ProjectPublic Agent API
Submit step-by-step project guides to ProjectPublic programmatically via three API
endpoints: POST /api/projects/import (create), PUT /api/projects/{id}/import
(update), and DELETE /api/projects/{id} (delete).
Last updated: 2026-03-05. Verified against source code.
Quick Start
Here is a complete, copy-pasteable example. Replace YOUR_API_KEY with your token.
Request
curl -X POST https://projectpublic.online/api/projects/import \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
--data-binary '{
"markup": "---\ntitle: Install Immich with Docker Compose\nsummary: Self-host Google Photos on your own Linux server in 30 minutes\ntags: docker, self-hosted, linux\ndifficulty: beginner\ntime: 30\nprerequisites: A Linux server with Docker and Docker Compose installed\n---\n\n[step: Create the project directory]\n\nCreate a dedicated directory for Immich:\n\n[terminal]\n$ mkdir ~/immich && cd ~/immich\n[/terminal]\n\n[callout:tip]\nUsing a dedicated directory keeps all Immich files organized in one place.\n[/callout]\n\n[step: Write the Docker Compose file]\n\nCreate the compose file:\n\n[code:yaml | docker-compose.yml]\nversion: \"3.8\"\nservices:\n immich-server:\n image: ghcr.io/immich-app/immich-server:release\n ports:\n - 2283:3001\n volumes:\n - ./upload:/usr/src/app/upload\n restart: always\n[/code]\n\n[step: Start the services]\n\n[terminal]\n$ docker compose up -d\nCreating network immich_default ...\nCreating immich-server ... done\n[/terminal]\n\nVerify Immich is running at http://your-server-ip:2283\n\n[checkpoint:]\n- Docker is running: `docker ps` shows no errors\n- Port 2283 is accessible from your browser\n[/checkpoint]"
}'
Critical format notes:
- The request body MUST be JSON (
Content-Type: application/json) - The JSON body MUST have a
markupfield whose value is the markup text as a string - Newlines in the markup string MUST be escaped as
\ninside the JSON value
Response (201 Created)
{
"data": {
"project": {
"id": "a3f8c2d1-e4b5-4f6a-8c7d-9e0f1a2b3c4d",
"title": "Install Immich with Docker Compose",
"slug": "install-immich-with-docker-compose-f8a3b2",
"status": "draft"
},
"submitted": false
}
}
The project.id UUID is required for subsequent PUT update calls.
Permanent Installation as a Slash Command
Claude Code supports persistent slash commands stored as .md files in
~/.claude/commands/. Install once and use /publish-guide from any project.
Step 1 — Save your API key
echo 'pp_YOUR_KEY' > ~/.claude/projectpublic.key && chmod 600 ~/.claude/projectpublic.key
Replace pp_YOUR_KEY with your actual API key from Settings > API Keys.
Step 2 — Install the command
mkdir -p ~/.claude/commands && curl -fsSL https://projectpublic.online/publish-guide.md > ~/.claude/commands/publish-guide.md
Step 3 — Per-project setup
Each project needs a .projectpublic file to track its ProjectPublic ID:
- First publish (no
.projectpublicfile): The command creates the project viaPOST /api/projects/importand saves the returned UUID to.projectpublic. - Subsequent publishes: The command reads the UUID and updates via
PUT /api/projects/{id}/import.
The .projectpublic file format is simply:
project_id=a3f8c2d1-e4b5-4f6a-8c7d-9e0f1a2b3c4d
Commit this file to your repo so collaborators can update the same project.
Step 4 — Publish
From any project directory containing a guide.md:
/publish-guide
The command reads guide.md, builds the JSON payload, and calls the API.
Authentication
Obtaining an API key
API keys are created through the web UI at Settings > API Keys, or via the API:
POST /api/users/me/api-keys
Content-Type: application/json
Cookie: <session cookie> (session auth required — cannot use API key to create keys)
{ "name": "My automation" }
Response (201):
{
"data": {
"id": "uuid",
"name": "My automation",
"key_prefix": "pp_a1b2c3d4e",
"key": "pp_a1b2c3d4ef567890123456789012345678901234567890123456789012345678",
"created_at": "2026-02-24T12:00:00.000Z"
}
}
CRITICAL: The key field is returned ONCE only. Store it immediately — it
cannot be retrieved again.
Key format
- Always starts with
pp_ - Total length: 67 characters (
pp_+ 64 lowercase hexadecimal characters) - Example:
pp_a1b2c3d4ef567890123456789012345678901234567890123456789012345678
Using the API key
Every import request MUST include the Authorization header:
Authorization: Bearer pp_a1b2c3d4ef567890123456789012345678901234567890123456789012345678
The word Bearer MUST be present. The token MUST start with pp_. Any other
format returns 401.
Create Endpoint
POST /api/projects/import
Creates a new project from ProjectPublic markup text.
Method: POST
URL: https://projectpublic.online/api/projects/import
Authentication: Bearer API key (or session cookie)
Request headers:
Authorization: Bearer pp_...
Content-Type: application/json
Request body (JSON):
{
"markup": "<ProjectPublic markup text as a string with \\n newlines>",
"submit": false
}
| Field | Type | Required | Description |
|---|---|---|---|
| markup | string | Yes | The full markup text. Must be non-empty. |
| submit | boolean | No | When true, transitions the project to pending_review immediately. Default: false (stays as draft). |
Success response: 201 Created
{
"data": {
"project": { "id": "uuid", "slug": "...", "status": "draft", ... },
"submitted": false
}
}
Update Endpoint
PUT /api/projects/{id}/import
Replaces an existing project's content with new markup. You MUST be the project
author. The {id} is the UUID from the create response (project.id).
Method: PUT
URL: https://projectpublic.online/api/projects/{id}/import
Authentication: Bearer API key (or session cookie)
Request headers:
Authorization: Bearer pp_...
Content-Type: application/json
Request body: Same as the create endpoint.
{
"markup": "<updated markup text>",
"submit": false
}
Success response: 200 OK — same shape as the create response.
What the update replaces:
- All existing steps and their elements are deleted and re-inserted from the new markup
- All tag associations are replaced with tags from the new frontmatter
- Project metadata (title, summary, difficulty, etc.) is updated from the new frontmatter
- The
markup_sourcefield is updated alongside the parsed elements - The project's URL slug is NOT changed — it is fixed at creation time
Status rules for update:
| Current status | submit: true effect | submit: false effect |
|---|---|---|
| draft | Transitions to pending_review | Stays as draft |
| pending_review | No effect | Stays pending_review |
| published | No effect | Stays published |
| unlisted | No effect | Stays unlisted |
| archived | 409 error — cannot edit | 409 error — cannot edit |
Delete Endpoint
DELETE /api/projects/{id}
Soft-deletes a project (sets status to archived). You MUST be the project author.
Method: DELETE
URL: https://projectpublic.online/api/projects/{id}
Authentication: Bearer API key (or session cookie)
No request body required.
Success response: 200 OK
{
"data": {
"id": "a3f8c2d1-e4b5-4f6a-8c7d-9e0f1a2b3c4d"
}
}
Archived projects cannot be edited or submitted. This action is not reversible via the API.
Markup Format
The markup format is ProjectPublic's custom bracket syntax. It is NOT Markdown,
NOT YAML, NOT HTML. While text content supports inline Markdown formatting
(bold, italic, links), the structural elements use [bracket] tags.
Document structure
Every document has this shape:
---
<frontmatter key: value pairs>
---
[step: First Step Title]
<elements for step 1>
[step: Second Step Title]
<elements for step 2>
Rules:
- The
---frontmatter block MUST appear at the very start of the document - The frontmatter MUST open and close with
---on their own lines - There MUST be at least one
[step: ...]section - Steps MUST have a title —
[step:]alone is a parse error
Frontmatter
The frontmatter uses simple key: value pairs — one per line. This is NOT YAML.
Do NOT use YAML list syntax, nested objects, or multi-line values.
---
title: Install Immich with Docker Compose
summary: Self-host Google Photos on your own Linux server in 30 minutes
tags: docker, self-hosted, linux
difficulty: beginner
time: 30
prerequisites: A Linux server with Docker and Docker Compose installed
video: https://www.youtube.com/watch?v=example
---
All frontmatter fields:
| Key | Required | Type | Constraints |
|---|---|---|---|
| title | Yes | string | Max 120 characters |
| summary | Yes | string | Max 280 characters |
| tags | Yes | string | Comma-separated; at least 1 required |
| difficulty | Yes | enum | See valid values below |
| time | No | integer | Positive integer in MINUTES (not "30 minutes") |
| prerequisites | No | string | Free text, one line |
| video | No | string | YouTube or Vimeo URL, max 500 chars |
difficulty — valid values (no others are accepted):
beginnerintermediateadvanced
Any other value (e.g. easy, hard, expert) causes a PARSE_ERROR.
tags — format:
Tags MUST be comma-separated on a single line:
tags: docker, self-hosted, linux
NEVER use YAML list syntax:
# WRONG — causes incorrect parsing
tags:
- docker
- self-hosted
Unknown tags are automatically created. Use existing tags when possible (see Available Tags section).
Steps
Steps divide the project into numbered sections.
[step: Create the Docker Compose file]
Steps can optionally include a video timestamp to sync with a YouTube video set in frontmatter. When a reader clicks the step, the embedded video seeks to that time.
[step: Create the Docker Compose file | 1:30]
The timestamp format is M:SS (e.g. 1:30 for 1 minute 30 seconds, 12:00 for
12 minutes). For larger minute values, use MM:SS (e.g. 90:00).
Collapsible steps:
Steps can be made collapsible (accordion-style) using one of two keywords. Use this to let readers hide steps they have already completed or do not need.
| Keyword | Behaviour |
|---|---|
collapsible | Renders with a toggle header; starts open |
collapsed | Renders with a toggle header; starts collapsed |
[step: Optional: Configure advanced settings | collapsible]
This step is collapsible — starts open. Readers can collapse it after reading.
[step: Appendix: Full configuration reference | collapsed]
This step starts collapsed. Readers click the header to expand it.
[step: Start the services | 2:15 | collapsible]
Combining a video timestamp with collapsibility — timestamp is always listed first.
The reader's open/closed preference persists across page reloads (stored in localStorage,
keyed by step number). Use collapsible for steps readers may want to re-visit;
use collapsed for long reference steps that most readers will skip.
Rules:
- The colon after
stepis REQUIRED - A space after the colon is REQUIRED
- The title text is REQUIRED —
[step:]alone is a parse error - Steps are auto-numbered 1, 2, 3, ... in the order they appear
- The
[step: ...]tag MUST be on its own line - The
| timestamppart is optional — omit it if there is no video or no relevant time - When combining timestamp and collapsibility, the timestamp MUST come first:
| 1:30 | collapsible - Only
collapsibleandcollapsedare recognized as keywords; other pipe modifiers are ignored
Element types
Elements appear within step bodies. Text between block tags becomes a text element.
Block elements (require closing tag): code, terminal, callout, checkpoint, tabs/tab
Self-closing elements (no closing tag): image, video, url
Text
Plain text between block tags becomes a text element automatically. There is no
explicit [text:] tag.
Inline Markdown is supported within text elements:
This is a paragraph. You can use **bold**, *italic*, `inline code`, and [links](https://example.com).
A blank line creates a new paragraph within the same text element.
Code block
Displays code with syntax highlighting.
Syntax:
[code:LANGUAGE | FILENAME]
code content here
[/code]
LANGUAGEis optional — if omitted, write[code](no colon)FILENAMEis optional — if included, separate from language with|(space-pipe-space)- The closing
[/code]tag is REQUIRED
Examples:
[code:bash | install.sh]
sudo apt update && sudo apt install -y docker.io
[/code]
[code:yaml | docker-compose.yml]
version: "3.8"
services:
app:
image: myapp:latest
[/code]
[code:python]
print("Hello, World!")
[/code]
[code]
some content without language
[/code]
Collapsible code blocks:
Any code block can be made collapsible (accordion-style) by adding collapsible or
collapsed as an additional pipe-separated modifier:
| Keyword | Behaviour |
|---|---|
collapsible | Renders with a toggle header; starts open |
collapsed | Renders with a toggle header; starts collapsed |
[code:bash | install.sh | collapsible]
sudo apt update && sudo apt install -y docker.io
[/code]
[code:bash | collapsed]
sudo apt update && sudo apt install -y docker.io
[/code]
When a filename is present, it is used as the header label. When only a language is given, the language name is used. The reader's preference persists across page reloads.
Do NOT:
- Use triple backticks (
```) — this is NOT Markdown - Omit the closing
[/code]tag (causesPARSE_ERROR) - Nest block elements inside code blocks
Terminal
Displays a shell session (dark background, monospace font). Commands and output are stored together as a single pre-formatted block.
Syntax:
[terminal]
content here
[/terminal]
The closing [/terminal] tag is REQUIRED.
Example:
[terminal]
$ docker compose up -d
Creating network immich_default ...
Creating immich-server ... done
[/terminal]
Collapsible terminal blocks:
Terminal blocks can be made collapsible (accordion-style) using one of two keywords:
| Keyword | Behaviour |
|---|---|
collapsible | Renders with a toggle header; starts open |
collapsed | Renders with a toggle header; starts collapsed |
An optional title in double quotes can follow the keyword. When no title is provided, "Terminal" is used as the header label.
[terminal | collapsible]
$ docker compose up -d
Creating network immich_default ...
[/terminal]
[terminal | collapsed "Build Output"]
Compiling project...
Build succeeded in 4.2s
[/terminal]
The reader's open/closed preference persists across page reloads.
Do NOT:
- Omit the closing
[/terminal]tag (causesPARSE_ERROR)
Callout
A highlighted box for tips, warnings, notes, or danger notices.
Syntax:
[callout:VARIANT]
Callout content here. Inline **Markdown** is supported.
[/callout]
The closing [/callout] tag is REQUIRED.
Collapsible callouts:
Any callout can be made collapsible (accordion-style) using one of two keywords:
| Keyword | Behaviour |
|---|---|
collapsible | Renders with a toggle header; starts open |
collapsed | Renders with a toggle header; starts collapsed |
An optional title in double quotes can follow the keyword. When no title is provided, the variant name (e.g. "Info", "Warning") is used as the header label.
[callout:warning | collapsible]
Starts open. No custom title — header shows "Warning".
[/callout]
[callout:info | collapsible "Before you begin"]
Starts open with a custom header title.
[/callout]
[callout:danger | collapsed "Advanced: firewall rules"]
Starts collapsed. Click the header to expand.
[/callout]
The reader's open/closed preference persists across page reloads (stored in
localStorage). Use collapsible callouts for lengthy notes or troubleshooting
details that readers may want to hide after reading. Use collapsed (starts
closed) for long optional content like advanced configuration details.
Valid variant values — ALL valid values, no others are accepted:
| Variant | Use case |
|---|---|
info | Informational note or extra context |
warning | Caution — something that might cause problems |
danger | Critical warning — data loss or security risk |
tip | Helpful tip or best practice |
Examples:
[callout:info]
Immich requires at least 2GB of RAM to run smoothly.
[/callout]
[callout:warning]
Changing the upload directory after setup will break existing photo links.
[/callout]
[callout:danger | collapsible]
Do NOT expose the Immich admin interface to the public internet.
Here is a detailed explanation of why this is dangerous and how to
configure your firewall properly...
[/callout]
[callout:tip | collapsed "Performance tip"]
You can enable GPU transcoding in Immich settings to speed up thumbnail
generation. This requires a compatible GPU and additional Docker configuration.
[/callout]
[callout:tip]
Use `docker compose` (v2) rather than the older `docker-compose` command.
[/callout]
Do NOT:
- Use
[callout:note],[callout:success],[callout:error], or[callout:caution](causesPARSE_ERROR— only the four variants above are valid) - Omit the closing
[/callout]tag - Use
| collapsibleand| collapsedtogether — use one or the other - Put the title before the keyword:
"Title" collapsibleis wrong; it must becollapsible "Title"
Image
Displays an image with an optional caption.
Syntax:
[image: URL | Caption text]
or without caption:
[image: URL]
This is a self-closing element — there is NO closing [/image] tag.
| Part | Required | Description |
|---|---|---|
| URL | Yes | Fully qualified URL to the image |
| Caption | No | Optional caption (also used as alt text) |
Examples:
[image: https://example.com/screenshot.png | The Immich dashboard after first login]
[image: https://example.com/diagram.png]
Do NOT:
- Use Markdown image syntax
— it will render as plain text - Add a closing tag (
[/image]does not exist)
Video
Embeds a YouTube or Vimeo video.
Syntax:
[video: URL]
This is a self-closing element — there is NO closing [/video] tag.
Example:
[video: https://www.youtube.com/watch?v=dQw4w9WgXcQ]
URL link preview
Renders a link preview card (title, description, thumbnail fetched from OG metadata).
Syntax:
[url: URL]
This is a self-closing element — there is NO closing [/url] tag.
Example:
[url: https://immich.app]
If OG metadata fetch fails, the URL is displayed as a plain link.
Checkpoint
A list of verification items shown at the bottom of a step. Readers can check items off as they complete them.
Syntax:
[checkpoint:]
- First thing to verify
- Second thing to verify
- Third thing to verify
[/checkpoint]
- The closing
[/checkpoint]tag is REQUIRED - Each item MUST begin with
-(hyphen-space) - The opening tag MUST be exactly
[checkpoint:]— nothing between:and]
Example:
[checkpoint:]
- Docker is running: `docker ps` shows no errors
- The docker-compose.yml file exists in the current directory
- Port 2283 is accessible from your browser
[/checkpoint]
Do NOT:
- Write
[checkpoint: Verify these items]with text after the colon — the parser matches[checkpoint:]literally; any text between:and]breaks the match - Use any list syntax other than
-(hyphen-space) - Omit the closing
[/checkpoint]tag
Tab Group
Tabbed content blocks (like DigitalOcean-style OS/language selectors). Useful when a step has different instructions for different platforms, languages, or tools.
Syntax:
[tabs:]
[tab: Ubuntu/Debian]
[terminal]
$ sudo apt install docker.io
[/terminal]
[/tab]
[tab: Fedora/RHEL]
[terminal]
$ sudo dnf install docker
[/terminal]
[/tab]
[tab: macOS]
Install Docker Desktop from https://docker.com/products/docker-desktop
[/tab]
[/tabs]
Rules:
- The outer
[tabs:]and[/tabs]tags are REQUIRED - Each tab is wrapped in
[tab: Label]...[/tab] - Tab labels are REQUIRED —
[tab:]alone is a parse error - Tabs can contain: text,
[code:...],[terminal], and[callout:...] - Tabs CANNOT contain: images, videos, URLs, checkpoints, or nested tab-groups
- A
[tabs:]block MUST contain at least two[tab:]sections - When a reader selects a tab (e.g. "Ubuntu/Debian"), all tab groups on the page with a matching label switch to that tab automatically
Tab colors:
Tabs are automatically assigned colors round-robin from a built-in palette — no markup needed.
To set an explicit color for a specific tab, add | color:NAME to the tab label:
[tabs:]
[tab: Ubuntu/Debian | color:emerald]
[terminal]
$ sudo apt install docker.io
[/terminal]
[/tab]
[tab: Fedora/RHEL | color:blue]
[terminal]
$ sudo dnf install docker
[/terminal]
[/tab]
[tab: macOS | color:amber]
Install Docker Desktop from https://docker.com/products/docker-desktop
[/tab]
[/tabs]
Available color names: blue, emerald, amber, rose, violet, cyan, orange, pink
When some tabs have explicit colors and others don't, the auto-assigned tabs get colors from the remaining palette slots in round-robin order. All colors use subtle semi-transparent backgrounds that work well in dark mode.
Do NOT:
- Nest
[tabs:]inside[tabs:]— not supported - Put images, videos, or checkpoints inside a tab — they will not render
- Omit the
[/tabs]or[/tab]closing tags - Use only one
[tab:]inside a[tabs:]block — at least two are required
Available Tags
Use existing tags when possible. Unknown tags are auto-created but may result in duplicate or fragmented taxonomy entries.
Platforms & Hardware
raspberry-pi, unraid, proxmox, truenas, synology, openwrt, nas-build,
mini-pc, gpu-passthrough, 3d-printing
Containerization & Orchestration
docker, docker-compose, kubernetes, portainer, podman
Home Automation
home-assistant, zigbee, z-wave, mqtt, esphome, node-red
Media
plex, jellyfin, sonarr, radarr, arr-stack
Networking
networking, dns, vpn, wireguard, reverse-proxy, nginx, traefik, firewall
AI & Machine Learning
local-llm, ollama, stable-diffusion, machine-learning
Self-Hosted Applications
self-hosted, nextcloud, immich, paperless
Operating Systems
linux, windows, macos
General
beginner, security, automation, scripting, backup, monitoring
Other commonly used tags (may have been created by users):
python, bash, javascript, typescript, go, rust, git, postgresql,
mysql, redis, sqlite, mongodb, aws, cloudflare, s3, storage, ssl,
certificates, ssh, authentication, caddy, terraform, ansible, grafana,
prometheus, vaultwarden, pihole, tailscale, ubuntu, debian, homelab
Tag names are lowercase and hyphen-separated. Use exact slug strings in frontmatter.
Error Reference
All errors return JSON:
{ "error": "Human-readable message", "code": "MACHINE_READABLE_CODE" }
Authentication errors (401)
| Code | Condition | Fix |
|---|---|---|
UNAUTHORIZED | Authorization header missing, not Bearer pp_... format, or token invalid | Add Authorization: Bearer pp_... header with a valid token |
Validation errors (400)
| Code | Condition | Fix |
|---|---|---|
INVALID_BODY | Request body is not valid JSON | Ensure body is {"markup": "..."} with properly escaped newlines (\n) |
VALIDATION_ERROR | markup field is missing or empty string | Include markup as a non-empty string in the JSON body |
Parse errors (400)
All markup parse errors return "code": "PARSE_ERROR". The error message
includes a description and, when available, a line number.
| Error message pattern | Cause | Fix |
|---|---|---|
Missing frontmatter block | No --- block found at start | Start document with --- frontmatter block |
Frontmatter is missing required field: title | title absent | Add title: Your Title to frontmatter |
Frontmatter is missing required field: summary | summary absent | Add summary: Your summary to frontmatter |
Frontmatter must include at least one tag | tags empty or missing | Add tags: docker (or any tag) |
Frontmatter difficulty must be one of: beginner, intermediate, advanced | Invalid or missing difficulty | Use exactly beginner, intermediate, or advanced |
Document must contain at least one [step: Title] block | No steps | Add [step: My Step Title] |
Unclosed [code] block | [code...] without [/code] | Add [/code] after code content |
Unclosed [terminal] block | [terminal] without [/terminal] | Add [/terminal] after terminal content |
Unclosed [callout] block | [callout:...] without [/callout] | Add [/callout] after callout content |
Unclosed [checkpoint:] block | [checkpoint:] without [/checkpoint] | Add [/checkpoint] after checkpoint items |
Unclosed [tabs:] block | [tabs:] without [/tabs] | Add [/tabs] after all tab blocks |
Unclosed [tab] block | [tab: ...] without [/tab] | Add [/tab] after tab content |
Invalid callout type "..." | Callout variant not in allowed set | Use one of: info, warning, danger, tip |
Image tag is missing a src value | [image:] with no URL | Add URL: [image: https://example.com/img.png] |
Video tag is missing a URL | [video:] with no URL | Add URL: [video: https://youtube.com/...] |
URL tag is missing an href value | [url:] with no URL | Add URL: [url: https://example.com] |
[tabs:] block must contain at least two [tab:] sections | Only 0 or 1 [tab:] inside [tabs:] | Add at least two [tab: Label]...[/tab] blocks inside the [tabs:] |
Authorization errors (403)
| Code | Condition | Fix |
|---|---|---|
FORBIDDEN | Authenticated user is not the project author | Use the API key belonging to the project author |
Not found errors (404)
| Code | Condition |
|---|---|
NOT_FOUND | No project exists with the given {id} in the URL |
Conflict errors (409)
| Code | Condition |
|---|---|
INVALID_STATUS | Project is archived — archived projects cannot be edited |
Rate limit errors (429)
| Code | Condition | Fix |
|---|---|---|
RATE_LIMITED | Too many requests in the time window | Wait and retry. The import endpoints (POST /api/projects/import and PUT /api/projects/{id}/import) do not currently have per-endpoint rate limits, but the server may enforce global limits in the future. |
Server errors (500)
| Code | Condition |
|---|---|
INTERNAL_ERROR | Database error, tag resolution failure, or other server error |
SLUG_GENERATION_FAILED | Could not generate a unique slug after 5 attempts |
Common Mistakes
1. Sending the markup as plain text instead of JSON
The endpoint expects Content-Type: application/json with the markup as a string
value in a JSON object.
Wrong — body is the raw markup text:
---
title: My Guide
...
This returns INVALID_BODY (the server tries to parse JSON and fails).
Correct — body is JSON with a markup field:
{ "markup": "---\ntitle: My Guide\n..." }
Newlines in the markup string MUST be escaped as \n when embedded in JSON.
2. Using Markdown code fences instead of [code:...]
Wrong:
```yaml
services:
app: ...
```
Correct:
[code:yaml | docker-compose.yml]
services:
app: ...
[/code]
3. Using YAML list syntax for tags
Wrong:
tags:
- docker
- linux
Correct:
tags: docker, linux
4. Using an invalid difficulty value
Wrong: difficulty: easy, difficulty: hard, difficulty: expert
Correct — only these three values are valid:
difficulty: beginnerdifficulty: intermediatedifficulty: advanced
5. Using invalid callout variants
Wrong: [callout:note], [callout:success], [callout:error], [callout:caution]
Correct — only these four variants are valid:
[callout:info], [callout:warning], [callout:danger], [callout:tip]
6. Forgetting closing tags on block elements
These elements REQUIRE a closing tag:
[code:...]needs[/code][terminal]needs[/terminal][callout:...]needs[/callout][checkpoint:]needs[/checkpoint][tabs:]needs[/tabs][tab: ...]needs[/tab]
These elements have NO closing tag:
[image: ...]— self-closing[video: ...]— self-closing[url: ...]— self-closing
7. Missing or empty step title
Wrong: [step:] or [step: ] — both cause a parse error
Correct: [step: Install Docker]
8. Putting content before the frontmatter block
Wrong:
Here is my guide.
---
title: My Guide
Correct:
---
title: My Guide
...
---
Here is my guide.
9. Writing time with units
Wrong: time: 30 minutes, time: 2 hours
Correct: time: 30 — the value is always a positive integer in minutes
10. Adding text after [checkpoint:
Wrong: [checkpoint: Verify these items]
Correct: [checkpoint:] — the tag is matched literally with nothing between : and ]
11. Nesting block elements
Block elements cannot be nested inside other block elements — with one exception: tab groups can contain text, code, terminal, and callout elements inside each tab.
Wrong:
[callout:info]
[code:bash]
sudo apt update
[/code]
[/callout]
Correct: Place the code block outside the callout, before or after it.
Exception — nesting inside tabs IS allowed:
[tabs:]
[tab: Ubuntu]
[code:bash]
sudo apt install docker.io
[/code]
[/tab]
[/tabs]