Skip to content

Collections — the content engine

Module id: 1a-blog-publisher (frozen — it is the settings row every workspace keys on) · Mounted at: /api/modules/collections · Always on (not per-workspace toggleable; its row also carries workspace identity).

Purpose

Collections is the writing-and-publishing engine. It turns a topic decision into a fully-drafted, multi-locale article, lets a human or the strategy agent review and edit it, and publishes it to whatever CMS the workspace connected (Payload, WordPress, Webflow, Drupal, Shopify, Wix, or a custom endpoint). It also serves the Content → Collections UI: the content calendar, the SEO performance list, per-article analytics, the editor, and the media library.

The CMS adapters and format converters live in the spine at workspace/collections/; this module is the transport, the writer pipeline, and the item store.

Features

  • Article writing pipeline (write-from-spec.jswriteFromSpec): per-locale native brief adaptation (not translation), per-locale live SERP context (PAA / related searches / top-organic titles via DataForSEO), Perplexity research enrichment with source validation + competitor-domain filtering, ideal-client + catalog prompt assembly, Sonnet generation per locale, a link-policy enforcement pass with one regeneration retry, a copy-editor pass, a mechanical internal-link clean (services/link-hygiene.js — own-domain links can never carry a campaign tag), CTR-optimized meta title/description generation (with one retry), custom-field population, cover-image search, and up-front taxonomy (relationship-field) classification. Writing never publishes — drafts always land as pending_review; publish is a separate step. Degradation warnings (enrichment fallback, missing meta, missing image) ride the result so a degraded write is never indistinguishable from a clean one.
  • Brief builder + gate (services/brief-builder.jsbuildBrief, W-2): every write path (manual dialog, chat, autopilot) runs through ONE builder before the expensive stages. It completes a partial spec (derives the target keyword when absent or template-filled, sharpens a missing/generic angle from live SERP titles, suggests a word target when the SERP demands long-form; one Haiku call + one cached SERP read), carries the customer_question the piece must answer (supplied by the caller or derived — the writer's answer-first lead answers it verbatim; the content doctrine's anchor since Autopilot v2), and enforces the mechanical gates: exact-duplicate keyword/title in the collection → reject naming the existing piece; the workspace's standing instructions (agent memory, supplied by the caller as spec.workspaceDirectives — this module owns no agent state) → applied while deriving the angle and customer question, and reject when the topic itself is one the workspace has said it does not write about (a framing/audience/tone rule is obeyed, never a rejection); bare definitional "what is X" TOFU → reject (deterministic regex pre-model + model verdict; tofu_justification overrides); off-territory — the topic serves a different BUYER than the workspace's declared territory → reject (gate.territory); ROI/"worth it"-shaped briefs without user data → angle reframed to decision criteria so the writer never invents break-even math (measured grounding failure). The directive gate runs FIRST among the post-model gates so a forbidden topic is never rescued by the reframes below it, and costs no extra call — it is one more field on the classification Haiku already makes.

The territory gate (2026-08-08) closes the gap the crm boekhoudkantoor incident went through: the board offered Luniq — a company that builds and manages B2B websites — a one-click write for CRM for accounting firms. Nothing was broken. idealClient was populated and specific, and it was already reaching this very call — as a suffix on one prompt line, which is background, not a test. Every gate here judged the CRAFT of a brief; none asked the only question that disqualifies a topic outright. The fix is not new data: the canonical spine helper (workspace/territory.jsterritoryBrief) renders the declared territory once, TERRITORY_TEST ships beside it so the block is read as a verdict rather than description, and off_territory is one more field on the same Haiku call — no new model call, no new settings key. It sits AFTER the directive gate on purpose: an explicit operator instruction outranks an inference from the declared buyer, so when both fire the human's own words are the reason the user sees.

Calibrated against false POSITIVES, which are the expensive direction — a wrongly refused opportunity is invisible. The test rejects the WRONG BUYER only, never the right buyer asking an adjacent question, and says so explicitly ("when genuinely unsure, treat it as ON territory"). Measured on live workspaces before shipping: the incident case rejects with a precise reason, three deliberate edge cases (GDPR for B2B lead forms, B2B sales-cycle length, Core Web Vitals for marketers) all pass, and across 30 real banked ideas in three other verticals — coffee machines, HR services, livestreaming — it fired zero times. A workspace that has declared no territory gets gate.territory: 'n/a' and nothing is refused; a classifier outage gets 'skipped', never a silent 'pass', so a piece written ungated is distinguishable from one that passed. Directives are stamped on the brief (workspace_directives) and rendered into the writer's prompt last, closest to the task, since they outrank anything inferred from the SERP. The resulting brief persists on every article row (blog_articles.brief, jsonb, group-level) with per-locale native_keywords stamped after adaptation — the refresh dividend. Fail-open on infra: SERP/Haiku outage records builder_degraded and never blocks a write; gate rejections throw with a user-facing, actionable message.

  • Structure variants (W-3): brief.intent_shape selects the STRUCTURE skeleton the generator receives — how-to (answer-first + numbered steps), comparison (criteria-led, honest "when the other option wins" required), decision ("choose X if / skip X if"), best-of (selection criteria + per-option verdicts), alternatives, local, justified-definitional — instead of one hardcoded outline. Defined in workspace/collections/writing-doctrine.js (structureRules(intentShape)); all variants are CMS-safe (the table ban stands — comparisons render as lists).
  • Landing-page pipeline (write-landing-page.jswriteLandingPage) — switched off 2026-08-27: the landing_page archetype carries status: 'off' in workspace/collections/content-types.js, which hides it from the picker, clamps it to article on resolve, and drops write_landing_page from the manifest (isContentTypeEnabled); the code stays for when it returns. When on, it is a second archetype with a conversion doctrine (promise → problem → outcome → offer → proof → objections → single CTA); no SERP/keyword/link-policy machinery. Grounds all claims in the catalog — it won't invent features, guarantees, or pricing. Since W-3 it generates its copy and hands off to the same finishDraft spine as the article playbook (meta + custom fields + hero image + taxonomy + pending_review save, with the warnings channel) — the previously duplicated ~120-line tail is gone.
  • Output gate (W-4): after the pending_review save, every locale row is scored with the SAME 100-point scorer the UI/agent read (scoreArticleFromDb on the persisted row — what's scored is exactly what the reviewer sees) and gets quality_score + quality (section breakdown) persisted. Score < 70 (under the baseline's worst legitimate score) or missing meta title → needs_attention = true: an amber flag in the review list, a warning on the job result, and NEVER eligible for autopilot publish (the B-4 invariant). No auto-retry yet — the scorer has a known cross-language artifact (W-5); retrying false negatives burns money. Scoring failures never fail a write that already saved. Article playbook only (the scorer is article-shaped).
  • Archetype consistency (W-3): both playbooks assert the target collection's registry entry (writerPlaybook on content-types.js) matches — writing an article into a landing_page collection now fails with the right skill named instead of silently producing the wrong shape.
  • CMS optional (2026-08-27): the write path no longer runs the publishing guard — writeFromSpec, writeLandingPage, the write_article preflight and the brief-preview route overlay the collection with resolvePublishTarget only, so a collection that holds drafts (no CMS, credentials incomplete, nothing mapped) is still written into. preparePublishSettings guards the PUBLISH path alone (blog.js#republishArticleGroup, and through it publish_article and the article page's Publish button), refusing with workspace/collections/publishing.js#publishingBlockedMessage and the mode's reason as .code. write_article carries the mode in its result (publishing) and adds a "Draft stays in Orbit: …" observation when the collection does not publish; get_collections carries publishing per collection and cms.connected in the envelope; the collections API list carries publishing per row for the card badge and the article page.
  • Copy-editor (services/copy-editor.js): one Sonnet "edit, don't rewrite" pass per locale — strips em-dashes (deterministic regex) and AI filler, moves inline body links to a Sources section, fixes business-framing errors, with aggressive safety bails that revert to the writer's draft on any over-editing signal (word-count drop, dropped headings/entities/links, FAQ/Sources shrinkage).
  • Publish (blog.jsrepublishArticleGroup): pushes an article group live with publish-once semantics (plans/cms-contract-v1.md §3). No CMS post → CREATE (all locales). Existing CMS post → COMPLETE only: locales whose first publish failed are added onto the existing document via the adapter's completePublish (the same _addTranslation hook as first publish); live locales are never re-pushed, and a fully-live group is refused ("editing live content isn't available yet"). On platforms that publish all languages in one structural operation (Webflow/Wix), completion refuses honestly before any write. Refuses to publish discovered articles (externally owned). Marks per-locale success/failure truthfully, backfills the computed url, and requests Google indexing. Powers the UI publish buttons and the agent's publish_article skill. Degradations the publish SURVIVED — a dropped cover image, a field the adapter had to skip — now ride back folded into the result's warning alongside any failed locales, so an incomplete live article is no longer indistinguishable from a clean success. Publication timestamps ride ONE helper (publicationTimestamps) shared by both branches, so they cannot drift: published_at (when it went live) and first_published_at (the write-once cohort anchor, so "how did the pieces we published 30 days ago perform" stays stable across later republishes). Both are set only when currently null. The update branch previously stamped neither, which is why every "published this period" counter in the codebase counts created_at instead — see db/blog-articles-first-published-at.sql.
  • Edit-published / republish loop — DORMANT: the machinery stays (editing a published article flips it to unpublished_changes via markDirtyIfPublished), but canEditPublished is false on every CMS (v1 doctrine), so the loop is unreachable for new items and republish-of-live is refused. It wakes per-platform when a CMS revives the capability.
  • The save token is the row's FINAL updated_at (services/article-mutations.js, 2026-09-09): update_blog_articles_updated_at is a BEFORE UPDATE trigger that stamps updated_at = now() on EVERY update, so any second write after the one whose stamp a caller returned leaves that caller holding a stale token, and its next guarded write is a 409 ("changed elsewhere") on a row nobody else touched — the Byve publish incident. markDirtyIfPublished now returns the rows it flipped and updateArticleFields hands back the flipped stamp; the cover-image routes run through setArticleGroupImage, which does the same and answers the addressed row's updated_at. The editor adopts a token only from a server answer (a full read or a mutation's updated_at), publishes through its flushed autosave queue rather than a second parallel PATCH, and re-reads the article after a publish. Pinned by article-mutations.test.js.
  • Publish claims the URL (2026-08-16, corpus-canonical-split): the moment either publish branch stamps a row's url, releaseSitePageTwins (spine, site-context/page-ownership.js) deletes any site_pages row for that URL (exact-normalized match) so a URL lives in exactly one table from the first second, not from the next nightly sweep. Non-throwing: a publish never fails on cleanup.
  • Signal detectors (agent-signals.jsclassifyCorpus, mechanical, no LLM): never-landed left with signal plan step 5 (2026-09-01) — each unlanded piece (live past the 90-day settle window, zero impressions, not indexed) is a per-page xmod:not-visible card now, minted by the cross-module not_visible detector reading this module's listPublishedWithPerformance through the broker, so the work resolves page by page instead of as one unworkable list (the aggregate rows closed superseded via RETIRED_FINGERPRINTS). What stays here: language gaps — a piece earning real impressions in one configured locale that was never produced in another. Both read through listPublishedWithPerformance / localesByGroup and anchor on first_published_at, so a row with no publication stamp is never called a failure. Live across the fleet these are the highest-yield detectors in the product: 83 never-landed pieces on Luniq alone, and gaps like a page earning 1,688 impressions/month that exists only in English. Third detector (2026-08-02): site drift (classifyDrift) — published rows whose stored URL the live sitemap no longer lists, ONE sitemap read per sweep, one aggregate signal. Absence needs a COMPLETE read (2026-08-05): the classifier takes complete from readSitemap and returns nothing when it is false, because a partial read makes every unread sitemap's pages look deleted. It did — reading only /sitemap.xml on a site whose articles live in a robots.txt-declared /sitemap-articles.xml told Forest Forward and Give It Forward that 190 live articles had been removed. The readSitemap fix (robots.txt first) plus this gate took that to 0 false positives while a genuinely missing row still reports. Renamed pages heal via the nightly URL canonicalizer (weekly until 2026-08-18); pages removed on the site while Orbit still says "published" are the CMS-drift case that rots exports and reported numbers (found live: 4 Luniq articles soft-deleted on-site). The publish-cohort card (classifyPublishCohort, agency pass §2b) also left with step 5 — the per-page not-visible cards carry the same pieces individually, and get_publish_outcomes' bucketCohorts remains the program-level cohort read. Sixth detector (2026-09-01, signal plan step 6): cluster coverage (classifyClusterCoverage, same reads as the link scan) — one card per PILLAR (subject cluster:<path>): a KEY catalog page (spine listKeyPagePaths gates it — an 18-collection webshop must not get 18 cards; an unreadable key set freezes the detector) with fewer than 2 supporting pieces, brand suffixes stripped from the pillar name, measured on the pillar's own clicks; pieces relating to no pillar roll into ONE cluster:unassigned deliberate-decision card (≥5 pieces, ≥30% of a ≥8-piece corpus). Replaces the collections:no-supporting-content orphan list card. Fourth detector (2026-08-03, agency pass §2): internal-link gaps (classifyInternalLinkGaps over listPublishedContentForLinkScan + the spine's getCatalogSummary) — commercial catalog pages (products/services/collections) that ≥2 topically-related published articles never link to. Mechanical on purpose: token overlap on title/keyword decides relevance, a substring check on the stored body decides linked-or-not — no embeddings, no LLM, no HTTP. Primary-locale scan (the catalog summary resolves there); cap 3 cards by missing count, per-page-path fingerprints, and deliberately no how — inserting links edits PUBLISHED bodies, which is canEditPublished-gated per CMS.
  • Auto-publish — RETIRED 2026-08-12 (Leon; 9103b54): the A4 lane, its cron/trigger, requireReview, AutoPublishCard, its notification type agent_article_published (registry entry finally removed 2026-08-16) and the listAutoPublishGroups / countPublishedSince reads (removed 2026-08-16) are all gone — zero workspaces had ever opted in. The board is the one place content starts and finishes; edits/republishes/deletes were always human-approved and stay so.
  • Publish outcomes (agent-tools/get-publish-outcomes.js, v3/A6; 2026-08-23: collection scope, offset + total/returned/truncated envelope, rows carry collection_id): the corpus bucketed by time-since-publication (settling <14d, then 14d/30d/60d+ cohorts) with landed rates — the only window anchored on first_published_at rather than trailing from today. Rows with no anchor land in NO cohort and are counted separately (unknown_publish_date). Each piece row carries the stable key id plus the real stored url (both from listPublishedWithPerformance, which now selects url in the same query) — the rows used to be title-only and could not be referenced or acted on.
  • Client review share (share-views.jscontent_draft, manifest shares): a piece parked at pending_review can be handed to the client as a branded public page at /s/:token, minted per article group so the link survives a locale being added. Works for a piece of ANY collection — the payload is versions[], ONE per locale (primary first), each carrying the stored document whole (content_markdown, exactly as it would publish) plus copy resolved server-side (collection · language · reading time, the search-result preview) and details[]the same fields the editor shows, by the same rule (!managed && (!role || relationship)), so a client sees which category and author the piece goes out under. Values arrive readable, not raw: relationship/select ids are resolved to their CMS option labels (an unresolvable opaque id — uuid/long hex — is dropped rather than printed at a client), booleans read Yes/No, dates are formatted, and each field carries the kind the renderer draws — the read-only mirror of the editor's type-first widget registry, resolved server-side so the public page never branches on a type it doesn't know. Every language is readable on the page, because locales are natively written and a client reading the French must see the French piece, not the Dutch one relabelled; a locale row with no body yet yields no version. Never anything collection-shaped. Approving there does not publish: it stamps client_approved_at via markClientApproved on every pending_review row of the group, and the agency still presses Publish — an anonymous link holder must never reach the live site. The two guards (status = 'pending_review', stamp still null) make a stale link and a replayed request harmless, and a stamp that moved nothing answers with an error, never a tick. load resolves a state (in_review / approved / live / withdrawn / gone) because the link outlives the moment it was sent; only in_review offers the verb. Mechanism and gates: Shares.
  • Statuses: draft, pending_review, published, unpublished_changes, failed. Plus client_approved_at — a stamp, not a status: it records that the client said yes on the shared page and never moves the piece (db/content-client-approval-migration.sql).
  • Locales: N languages per workspace (settings.languages, first = primary). One blog_articles row per locale sharing an article_group_id; per-locale native target keyword; per-locale CMS document ids on "separate"-locale CMSs (WordPress/Wix), shared ids elsewhere.
  • Locale sync (services/locale-sync.jsPOST …/sync-locales): after editing ONE locale in the editor, push the change to the sibling locales. Each sibling is re-adapted natively (same substance as the edited version; its own keyword/angle/slug/localized links preserved — not literal translation) using the same per-locale writer system prompt, saved through updateArticleFields (published rows flip to unpublished_changes), then re-scored through the write-time quality gate. Capability rules match edit_article (published siblings skipped without canEditPublished); never publishes.
  • Field mapping: collection-driven. Each FieldDef has a role (maps to a dedicated blog_articles column via ROLE_COLUMN) or is role-less (stored in the custom_fields bag). queries.js is the read-direction projector; the CMS funnel is the write direction. A role with no dedicated column falls through to custom_fields[key] rather than reading null — that is where those values actually live (a tags-role relationship is classified at write time and written to the bag like any other relationship, which is exactly where the editor reads it). Returning null made the canonical projector disagree with the editor about the same field, so every reader downstream of it — agent tools, the content_draft share page — saw a piece with no taxonomy on it.
  • Prompt config (config/prompts.js): dynamically-built, branded, per-locale system prompt from settings (no hardcoded brand) plus the per-collection writer brief; writing doctrine imported from the spine.
  • Content calendar, SEO list, per-article analytics, blank-draft creation, cover-image upload/set, delete (with capability-gated CMS removal), media library + Unsplash search.
  • Content-quality score (services/content-quality.js): a 100-point SEO/AEO/conversion score, a canonical port of the frontend's computeSeoChecklist (parity required), surfaced to the agent via get_item_details.
  • Module pause toggle per workspace.

Routes

Composed in routes/index.js from articles.js + media.js.

MethodPathPurpose
GET/calendar/:workspaceIdArticles grouped by date (optional ?collection=); refreshes GSC snapshots
GET/seo/:workspaceIdEvery article with URL + latest GSC snapshot (7d/28d/90d + query breakdowns), paginated
GET/articles/:workspaceId/runningIs a write_article job queued/running (skill_jobs)
/articles/:workspaceId/brief-previewREMOVED (runs-and-actions PR A, 2026-09-02): every door now gates through the generic POST /api/agent/:ws/skills/:skill/preflight, which runs write_article's own preflight() — same brief build + gates, plus the workspace directives and the caller's honest origin, both of which this route's duplicated copy dropped. Field caps across the dialog form, the skill schema, and locale adaptation now all derive from workspace/collections/write-limits.js (WRITE_LIMITS, asserted by write-article.test.js) — locale adaptation used to accept 600-char angles against a 500 schema cap
GET/articles/:workspaceId/:articleIdSingle article + latest snapshot + sibling locale variants
GET/articles/:workspaceId/:articleId/analyticsSnapshots + live GSC metrics + per-query 90d trend
POST/articles/:workspaceId/:articleId/publishPublish/retry via republishArticleGroup
POST/articles/:workspaceId/:articleId/sync-localesPush the edited locale's content to sibling locales via syncArticleLocales (synchronous, parallel per sibling; optional target_locales body)
PATCH/articles/:workspaceId/:articleIdEdit fields (optimistic concurrency via expectedUpdatedAt; the answer's updated_at is the row's FINAL stamp, post status flip, and is the caller's next token)
POST/articles/:workspaceId/:articleId/upload-imageUpload cover image (group-wide, via setArticleGroupImage; answers image_url + the row's updated_at)
DELETE/articles/:workspaceId/:articleIdDelete article + siblings (capability-gated CMS removal)
POST/articles/:workspaceId/:articleId/set-imageSet cover from URL (group-wide, via setArticleGroupImage; answers image_url + the row's updated_at)
POST/articles/:workspaceId/blankCreate an empty draft (one row per language, shared group)
GET/POST/PATCH/DELETE/media/:workspaceId[...]Media library CRUD
GET/unsplash/searchUnsplash search proxy

Crons

One, light: the URL canonicalizer (Sun 04:10 — services/url-canonicalizer.js, 2026-08-02): reconciles every published row's blog_articles.url against the live sitemap, because publish-time URLs silently age through site path migrations and CMS slug renames (13 stale of 298 found on Luniq — all still resolving via the site's redirects, pure chain-risk). One sitemap fetch classifies the corpus; null/stale rows fill by unique locale+slug match; remaining stale rows follow the site's own redirect chain (paced ~1/s, ≤40/run) and adopt the final URL only when the sitemap lists it and it isn't a listing page — a redirect that settles on a hub/listing page means the article was removed on-site and is reported (unresolved), never stored. Operator trigger: collectionsCanonicalizeUrls. (collectionsRepairTitles / services/title-repair.js — the slug-derived-title repair that existed only for GSC-discovered rows — was deleted 2026-08-16 with the discovery path itself; see the gsc module page and plans/corpus-canonical-split.md.)

Data-pipeline history: the weekly site refresh moved to the Pages module on 2026-07-07; daily GSC sync is owned by the GSC module (the cronGscSync key lives in this row but is consumed there). Strategy runs live with the agent.

Agent tools & skills

Read tools (import only core/ + the module's own queries.js):

ToolWhat it does
get_collections(2026-08-23) The workspace's collections wholesale via queries.listCollections (→ spine getCollections): id/key/name/position, archetype (content-types.js: id, item noun, writer skill, required roles + which are still unmapped), writer config, base URLs per locale, CMS wiring (cms_collection, cms_media_collection, cms_options keys), the FULL fields[] (key/label/role/type/cms_field/max_length/required/generate/managed/ai_instructions/classification_mode/options_count), per-status item counts (countItemsByStatus), and the envelope's cms { type, capabilities } from getCMSCapabilities. collection narrows to one; include_options inlines option values capped at 50/field (options_truncated). Closes the audit's top gap: nothing returned workspace_collections whole, so an item's collection_id was unmappable and an empty collection's schema invisible
get_media(2026-08-23) The media library (workspace_media) searchable + paginated via queries.listMedia (media-queries.js): search over description/filename, limit ≤100, offset, total/returned/truncated; rows carry mime_type, file_size_bytes, width/height. The profile's media section (getMediaSummary) is now a thin wrapper over the same list
get_content_inventoryUnified list of blogs OR site pages (identity + indexation + GSC 28d + pixel summary + bands) + coverage rollup. Blog rows carry the REAL stored url/slug and locale_variants[] with each sibling's url (2026-08-02 — the tool used to hardcode url: null and the agent guessed slugs), plus source (always generated since 2026-08-16 — pre-existing site content lives in Pages, reachable via entity_type 'page' / search_site_pages) and the stored write-time gate quality_score/needs_attention (2026-08-05). status filter (any status or 'all'; default = the live-ish set) makes draft and failed rows reachable — "what's in draft / what failed to publish" — and coverage.status_counts reports every status's count regardless of the filter. Pages widen via tier ('key' default | 'listed' | 'all'), page_type (default = catalog types; pass 'home'/'pricing'/… or 'all') and locale, and every page response carries page_corpus — the whole-site rollup (totals, per-type/locale/tier counts, indexed, untracked, missing, and since 2026-08-17 search = the site's total page clicks/impressions from the latest snapshot per page) from the spine's getPageCorpusFacts. scope_note (2026-08-17): an empty blog result names the page corpus + its search totals and the page call to make; a page result whose scope hides search clicks says shown-vs-total and which dimension to widen — the tool never lets the default scope (key tier · catalog types · primary locale) pass for the whole site. sort = 'action' (default) or one metric desc (clicks/impressions/visitors/leads). Indexation is THREE states for pages (2026-08-23, commit 62cdf3e): a page carrying its own noindex directive is a DECISION, not a backlog item, so it counts in coverage.noindex_count and is excluded from both indexed_count and unindexed_count; indexation: 'noindex' filters to it, and rows carry indexation.noindex. noindex wins where both hold (trailing impressions outlive a deindex by up to 28 days). Blogs have no noindex column, so the value is null there and they keep the two-state path unchanged. Windowed with the shared list envelope total/offset/returned/truncated (2026-08-23; was items_total/items_truncated), view:'list' = lean flat rows (limit ≤1000) for corpus exports. Collections audit (2026-08-23): blog rows carry collection_id + collection_key (a 3-collection workspace is no longer one flat list), title comes from itemDisplayTitle() (title role → first text field) instead of the raw column, status 'live' = published ∪ unpublished_changes, search (title ilike) + needs_attention filters apply at the DB read (so the rollup counts the matches), range 7d/28d/90d drives the pixel block — whose metric keys are suffixed by the window they cover (visitors_7d …) with pixel.window naming it, never a 28d label on a 90d number — and sort gained ctr/position/date/title/quality + sort_dir (nulls last; position/title default asc). Rank + sort live in inventory-rank.js. A failed read returns { error }. Inputs: entity_type, view, collection, locale, status, search, needs_attention, range, tier, page_type, band, indexation, sort, sort_dir, limit, offset
get_item_detailsDeep dive on ONE blog/page across sections identity, gsc, pixel, indexation, quality + opt-in content (the FULL body/page text, 30k-char windows via content_offset; the mandated read before any body edit); quality = the LIVE 100-point recompute (blog-only), distinct from identity's stored_quality (the W-4 write-time gate — the two can disagree and the tool says which is which). Blogs also emit the persisted brief (jsonb object, truncated past 2000 chars), cms_id, client_approved_at, publish_error ({ action, message, at } — stamped by blog.js on a failed publish, cleared on the next clean one; also on inventory rows; db/tool-coverage-migration.sql), and gsc's snapshot_90d; page identity adds heading_outline, schema_types, og_keys, inbound_links, viewport/lang, is_tracked, tier, key_score, collection_id, missing_since, last_seen_at, sitemap_lastmod. gsc_rich_results is pages-only — blogs have no such column and now emit null with a caveat instead of a fake [] (ditto alternate_urls, which is a jsonb object, not an array) Collections audit (2026-08-23): blog fetchers extracted to item-details-blog.js (+ item-details-shared.js); identity adds image_url, content_version, updated_at, first_published_at, collection_key/collection_name/archetype, custom_fields_unmapped (stored keys the field map no longer defines), title via itemDisplayTitle(), and locale_variants[].title; a top-level cms { type, capabilities } block rides with identity; quality is archetype-aware — the article scorer runs only when the collection's writerPlaybook is article, otherwise { not_applicable: true, archetype }; new media section (default set) resolves every media-type field (+ image_url when no coverImage role) to workspace_media rows via getMediaByUrlslibrary: null = stock/CMS-hosted, still a real image. Descriptions say "collection item" where a row may belong to any collection; entity_type values are unchanged URL identity (2026-08-23): url accepted as the alternate to id — resolved through queries.js#resolveItemByUrl (the spine's page-identity.js#resolveUrl: collection items first, then site pages, against urlForms(url) — trailing slash toggled, http→https); entity_type then comes from the hit, the response carries resolved_from_url, a miss lists every spelling tried. range enum comes from core/utils/window.js#RANGES (preset-only: the snapshot store holds fixed windows).
get_content_clustersCannibalization clusters (union-find over title/query/target-keyword Jaccard), one keeper per cluster; the cap is genuinely per-locale (was a workspace-wide 500 while named per-locale) and truncation is flagged. (2026-08-23) Reads through queries.listClusterCandidates (live PRIMARY rows, keyword or not), takes collection, and reports excluded_no_keyword — rows without a target_keyword used to be dropped by the query, so a non-SEO collection read as "no cannibalization" (18 of 36 on the first live run)
get_keyword_validationValidate 1–100 keywords via DataForSEO (volume + KD + CPC + intent), ~$0.05/batch
get_serp_stateLive SERP for ONE query: top-10, PAA, related, featured snippet, AI-Overview presence + cited domains. Capped 3 calls/turn/workspace

Skills (act):

SkillWhat it does
write_articleWrite ONE article (all locales) as reviewable draft. Inputs: title, target_keyword, angle + optional catalog anchor/word count/pinned pages/sources/collection/tofu_justification/pre-built brief. Runs the brief gate (duplicate / workspace directives / TOFU / ROI-reframe) before writing. Est. $0.60. Never publishes
write_landing_pageSwitched off 2026-08-27 — not registered while the landing_page archetype is status: 'off' (the manifest gates it on isContentTypeEnabled). When on: write ONE landing page (all locales) as draft. Inputs: title, offer, collection + optional CTA/audience/objections/proof. Est. $0.40
edit_articleField-driven edit of ONE item via updateArticleFields. chatOnly. Refuses discovered items and published items on non-editable CMSs — since the queueGuard hook ALSO at queue time, so no doomed approval card ever renders (the user sees a plain “article is live, can't be changed from here” line). Forgiving key resolution (normalized/role/aliases) + body_patch find→replace for one small body edit or body_patches (max 20, in order against the progressively-patched body, all-or-nothing) for several spots in one action/approval (exact-unique, whitespace-tolerant fallback; truncation guard stands for full-body rewrites). Body edits start with a full get_item_details sections:['content'] read; description carries the locale rule (one row = one language version, edit the one matching the user's language)
sync_localesRe-adapt an item's sibling language versions from ONE just-edited version — wraps the canonical syncArticleLocales (same path as the editor's Sync-languages button). Per-locale synced/skipped/failed; published siblings need canEditPublished. chatOnly + approval-gated; the agent proposes it after edit_article when siblings exist
publish_articlePublish/republish ONE article group. chatOnly. Changes the live site
delete_articleDelete ONE article + locale siblings. destructive + requiresCmsCapability: 'canDeleteFromCms' — with that capability false on every adapter (v1 doctrine, 2026-08-14) the skill is filtered from listings and refused at queue everywhere; deletes are a human action (article page / the CMS) until a platform re-enables it
(consolidate_articles — RETIRED 2026-08-13)Deleted rather than fixed after the robustness audit found it deleting the loser even on a fully-failed fold. Consolidation is a guided doctrine play now (§B in seo-fundamentals): edit_article folds, delete_article retires LAST, the 301 line is handed over verbatim

Resources (agent-resources.js): loaders for collection, article, and page focus blocks when the agent panel is docked on a detail page. The article focus includes locale_variants (sibling language rows: id + locale + status) so a request in another language resolves to the right row without a tool call.

Settings keys

The '1a-blog-publisher' schema is defined in the module's config.js. Full key-by-key reference: All settings & options. Grouped:

  • IdentitycompanyName, companyDescription, industry, products, languages (ordered, first = primary), serviceAreas (drives DataForSEO location)
  • Operator intentidealClient, visibilityTargets, competitors, brandVoiceNotesByLocale, avgLeadValueCurrency (the workspace display currency; avgLeadValue itself was retired 2026-08-20 — value is per conversion goal, required at goal setup)
  • AI-derived statebrandIdentity, brandIdentityByLocale, brandVoiceByLocale, lastSiteRefreshAt
  • Operational stateimageDimensions, onboardingStatus/StartedAt/CompletedAt/Error (blogsPerWeek retired 2026-07-13 — cadence lives in agent goals; foundationBackfilledAt retired 2026-08-16 with the foundation-backfill modal — pre-pipeline rows promote it to onboardingStatus: 'complete' on the next settings read, then the key is dropped)
  • IntegrationscmsType* + per-CMS credential blocks (Payload/WordPress/Webflow/Drupal/Shopify/Wix/Custom), gscSiteUrl*, gscServiceAccountJson (legacy), gscVerified
  • Schedules + strategycronGscSync (default 0 5 * * *), agent (json: paused — THE per-workspace off-switch, every scheduled job for the workspace skips while set; dailyCostCapUsd, thresholds overrides)

Models (config/models.js): blogGeneration = claude-sonnet-5 (W-6, eval-gated; generationOptions strips temperature and disables thinking for the Sonnet-5 family — the coupled trio), classification = claude-haiku-4-5-20251001, seoAnalysis = claude-sonnet-4-6. WRITER_VARIANT (v3-sonnet5) stamps every article's doctrine_version + writer api_events metadata for cohort comparison.

Classification thresholds (thresholds.js, overridable via settings.agent.thresholds): striking-distance 8–20, underperforming max impressions 50, stale 60d, dead-article age 180d, high-traffic 50, cluster Jaccard cuts (title 0.5 / query 0.4 / target-keyword 0.7).

Data

  • Owns: blog_articles (one row per locale; queries.js is the item read surface, media-queries.js the workspace_media one (list/search/by-url), queries.listCollections the module's door to the spine's workspace_collections; brief jsonb = the persisted writing brief; quality_score/quality/needs_attention = the write-time output gate — all null/false for legacy rows), workspace_media; Storage bucket article-images (+ media bucket). Reads blog_seo_snapshots, skill_jobs.
  • Via spine: site_pages, site_page_snapshots, workspace_collections, workspace_settings, pixel_pageviews.
  • RPCs: latest_blog_snapshots, latest_page_snapshots_for, pixel_articles_summary, pixel_pages_summary, pixel_article_stats.

Key files

FileRole
index.jsManifest: id/name, settingsSchema, routes, queries, agentTools, agentSkills, resources
config.jsThe '1a-blog-publisher' settings schema (frozen moduleId)
config/prompts.jsPer-workspace/per-locale system + user prompts, catalog-context builder, meta + landing prompts
config/models.jsClaude model ids per task
write-from-spec.jsThe article writer pipeline + shared writer spine (finishDraft, saveBlogArticles, generateMetaFields, populateCustomFields, buildArticleUrl, DOCTRINE_VERSION). No post-publish Google ping: requestGoogleIndexing was removed 2026-08-08 — Google honours the Indexing API only for JobPosting/BroadcastEvent, so it accepted and discarded every article while logging success, on a fleet-shared 200/day project quota
write-landing-page.jsThe conversion/landing-page playbook: generation stages only; finishing rides finishDraft. Nothing calls it while the archetype is switched off (2026-08-27)
blog.jsrepublishArticleGroup — the sole CMS publish/republish path
queries.jsCanonical blog_articles read surface + field projector (ROLE_COLUMN, projectItemValues, listItems, getItem, getGroup)
share-views.jsThe content_draft public share view — one piece in review, for the client to read and approve
services/article-mutations.jsupdateArticleFields, deleteArticleGroup, markDirtyIfPublished, markClientApproved (shared by routes, skills and the share view)
services/brief-builder.jsONE brief builder + mechanical gate for every write path (duplicate/directive/TOFU/territory/ROI rules, VoC, keyword/angle derivation)
services/copy-editor.jsThe final "edit, don't rewrite" quality pass
services/content-quality.jsThe 100-point content score (frontend parity mirror)
services/locale-brief.jsPer-locale native brief adaptation
services/locale-sync.jsPush one edited locale to its sibling locale rows: native re-adaptation per sibling (own keyword/angle/links preserved, same writer system prompt), internal links cleaned through link-hygiene.js (the source's links carry over, its old campaign tags do not), saved via updateArticleFields (published → unpublished_changes), re-scored via scoreAndPersistQuality. Skips published siblings without canEditPublished; never publishes; custom fields untouched
services/source-validator.jsHEAD-check + domain-credibility scoring of research sources
services/internal-linking.jsInternal-link + site-link prompt blocks; findRelatedArticles reads Orbit's articles AND collection-attributed site_pages (2026-08-16), so new pieces keep linking to the site's pre-existing posts. Every candidate URL goes through link-hygiene.js#stripTrackingParams before the writer sees it
services/link-hygiene.jsThe internal-link contract, mechanical (2026-08-24): ownHostnames (the ONE derivation of "our site" — GSC property incl. sc-domain: + every collection base URL), isOwnUrl, stripTrackingParams (utm_* + ad click ids, string surgery so an untagged URL comes back byte-identical), cleanInternalLinks (markdown pass). Applied on both sides: the URLs injected into the prompt, and the generated markdown in finishDraft + locale-sync. Tags were APPENDED by our own code until 2026-08-22 (1,003 stored articles still carry them); a tag on an own-domain link splits the target into a crawlable parameter variant (Health's param-explosion/canonical checks), rewrites the session's source in GA4 on the click, and is read by nothing — the pixel attributes internal clicks by referrer. External links are never touched
services/reconcile-posts.jsCollection↔site-page URL-drift self-heal
thresholds.jsClassification cuts used by the read tools