Back to home

A research-to-publish pipeline you run yourself

Research, draft, gate, and publish SEO articles from the command line, an MCP client, or your own agent. Bring your own model and API keys; the output is a static post directory you own.

Research & Planning

One command builds a self-contained research packet

Point it at a keyword and get back everything a writer needs to work from — a content brief, an LLM-planned outline, and the full format contract for the target site. Zero repo knowledge required to write against it.

Optional SERP analysis

Set SERP_API_KEY and the packet includes word-count targets, People-Also-Ask questions, featured snippets, and competitor headings from the top 10 results. Without it, the plan falls back to sensible defaults — nothing fails.

Cited research, provenance-checked

Set TAVILY_API_KEY and sources are collected with numbered citations the draft can reference as [n]. Every citation marker that survives assembly must trace to a real source in the packet — one with no match is stripped and the run is rejected, not shipped silently.

Per-site profiles, not hardcoded rules

Cluster hubs, the internal-link allowlist, and the keywords a site should never compete for all come from one JSON site profile (scripts/lib/siteProfile.mjs). Point the same toolkit at a different site by swapping the profile, not the code.

research-only
node scripts/generate-post.mjs \
        "linkedin ghostwriting" --research-only
      → output/packets/linkedin-ghostwriting.packet.json
Packet contentspacketVersion: 1
topic.primaryKeyword"linkedin ghostwriting"
serp.availabletrue — SERP_API_KEY set
research.availabletrue — TAVILY_API_KEY set
constraints.wordCount{ min: 1600, max: 2200 }
constraints.faq{ required: true, min: 4, max: 6 }

How research works

1

Call research (CLI --research-only, the MCP tool, or POST /research)

2

Fixed stages run: content brief, LLM outline plan, optional SERP + Tavily

3

A self-contained packet JSON comes back — nothing is written to disk

4

Hand the packet to any writer: built-in, your own agent, or a human

Pluggable Drafting

Three ways to write, one fixed tail

The built-in LLM writer, an externally-written draft, and a hand-written draft all run through the exact same finishing steps — sanitization, citation renumbering, JSON-LD generation, and format-contract validation. Nothing skips the tail.

Three drafting modes, one tail
1
Built-in LLM draftingBYOK env vars, writes the whole draft
2
research-only → your writerpacket out, draft.md in via --from-draft
3
--from-draft, hand-writtenskip the LLM entirely
sanitize → citations → JSON-LD → contract gate → post directory

Bring your own model

Any OpenAI-compatible chat-completions endpoint works. Set LLM_PROVIDER / LLM_BASE_URL / LLM_MODEL / LLM_API_KEY, or pass a model object per call — settings are scoped per request and never touch process.env, so concurrent callers can't see each other's keys.

Or write it yourself

research-only mode stops after planning and hands you the packet. Write the draft with your own agent, then run it through the fixed tail with --from-draft — every check the built-in path gets, an external draft gets too.

A plain, inspectable draft format

A Markdown file with YAML front matter: title, description, primaryKeyword required; ## sections, an FAQ block, and [n] citation markers. No proprietary format, no binary blob — you can read and edit the draft by hand.

Blog, guide, article

Standard long-form content: BlogPosting + BreadcrumbList JSON-LD, and FAQPage markup whenever the draft includes an FAQ section.

How-to

contentType: 'how-to' adds HowTo schema on top of the standard graph, extracted straight from the draft's numbered steps.

Comparison, listicle

Same fixed tail, same contract — the content-type value only changes how the outline is planned, not what gets validated.

The Publish Gate

The gate is authoritative, not a suggestion

gate() and publish() run the identical validation function. A client that skips gate and calls publish directly still gets rejected — there is no code path that writes an unvalidated post.

Link safety, always on

Placeholder links (example.com and friends), funnel links to forbidden paths, a funnel link missing its mandatory UTM template, or more funnel links than the profile allows — all hard-fail, and none of it needs a network call.

Citation provenance, always on

Every [n] marker that survives assembly must match a real citationNumber in the research packet, or the run is rejected. This is bookkeeping, not fact-checking — it proves a citation points somewhere real, not that the sentence beside it is accurate.

Opt-in link liveness

Pass --check-links (or checkLinks: true) to HEAD/GET every outbound link. Only a definitive dead link — 404, 410, or a domain that fails to resolve — is an error. Timeouts, 5xx, and bot-blocks are warnings, because a flaky check that blocks publishing is worse than no check.

Word count & FAQ, from the profile

The word-count band, whether an FAQ is required, and how many questions it needs all come from the site profile's per-post contract — not a constant buried in the validator.

gate() resultok: false
Placeholder links
Funnel UTM template
Funnel link volume (≤ max)
Citation provenance
FAQ present (4–6 questions)
Word count in band

errors: ["Word count 640 is far below the 1600–2200 contract minimum"]

publish() runs this exact function — a gate pass means publish will not be rejected on contract grounds.

What the gate does not check

Both checks above are deliberately narrow, and that scope was a deliberate call, not an oversight:

  • Provenance ≠ truth. Tracing a [n] marker to a real packet source doesn't verify the sentence next to it accurately represents that source.
  • Liveness ≠ relevance. A link resolving and returning 200 says nothing about whether the page still supports the claim beside it.
Static Output

Publish writes a post directory, not a database row

No accounts, no job queue, no hosted service standing between your draft and the page. publish() writes the exact HTML shape the static site's build already expects, and you own the file from that point on.

landing-page/src/content/blog/linkedin-ghostwriting/
index.html
        <title>LinkedIn Ghostwriting: ...</title>
        <meta name="description" content="...">
        <meta property="article:published_time" content="2026-08-20">
        <meta property="article:tag" content="LinkedIn">
        <article data-post-content>...</article>
        <script type="application/ld+json">
          { "@graph": [ BlogPosting, BreadcrumbList, FAQPage ] }
        </script>
Typed errors
invalid_input · 400invalid_profile · 400llm_not_configured · 400contract_violation · 422post_exists · 409stage_failed · 502

Plain files, no lock-in

Each post is landing-page/src/content/blog/<slug>/index.html — a <title>, meta tags, an <article> body, and a JSON-LD @graph. It's a static site's source file, not a row behind an API you have to keep paying to read back.

Any site, one config change

Everything site-specific — brand, funnel host, UTM template, clusters, and the per-post contract — lives in one profile JSON. contentPolicy.mjs itself carries no site constants; pointing the toolkit at a new site is a --profile flag, not a fork.

Typed errors, not parsed prose

Every failure is a typed error carrying a stable code and HTTP status — invalid_input, invalid_profile, llm_not_configured, contract_violation, post_exists, stage_failed — so a caller can branch on the code instead of matching a message string.

See the whole surface

The docs cover the MCP tools, the HTTP routes, and the exact packet and draft schemas.