Skip to main content
Until 2026-09-12 a published piece got one post and stopped, the content list showed page views and nothing else, a figure in a draft had no source to cite because the account’s own testimonials, reviews, tickets and grid results lived behind six other modules, and the research a writer did stayed in the chat that did it. Four changes close those gaps, and each one is a contract the Content department, the editor, the API and the Claude Code plugin share.

Distribution is part of the asset

The distribution plan on the row

A content item carries its distribution plan from the moment it is briefed, under settings.distribution_plan:
The channels follow the item’s journey stage and content type: an awareness piece gets the widest plan, a decision piece leans on outreach and the digest, and a social post is itself a derivative and gets an empty plan rather than a plan to repurpose a repurpose. paid is always planned as winner-only, dated thirty days after publish, because nothing is amplified until the scorecard says the piece earned it. The key is written through the same settings object every other writer uses (content_create, content_update, the Olympus PATCH and the dashboard PUT all merge top-level keys), so a plan never overwrites a sibling key and a sibling never overwrites the plan. A malformed plan reads as no plan, never as half a plan. The department writes the plan with the brief. When the Content department creates a piece it writes settings.distribution_plan in the same call as the copy, starting from the default for the stage and type and edited to the brief, and its header line gains a Channels: line:
Channels: none planned is what it says when the row has no plan. The social and email departments mark a row drafted or done with the derivative id in link after they persist a post or a campaign, so the plan is also the record of what was actually made. A publish without a plan is said out loud. When a piece is published with no plan or an empty channels list, the department states no distribution plan before it treats the publish as done, and the editor’s pre-publish warnings gain No distribution plan: the piece will get one post and stop. Neither blocks the publish. In the editor, the sidebar’s Distribution section reads the plan and shows one row per channel with its format, a status chip (planned, drafted, scheduled, done, skipped), the date and the derivative as a link (a social post opens in the calendar, a campaign opens the campaign, a task opens the tasks page, a URL opens the URL). A row’s status, date and link are editable in place and the whole plan is validated before it is saved, so a rejected plan shows the validator’s message and writes nothing; an item with no plan offers Add default plan, which writes the default plan for the item’s stage and type. A social post, whose default plan has no channels, gets neither the pre-publish note nor the action, and an unsaved item shows no section.

The publish event

Every native publish of a content item fires one content.published event: the editor’s Publish, POST /api/olympus/marketing/content/{id}/publish-to-site and the scheduled-publish cron all go through it. A workflow starts on it with the Content Published trigger (contentPublishedTrigger; content_published_trigger on the API), which carries: The trigger’s node config filters on project_id, content_types (an array or a comma-separated string) and first_publish_only, and a filter value it cannot read fails closed. It fires once, at the entry write, and not again from the deploy record, because firing at both would run every repurpose workflow twice per post: a workflow that must act only on a live page filters on visibility or waits for the deploy trigger. A status flip to published on an item with no site binding fires nothing (there is no page to distribute), and a Webflow item published from the Designer fires the Webflow CMS trigger instead. GET /api/olympus/workflows/meta/event-trigger-types lists the trigger with its output keys and a sample_output, a representative payload with one value per key, so a workflow author can wire the template variables without waiting for a real publish.

Auto-repurpose and the weekly digest

Two workflow templates ship the plan’s first two channels as automations. Both stage to a draft or an approval; nothing is posted or sent by itself.
  • Content Published to Social Drafts (content-published-repurpose). On the first publish of a piece, the social agent reads the source package (social_repurpose_source: the title, a plain-text excerpt, the section headers, candidate numbers, quotes and names to confirm, the hero as a Media Library asset and the UTM-stamped live link per platform), writes three posts in the brand voice across the platforms you name, saves each as a draft carrying linked_content_id, the platform link and a first comment, and emails the person who approves posts. A re-publish of an edited item does not re-draft. Variables: PLATFORMS, RECIPIENT_EMAIL, PROJECT_ID.
  • Weekly Content Digest Newsletter (content-digest-weekly). Every Tuesday at 9 AM, lists everything published on the site in the last seven days (the Olympus content list with status=published, published_since and sort=published), has the email agent write the digest from the pieces’ excerpts for the account persona, saves it as a draft newsletter campaign to the audience you name with every link stamped for crediting, waits for a named approver, and sends only after the approval. When nothing was published the run ends with a short email saying so and creates no campaign. Variables: AUDIENCE_ID, FROM_EMAIL, APPROVER_EMAIL, DIGEST_NAME, TIMEZONE.
Both are listed by workflow_templates_list and instantiated by workflow_create_from_template; see Standing Playbooks for the mechanics. The two workflow nodes they use gained the keys they need: Create Social Post (socialCreatePost) forwards link_url, first_comment and linked_content_id, dropping a blank value rather than sending an empty string, and List Marketing Content (marketingListContent) accepts published_since, published_within_days (turned into published_since when the run starts) and sort.

UTM crediting

A derivative’s clicks credit the piece only when the link carries the keys the content attribution resolver reads: utm_medium=content and utm_content=<the item's slug>. Every link the source package returns now carries them:
utm_source is the platform (linkedin, facebook, instagram, twitter, tiktok, google_business_profile, and newsletter on the digest, whose utm_campaign is content-digest), utm_campaign is the slug the page serves at and utm_content is the content_items row’s slug. The package also returns utm_medium and utm_content at the top level so a writer can build a link for a platform it does not list. Before this change the links said utm_medium=social and carried no utm_content, so a click on a repurposed post never reached the piece.
Use the link the source package returns rather than building one from the trigger payload. The trigger’s slug is the slug the page serves at, which is suffixed on a collision (my-post-2), while the credit resolves on the row’s own slug; a link built from the wrong one credits nothing. Hand-written links in the old convention (utm_medium=social, no utm_content) still resolve to nothing.
Once the credit resolves, the piece appears in campaign ROI as a content_item asset with its contacts, deals and revenue, and in the piece’s own scorecard as attributed_contacts.

The scorecard: leads per piece

Traffic rewards the wrong posts. The number that tells the calendar which stage and angle to write next is leads per piece, and until this round it existed nowhere: the list showed ClickHouse page views, content_analytics had no writer, and the parts of a scorecard sat in five tables nothing joined. One module now reads all five, and every surface reads through it, so the list, the editor and the department cannot report different numbers for the same page: The path is settings.published_route, stamped by publish-to-site, normalised the way the analytics routes normalise (query and fragment stripped, trailing slash removed, case kept). A piece with no route has no page to count on: its views, form submits and lead rate are null and the Postgres sources still answer. The collector being unreachable is a degraded scorecard (views null, degraded.clickhouse true), never a thrown error and never a zero, because a caller that reads 0 where the collector was down would prune a working post. Which number is the lead count. form_submits is what the tracking embed saw in the browser; leads is what reached the forms ledger. They differ when the embed captured a submit the site’s own handler rejected, or when a submission arrived by webhook with no embed on the page. Read leads for how many people the page converted and form_submits beside views for on-page behaviour.

In the list and the editor

The content list under Marketing > Content gained a Leads column beside views: the ledger count for the page, with the lead rate in the tooltip, and a Leads sort that orders by the count with ties broken by rate. A collector that could not answer renders as an em dash, never as a zero. The editor’s sidebar gained a Performance section: a window switch (7, 30 or 90 days, or all time) over views, visitors, form submits, leads and contacts, deals when there are any, the best rank position and the keywords, the linked social posts’ engagement, and the lead rate, plus the date of the newest stored nightly row. An unsaved item and a piece without a page get a sentence instead of a row of dashes.

The nightly writer

content_analytics finally has a writer. Every night the content-scorecard job upserts, for every published item with a site page, the row for the last fully closed UTC day: views and unique_views are that day’s own counts, likes, shares and comments come from the linked social posts’ latest snapshot, and external_analytics carries { writer: "content-scorecard", computed_at, scorecard, day }, where scorecard is the rolling 30-day scorecard as of that night and day is { date, views, visitors, form_submits, leads }. The typed columns therefore sum as a daily time series while the newest row’s envelope answers how the piece is doing now. The run is an idempotent upsert on (content_item_id, date), so re-running a day converges on the same rows; each account and each item runs in its own try/catch, so one unreadable row never costs another account its rows; it is bounded to a wall-clock budget with the least recently written accounts first, so a night that runs out of time leaves the freshest accounts for the next one; and a collector outage writes null for the day’s views with the reason in the envelope, never a misleading 0.

On the API

  • POST /api/olympus/marketing/content/views and its dashboard twin: batch page and lead stats for up to 200 { projectId, path } pairs, the read behind the list’s Leads column. Degradation is a 200 with degraded: true; an absent key means no rows, not zero.
  • GET /api/olympus/marketing/content/{id}/analytics (content_analytics_get): the scorecard computed now for ?window plus the stored daily rows, paged and date-filtered, and last_stored. GET /api/marketing/content/{id}/scorecard?window= is the editor’s read of the same.
  • GET /api/olympus/marketing/reports/campaign-roi and GET /api/marketing/reports/campaign-roi: attributed contacts, deals and revenue per marketing asset, content items included, from the same builder as the Reports hub, so the department reads the rows the customer sees.
Every shape is on the Content API page.

Proof and case studies

The proof pack

A consideration or decision piece needs a figure, a quote or a customer’s own words beside each claim, and the account already holds that proof in six modules that never reached a brief. GET /api/olympus/marketing/content/proof-pack (content_proof_pack; GET /api/marketing/content/proof-pack from the dashboard) reads all of them in one call: Every entry names its source (source_type, source_id), carries a ready-made citation ([source: testimonial:<id>], [source: before_after_grid:<id>]) the pre-publish check accepts beside a figure, and a consent flag read from the rows, never assumed. Entries are interleaved in the order the journey stage calls for (decision leads with testimonials and grid results, awareness with tickets and surveys), filtered by avatar_id, journey_stage, keyword and since, and capped by limit (default 40). A source that cannot be read is reported with ok: false in sources and a line in notes; the other six still return. Nothing is written.

The proof rules in the check

The pre-publish check classifies the item’s journey_stage by keyword into awareness, consideration, decision or retention and applies two proof rules:
  • claims_without_source is a warning at every stage and an error at the decision stage: a sentence stating a figure with no source in the same paragraph, where a source is a link, a bare URL or a [source: ...] citation. That is the page the reader is deciding on.
  • proof_per_section (warning) applies to consideration and decision pieces: every H2 section must carry one proof element under it, a figure, a quote, a link or an image, and the check names the sections that have none.
The [source: ...] marker is stripped before word counts and phrase matching, so it never counts as copy. The Content department reads the proof pack before it drafts a consideration or decision piece and puts the citation beside the figure, so the rule is survivable rather than a wall.

Drafting a case study from a won deal

POST /api/olympus/marketing/content/case-study (content_case_study_draft; POST /api/marketing/content/case-study from the dashboard) turns one won CRM deal into a case-study draft. It reads the deal with its contacts and company, the contacts’ support tickets and call summaries for the problem in the customer’s words, the consented testimonial for the quote and a before/after grid’s measured results for the numbers, asks the Content department for the prose, and saves a content_items row of type case_study in draft, grounded on the avatar given (else the grid’s target avatar), at the Decision stage, with settings.case_study and a five-section body: The problem, What we did, Results, In their words, What this means for you. Every result line carries [source: before_after_grid:<id>] and the quote [source: reputation_testimonial:<id>], so the draft passes the decision-stage rules as written. Three rules protect the customer:
  • A won deal is one the account calls won. Its status is one marked is_won in the account’s CRM statuses, or the literal won or closed_won; anything else is 409 deal_not_won and nothing is written.
  • No consent, no quote. The testimonial must be public with consent granted and not revoked; otherwise 409 no_consent and nothing is written. Ticket and call lines shape the problem paragraph in the customer’s words and are never attributed.
  • Numbers never come from the model. The results are parsed from the grid’s measurable_results (from X to Y, X -> Y), each tagged with its source, and the model is told to use only those figures; the quote is copied from the testimonial row and never rewritten. What the model writes is prose.
The draft holds the request open for up to three minutes and honours Idempotency-Key, so a client that times out and retries gets the first answer instead of a second row and a second model call. The other refusals: 404 deal_not_found, testimonial_not_found, grid_not_found; 400 invalid_reference for an avatar outside the account; 502 draft_unavailable or draft_bad_response when the department does not answer with the JSON asked for. In the editor, an item of type case_study shows its record in five sections (Client information, The challenge, Our solution, Measurable results, Client testimonial), read from and written to settings.case_study; the sources each result and the quote were built from are shown read-only, because they are the records the draft came from, not something to type.

Research with the knowledge base

The Content research knowledge base

Each account has one knowledge base with context_type: "content_research", named Content research, created on first use and never duplicated. It is where the research run keeps every page it relied on, every brief it wrote and, over time, every other research artifact the content program produces. It appears on the knowledge base hub like any other KB, filterable by its context type, and kb_search with no knowledge base id covers it along with every other KB in the account.

The research run

POST /api/olympus/marketing/content/{id}/research (content_research_run; POST /api/marketing/content/{id}/research from the dashboard) researches one content item, in this order:
  1. Knowledge bases first. Every non-archived knowledge base in the account is searched for each query (brand guides, service menus and earlier research all count), and each hit is a candidate claim of kind kb.
  2. The SERP. The Google results for the item’s target keyword, through DataForSEO and counted against the monthly SEO research cap. A SERP brief stamped on the item in the last 30 days (settings.serp_brief) is reused rather than paid for again; a cap hit returns serp: null plus a gap (SERP not read: monthly research cap), never an error.
  3. The web. Up to max_sources pages (default 5, at most 10) found by search with their page text, preferring pages in the SERP top 10; a page already in the Content research KB is reused, and a page over 60,000 characters is skipped and reported as a gap rather than truncated silently.
  4. Extraction. Claims, figures, competitor notes and gaps, each claim with a quote copied verbatim from its passage and a source URL. A quote that is not found in the passage is dropped and counted in warnings: the deterministic guard against invented evidence. Without a model key the passages themselves become the claims and confidence_score is 0.
  5. Indexing with provenance. Every web page the run used is indexed into the Content research KB with its URL, and recorded as a source and a page, so the next kb_search finds it and a claim can point at the page it came from. One source failing is reported (indexed: false, a gap) and the run continues.
  6. The artifact. A knowledge artifact of type content_research, named Research: title, with the full result as JSON and the rendered brief as markdown, the markdown also indexed as a document in the same KB so a semantic search surfaces the brief itself. A re-run refreshes the artifact the item already points at rather than piling up a new one.
  7. The stamp, last. settings.research on the item: { version, artifact_id, knowledge_base_id, ran_at, queries, claims_count, stats_count, sources[] (max 10), serp_keyword, gaps[] (max 10) }, merged into settings so sibling keys survive, and written only after the artifact exists and indexing finished.
The body accepts queries (up to five, defaulting to the target keyword or the title), keyword, include_web, include_serp, max_sources, location_code or location_name, index_sources and serp_brief_from_item. The response carries the artifact ids, the stamp, claims[], stats[], competitor_notes[], gaps[], sources[], serp, the hit counts, extraction (llm or fallback), confidence_score, spent and warnings[]. GET on the same path returns the stored stamp and the artifact it points at. POST /api/olympus/marketing/content/research runs the same research for a topic that has no item yet (topic required); nothing is stamped and the artifact is the deliverable. What a run costs. Search credits (one search per query, pages fetched inline), one SERP call unless a fresh SERP brief exists or include_serp is false, embeddings for every indexed page and for the brief, and one model completion for the extraction. The run is synchronous inside the request and may take up to five minutes; call it with a client timeout of four minutes or more, and send an Idempotency-Key so a retry replays the first answer. A re-run spends again for the SERP and the extraction but never re-indexes a URL the KB already holds.

Artifacts, and how to find them

The Content research KB holds one artifact type per deliverable, each named Type: subject, each with content_json.version and content_json.content_id (or null for an account-level artifact), and each with its markdown indexed as a document in the same KB: Two reads find them without knowing the knowledge base id first: GET /api/olympus/knowledge-bases/artifacts?artifact_type=&content_id=&is_verified= (kb_artifacts_list) lists the account’s artifacts by type, newest first, and GET /api/olympus/knowledge-bases/artifacts/{artifactId} (kb_artifact_get) reads the one a stamp points at. Semantically, kb_search finds a brief or a proof pack through its indexed markdown, whose rows carry metadata.artifact_id and metadata.artifact_type; a research source carries metadata.url.

What the department does with it

The Content department searches before it writes: knowledge_search_query across every knowledge base for each H2 it plans, then, when the pinned item has no settings.research or the stamp is older than 30 days, it runs the research and reads it back before the outline. Every number, quote or third-party fact in the draft cites a claim’s source_url inline, or names the knowledge base for a passage that came from one (per our service menu). A page it relied on that is not in the run’s sources[] goes into the Content research KB with its URL, so the next writer finds it. Its header line gains Sources: <n>. The knowledge index the department reads at the start of a turn lists the research KB’s artifacts (id, type, name, updated at) beside the knowledge bases, and each row of its content data file carries the research stamp summary (artifact_id, claims_count, ran_at). In the editor, the sidebar’s Research section reads the stored stamp and the artifact it points at through GET /api/marketing/content/{id}/research and shows the brief: the claims with their source, the figures, the gaps, the sources, when it ran and a note once the run is older than 30 days. Run research (Run again once a brief exists) sends the same POST with a progress line while it runs, and the section states the spend before the run: one web search per query with up to five page fetches, one Google results read counted against the monthly SEO research cap (skipped when a SERP brief from the last 30 days exists), embeddings for each page it indexes and one model call for the extraction; a re-run refreshes the same brief and never re-indexes a page already in the knowledge base. An unsaved item shows no section.

Known limits

  • Related questions and SERP features are empty on a live SERP read; a stored SERP brief carries them.
  • Two runs indexing the same new URL at the same moment can index it twice once; the dedupe is on the stored URL, and the next run reuses the first copy.
  • There is no background job: a run is synchronous inside the request that asked for it.
  • The Content Department — the writer that plans distribution with the brief, cites the proof pack and researches before it drafts
  • Pre-Publish SEO Check — the two proof rules, every other check, and the response shape
  • Standing Playbooks — the repurpose and digest templates beside the other ready-made workflows
  • Workflow Nodes — the Content Published trigger and the marketing nodes it feeds
  • Attribution — how a UTM-stamped click becomes a credited lead
  • Reports — the campaign ROI report a credited piece appears in
  • Content API — every route on this page with its params and response keys
  • Social Media API — the repurpose source package and its UTM links