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.
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.
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.
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.
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.
node scripts/generate-post.mjs \
"linkedin ghostwriting" --research-only
→ output/packets/linkedin-ghostwriting.packet.jsonCall research (CLI --research-only, the MCP tool, or POST /research)
Fixed stages run: content brief, LLM outline plan, optional SERP + Tavily
A self-contained packet JSON comes back — nothing is written to disk
Hand the packet to any writer: built-in, your own agent, or a human
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.
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.
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 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.
Standard long-form content: BlogPosting + BreadcrumbList JSON-LD, and FAQPage markup whenever the draft includes an FAQ section.
contentType: 'how-to' adds HowTo schema on top of the standard graph, extracted straight from the draft's numbered steps.
Same fixed tail, same contract — the content-type value only changes how the outline is planned, not what gets validated.
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.
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.
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.
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.
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.
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.
Both checks above are deliberately narrow, and that scope was a deliberate call, not an oversight:
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.
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>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.
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.
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.
The docs cover the MCP tools, the HTTP routes, and the exact packet and draft schemas.