Format for Publish Skill (Strapi)
Transform the cited draft into a publish-ready package for Strapi. The skill produces three files: a clean markdown body (for pasting into Strapi rich-text fields), a Strapi-shaped JSON payload (for API publish), and a README with step-by-step publish instructions.
Input
For slug {slug}:
content-pipeline/6-drafts-cited/{slug}.md(the cited draft)references/strapi-format.md(application rules)../../templates/strapi-format.md(canonical Strapi reference)
Process
- Read the cited draft. Make a working copy in memory; don't edit the source.
- Strip the editor-notes section if present (everything from
## Editor notesonward). That's internal pipeline metadata, not for publishing. - Extract the H1 as the title and remove it from the body. Strapi renders the title separately; an H1 in the body causes duplicate-title bugs.
- Apply callout transformations per
references/strapi-format.md:**Tip:** ...and**Pro tip:** ...→:::tip ... :::**Note:** ...and**Sidenote:** ...→:::note ... :::**Editor:** ...→:::editor ... :::4b. Convert GFM tables for the live renderer (PLEAA-567 workaround — publish boundary ONLY). The draft/preview keep real markdown tables; the public Next.js renderer can't render GFM tables yet (CTO ticket pending), so at THIS stage convert each table in the working copy: render it to a legible table-card PNG (same matplotlib table-card path/generate-visualsuses fortype=tabletext-tables), upload as a media block where the table stood, and keep a compact bulleted text version beneath the image (one bullet per row, bold first-column label) so the content stays crawlable. When the renderer fix ships, delete this step — nothing upstream changes.
- Build the Strapi v5 JSON payload with title, slug, description (first 1–2 sentences of intro, ≤80 chars — also serves as
<meta name="description">), blocks[] (singleshared.rich-textcomponent holding the markdown body), category (documentId resolved via/api/categories), publishedAt. The schema is strict —author_name,read_time,cover_image_url,tags,excerpt,content,seoare NOT in the Article content-type; Strapi 400s on them. Author is attached manually in admin if needed. The article-levelcoverrelation is auto-attached on--auto-publishto the first uploaded image (PLEAA-570, 2026-05-11) so RSS feeds, OG tags, social cards, and admin preview resolve the hero too. SEO surface istitle+descriptiononly (PLEAA-457 DOD#4 resolved 2026-05-07). - Run the formatter script:
The script writes the three files topython .claude/skills/format-for-publish/scripts/format_for_strapi.py "<slug>"content-pipeline/8-publish/{slug}/. - If the user has wired up Strapi API access via Doppler AND wants direct publish, append
--publish:
Requiresdoppler run -- python .claude/skills/format-for-publish/scripts/format_for_strapi.py "<slug>" --publishSTRAPI_BASE_URLandSTRAPI_API_TOKENenv vars. Article is created in Strapi as a DRAFT (publishedAt = null) — editor publishes manually after review. - Tell the user the output paths and which option (paste vs API) is active.
Output
content-pipeline/8-publish/{slug}/
article.md— clean markdown body (paste in Strapi rich-text field)article.json— Strapi-shaped payloadREADME.md— paste-or-publish instructions with title, slug, excerpt, SEO fields, suggested categories/tags
Quality checklist
- H1 stripped from body (title lives in JSON only)
- No
**Tip:**/**Note:**/**Editor:**markdown prefixes remaining in body — all converted to:::callouts - Editor-notes section excluded from output (it's pipeline metadata, not content)
-
descriptionis real prose from the intro, not the title or first heading -
description≤ 80 chars (Strapi v5 cap) -
blocks[0].__component == "shared.rich-text"andblocks[0].bodynon-empty - No legacy v4 fields (
excerpt,content,seo,categories[]) in payload -
category(when set) is a documentId STRING, not an array - Slug matches the input slug exactly
-
publishedAt: nullfor draft mode; ISO timestamp only when--auto-publishis set
Customizing for your Strapi schema
The default JSON shape assumes a typical "Article" content type with: title, slug, excerpt, content, seo (component), categories (relation), tags (relation), publishedAt. If your Strapi has different field names or component shapes:
- Open
format_for_strapi.py - Edit the
build_payload()function — adjust field names, nest components per your schema - The transformation logic (callouts, excerpt extraction) works regardless of payload shape
When direct API publish fails
Common causes:
- 401 Unauthorized: API token missing or wrong scope. Check Strapi admin → Settings → API Tokens; needs at least "Authenticated" or "Full access" for content creation
- 400 Bad Request: Payload doesn't match your Strapi schema. Check
article.jsonagainst your real content type — likely a missing required field or extra unknown field - 404 Not Found:
STRAPI_BASE_URLis wrong or the endpoint isn't/api/articles(e.g., your content type is named differently — check Settings → API Tokens → endpoint list)
When --publish fails the script keeps the local files so you can paste manually.
Auto-publish vs draft modes
The skill supports two publish modes:
Draft mode (default; what --publish does without --auto-publish):
publishedAt: nullin the payload- Article enters Strapi as a draft
- Editor reviews in admin, hand-edits, captures any remaining screenshot placeholders into media library, then clicks Publish
- The human checkpoint is where the last 20% of quality lives
Auto-publish mode (--auto-publish flag, or BLOG_AGENT_AUTO_PUBLISH=1 env var):
publishedAtset to the current ISO timestamp in the payload- Article goes live on Strapi immediately
- Required for
/auto-blog-loopautonomous mode (no human in the loop, by design) - Compensating control: a quality precondition gate (see "Quality precondition" below) refuses to publish if the upstream
/quality-checkverdict is FAIL — belt-and-suspenders even if the orchestrator is misconfigured
Pick auto-publish for the cron-driven /auto-blog-loop path. Pick draft mode for one-off manual runs where an editor is going to review.
Quality precondition (auto-publish only)
Before issuing the Strapi POST when --auto-publish is set, the script re-reads content-pipeline/quality-checks/{slug}.md and parses the verdict line. If the verdict is FAIL OR if the file is missing, the script refuses to publish, exits non-zero, and prints the reason. The orchestrator will then quarantine the slug. This is a safety net — /blog-pipeline should never call /format-for-publish --auto-publish on a FAIL'd article, but the precondition makes a misconfigured orchestrator harmless.
Publish gate (PLEAA-581) — artifact must be on origin/main
--publish and --auto-publish both run an artifact precondition before any HTTP write to Strapi (POST /api/articles, PUT /api/articles/{id}, POST /api/upload). The script verifies:
content-pipeline/8-publish/{slug}/article.jsonexists locally, AND- The same path is present in
origin/mainHEAD (git ls-tree -r origin/main -- <path>returns a non-empty line).
Both must hold. If either check fails the script exits non-zero with a publish gate REJECT message that names the missing artifact and links /blog-pipeline. This is a HARD gate, not a warning — --auto-publish is no longer allowed to write to Strapi for a slug whose package isn't committed and pushed.
Override: --human-approved "<reason>"
The only escape hatch is --human-approved "<reason>" (required, non-empty). When set, the script:
- skips the artifact check,
- appends one tab-separated line to
content-pipeline/audit/publish-overrides.logwith: ISO timestamp, slug, reason,git config user.email, current branch, HEAD sha, - prints
publish gate OVERRIDDEN — reason=...to stderr, - proceeds with the publish flow.
Use the override only for legitimate one-off operations like a manual unpublish/reseed or a hotfix where the package can't be committed first. Every override is forensically recoverable from the audit log. The log file is gitignored (per-environment append-only); rotate it to long-term storage via your own retention policy if you need an organization-wide trail.
Why this exists
PLEAA-577 (root cause + audit) traced an incident where eight Strapi articles went live without ever passing through /blog-pipeline — the Strapi UI / API token had publish authority with nothing local to gate against. Layer 1 of PLEAA-581 is the client-side belt: a --publish call without an artifact in origin/main is now refused at the script level. Layers 2 (Supabase approval registry + edge-function gate) and 3 (Strapi token surface reduction) live server-side and continue to enforce the same rule even when this script is bypassed.
manifest.json (Layer 2 hook)
Every run of format_for_strapi.py writes content-pipeline/8-publish/{slug}/manifest.json alongside article.md / article.json / README.md. The file is small (slug, title, pipeline_run_id, approved_by, generated_at) and is the source-of-truth artifact that the sync-publish-approvals.yml GitHub Action upserts into the Supabase blog_publish_approvals registry on push to main. The edge function (sync-blog-posts) then refuses any Strapi entry.publish webhook whose slug has no registry row.
Whiteboard staging (PLEAA-448)
After the publish package is written (and any Strapi POST has run), the script bakes the GitHub-Pages whiteboard artifacts for the slug:
- Calls
scripts/bundle_viewer.py <slug>→ writesdocs/run-<slug>.html(self-contained offline viewer with every stage's content inlined). - Idempotently appends
"<slug>"to the fallback runs array indocs/index.html(thecatch (e) { return [...] }block at ~line 197). If the slug is already there, the file is not rewritten. - Best-effort
git add docs/run-<slug>.html docs/index.htmlso onegit commit && git pushfrom the operator surfaces the run at https://lionelndong.github.io/blog-agent-2/ within ~1 minute.
This step is best-effort — any failure prints a warning and the operator can re-run python scripts/bundle_viewer.py <slug> manually. Set BLOG_AGENT_SKIP_WHITEBOARD=1 to disable (e.g. for one-off non-board runs).