Skip to main content
Until 2026-09-12 a post published under nobody, carried no structured markup beyond the page nodes the SEO suite wrote, was drafted from an outline the writer guessed at rather than the one the search results reward, argued nothing a competitor would not also say, and shipped under the first title the model produced. Four contracts close those gaps. Each is a row, a route and a rule the Content department, the API and the Claude Code plugin share.

A named author on every post

Authors

An account keeps its content authors, the named practitioners its posts publish under, as rows of their own: name, role, bio, headshot_url, credentials, same_as (profile URLs), voice_notes and is_default. An account with any authors has exactly one default: the first author created becomes it, and is_default: true on another author makes that one the default and clears the previous one in the same transaction. Turning is_default off on the current default is refused (409 default_required); make another author the default instead. Deleting the default while other authors exist is refused too (409 default_in_use); the last author can go. A name is unique per account (409 duplicate_name). Deleting an author unlinks its items rather than deleting them. A content item names its author through author_id, one of the eleven fields every content writer accepts; every read returns author { id, name } beside it. An item with no author_id publishes under the account default. GET /api/olympus/marketing/content-authors (content_authors_list), the create, get, update and delete routes and their dashboard twins are on the Content API. The pre-publish check raises author_missing as an error when the row has no author and the account has no default author, so a post cannot reach the site anonymous by accident. The department reads the authors first, writes By: <name> in its header, and when the account has none it says so and offers to create one (content_authors_create) before drafting; it never invents a byline.

The markup a publish emits

content_publish_to_site (POST /api/olympus/marketing/content/{id}/publish-to-site) does two things with the author on every publish. It maps the resolved author (the item’s, else the default) onto the collection’s author reference field and creates the entry in the scaffold’s authors collection when it is missing, so a preset blog whose schema requires an author no longer fails on that field; a slug already typed into settings.cms_fields for the field wins, and a required author field with nothing to map is 422 { field, code: "author_missing" }, and a required author field whose author exists but whose entry in the authors collection could not be written (the collection missing from the manifest, a field the profile cannot fill, a provider refusal) is 422 { field, code: "author_entry_failed", author_id, warnings[] } with the cause in the message, to relay rather than to answer with another author. It then builds the page’s JSON-LD graph: the WebSite, Organization and WebPage nodes the SEO suite already wrote, followed by an Article with the author as a Person (name, jobTitle, sameAs, image), datePublished, dateModified, the hero as its image and the tags as keywords; a BreadcrumbList from the page’s path; and a FAQPage when the item’s faq holds at least one real question and answer. The graph is stored on the item as settings.json_ld (with json_ld_generated_at) and written into the collection’s JSON-LD field when the collection has one (a string, html or object field named jsonLd, schemaMarkup, structuredData or customCode); the publish response says which with json_ld_target: "field" | "row"; a value already typed into settings.cms_fields for that field is published unchanged and the graph stays on the row. On a static site the deploy injector adds the same three nodes to the page whose route matches the item, so the markup is on the page even when the collection has no field for it. The faq field is [{ question, answer }], at most 20 pairs, and the department fills it with the three to six questions the research and the proof pack surfaced (the SERP brief’s people-also-ask, ticket first messages, call summaries), each answered in one to three sentences. answer_block is the 40 to 60 word direct answer the department places under the H1 and stores on the row, so an answer engine finds the answer before the argument. The check raises faq_schema_mismatch when the body has a FAQ section and faq is empty, or the reverse, and answer_block_missing when neither the row nor the lead paragraph carries the answer. An unpublish builds no graph. A Webflow collection reaches the graph only through a mapped field; when it has none the publish returns a publish_warnings[] line saying so, and every publish_warnings[] line is to be relayed the way the SEO warnings[] are.

llms.txt

Every native publish and unpublish regenerates the project’s llms.txt (at public/llms.txt on a Next.js, React, Vite, Remix, Astro or SvelteKit project, else at the root): the site’s pages first, then its published posts by their published route, on the production host, deduped and capped. The publish response carries llms_txt_regenerated. The scheduled-publish cron flips an entry live without this path, so a scheduled go-live regenerates nothing until the next manual publish; that gap is known and listed below.

The SERP brief: the specification a draft waits for

What a brief holds

The search results for the target keyword are the specification of the page that will rank for it. POST /api/olympus/marketing/content/{id}/brief (content_brief_build) reads them once and writes the brief: The model is asked only to break a tie: when the intent rules cannot separate two verdicts, or a verdict fell to its default, one JSON completion decides, and an answer outside the vocabularies leaves the rule verdict standing with a warning. Everything else is deterministic. The brief is stored three ways: as a serp_brief knowledge artifact in the account’s Content research knowledge base (a re-run refreshes the same artifact; find it with kb_artifacts_list({ artifact_type: "serp_brief" }), see Artifacts, and how to find them); as a row in seo_serp_analysis under the SEO project whose domain matches the site, so the stored SERP read finally has a writer; and, last, on the item itself: search_intent, page_type, serp_brief and serp_brief_captured_at as columns, plus the settings.serp_brief stamp the research run reuses for 30 days instead of paying for the SERP again. POST /api/olympus/marketing/content/brief (content_brief_topic) builds the same brief for a topic that has no item yet and stamps nothing; the artifact is the deliverable.

Why a draft waits for it

The four brief columns are read-only on content_create and content_update: sending a value is 400 read_only_field naming the brief route as the writer, while echoing back exactly what the row holds is accepted, so an autosave or a whole-row PATCH never trips it. The brief route is the only writer, which is what makes the columns trustworthy: when search_intent says commercial, the SERP said so. The department builds the brief before the outline. Its header carries Intent: and Type: read from the row, the outline covers what_the_top_3_all_say and lands inside the word_count_band, the FAQ comes from paa, and the thesis (below) is the sentence that disagrees with the consensus. A draft written before the brief is a draft written to a guess, and the department says so rather than proceeding. GET on the same route returns what is stored, so a second writer never rebuilds a brief that exists.

What a build spends

One live Google SERP read through DataForSEO plus the on-page parses (three by default, five at most), all counted against the account’s monthly SEO research cap; a cap already spent is 402 seo_research_limit with nothing written, and a cap hit part-way through the parses is a warning on the brief. At most one model completion, for the tie-break. The market is the account’s SEO location unless location_name or a known location_code is sent. Idempotency-Key is honoured, so a retried POST does not read the SERP twice.

Positioning: the thesis every piece argues

The positioning object

The brand positioning lives on the active brand guide (the project’s own guide when the piece has a project, else the account-level guide), as one object: thesis (the one sentence the brand argues), beliefs[], we_are_against[], category_name (the category the brand names, also the category the bottom-funnel plan builds its “best [category] for [segment]” pages on) and proof_points[{ claim, source }]. GET and PUT /api/olympus/marketing/brand/positioning (brand_positioning_get, brand_positioning_set) read and write it; the dashboard twins take marketing.branding. PUT replaces the whole object. Send every key each time; a key left out is cleared. Strings are trimmed and capped (thesis 500 characters, category 120, each belief or against 300, twelve entries per list, twelve proof points), a proof point needs both claim and source, and a 400 names the field. A saved positioning is mirrored as a verified positioning knowledge artifact in the Content research knowledge base so kb_search finds it; a failed mirror is a warning on the save, never a failed save. The department drafts the object from the mission statement, brand_is and brand_is_not, the avatars’ objections and the proof in the before/after grids, and asks the owner to approve before it writes.

Thesis and hook in the header

Every draft header now carries two more lines. Thesis: is the one sentence the piece argues, from positioning.thesis or one of the beliefs, else the piece’s own claim; a draft without one is not a draft. Hook: is the pattern of the opener, one of the sixteen hook slugs the social playbook and the title generator share, persisted on the item as settings.hook_pattern. The rubric gains a fifth axis: at least one sentence a competitor would not publish, which passes when the body carries a belief from the positioning or states a disagreement with what the top three results all say.

Titles that make a claim

Five candidates per piece

POST /api/olympus/marketing/content/{id}/titles (content_titles_generate) asks the content department for five title candidates (up to ten), each tagged with one of the sixteen hook patterns: specific-number, contrarian, mistake, before-after, unanswerable-question, persona-callout, curiosity-gap, objection-first, in-medias-res, hot-take, list-promise, myth-truth, customer-quote, timely, proof-teaser, definition-reframe. The patterns offered are filtered to the item’s journey stage: a Schwartz stage name such as Problem Aware maps exactly, a funnel name goes through the stage classifier (awareness opens unaware and problem-aware patterns, consideration the solution- and product-aware ones, decision and retention the product- and most-aware ones), curiosity-gap is open at every stage, and an unknown stage opens all sixteen. The candidates are argued from the positioning when one is stated (positioning_used). The builder validates what the model returns. A line is dropped and named in dropped[] when its pattern is not open at the stage, it carries an exclamation mark, it duplicates another candidate or it equals the current title. The survivors are stored as settings.title_candidates (replacing the previous set; hook_pattern and title_picked survive), and nothing else on the item changes. The pre-publish check meanwhile warns title_generic on a title with no number, no name, no claim and no contrast, which is the shape every candidate is generated to avoid.

Picking one

POST /api/olympus/marketing/content/{id}/titles/pick (content_titles_pick) with a 0-based index writes the candidate to title and meta_title (apply_to narrows it to one), sets settings.hook_pattern to the candidate’s pattern when the H1 changed, stamps settings.title_picked { index, picked_at, picked_by, applied_to } so the test below measures from this moment, and records a content_versions row through the same writer the update route uses, so the change is in the version list and reversible. The new title reaches the live page only on the next publish of a published piece; nothing republishes on its own, and Search Console keeps measuring the old title until then, so a pick on a live piece is followed by content_publish_to_site. GET on the titles route returns the candidates, current_index (which one is live), the last pick and the latest results. The department presents the five with pattern and rationale and picks the one the user chooses, defaulting to the one whose pattern it used for the opener; it never writes title_candidates by hand.

The weekly title test

Every Monday at 06:10 UTC the content-title-results job pools three sources for every published item that carries candidates: The job writes settings.title_results (by_candidate[] with impressions, clicks, CTR and source, the baseline, the winner with its lift, and notes[]), and writes the winning candidate to meta_title only, with a content_versions row, when it beats the live title by at least 20 percent relative and half a point absolute on at least 500 impressions and 10 clicks over the last 90 days. The H1 stays; a person changes it through the pick, and the winning meta title, like a picked one, is on the page from the next publish. A candidate can win on social and email numbers alone against a Search Console baseline; the thresholds make that deliberate, and winner.sources names where the clicks came from. An account with no Search Console archive is listed in gsc_skipped with the note No Search Console archive for this account, and nothing is written without a baseline. The off-site convention is what makes the test possible: when the social or email department repurposes a piece that has candidates, each derivative carries a different candidate as its first line and is tagged title:<index> (social) or utm_term=title-<index> (email). An untagged post is ignored, not counted against any candidate.

Known limits

  • The scheduled-publish cron flips an entry live without the publish path, so a scheduled go-live regenerates no llms.txt, stamps no refreshed_at and emits no JSON-LD until the next manual publish of that item.
  • Server-rendered Next.js posts are not HTML files at deploy time, so the deploy injector cannot stamp them; their graph is on the row (settings.json_ld) and in the collection’s JSON-LD field when one exists, and the template must render it in the head.
  • Search Console CTR lags the sync by two to three days and needs the SEO connection to have written rows; the title job never calls Google itself.
  • A winner needs a baseline: an item whose live title is not one of the candidates and has no Search Console rows never gets a winner written, and notes[] says so.
  • Two guides in one account get two positioning artifacts; the mirror refreshes the artifact of the guide it saved.