Key takeaways
  • The pipeline has published 157 of 9,342 candidate pages; the rest are pruned, blocked, clustered, or still queued — nothing is published on volume alone.
  • A row is marked published only after mark-deployed.mjs confirms the live URL returns HTTP 200. A committed file that fails to deploy stays queueda failed deploy never lies.
  • Three real refusals are documented here with commit hashes and run IDs: a pricing page blocked for 13 days over one disputed figure, a fact-ownership bug that wedged a page indefinitely rather than let it ship wrong, and a self-test bug that took the entire nightly publish red for 4 runs rather than generate on unverified gates.
  • Forty-one /snippets pages sit blocked on purpose — the pipeline will not write about code nobody has run on a real store, and it says so in the data file, not just in prose.

01 A pipeline you should not trust by default

If you have spent any time near programmatic SEO in the last two years, you have a reflex, and it is the correct one: an AI publishing pages unsupervised is a content-slop factory waiting to be caught. Wrong prices, invented statistics, a hundred pages that all cite the same unverifiable "study." The reflex exists because the failure mode is common and because most pipelines have no mechanism that would catch it even if they wanted to.

This article is not asking you to trust ours instead. It is showing you the mechanism, with the file paths, commit hashes, and CI run IDs quoted verbatim from the repository these pages actually come from. That repository is private — these receipts are ours, not links you can click — so the honest framing is this: every specific string below is real, and if you ever evaluate us, asking to be walked through any one of them live is a fair test we will pass. INSO runs a programmatic SEO pipeline that drafts, reviews, and publishes pages twice a day with no human approving any individual page before it goes live. As of the 2026-09-02 snapshot of docs/seo/data/queue.json, it has published 157 of them, out of 9,342 candidate keyword rows it has ever considered. The other 9,185 were pruned, blocked, clustered, or are still waiting in the queue.

That gap — 157 published against 9,342 considered — is the whole story in one ratio. This is not a pipeline optimized to publish as much as possible. It is optimized to publish nothing it cannot re-verify, and the rest of this article is about the code that enforces that, including the three times it caught something and the one time it caught itself.

02 What actually runs, twice a day

The publish workflow (.github/workflows/pseo-publish.yml) runs on a cron — 8am and 7pm America/Chicago — and calls scripts/pseo/run-batch.mjs, which generates one post per template with queued work, in parallel, bounded by a concurrency cap computed from the runner's RAM and core count. Each template's generation is fully isolated: it reads only its own queue rows, writes only its own output path, and never touches the queue itself.

Inside each generation, scripts/pseo/generate.mjs runs an eight-step pipeline, and every step is a separate headless Claude Code call with its own artifact written to a work directory so the whole thing is inspectable in CI logs after the fact:

StepWhat it does
1. queuePull the next queued keyword for this template from queue.json.
2. dataThe section's own data step — live App Store facts, a live repo/npm/doc fetch, or a captured pricing register plus a live drift probe, depending on the section.
3. strategistTitle, angle, outline, keyword strategy.
4. researcherMid-tier web research — real facts, competitor context.
5. copywriterFull HTML draft.
6. reviewerAudits the draft against the shared style guide and the section's own spec checks.
7. formatterStrict JSON matching the section's post schema.
8. emitShared validation, then the section's own hard gates, then write to disk — or refuse.

Step 8 is the point of this article. Every section — /connect, /build, /snippets, /apps, and the rest — owns its own hard gates, described in its own spec file under docs/seo/, because the rules genuinely do not transfer between sections. A page in /connect is making a claim about what an MCP server's tools do; a page in /build is making a claim about what a vendor charges. Auditing the second kind of page for measurement vocabulary it cannot contain, or the first kind for a pricing citation it has no reason to carry, is exactly the mistake the code is structured to prevent — each family's reviewer checks are separate functions, asserted against each other in scripts/pseo/gates.selftest.mjs so that "the two families don't leak into each other" is a tested property, not a hope.

03 Three hard gates — volume is not one

The gates do not ask whether a page will rank. They ask whether it is true and whether it is honest that it is true. Per CLAUDE.md, the three hard gates across the pipeline are:

  1. A winnable SERP, verified against a live capture — not inferred from a rule. Twelve inferred verdicts were checked against real captures during one 2026-07-25 review; six were materially wrong, and three of those were scored dangerously optimistic.
  2. An ownable, unique data point that no sibling page also claims — enforced by a machine-checked ledger (more on this below), not by a style guideline asking writers to avoid repetition.
  3. A true premise — a page may never assert an absence ("Vendor X ships no native Y") that its own captured search results contradict.

Search volume orders the queue. It decides nothing about whether a page gets written. A null-volume keyword sitting on an easy, winnable SERP is a legitimate build; a 260-searches-a-month keyword sitting on a SERP the vendor already owns is not, regardless of the traffic it would theoretically be worth.

The uniqueness gate is the one most pages actually fail against, so it's worth naming precisely: scripts/pseo/uniqueasset.mjs maintains a machine-readable ledger where every page in a given section owns exactly one fact, keyed as <entity-handle>:<fact-slug>. Two functions enforce it — assertUniqueAssets() checks at build time that no two rows in the ledger claim the same key, and assetRenderedIn() checks at generation time that the page actually printed the fact it was assigned, not a different one. The module's own header comment names the two defects this replaced: one server's fact shipping as both a page's headline and a different page's supporting evidence, and a curation page silently shipping an asset other than the one it was given. Comparing prose was how both of those slipped through; comparing keys is why they can't now.

04 Published means the URL returned 200

Generation and publication are two different events, on purpose. generate.mjs writes a file and leaves the queue row's status at queued — never published — even after the file is committed. Only scripts/pseo/mark-deployed.mjs, run after the site actually deploys, flips a row to published, and it does so only after fetching the live URL and confirming it returns HTTP 200:

scripts/pseo/mark-deployed.mjs comment
// Post-deploy queue marker. Run ONLY after a successful deploy. A queued row is
// marked 'published' iff BOTH its committed file exists AND its URL serves HTTP 200.
// File-existence alone doesn't prove liveness: a post that fails to build is still
// committed but 404s — such rows stay 'queued' for a later run to retry.

As CLAUDE.md puts it: "Generation leaves rows queued on purpose, so a failed deploy never lies." If a page fails to go live after three deploy attempts, mark-deployed.mjs marks the row failed rather than leaving it to retry forever and quietly consume a fan-out slot every run. A live page still showing queued is not a bug — it just means the next scheduled run hasn't marked it yet.

05 Refusal #1: blocked for 13 days over one figure

On 2026-08-18, build/build-vs-buy-ai-chatbot — a page comparing the cost of building a chatbot in-house against buying Chatbase — failed a truth gate on its first generation attempt. It failed again on a scheduled run on 2026-08-31. Two failures in a row trips a two-strike quarantine, and the row's own blocked_reason field, still visible in this repo's git history, reads:

docs/seo/data/queue.json (as of commit 619cc1a's parent) json
"blocked_reason": "truth gate failed 2 run(s) in a row: foreign-fact/FOREIGN_ASSET.
  Last message: build-vs-buy-ai-chatbot renders \"cohort-captured-2026-07:unit-price-
  spread-18x\", which belongs to another row. (run https://github.com/INSOcodes/
  inso-codes-website-v2606/actions/runs/33429715807). Set status back to \"queued\"
  once the underlying record or the generator is fixed — this quarantine is
  reversible on purpose."

The gate had flagged the page for printing figures that belonged to a different row's assigned fact — a genuine foreign-fact violation shape, just not the actual defect this time. The real cause, diagnosed and fixed the same day in commit 4343868, was that two independent code paths disagreed about what counted as "restating" a shared figure. One path (the emit gate, added in commit 10546fe to make the whole build-vs-buy template able to generate at all) requires a page to print its own assigned figures — in this case, Chatbase's $0.040-per-credit and $0.064-per-credit rates. The other path (the standing audit, check-build.mjs) read those same two figures and saw them as evidence that the page was restating a different row's fact — the hub page's 18x vendor price spread, whose lowest and highest endpoints happen to be those same two numbers. Two log hits, exactly matching the two legitimately shared figures — proof, once someone looked, that the writer hadn't actually copied anything.

The fix made check-build.mjs call the same narrowing function the emit gate already used (foreignAssetsForBuild), so the two paths could no longer disagree with each other. The reversal commit (619cc1a) is explicit about what did and didn't get unblocked: the sibling row, build/build-vs-buy-ai-agent, stayed blocked, because its own foreign-asset failure — involving Chatbase's hobby-plan credit-cost inversion — was "a different, undiagnosed defect," not the same bug. The gate did not get looser to let one page through; it got more precise, and the page that still had a real problem stayed exactly where it was.

i
Note

The same page family also runs a live re-fetch of each vendor's own pricing page to detect drift since the figures were captured (scripts/pseo/builddata.mjs). For Chatbase specifically, that re-fetch returns too little text to draw any conclusion — its pricing page renders client-side — and the check is explicit that this is reported as inconclusive, never as drift: "not one of the captured quote(s) appears in the fetched HTML — the page almost certainly renders its prices client-side, so this fetch establishes nothing about any of them." That distinction exists because collapsing "we couldn't check" into "it changed" would flag most vendors as stale on a normal day and train everyone to ignore the warning within a week.

06 Refusal #2: a fact that belonged to someone else

On 2026-08-31, the connect-directory template was wedged — not blocked with a clear reason like the row above, just silently failing the same first-queued row on every single run, because run-batch.mjs retries the identical row every time until it succeeds. The row was connect/shopify-partner-api-mcp-server, and its problem was a false positive in the exact mechanism from Refusal #1: the foreign-fact ownership check.

scripts/pseo/uniqueasset.mjs's foreignAssetsIn() matched a row's assigned fact signature against page text with plain String.indexOf. Magento's MCP server row owns the identifier get_revenue as part of its assigned fact. The Shopify Partner API server's own tools are named get_revenue_summary, get_revenue_forecast, and get_revenue_anomalies — and a plain substring search finds get_revenue inside every one of them. The Partner API page was refused, over and over, for supposedly rendering a fact it never touched, because a prefix isn't an identifier.

The fix (commit 34a3b75) added a boundedIndexOf helper that rejects a match glued to another identifier character on either side — the same class of bug named in this repo's housebrand playbook, where a prefix match once let INSO-Bot read as the house brand INSO. It has a side effect that doubles as its own test: it also stops $100 from matching inside $1000. The fix is asserted in gates.selftest.mjs §4a with two fixtures side by side — one proving the Partner API page naming its own get_revenue_summary tool no longer trips Magento's ownership, and a second, deliberately unfixed fixture proving that a page which actually prints Magento's get_revenue verbatim and unattributed still gets rejected. The gate didn't get quieter; it got a boundary. The page published the same day the fix landed, and it is live today at connect/shopify-partner-api-mcp-server.

07 Refusal #3: catching our own bug

The most honest receipt in this pipeline is not a gate catching a wrong fact on a page. It's the gate catching a bug in itself, and refusing to let anything ship until that bug was found — including pages that would have been perfectly fine.

The publish workflow's very first step is named, without irony, "Gate self-test (pre-generation, ALWAYS FATAL)" — it runs gates.selftest.mjs, which doesn't audit any page; it audits the gate code itself, replaying every defect the gates were written to catch against both a failing input and the legitimate input the gate must not reject. If the gate code can't prove it still works, nothing generates that run, full stop. CLAUDE.md states the reasoning directly: "A gate that cannot reproduce its own defect is not a gate," after three separate passes had shipped gates that were green because they tested nothing.

On 2026-08-29, that self-test itself broke. One of the selftest's own sections had frozen a fixture date (TODAY = '2026-08-17') so its assertions would be reproducible regardless of what day the suite actually ran — standard practice for testing date logic. But one helper call inside that section called checkAppsPage() with only two of its three arguments, and the function it called, staleExplains(), silently defaults a missing today argument to the real wall-clock date. As the calendar moved further from the frozen 2026-08-17 fixture, one assertion's expected result quietly stopped matching, and on 2026-08-29 it flipped to a hard failure. The pipeline had generated nothing since.

git log, commit e4be169 text
"pSEO auto-publish" has failed every run since 2026-08-29, all at
"Gate self-test (pre-generation, ALWAYS FATAL)", generating nothing.

gates.selftest.mjs §20 froze TODAY = '2026-08-17' but its helper called
checkAppsPage() with two arguments, and staleExplains() defaults 'today' to
the wall clock — so assertion 20k flipped to FAIL as the real date drifted
away from the fixture, and the gap widened daily.

The pipeline runs twice a day. From 2026-08-29 to the fix on 2026-08-31, that's four scheduled runs — the entire nightly publish workflow went red four times in a row, generating and shipping nothing, because the always-fatal self-test step is deliberately allowed to block every page in the queue rather than let anyone reason "the gate code has a bug, but the pages are probably still fine." The fix threaded the frozen today value through all nine of that section's call sites and added ISO-date validation at both entry points, so a missing value fails loudly instead of defaulting into a silent pass. Publishing resumed the same day, and within about twelve hours the pipeline shipped two live pages from the sections those refusals had wedged: the build-vs-buy hub page and the Shopify Partner API connect page. The refused chatbot-pricing row itself is still in the queue as this is written — requeued, not published, because being unblocked earns a page another attempt at the gates, not a pass through them.

08 41 pages we refuse to write

Not every refusal is a bug being diagnosed. Some are a standing, deliberate policy: every one of the 41 rows in the /snippets category — a planned gallery of Liquid code snippets for Shopify themes — currently sits status: "blocked" in queue.json, and has for as long as the category has existed. Verified directly: grep -c "CAPTURE INSTRUCTION" docs/seo/data/liquid_keywords.csv returns 41, matching the 41 blocked rows exactly.

The reason is that a /snippets page's entire premise is "we ran this on a real Dawn dev store, and here is what happened" — the dev-store capture is the asset, not decoration on top of a generic explainer. Until a human actually runs the snippet and records what it did, the cell that should hold that fact in liquid_keywords.csv instead holds a literal, unmissable placeholder: CAPTURE INSTRUCTION (not yet a fact):. Unblocking a row is specified as two edits, in order, in docs/seo/liquid-template-spec.md §6.1 — replace the placeholder cell with the captured fact and its date, then flip the row's status to queued. Doing the second without the first is not a style nitpick; it is a hard gate failure. scripts/pseo/snippetsgate.mjs returns capture-pending and refuses to generate, because the alternative is a page assembled from repackaged public documentation, burning a keyword whose search ranking has to be earned with a real answer.

An AI session with no access to a dev store cannot unblock these rows by writing more convincing prose. It can prepare draft structure. It cannot manufacture the one fact that would make the page honest.

09 The same words, opposite rules

The clearest illustration that these gates encode actual epistemics, not a generic "sound more careful" filter, is that two sections of the same pipeline enforce exactly opposite rules about the same vocabulary — and both are correct, for symmetric reasons.

SectionRule on "we tested / we measured"Why
/connectBanned outrightIts automated data path (connectdata.mjs) holds no credentials for the third-party systems it writes about. It can read public docs and repos, not run the integration. It can never honestly claim to have tested anything, so the vocabulary of having tested it is banned at the reviewer level, not softened.
/snippetsRequiredThe page's only asset is a real capture from a real dev store. Once that capture has landed, withholding hands-on language would understate the one thing that makes the page worth publishing — and before it has landed, the row is blocked (see above), so the requirement never fires on an unearned page.

Applying either rule to the other section produces exactly one of the two failure modes this whole pipeline exists to prevent: /connect borrowing /snippets' rule would fabricate a measurement nobody took. /snippets borrowing /connect's rule would strip the one sentence that justified writing the page. CLAUDE.md calls this out directly as a documented inversion precisely because it is not intuitive, and a reviewer trained on one section's checklist would get the other section's page wrong with full confidence.

10 What this actually buys you

None of this makes the pipeline infallible — it has bugs, as Refusal #3 shows in detail, and it will have more. What it buys is a specific, narrower guarantee: when this pipeline is uncertain whether a fact is true or whose fact it is, its default is to refuse, not to hedge the copy and ship anyway. A page sitting in blocked or queued for two weeks costs nothing but the keyword's ranking window. A published page stating a number nobody can trace back to a dated, sourced capture costs the only thing a technical audience is actually buying when they read one of these pages: the ability to trust the next one without re-checking it themselves.

That is also the whole thesis behind why an AI-native agency writes about its own tooling this way. We build systems that let AI move fast on volume — 9,342 keyword candidates evaluated, 157 pages shipped — precisely by building the machinery that lets it refuse the other 9,185 for a stated, checkable reason instead of a shrug. If you're evaluating whether to let an AI system publish, generate, or act on your behalf without a human checking every output, the question worth asking isn't whether it's fast. It's whether it has a mechanism like this one: something dumber and stricter than the AI itself, that the AI cannot argue its way past.

If you're building something similar — or trying to figure out where your own AI pipeline would fail this test — that's the kind of system design we do.

Source table

ClaimSource (this repo)
157 published / 9,342 total candidate rows, full status breakdowndocs/seo/data/queue.json (computed directly, 2026-09-02 snapshot)
The 8-step generation pipelinescripts/pseo/generate.mjs (header comment), scripts/pseo/run-batch.mjs
"A failed deploy never lies" / publish-only-on-200 mechanismCLAUDE.md; scripts/pseo/mark-deployed.mjs
Three hard gates; volume is not a gateCLAUDE.md ("pSEO: what actually gates a page")
Uniqueness ledger mechanism (assertUniqueAssets, assetRenderedIn)scripts/pseo/uniqueasset.mjs (header comment + implementation)
Refusal #1: build-vs-buy-ai-chatbot two-strike quarantine, verbatim blocked_reasongit show 619cc1a -- docs/seo/data/queue.json; fix in commit 4343868; template unblocked in 10546fe
Chatbase pricing drift check reporting "inconclusive" not "drift"scripts/pseo/builddata.mjs (recheckVendor); referenced in commit 34a3b75
Refusal #2: connect-directory wedged on Partner API / Magento boundary bugcommit 34a3b75; fixture assertions in scripts/pseo/gates.selftest.mjs §4a; live page at connect/shopify-partner-api-mcp-server in queue.json (published 2026-08-31)
Refusal #3: self-test date bug, 4 red runs from 2026-08-29 to 2026-08-31commit e4be169; publish schedule (twice daily) in .github/workflows/pseo-publish.yml
"Gate self-test (pre-generation, ALWAYS FATAL)" step name.github/workflows/pseo-publish.yml
41 of 41 /snippets rows blocked, capture-pendingdocs/seo/data/queue.json (computed); docs/seo/data/liquid_keywords.csv (41 CAPTURE INSTRUCTION placeholders); docs/seo/liquid-template-spec.md §6.1; gate in scripts/pseo/snippetsgate.mjs
/connect bans "we tested"; /snippets requires itCLAUDE.md ("pSEO section map")

07 Frequently asked questions

How many pages has INSO actually published with this AI pipeline?
157, as of the 2026-09-02 queue snapshot in this repository (docs/seo/data/queue.json). Of 9,342 candidate keyword rows, 157 are published, 8,182 are pruned (rejected before any page was drafted), 715 are blocked (drafted or attempted, then held back by a gate), 259 are queued (waiting their turn), and 29 are clustered into a single page each. The published count is a small fraction of the candidates on purpose.
What stops the AI from publishing a page with a wrong or made-up fact?
A set of hard gates that run before a page can be committed, and a second, independent audit that runs against pages already on disk. They check three things: whether the page's central claim is checkable against a live capture, whether that claim is uniquely assigned to that page (not copied from a sibling), and whether every price or absence claim carries the date it was verified. A gate failure blocks the page — it does not get "fixed" by softening the claim into something vaguer.
What does "published" mean if a page can still be wrong later?
It means the page was live and returning HTTP 200 at the moment mark-deployed.mjs checked it — verified against the internet, not against the commit log. Prices and absence claims carry the date they were captured for exactly this reason: a true statement about a vendor's pricing page on 2026-07-26 is a dated historical fact, not a promise that the vendor never changes its prices.
Has the pipeline ever actually refused to publish something?
Yes, and this article documents three instances with git commit hashes and CI run IDs: build/build-vs-buy-ai-chatbot sat in a blocked queue state for 13 days before a diagnosed gate bug was fixed; a /connect page for the Shopify Partner API failed on every generation attempt because a fact-ownership check mismatched on a substring, and it published only once that boundary bug was fixed; and the nightly publish workflow itself ran red for 4 consecutive runs when a self-test fixture went stale, because the self-test step is hard-coded to block everything rather than let ungated code run.
Why are all 41 /snippets pages still blocked?
Because the underlying claim on each of those pages is "we ran this Liquid snippet and here is what happened," and nobody has run them yet on a real Shopify dev store. The data file that would hold each page's central fact (docs/seo/data/liquid_keywords.csv) still holds a literal placeholder string reading "CAPTURE INSTRUCTION (not yet a fact):" for all 41 rows, and the gate (snippetsgate.mjs) refuses to generate a page from that placeholder. This is deliberate: the alternative is writing 41 pages from repackaged public documentation while burning a keyword whose search ranking was earned honestly.
AM
Alex Mashkovtsev
Founder · Engineering Lead at INSO

Alex leads engineering at INSO, an AI-native product & commerce studio. He's shipped custom Shopify apps, checkout redesigns, and theme architecture for brands across the US and EU.