blog-writer
Channel-master writer for long-form blog posts. Owns the SHAPE of a post — beat structure, title/preview/slug commitments, per-post container layout, per-site voice anchor discovery — and delegates VOICE (prose generation) to /authors-voice and PUBLISH mechanics to the openwriter
Install
npx skills add https://github.com/travsteward/openwriter/tree/main/skills/blog-writer
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install travsteward-openwriter@llmmart
git clone https://github.com/travsteward/openwriter.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole travsteward/openwriter collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Blog Writer
Channel-master skill for long-form blog content. Owns ideation → beats → draft → image → publish.
Architecture: beats-first (v0.5.0) + plugin-backed publish (v0.4.0). Each post lives in its own container with two sibling docs: a Beats doc (the structural commitments — beat list + title/preview/slug as B0) and a Draft doc (the voice-poured prose). Beats reshape regularly; draft re-pours via /authors-voice against a per-site anchor (voice/anchor-<site-slug>.md). Publish is a single post_to_blog MCP call against a site registered via add_blog_site — site-specific frontmatter (layout, author, prerender, date → publishedDate for Astro, etc.) lives on the github plugin's per-site config in ~/.openwriter/config.json, not in project config. Mirrors book-writer's discipline at post scale.
Convention
This skill obeys the shared writer contract at WRITER-CONVENTION.md. Brief shape and return shape match that doc. Sub-form values: long | short | tutorial | announcement.
OpenWriter pad mechanics are canonical in /openwriter — read it, don't re-derive from tool descriptions. The load-bearing rule: populate_document is create-only, used ONCE; it re-sends the whole body, so calling it again to "fix" a doc appends a duplicate. All edits (rewrite / insert / delete) go through write_to_pad on fresh node IDs. Plus the read ladder (outline_doc → search_docs → peek_doc → read_pad).
Blog posts ship SEO-complete in the FIRST pass. A blog post almost always carries SEO — internal/cluster links, meta + slug + tags, snippet-targeted headings, FAQ/schema-eligible content all go into the initial draft (the minion brief + first populate_document), never bolted on after. Bolting SEO on later forces an edit (→ write_to_pad, never a 2nd populate). SEO-strategy-led posts (pillar / landing / comparison) front-end through /seo-writer if you have it installed (not bundled with OpenWriter).
Cover image is a publish gate (firm rule 12). Placement rules: no bottom-band text (the platform renders og:title there), keep the subject center-safe, ≤5–7 words of overlay text, do not echo the title/description. Palette / brand skin comes from the project's style_doc (e.g. recipebox.md). Generation, canonical size, and the tmp-first rule for text-overlay covers: docs/images.md.
ABOUT TO HAND-EDIT A PUBLISHED POST'S .md, ITS image: FRONTMATTER, OR DROP A COVER INTO THE REPO'S public/? STOP. The OpenWriter doc's blogContext.coverImage is the single source of truth, and post_to_blog is the ONLY writer to the blog repo. Edits flow doc → Accept → Publish — never doc and repo in parallel, because that drift is exactly what breaks idempotent republish (Publish rewrites <content_dir>/<slug>.md wholesale and your hand-edits vanish). Never hand-name a cover; never side-channel an image; never git push the post yourself. Canonical publish flow + the lastPublish-clobber footgun: docs/integrate.md.
Modes
| Mode | Trigger | What it does | Sub-doc |
|---|---|---|---|
setup |
/blog-writer setup, "register blog repo", "add blog site" |
One-time per blog: inspect_blog_repo clones the target, auto-proposes frontmatter_defaults + frontmatter_field_map from existing posts; add_blog_site persists the site config |
docs/setup.md |
brainstorm |
/blog-writer brainstorm, "brainstorm blog topics" |
Open a Blog Ideas doc; propose 3-5 candidate angles with tone + length labels; hand off to beats when user picks |
docs/brainstorm.md |
beats |
/blog-writer beats, "extract beats", "blog beats" |
Query-first beat extraction with 3-pass (short/announcement, 3-5 beats) or 5-pass (long/tutorial, 8-15 beats); 9 blog category tags (CLAIM/REFRAME/MECHANISM/EVIDENCE/DEMO/SCENE/OBJECTION/APHORISM/PIVOT) + HOOK/CTA positional roles; locks title + preview + slug as B0; dopamine arc layered with conversion arc | docs/beats.md + docs/titling.md |
draft |
/blog-writer draft, "draft this post", "pour the beats" |
Per-beat dispatch to /authors-voice Apply Protocol with site-specific anchor; cross-beat coherence pass; supports beat-level reshape loop (re-pour only the affected beats) |
docs/draft.md + docs/voice-anchor.md |
images |
/blog-writer images, "blog image", "featured image" |
Read style doc, craft prompt, generate cover via insert_image (no docId → returns path → blogContext.coverImage); optional inline images at afterNodeId |
docs/images.md |
integrate |
/blog-writer integrate, "publish", "post to blog" |
post_to_blog against the registered site — builds frontmatter from blogContext + site defaults, copies referenced /_images/... to image_dir, rewrites paths, commits, pushes; verifies pending decorations accepted first |
docs/integrate.md |
pipeline |
/blog-writer pipeline, "full blog workflow", "write and publish" |
7-step sequence (Setup → Brainstorm → Beats → Draft → Images → Accept → Publish → Verify) with explicit gates; reshape loop returns to Beats step | docs/pipeline.md |
Modes can chain (pipeline runs them in sequence) or stand alone. Beats reshape → draft re-pour is the inner loop the architecture is designed around.
Setup (per-session vs per-blog)
Per-session. Read the project's CLAUDE.md and extract the optional ## Blog section. It can carry writing_rules, style_doc, content_driven, aspect_ratio. Used by images mode + as defaults for beats/draft. Schema: docs/project-config.md. If absent, defaults apply and the skill still runs.
Per-blog (one-time). Each blog repo must be registered with the github plugin via add_blog_site before integrate can publish to it. Run /blog-writer setup once per blog. The setup mode uses inspect_blog_repo to auto-propose frontmatter_defaults (layout, author, prerender — fields constant across the site's existing posts) and frontmatter_field_map (e.g. date → publishedDate for Astro sites). Sub-doc: docs/setup.md.
Architecture (build internals)
Per-post container structure (Beats + Draft sibling docs), the 10-step OpenWriter call-order for building one post, and the return/output contract live in docs/architecture.md. Two facts ride every build:
- Two sibling docs per post.
Beats — <Title>(content_typenotes) holds structure;<Title>(content_typeblog) is the publishable Draft — one per container. Reshape beats → re-pour only the affected beats, never the whole post. - ABOUT TO CALL
switch_document(or any view-control MCP) MID-BUILD? STOP. The agent builds the container + Beats + Draft + metadata + images silently, targeting docs bydocId; the user watches the activity feed and navigates themselves. Onlyswitch_documenton an explicit user instruction ("open the Draft", "show me the Beats").
Firm rules
- Beats before draft. Draft mode requires a locked Beats doc. No beats → run
beatsfirst. Pouring prose without committed beats produces shapeless drafts that need full structural rework downstream. - Beats and Draft live in separate docs, always. One container per post, two sibling docs:
Beats — <Post Title>(content_type: notes) and<Post Title>(content_type: blog — the publishable doc). Beats reshape → draft re-pour. Don't mix prose into the Beats doc; don't put structural commitments in the Draft doc. - Title + preview + slug are B0 commitments. Locked in the Beats doc's B0 block during
beatsmode. Whendraftmode runs: title mirrors to the Draft doc's title field viarename_item(the publish plugin reads title from there, not fromblogContext.title); preview + slug mirror toblogContextviaset_metadata. See docs/titling.md. The first paragraph of the draft must echo the title — can't echo what isn't locked. - Per-site voice anchor.
draftmode readsvoice/anchor-<site-slug>.mdwhere<site-slug>is the slugified site label fromlist_blog_sites. Silent fallback tovoice/anchor.mdif not present. Discovery is convention-based, NOT in plugin config. See docs/voice-anchor.md. - Setup before integrate. A blog repo must be registered via
add_blog_sitebeforeintegratecan publish. Checklist_blog_sitesat session start — if the target repo isn't there, run/blog-writer setupfirst. - Project config is optional; per-site config is canonical.
## Blogin CLAUDE.md is forwriting_rules,style_doc,content_driven,aspect_ratio. Frontmatter shape (layout,author,publishedDatevsdate, etc.) lives on the github plugin's per-site config, NOT in project config. - Voice always. Every beat pours through
/authors-voiceApply Protocol with the site-specific anchor. The skill owns shape; voice owns diction. Per-beat dispatch is the default forlong/tutorial; collapse to single dispatch forshort/announcementunder 1000w. - Two-step doc creation.
create_document(spinner) →populate_document(content). Never inline a 30s generation into one tool call. - Today's date in the doc. Set
blogContext.dateasYYYY-MM-DDin the project's configured timezone (defaultAmerica/Los_Angeles). The plugin formats / renames per site config. - Accept pending decorations before publishing. Agent-inserted images and rewrites land as pending decorations until the user accepts them in the right-rail Review tab.
post_to_blogreads the canonical doc on disk — pending changes don't ship. After agent writes,integratemust tell the user "click Accept All in the right rail, then I'll publish" rather than silently posting incomplete content. See docs/integrate.md. - Mark-sent is automatic. After a successful
post_to_blog, the plugin writesblogContext.lastPublish = { publishedAt, publishedUrl, commit, file }on the Draft doc. File tree shows a green ✓; right-click menu surfaces "View Post." Same convention as tweets / articles / newsletters. - Cover image before publish. No post
integrates without a cover/OG image — a gate, not optional. Design per the Convention above (placement rules +style_docpalette; docs/images.md mechanics).
Anti-patterns
- ❌ Calling
draftwithout first runningbeatsfor the post — fails with "no Beats doc found in container" - ❌ Calling
post_to_blogwithout first runningsetupfor that repo — fails with "no blog site with id X" - ❌ Putting site-wide constants (
layout,author,prerender) intoblogContextinstead of the site'sfrontmatter_defaults - ❌ Calling
post_to_blogimmediately afterinsert_imagewithout waiting for accept — pending image stays in browser overlay, doesn't ship - ❌ Writing the blog post
.mdfile directly into the target repo via Write/Edit tools — that'spost_to_blog's job - ❌ Generating an image before the content is approved (image themes might shift)
- ❌ Skipping voice protocol because "the draft sounds fine"
- ❌ Mixing modes — finish one, then start the next
- ❌ Single global voice anchor when a site-specific one exists —
draftmode reads conventionally; no opt-in required - ❌ Reshaping beats AND re-pouring the entire draft in one pass — reshape beats first, lock them, then re-pour ONLY the affected beats
- ❌ Calling
/blog-pipeline,/blog-images,/blog-integrate,/blog-feature-imagesas separate skills (deprecated stubs that redirect here)
Scripts
mcp__openwriter__insert_image— Gemini image generation directly into the active OpenWriter doc (primary path; cover via path-return +blogContext.coverImage)- image-gen CLI (if installed locally) — standalone fallback for non-OpenWriter workflows
- Sharp conversion (PNG → WebP) — for projects where the target site insists on
.webp(the github plugin copies PNGs unchanged; conversion is a project-specific concern before publish)
Related skills
Delegated / required:
- /authors-voice — voice pipeline; REQUIRED for any prose generation (
draftdelegates every beat dispatch). - /anti-ai — final AI-tells fingerprint scrub; recommended after a post is voice-poured.
- openwriter — the workspace/document MCP; REQUIRED for the doc management this skill governs.
- /seo-writer — optional, not bundled: SEO-strategy-led posts front-end there, then reuse this skill's publish path.
NOT covered (use your own tooling instead): project deploy pipelines · cross-channel announcements (Discord, X, etc.) · /newsletter-writer (different channel) · /x-writer (different channel) · /book-writer (multi-chapter books — global beat sheet, workspace management).
Files (openwriter)
-
docs
-
architecture.md 4.9 KB
# Architecture: container layout, doc lifecycle, output contract Reference for how one post's OpenWriter docs are structured, built, and returned. Consulted during a build — the per-mode docs (`beats.md`, `draft.md`, `images.md`, `integrate.md`) own the per-step detail; this is the consolidated overview. ## Per-post container layout Each post lives in its own container under the blog workspace: ``` [Project] Blog/ (workspace) └── <Post Title>/ (per-post container) ├── Beats — <Post Title> (beats mode output — content_type: notes) ├── <Post Title> (draft mode output — content_type: blog, THIS is the publishable doc) └── Sources — <Post Title> (optional — content_type: notes) ``` The Draft doc's title IS the post's locked title (no prefix). The publish plugin reads `title` from the doc title field, not `blogContext.title`. The Draft doc is identified inside the container by `content_type: blog` — only one per container. Beats and Draft are SEPARATE docs by firm rule. Beats reshape → draft re-pour stays cheap because each doc has a single owner. Sources is optional — most posts let the model's training data carry examples; build a Sources doc only for post-training-cutoff topics or contested citations the author wants pinned. ## OpenWriter doc lifecycle (call order for one post) How a single post's docs are prepared, populated, mirrored, and published — in call order. Each mode owns part of this sequence: | Step | When | Tool call | Notes | |---|---|---|---| | 1 | First post for a project | `create_workspace({ name: "[Project] Blog" })` | Only if the project's blog workspace doesn't exist yet. Check `list_workspaces` first. | | 2 | `beats` mode, start of session | `create_container({ workspace_id, name: "<provisional post title>" })` | Per-post container; rename later if title sharpens. Provisional name from brainstorm / source material. | | 3 | `beats` mode | `create_document({ container_id, title: "Beats — <Post Title>", content_type: "notes" })` → `populate_document` | Two-step. The Beats doc is the methodology output; lives the whole post's life. | | 4 | `beats` mode, on title-lock | `rename_item({ id: container_id, name: "<locked title>" })` + same for Beats doc title if provisional | Container name + Beats doc title both follow the locked title; keeps the sidebar coherent. | | 5 | `draft` mode, first run | `create_document({ container_id, title: "<locked title>", content_type: "blog" })` → `populate_document({ content: "" })` | The Draft doc title IS the published title (no prefix). content_type `blog` marks it as the publishable doc inside the container. | | 6 | `draft` mode, immediately after Step 5 | `set_metadata({ docId: draftDocId, metadata: { blogContext: { active: true, description, slug, date, tags } } })` | Title is on the doc title field already; everything else lives on `blogContext`. Don't set `blogContext.title` — the publish plugin ignores it. | | 7 | `draft` mode, per beat | `/authors-voice` Apply Protocol → integrate result into Draft doc (target by `docId`, not by active view) as a pending decoration | Per-beat dispatch is the default; collapse to single dispatch for `short`/`announcement` under 1000w. | | 8 | `images` mode | `insert_image` (cover: no docId, returns path → `set_metadata` blogContext.coverImage; inline: with docId + afterNodeId) | Inline images land as pending decorations on the targeted doc. | | 9 | Before `integrate` | `get_pad_status({ docId: draftDocId })` → expect `pending: 0` | Pending decorations are NOT on disk; `post_to_blog` reads the on-disk canonical doc and would skip them. If pending > 0, prompt user to Accept All in the right-rail Review tab. | | 10 | `integrate` mode | `post_to_blog({ site_id, commit_message })` | Plugin reads the on-disk Draft doc, builds frontmatter from `blogContext` + site defaults, copies images, commits, pushes. Sets `blogContext.lastPublish` on success. | Reshape loop: Steps 2–7 repeat for affected beats only — never re-pour the whole post. ## Output contract Every mode writes to OpenWriter and returns (shape per [WRITER-CONVENTION.md](../../WRITER-CONVENTION.md)): ```json { "status": "draft-ready" | "needs-input" | "blocked", "artifact": { "doc_id": "...", "workspace_id": "...", "container_id": "..." }, "next_steps": ["/blog-writer beats", "/blog-writer draft", "/blog-writer images", "/blog-writer integrate"], "notes": "<optional>" } ``` Mode chain (`next_steps`): - After `setup` → `["/blog-writer brainstorm", "/blog-writer beats"]` - After `brainstorm` → `["/blog-writer beats"]` - After `beats` → `["/blog-writer draft"]` - After `draft` → `["/blog-writer images", "/blog-writer integrate"]` - After `images` → `["/blog-writer integrate"]` - After `integrate` → `["verify-live-url"]` (most sites auto-deploy on push; a manual deploy step only for projects with custom deploy pipelines) -
beats.md 21 KB
# Beats — Blog Post Methodology A beat is the smallest unit of forward movement: one shift in the reader's understanding, attention, or emotional state. The fundamental unit the editor operates on with the author. Blog-scale adaptation of book-writer's beat methodology. Same discipline, smaller container. For deep methodology background see `book-writer/docs/beats.md` — this doc is the operational form for posts. ## Firm rules ### 1. Beats live in a separate doc, ALWAYS. Each post has TWO sibling docs in its container: - `Beats — <Post Title>` (content_type: notes) — the locked beat structure (this doc's output) - `<Post Title>` (content_type: blog) — the prose pour, identified by content_type as the publishable doc (draft mode's output) Beats and draft change on different cycles. Beats get reshaped when the author rethinks a claim; the draft gets re-poured through `/authors-voice` against the new beats. Keep them separated so a beat-level reshape doesn't fight an in-flight prose edit. ### 2. Beats are commitments, not content. A beat is the OUTCOME the writer must produce in the reader. NOT the content the writer uses to produce it. | Content brief (wrong) | Commitment (right) | |---|---| | "Mention Stripe Connect, PayPal, and Square, then say why Connect is best" | "Position payment-rails choice as a one-way door — reader registers Connect as the locked-in standard" | | "Show the migration steps: pnpm install, run codemod, restart" | "Land the migration as boring — reader registers 'three commands, no babysitting'" | The right column tells the writer WHAT must land. The author's frame + the model's training data bring the specifics. When the editor specifies the prose instead of the move, the minion can't bring its moves. ### 3. Query-first: pull from author, don't propose. The editor STRUCTURES what the author owns. When beats are needed — to fill a post, extend the spine, sharpen a claim — DEFAULT to querying the author. Mining source docs and proposing 3 candidate beats wastes a turn; the author rejects all 3 because they came from outside the author's brain. Correct move: name the SIGNAL in the author's recent thinking → formulate ONE focused question → author talks → editor structures the beat from the author's words. **Query patterns that work:** - "You just shipped X. What's the counter-intuitive thing about it that nobody else writes?" - "If someone reads this and only remembers ONE sentence, what should it be?" - "What did you think before you shipped X, and what do you think now?" - "What's the reader doing wrong today that this changes?" - "What's the t-shirt line for this post?" Propose only when (a) the author asks for candidates explicitly, (b) candidates are mechanically derived from material the author owns (a PR diff, a recent feature spec) — presented as restructuring, not invention, or (c) `announcement` posts where the angle is "we shipped X" and the beats are mechanically downstream of that. ### 4. Declarative-claim names, never categorical labels. Every beat name must communicate the beat's SUBSTANCE — what it asserts — not what KIND of beat it is. | Good (substantive) | Bad (categorical) | |---|---| | `B1 — STRIPE CONNECT IS A ONE-WAY DOOR` | `B1 — THE HOOK` | | `B3 — MIGRATION TAKES THREE COMMANDS` | `B3 — THE WALKTHROUGH` | | `B5 — YOU'LL HATE THIS ON DAY ONE` | `B5 — THE OBJECTION` | Format: declarative claim, present tense, 4-10 words, the active assertion the beat makes. Author should be able to picture the move from the name alone. ## Beat count and pass depth by sub-form Beat methodology scales by post size. Two depths: | sub_form | Beat count | Pass depth | Notes | |---|---|---|---| | `short` | 3-5 | 3-pass | Quick announcement, opinion take, single-feature drop | | `announcement` | 3-6 | 3-pass | Feature launch with context + demo + CTA | | `long` | 8-15 | 5-pass | Deep dive, framework, multi-section exploration | | `tutorial` | 8-12 | 5-pass | Step-by-step (some beats wrap code blocks / screenshots) | The compressed 3-pass skips TENSION and CATEGORY — for posts under 1000 words those passes are overhead the post can't earn back. Drop straight from DUMP to SEQUENCE to COMPRESSION. ## The dopamine arc for a blog post (layered with a conversion arc) A post's beat list reads as a 4-act dopamine sequence at compressed scale, layered onto a conversion arc that books don't have: | Act | Dopamine job | Conversion job | Beats (long post) | Beats (short post) | |---|---|---|---|---| | **Hook** | Crack the reader open with tension / curiosity-gap / surprising claim | Earn the click into reading | B1-B2 | B1 | | **Develop** | Each beat resolves one prior tension and opens the next | Move reader from "interesting" to "I believe it" | B3 to B(n-2) | B2-B(n-1) | | **Bridge** | Pivot or reframe — zoom out to implication, or zoom in to scene | Move reader from "I believe it" to "what do I do" | B(n-1) | (often skipped) | | **Payoff + CTA** | Close the loop opened in Hook; the action invitation lands clean | The action invitation lands clean | Bn | Bn | Both arcs must hold. When they diverge (rare), the conversion arc wins for the CTA-ending segment — that's where most posts lose readers, so the close gets sequenced for conversion even if it costs a small dopamine beat. Acts are organizational scaffolding in the Beats doc, NOT dispatch units. Each beat is its own dispatch when prose pours. ## The 5-pass extraction (long / tutorial) Editor drives, author owns substance. The shape is universal across writing channels (DUMP captures, structure passes shape, COMPRESSION tests); the TAGS used inside CATEGORY and the dimensions used inside TENSION are blog-specific. Book-writer uses argument-domain tags (REVEAL / MECHANISM / etc.); copy uses conversion tags (PROMISE / PROOF / OBJECTION / etc.); the blog taxonomy below sits between, borrowing from both. ### Pass 1: DUMP Author brain-dumps every interesting, counter-intuitive, sharp, lived, weird, or sticky thing on the topic. No filtering, no sequencing, no length cap. Editor captures verbatim. Target: 15-25 raw beat-candidates (more than will survive). Editor prompts that help: - "What's the counter-intuitive thing here?" - "What's the scene from your own work that lands a piece of this?" - "What's the t-shirt line?" - "What's the reversal — reader expected X, gets Y?" - "What's the part you keep coming back to in conversation?" ### Pass 2: TENSION (blog-customized — four dimensions) Books only tag two tension dimensions per beat. Blog posts have two more: an external promise (the title + preview), and an external action (the CTA close). Tag each candidate against ALL four: 1. **Question this beat ANSWERS** — the tension it resolves 2. **Question this beat OPENS** — the tension it primes for the next beat 3. **Promise this beat DELIVERS on** — which part of the title + preview promise this beat pays off (or `none` if the beat is structural / connective) 4. **Reader action this beat INVITES** — implicit for most beats (scroll / register / trust), explicit for the CTA close (subscribe / share / try / follow / book) Failure modes Pass 2 catches: - **Promise-orphan beat** — answers a question and opens another, but doesn't deliver on ANY part of the title's promise. Cut or repurpose: it's interesting but doesn't pay off the contract the link surface made. - **Title-undelivered promise** — no beat in the list delivers on a specific phrase in the title or preview. Either reshape the title (the post isn't actually about that), or add a beat that delivers (the post is missing a load-bearing move). - **Dead-end beat** (no open question) — fine at post close; in the middle, flag for cut / merge / repositioning. - **Non-sequitur beat** (no answered question) — find the prior beat it should follow, or cut. A clean blog beat: answers ONE question, opens ONE question, delivers ONE slice of the promise, invites a coherent (often implicit) action. The conversion handoff. ### Pass 3: CATEGORY (blog taxonomy — 9 tags + 2 positional roles) Tag each beat with one category. Categories are MOVES the beat makes — not roles. Two roles (HOOK, CTA) are positional, not tagged: **Positional roles** (placement is the rule, no category tag): - **HOOK** — always B1. Opens with tension or curiosity-gap, echoes the title's load-bearing phrase, primes the post's first question. Internally categorized (it's still a CLAIM or REFRAME under the hood) but its position IS its job. - **CTA** — always the closing beat for posts that want reader action. Invites a specific next move (subscribe, share, try, follow). For posts without an explicit CTA, the closing beat is APHORISM or PIVOT instead — no positional CTA needed. **Category tags** (one per beat): | Tag | Meaning | Common shape | |---|---|---| | **CLAIM** | New information lands; the load-bearing assertion | "X is true / X happens / X works this way" | | **REFRAME** | Challenges the reader's prior understanding | "You think X. Actually Y." | | **MECHANISM** | Explains how or why something works | Step-by-step or causal chain | | **EVIDENCE** | Proof: data, study, citation, anecdote-as-evidence | "Here's the data / here's the study / here's the case" | | **DEMO** | Shows the thing in action (code block, screenshot, walkthrough) | The reader sees, not just hears | | **SCENE** | Lived moment that grounds an abstraction | "The day we shipped X / when our customer hit Y" | | **OBJECTION** | Anticipates and dismantles a likely pushback | "You might say X. Here's why X doesn't hold." | | **APHORISM** | Compressed single-line beat | The t-shirt line; one sentence; fires on its own | | **PIVOT** | Directional turn between sections | "So far we've looked at X. Now —" | DEMO and OBJECTION are blog-specific additions to the book taxonomy. DEMO covers visual/procedural proof that books rarely lean on. OBJECTION covers persuasion work that essays and tutorials need but book chapters do less of. **Confirm the MIX. Typical long blog post:** | Composition | Share | |---|---| | CLAIM / REFRAME (the post's load-bearing moves) | 30-40% | | MECHANISM / EVIDENCE / DEMO (the proof layer) | 25-35% | | SCENE / APHORISM (the grounding + compression layer) | 15-25% | | OBJECTION (anticipates pushback) | 5-15% | | PIVOT (connective tissue) | 5-10% | Skew shifts by sub-form: - **Tutorial** — heavier DEMO + MECHANISM (50-60%), lighter REFRAME / OBJECTION - **Opinion** — heavier REFRAME + APHORISM, lighter DEMO - **Announcement** — heavier CLAIM + DEMO (showing the new thing), light on REFRAME / MECHANISM - **Framework** — balanced CLAIM + MECHANISM + EVIDENCE, OBJECTION as the closer before CTA Bad mixes that signal trouble: - All EVIDENCE → reads academic, no register variation - All APHORISM → reads tweet-thready, no grounding - All MECHANISM → reads textbook, no surprise - Zero OBJECTION on a persuasive post → reader's pushback is unaddressed - Zero DEMO on a tutorial → reader doesn't see the thing work ### Pass 4: SEQUENCE (dopamine arc + conversion arc, layered) Order beats by dopamine flow — each beat's OPEN question becomes the next beat's TENSION. Same primary rule as book. For posts with a CTA (most blog posts), the dopamine arc layers ONTO a conversion arc. Both must land: | Position | Dopamine job | Conversion job | |---|---|---| | Hook (B1) | Crack the reader open: surprise, curiosity-gap, title-echo | Earn the click into reading | | Early develop | Resolve B1's tension → open the next | Build trust the post is worth finishing | | Mid develop | Proof + mechanism + scene | Move the reader from "interesting" to "I believe it" | | Pivot | Directional turn (PIVOT beat or REFRAME beat) | Move from "I believe it" to "what do I do" | | Late develop | Objection + payoff | Address the last pushback before action | | Close (Bn) | Aphorism that lands the t-shirt line, OR CTA inviting action | The action invitation lands clean | Watch for the standard sequence failures plus blog-specific ones: - **Broken dopamine** — beat N opens a question beat N+1 doesn't answer (reader waits without payoff) - **Premature reveals** — payoff lands before setup primed anticipation - **Stacked openings without payoff** — post keeps promising and never delivers - **Stacked payoffs without new tension** — post peaks then flatlines - **Conversion break** — OBJECTION beat positioned BEFORE the post has built enough trust to handle pushback (move it later) - **CTA without runway** — closing CTA arrives without the post having earned the action (need an OBJECTION or APHORISM beat before CTA to close the loop) The right sequence is dopamine-optimal AND conversion-optimal. For most posts those are the same sequence; when they diverge, conversion arc wins for the CTA-ending segment. ### Pass 5: COMPRESSION State each beat as one tweet-length sentence. If you can't compress, the beat is still mush — split or cut. The compression test forces the author to NAME the move precisely. Blog-specific compression subtest: read all the compressed beats end-to-end. Do they form a coherent micro-story that lands the title + preview promise? If yes, the sequence is shippable. If reading the compressed beats produces a scattered set of claims that don't add up to the post the title promised, return to TENSION (Pass 2) — the promise-delivery dimension wasn't being checked, and the post is going to feel diffuse no matter how voice-poured the prose. ## The 3-pass extraction (short / announcement) For posts under 1000 words. Skip the full TENSION + CATEGORY passes — they're overhead the post can't earn back at this scale. Their work folds into SEQUENCE instead. 1. **DUMP** — author brain-dumps 5-10 candidates 2. **SEQUENCE** — order by Hook → Develop → Payoff/CTA. While ordering, the editor mentally tags each beat against the implicit four-dimension TENSION test (answers / opens / delivers on promise / invites action) and drops anything that doesn't fit ALL four. Don't write out the tags — just cut what fails. 3. **COMPRESSION** — one tweet-length sentence per beat, 3-5 survive For `announcement` posts the structure is usually mechanical: B1 = HOOK (the news, title-echo), B2 = CLAIM (why it matters), B3 = DEMO (show it), B4 = CTA (try it / read more / subscribe). Run the 3-pass anyway to sharpen claim-names — categorical "the news" / "why it matters" names produce sloppy prose. ## Beat density (craft choice) Different posts run at different densities. Pick deliberately for the post's velocity: | Density anchor | Typical words/beat | When | |---|---|---| | Aphoristic | 50-100 | Opinion takes, contrarian shorts | | Punchy | 150-250 | Most short/announcement posts | | Argumentative | 250-400 | Frameworks, multi-claim long posts | | Mechanism walk | 400-600 | Tutorials, deep dives, evidence-heavy posts | A single post can mix densities: 80w hook → 300w mechanism beat → 100w aphoristic payoff. That variation IS the rhythm. Per-site beat math (in `voice/anchor-<site_id>.md` or its companion `voice/beat-math-<site_id>.md`) can carry a target anchor for that site's voice. ## Beats doc artifact One doc per post: `Beats — <Post Title>`. Reads top-to-bottom as the flow. ``` # Beats — <Post Title> **Sub-form:** short | announcement | long | tutorial **Word target:** ~Xw **Site:** <site_label from list_blog_sites> **Voice anchor:** voice/anchor-<site-slug>.md (or default voice/anchor.md) ## Title + preview + slug (B0 commitments) See `docs/titling.md`. Title mirrored to Draft doc title via rename_item; preview + slug mirrored to blogContext. - **Title:** <locked title> - **Preview (140-160 char):** <locked description> - **Slug:** <locked slug> ## Beat list ### Hook (~Xw) **B1 — DECLARATIVE CLAIM IN CAPS.** [HOOK] (~Xw) One-paragraph outcome description. Title-echo phrase that must appear. Tension this beat opens. What registers in the reader after this beat lands. ### Develop (~Xw) **B2 — DECLARATIVE CLAIM.** [REFRAME] (~Xw) Outcome description. Which part of the title/preview promise this beat delivers on. Callbacks. Author-unique content. **B3 — DECLARATIVE CLAIM.** [MECHANISM] (~Xw) ... **B4 — DECLARATIVE CLAIM.** [EVIDENCE] (~Xw) ... **B5 — DECLARATIVE CLAIM.** [DEMO] (~Xw) ... ### Pivot (~Xw) **B6 — DECLARATIVE CLAIM.** [PIVOT] (~Xw) ... ### Payoff (~Xw) **B7 — DECLARATIVE CLAIM.** [OBJECTION] (~Xw) ... **B8 — DECLARATIVE CLAIM.** [APHORISM] (~Xw) ... **Bn — DECLARATIVE CLAIM.** [CTA] (~Xw) The action invitation — subscribe / share / try / follow / book. Concrete; no "if you found this useful" filler. ``` Each beat = declarative-claim name + category tag (or positional role for HOOK / CTA) + word target + brief outcome paragraph naming what must land. Outcome shape only — minion brings the prose, editor names the move. **Why no inline citations:** if the post has hardened sources (specific URLs, paper citations, author-name-year refs), they live in a sibling `Sources — <Post Title>` doc, NOT in the Beats doc. Beats reference Sources by name; the draft mode injects them into the minion brief when pouring prose. For most posts there are no hardened sources — model training data carries the examples. ## Minion dispatch When the Beats doc is locked, `draft` mode pours prose via `/authors-voice` Apply Protocol. **Default discipline (matches book-writer):** one beat = one dispatch. **Compressed dispatch (short / announcement, under 600w total):** all beats in a single dispatch, listed as commitments in the brief. Justified by post being shorter than book-writer's single-beat unit (500-650w). **Long / tutorial:** per-beat dispatch is the right call — each beat gets its own minion run with its own commitment brief. The author reviews per-beat and reshapes before the next pours. The dispatch brief carries: - The beat's outcome commitment (verbatim from Beats doc) - The site-specific voice anchor path (`voice/anchor-<site_id>.md` — see `docs/voice-anchor.md`) - Any must-appear phrases / callbacks - Word target for this beat - Prior beats already drafted (so the minion knows what's been said) ## Beat reshape loop Beats reshape regularly. The flow: 1. Author reads the draft, flags "B3 doesn't land" / "B5 should come before B4" / "kill B7" 2. Reshape the **Beats doc** (rename a beat, reorder, drop, add) 3. Re-pour the **Draft doc** for affected beats only (not the whole post — beats are atomic) 4. Voice-pass the new beats via `/authors-voice` against the site anchor 5. Author reviews The two-doc split makes this cheap. A beat-level reshape stays in the Beats doc; only the affected beat-prose gets re-poured. ## When to skip beats methodology - Single-paragraph drafts, one-off emails, tweet replies — beats are overhead the piece can't earn back; go straight to `/authors-voice` - Pure announcement with mechanical structure ("we shipped X") where the author wants to move fast — optional, but the 3-pass still catches weak claim-names - Iteration on already-drafted prose where structural rework isn't the goal — beats are upstream of prose; use the existing draft as preservation-scope source for `/authors-voice` Beats methodology is the right investment for any post where the structure is load-bearing — frameworks, deep dives, opinion pieces with multiple claims, tutorials. Skip when the post is shorter than a single book-writer beat (500-650w) AND the structure is mechanical. ## Output ```json { "status": "draft-ready", "artifact": { "beats_doc_id": "<beats doc>", "workspace_id": "...", "container_id": "<post container>", "sub_form": "long", "beat_count": 12 }, "next_steps": ["/blog-writer draft"], "notes": "Beats locked. Title/preview/slug mirrored to blogContext. Ready to pour prose." } ``` ## Anti-patterns - ❌ Writing prose into the Beats doc. Beats are commitments; prose lives in the Draft doc. - ❌ Categorical beat names ("THE HOOK," "THE MECHANISM," "THE PAYOFF"). Substance, not role. Positional roles (HOOK = B1, CTA = Bn) are placement rules, NOT beat names — the beat name is still a declarative claim. - ❌ Tagging a HOOK or CTA beat with a category in the brackets. They're positional roles; the bracket is `[HOOK]` or `[CTA]`, not `[CLAIM]` or `[APHORISM]`. - ❌ Packing content into the outcome paragraph (specific examples the model would invent anyway). Specify only AUTHOR-UNIQUE content + load-bearing callbacks. - ❌ Using book taxonomy tags (REVEAL / REGISTER SHIFT) on blog beats. Blog tags are CLAIM / REFRAME / MECHANISM / EVIDENCE / DEMO / SCENE / OBJECTION / APHORISM / PIVOT. - ❌ Promise-orphan beats — answer/open clean but deliver on NO part of the title/preview promise. Cut or repurpose. - ❌ Zero OBJECTION beats on a persuasive post. Reader's likely pushback goes unaddressed; conversion stalls. - ❌ Zero DEMO beats on a tutorial. Reader doesn't see the thing work; the tutorial fails to land. - ❌ Running the 5-pass on a 600-word announcement. Use the 3-pass. - ❌ Skipping beats and going straight to draft "because the topic is simple." If the post has 3+ claims, beats catch the broken sequence before the prose locks in. - ❌ Reshaping beats AND re-pouring the entire draft in one pass. Reshape beats first, lock them, then re-pour only the affected beats. -
brainstorm.md 3.1 KB
# Mode: Brainstorm User wants topic ideas. Open an OpenWriter doc and propose angles. ## When to use - User says "brainstorm blog topics" / "what should I write about" / "blog ideas" - Strategist hands off without an `angle` field — writer must ideate - User has a vague sense ("something about pricing") but no specific topic ## Workflow 1. Read project `## Blog` config (see [project-config.md](project-config.md)) 2. Read recent ship-events, PRs, or features the project has shipped lately 3. `create_document({ title: "Blog Ideas — [Project]", workspace: "[Project] Blog" })` 4. `populate_document({ content: "<topic list with angles>" })` 5. Discuss with user, refine, pick a topic 6. When a topic is locked, switch to `beats` mode — create the per-post container and start the beat extraction in a NEW `Beats — <Post Title>` doc ## What to propose When ideating, consider these axes: - **What's been shipping recently?** New features, fixes, breakthroughs — these are the easiest wins because the work is fresh and there's something concrete to show. - **What questions do users ask?** Support tickets, Discord/Slack threads, Reddit/HN comments — recurring questions are blog gold. - **What content gaps exist on the blog?** Read the existing blog index. What's missing? What's stale? - **What's trending in the space?** Tie a project event to a broader industry moment. - **What does the project's audience care about that the project hasn't said yet?** The unstated opinion, the contrarian take, the thing the founder believes but never wrote down. ## Format the brainstorm doc ```markdown # Blog Topic Ideas — [Project] ## 1. [Topic title] **Angle:** [the take, the hook, why this matters now] **Tone:** [conversational / technical / contrarian / announcement] **Length:** [short / long / tutorial] **Why it works:** [1-2 sentences on what makes this post worth writing] ## 2. [Topic title] ... ``` Three to five options is the sweet spot. More than five becomes a menu the user has to wade through. ## Output When the user picks a topic, return: ```json { "status": "draft-ready", "artifact": { "doc_id": "<brainstorm doc>", "workspace_id": "..." }, "next_steps": ["/blog-writer beats"], "notes": "User picked topic #N: '<title>'. Hand off to beats mode — extract beat structure + lock title/preview/slug as B0." } ``` The next stage (`beats`) creates a per-post container with a `Beats — <Post Title>` doc, runs the query-first beat extraction (3-pass for short/announcement, 5-pass for long/tutorial), and locks title + preview + slug as B0 commitments. After beats lock, `draft` mode pours the prose. ## Anti-patterns - ❌ Returning a single topic ("Here's what I think you should write"). Brainstorm = options. - ❌ More than 5 ideas. Quality over volume. - ❌ Drafting the full post inside the brainstorm doc. Separate per-post container for beats + draft. - ❌ Extracting beats inside the brainstorm doc. Brainstorm is topic ideation only; beats live in their own doc inside a per-post container. - ❌ Skipping the `## Blog` config read — project terminology and category matter from the first idea. -
draft.md 10.4 KB
# Mode: Draft Pour prose against a locked Beats doc. Each beat dispatches to `/authors-voice` with the site-specific voice anchor. Output: the `<Post Title>` sibling doc (content_type: blog) in the post's container — the publishable doc. ## When to use - User says "draft this post" / "write the draft" / "pour the beats" / "blog draft" - `beats` mode just locked → handoff calls into here - Author reshaped a beat in the Beats doc and wants the corresponding draft section re-poured - Strategist provided a brief with locked beats + site_id ## Hard prereq: Beats doc must be locked Draft mode does NOT extract beats. If there's no `Beats — <Post Title>` doc with a locked beat list and B0 (title + preview + slug) block, **STOP** and run `/blog-writer beats` first. Pouring prose without committed beats produces shapeless drafts that need full structural rework downstream — cheaper to commit the beats first. Signs the beats aren't locked: - Beat names are categorical ("THE HOOK," "THE MECHANISM") instead of declarative claims - No word targets on beats - Title / preview / slug not yet in the B0 block - Beats doc has prose in it instead of outcome commitments Hand back to `beats` mode for any of those. ## Workflow ### Step 1: Resolve inputs Gather: 1. **Beats doc id** — from session context or by `search_docs` for `Beats — <Post Title>` 2. **Per-post container id** — read the Beats doc's parent container 3. **Sub-form** — read from Beats doc frontmatter (short / announcement / long / tutorial) 4. **Site_id + label** — read from Beats doc frontmatter (`site_id`) or `list_blog_sites` if user names the site 5. **Voice anchor path** — compute slug from site label, check `voice/anchor-<site-slug>.md`, fallback to `voice/anchor.md` (see [voice-anchor.md](voice-anchor.md)) If site isn't resolved, prompt user once with a list from `list_blog_sites`. Don't guess. ### Step 2: Create or open the Draft doc The Draft doc's title IS the published title — name it directly as the locked title from Beats B0, no prefix: ```js // New draft (use the locked title from Beats B0 verbatim): create_document({ title: "<locked title from Beats B0>", container_id: "<per-post container>", content_type: "blog" }) // Then populate as a stub (empty body — beats pour later): populate_document({ docId: draftDocId, content: "" }) ``` If a Draft doc already exists in the container (reshape case), use that one — don't create a sibling duplicate. Recognize it by: `content_type: blog` inside the post's container (only one such doc per container). ### Step 3: Mirror preview + slug + date to the Draft's blogContext Title is already on the doc's title field from Step 2. Mirror the rest of the B0 commitments + boilerplate: ```js set_metadata({ docId: draftDocId, metadata: { blogContext: { active: true, description: "<from Beats B0, 140-160 char>", slug: "<from Beats B0>", date: "<YYYY-MM-DD>", // today in project timezone tags: ["<from Beats frontmatter>"] // coverImage / coverImageAlt set later by images mode } } }) ``` Title is NOT set on blogContext — the publish plugin reads title from the doc title, not blogContext.title. See [titling.md](titling.md) for the reasoning. If the user reshapes title / preview / slug in the Beats doc post-draft, re-mirror on the next pour. Title reshape: `rename_item({ docId: draftDocId, name: "<new title>" })`. Preview / slug reshape: `set_metadata` again. ### Step 4: Pour prose, beat by beat For each beat in the Beats doc (in sequence): 1. Build the dispatch brief: ```js { task: "<beat outcome paragraph from Beats doc, verbatim>", voice_anchor_path: "voice/anchor-<site-slug>.md", voice_anchor_fallback: "voice/anchor.md", must_appear: [ "<author-unique phrases from beat>", "<callbacks to prior beats>" ], word_target: "<beat word target from Beats doc>", prior_beats: [ "<one-sentence summary of each beat already poured, in order>" ], beat_number: "B3", beat_name: "MIGRATION TAKES THREE COMMANDS", category: "MECHANISM" // from beat's category tag } ``` 2. Delegate to `/authors-voice` Apply Protocol with the brief as the TASK 3. Receive voice-matched prose 4. Append to the Draft doc as a new paragraph block, tagged with the beat number for the reshape loop 5. Update `prior_beats` summary for the next dispatch **Dispatch granularity:** | sub_form | Dispatch strategy | |---|---| | `short` (3-5 beats, <1000w total) | Single dispatch — all beats listed as commitments in one brief | | `announcement` (3-6 beats, <1200w total) | Single dispatch if <1000w; per-beat if heavier | | `long` (8-15 beats, 1500-3000w total) | Per-beat dispatch | | `tutorial` (8-12 beats, 1500-2500w total) | Per-beat dispatch; code blocks land as part of the relevant beat | Per-beat dispatch protects voice quality — a long, multi-claim brief collapses density across the post. The book-writer empirical lock (one beat = one dispatch produces gold prose; multi-beat dispatches collapse) applies at blog scale too. ### Step 5: Cross-beat coherence pass After all beats have poured: 1. Read the full Draft doc 2. Check transitions between beats — does B3 close the question B2 opened? Does B4 open a question B5 answers? 3. Check the open/close loop — does the final beat's payoff close the tension the title + B1 opened? 4. Check for repetition — did two beats land the same claim with different prose? 5. Patch transitions or duplications via `/authors-voice` minion calls (small targeted dispatches), not by rewriting If the coherence pass surfaces a structural problem (a missing beat, an out-of-order beat), STOP. Don't patch in prose. Return to `beats` mode, reshape, re-pour the affected beats. ### Step 6: Date handling Set `blogContext.date` to today in the project's configured timezone (default: `America/Los_Angeles`). Format: `YYYY-MM-DD`. The publish plugin formats / renames per site config (`date → publishedDate` for Astro sites). ### Step 7: Output ```json { "status": "draft-ready", "artifact": { "draft_doc_id": "<draft doc>", "beats_doc_id": "<beats doc>", "container_id": "<per-post container>", "workspace_id": "...", "site_id": "<site uuid>", "voice_anchor_used": "voice/anchor-recipebox.md" }, "next_steps": ["/blog-writer images", "/blog-writer integrate"], "notes": "Draft poured, N beats integrated, voice-anchored to <site-label>. User should review in OpenWriter and Accept All before integrate." } ``` ## Reshape loop When the author reshapes a beat in the Beats doc, draft mode re-pours ONLY the affected beat(s), not the whole post. Flow: 1. Author edits B5 in the Beats doc (renames the claim, swaps the category, adjusts word target) 2. User says "re-pour B5" or "redraft B5" 3. Draft mode reads the new B5 commitment, builds a dispatch brief with `prior_beats` reflecting B1-B4 from the existing Draft 4. Single `/authors-voice` dispatch 5. Replace B5's paragraph in the Draft doc (find by beat-number tag from Step 4 of original pour) 6. Re-run cross-beat coherence on neighbors (B4 → B5 transition, B5 → B6 transition); patch transitions if needed If the author reshapes B5 AND inserts a new B6 between old B6 (now B7), re-pour B6 and re-pour the new B7. Don't re-pour B1-B4 or B8+. If multiple beats reshape in a single editing session, batch the re-pours but keep them per-beat — don't collapse into a multi-beat dispatch. ## Voice composition Every beat pour runs through `/authors-voice`. Two paths: - **Preferred:** `/authors-voice` Apply Protocol via Skill invocation. Reads the site-specific anchor, runs the minion, returns voice-matched prose, runs post-write audit (NEVER patches, fingerprint check). This is the spec. - **Fallback for non-OpenWriter workflows:** OpenWriter's built-in Author's Voice "Enhance" plugin — API-backed, full corpus RAG. Operates on the Draft doc directly after a non-voice draft pour. Use only when running the skill standalone outside the per-beat dispatch flow. The voice layer eliminates AI tells, enforces register, locks down diction. The drafting skill owns SHAPE; voice owns VOICE. ## What lives where (per-post container layout) ``` <Post Title>/ (container — named after the post) ├── Beats — <Post Title> (beats mode output — content_type: notes) ├── <Post Title> (THIS doc — the publishable post — content_type: blog) └── Sources — <Post Title> (optional — content_type: notes) ``` The Draft doc's TITLE is the post's locked title (no prefix). That's what the publish plugin reads as the published title. The Beats and Sources docs are visually distinguished by the `Beats — ` / `Sources — ` prefix, and structurally distinguished by their `content_type` (anything other than `blog`). The Draft doc is identified inside the container by `content_type: blog` — the only such doc per container. Beats and Draft are SEPARATE docs by firm rule. Beats reshape doesn't touch Draft directly; only this mode re-pours the affected sections. Sources doc is optional — most posts let the model's training data carry the examples. Build a Sources doc only when (a) post cites specific URLs / papers / quotes the author wants pinned, (b) topic is post-training-cutoff (recent data the model can't be trusted on), or (c) author wants Smith 2019 specifically, not Jones 2020. ## Anti-patterns - ❌ Pouring prose without locked beats. Run `beats` mode first. - ❌ Pouring all beats in one mega-dispatch for `long` posts. Voice density collapses across multi-beat dispatches. - ❌ Writing prose into the Beats doc instead of the Draft doc. The two-doc split is load-bearing. - ❌ Re-pouring the whole draft when one beat reshaped. Re-pour the affected beat only. - ❌ Using a single global voice anchor when a site-specific one exists. Read [voice-anchor.md](voice-anchor.md) — discovery is conventional, not opt-in. - ❌ Writing `---\nfrontmatter\n---` into the Draft doc body. Use `set_metadata({blogContext})`. - ❌ Site-wide constants (`layout`, `author`, `prerender`) in `blogContext`. Those live on the site's `frontmatter_defaults` (set during `/blog-writer setup`). - ❌ Generating images mid-draft. Images come AFTER draft is approved — the visual concept depends on the locked angle. - ❌ Skipping the cross-beat coherence pass. The post reads as a sequence; verify the sequence lands before declaring done. - ❌ Patching a structural problem with prose. Structural problems return to `beats` mode. -
images.md 8.1 KB
# Mode: Images Generate the featured (cover) image and any inline body images for a blog post. Primary path is the openwriter `insert_image` MCP tool — places the image directly in the active doc (or onto disk for cover wiring), so the github plugin's `post_to_blog` finds it automatically. ## When to use - User says "blog image" / "featured image" / "OG image" / "cover image" / "inline image for the post" - Pipeline mode is on Step 2 - User has an approved draft and needs the visual ## Requirements - `GEMINI_API_KEY` set on the openwriter server (the plugin falls back to the publish platform API if absent) - Active OpenWriter blog doc OR `set_metadata` access if generating a cover for a non-active doc - Project SHOULD have a `style_doc` in `## Blog` config — used to pick a consistent visual style across the site. Optional but recommended. ## Two image roles | Role | Where it lands | How to set | |---|---|---| | **Cover** (OG card / hero) | `blogContext.coverImage` on the doc; published as `coverImage` (or whatever the site renames it to) in frontmatter | `insert_image` with no `docId` → returns path → `set_metadata({blogContext: {coverImage, coverImageAlt}})` | | **Inline** (body image at a specific point) | Becomes an `<img>` node in the doc body at `afterNodeId` | `insert_image` with `docId` + `afterNodeId` | There's also a third mode — `insert_image` with `set_cover: true` — but that sets `articleContext.coverImage` (designed for `/x-writer` articles). For blog posts, use the path-return + explicit `blogContext.coverImage` set instead. ## Workflow ### Step 1: Read style guidance If the project's `## Blog` config has `style_doc`, read it. Style docs typically have: - Visual categories + decision tree - Tone matrix (matching the post's angle to a style) - Prompt templates per style - Project-specific rules (safe zones, no-faces, etc.) - History table of previously used images (skip styles used in the last 3 posts) If no `style_doc`, default to: "clean modern illustration; soft palette appropriate to the site's brand; 16:9 cover, 16:9 inline; no text on image." **OG placement rules — any cover that carries text obeys these placement rules:** no text at the bottom (the platform renders og:title there → collision), ≤5–7 words, don't echo the title/description, keep the subject in the center safe zone, must read in 1–2s at thumbnail. **Text-overlay covers** (photo+text styles) are composited with Sharp: generate the *no-text* base via `insert_image`, then overlay text from a `C:/tmp/` working file (left/center-left, never bottom) and move to the project only on approval. Pure no-text covers (Style A/B/C) skip tmp and ride the `insert_image` path below. **Canonical output size: 1200×630 (1.905:1).** The blog card hard-codes this ratio (`aspect-ratio: 1200/630`); the post page shows the image uncropped. Gemini's widest is 16:9, so generate at `aspect_ratio: "16:9"` then Sharp `fit:"cover"` to exactly 1200×630 (trims top+bottom of the AI frame — keep the subject centered). Honor a `feature_image_size` override from the project's `## Blog` config. **Do not trust OpenWriter's article-cover preview for spacing** — it clips to ~2.5:1; the real blog post page renders the full 1200×630. ### Step 2: Analyze content (optional) If `content_driven: true` in the project's `## Blog` config, read the post via `read_pad` first. Extract the ONE concept that would make someone scroll-stop and click. The cover prompt should land that concept visually. ### Step 3: Craft the prompt Universal rules (apply to every prompt regardless of project): - 2–4 sentences, focused - Specific scene description over abstract concepts - Include lighting details — they drive mood more than anything else - End with "No text, no watermarks, no logos" - Never say "leave [area] empty" — Gemini interprets that as "draw a literal box" - Money in word form: "Five Hundred Dollars" not "$500" ### Step 4: Generate the cover ```js const { src } = await insert_image({ prompt: "<crafted prompt>", aspect_ratio: "16:9" // no docId, no set_cover — generates to disk and returns path }); // src is like "/_images/9c69e9b0.png" ``` Then wire it to the active doc's blogContext: ```js set_metadata({ docId, metadata: { blogContext: { coverImage: src, coverImageAlt: "<descriptive alt text — what's in the image, why it's relevant>" } } }) ``` ### Step 5: Generate inline images (optional) For each inline image: ```js insert_image({ prompt: "<crafted prompt>", docId, // 8-char hex from the active doc afterNodeId: "<node id>", // from read_pad output, place image after this paragraph alt: "<descriptive alt text>", aspect_ratio: "16:9" }) ``` The image lands in the doc body as a pending decoration. **Tell the user to accept it in the Review tab before invoking integrate** — see the integrate mode's Step 3 gotcha. ### Step 6: Review Show the user the generated images in the openwriter UI (or via Claude_in_Chrome screenshot). Iterate prompts until approval. Generated images often miss the mark on the first try; budget for 2–3 regenerations. ### Step 7: Update style history (optional) If using a `style_doc` with a history table, add an entry: post title, style used, date. Keeps the next post from repeating. ## Image format notes The github plugin copies PNGs into the repo unchanged. If the target site insists on `.webp` for performance, run sharp conversion before publish — or set up the site's build pipeline to handle PNG → WebP at build time (preferred). The plugin doesn't convert formats. ```bash # If you must pre-convert node -e "require('sharp')('<src.png>').webp({quality:82}).toFile('<dst.webp>').then(()=>{})" ``` ## Standalone CLI fallback For non-OpenWriter workflows (legacy projects, scripted batch image generation, projects that don't use the github plugin): ```bash node <path-to-image-gen-cli>/cli.bundle.js \ -p "[PROMPT]" \ -o /c/tmp/blog-image.png \ -a "16:9" ``` This was the v0.3.x path. The image lands in `/c/tmp/`, then a manual file copy + frontmatter edit wires it into the project. The new path via `insert_image` skips all that — image lands in OpenWriter and rides through `post_to_blog` to the repo. ## Style libraries Each project can have its own style doc (anywhere on disk — point to it via `style_doc` in the project's `## Blog` config) with: - Visual categories and decision tree - Tone matrix - Prompt templates per style - Project-specific rules - History table ### Example | Project | Style doc | |---|---| | RecipeBox | `<your style library dir>/recipebox.md` | To add a new project's style doc: create the file with the same structure and reference it in the project's `## Blog` config under `style_doc`. ## Output ```json { "status": "draft-ready", "artifact": { "doc_id": "<blog draft doc>", "workspace_id": "...", "cover_image_path": "/_images/<filename>.png", "inline_image_paths": ["/_images/<filename>.png", "..."] }, "next_steps": ["/blog-writer integrate"], "notes": "Cover wired to blogContext.coverImage. Inline images placed as pending decorations — tell the user to Accept All in the right rail before publishing." } ``` ## Anti-patterns - ❌ Saving the image to `/c/tmp/` and manually copying it into the target repo — that's the old v0.3 path; the new flow puts images directly in OpenWriter and lets `post_to_blog` handle copy + path rewrite - ❌ Using `insert_image({set_cover: true})` for blog covers — it writes to `articleContext.coverImage` (article skill's field), not `blogContext.coverImage`. The plugin reads `blogContext` only. - ❌ Setting `coverImage` to an absolute filesystem path or external URL — `post_to_blog` only knows how to rewrite `/_images/...` references. External images must be downloaded into `~/.openwriter/profiles/<profile>/_images/` first. - ❌ Forgetting to set `coverImageAlt` — accessibility + the site layout often shows alt text as a caption fallback - ❌ Inline images without `afterNodeId` — won't insert into the body - ❌ Generating an image before content is approved — angle might shift - ❌ Reusing a style from the last 3 posts — history table exists for a reason -
integrate.md 9.3 KB
# Mode: Integrate Publish the active OpenWriter blog doc to a registered blog repo. One MCP tool call (`post_to_blog`) handles the whole flow: clone-or-refresh the repo, build clean frontmatter from `blogContext` + site `frontmatter_defaults`, copy referenced images to the site's `image_dir`, rewrite `/_images/...` paths to public URLs, commit, push. ## Source of truth + idempotency (read first) The OpenWriter doc is the **single source of truth** and `post_to_blog` is the **only writer** to the blog repo. The flow is always **doc → Accept pending → Publish**. Never hand-edit the published `.md`, never hand-set its `image:` frontmatter, never drop a cover into the repo's `public/`, and never `git push` the post yourself — each creates a doc-vs-repo drift that the next Publish silently overwrites (it rewrites `<content_dir>/<slug>.md` wholesale), so your hand-edits vanish and the cover can orphan. Republish is idempotent **by slug**: same `slug` → same target file, overwritten in place, never a duplicate. (Deterministic cover *naming* — `og-{slug}` — and per-site image *path-style* are being standardized in the plugin; until that lands, make `blogContext.coverImage`'s filename already match the site's convention, e.g. `og-{slug}.png`, so Publish reproduces the expected path. Verify the emitted `image:` matches the site's other posts — leading-slash vs not — before trusting a republish.) ## When to use - User says "integrate" / "publish" / "post to blog" / "wire up the post" / "ship the post" - Pipeline mode is on Step 3 - Draft is approved in OpenWriter and any cover/inline images are accepted ## Requirements - Target blog repo registered via `add_blog_site` (run `/blog-writer setup` first if not) - Active OpenWriter doc with `content_type: blog` (or `blogContext.active: true`) - Doc title set - `gh auth login` working - **All pending agent decorations accepted** — see the gotcha below ## Workflow ### Step 1: Find the site ```js const sites = await list_blog_sites(); const site = sites.find(s => s.label === "<target>") || sites[0]; ``` If multiple sites and the user didn't name one, ask. If zero, redirect to `/blog-writer setup`. ### Step 2: Make sure the doc is on `blogContext`, not in prose The plugin builds frontmatter from `metadata.blogContext` only — top-level fields are ignored intentionally to prevent openwriter-internal leaks. Verify or set: ```js set_metadata({ docId, metadata: { blogContext: { active: true, description: "<140-160 char SEO description>", date: "<YYYY-MM-DD>", // plugin maps to publishedDate if site requires tags: ["<category1>", "<category2>"], // real categories, not the "blog" content-type marker author: "<override site default if needed>", slug: "<filename-slug-without-md>", coverImage: "/_images/<filename>.png", // path returned by insert_image coverImageAlt: "<alt text>" } } }) ``` Fields NOT to set on `blogContext`: - `layout`, `prerender`, `authorImage` — site-wide constants; live in `frontmatter_defaults` (set during `setup`) - `title` — comes from the doc title, not blogContext ### Step 3: Accept any pending agent decorations **This is the gotcha that bit the first E2E test.** `post_to_blog` reads the canonical doc on disk via `getDocument()` and `tiptapToMarkdown()`. Agent-inserted images and rewrites land as pending decorations in the in-memory overlay until the user accepts them via the right-rail Review tab. Until accepted, they're NOT on disk and the published post won't include them. Symptoms: - You inserted an inline image via `insert_image` → `images_committed: 0` after `post_to_blog` - You rewrote a paragraph → published post still shows the original text Fix today: tell the user to click "Accept All" (or hit Shift+A) in the right-rail Review tab. Then `post_to_blog`. Confirm via `read_pad` that the body matches what's in the OW UI. **Don't try to "auto-accept" via `POST /api/auto-accept`** — its `stripPendingAttrs()` clears the overlay rather than committing it to canonical, so you'll lose the agent's writes instead of publishing them. Cover images set via `set_metadata({ blogContext: { coverImage } })` are NOT pending — they persist to disk immediately because `set_metadata` writes through. Only body-level decorations (inserts, rewrites, deletes) go through the pending overlay. ### Step 4: Publish ```js post_to_blog({ site_id: "<uuid from list_blog_sites>", slug: "<optional — defaults to blogContext.slug or slugified title>", commit_message: "<optional — defaults to 'blog: {title}'>" }) ``` Plugin does: 1. Clone-or-refresh the repo at `~/.openwriter/_blog-clones/<site_id>/` 2. Build frontmatter from `site.frontmatter_defaults` → `title` → `blogContext` (with `frontmatter_field_map` renames applied) → cover image path rewritten to public prefix 3. Strip frontmatter from `tiptapToMarkdown` output (the openwriter-internal JSON frontmatter never ships) 4. Rewrite every `/_images/<filename>` in the body to `<image_public_prefix>/<filename>` 5. Copy referenced images (inline + cover) from `~/.openwriter/profiles/<profile>/_images/` into `<repo>/<image_dir>/` 6. Write the post file at `<repo>/<content_dir>/<slug>.md` 7. `git add -A && git commit -m <message> && git push origin <branch>` Returns: ```json { "success": true, "file": "src/pages/blog/<slug>.md", "commit": "<short hash>", "images_committed": 2, "live_url": "https://<site>/blog/<slug>/", "message": "Pushed to <owner>/<repo>@<branch>" } ``` `live_url` is included when the site has `site_url` + `blog_url_pattern` configured (set during setup). Same call also writes `blogContext.lastPublish` on the doc — file-tree right-click then shows a green ✓ badge + "View Post" menu item that opens this URL. Standard mark-sent convention shared with tweets / articles / newsletters. ### Step 5: Verify If `images_committed` is less than expected, check Step 3 — pending decorations likely weren't accepted before publish. Cat the file in the local clone to confirm: ```bash cat ~/.openwriter/_blog-clones/<site_id>/<content_dir>/<slug>.md ``` The frontmatter should be clean — site defaults at the top, then `title` + `blogContext` fields (after rename). No `status`, no `enrichmentStale`, no `tags: [blog]`, no `slug` duplicate if the site doesn't use it. Also confirm the doc was marked as sent. `get_metadata({docId})` should now return: ```json "blogContext": { ..., "lastPublish": { "publishedAt": "<ISO>", "publishedUrl": "<live URL>", "commit": "<short hash>", "file": "<repo-relative path>" } } ``` If `lastPublish` is missing, either the writeback failed (surface the `warning` field from the post_to_blog response) **or a later `set_metadata({ blogContext: {...} })` shallow-replaced the object and wiped it** — `set_metadata` replaces a nested object wholesale, so always spread the existing `blogContext` (including `lastPublish`) when updating it, or update only the leaf key you mean to change. The publish itself still landed; the file-tree just won't show the sent badge or "View Post" item until the link is restored (re-publishing rewrites it). ### Step 6: Wait for auto-deploy Most static sites deploy on push (Netlify, Cloudflare Pages, Vercel) — usually 1–3 min for an Astro/Next build. If the site has a custom deploy pipeline (build step on a server, manual approval), hand off to your project's deploy pipeline instead. Verify the URL returns HTTP 200: ```bash curl -sS -o /dev/null -w "HTTP %{http_code}" -L --max-time 15 "<site_url>/<blog_url_pattern with slug>" ``` For the live render check (does the post look right? did the cover land? does the layout pick up the new tags?), open the URL in the user's chrome via the Claude_in_Chrome MCP. ## Output ```json { "status": "draft-ready", "artifact": { "doc_id": "<openwriter doc>", "workspace_id": "...", "site_id": "<site uuid>", "commit": "<short hash>", "file": "<repo path>", "live_url": "<projected URL — verify after deploy>" }, "next_steps": ["announce (your own channels)"], "notes": "Published to <owner>/<repo>@<commit>. Site auto-deploys on push; verify live URL in ~2 min." } ``` ## Anti-patterns - ❌ Manually writing the post .md file into the target repo via Write/Edit tools — skips frontmatter assembly, image copy, path rewrite, and the per-site config - ❌ Stuffing site-wide constants (`layout`, `author`, `prerender`) into `blogContext` instead of `frontmatter_defaults` — they'll write into every post and drift across the site - ❌ Publishing immediately after `insert_image` without prompting the user to accept the pending decoration — image won't ship - ❌ Calling `/api/auto-accept` to "clear pending" — that function strips the overlay, dropping the agent's writes instead of committing them - ❌ Manually `git push`-ing from the local clone after `post_to_blog` already pushed — the plugin owns the commit + push, double-pushing creates noisy history - ❌ Setting frontmatter directly on top-level metadata (e.g. `metadata.author`) — the plugin reads only `metadata.blogContext`; top-level is ignored to keep openwriter-internal fields out of the published frontmatter - ❌ Auto-committing the new files in the target repo — the plugin already committed them; touch them only if a follow-up edit is genuinely needed -
pipeline.md 9.2 KB
# Mode: Pipeline End-to-end blog post creation. Runs every mode in sequence with verification gates between stages. ## When to use - User says "blog pipeline" / "full blog workflow" / "write and publish" / "new blog post end to end" - Strategist hands off with sub-form set and angle locked — wants the whole flow autonomously ## Sequence | Step | Mode | Action | Gate | |---|---|---|---| | 0 | `setup` (one-time) | Register the blog repo with the github plugin if not already | `list_blog_sites` includes target | | 1 | `brainstorm` (optional) | Ideate topics in OpenWriter | User picks a topic | | 2 | `beats` | Extract beat list + lock title / preview / slug | User locks the Beats doc | | 3 | `draft` | Pour prose per-beat via `/authors-voice` with site-specific anchor | User approves the Draft doc | | 4 | `images` | Generate cover + any inline body images | User approves images | | 4.5 | (manual) | User accepts pending decorations in right-rail Review tab | `pending: 0` on the Draft doc | | 5 | `integrate` | `post_to_blog` against the registered site — builds frontmatter, copies images, commits + pushes | `images_committed` matches, commit hash returned | | 6 | (auto-deploy verify) | Wait ~1–3 min for Netlify/CF/Vercel build, then verify live URL returns HTTP 200 | HTTP 200 + visual check | Step 0 only runs the first time the user publishes to a given repo. Step 1 is optional — skip if the user already has a locked topic. ## Entry points Users can enter at any step: - **"Set up my blog repo"** → Step 0 - **"Brainstorm blog topics"** → Step 1 - **"Extract beats for this post"** → Step 2 - **"Pour the draft"** → Step 3 (requires locked Beats doc) - **"Generate a cover image"** → Step 4 - **"I have the content + image, publish"** → Step 4.5 (if pending) → Step 5 - **"Blog is ready, push it"** → Step 5 - **"Did the post land?"** → Step 6 Detect the entry point from what the user provides and what already exists in OpenWriter + the github plugin's site list. ## Step 0: Setup If `list_blog_sites` doesn't include the target repo, hand off to [setup mode](setup.md). One-time per blog. After setup, skip to Step 1 (or further if topic is already in hand). ## Step 1: Brainstorm (optional) Run [brainstorm mode](brainstorm.md) if the user doesn't have a topic locked. Produces a Blog Ideas doc with 3-5 candidate topics. User picks one. **Gate:** User says "this one" — picks a topic. Skip Step 1 entirely if the user already has the topic in hand. ## Step 2: Beats Run [beats mode](beats.md). Create per-post container, create `Beats — <Post Title>` doc, run the 3-pass or 5-pass extraction (sub-form dependent), lock title + preview + slug as B0. **Gate:** Beats doc reads top-to-bottom as a clear flow. Title + preview + slug are committed. User explicitly approves the structure. The beats step is where the post's shape gets locked. Don't skip it — pouring prose without committed beats produces shapeless drafts that need full structural rework downstream. The 3-pass for `short` / `announcement` is fast (5-10 min); the 5-pass for `long` / `tutorial` is the bulk of the editing work and pays for itself on every reshape. ## Step 3: Draft Run [draft mode](draft.md). Create the `<Post Title>` sibling doc (titled with the locked title from Beats B0, `content_type: blog`), resolve voice anchor (`voice/anchor-<site-slug>.md` or fallback), pour prose beat-by-beat via `/authors-voice` Apply Protocol. Cross-beat coherence pass after all beats land. Mirror preview + slug from Beats to Draft `blogContext` (title is already on the doc title field, not blogContext). **Gate:** User approves the draft. Iterate via the reshape loop (below) if structural issues surface. Don't proceed until they explicitly approve — image generation hangs on the final angle, and the published frontmatter freezes the description. ## Step 4: Images Run [images mode](images.md). Read the style doc, check history, generate cover via `insert_image` + wire to `blogContext.coverImage`. Optionally generate inline images placed after specific paragraphs. **Gate:** User approves the images. Iterate prompts until approval. ## Step 4.5: Accept pending Tell the user: *"I've inserted N images / wrote M paragraphs. Click Accept All (or Shift+A) in the right-rail Review tab so they commit to canonical before I publish — `post_to_blog` reads the canonical doc, not the pending overlay."* Wait for the user to confirm. Then verify: ```js const status = await get_pad_status({ docId: draftDocId }); // or scan read_pad output for "pending: 0" ``` If still pending, prompt again. Do not proceed until clean. **Why this matters.** `post_to_blog` reads `srv.getDocument()` which returns the on-disk canonical doc. Agent-pending decorations (inserts from `insert_image`, rewrites from `write_to_pad`) live in the in-memory overlay until accepted. Skip this step and you'll publish a post missing your latest agent writes. ## Step 5: Publish Run [integrate mode](integrate.md): - Find the site_id via `list_blog_sites` - `post_to_blog({site_id, commit_message: "blog: <title>"})` against the Draft doc **Gate:** `success: true` returned, `images_committed` count matches expectation, commit hash present. If `images_committed` is 0 when you expected images, fall back to Step 4.5 — the user didn't accept the pending decorations. ## Step 6: Verify deploy Most static sites auto-deploy on git push (Netlify, Cloudflare Pages, Vercel). Wait ~1–3 min for the build. Construct the verification URL: ``` {site_url}{blog_url_pattern with slug} ``` Both fields come from the github plugin's per-site config (set during `/blog-writer setup` from `inspect_blog_repo` proposals; CNAME-derived). Defaults: assume `https://<owner>.<framework_default>/blog/<slug>/` if not configured. ```bash curl -sS -o /dev/null -w "HTTP %{http_code}" -L --max-time 15 "<url>" ``` For a real visual check (cover landed, tags pill correctly, inline image renders), open the URL in the user's chrome via Claude_in_Chrome MCP and screenshot. **Gate:** HTTP 200 + visual confirmation. For projects with a custom deploy pipeline (build server, manual approval), hand off to your project's deploy pipeline instead of waiting on auto-deploy. ## Reshape loop (inner cycle) The reshape loop is the heart of the architecture. Beats reshape regularly during Step 3 (and sometimes Step 4 / Step 5 when reading the post in context surfaces a structural problem). The flow: 1. Author reads the Draft doc, flags "B3 doesn't land" / "B5 should come before B4" / "kill B7" 2. **Return to Step 2 (Beats)** — reshape the Beats doc (rename, reorder, drop, add) 3. **Re-run Step 3 (Draft) for affected beats only** — per-beat dispatch via `/authors-voice` with the unchanged site anchor; replace the affected paragraph in the Draft doc 4. Cross-beat coherence patch on neighbors of the reshaped beat(s) 5. Author re-reviews; loop until approved The two-doc Beats + Draft split makes the reshape loop cheap. Each loop iteration is one Beats edit + one or two beat re-pours, not a full rewrite. If reshape iterations stack up (5+ loops on one post), STOP and reconsider — the post's premise may not be working; return to Step 1 brainstorm. ## Progress reporting After each step, report what completed: ``` Step 0/6 - Setup: Registered RecipeBox (yourname/recipebox-website) Step 1/6 - Brainstorm: User picked topic "Stripe Connect is a one-way door" Step 2/6 - Beats: 12 beats locked, B0 title/preview/slug committed (Beats doc: bb4f6c46) Step 3/6 - Draft: 12 beats poured, voice anchor voice/anchor-recipebox.md, draft approved (Draft doc: d8a1f203) Step 4/6 - Images: Cover + 1 inline image generated, wired to blogContext Step 4.5/6 - Accept: User accepted pending decorations Step 5/6 - Publish: Commit 16413ed pushed to main, 2 images committed Step 6/6 - Deploy: Live at https://recipebox.example.com/blog/weekly-meal-plans/ (HTTP 200, visual confirmed) ``` If a gate fails, stop and report what blocked. ## Output ```json { "status": "draft-ready", "artifact": { "beats_doc_id": "...", "draft_doc_id": "...", "container_id": "...", "workspace_id": "...", "site_id": "<uuid>", "commit": "<short hash>", "live_url": "<verified URL>" }, "next_steps": ["announce (your own channels)"], "notes": "Pipeline complete. Live at <url>. Ready for announcement." } ``` If deploy hand-off is the final step (custom deploy pipeline), the pipeline returns after Step 5 with a manual-deploy note in `next_steps`. ## Anti-patterns - ❌ Skipping Step 2 (Beats) and jumping straight to Step 3 — produces shapeless drafts; reshape loop becomes a full rewrite - ❌ Skipping a gate ("user probably approves") — every gate is explicit confirmation - ❌ Running steps in parallel — each step's output feeds the next - ❌ Skipping Step 4.5 — publishes a post with stale body / missing images and you have to do it twice - ❌ Re-pouring the entire draft when one beat reshaped (Step 3 reshape loop) — re-pour only the affected beat(s) - ❌ Auto-deploying without explicit "push it" approval when there's a custom deploy pipeline — see global CLAUDE.md "Never Push Without Explicit Approval". (`post_to_blog` IS a push, but it's to the blog content repo, not a production deploy — for static sites with auto-deploy that's the same thing. Verify before assuming.) -
project-config.md 4.6 KB
# Reading the `## Blog` config As of v0.4.0, frontmatter shape (`content_dir`, `image_dir`, `post_format`, the actual frontmatter fields) lives on the github plugin's per-site config in `~/.openwriter/config.json`, set during `/blog-writer setup`. The project's `## Blog` section is now optional — it carries writing rules and image style guidance, NOT publish mechanics. ## What still belongs in `## Blog` Fields the blog-writer skill (modes `beats`, `draft`, `images`, optionally `pipeline`) still reads from the project's CLAUDE.md `## Blog` section: ```yaml # Writing writing_rules: docs/writing-style.md # optional — overrides default voice/style rules author: "Alex Carter" # default author byline (can be overridden per-post via blogContext) timezone: America/Los_Angeles # for date defaults # Images style_doc: docs/blog-image-styles.md # path to your image style library doc content_driven: true # if true, images mode reads the post first for concept analysis aspect_ratio: 16:9 # default for blog covers image_format: png # png | webp — webp triggers sharp conversion before publish # Deploy verification site_url: https://example.com # base URL for the live-URL HTTP 200 check blog_url_pattern: /blog/{slug}/ # URL pattern to construct the post's live URL ``` Project config is now lightweight — five-ish fields at most. The heavy lifting (content_dir, frontmatter, image_dir, public prefix, framework) lives in the github plugin's per-site config. ## What moved OUT of `## Blog` These fields used to live in project config (v0.3.x) but are now set during `/blog-writer setup`: | Old field (v0.3) | New home (v0.4+) | |---|---| | `content_dir` | `add_blog_site({content_dir})` | | `image_dir` | `add_blog_site({image_dir})` | | `image_public_prefix` (implicit) | `add_blog_site({image_public_prefix})` | | `post_format` | `add_blog_site({framework})` (`astro` / `next` / `jekyll` / `hugo` / `unknown`) | | `image_naming` (filename pattern) | n/a — the plugin uses openwriter's `/_images/<hash>.png` style, no per-site renaming | | `registries` (registry update list) | n/a — Astro/Next/Hugo/Jekyll auto-discover posts from `content_dir`; no separate registry to update | If you find a project still using the v0.3 schema, you can leave it alone or migrate by running `/blog-writer setup` — the per-site config takes precedence; project config gets ignored for those fields. ## Required vs optional All `## Blog` fields are now optional. Defaults apply if missing: | Field | Default | |---|---| | `writing_rules` | None — voice + style live in `/authors-voice` and the site's voice anchor (see [voice-anchor.md](voice-anchor.md)); writing_rules only overrides for project-specific terminology or rhetorical patterns | | `author` | None — `blogContext.author` per post, or site's `frontmatter_defaults.author` | | `timezone` | `America/Los_Angeles` | | `style_doc` | None — `images` mode falls back to generic "clean modern illustration" prompt | | `content_driven` | `false` | | `aspect_ratio` | `16:9` | | `image_format` | `png` | | `site_url` + `blog_url_pattern` | None — pipeline mode skips Step 4 (deploy verify) without these | The skill should NOT block on missing `## Blog` — it should fall through to defaults and proceed. The hard requirement is that the target blog repo is registered via `add_blog_site`, not that the project has a `## Blog` section. ## Adding a new project Two steps: 1. **Per-blog setup (one-time):** `/blog-writer setup` — registers the GitHub repo with the github plugin. See [setup.md](setup.md). 2. **Per-project config (optional, recommended):** add a `## Blog` section to the project's CLAUDE.md with the fields above. Especially helpful: - `style_doc` — for visually consistent image generation - `writing_rules` — for project-specific voice - `site_url` + `blog_url_pattern` — for deploy verification If the project has neither, the skill still works — defaults take over. ## Multi-blog projects If a single project publishes to multiple blog repos (e.g. company main blog + engineering blog), register each repo with `add_blog_site` using distinct labels. Pipeline mode prompts the user to pick when multiple sites are registered. The project's `## Blog` config is still single-shape — it applies to all blogs the project publishes to. For per-blog style differences, use separate `style_doc` files referenced from inside the writing-rules doc, or rely on the per-site `frontmatter_defaults` to encode site-specific constants. -
setup.md 7.1 KB
# Mode: Setup One-time-per-blog-repo registration with the openwriter github plugin. After setup, `integrate` can publish to this repo with a single `post_to_blog` call. ## When to use - User says "set up blog repo" / "register blog site" / "add my blog" - `list_blog_sites` returns no entry for the target repo before publishing - Onboarding a new project onto the github plugin pipeline ## Requirements - openwriter MCP server running, github plugin enabled - `gh auth login` set up on the user's machine (the plugin uses the user's local gh credentials; no PATs) - The target repo must exist on GitHub and the user must have push access ## Workflow ### Step 1: Confirm the target Get the GitHub URL or `owner/repo` shorthand from the user. Examples: - `yourname/recipebox-website` - `https://github.com/acme/blog` Reject anything that doesn't parse to `owner/repo`. ### Step 2: Inspect Call `inspect_blog_repo` with the URL. The tool: 1. Clones the repo shallow to `~/.openwriter/_blog-inspect-cache/` 2. Detects framework (`astro` / `next` / `jekyll` / `hugo` / `unknown`) from config files 3. Finds the directory with the most markdown files → proposes `content_dir` 4. Reads up to 10 sample posts' frontmatter 5. **Auto-proposes `frontmatter_defaults`** — fields present in EVERY sample with the same value (constants the site relies on, e.g. `layout`, `author`, `prerender`) 6. **Auto-proposes `frontmatter_field_map`** — if the site uses `publishedDate` or `pubDate` instead of standard `date`, suggests the rename `{date: "publishedDate"}` 7. **Auto-proposes `site_url`** — scans `CNAME` / `public/CNAME` / `wrangler.toml routes`, then falls back to the **GitHub Pages API** (credential-free via `gh`). If it still can't resolve — common for Netlify / Vercel sites whose domain lives only in the host dashboard — it returns **`needs_site_url: true`** + a hint instead of a value. That flag is your cue to ask the user, not ship a post with a dead "View Post" link. 8. **Auto-proposes `blog_url_pattern`** — defaults to `/blog/{slug}/` (the convention almost every Astro/Next/Hugo blog uses). User can override. 9. Filters out files that look like prior leaked-openwriter posts (have `enrichmentStale`, `tags: [blog]`, or `status: draft` + ISO-with-time `date`) — those would pollute the constants detection Returns: ```json { "owner": "...", "repo": "...", "framework": "astro", "content_dir": "src/pages/blog", "image_dir": "public/blog-images", "image_public_prefix": "/blog-images", "frontmatter_schema": ["layout", "title", "description", "publishedDate", "author", "authorImage", "coverImage", "coverImageAlt", "tags", "prerender"], "frontmatter_defaults": { "layout": "../../layouts/BlogPost.astro", "author": "...", "authorImage": "/avatars/...svg", "prerender": true }, "frontmatter_field_map": { "date": "publishedDate" }, "site_url": "https://recipebox.example.com", "blog_url_pattern": "/blog/{slug}/", "samples_analyzed": 7, "samples_skipped_openwriter_leak": 0, "markdown_files_found": 7, "confidence": "high" } ``` ### Step 3: Show the proposal to the user Present the inspection result. Highlight: - **Framework + content_dir** — confirm these match where the user expects new posts to land - **frontmatter_defaults** — the constants every published post will get - **frontmatter_field_map** — any rename (e.g. Astro's `publishedDate`) - **site_url** — if proposed, confirm it's the public hostname; if `needs_site_url: true`, **ask the user for it before adding the site** — without it every published post gets a dead "View Post" link (`post_to_blog` only returns `live_url` when `site_url` + `blog_url_pattern` are both set) - **blog_url_pattern** — default `/blog/{slug}/` works for most sites; ask if the site uses something else (`/posts/{slug}`, `/blog/{slug}` without trailing slash, etc.) - **samples_skipped_openwriter_leak** — if non-zero, mention there are stale openwriter-format posts in the repo the user may want to clean up before they pollute future inspections If `confidence: low` or `samples_skipped_openwriter_leak > 0`, slow down and verify with the user before adding the site. ### Step 4: Add the site Once approved, call `add_blog_site` with the full payload: ```js add_blog_site({ label: "<short user-friendly name, e.g. 'RecipeBox'>", owner, repo, branch: "main", // or detected default branch content_dir, image_dir, image_public_prefix, framework, frontmatter_defaults, // pass through from inspect frontmatter_field_map, // pass through from inspect frontmatter_schema, // pass through from inspect site_url, // pass through (or user-provided), e.g. "https://recipebox.example.com" blog_url_pattern // default "/blog/{slug}/" — pass through unless user overrides }) ``` Persists to `~/.openwriter/config.json` → `plugins['@openwriter/plugin-github'].blogSites[]`. Returns the site id (uuid). Surface this id to the user — they'll need it (or the label) when invoking `integrate` later. ### Step 5: Verify Call `list_blog_sites` and confirm the new site appears with all the fields the inspector proposed. The `frontmatter_defaults`, `frontmatter_field_map`, and `frontmatter_schema` keys must round-trip back — if they're missing on the persisted record, the plugin's per-site config was stripped by an older bug (fixed: see `adr/plugin-slot-nested-data.md`). Re-run setup against a fresh openwriter spawn if it happens. ## Edits after setup **Backfill / correct a field with `edit_blog_site`.** If a site was registered without `site_url` (or with the wrong `blog_url_pattern`), call `edit_blog_site({ site_id, site_url, blog_url_pattern })` — only the fields you pass change, everything else is left intact. This is the one-click fix when a first publish came back with no live link: backfill `site_url`, then republish. No need to remove + re-register. The panel at right-rail → Plugins → GitHub shows the registered sites and lets the user remove them. For editing `frontmatter_defaults` after setup, the user edits `~/.openwriter/config.json` directly — the panel doesn't yet expose a defaults editor. ## Output ```json { "status": "draft-ready", "artifact": { "site_id": "<uuid>", "site_label": "<label>", "owner": "<owner>", "repo": "<repo>" }, "next_steps": ["/blog-writer brainstorm", "/blog-writer beats"], "notes": "Site registered. Use site_id when calling /blog-writer integrate." } ``` ## Anti-patterns - ❌ Skipping the inspect step — registers a site without the defaults the target requires. Posts ship with minimal frontmatter and break the site's build/render. - ❌ Inventing `frontmatter_defaults` from intuition instead of detecting them — get them wrong and every post inherits the wrong layout / author / etc. - ❌ Hardcoding `frontmatter_field_map: {date: "publishedDate"}` for every Astro site — some Astro sites use plain `date`. Let `inspect_blog_repo` detect from existing posts. - ❌ Adding the site without surfacing the proposal — the user should see what's being baked into the config before it's saved. -
titling.md 11.7 KB
# Title + Preview + Slug — B0 commitments Title, preview text (meta description), and slug are the THREE commitments locked before any prose pours. They're B0 in the Beats doc — the load-bearing beats that determine whether the post gets read at all. ## Why these three are one unit A post has two moments of decision before the reader commits: 1. **The link surface** — title + preview shown on a feed, a search result, a social share, an RSS reader. ~3 seconds of attention. Click or scroll past. 2. **The first paragraph** — opens after click. ~10 seconds of attention. Keep reading or bounce. Title + preview own moment 1. The Hook beats (B1-B2) own moment 2. If moment 1 fails the rest is dead. Lock title + preview before drafting because the draft must DELIVER what they promise — work backward from the click. The slug is the URL surface — it shapes what links look like in feeds, mentions, citations. It also affects search ranking and shareability. It's locked at the same time so the published URL doesn't get reshaped after publication (URL changes break inbound links). ## Title conventions ### Format - **50-70 characters** (Google cuts off display title around 60; some platforms push 70) - **Sentence case** by default, Title Case if the site convention demands it (check existing posts) - **No trailing period** (titles aren't sentences) - **No emoji** unless the site's existing posts use them consistently - **No subtitle/colon split** unless the site convention demands it ("Topic: Detail" is fine; gratuitous colons are not) ### What makes a title land Run the title through these tests — if it fails any one, rewrite: 1. **Specificity test.** Does the title name a specific claim, mechanism, outcome, or person? "How we built X" beats "Lessons from our build." "Why Meal planning is a one-way door" beats "Choosing a payment processor." 2. **Promise test.** Does the title tell the reader what they GET from reading? Information, a framework, a reframe, a warning, a story. If the title is a vague gesture ("Thoughts on payments"), it makes no promise. 3. **Curiosity-gap test.** Does the title hint at a tension or reveal without resolving it? "Meal planning is a one-way door" — gap: WHY is it one-way? "We migrated 500 customers in three days" — gap: HOW? 4. **First-sentence test.** Could the title be the first sentence of a Tweet that lands? If yes, it's punchy enough. If it reads like a chapter heading from a textbook, it's not. 5. **Search-snippet test.** Imagine the title in a Google result with a 160-char description below it. Would someone scrolling click THIS one over the four others on the page? If no, sharper. ### Title patterns that work | Pattern | Example | When | |---|---|---| | Declarative claim | "Meal planning is a one-way door" | Opinion, contrarian, reframe | | Specific outcome | "We migrated 500 customers in three days" | Announcement, case study | | Counter-intuitive | "Why our slowest feature is our best one" | Reframe, deep dive | | How-to (specific) | "How to ship a Stripe Connect migration without downtime" | Tutorial, framework | | Number + payoff | "Three reasons your auth flow is leaking conversions" | Listicle (use sparingly) | | Question (load-bearing) | "Should you self-host or use Connect?" | Decision-frame post | | Lived-scene anchor | "The day we deleted our payment retry queue" | Story-driven post | ### Title patterns that don't work - ❌ "Some thoughts on X" — promises nothing - ❌ "X 101" — generic, signals beginner content even when the post isn't - ❌ "Why X matters" — abstract, vague stakes - ❌ "The future of X" — speculative without grounding - ❌ "X: the complete guide" — overpromises unless the post is genuinely complete - ❌ Any title that starts "Introducing..." (except for hard product announcements, and even then it's weak) ### Per-site title voice Different blogs have different title postures. The site's voice anchor (`voice/anchor-<site_id>.md`) can carry a title-style line that shapes the posture: - **Technical blog:** keyword-forward, scannable, declarative — "Meal planning is a one-way door" - **Opinion blog:** provocative, claim-first — "Your auth flow is the bottleneck, not your API" - **Tutorial blog:** outcome-first, how-to — "Ship a Connect migration in three days" - **Founder/personal blog:** voice-forward, conversational — "The day we deleted our payment retry queue" When drafting titles, check the existing posts on the site. Match the register; don't import a tone the site doesn't speak. ## Preview text (meta description) conventions The preview text is what shows up: - Under the title in Google search results - In the share card when the URL is pasted into Slack / Discord / iMessage - In the RSS reader summary - In the OG card on social platforms (fallback if no custom OG description) It's set on `blogContext.description` and lands in the published frontmatter (`description: `, or whatever field the site's `frontmatter_field_map` renames it to). ### Format - **140-160 characters** (Google truncates around 155-160; staying under 160 avoids the "..." cutoff) - **One or two sentences max** - **Complete sentences** — preview text is read; it's not a tagline - **First-person or second-person** matching the site's voice - **No trailing period IF it's a single fragment; otherwise punctuate normally** - **No quotation marks** (they don't render well in search snippets) - **Don't restate the title verbatim** — the title is already shown above ### What makes a preview land The preview earns the click the title started. Two patterns work: 1. **Extend the curiosity gap.** Title plants the tension; preview deepens it without resolving. *Title: "Meal planning is a one-way door." Preview: "Three months in, here's what we learned about why most platforms can't migrate off Connect — and the one architecture choice that gives you an out."* 2. **Promise the payoff.** Title makes a claim; preview names what the reader walks away with. *Title: "How to ship a Stripe Connect migration without downtime." Preview: "The exact runbook we used to migrate 500 customers in three days. Zero charge failures, zero rollbacks, two engineers."* ### Anti-patterns - ❌ Restating the title — wasted real estate - ❌ "In this post, we'll discuss..." — meta-narration, no value - ❌ Single-keyword stuffing — sounds like SEO spam - ❌ Question without payoff — "Have you ever wondered about X?" is filler - ❌ Over-160 chars — the cut-off mid-sentence kills the click - ❌ All-caps shouting — reads as a banner, not a description ### The preview test Read the title and preview as a pair, as if they're showing up in a Google result you didn't write. If you'd click it, ship it. If you'd scroll past, rewrite. ## Slug conventions The slug is the URL-safe filename portion of the post's URL. The published URL is `{site_url}{blog_url_pattern with slug}` — e.g. `https://example.com/blog/weekly-meal-plans/`. ### Format - **Lowercase only** - **Hyphen-separated** (never underscores — hyphens are the search-engine convention) - **2-5 words** (shorter is better; long slugs get truncated in shares) - **Keyword-forward** — first word should carry the search intent - **No stop words** unless required for meaning — drop "the," "a," "of" when removable - **No dates** — let the site's `blog_url_pattern` add a date prefix if the site convention requires it - **No file extensions** — `.md`/`.mdx` is added by the publish plugin - **ASCII only** — no accented characters or non-Latin glyphs (some routers strip them silently) ### Slug ↔ title relationship The slug is usually a compressed form of the title: | Title | Slug | |---|---| | Meal planning is a one-way door | `meal-planning-one-way-door` | | We migrated 500 customers in three days | `migrated-500-customers-three-days` | | How to ship a Connect migration without downtime | `connect-migration-without-downtime` | | The day we deleted our payment retry queue | `deleted-payment-retry-queue` | Drop articles ("the," "a," "is"), drop personal pronouns ("we," "our") unless they're load-bearing, keep the substantive nouns and verbs. ### Slug stability **Once published, the slug NEVER changes.** Changing it breaks every inbound link — feeds, social shares, citations, the "View Post" link the openwriter doc keeps. If you regret a slug after publish, the move is to publish a NEW post with the corrected slug and 301-redirect the old one (handled at the site level, not by this skill). If the draft is unpublished and the slug needs to change, fine — just confirm with the user before re-locking. ### Slug uniqueness The slug must be unique within the site's `content_dir`. The publish plugin will error or overwrite if it collides — check `inspect_blog_repo` output or `gh` for existing slugs before locking a new one. If the desired slug is taken, add a disambiguator: `weekly-meal-plans-2026`, or rephrase. ## Locking the three to the Draft doc When title + preview + slug are locked in the Beats doc B0 block, mirror them onto the Draft doc — **title via `rename_item`, preview + slug via `set_metadata` → `blogContext`**. The publish plugin reads title from the doc's actual title field, not `blogContext.title`. ```js // Title goes on the DRAFT doc's title field (NOT blogContext). rename_item({ docId: draftDocId, name: "<locked title>" }) // Preview + slug + the rest go on blogContext. set_metadata({ docId: draftDocId, metadata: { blogContext: { active: true, description: "<locked preview, 140-160 char>", slug: "<locked-slug>", date: "<YYYY-MM-DD>", tags: ["<category1>", "<category2>"] // coverImage / coverImageAlt set later by images mode } } }) ``` Why title-via-doc-title: the publish plugin treats the OpenWriter doc title as the canonical published title. `blogContext.title` is ignored (intentionally — keeps the title surface single-sourced for direct editing in OpenWriter's title bar). Don't set both — set the doc title. The Beats doc keeps the B0 block as the canonical authored copy of the three commitments; the Draft doc's title field + blogContext is the publish-ready copy. They stay in sync because reshape passes update both surfaces. The BlogComposeView UI also surfaces these as form fields — if the user wants to edit by hand mid-cycle, point them there and re-read the doc title + metadata before re-pouring. ## Workflow The `beats` mode handles title + preview + slug as part of the Beats doc lock: 1. Author drafts the beat list via the 3-pass or 5-pass 2. Editor proposes 2-3 title candidates that name the post's load-bearing claim 3. Author picks / sharpens — one locked title 4. Editor drafts 2 preview candidates (curiosity-gap and payoff-promise patterns) 5. Author picks / sharpens — one locked preview 6. Editor proposes the slug compressed from the locked title; author confirms or sharpens 7. Mirror to `blogContext` via `set_metadata` If the title is reshaped post-draft, re-check the preview (it might still serve), and the slug stays (post is already published OR not yet published — author decides). ## Anti-patterns - ❌ Drafting prose before title + preview are locked. The first paragraph of the draft should ECHO the title; can't echo what isn't locked. - ❌ Treating title as decoration. It's the load-bearing B0 commitment — the post's whole point reduced to one line. - ❌ Letting the preview be "the first 160 characters of the post." That's a fallback for sites that don't author meta descriptions; for hand-authored posts it wastes the slot. - ❌ Slug-as-afterthought. The slug is the URL — author it deliberately. - ❌ Renaming the slug after publish without a redirect plan. Breaks every inbound link. - ❌ Stuffing keywords into title or preview ("Stripe Connect migration tutorial 2026 step-by-step guide"). SEO theater, not SEO. -
voice-anchor.md 5.7 KB
# Per-Site Voice Anchor Each blog site can have its own voice anchor. A `/blog-writer draft` pour reads the site-specific anchor first; if none exists it falls back silently to the global default. This file documents the discovery convention. The actual voice machinery (anchor blend, NEVER rules, fingerprint, minion dispatch) lives in `/authors-voice` — this doc is just the path lookup blog-writer performs before delegating. ## Why per-site anchors Different blogs speak different voices. The same author writes RecipeBox posts in a sharp, founder-direct register and a personal essay blog in a slower, reflective register. Forcing both through one anchor produces homogenized prose that doesn't land on either site. A per-site anchor lets the blend shift by site without rewriting the global default for the whole writing practice. ## Discovery convention When `draft` mode is about to delegate to `/authors-voice`, it looks up the anchor file in this order: 1. **Site-specific anchor** — `~/.claude/skills/authors-voice/voice/anchor-<site-slug>.md` 2. **Global default** — `~/.claude/skills/authors-voice/voice/anchor.md` The `<site-slug>` is the registered site's `label` (from `list_blog_sites`) run through this slug rule: - Lowercase - Replace any non-`[a-z0-9]` run with a single hyphen - Strip leading/trailing hyphens Examples: | Site label | Anchor path | |---|---| | `RecipeBox` | `voice/anchor-recipebox.md` | | `OpenWriter` | `voice/anchor-openwriter.md` | | `Foo & Bar` | `voice/anchor-foo-bar.md` | Silent fallback: if the site-specific file doesn't exist, draft mode uses `anchor.md` and prints a single line to the user — `voice: using default anchor (no anchor-<slug>.md found)`. Don't block, don't prompt — just inform. ## File shape Anchor files match the existing `/authors-voice` convention. Minimum viable anchor is a 5-line author blend (sums to 100%): ``` - 30% Bill Bryson - 25% Mary Roach - 20% Atul Gawande - 15% Oliver Sacks - 10% Malcolm Gladwell ``` `/authors-voice` reads this blend and constructs the voice prompt for the minion. The blend is the load-bearing part; everything else (NEVER rules, fingerprint, coined terms) is optional and lives in `/authors-voice` companion docs (`anchor-analysis.md`, `never-rules.md`, etc.). For a site-specific anchor, you can also create a matching analysis doc: - `voice/anchor-<site-slug>.md` — the blend (required) - `voice/anchor-<site-slug>-analysis.md` — the deep voice analysis (optional; produced by `/authors-voice` corpus analysis) The analysis doc carries the site-specific fingerprint (sentence stats, diction tells, register notes, coined-term inventory). Build it AFTER you have a few posts from that site in the corpus. ## When to build a site-specific anchor Build one when ANY of these conditions hold: 1. **The site's existing posts read in a distinctly different register from your default voice.** Founder blog vs craft blog vs marketing blog — all yours, all different. 2. **You've drafted 3+ posts for the site through the default anchor and consistently had to voice-tune them post-pour.** Tuning means the default is wrong for this site. Bake the fix into a site-specific anchor. 3. **The site has a co-author or guest contributors whose voice you want to preserve.** Different anchor per author within the same site is possible — name them `anchor-<site-slug>-<author-slug>.md` and pass the author override into the dispatch brief. Don't build one for sites you haven't published on yet. The default is fine until you have evidence of mismatch. ## How `draft` mode uses the anchor When `draft` mode pours prose, it builds a dispatch brief and hands it to `/authors-voice` Apply Protocol. The brief carries: ```js { task: "<beat outcome commitment, verbatim from Beats doc>", voice_anchor_path: "voice/anchor-recipebox.md", // or anchor.md fallback voice_anchor_fallback: "voice/anchor.md", must_appear: ["<author-unique phrases>", "<callbacks>"], word_target: "<beat word count>", prior_beats: ["<beats already drafted, in order>"] } ``` `/authors-voice` reads the anchor file, constructs the voice prompt, runs the minion, returns voice-matched prose. Blog-writer integrates the result into the Draft doc. If the site-specific anchor exists but `/authors-voice` reports it's malformed (no blend, wrong percentages summing to non-100%, etc.), draft mode falls back to default and surfaces the error to the user. Don't block — broken anchors shouldn't stop publishing. ## Bootstrapping a site-specific anchor The fastest path: 1. Run `/blog-writer setup` for the site (gets the label, generates the slug) 2. Copy `voice/anchor.md` to `voice/anchor-<site-slug>.md` 3. Edit the blend — adjust percentages, swap authors, until the blend matches how the site's existing posts read 4. Optional: feed the site's existing posts into `/authors-voice` corpus analysis to produce a matching `anchor-<site-slug>-analysis.md` Then draft. The first 2-3 posts will reveal whether the blend is right; tune percentages until the voice lands consistently. ## Anti-patterns - ❌ Putting voice anchor paths into the github plugin's per-site config. Voice paths are an authors-voice concern, not a publish-plugin concern. Discovery is convention-based, not config-coupled. - ❌ Hardcoding the site slug somewhere in the skill instead of computing from `label`. Sites get renamed; the slug is a runtime derivation. - ❌ Blocking the draft when no site-specific anchor exists. Silent fallback to default, one-line notice, proceed. - ❌ Building a site-specific anchor before publishing 3+ posts there. Premature anchor = optimized for the wrong target. - ❌ One mega-anchor that tries to cover every site. The blend is load-bearing; covering N sites in one anchor blurs all of them.
-
-
SKILL.md 13.9 KB
--- name: blog-writer description: | Channel-master writer for long-form blog posts. Owns the SHAPE of a post — beat structure, title/preview/slug commitments, per-post container layout, per-site voice anchor discovery — and delegates VOICE (prose generation) to /authors-voice and PUBLISH mechanics to the openwriter github plugin (`add_blog_site` + `post_to_blog`). Use when: "/blog-writer", "write a blog post", "blog draft", "brainstorm blog topics", "blog beats", "extract beats from this post", "write about this feature", "draft a post", "blog title", "preview text", "OG description", "blog image", "featured image", "OG image", "integrate", "create the files", "wire up the blog post", "publish to blog", "post to blog", "set up blog repo", "register blog site". Requires: OpenWriter MCP server configured + github plugin enabled + `gh auth login` set up locally. Project SHOULD have a `## Blog` section in its CLAUDE.md for writing-rules / image-style overrides (optional after setup). metadata: author: travsteward version: "0.5.0" license: MIT --- # Blog Writer Channel-master skill for long-form blog content. Owns ideation → beats → draft → image → publish. **Architecture:** beats-first (v0.5.0) + plugin-backed publish (v0.4.0). Each post lives in its own container with two sibling docs: a `Beats` doc (the structural commitments — beat list + title/preview/slug as B0) and a `Draft` doc (the voice-poured prose). Beats reshape regularly; draft re-pours via `/authors-voice` against a per-site anchor (`voice/anchor-<site-slug>.md`). Publish is a single `post_to_blog` MCP call against a site registered via `add_blog_site` — site-specific frontmatter (layout, author, prerender, `date → publishedDate` for Astro, etc.) lives on the github plugin's per-site config in `~/.openwriter/config.json`, not in project config. Mirrors book-writer's discipline at post scale. ## Convention This skill obeys the shared writer contract at [WRITER-CONVENTION.md](../WRITER-CONVENTION.md). Brief shape and return shape match that doc. Sub-form values: `long` | `short` | `tutorial` | `announcement`. **OpenWriter pad mechanics are canonical in [/openwriter](../openwriter/SKILL.md) — read it, don't re-derive from tool descriptions.** The load-bearing rule: `populate_document` is **create-only, used ONCE**; it re-sends the whole body, so calling it again to "fix" a doc appends a duplicate. All edits (rewrite / insert / delete) go through `write_to_pad` on fresh node IDs. Plus the read ladder (`outline_doc` → `search_docs` → `peek_doc` → `read_pad`). **Blog posts ship SEO-complete in the FIRST pass.** A blog post almost always carries SEO — internal/cluster links, meta + slug + tags, snippet-targeted headings, FAQ/schema-eligible content all go into the *initial* draft (the minion brief + first `populate_document`), never bolted on after. Bolting SEO on later forces an edit (→ `write_to_pad`, never a 2nd populate). SEO-strategy-led posts (pillar / landing / comparison) front-end through `/seo-writer` if you have it installed (not bundled with OpenWriter). **Cover image is a publish gate** (firm rule 12). Placement rules: no bottom-band text (the platform renders og:title there), keep the subject center-safe, ≤5–7 words of overlay text, do not echo the title/description. Palette / brand skin comes from the project's `style_doc` (e.g. `recipebox.md`). Generation, canonical size, and the tmp-first rule for text-overlay covers: [docs/images.md](docs/images.md). **ABOUT TO HAND-EDIT A PUBLISHED POST'S `.md`, ITS `image:` FRONTMATTER, OR DROP A COVER INTO THE REPO'S `public/`? STOP.** The OpenWriter doc's `blogContext.coverImage` is the single source of truth, and `post_to_blog` is the ONLY writer to the blog repo. Edits flow **doc → Accept → Publish** — never doc *and* repo in parallel, because that drift is exactly what breaks idempotent republish (Publish rewrites `<content_dir>/<slug>.md` wholesale and your hand-edits vanish). Never hand-name a cover; never side-channel an image; never `git push` the post yourself. Canonical publish flow + the `lastPublish`-clobber footgun: [docs/integrate.md](docs/integrate.md). ## Modes | Mode | Trigger | What it does | Sub-doc | |---|---|---|---| | `setup` | `/blog-writer setup`, "register blog repo", "add blog site" | One-time per blog: `inspect_blog_repo` clones the target, auto-proposes `frontmatter_defaults` + `frontmatter_field_map` from existing posts; `add_blog_site` persists the site config | [docs/setup.md](docs/setup.md) | | `brainstorm` | `/blog-writer brainstorm`, "brainstorm blog topics" | Open a Blog Ideas doc; propose 3-5 candidate angles with tone + length labels; hand off to `beats` when user picks | [docs/brainstorm.md](docs/brainstorm.md) | | `beats` | `/blog-writer beats`, "extract beats", "blog beats" | Query-first beat extraction with 3-pass (short/announcement, 3-5 beats) or 5-pass (long/tutorial, 8-15 beats); 9 blog category tags (CLAIM/REFRAME/MECHANISM/EVIDENCE/DEMO/SCENE/OBJECTION/APHORISM/PIVOT) + HOOK/CTA positional roles; locks title + preview + slug as B0; dopamine arc layered with conversion arc | [docs/beats.md](docs/beats.md) + [docs/titling.md](docs/titling.md) | | `draft` | `/blog-writer draft`, "draft this post", "pour the beats" | Per-beat dispatch to `/authors-voice` Apply Protocol with site-specific anchor; cross-beat coherence pass; supports beat-level reshape loop (re-pour only the affected beats) | [docs/draft.md](docs/draft.md) + [docs/voice-anchor.md](docs/voice-anchor.md) | | `images` | `/blog-writer images`, "blog image", "featured image" | Read style doc, craft prompt, generate cover via `insert_image` (no docId → returns path → `blogContext.coverImage`); optional inline images at `afterNodeId` | [docs/images.md](docs/images.md) | | `integrate` | `/blog-writer integrate`, "publish", "post to blog" | `post_to_blog` against the registered site — builds frontmatter from `blogContext` + site defaults, copies referenced `/_images/...` to `image_dir`, rewrites paths, commits, pushes; verifies pending decorations accepted first | [docs/integrate.md](docs/integrate.md) | | `pipeline` | `/blog-writer pipeline`, "full blog workflow", "write and publish" | 7-step sequence (Setup → Brainstorm → Beats → Draft → Images → Accept → Publish → Verify) with explicit gates; reshape loop returns to Beats step | [docs/pipeline.md](docs/pipeline.md) | Modes can chain (pipeline runs them in sequence) or stand alone. Beats reshape → draft re-pour is the inner loop the architecture is designed around. ## Setup (per-session vs per-blog) **Per-session.** Read the project's CLAUDE.md and extract the optional `## Blog` section. It can carry `writing_rules`, `style_doc`, `content_driven`, `aspect_ratio`. Used by `images` mode + as defaults for `beats`/`draft`. Schema: [docs/project-config.md](docs/project-config.md). If absent, defaults apply and the skill still runs. **Per-blog (one-time).** Each blog repo must be registered with the github plugin via `add_blog_site` before `integrate` can publish to it. Run `/blog-writer setup` once per blog. The setup mode uses `inspect_blog_repo` to auto-propose `frontmatter_defaults` (layout, author, prerender — fields constant across the site's existing posts) and `frontmatter_field_map` (e.g. `date → publishedDate` for Astro sites). Sub-doc: [docs/setup.md](docs/setup.md). ## Architecture (build internals) Per-post container structure (Beats + Draft sibling docs), the 10-step OpenWriter call-order for building one post, and the return/output contract live in [docs/architecture.md](docs/architecture.md). Two facts ride every build: - **Two sibling docs per post.** `Beats — <Title>` (content_type `notes`) holds structure; `<Title>` (content_type `blog`) is the publishable Draft — one per container. Reshape beats → re-pour only the affected beats, never the whole post. - **ABOUT TO CALL `switch_document` (or any view-control MCP) MID-BUILD? STOP.** The agent builds the container + Beats + Draft + metadata + images silently, targeting docs by `docId`; the user watches the activity feed and navigates themselves. Only `switch_document` on an explicit user instruction ("open the Draft", "show me the Beats"). ## Firm rules 1. **Beats before draft.** Draft mode requires a locked Beats doc. No beats → run `beats` first. Pouring prose without committed beats produces shapeless drafts that need full structural rework downstream. 2. **Beats and Draft live in separate docs, always.** One container per post, two sibling docs: `Beats — <Post Title>` (content_type: notes) and `<Post Title>` (content_type: blog — the publishable doc). Beats reshape → draft re-pour. Don't mix prose into the Beats doc; don't put structural commitments in the Draft doc. 3. **Title + preview + slug are B0 commitments.** Locked in the Beats doc's B0 block during `beats` mode. When `draft` mode runs: title mirrors to the Draft doc's title field via `rename_item` (the publish plugin reads title from there, not from `blogContext.title`); preview + slug mirror to `blogContext` via `set_metadata`. See [docs/titling.md](docs/titling.md). The first paragraph of the draft must echo the title — can't echo what isn't locked. 4. **Per-site voice anchor.** `draft` mode reads `voice/anchor-<site-slug>.md` where `<site-slug>` is the slugified site label from `list_blog_sites`. Silent fallback to `voice/anchor.md` if not present. Discovery is convention-based, NOT in plugin config. See [docs/voice-anchor.md](docs/voice-anchor.md). 5. **Setup before integrate.** A blog repo must be registered via `add_blog_site` before `integrate` can publish. Check `list_blog_sites` at session start — if the target repo isn't there, run `/blog-writer setup` first. 6. **Project config is optional; per-site config is canonical.** `## Blog` in CLAUDE.md is for `writing_rules`, `style_doc`, `content_driven`, `aspect_ratio`. Frontmatter shape (`layout`, `author`, `publishedDate` vs `date`, etc.) lives on the github plugin's per-site config, NOT in project config. 7. **Voice always.** Every beat pours through `/authors-voice` Apply Protocol with the site-specific anchor. The skill owns shape; voice owns diction. Per-beat dispatch is the default for `long`/`tutorial`; collapse to single dispatch for `short`/`announcement` under 1000w. 8. **Two-step doc creation.** `create_document` (spinner) → `populate_document` (content). Never inline a 30s generation into one tool call. 9. **Today's date in the doc.** Set `blogContext.date` as `YYYY-MM-DD` in the project's configured timezone (default `America/Los_Angeles`). The plugin formats / renames per site config. 10. **Accept pending decorations before publishing.** Agent-inserted images and rewrites land as pending decorations until the user accepts them in the right-rail Review tab. `post_to_blog` reads the canonical doc on disk — pending changes don't ship. After agent writes, `integrate` must tell the user "click Accept All in the right rail, then I'll publish" rather than silently posting incomplete content. See [docs/integrate.md](docs/integrate.md). 11. **Mark-sent is automatic.** After a successful `post_to_blog`, the plugin writes `blogContext.lastPublish = { publishedAt, publishedUrl, commit, file }` on the Draft doc. File tree shows a green ✓; right-click menu surfaces "View Post." Same convention as tweets / articles / newsletters. 12. **Cover image before publish.** No post `integrate`s without a cover/OG image — a gate, not optional. Design per the Convention above (placement rules + `style_doc` palette; [docs/images.md](docs/images.md) mechanics). ## Anti-patterns - ❌ Calling `draft` without first running `beats` for the post — fails with "no Beats doc found in container" - ❌ Calling `post_to_blog` without first running `setup` for that repo — fails with "no blog site with id X" - ❌ Putting site-wide constants (`layout`, `author`, `prerender`) into `blogContext` instead of the site's `frontmatter_defaults` - ❌ Calling `post_to_blog` immediately after `insert_image` without waiting for accept — pending image stays in browser overlay, doesn't ship - ❌ Writing the blog post `.md` file directly into the target repo via Write/Edit tools — that's `post_to_blog`'s job - ❌ Generating an image before the content is approved (image themes might shift) - ❌ Skipping voice protocol because "the draft sounds fine" - ❌ Mixing modes — finish one, then start the next - ❌ Single global voice anchor when a site-specific one exists — `draft` mode reads conventionally; no opt-in required - ❌ Reshaping beats AND re-pouring the entire draft in one pass — reshape beats first, lock them, then re-pour ONLY the affected beats - ❌ Calling `/blog-pipeline`, `/blog-images`, `/blog-integrate`, `/blog-feature-images` as separate skills (deprecated stubs that redirect here) ## Scripts - `mcp__openwriter__insert_image` — Gemini image generation directly into the active OpenWriter doc (primary path; cover via path-return + `blogContext.coverImage`) - image-gen CLI (if installed locally) — standalone fallback for non-OpenWriter workflows - Sharp conversion (PNG → WebP) — for projects where the target site insists on `.webp` (the github plugin copies PNGs unchanged; conversion is a project-specific concern before publish) ## Related skills Delegated / required: - **/authors-voice** — voice pipeline; REQUIRED for any prose generation (`draft` delegates every beat dispatch). - **/anti-ai** — final AI-tells fingerprint scrub; recommended after a post is voice-poured. - **openwriter** — the workspace/document MCP; REQUIRED for the doc management this skill governs. - **/seo-writer** — optional, not bundled: SEO-strategy-led posts front-end there, then reuse this skill's publish path. NOT covered (use your own tooling instead): project deploy pipelines · cross-channel announcements (Discord, X, etc.) · **/newsletter-writer** (different channel) · **/x-writer** (different channel) · **/book-writer** (multi-chapter books — global beat sheet, workspace management).
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.