Site architecture: roles, clusters and the keyword map
Page roles
Every page and post on a website project can hold one of four roles, stored on the row (website_pages.page_role and content_items.page_role), never inferred at read time:
GET /api/olympus/marketing/site-architecture/page-roles?project_id= (site_page_roles_get) returns every page and post of one project with its current role, its URL, its cluster role and, for rows whose role is unset, a suggestion with the reason it was made: conversion_goal (a url-kind conversion definition on the account, skipped when the goal sits on a confirmation route such as /thank-you), seo_memory (a Money pages: /pricing, /contact line or a Money pages list in the SEO department’s memory, exactly as the plugin’s decay play writes it), route_pattern (pricing, contact, quote, demo, book; the housekeeping routes for utility), cluster_pillar_url and cluster_role_pillar. POST takes one of two bodies: { pages: [{ page_id | content_id, page_role }] } sets roles in bulk (up to 500, each entry its own write, null clears, a row outside the account is not_found and never updated), or { project_id, seed: true, apply } proposes roles for the unset rows and, with apply: true, writes them. A role a person set is never overwritten by a seed, and an account that has marked no money page gets a note wherever a money page is expected, never a guess. site_page_roles_set is the tool; the dashboard twin is /api/marketing/site-architecture/page-roles.
A page that exists only in the crawl index cannot hold a role until it has a website_pages row (the file-page sync creates them); the seed and the link list match crawled rows to roles by route, so a role set on the row applies to the crawled URL too.
Clusters and the Sunday sweep
A content item joins a topic cluster through two fields the routes accept:topic_cluster_id (a seo_topic_clusters row in the account) and cluster_role (pillar or spoke). Every read returns topic_cluster { id, pillar_keyword } beside them, and the Olympus list filters on both. The writer decides them: the department at brief time, the API, or a person; nothing sets them by inference.
Every Sunday at 06:30 UTC the content-cluster-sweep recomputes each cluster’s scores from published content, after the SEO analysis sweep (05:35) and the decay link (06:20) have written their rows:
coverage_scoreis the share ofcluster_keywordsthat some published item in the account targets (target_keyword, normalised), andmissing_subtopicslists the ones nothing covers, up to 100: the next briefs.internal_link_scoreis the share of spokes (published items with the cluster’stopic_cluster_idand a role other than pillar) whose body links the pillar URL;linking_issuesnames each spoke that does not (spoke_missing_pillar_link) or saysno_pillar_urlwhen the cluster has none.- The pillar URL is the cluster’s
pillar_url, else the URL of the cluster’s item markedcluster_role: pillar; whenpillar_urlwas null the sweep fills it from that item, once, and never changes a stored one. - Every
seo_keyword_clustersrow whose primary or member keyword a published item targets getscontent_status: mappedandtarget_urlset to the item’s URL. Rows already on that URL are skipped; atarget_urla person set to a URL no published item serves (a hosted service page, a page on another site) is kept and named in the sweep’s notes, never overwritten. The sweep only moves a mapping between URLs the content system owns.
The keyword map
GET /api/olympus/marketing/content/keyword-map?project_id= (content_keyword_map; GET /api/marketing/content/keyword-map from the dashboard) is the one read to make before choosing a keyword for a new piece: keyword to item to live URL to best rank position to decay status, grouped by cluster with the pillar row first.
missing_subtopics are the briefs to write); items with no cluster are the last group with cluster: null. best_position is the lowest current_rank among the rank-tracking rows publish-to-site seeded for the item. collisions[] holds the two things a site must not carry: duplicate_target (two non-archived items on one normalised keyword) and cannibalization (an open seo_keyword_cannibalization row, matched by keyword or by a competing page URL, with its severity and recommended_primary_url). totals.decaying counts rows whose decay_status is set and not recovered. The map reads the newest 2,000 non-archived items.
Links that build the architecture
The site-links read the department picks internal links from (GET /api/olympus/marketing/content/site-links?project_id=&content_id=, content_site_links) is ordered for the architecture, not by date: the money pages first, then the pillar of the item being written (its cluster’s pillar_url, else the cluster’s item marked pillar; a pillar URL nothing lists is synthesised as pillar:<url>), then the other pillars, then the rest by recency. Every row carries role, suggested_anchor (the target’s keyword, else its title without the brand suffix) and is_pillar_for_item; the response adds roles { money, pillar }, item { id, topic_cluster_id, cluster_role } and pillar { id, title, url, cluster }. The item asking is never listed as its own target, by URL and not only by id, and when it is itself its cluster’s pillar the response says so in a note and lists no pillar, because a pillar links down to its spokes and to the money pages.
Three check rules hold the structure. money_link_missing is an error on a consideration or decision piece that links no money page (not raised at other stages, and skipped when the account has marked none), pillar_link_missing is an error on a spoke whose body does not link its cluster’s pillar URL, and keyword_already_targeted warns when another non-archived item in the account targets the same keyword. The keyword collision is also reported at write time: content_create and content_update answer with warnings[] carrying one keyword_already_targeted line and keyword_conflicts[] naming the other item, and still write the row. The department reads the keyword map before choosing a keyword, takes one from the cluster’s missing_subtopics when the brief names none, and refuses a keyword the map lists as a collision unless the owner says consolidate. See Pre-Publish SEO Check and The Content Department.
Bottom-funnel pages
Three content types
Three content types join the list every content writer accepts:comparison (your brand vs a rival, with a sourced, dated table), alternatives (rival alternatives, or best category for segment, side by side) and research (an original data study from the account’s own numbers, with the methodology on the page). Each carries structured blocks in settings, top-level keys the settings merge keeps:
Two rules in the pre-publish check are errors on a comparison or alternatives page:
comparison_table_missing when there is no table in the body and no settings.comparison_table rows, and competitor_claim_unsourced for every table row that carries a rival value with no source URL or no checked_at date (a body table needs a link or a [source: ...] marker in the row and a date in the row, the header or the line under the table). A rival’s price or feature is never written from memory.
The plan
POST /api/olympus/marketing/content/bofu-plan (content_bofu_plan; POST /api/marketing/content/bofu-plan from the dashboard) plans the pages people read when they are choosing now, for one website project:
- Rivals are the tracked competitor set of the SEO project whose domain is the site’s host (screened the way the competitors read screens them, sorted by shared keywords, at most ten), plus any hosts sent in
rivals[]. Segments are the active customer avatars (at most six). The brand is the brand guide’s name and the category ispositioning.category_name, else the guide’s industry. - Phrases are built per rival and segment: brand vs rival, rival alternatives, best category for segment, with variants. DataForSEO is asked once for the volume and difficulty of every exact phrase and once, when
include_ideasis true, for discovery ideas from the seeds; both calls count against the monthly SEO research cap. A cap already spent is402 seo_research_limitwith nothing written; a cap hit or a vendor error on the second call is a warning, never a failure. - Gates, in order:
keyword_too_long,volume_below_floor(min_volume, default 10),difficulty_above_cap(max_difficulty, default 70) andkeyword_collision(an item in the account, not deleted or archived, whose normalised target keyword equals the phrase or a variant, or an open cannibalisation row). Every drop is returned indropped[]with its reason and the item or row it hit, so a collision drop is read as refresh that one instead of writing a twin. - Candidates come back with
type(vs,alternatives,best_for), the title, the target keyword and its variants, volume and difficulty, the rival and segment,search_intent: commercial, the template slug andevidence:rival_facts_sources(the rival’s pricing page, its home page and any tracked competitor change URLs, at most five) andown_proof, the proof pack counts for the segment at the Decision stage. - Seeding. With
seed_drafts: trueeach candidate becomes a draft:content_typecomparison or alternatives,page_role: money,journey_stage: Decision,search_intent: commercial, the avatar, the target keyword, the project,settings.bofu(rival, segment, sources, own proof, volume, variants, the template) beside an emptycomparison_table(the six default criteria, values blank) anddecision_cta, plus a first version row. The three bottom-funnel templates (brand-vs-rival,rival-alternatives,best-category-for-segment, identified bydefault_settings.template_slug) are materialised as account templates on first use and appear in the templates list like any other. - The record. The plan is stored as one
bofu_planartifact per project in the account’s Content research knowledge base;GET /api/olympus/marketing/content/bofu-plan?project_id=(content_bofu_plan_get) returns the newest one with what it seeded.
max_candidates caps the plan (1 to 40, default 12). Idempotency-Key is honoured, and two plans for one project that run at the same moment seed each page once: the second reports the first run’s drafts as keyword_collision in seeded.skipped, and one artifact is refreshed. An empty competitor set, no category or no avatars returns a plan with no candidates and a note per gap, and spends nothing.
Writing one
The department’s play runs the plan without seeding first and shows the candidates with their volume, the rival’s pricing URL and the proof counts, and the dropped list with reasons; on an explicit yes it seeds the approved ones. For each draft the rival facts come from the competitor analysis and from reading the URLs insettings.bofu.sources (the pricing page first), and every row written into settings.comparison_table.rows carries source (the URL read) and checked_at (that day). The account’s own column comes from the proof pack. The body follows the seeded template’s headings, an honest-concession section included, and settings.decision_cta names one decision and the offer or URL it goes to. Learn more is not a decision. The check must be clean of the two table rules before review.
The decision loop: double down, refresh, prune
What the Sunday run stamps
The SEO analysis sweep already writesseo_content_decay rows: pages whose Search Console clicks fell between two 28-day windows (see SEO Delivery Tools). Until this round those rows named a URL and nothing else knew which content item served it. Every Sunday at 06:20 UTC the content-decay-link run resolves each row of the account to the published item behind its URL and stamps the item:
The four are read-only on
content_update (400 read_only_field; echoing what the row holds is accepted), so the values are always the run’s. An item whose row converged away is marked recovered with the priority cleared; a row that cannot be resolved to a live URL keeps its link and its stamps rather than being unlinked; a run whose URL resolver fails writes nothing for that account and retries next Sunday. Rows a person closed are mirrored but never notified.
The trigger and the refresh task
Once per decay episode (the pair of item and decay row, claimed on the item assettings.decay_episode before anything customer-visible happens) the run raises content.decay_detected. The Content Decay Detected workflow trigger (contentDecayDetectedTrigger; content_decay_detected_trigger on the API, in the event trigger catalog with a sample payload) carries content_id, title, url, decay_status, decay_severity, refresh_priority, top_declining_keywords, review_disposition, suggested_disposition, decay_id, traffic_decline_pct, peak_traffic, current_traffic, detected_at, project_id and content_type, and filters on project_id, content_types, severities (severe, moderate, mild) and min_refresh_priority, each failing closed. One run per workflow, item and decay row.
When no workflow in the account handles the event, the run files the work instead: a PM task titled Refresh: title (task_type: content, todo, high priority and due in 14 days at a priority of 3000 or more, medium and 30 days at 2000, low below), with the refresh brief below as its description, related_url the live page, the tags content and refresh, and custom_fields.seed_key so one open task exists per episode. The task lands on the PM project linked to the item’s website project, else the newest marketing or SEO project, else a Content project created once. A workflow with the trigger replaces that default.
The refresh brief
GET /api/olympus/marketing/content/{id}/refresh-brief (content_refresh_brief_get; GET /api/marketing/content/{id}/refresh-brief from the dashboard) assembles the brief from rows that already exist and spends nothing: what declined (the linked decay row: clicks, keywords and weighted position across the two windows, severity, type, when it was detected, the sweep’s recommended actions), the declining keywords worst click loss first, the SERP brief stored on the item (re-read before changing the outline; the checklist asks for a fresh one when none is stored), the scorecard from the newest nightly row (views, form submits, leads, contacts, lead rate, best rank, deals won), keyword siblings and any open cannibalisation finding, newer internal-link donors (published pieces newer than this one, same cluster first, at most eight), the suggested disposition beside the one already recorded, and a checklist. It returns data (the structured brief, version: 1) and markdown. A row outside the account is a 404.
The refresh play the department runs from it: re-read the SERP brief (capture one when missing), cover the declining keywords, close the outline gaps against the top three, add links from the donors, publish a new version on the same URL (a version, then the publish), re-run the check, and record the decision on the row. refreshed_at is the proof the refresh shipped.
Dispositions
Five words say what happens to a page, stored asreview_disposition on the item, writable through content_update and a filter on the list: double_down, refresh, rewrite, consolidate, prune. The run only suggests. Its suggested_disposition follows one rule set: zero traffic over twelve months is prune, or consolidate when a sibling holds the keyword; a keyword sibling is consolidate; severe decay with the current traffic at a quarter of the peak or less is rewrite; any other decay is refresh; a top performer that is not decaying is double_down. The refresh brief recomputes it with the siblings in hand; the trigger payload carries the run’s own suggestion, which never says consolidate because the run does not read siblings.
Prune candidates
GET /api/olympus/marketing/content/prune-candidates (content_prune_candidates; GET /api/marketing/content/prune-candidates from the dashboard) lists the published items at least min_age_days old (30 to 1095, default 365) with zero views and zero leads across the last twelve months, oldest first, up to limit (1 to 200, default 50), optionally on one project_id. Each candidate carries views_12m, leads_12m, measured_by, suggested_disposition (consolidate with consolidate_into naming the sibling when another published item holds the keyword or an open cannibalisation row names it, else prune), the recorded review_disposition and decay_status, and default_action: content_unpublish_from_site, which drafts the site entry; nothing leaves the internet until the project deploys.
The measurement is honest about its source. Stored nightly scorecard rows count as the twelve-month measurement only when they cover the window (the earliest row within three days of the window start, or of the item’s published_at when the item is younger); otherwise the item is measured through the live views lookup over the whole window (measured_by: views_lookup) and warnings[] says so. An item that cannot be measured (no page, or the analytics collector unreachable) is listed under unmeasured, never as a candidate. Money pages are excluded and counted. The department reads this list and the refresh queue before it proposes a net-new topic: a decaying page on the keyword is a refresh, never a new page; a zero-traffic page on the keyword is consolidate-or-prune first.
Conversion inside the piece
Offers on the brand guide
The offers a call to action can point at live on the account’s active brand guide, one list read and written whole:GET and PUT /api/olympus/marketing/brand/offers (brand_offers_get, brand_offers_set; /api/marketing/brand/offers from the dashboard). Each offer is { id, name, promise, guarantee, bonuses[], cta_label, cta_url, stages[] }: the promise the reader accepts, the guarantee that removes the risk, the bonuses, the button label and the one link, and the journey stage names it fits (an offer with no stages fits every stage; names match the item’s journey_stage case-insensitively). PUT is a full replacement: read first, send the whole list back. Ids are minted by the server, and an incoming id survives only when it names an existing entry, so content items stay pointed at their offer across a save. The response carries lock_version; a PUT with expected_lock_version behind the guide is 409 offers_conflict with the current version to reload. Limits: 20 offers, names unique and up to 120 characters, promise and guarantee 400, eight bonuses of 200, cta_label 60, cta_url an http(s) URL or a site path starting with / (at most 500 characters), ten stage names of 80.
A content item names its offer through offer_id. The content routes check the id against the list at write time (an unknown id, or no active guide, is 400 invalid_reference naming the offers read), and every read returns it as stored.
The five-stage CTA table
The three buckets (awareness subscribes, consideration downloads, decision buys) are replaced by a five-rung table, Eugene Schwartz’s stages of awareness plus the after-sale rung, keyed to the account’s own journey stage names:buildStageCtaTable produces one row per stage of the item’s journey map, named the way the map names it (a keyword read of the stage name, then its position), with the five rungs as the fallback when the item has no journey. Each rung falls back down its destination list when the first cannot be resolved: a solution-aware piece on an account with no comparison page asks with the lead magnet, then the newsletter.
The pre-publish check reads the same table. cta_missing warns when the body has no ::cta or ::upgrade shortcode, no form, no link whose text carries a CTA verb or whose target is a CTA destination, no link to the offer’s cta_url and no link to a money page. cta_stage_mismatch warns when every call to action the check could place asks for a rung the piece’s stage does not allow (awareness allows unaware and problem_aware; consideration problem_aware, solution_aware and product_aware; decision product_aware and most_aware; retention retention and most_aware): a decision piece whose only ask is Subscribe, an awareness piece linking /pricing. An account whose journey names its last stage Ready to book writes settings.cta_stage_table: [{ stage: "most_aware", journey_stages: ["Ready to book"] }] (with optional verbs and destinations that merge into the built-in row) so the check keys to that name; the department writes the key at brief time from the table it drafted against.
The shortcodes
The department writes placements, never CTA copy. Two shortcodes, each on a line of its own with two colons, mark where the plan’s blocks render:inline sits after the second section, once the reader has committed; end follows the last section; upgrade follows it when the piece has a lead magnet. The HTML twins are the comments hiveku:cta variant=inline, hiveku:cta variant=end and hiveku:upgrade. An unlabelled ::cta is read by position (the last one with nothing after it is the end). Optional verb= and destination= attributes on the end CTA are checked against the stage row; a url= counts toward the money-page and pillar-link rules. The check accepts a looser grammar (three colons, mid-line) than the site renders, so write the strict form.
The plan supplies the copy, so a changed offer or lead magnet reaches every published piece: a CTA block is { variant, verb, destination, headline, supporting_line, label, url, form, proof_type, proof_line } (the form carries a form_key Hiveku captures submissions on) and the upgrade block { kind, title, description, benefits[], download_url, form }. On a markdown body the shortcodes publish verbatim and the site’s PostBody component (in the starter template) renders them as the inline card, the final call to action and the lead magnet; a site scaffolded before the component existed shows the lines until its post page adopts it. On an html-typed body (Webflow rich text, html collections) the mapper renders the blocks as plain HTML asides when it is handed the plan and removes the shortcodes when it is not; the publish path hands it no plan yet, so an html body publishes clean and without the blocks today.
The lead magnet and the checkpoint
lead_magnet on the item is { kind, title, asset_id?, content_item_id?, form_id?, delivery_sequence_id? } with kind one of guide, checklist, template, ebook, whitepaper, webinar, download or other, or null to clear; every id present is looked up in the account (400 invalid_reference naming lead_magnet.<field>). It should name the thing the reader can take away from this piece, the checklist from the checklist post, the template from the how-to, never a generic newsletter. The plan resolves it into the asset, the gated item, the form (with its form_key) and the delivery sequence, reports download_url and missing[], and renders a kind it has no copy for (download, other) as a guide.
GET and POST /api/olympus/marketing/content/{id}/conversion (content_conversion_plan; /api/marketing/content/{id}/conversion from the dashboard) return the plan: the stage row, the whole table, the offer and where it came from (item, stage, first, or none), the lead magnet, the three blocks, the shortcode counts in the body, the journey and guide, and warnings[] naming what is missing: lead_magnet_missing, lead_magnet_reference_missing, offer_missing, offer_stage_mismatch, comparison_page_missing, form_missing, delivery_sequence_missing, journey_stage_missing. POST also ensures the journey checkpoint: when the piece has a lead magnet with a form and sits on a journey stage, that stage gets a form_submit checkpoint for the form, once (the stage exit when it has none, an entry signal otherwise), so enrollment and the scorecard measure the conversion; checkpoint.status is created when this call made it, exists, missing or skipped with a reason. delivery.status says whether the download is delivered: sequence_linked, sequence_inactive, sequence_missing (the form’s submissions land in the ledger and the CRM, but no sequence sends the file yet), no_form or no_lead_magnet. The publish path does not call this route yet; run the POST once the piece is live so the checkpoint exists.
Known limits
- Page roles and cluster fields are stored, never inferred: an account that marks no money page gets
money_link_missingskipped and a note on every link read, and a spoke with no cluster is not checked for its pillar. - The keyword map reads at most 2,000 non-archived items, newest first, and does not flag the cut.
- The bottom-funnel gate compares the raw target keyword case-insensitively against rows committed while the plan ran; a twin whose keyword differs only by punctuation or versus is caught by the plan’s own normalised check, not by that last re-check.
- The decay link derives
refreshed_atfrom the last publish when the publish path could not stamp it, so a refresh is never lost, at most a week late. A decay row a person closed keeps its id on the SEO side; a page that decays again under a closed row is not notified again. - Nothing inserts the shortcodes on its own: the department writes them, and the editor’s helper adds the missing placements on request. An html body publishes without the blocks until the publish path hands the mapper the plan.
- No workflow template ships for the decay trigger yet; the PM task is the default, and a workflow with the trigger replaces it.
Related pages
- Authors, the SERP Brief, Positioning and Titles — the other four round-B contracts
- The Content Department — the Cluster: line, the money-first linking rule, the refresh and bottom-funnel plays
- Pre-Publish SEO Check —
money_link_missing,pillar_link_missing,keyword_already_targeted, the two table rules and the two CTA rules - Distribution, Performance, Proof and Research — the scorecard the prune list and the refresh brief read, and the proof pack the comparison table’s own column comes from
- SEO Delivery Tools — the analysis sweep that writes the decay rows the Sunday link run reads
- Workflow Nodes — the Content Decay Detected trigger
- Content API — every route on this page with its params and response keys