Theming: the rest of it
Theme your site covers the keys and what each one changes. This page is the parts it leaves out: two undocumented keys, the merge semantics of PATCH, how site themes, theme components and reader themes stack, the analytics tags and the custom <head> (including which of them a reader can actually refuse), how to shape the generated link-preview card, which CSS variables and selectors you may rely on, the fonts and images your pages are allowed to load from somewhere other than us, and the image widths our asset URLs accept.
Two keys in more detail
notFound — your own 404 page
{
"notFound": {
"title": "We moved that one",
"body": "Try the search, or start from the guides index.",
"pageId": "b0662f97-6c2e-4f0b-9d44-1b8f5e3a7c21",
"hidePage": true,
"links": [{ "label": "All guides", "href": "/guides" }],
"showSearch": true,
"showSections": true,
},
}Key | Limit |
|---|---|
| 1–120 characters. With |
| up to 600 characters. Shown only when no page body renders |
| a uuid of a page in this space — on a site's |
| boolean, default |
| up to 8 link objects |
| boolean, default |
| boolean, default |
| boolean — the older spelling of |
|
|
pageId is the 404's body: the reader renders that page's blocks, with your header and footer around them and (unless switched off) the search box and the section list underneath. The answer is still a 404 status with noindex, so a crawler is told the truth — a soft 404 is a not-found page answered with a 200, and this is not one. A pageId that is not there when you save is a 422 naming the key. If the page later cannot be fetched (deleted, unpublished, its space made private) the reader falls back to the title, body and links, never to an error screen. hidePage does its work with the page's own hideFromNav and noindex flags, so the page details panel shows them, and switching hidePage off later leaves the flags for you to clear there. pageSpaceId is written by the API when it checks pageId; do not set it.
ads
{
"ads": {
"network": "…",
"publisherId": "…",
"slotId": "…",
"size": "728x90",
"enabled": true,
},
}Key | Notes |
|---|---|
| 1–40 characters. A preset network name, or |
| 1–120 characters. |
| up to 120 characters, optional. |
|
|
| Defaults to |
With network: "custom" you also supply scriptUrl, slotHtml and configVar.
Some networks cannot run on a forumulate.com address. A network whose script refuses to run inside a frame is refused with a 422 ads.network: "…cannot run on a forumulate.com address — it needs a verified custom domain." The check is that the space is actually served on a live domain, not merely that one is verified.
PUT, PATCH, and how to remove a key
PUT /orgs/{orgId}/spaces/{spaceId}/theme — replaces the whole theme
PATCH /orgs/{orgId}/spaces/{spaceId}/theme — mergesPUT overwrites the entire theme object. Keys you omit are gone. Read with GET and send the union, or use PATCH.
PATCH merges, with three rules:
Nested objects | merge key by key |
Arrays and scalars | replace wholesale |
An explicit | clears the field |
That last one is the answer to "how do I remove a logo". Not an empty string, not omitting it — null:
PATCH /orgs/{orgId}/spaces/{spaceId}/theme
{ "logoUrl": null, "announcement": null }null clears a key of any type: a string, an object ({"chrome": null}), an array ({"chrome": {"footerColumns": null}}), one key inside an object ({"chrome": {"cta": null}}) or one entry of a map ({"settings": {"old_key": null}}). The one place it is refused is INSIDE an array element — {"chrome": {"footerColumns": [{"heading": null}]}} is a 422, because arrays replace wholesale, so a null there would be stored rather than applied.
"" is accepted for the URL keys and is not the same thing: it stores an empty string. If you are clearing a logo, send null.
Because arrays replace rather than merge, a PATCH sending one chrome.footerColumns entry replaces all of them. Send the full array.
Both answer 422 for an unrecognised key, with the path named:
{
"error": {
"code": "validation.failed",
"message": "Unrecognised fields in the request body.",
"details": {
"_": [
"Unrecognised: chrome.footerColumns.0.title. Not fields we have — they would be ignored, so they are reported here.",
],
},
},
}This is the one body in the API that refuses unknown keys outright instead of dropping them and reporting them in X-Unrecognized-Fields. A misspelled theme key is otherwise invisible: the page renders, just not the way you asked. It cost one customer a day on chrome.footerColumns[].title where the field is heading.
The size limits, in characters
Key | Limit |
|---|---|
| 20,000 characters |
| 20,000 characters |
| 10,000 characters |
each | 10,000 characters |
| 50 entries |
| 400 entries |
| 100 entries |
| 20 entries |
| 20,000 characters |
| 10,000 characters |
a nav item's | 20,000 characters |
a nav item's | 10,000 characters |
Characters, not bytes. A 20,000-character stylesheet of multi-byte characters is accepted and is more than 20,000 bytes on the wire. Exceeding a limit is a 422 validation.failed with the field path in the error.
If you are near 20,000, split into cssSnippets — 50 × 10,000 is a great deal more room, each snippet is individually toggleable, and a broken one is easier to find.
How themes stack
Three layers at render time, in order. Each later layer wins.
site theme → space theme → the reader's pickTheme components are not one of them. This page used to list them as a fourth, live layer; they are a source you copy from, and the section after this one says what that actually means.
1. Site theme under space theme. A space that belongs to a site inherits the site's theme as a base, and the space's own theme wins per field. So a site can set the fonts and a space can override just the accent colour.
2. The reader's pick is not a merge at all. A reader theme is a CSS cascade override: its compiled CSS is injected into a <style> element appended to the document head client-side, after the server-rendered theme, so it wins per token. Tokens the alternate does not declare fall through to the space theme.
GET PUT /orgs/{orgId}/spaces/{spaceId}/reader-themes
{ "themes": [{ "name": "High contrast", "theme": { … } }] }Up to 8, each a partial theme. The reader's choice is stored in their own browser under cflx-reader-theme:<spaceId>; "Default" removes the override. We never see it.
Where the picker appears today
Only on forum reader surfaces (/f/…). Stored alternates are served to every surface in the readerThemes of GET /public/orgs/{orgId}/spaces/{spaceId}/chrome, and the control that offers them is mounted on the forum reader and nowhere else — so on a docs, blog, changelog or roadmap space you can write eight palettes, read all eight back, and see no picker.
That is a gap and not a setting: there is nothing to configure that will make it appear. If you want readers to choose, either put the choice on a forum space or wait — and in the meantime the light/dark toggle, which is a separate control and reaches every surface, is the one that works everywhere. The alternates you store now are not wasted: they are already in the payload and will be offered when the picker reaches the other readers.
Header and navigation keys you can mix
Every key below is a theme key (scope: theme — the space's, or the site's for every member space), optional, and works under every look; unset renders the header exactly as it did before the key existed. A look never decides any of them — see Mix and match: looks are patches.
Key | Shape | Unset | What it does |
|---|---|---|---|
|
|
|
|
| array of | today's order | The end group's slots, in this order; slots you leave out follow in today's order. Supersedes |
|
|
|
|
| boolean |
| ☰ at the header's end from 1024px, listing your header, site and footer links — even when the header shows no links. A ☰ with nothing to list at a width is not drawn there |
|
|
| Where the link lives on a wide screen: |
|
|
|
|
|
|
|
|
| ≤ 40 characters, or | "Search" |
|
|
|
|
|
| boolean |
|
|
|
|
| A 32px ring around the |
|
|
|
|
|
| off |
|
|
| its | The drawer button's side of the PHONE row (below 768px): |
|
|
| The ☰ menu button's side of the phone row; |
| ≤ 4 header items (dropdowns allowed) | none | Drawn at the END of the section row — "APIs & SDKs ▾ · Help ▾" |
|
|
| Below 768px, |
| boolean |
|
|
| boolean |
| Round 6: with |
| integer px, 0–24 | today's (8px; the chrome's copy rounds its foot only) | The content strip's corner radius from 768px; below that every strip is full-bleed and square. Read only for a content strip. No layer default |
|
| follows | The rail search box's key hint |
|
| each collection's |
|
| boolean |
| The rail's |
|
|
| The rail's |
|
|
| The rail's |
|
|
| The rail's |
| boolean |
| The rail's |
| boolean |
| The rail's |
| boolean |
| The rail's |
| boolean |
| The rail's |
| boolean |
| The rail's |
| boolean |
| The rail's |
| boolean |
| The rail's |
|
|
| The rail's |
| boolean |
|
|
|
|
|
|
|
|
|
|
Fixes in the same round that need no key: a centred search (searchPlacement: "center") collapses to a search glyph on ONE phone row; the phone bar never shrinks the brand or the version pill (the search folds to a glyph first, then buttons move to the menu); linksAlign: "after-search" puts the links right after the pill under every frame; the drawer's head never scrolls away; the announcement adds no blank band on a phone.
Both phone-side keys only ORDER the phone row: which controls fold into the menu on a narrow screen is the same whichever side they sit on.
Mix and match: two headers no preset draws
An icon-only search in a ring, the org's name, a mark on phones, and the buttons before the toggle:
{
"chrome": {
"search": "icon",
"searchStyle": { "label": "", "kbd": "mod-k", "kbdStyle": "plain", "iconFrame": "circle" },
"brandText": { "show": "show", "source": "org", "phone": "hide" },
"logoPhone": "mark",
"header": { "order": ["ctas", "signIn", "search", "themeToggle", "menu"] }
}
}A forum header with a desktop sidebar fold, a site menu and sign-in kept on the phone bar:
{
"chrome": {
"sidebarToggle": { "placement": "start", "desktop": "collapse" },
"header": { "menuButton": { "desktop": true, "phone": "always" } },
"signInStyle": { "look": "outline", "phone": "bar" }
}
}A phone row with the menu first and the drawer button last, under a flush square-cornered notice on the content column:
{
"chrome": {
"sidebarToggle": { "placement": "start", "placementPhone": "end" },
"header": { "menuButton": { "phoneSide": "start", "glyph": "two-bar" } }
},
"announcement": {
"text": "v5 is out",
"placement": "below-header",
"span": "content",
"inset": false,
"radius": 4
}
}Who sees what: audience, the account menu and "My activity"
audience on every item you place
Every piece of reader furniture you place takes audience:
Value | Shown to |
|---|---|
| everyone — what every item did before the key existed |
| a reader who is signed in |
| a reader who is not |
It is accepted on chrome.headerLinks[] (and their dropdown children[] and mega-menu column links), chrome.cta, chrome.secondaryCta, chrome.ctas[], chrome.topBar.links[], chrome.subnav.links[] / endLinks[], chrome.footerLinks[], chrome.footerColumns[] (the whole column) and their links[], announcement, the docs rail's docs.sidebar.anchors[] / footerLinks[], the forum sidebar's forum.sidebar.custom[], and every block of a page or front page (attrs.audience; in markdown {: audience=signedOut} after a block, or audience="signedIn" on a directive). In the console: the Audience select beside each header link, button and CTA, and in a block's hooks panel. In the text boxes for footer links and columns, add a third column: Pricing | /pricing | signed-out (a column heading takes it too: Members | signed-in).
The default for a sign-in or sign-up door is signedOut. A button whose action is signIn/signUp, a link to a sign-in or sign-up route (/login, /signup, /register, /join, …), and a button labelled like one ("Start free", "Sign up", "Log in", "Get started free", "Create an account") are shown to signed-out readers only unless you say otherwise — a signed-in reader is no longer shown "Start free" beside their own avatar. Set audience: "all" to keep such an item for everyone.
How it works, and why nothing flashes: reader pages are cached and shared, so the server renders every item with data-audience. A tiny script before first paint marks <html data-viewer="in"> or "out" from a readable fm_signed_in flag the API sets beside the session cookie (a flag only — no identity), and CSS hides what is not for this reader. The reader's own "who am I" request then confirms it and corrects a stale flag. This is visibility, not access control: the item is in the page's HTML either way. Content only some readers may read belongs in an audience block with a claim/condition (filtered on the server), not here.
Style hooks: html[data-viewer="in"] / html[data-viewer="out"] are stable, so your own CSS can key on them too.
chrome.accountMenu — the avatar's menu, on every surface
A signed-in reader's avatar opens the account menu on docs, blog, changelog, forum, sites and custom domains alike.
Key | Shape | Unset | What it does |
|---|---|---|---|
| array (1–16) of |
| The entries, in order. Your own links take the header menus' icon vocabulary (a glyph, a Lucide name or an image) |
| boolean |
| The reader's name beside the avatar |
| boolean |
|
|
| boolean | on a forum | The notifications bell beside the menu |
activity ("My activity") and account ("Account & privacy") open /me on the current space. The forum entries — profile, bookmarks, notifications, preferences and the bell — open your organization's forum when the current space is not one (its first public forum), and are left out when you have none. Dividers collapse when the entries around them drop out.
"My activity" at <base>/me
Every reader surface serves /me — on our addresses, on a custom domain and under a path mount (acme.com/docs/me), and at a site's root. It is the reader's control center for what they made in your organization (every public space): posts and topics, replies and comments (forum, blog and changelog replies and docs page comments), reactions and votes, bookmarks, what they watch (topics, spaces, categories, tags) and their uploads — newest first, filtered by space, searchable, paged. Each row can be opened, edited (a reply, within your edit window), deleted, unreacted, unbookmarked or unwatched — one at a time or as a selection — under exactly the rules the single-item controls apply (a topic others replied to stays; your edit window and daily deletion limits hold); a row that cannot be changed says why.
"Account & privacy" (/me?tab=account) holds the reader's name, username and picture, a link to their notification preferences, their signed-in devices ("Sign out everywhere else"), a download of their activity here, and the ways out: delete all my content in this community (removing an account from a single organization is deliberately not offered — the account is the person's across every community) and erasing the whole account, which says plainly that it reaches every community. /me is reserved: a page or post with the slug me is answered by this page instead.
API: GET /public/orgs/{orgId}/me/activity?type=&spaceId=&q=&cursor=, POST /public/orgs/{orgId}/me/activity/bulk ({"action","ids"}, or {"action":"delete","all":true}), GET /public/orgs/{orgId}/me/export.
Mix and match
Signed-out readers see "Start free" and a sign-up announcement; members see "My activity" in the header and a members' notice; the account menu leads with a link to your own app:
{
"announcement": {
"text": "Members: the March AMA is live",
"href": "/forum/ama",
"audience": "signedIn"
},
"chrome": {
"ctas": [
{ "label": "Start free", "href": "/signup" },
{ "label": "My activity", "href": "/me", "style": "outline", "audience": "signedIn" }
],
"headerLinks": [
{ "label": "Pricing", "href": "/pricing", "audience": "signedOut" },
{ "label": "Docs", "href": "/docs" }
],
"accountMenu": {
"items": [
{ "label": "Open the app", "href": "https://app.example.com", "icon": "link" },
"divider",
"activity",
"profile",
"notifications",
"account",
"signOut"
],
"showName": true
}
}
}"Start free" needs no audience — it is a sign-up door, so it is already for signed-out readers only. Every key above is independent: set any one without the rest.
The language switcher: i18n.switcher
A space with more than one language draws a language switcher. Where it sits and how it looks is theme.i18n.switcher. Every key is independent, and each one resolves in this order:
the space's own theme;
its site's theme;
the organization's default: Settings → Language switcher, or
PUT /orgs/{orgId}/language-switcherwith the same keys;what the reader did before the key existed.
Set the switcher once for the organization, and change one key on one space when that space needs something different.
Key | Values | Unset |
|---|---|---|
|
| shown |
|
| docs: |
|
|
|
|
|
|
|
| off |
|
| the region in the tag, else the usual country |
|
| every declared language, in the order you declared them |
|
| — |
| when this page has no translation in a language: |
|
|
| off |
{
"i18n": {
"switcher": {
"placement": "header",
"style": "compact",
"flags": true,
"flagCountries": { "default": "US" },
"locales": ["default", "es"],
"missing": "hide",
"remember": true
}
}
}The page's address sets its language. /docs/es/webhooks is a Spanish page and /docs/webhooks is a page in the space's own language. Everything around the content follows the address: the header, the account menu, dialogs, dates and toasts. A reader's earlier choice, their account language and their browser language never change it. remember only offers a link to the translation. It never switches the page. That is also why the page can be cached by its URL.
The header placements, footer and floating are rendered on the server, in place, so the switcher never moves after the page loads. floating and the remember notice are fixed-position, so neither shifts the layout. Every language is a real link with hreflang. The current one has aria-current="page". A disabled one has aria-disabled="true". The dropdown supports the arrow keys, Home and End, typing a letter to jump, and Escape.
In the studio, double-click the switcher to edit these keys in the inspector. To restyle it, use the hooks [data-fx-language-switcher], [data-fx-language-option] (one language in a row), [data-lang-flag] and [data-fx-language-suggest] (the remember notice), or element styles on any of them. [data-language-placement] tells you where it is drawn. theme.docs.localeSwitcher (the older docs-only key) is still read: its placement and style apply where i18n.switcher leaves them unset.
Theme components are a copy, not a layer
POST …/theme/apply resolves the component and deep-merges the result into the space's live theme, once. There is no ongoing link:
editing the component afterwards changes nothing in the spaces that wore it — apply it again, per space,
deleting it leaves those spaces exactly as they look,
GET …/themeon the space shows the applied values as if you had typed them, because at that point you did.
Where components do compose is among themselves, before the copy is taken. A component may have children (parentThemeId), and GET /orgs/{orgId}/theme-components/{id}/composite is the parent with its enabled children layered on in creation order — the thing apply actually copies. In that resolution most fields are last-wins, but CSS is additive:
| concatenated with a newline, child later — so the child wins the cascade |
| concatenated |
| merged by key, child winning |
everything else | child replaces |
So a design system that has to stay in step across many spaces is a site theme (see Sites), which is a live layer. A component is for handing a look to a space once, exporting it, or publishing it to the registry.
The eight built-in presets
POST …/theme/apply with a presetId instead of a componentId applies one of ours. Each is a partial theme of schema fields only — colours, typeface, scheme, width, and for four of them a full palette. No customCss, so applying one is safe anywhere and cannot bring markup assumptions with it.
| Name | Scheme | Type | Width | What it is |
|---|---|---|---|---|---|
| Classic |
| sans |
| Indigo accent on a light page. The neutral. |
| Midnight |
| sans |
| Always dark, violet glow. Full palette. |
| Editorial |
| serif |
| Serif long-form, warm red. Full palette. |
| Terminal |
| mono |
| Monospace on black, phosphor green. |
| Sunrise |
| sans |
| Warm coral, edge to edge. |
| Slate |
| sans |
| Muted blue-grey; gets out of the way. |
| Forest |
| sans |
| Deep green on a light page. |
| Grape |
| sans |
| Purple with a magenta accent. |
A preset is a merge, like a component: anything it does not mention — your logo, your customCss, your chrome — survives. An unknown presetId is a 404.
Note the contentWidth column. Six of the eight set wide or full, so applying one changes your layout as well as your colours. If that is not what you wanted, set Content width back in the studio's Look panel — choose Default to remove the key entirely — or patch contentWidth over the API. Neither the preset nor the width touches the "on this page" rail; that is onThisPage, the On this page picker beside it.
Every theme endpoint
The theming guide documents four. There are fourteen, plus the three origin endpoints under Asking what a host actually allows.
Call | What |
|---|---|
| The live theme. |
| Apply a named theme wholesale. |
| A draft — see Draft a theme, and preview it. |
| Publish the draft. 409 if the live theme moved. |
| Alternates a reader may pick. |
| Reusable theme fragments. |
| Import one. |
| Edit, remove. |
| The component resolved against its parents. |
| Export it. |
| Themes published for installation. |
| Publish one. |
| Install one. |
| Unpublish. |
The live-theme answers (GET, PUT and PATCH …/theme) are { "theme": …, "warnings": [] }. warnings lists what the stored theme says that readers will not see — today, a motion.targets entry whose animation no longer exists because the site removed that keyframe (code: "motion.unknown_animation", with the key). A write that would introduce such a reference is a 422 naming the key and the valid names instead; see Motion in the theming guide. It also lists colour pairs your palette / paletteDark sets that fall under WCAG AA contrast in light or dark mode (code: "contrast.low", key = the slot to change) — the visual editor's own check, as a warning; the save still succeeds. The same check covers primaryColor (3:1 on the page), components.link.color/colorDark (4.5:1 on background and surface) and elementStyles rules that set both a color and a background. Site theme saves (POST/PATCH …/sites) and front-page saves (PUT …/front-page, which also measures each feed's card colours and moreButton.color) answer the same warnings as contrastWarnings.
Draft, then publish, rather than editing the live theme while readers are on it. It is one more call and it is the difference between a preview and an incident.
Analytics tags, and the custom <head>
Not a theme key and not in the theming guide at all, which is why nobody found it:
GET PUT /orgs/{orgId}/spaces/{spaceId}/analytics-tags
{
"ga4": "G-XXXXXXX",
"gtm": "GTM-XXXXXX",
"segment": "<write key>",
"intercom": "<app id>",
"customHead": "<meta name=\"google-site-verification\" content=\"…\">"
}Keys merge: a key you omit keeps its stored value, and "" clears it. customHead is limited to 20,000 characters.
IDs are format-checked, because they are pasted into inline scripts: ga4 is G- plus 4–20 uppercase letters/digits, gtm is GTM- plus 4–12, segment is an 8–64 character alphanumeric write key and intercom a 6–16 character lowercase app id. Anything else is a 422; a malformed value stored before this check is ignored by the reader. It is a space-level setting, so every reader surface of the space gets it — not only the docs pages.
Plausible, Fathom, Umami, Matomo, Simple Analytics and PostHog use the same route, and the organization can set defaults for every space. See Analytics on your reader.
What customHead can contain, and where each part lands
customHead is parsed and emitted per tag, not pasted into the page as one blob:
In your snippet | Where it goes | Which hosts |
|---|---|---|
| a real | every host |
| a real | every host |
| a loaded script | verified custom domain only |
| one inline script | verified custom domain only |
| dropped | — |
A snippet containing no tag at all is treated as plain JavaScript, which is what every snippet written before this was.
The snippet is split on the server. On a host where its scripts are not emitted, the script source is not sent to the browser either — it is absent from the page's HTML and from its serialised page data, not merely left unexecuted.
Three refusals worth knowing, because each one used to be a silent pass:
http-equivon a<meta>is dropped — that is how a meta sets a CSP or a refresh redirect, which is control of the page rather than head content.javascript:anddata:in ahreforsrcare dropped; ahrefmust behttps://,//or a rooted path.rel="preload",rel="modulepreload"andrel="import"are dropped: they load code.
Event attributes (onload=…) and anything else not on the small allowlist are stripped from the tags that are kept.
This used to emit the whole snippet as the body of one inline
<script>. So a verification meta, a stylesheet link and a<script src>— the three things anybody actually puts in a head — became a syntax error inside a script and vanished without a word.
Which consent tier each one is on, honestly
Tag | Category | Can a reader refuse it? |
|---|---|---|
|
| Yes. Nothing loads before a grant. |
|
| No — see below. |
|
| No — see below. |
| none | No. They are inert and always emitted. |
|
| No — see below. |
|
| No — see below. |
functional is a tier a reader cannot decline. It covers the things a site needs in order to work the way the reader asked — their language, their theme, the support channel they are about to use — and no consent UI offers it as a choice, so it is granted from the first paint. These docs used to say customJs was "behind functional-tier cookie consent", which read as "nothing runs until a banner is answered". It does not: on a reader surface it runs immediately, and that is deliberate.
What actually holds a script back is therefore the host, not consent:
analyticstags wait for a real grant, on any host.Executable
customHead,customJsandintercomrun only on a verified custom domain. Onforumulate.comand on a*.forumulate.comsubdomain they are not emitted at all — our origin serves/apisame-origin, so an owner's script there could call it as the logged-in reader. If you tested acustomHeadscript on a platform subdomain and saw nothing, this is why, and no setting changes it.There is no exception.
customJsused to run on every host; it now follows the same rule. Itswindow.__theme_settingsline is our data, not your script, and is still emitted everywhere.An external
<script src>in a nav item'spanel.htmlor inchrome.footer.htmlfollows the same rule: loaded once per page, with the nonce, on a verified custom domain only, and its origin is allowed for you. Inline code andon…handlers in that HTML never run, on any host.
The same rule covers scriptOrigins, ad networks and chat-widget connectors. Where owner and vendor scripts run in the theming guide puts all of them in one table.
Seeing the allowlist a reader gets
The reader's Content Security Policy is built from two public lists, and you can read both without a token to check a vendor or an API is on them. Each answers { "origins": [...] }, and only for a public space:
GET /public/orgs/{orgId}/spaces/{spaceId}/script-origins: every origin the space's pages may load a script from. It is the union of
theme.scriptOrigins(up to 20), the chat widgets of enabled connectors, your consent manager and analytics tags, and the external<script src>origins in your header panels, footer HTML and theme components.GET /public/orgs/{orgId}/spaces/{spaceId}/api-origins: every origin the space's API reference may send requests to (the CSP
connect-src, and the servers the "Try it" proxy accepts). It is the union oftheme.apiOrigins, the servers your imported specs name, and the servers named by theopenapiblocks of published pages,https://origins only.
If a widget paints but never loads, or "Try it" is refused, the origin it needs is missing from one of these.
Shaping the auto-generated link-preview card
Every public page already has an Open Graph image — social-card.png, generated per page. seo.socialImageUrl replaces it with a picture of your own, which is all-or-nothing: either hand-build a PNG per page, or accept our dark slate. seo.socialCard is the middle: the same card, in your colours, with your logo and your typeface. seo.twitterSite ("@acme", with or without the @; "" clears it) is published as twitter:site on every page of the space, so a card shared to X is credited to your account.
Site-wide <meta> and <link> tags (verification tokens, theme-color, …) are not an SEO key: put them in the analytics door's customHead (above), whose meta and link tags render on every host.
The other seo keys
PUT …/seo merges key by key. Besides the card keys above:
Key | Meaning |
|---|---|
| The space's own description, the fallback for every page that has none. |
|
|
| The name |
| The title pattern, |
|
|
| As described above and in the console's Search & sharing panel. |
The cards, uploads (/api/assets/…) and proxied content images are open to crawlers in robots.txt on the platform address too; the rest of /api/ stays disallowed.
PUT /orgs/{orgId}/spaces/{spaceId}/seo
{
"socialCard": {
"background": "#0B1020",
"backgroundTo": "#141B36",
"accent": "#6E56CF",
"titleColor": "#F5F6FA",
"font": "serif",
"logoUrl": "https://cdn.acme.test/wordmark.png",
"footer": "acme.com",
"accentBar": true
}
}Key | What it is |
|---|---|
| The left stripe, and the eyebrow unless |
| The card background. Flat on its own. |
| With |
| The headline colour. |
| The small label above the headline. |
|
|
| Drawn top-right, scaled into a 180×72 box. |
| Replaces the credit line, ≤ 80 characters. |
|
|
Colours are 6-digit hex (#4F46E5), the same rule a theme colour follows. Every key is optional and each falls back to the card as it has always rendered, so a space that sets none of this gets the image it got yesterday. The keys merge on write: setting accent next week will not drop the logoUrl you set today.
logoUrl is fetched server-side and embedded in the image, because the PNG every scraper reads is rasterised with no network — a remote href would render in a browser looking at the SVG and be missing from the PNG that actually gets shared. So: an https URL that is publicly reachable, or a path from your asset library (/api/assets/<assetId>, read straight from storage — it must be an upload of this organization's, in a public space or none), or an inline data: raster; a raster or SVG image, under 512 KB. Any other path is refused, because the card has nowhere to resolve it. If the logo cannot be read the card still renders, without it.
theme.whiteLabel: true removes the credit line regardless, as it does everywhere else. footer: "" removes just the line without claiming white-label.
The template applies to every generated card: the space card, blog posts, forum topics, roadmap items, changelog releases, docs pages and course lessons. A docs page with no template keeps its own card design; once a template is set, its card is the templated one.
When a change shows up. Each generated card's URL carries a fingerprint of its look — social-card.png?v=<fingerprint>, built from socialCard, theme.primaryColor and theme.whiteLabel. Change any of them and the next page render advertises a new URL, so nothing — our edge, your CDN, or a scraper's own image cache — can keep serving the old card under it. Saving the template evicts the reader caches the same way a theme save does, so the new URL is in the page head within seconds. Links that were already shared keep the image the network captured at the time; most networks let you re-scrape a URL (Facebook's Sharing Debugger, LinkedIn's Post Inspector) to pick up the new page head.
Which CSS variables you may rely on
Stable: target these
The stable list lives in one place: CSS variables worth targeting in the theming guide. The theme's names on it are --reader-*, --font-* or --setting-*, scoped to .forumulate-reader; the rest are the layout variables a block or a styled section publishes on its own element. Those are the names we keep working. A variable is only emitted for a key you set, which is what lets a site theme and a space theme stack.
This page used to carry a second copy of that list, and the two disagreed about whether --bg and --signal were stable. They are not. Both guides now say the same thing: the published names are stable and the names below are internal.
Internal — do not target these
--bg, --surface, --panel, --ink, --ink-3, --muted, --line, --line-strong, --line-control, --signal, --signal-fg, --signal-wash, --accent, --accent-wash, --grid, --hl-comment, --hl-keyword, --hl-string, --hl-number, --hl-title, --hl-builtin, --hl-tag, --hl-attr, --hl-meta, --hl-emphasis.
These are the names our own stylesheet reads. Some of them a theme does write — palette.background and palette.surface set --bg and --surface as well as their published --reader-* names, because the only way to repaint them used to be to guess the internal name. Reading one from customCss works today; targeting one is a private contract you lose on our next refactor, and --line-control is not written by a theme at all.
The ten --hl-* variables are our shipped syntax palette, and they are the clearest case of why this list exists: they were the only way to recolour code highlighting, they are declared three times (light, the OS dark query, and a pinned [data-theme='dark']), and overriding one from customCss means overriding it in all three places or getting it in one mode only. The published names are --reader-syntax-<group> and the theme key is components.syntax / components.syntaxDark, which gets the mode gates right for you. Each .hljs-* rule now reads the published token with the --hl-* one as its fallback, so a theme that sets nothing highlights code exactly as before.
If a published variable does not paint something you need, that is worth telling us — it is a missing token, not a reason to reach for an internal name.
Which SELECTORS you may rely on — the fx-1 hook set
The variables above are a contract about colour. This is the contract about structure: the selectors that reach the parts of a reader page, so that customCss above the colour level does not have to be written against our Tailwind utility classes.
It needed saying. The header was <header class="border-b">, the brand was header a.mr-auto > span, the CTA was rounded-md bg-brand text-white and the light/dark control was [role=group][aria-label=Theme] — a selector that stops matching the moment the label is translated. A customer matched their product with about 3 KB of CSS written against names like those, and they were right to say it would break on our next refactor.
The promise. The names in this section do not change. We will add to the set; we will not rename or remove a name in it. If one ever has to break, it breaks as a new version (fx-2) published alongside fx-1, not in place of it. Everything not in this section — our Tailwind classes, our element nesting, the order of children — is an internal detail and does change.
Every hook is inside .forumulate-reader, so scope your rules with it if you want reader-only effect. That class is on exactly one element per page — the reader shell — which is what makes .forumulate-reader > div a selector with a single meaning.
The chrome and the page
Hook | The element |
|---|---|
| The header bar. Also what |
| The row inside the header that holds the brand, the links and the CTA |
| The whole brand lockup — mark, wordmark image and words — as one link |
| The words inside the lockup ( |
| The header's call-to-action button ( |
| The header's second button ( |
| One top-level header item ( |
| The dropdown, mega menu or HTML panel an item opens; on a phone, the accordion body |
| A link inside a panel (a dropdown entry, a column link, a panel footer link) |
| A mega menu's image card ( |
| The light/dark control, in all three |
| The wrapper around the page itself, between header and footer |
| The footer |
| One footer column ( |
| The consent control, wherever |
| Your own footer markup ( |
| The footer's newsletter form ( |
| The docs reader's left rail |
| The brand lockup at the top of that rail ( |
| The search box in that rail |
| Any reader dropdown's box — a filter, a form field, the theme switcher, a version or language picker ( |
| A dropdown's open list (it floats above the page; |
| One option in that list — |
| A language switcher's box, wherever |
| One language in a switcher drawn as a row ( |
| The |
| The on-this-page rail on the right |
| The article body on a docs-style page — this is where you set a width |
| The page's |
| A theme component outlet a component has filled; |
| One theme component's markup inside an outlet; |
| The utility row above the header ( |
| The section row below the header ( |
| The rule |
| The header's search control ( |
| With |
| A |
| A header link with its own |
| A pinned button that leaves the phone row below 420px, and its copy in the menu panel |
| The empty slot in the header's menu panel the forum sidebar folds into |
| The frame around an |
| The shape along the footer's top ( |
| The tab a |
| The post title a header shows once the title scrolls away ( |
| The brand name's first part in its own ink ( |
| The rule between the controls and the buttons ( |
| A utility-bar link's words ( |
| A footer column's heading ( |
| The footer's brand block — lockup, description, social marks ( |
| The footer's live status pill ( |
| The generated title bar above an index; |
| A standfirst under a title ( |
| An entry's title in an index ( |
| A forum topic's title ( |
| A metadata row — date, byline, reading time ( |
| The footer's art band ( |
| The footer's breadcrumb row ( |
| The docs rail when |
| The docs section switcher ( |
| An empty slot in the header for page-scoped controls: |
| An Ask AI launcher outside the floating button ( |
| The docs section after the brand ( |
| A status page's overall banner ( |
| A status page's component group ( |
| One component row; |
| A component's day strip ( |
| One day in the strip; |
| The strip's legend ( |
| An open incident's card ( |
| A scheduled maintenance card |
| A status page's subscribe band |
| The KPI tiles band ( |
| A course landing's hero ( |
| A course's outline; |
| One lesson in the outline; |
| A lesson's curriculum rail ( |
| A course progress bar; |
| The end-of-lesson "Next up" card |
| A completion certificate; |
| One FAQ question with its answer ( |
| A question's row (the |
| A question's answer, with its feedback row |
| A FAQ section; |
| The FAQ search box, in the hero or inline ( |
| A FAQ category card ( |
| [data-fx-legal-row] | One legal register row (list, split or card) | | [data-fx-legal-facts] | The facts line under a legal document's title (effective, updated, edition) | | [data-fx-legal-kicker] | The small line above a legal document's title (theme.legal.head.kicker) | | [data-fx-blog-hub-head] | A blog hub's head (back link, eyebrow, title, counts) on archive, tag, author, series and search pages | [data-fx-blog-hub-list] | A blog hub's own list of posts | [data-fx-blog-post-marks] | The claps and restack row under a post (theme.post.marks) | [data-fx-blog-post-sponsor] | A post's sponsor card (theme.post.sponsor) | [data-fx-helpful-prompt] | The "Was this helpful?" prompt under a post or a release; [data-helpful-thanks] once answered | [data-fx-blog-archive-calendar] | The archive's month calendar (theme.blog.archive.calendar); [data-archive-calendar] holds the month | [data-fx-blog-membership-offer] | A blog's membership offer: plans and the checkout button (/membership, paid posts)
The interactive blocks
The interactive content blocks carry their own hooks, on every page that uses them:
Hook | The element |
|---|---|
| A carousel (also a testimonials carousel). Layout: |
| One slide |
| A gallery grid. Layout: |
| The full-screen picture viewer (a |
| A pricing block, cards or table. Layout: |
| One plan card; the recommended one also carries |
| A testimonials block. Layout: |
| One quote |
| A logo wall. Layout: |
| A stats block. Layout: |
| One number with its label |
| A marquee (also a scrolling logo wall's row). |
| A button-with-dialog; the dialog is |
| A before/after slider. |
| A floating layer (a picture or a giant word). |
| A sticky story. |
| One step of a story; the one being read carries |
| The fallback picture of a code demo (demos do not run yet) |
| A hero block; |
| A person card; |
| An office map; |
| One place in an office map: its map, then its name, address and note |
| A street map (office map, venue); |
They take the same promise as the rest of fx-1, and every name above is also a theme.motion.targets key, so an owner can animate a stat or a plan card the way they animate the header.
Every type's pages: the type stamp and the shared parts
Each reader page's own root says what it is, the same way on every type with reader pages, so one rule can reach "every status history page" or "every item page":
Attribute | Value |
|---|---|
|
|
|
|
| Which sub-page or hub ( |
| The id of the one item on an item page ( |
| The section the page belongs to ( |
[data-reader-type='status'][data-reader-surface='sub'][data-reader-page='history'] [data-fx-section-title] {
letter-spacing: 0.08em;
}The parts every type's pages share:
Hook | The element |
|---|---|
| A titled region of a page |
| Its heading — any heading outside the article; |
| A list page's list |
| One row or card in it; its title is |
| A boxed card or panel ( |
| A cover or hero picture ( |
| An empty state |
| Previous/next or page-number navigation |
| A filter or sort row above a list |
| A row of view tabs |
| An item's row of buttons (subscribe, RSVP, apply, share) |
| Your ad ( |
The front page
A composed front page's live blocks carry these, on the space's front page, a site's front door and anywhere else a feed sits:
Hook | The element |
|---|---|
| A |
| One card in it; a featured item also carries |
Both are theme.motion.targets keys (feed, feed-item), so a feed's cards can reveal as the reader scrolls to them.
You rarely need a hook for that, though: theme.motion.reveal.targets: ["everything"] (the recommended choice, and what every motion preset uses) reveals every top-level block of every body and every entry of every index page — feed cards, topic rows, forum posts, releases, roadmap cards, events, roles, lessons, FAQ questions, status groups — in reading order, staggered. The narrower targets (code, tables, media, embeds, quotes, tabs, steps, accordions, buttons, sections, items, …) and the block types each one covers are listed under Motion in the theming guide. Your own CSS can read the reveal's state from these attributes:
Attribute | Means |
|---|---|
| Reveals are armed (the motion script ran); before it, and without script, nothing is hidden |
| This block or entry has revealed (or was on screen when the page opened) |
| A piece of a block taller than the screen, which reveals piece by piece instead of as a whole |
A blog post
The parts a post template (theme.post) adds to a blog post. A post with no template renders none of them.
Hook | The element |
|---|---|
| The band behind the post's head ( |
| The trail above the post ( |
| The line above the title ( |
| The post's byline row, and a date or read time placed on its own ( |
| The dek under the title ( |
| The post's authors, wherever they are placed |
| The rail beside the words ( |
| The share control (not an |
| The "Written by" cards ( |
| The series navigation ( |
| More to read as a live feed ( |
Each fx hook here is also a theme.motion.targets key.
Three hooks that are classes, not attributes
Our own stylesheet already names these by class, on every one of them, in @layer components. A second attribute beside each would be two contracts for one element — and the card block is rendered by a shared package, so it is not even ours to attribute. The class is the hook, with the same promise and the same version:
Hook | Selector |
|---|---|
button |
|
input |
|
card |
|
Already in the markup, and also stable
These predate the set and are in it: [data-space-chrome], [data-site-chrome], [data-reader-controls] (anything that disappears in print), [data-docs-layout], [data-reader-aside], [data-mobile-nav], [data-space-menu], [data-page-actions], [data-copy-page], [data-announcement-banner], [data-reader-consent-footer], [data-changelog-layout], [data-article-layout], [data-page-embed], [data-block-t] (one per content block type), [data-badge-style], [data-rss], [data-release], [data-reader-measure] (every long-form element that keeps to layout.measure: a legal document's article, a blog post, release or roadmap item body), and the classes .forumulate-reader, .forumulate-prose, .page-actions-btn, .announcement-banner, .reader-nav-disclosure, .plate-label, .cf-code, .cf-copy.
View-transition names
With theme.motion.pageTransition set, a move between reader pages runs inside a native view transition where the browser supports one, and for its duration the main region ([data-fx-main]) is named reader-main and the header ([data-fx-header]) reader-header. Those two names are part of the same stable set:
::view-transition-new(reader-main) {
animation-duration: 600ms;
}The header group is pinned (no animation); the rest of the page is root. Put data-no-view-transition on an element to keep the links inside it out of this. See Motion in THEMING.md for exactly when it runs.
Hooks you put on a block yourself
The hooks above reach OUR chrome. To reach one particular block of YOUR page, give the block its own name. Every block takes three optional attributes, in the editor (the # beside the drag handle, or a section's Style popover), in the API's block JSON and on any block that has a markdown directive:
Attribute | Rendered as | Rule |
|---|---|---|
| the element's | a lowercase slug, unique on the page (see below) |
|
| up to four lowercase names, each published as |
|
| a lowercase slug |
.forumulate-reader [data-fx-block='hero'] h2 {
letter-spacing: -0.02em;
}
.forumulate-reader .u-wide {
max-width: none;
}u- is a namespace we will never use for a class of our own, so a name you pick cannot collide with ours, now or later. An anchor is unique per page: a second block with the same anchor, or a heading whose text slugs to it, gets -2 appended, exactly as two headings with the same words do. An anchor on a heading replaces the heading's own slug (and the link beside it follows); on a section it replaces the section key as its id. The on-this-page rail reads whichever id the element ends up with.
A styled section also publishes its values as custom properties you can move from customCss without !important: --sec-pt, --sec-pb, --sec-px, --sec-min-h, --sec-radius, --sec-border, --sec-bg-color, --sec-bg-image, --sec-bg-size, --sec-bg-position, and the choices as attributes: [data-scheme], [data-bleed], [data-align], [data-valign], [data-shadow], [data-bg-attach]. Its background is .doc-section-bg, the last child of the section.
A block's own look
A few blocks take look keys of their own, in the block JSON, the editor (the # panel beside the drag handle, or a grid cell's style popover) and markdown. Unset, each renders exactly what it always did:
Block | Key | Values | Markdown |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 0–64 (px) or |
|
|
| 1–12 — one column wraps a single card |
|
|
|
| — |
|
| px 8–160, a length, or |
|
|
| a colour token ( |
|
|
| a whole number, 100–900 |
|
|
| letter-spacing in em, -0.2–0.5 |
|
|
|
| — |
|
| a spacing token, px, or | — |
|
| a spacing token, px, a length, or |
|
|
|
|
|
|
| a colour token or a #hex |
|
|
| a whole number, 100–900 |
|
|
| the room between items: a spacing token or px 0–400 — a theme default is |
|
|
| the row's room above / below ( |
|
They publish as [data-text-align], [data-variant="eyebrow"], [data-marker], [data-stack-on], [data-hide-on], and on a cell [data-shadow], [data-radius] with --cell-radius, and a sized or coloured text block as [data-size] / [data-ink] with --blk-fs-lg/md/sm and --blk-ink, and a heading's own weight and tracking as [data-weight] / [data-tracking] with --blk-fw and --blk-ls. Those two outweigh typography.levels.<h1…h6>.weight/.tracking, which set every heading of a level; the block keys are for the one heading that differs (a hero's 800 at -0.03em). null clears either. An eyebrow or an aligned heading keeps its look whatever typography.levels says, because it was set on that one block; restyle every eyebrow with typography.levels.eyebrow (or an elementStyles rule on h2[data-variant="eyebrow"], which now outweighs ours).
A section's own rhythm and dark band. gap puts that space between the blocks inside a section and takes their own margins away (page uses the front page's flow gap). background.colorDark, gradientDark and imageDark paint the band in dark mode, each falling back to its light twin. A spacer block is an empty band of a set height, for the gap a margin cannot say. A picture with align: center now centres on a composed front page too, and a captioned picture follows its section's contentAlign.
A band inside a band. A section may hold ONE more section — a centred head inside a full-bleed band, then left-aligned feeds under it — and no deeper: a third level is a 422 that names its path ("can sit inside one other section at most"). A section still never sits inside a callout, a quote or any other block's prose. A card inside a band is a grid cell: give a grid with cols: 1 one gridItem with bg, pad, shadow and radius, and put it in the section.
A section used only for layout — a full-width band, some padding — is a look band, not one of the page shape's slots, whenever its key names no slot the shape declares, or when it says role: "layout". Publishing and conformance read straight through it, so a post can use one without being told its required slots are empty.
Reach for a setting before a hook
Several of the things people historically wrote CSS for are settings now, and a setting is the thing we keep working: chrome.sticky, chrome.height, chrome.align, chrome.ctaStyle, chrome.showSignIn, chrome.themeToggle (including its radius and activeColor), layout.measure, chrome.sidebarBrand, chrome.footer.logo / logoMark, chrome.footer.cookieLinkPlacement, theme.brandName, theme.colorScheme and palette.header_background / header_primary. Position our chrome with those settings, not with elementStyles: a theme that moves the header by hand is a theme that breaks on our next markup change. transform is accepted only when it repaints in place — scale(), rotate(), skew() — and a translate…, matrix… or perspective is a 422 naming the declaration.
Size, pictures, underlines and layering
elementStyles also takes the properties an editorial layout is built from — an overlapping headline card, covers cropped to one ratio, a coloured offset underline, a sticky rail, a frosted panel. Each one either repaints or reflows the element inside its own box, or moves it inside its own block, and its values are narrowed so it cannot do more:
Property | Takes |
|---|---|
|
|
|
|
|
|
| the usual values; thickness up to 20px / 1em, offset within ±40px / ±4em, a colour never a |
|
|
|
|
|
|
|
|
|
|
| line numbers, |
|
|
An elementStyles rule is anchored one class heavier than the reader root (:root .forumulate-reader …), so a rule on an element our own stylesheet also styles — an eyebrow, a block's alignment — wins, the way a per-element rule should.
Why those limits:
fixedis never accepted. A fixed element is placed against the viewport, so it could sit over the consent banner, the studio's staff bar or sign-in — the three things a theme must never be able to cover.absoluteonly inside a block. The rule's selector has to name a descendant of a block you gave a hook —[data-fx-block="lead"] .card, not.cardand not the block itself. We then mark that blockcontain: layout paint, so it is the element's containing box and its clip: however it is offset, it cannot leave the block.Layers stop at 10. Our sticky header is layer 30 and the overlays above it higher, so a card can be layered over its neighbours and never over us.
Sizes stop at 200vh / 4000px, so a block cannot be made endless.
The Kilter replica's lead story — a white headline card pulled up over the bottom of a 16:9 cover — is four rules and no customCss:
{
"elementStyles": [
{
"selector": "[data-fx-block=\"lead\"] article img",
"declarations": { "aspect-ratio": "16 / 9", "object-fit": "cover" }
},
{
"selector": "[data-fx-block=\"lead\"] article h3",
"declarations": {
"position": "relative",
"z-index": "2",
"margin-top": "-96px",
"margin-right": "15%",
"padding": "20px 24px 0",
"background-color": "#ffffff"
}
},
{
"selector": "article a",
"declarations": {
"text-decoration-line": "underline",
"text-decoration-color": "#5200ff",
"text-decoration-thickness": "2px",
"text-underline-offset": "4px"
}
},
{
"selector": "[data-fx-block=\"lead\"] article h3",
"declarations": { "margin-top": "-40px", "margin-right": "0" },
"media": "phone"
}
]
}A value outside these is a 422 that names the declaration and says why — elementStyles.1.declarations.z-index: z-index is auto or a whole number from 0 to 10… — and a misspelled property names the one you probably meant (aspect-ration → "Did you mean aspect-ratio?").
Variants of an element rule
An elementStyles rule may carry the condition it applies under — the studio's visual editor writes these, and you can too:
Key | Values | Compiles to |
|---|---|---|
|
|
|
|
|
|
|
| the same gates the dark palette uses |
| a page or post id | only where that piece is on screen, header and footer included |
{
"elementStyles": [
{
"selector": ".btn",
"declarations": { "background-color": "#0f766e" },
"media": "phone",
"state": "hover"
}
]
}A rule without them is the rule it always was. Phone and tablet rules are emitted after the rest, so at equal specificity the narrower screen wins.
Fonts
The three type roles
customFontFamily sets the heading face and the body face to one family, which is the right answer for a brand with one typeface and no help at all for a brand with three. Three keys say it directly:
Key | Sets | Used by |
|---|---|---|
|
| Every |
|
| Body text, and the reader root's own |
|
|
|
{
"customFontUrl": "https://cdn.acme.com/fonts/acme.css",
"headingFontFamily": "Acme Display",
"bodyFontFamily": "Acme Text",
"monoFontFamily": "Acme Mono"
}What falls back to what, because it is not a single chain:
each role falls back to
customFontFamily, then to the built-infontchoice, then to ours. A theme that only setscustomFontFamilyis unchanged.bodyFontFamilydoes not become the heading face. If you set only the body face, headings keep ours — retyping every heading in a space from a key that does not mention headings would be a surprise.monoFontFamilynever falls back to a proportional family. Code set in a text face is a bug, not a brand choice, so it falls back tofont: "mono"and then to the system monospace stack.
Naming a family does not load it. The faces have to arrive through customFontUrl or an @font-face rule in your own CSS, and the origin they come from has to be in the page's policy — which is the next section. A family that is named and not loaded resolves silently to the next entry in the stack, so "the font did not change" and "the font did not load" look identical.
Which origins your pages may load fonts, styles and images from
A reader page sends a Content-Security-Policy, so a stylesheet, a font or an image on a host that is not in it does not load — the browser refuses the request and logs it to the console. These are always allowed, on every host:
Directive | Always allowed |
|---|---|
| the page's own origin, inline styles, our site, |
| the page's own origin, |
| the page's own origin, |
| the page's own origin, |
Anything else has to be named by your theme, and we work most of it out for you:
Where it comes from | Added to |
|---|---|
|
|
|
|
a |
|
|
|
|
|
|
|
|
|
|
|
You do not need the API for any of the declared lists: the theme editor's Allowed origins section edits fontOrigins, imgOrigins, mediaOrigins, frameOrigins and apiOrigins as rows, checks each row with the same rule the API applies (a wildcard or a path is flagged before you save, and only valid rows are saved), shows each list's cap, and underneath shows the effective policy per address — what this theme adds to each directive at each address the space is served on, as last saved. scriptOrigins lives in the studio's Scripts & ads panel, next to the custom-domain rule that governs it.
Three rules worth knowing before you debug one of these:
An
@importyou write is removed, and so is its origin. We strip every@importout of theme CSS before a reader sees it, so a font loaded that way never loads — and an origin that only an@importnames is deliberately not added to the policy, because a permission for a request the browser can never make is a false statement about the page. Load a hosted stylesheet withcustomFontUrlinstead, and use@font-face { src: url(…) }for files you host yourself.The rules are the same on every host. A domain of your own, a claimed
*.forumulate.comsubdomain and the space's address onforumulate.combuild this part of the policy from the same theme, so a font that loads at one of your addresses loads at all of them. (This was not true before 2026-10-08: at theforumulate.comaddress the theme's origins were not consulted at all, so a font on your own CDN was refused there and worked on your subdomain.GET /orgs/{orgId}/spaces/{spaceId}/addresseslists the addresses a space has — do not assemble one by hand.)Each origin you declare must be an exact
httpsorigin, such ashttps://cdn.acme.com. InfontOrigins,fontOriginsRemoved,imgOrigins,mediaOrigins,apiOriginsandscriptOrigins, a wildcard (https://*.acme.com), a scheme on its own (https:), a plain-httporigin or an origin with a path is refused with a 422. The whole write fails, nothing is stored, and the error names the entry, for example"fontOrigins.0": ["Must be an exact https origin, e.g. https://cdn.example.com"]. These forms would mean "any host", and keeping the list narrow is the point (seeimgOriginsbelow). Each list holds at most 20 entries. When the policy is built, at most 12 origins reach each directive: the ones derived from your theme come first, then the ones you declared, and anything after the twelfth is left out.
fontOrigins is the escape hatch for a URL we cannot see — a face fetched by a script, or a stylesheet that redirects to a second host. fontOriginsRemoved is the opposite: it takes an origin back out, including one we derived from your own CSS, and it is subtracted where the policy is built, so it keeps holding on the next request.
imgOrigins needs its own paragraph, because img-src is narrow for a reason that is not about you. Every external image in a document is fetched once by our proxy into the bucket our CDN serves, so your readers' browsers never ask a third party for a picture and no third party learns who is reading. A logo is the one image that cannot work that way: you typed the URL into a setting, and the page chrome loads it directly. So the three brand keys are derived automatically, and imgOrigins is for the images derivation cannot see — a sprite named inside your own CSS, an image your own header markup pulls. Declaring one adds those hosts and nothing else: it does not turn the proxying off, document images are still rewritten, and there is no way to express "any host" in it.
mediaOrigins is the same shape for media-src, which is what a video block and a section's background video load through. A video in your library (an upload, /api/assets/…) needs nothing: our origin and our asset CDN are always allowed. A video on another host is never proxied — a stream is not a picture we can fetch once — so its exact origin has to be named here, for example "mediaOrigins": ["https://videos.acme.com"], or the reader's browser refuses it and the player stays on its poster. Same rules as the rest: exact https origins only, a wildcard is refused by name with a 422.
apiOrigins is the same shape for connect-src: the API origins your reference documents, so "Try it" can reach the API it describes. Imported OpenAPI specs contribute their own servers[] automatically; this is for a hand-written page, or a templated server URL we will not guess at.
frameOrigins — sites your embeds may frame
frame-src names the providers an embed block recognises (YouTube, Vimeo, Calendly, Typeform, Tally, Spotify, Google Maps and the rest — the full list is in the authoring reference) and nothing else, so an embed of any other site renders as a link. frameOrigins is how you say "I trust this host":
PATCH /orgs/{orgId}/spaces/{spaceId}/theme
{ "frameOrigins": ["https://js.stripe.com", "https://share.hsforms.com"] }An embed whose URL is on one of these origins is then framed as written, on every address the space is published at — the renderer and the policy read the same list, so a host is never framed on one address and refused on another. The frame is still sandboxed (it may run its own script, keep its own cookies, submit its own forms and open a new tab; it may not navigate your page or open a dialog over it), loads lazily, and is sent no referrer.
The rules are stricter than the other lists and the refusals say why:
One exact
httpsorigin per entry.https://*.acme.comis refused withWildcards are not accepted ("https://*.acme.com"). Name each origin exactly…;https:is refused as a scheme,http://…as not https, andhttps://acme.com/formsas having a path (the message names the origin to use instead).At most 12 entries, which is exactly what reaches
frame-src— a thirteenth would be stored and never allowed, so it is refused instead.Owner-authored pages frame these hosts (docs, blog posts, releases, legal, events, contact copy, intros). Forum posts and replies do not.
GET …/theme/origins reports them per host under contributes["frame-src"].
Asking what a host actually allows
Call | What |
|---|---|
| Every origin this theme names, where each was found, and the effective policy per host. |
| Stop allowing one (adds it to |
| Allow it again. |
{origin} is URL-encoded (https%3A%2F%2Fcdn.acme.com). The response:
{
"origins": [
{ "origin": "https://cdn.acme.com", "source": "customCss" },
{ "origin": "https://cdn.jsdelivr.net", "source": "customFontUrl" }
],
"allowed": ["https://cdn.acme.com"],
"removed": [],
"hosts": [
{
"host": "docs.acme.com",
"kind": "domain",
"url": "https://docs.acme.com",
"contributes": {
"font-src": ["https://cdn.acme.com"],
"style-src": ["https://cdn.acme.com"],
"img-src": [],
"media-src": [],
"script-src": [],
"frame-src": []
},
"notes": []
},
{
"host": "forumulate.com",
"kind": "platform",
"url": "https://forumulate.com",
"contributes": {
"font-src": ["https://cdn.acme.com"],
"style-src": ["https://cdn.acme.com"],
"img-src": [],
"media-src": [],
"script-src": [],
"frame-src": []
},
"notes": []
}
]
}origins is every font and stylesheet origin the theme names, with source saying what named it (customCss, cssSnippets, mobileCss, customFontUrl, fontFaces, stylesheet, declared) so you can find one without reading your own CSS. allowed and removed are the stored fontOrigins/fontOriginsRemoved lists. Image origins are not in origins — they are derived from the brand keys and imgOrigins, and they show up per host under contributes["img-src"]; likewise mediaOrigins under contributes["media-src"] and frameOrigins under contributes["frame-src"]. All six directives are always present, empty or not.
hosts is the part to read when something is blocked: one entry per address this space is published at, with the origins that address really adds to each directive, and notes for anything that is true of that address and not the others. The only such note today is on a forumulate.com address: a third-party script there shares an origin with /api, so a connector widget whose provider marks itself custom-domain-only is not placed on the page even though its origin is in the policy.
One more undocumented key
showLanguageSwitcher decides whether the reader offers the interface-language control. Left unset it follows the rule rather than a value — shown on a space that publishes more than one content locale, hidden otherwise, because the control changes our chrome and not your words, and on a one-language site it reads as a promise of translations that do not exist. Set it true to keep it everywhere, which is what you want if your readers do not all speak the language you publish in.
Brand assets, and the ?w= image widths
logoUrl, logoMark and faviconUrl take an https URL up to 2048 characters, a path from your asset library — /api/assets/<assetId>, which is what the console's Upload button writes — or an inline data: image, which costs no request and does not depend on a host staying up. A library path is published absolute wherever it leaves the page — the tab icon, the installed-app manifest, the Organization logo in the page's JSON-LD — on the address the reader is on, and on a space mounted at a path of your own domain (acme.com/docs) every image goes through that mount's _api route rather than your root, which is not ours to serve. //host/… is refused: it has no scheme, but a browser reads it as another site. theme.mobile.logoUrl takes the same shapes, and so do chrome.footer.logo and chrome.footer.logoMark — the footer's own lockup, which used to be forced to repeat the header's.
The footer slots take one extra answer the header's do not: false, meaning "nothing in this slot". Unset or "" keeps the header's image, so a theme written before those keys existed renders exactly as it did. See the theming guide for the table.
The accepted forms are an http(s) URL, a library path, a data: raster (png, jpeg, gif, webp, avif, bmp, ico) and data:image/svg+xml. At 2048 characters an inline image holds roughly 1.5 KB, so it is a mark rather than an illustration.
SVG, and what we strip out of it
SVG uploads are accepted, and so is data:image/svg+xml in the three brand keys. Both are sanitised first, and the sanitised document is what we store and serve — so what a GET hands back is what will be rendered.
An SVG is a document, not a picture: it can carry <script>, a <foreignObject> with HTML inside it, an <a> that navigates, <animate> that rewrites attributes, an <image> or an @import-ing <style> that reaches a third-party server, and an <!ENTITY> that reads a file or expands to a gigabyte. Served from a host of ours, that would be somebody else's script running on our origin. So the sanitiser is an element and attribute allowlist:
removed with everything inside them:
script,style,foreignObject,a,animate,animateTransform,set,iframe,image,metadata,switch,handler;removed: every
on*attribute, every attribute not on the allowlist, any attribute value containingjavascript:ordata:, anhref/xlink:href— or aurl(…)infill,clip-path,maskorfilter— that points anywhere but inside the same document, and astyleattribute containingurl(,@importorexpression(;kept: filter effects.
<filter>withfeGaussianBlur,feDropShadow,feOffset,feFlood,feComposite,feColorMatrix,feBlendandfeMerge/feMergeNode(and theirin,in2,result,stdDeviation,flood-color,operator,values,mode… attributes) are paint operations on what the drawing already drew, so a drop-shadowed illustration keeps its shadow.feImageis not kept: it loads a URL;refused outright, not stripped: a file declaring a
<!DOCTYPEor an<!ENTITY>(a drawing program does not write one), and anything with no<svgroot. These answer415 media.unsupportedon upload and422in a theme, because storing a blank picture and reporting success is worse than refusing.
In practice a logo exported from a drawing program goes through untouched. One with a tracking pixel, a hyperlink or an embedded font fetch comes out without them. If your mark depends on an external font, name the family with headingFontFamily and load it with customFontUrl rather than embedding it.
.svg has to be on your organization's files.authorized_extensions list, which it is by default. An uploaded SVG is served with Content-Disposition: attachment on direct navigation, so it downloads instead of rendering as a page on our origin; embedding it with <img src> is unaffected, and a script inside an SVG loaded as an image is inert in any case.
Where each one appears:
faviconUrlapplies to every reader surface. The browser tab shows your icon on the docs, blog, forum, roadmap, changelog, events, courses, status, FAQ, jobs, legal and contact readers, on their 404 pages too, on our domain and on yours.logoUrlandlogoMarkappear in the reader header and footer, in the docs reader's sidebar (unlesschrome.sidebarBrandishide), in the per-space web manifest and in generated OpenGraph images. Inside a site, the header and footer take them from the site theme, not the space's.
The widths
Append ?w= to an asset URL and you get that width instead of the original. Only these six widths are accepted:
32 64 128 256 512 1024<img src="/api/assets/9f1e…?w=256" alt="" />Any other value is a 400, not a rounding:
{
"error": {
"code": "validation.failed",
"message": "Unsupported width \"900\". Allowed: 32, 64, 128, 256, 512, 1024.",
},
}A repeated ?w=64&w=64 is also a 400 — "Pass one width." An empty ?w= is treated as absent.
Refused rather than rounded on purpose: silently serving the nearest allowed width would still let a caller mint unbounded distinct URLs, each one a distinct cache entry, which is the amplification the allow-list exists to stop. The width is parsed before any lookup, so a refused width costs no lookup and says nothing about whether the asset exists.
?w= is the only transform parameter. There is no h, q, format, dpr or fit.
When the width is ignored and you get the original
The transform is skipped, silently, when:
the file is not a JPEG, PNG, WebP or AVIF. SVG above all — it is a vector and a script container, never rasterised. GIF is excluded because it is almost always animated.
the image is animated (a multi-page WebP).
the source cannot be measured from its header.
the source is larger than your organization's maximum image megapixels. It is never decoded in that case.
the source is already no wider than you asked. We do not upscale.
the re-encode came out bigger than the original, which happens with small palette PNGs.
The format is preserved across the resize, so the content type stays true.
A variant is served with Cache-Control: public, max-age=86400, immutable and an ETag of "<assetId>-<size>-w<width>", so a conditional request gets a 304. An asset in a private space is served private, max-age=60 instead, whatever the width.
The caveat: a CDN bucket path has no ?w=
This is the part to know. If an asset lives in a public space and the deployment has an asset CDN configured, the URL you are handed is the storage bucket's own object key:
https://cdn.forumulate.com/<orgId>/<assetId>/logo.pngNothing of ours runs for that URL. The CDN is bound straight to the bucket, so there is no ?w= to honour — an unknown query parameter is not an error, it is simply ignored, and you get the full-size object.
URL shape |
|
|---|---|
| works |
| ignored — full size served |
any URL you pasted from elsewhere | ignored; it is not ours |
So: assets in private spaces always go through /api/assets/{id} and always support ?w=. Assets in public spaces may be on the bucket path. If you need a specific width for a public logo, upload it at that width rather than relying on the transform.
The same rule applies to the automatic rewriting we do on your behalf: only our own /api/assets/{id} path is rewritten to add a width. A logo may be any URL the owner pasted — an external host, a data URI, or a bucket key — and those are returned untouched.
In the API
Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go clients are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP call_api tool. Where a CLI command or MCP tool exists too, the last column names it. Operation names link to the API reference.
Asset
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List assets in a space |
| |
| Create an asset in a space |
| |
| List assets in an organization |
| |
| Delete the asset |
| |
| Get the asset |
|
My Activity
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| The signed-in reader's own activity in this organization |
| |
| Remove, unreact, unbookmark or unwatch several of your rows at once |
| |
| Download your activity in this organization (JSON) |
|
Space Settings reader themes
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List reader themes in a space |
| |
| Replace reader themes in a space |
|
Space Settings theme
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| The live theme |
| |
| Replace the live theme |
| |
| Merge into the live theme |
| |
| The theme draft, if there is one |
| |
| Save a theme draft |
| |
| Merge into the theme draft |
| |
| Discard the theme draft |
| |
| Make the theme draft live |
|
Theme Component theme components
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List theme components in an organization |
| |
| Create a theme component in an organization |
| |
| Update the theme component |
| |
| Delete the theme component |
| |
| Get the composite in an organization |
| |
| Import the theme component |
| |
| Export the theme component |
|
Theme Origins
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List origins in a space |
| |
| Create the origin |
| |
| Delete the origin |
|