Skip to main content

blog.publish

The "publish a blog post" pack. Two destinations, two body modes, two formats — picked at call time. Closes #68.

AxisOptions
Destinationghost (live publish via Ghost Admin API) · artifact (render to a helmdeck artifact, no external network)
Body modebody (the agent already wrote the post) · prompt + model (the pack expands the prompt into a body via the gateway LLM)
Formatmarkdown (rendered to HTML via goldmark when Ghost destination needs it) · html (pre-rendered, passes through)

The two body modes let an agent treat publishing as either a primitive (it composed the body upstream and just hands it off) or as a macro (it knows what it wants but lets the pack do the writing). The two destinations let the same agent publish a draft to a real Ghost blog OR generate a stand-alone artifact a downstream system can pick up.

Setup prerequisite​

For the ghost destination, add the Ghost Admin API key to the Vault panel:

FieldValue
Nameghost-admin-key (exact string — pack default; override with credential input)
Typeapi_key
Host patternYour Ghost installation's hostname (e.g. blog.example.com)
ValueThe full Admin API key in <id>:<secret> form (Ghost ships them this way; secret is hex-encoded)

Get the key from your Ghost admin: Settings → Advanced → Integrations → Add custom integration → Admin API Key. The key looks like 650f...:a1b2c3... — paste the whole thing, including the colon.

For the artifact destination, no vault credential is needed — the pack writes locally to the helmdeck artifact store.

Inputs​

FieldTypeRequiredDefaultNotes
destinationstringno"artifact""ghost" (publish via Ghost Admin API + save artifact safety net) or "artifact" (save only). Omitting the field defaults to "artifact" — the safety-net path that never loses the body.
also_save_artifactbooleannotrueWhen destination="ghost", controls whether the post body is also written as an artifact alongside the Ghost publish. Default true means a Ghost failure no longer loses the agent's work — see Partial success. Set false to opt out (today's pre-#203 hard-fail-on-Ghost-error behaviour).
formatstringyes—"markdown" or "html".
titlestringyes—Post title. Slugified for the artifact filename.
bodystringone-of—The post body. Either this or prompt+model.
promptstringone-of—Generation prompt for prompt mode. Either this or body.
modelstringwith prompt—Provider/model for prompt mode (e.g. openrouter/openai/gpt-4o-mini).
max_tokensnumberno1024Cap on the prompt-mode body length. Ignored in body mode.
tagsarrayno[]Tag names. For Ghost, converted to {name: ...} objects.
statusstringno"draft""draft" (default), "published", or "scheduled".
published_atstringwith status="scheduled"—RFC3339 timestamp in the future.
hoststringwith destination="ghost"—Ghost installation hostname. Accepts host, https://host, or http://host:port (the last for self-hosted Ghost on a non-HTTPS port).
credentialstringno"ghost-admin-key"Vault credential name. Override only if you store the key under a non-default name.
feature_image_artifact_keystringno—Operator-supplied feature image (v0.12.0 #146). Pass an artifact key from a prior pack call — typically image.generate, or a chained pack's cover_image_artifact_key. For Ghost, the pack uploads the bytes via /ghost/api/admin/images/upload/ then stamps the returned URL into the post's feature_image field. For artifact-mode, the cover lands as a sidecar <slug>-cover.png artifact alongside the post body. Mutually exclusive with hero_image:true.
hero_imagebooleannofalseAuto-generate the feature image via image.generate (v0.12.0 #146). Uses hero_image_prompt if set, falling back to the post title. Mutually exclusive with feature_image_artifact_key.
hero_image_promptstringno—Prompt for the auto-generated hero image when hero_image:true. Defaults to the post title if omitted.
hero_image_modelstringno"fal-ai/flux/schnell"fal.ai model used when hero_image:true. Browse choices via the helmdeck://image-models MCP resource.
mermaidbooleannotruePre-render ```mermaid fenced blocks to inline SVG (server-side via mmdc) so diagrams show reliably everywhere. Set false to leave fences for client-side rendering. See Mermaid diagrams.

Validation:

  • Exactly one of body or (prompt+model) — providing both or neither errors.
  • Providing both feature_image_artifact_key AND hero_image:true errors — pick one source for the cover.
  • status="scheduled" requires published_at in the future.
  • destination="ghost" requires host and a vault credential.
  • destination must be "ghost", "artifact", or omitted. Omitted defaults to "artifact".

Outputs​

Common fields (always present):

FieldTypeNotes
destinationstringEcho of the input destination (or "artifact" when omitted).
formatstringEcho.
body_sourcestring"input" (body mode) or "model" (prompt mode).
statusstringOutcome discriminator — "artifact_saved" (artifact-only path), "draft" / "published" / "scheduled" (Ghost confirmed), or "artifact_saved_ghost_failed" (Ghost attempted but failed; the body is in the artifact store — see Partial success).
model_usedstringOnly in prompt mode — the model that generated the body.

Artifact safety-net fields (present unless also_save_artifact:false was set):

FieldTypeNotes
artifact_keystringblog.publish/<slug>.{md|html}. Resolve via /api/v1/artifacts/<key>.
artifact_urlstringPresigned URL for direct fetch (S3 backend) or a memory:// URL (in-memory test backend).
sizenumberBytes.
feature_image_artifact_keystringSidecar cover artifact (blog.publish/<slug>-cover.png) when a feature image was supplied or auto-generated. In destination=ghost mode, Ghost's reference to the original input artifact key takes precedence — see "Feature-image fields" below.

Ghost-specific (present when destination="ghost" AND Ghost publish succeeded):

FieldTypeNotes
post_idstringGhost post id.
urlstringPublic URL.
html_urlstringSame as url, for parity with github.* packs.
published_atstringGhost-assigned RFC3339.
feature_image_urlstringGhost-hosted CDN URL of the uploaded cover (present when feature_image_artifact_key or hero_image:true was set).

Partial-success-specific (present when Ghost failed but the artifact was saved):

FieldTypeNotes
ghost_errorstringThe error message from the failed Ghost step (vault credential missing, JWT key format, API non-2xx, egress denied, etc.). The agent retries by re-invoking blog.publish with the body fetched from artifact_url.

Feature-image fields (both destinations):

FieldTypeNotes
hero_image_model_usedstringOnly when hero_image:true. Echoes the model that actually generated the cover.

How to read the response​

The status field is the canonical discriminator. Always check status rather than assuming HTTP 200 means full success. The pattern:

status valueMeaningAgent next step
artifact_savedNo Ghost publish was requested (or destination=artifact/omitted). The body is in the artifact store.Fetch via artifact_url if needed; otherwise nothing more to do.
draft / published / scheduledGhost confirmed the publish at the named state. The artifact is also saved (unless also_save_artifact:false).Done — the post is live.
artifact_saved_ghost_failedGhost was requested but failed. The body is in the artifact store.Inspect ghost_error, fix the underlying issue (credentials, host, network), and re-invoke blog.publish with the body fetched from artifact_url.

Vault credentials needed​

ghost-admin-key for ghost destination only. Optional for artifact destination.

Use it from your agent (OpenClaw chat-UI worked example)​

OpenClaw chat capture pending.

Developer reference (curl)​

Artifact mode (no Ghost required)​

ADMIN_PW=$(grep HELMDECK_ADMIN_PASSWORD /root/helmdeck/deploy/compose/.env.local | cut -d= -f2)
JWT=$(curl -fsS -X POST http://localhost:3000/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d "{\"username\":\"admin\",\"password\":\"${ADMIN_PW}\"}" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')

curl -fsS -X POST http://localhost:3000/api/v1/packs/blog.publish \
-H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
-d '{
"destination": "artifact",
"format": "markdown",
"title": "Demo PR-D2 post",
"body": "# Hello\n\nThis is a test."
}'

Response:

{
"pack": "blog.publish",
"version": "v1",
"output": {
"destination": "artifact",
"format": "markdown",
"body_source": "input",
"status": "artifact_saved",
"artifact_key": "blog.publish/demo-pr-d2-post.md",
"artifact_url": "https://s3.example/blog.publish/...?X-Amz-Signature=...",
"size": 101
}
}

Tip: Omitting destination entirely is equivalent to destination=artifact. The artifact-only path is the safety-net default — no agent ever loses a body to a misconfigured Ghost setup.

Ghost mode (live API)​

curl -fsS -X POST http://localhost:3000/api/v1/packs/blog.publish \
-H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
-d '{
"destination": "ghost",
"format": "markdown",
"title": "Hello from helmdeck",
"body": "# Welcome\n\nThis post was filed via blog.publish.",
"host": "blog.example.com",
"tags": ["demo","helmdeck"],
"status": "draft"
}'

Response (Ghost succeeded — note the merged shape with both post_id and artifact_key from the #203 safety net):

{
"pack": "blog.publish",
"version": "v1",
"output": {
"destination": "ghost",
"format": "markdown",
"body_source": "input",
"post_id": "650f1234567890",
"url": "https://blog.example.com/p/hello-from-helmdeck/",
"html_url": "https://blog.example.com/p/hello-from-helmdeck/",
"status": "draft",
"published_at": null,
"artifact_key": "blog.publish/hello-from-helmdeck.md",
"artifact_url": "https://s3.example/blog.publish/...?X-Amz-Signature=...",
"size": 57
}
}

To skip the artifact safety net (today's pre-#203 ghost-only behaviour), add "also_save_artifact": false to the request body. The response then omits artifact_key/artifact_url/size and Ghost failures revert to hard errors.

Prompt mode + Ghost​

curl -fsS -X POST http://localhost:3000/api/v1/packs/blog.publish \
-H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
-d '{
"destination": "ghost",
"format": "markdown",
"title": "Why packs beat naive function-calling",
"prompt": "Write a 400-word post arguing that typed packs (helmdeck) yield 10x lower per-task LLM cost than naive function-calling on Sonnet. Use a concrete example.",
"model": "openrouter/openai/gpt-4o-mini",
"max_tokens": 600,
"host": "blog.example.com",
"status": "draft",
"tags": ["agent-architecture","cost"]
}'

The pack calls the gateway LLM with a frozen system prompt that instructs it to emit ONLY the post body in the requested format (no preamble, no surrounding code fences, no repeated title).

Partial success​

When destination="ghost" AND also_save_artifact:true (the default) AND Ghost fails after the artifact write, the pack returns a successful HTTP response carrying a partial-success shape. The body is safe in the artifact store; only the side-effect (Ghost publish) failed. The agent retries the Ghost step against the artifact without paying for prompt expansion again.

Example response when Ghost returns 401 (bad credentials):

{
"pack": "blog.publish",
"version": "v1",
"output": {
"destination": "ghost",
"format": "markdown",
"body_source": "model",
"model_used": "openai/gpt-4o-mini",
"status": "artifact_saved_ghost_failed",
"ghost_error": "ghost API POST https://blog.example/ghost/api/admin/posts/?source=html: 401 Authorization failed",
"artifact_key": "blog.publish/why-packs-beat-naive-function-calling.md",
"artifact_url": "https://s3.example/blog.publish/...?X-Amz-Signature=...",
"size": 2174
}
}

Failure modes that go through partial success (when the safety net is on):

  • Vault credential missing or wrong shape (<id>:<secret> parse failure).
  • Ghost API non-2xx response (auth, validation, rate-limit, 5xx).
  • Egress guard denied the Ghost host.
  • Markdown→HTML conversion failure (rare — affects malformed input).

Failure modes that still hard-error (artifact write itself broke):

  • S3/MinIO artifact upload failed — there's nothing to fall back to. The pack returns artifact_failed.

Opting out of the safety net: Pass "also_save_artifact": false in the request. Today's pre-#203 contract is preserved verbatim — Ghost-only behaviour, and Ghost failures surface as hard errors with no artifact saved.

Error codes​

These are hard errors — the handler returns a PackError and no response shape. Many Ghost-side failures that previously hard-errored now surface in Partial success responses instead; only the cases where the safety net itself is bypassed or broken remain as hard errors below.

CodeTriggersCaptured response
invalid_inputdestination outside "ghost"/"artifact"/emptydestination must be "ghost", "artifact", or empty (defaults to artifact)
invalid_inputformat outside "markdown"/"html"format must be "markdown" or "html"
invalid_inputtitle emptytitle is required
invalid_inputBoth body AND prompt suppliedmust provide either body OR prompt+model, not both
invalid_inputNeither body nor prompt suppliedmust provide either body OR prompt+model
invalid_inputprompt set but model missingprompt mode requires model (provider/model)
invalid_inputstatus outside the closed setstatus must be "draft", "published", or "scheduled"
invalid_inputstatus="scheduled" without published_atpublished_at (RFC3339) is required when status=scheduled
invalid_inputpublished_at not in the futurepublished_at must be in the future for status=scheduled
invalid_inputdestination="ghost" without hosthost is required when destination=ghost
internalPrompt mode but pack registered without a gateway dispatcherblog.publish prompt mode registered without a gateway dispatcher
handler_failedPrompt expansion model returned no choicesblog.publish prompt expansion: model returned no choices
artifact_failedArtifact store write failed (the safety net itself broke)artifact upload failed: …

Hard errors only when also_save_artifact:false (caller opted out of the safety net, so Ghost failures bubble up as in the pre-#203 contract):

CodeTriggersCaptured response
invalid_inputghost-admin-key not in vaultvault credential "ghost-admin-key" not found …
invalid_inputVault key not in <id>:<secret> formghost-admin-key vault value must be \:` …`
invalid_inputGhost host resolves to a blocked rangeegress denied: …
handler_failedGhost API non-2xxghost API POST …: 401 Authorization failed
handler_failedMarkdown→HTML conversion failedmarkdown→html for Ghost: …

With also_save_artifact:true (the default), all five of those failures appear in the response's ghost_error field under status="artifact_saved_ghost_failed" — see Partial success.

Mermaid diagrams in technical posts​

By default (mermaid: true), blog.publish pre-renders ```mermaid fenced blocks in a markdown body to inline SVG server-side — it shells to mmdc in the sidecar (the same renderer slides.render uses) and replaces each fence with an <img src="data:image/svg+xml;base64,…" class="mermaid-svg" />. The diagram is then baked into the post, so it renders identically on Ghost (regardless of theme), in email, in RSS, and in any plain-markdown reader — no client-side MermaidJS required.

mermaidBehaviour
true (default)Fences → inline-SVG <img> before publishing. Works for every destination/format. Needs a session (the pack runs with one — see below).
falseFences are left as ```mermaid and rendered client-side: in html/Ghost output they become <pre class="mermaid"> + an injected MermaidJS <script> (via goldmark-mermaid); in markdown output the fence passes through verbatim (for renderers that know mermaid — Docusaurus, GitHub, MkDocs). Use this if you'd rather ship the source fence than a baked SVG, or your reader already loads MermaidJS.

Prompt-mode nudging. In prompt mode the pack's system prompt instructs the model to emit ```mermaid fences when content is genuinely visual (architecture, request flow, sequence interactions, state transitions, decision trees) and to prefer prose otherwise. So "write a post about X with a diagram of its architecture" produces a diagram with no extra flag — and with the default mermaid: true it ships as SVG.

Supported diagram kinds. Anything mermaid supports: flowchart/graph, sequenceDiagram, stateDiagram-v2, classDiagram, erDiagram, gantt, pie, mindmap, etc. Invalid mermaid syntax fails the publish with handler_failed (the mmdc error), rather than silently shipping a broken diagram.

Session chaining​

Runs with a session (NeedsSession: true) so it can reach mmdc for server-side mermaid rendering — i.e. each publish acquires a short-lived sidecar even for diagram-free posts (set mermaid: false to skip the render work). Composes naturally:

  • research.deep → content.ground → blog.publish — the canonical "evidence-grounded blog post" chain. Research surfaces sources; content.ground appends citations into a draft body; blog.publish ships it.
  • web.scrape → blog.publish (artifact mode) — re-publish a scraped page as a draft artifact for later editing.
  • repo.fetch + fs.read + blog.publish (prompt mode) — generate a release-notes blog post from a repo's recent changelog without the agent ever materializing the body itself.

Async behavior​

Synchronous. Wall-clock = (prompt-mode LLM call, ~3–10s if used) + (markdown→html via goldmark, ~1ms) + (Ghost API round-trip, ~200–800ms) for ghost mode; or just the goldmark step + artifact upload for artifact mode (~10–50ms).

See also​