Template JSON reference
Every field a template document can carry — the full schema, in one place.
Every template — system template, brand template, or creative — is one JSON document with the same shape. This page is the full reference for that shape: what each field means, which values it takes, what happens when a field is absent, and the rules that make templates behave well under agents and brand-fit. It's written for template authors and for agents working through the API; the canvas editor writes all of this for you.
A machine-readable JSON Schema of the same shape lives at /template.schema.json — generated from the type definitions themselves, so it can't drift from the code. Point a validator or an agent at it; documents may carry extra fields (the API preserves unknown fields inside elements), so it validates loosely by design.
The document
{
"id": "event-card",
"name": "Event Card",
"description": "One-line summary shown in pickers and discovery.",
"agentInstructions": "# Playbook…",
"kind": "branded",
"variant": "dark",
"themeId": null,
"formats": [ … ],
"elements": [ … ],
"guides": [ … ],
"clusters": [ … ],
"groups": [ … ],
"brandFit": { "rules": { … } },
"styles": [ … ],
"typeStyles": [ … ],
"roleColors": { … }
}| field | type | meaning |
|---|---|---|
id |
string | Stable identity. System templates use a slug (hero-statement); brand templates get <brand>-<slug>-<hex> at instantiation. |
name, description |
string | Human-facing. The description also feeds template matching, so write it as a real summary. |
agentInstructions |
string (markdown) | The playbook — the highest-leverage signal the copy agent sees. Structure, per-role guidance, worked examples, what to avoid. |
kind |
"branded" | "concept" |
Branded templates resolve typography/color from the brand at render time. Concept templates (fake-UI parodies — Notes, iMessage) bypass the brand cascade and carry their own typeStyles + roleColors at the document root. |
variant |
string | The active color scheme id. Built-ins "dark" / "light" read the brand's colorRoles directly; any other value names a custom scheme in brand.colorSchemes. Not used by concept templates. |
themeId |
string | null | Opt into a custom brand theme (different fonts/button shape). null, "default", an id that no longer exists, and absent all resolve to the brand root. |
formats |
array | The aspect ratios this template designs for. |
elements |
array | The layers. See below. |
guides, clusters, groups |
arrays | The layout rails brand-fit uses to keep the design intact when content changes. |
brandFit |
object | Which automatic layout rules run for this template. |
styles |
array | Template-scoped CSS classes, each { "name", "css", "description?" } (rarely needed — system sys-* classes cover most treatments). |
typeStyles |
array | Document-level typography — the cascade source for concept templates. Each entry mirrors brand.typeStyles: { "id", "label", "family", "weight", "letterSpacing?", "lineHeight?", "textTransform?", "assetFile?", "description?" }, matched by element.role. |
roleColors |
object | Concept-only flat role → color map ({ "heading": "#111111" }) — no dark/light variant suffixes, unlike the brand's colorRoles. |
Absence and defaults
Absent, null, and false are not interchangeable. The rules:
| field | when absent |
|---|---|
kind |
"branded" — everything tests for "concept" explicitly. |
variant |
"dark". |
themeId |
Brand root — same as null, "default", or an unknown id. |
constraints.contentEditable |
Editable. The agent lock engages only on an explicit false. (Freshly created logo elements are the one type the editor seeds locked.) |
useBrandDefault |
Off — an empty slot shows a gray placeholder, and no automated image pass will touch it. |
formatOverrides[fmt].hidden |
The element renders on that format; only true hides. |
brandFit |
Rules fall back to their built-in defaults — see brandFit. A rules entry that sets only enabled inherits the rule's default auto, and vice versa. |
Unknown fields: a top-level document field the API doesn't recognize is silently dropped on save (the PUT routes build from an explicit field list), but unknown fields inside an element are preserved as-is.
Formats
{ "name": "1x1", "label": "1:1 — 1080×1080", "width": 1080, "height": 1080 }The four canonical formats are 16x9 (1200×628 — deliberately Meta's link-ad size, not a true 16:9), 1x1 (1080×1080), 4x5 (1080×1350), 9x16 (1080×1920). Custom pixel formats get dims-derived names ("1920x1080"); a custom format created from a 16:9 ratio lands at a true 1920×1080. The format name is an identity key — per-format overrides, preview URLs, and guides all key on it, so formats are add/remove only, never renamed.
Author the base styles for the 1:1 layout (or the widest format if there's no 1:1 — the same precedence the auto-scaler and the validator use) and put other formats in formatOverrides. Formats you don't hand-tune are auto-scaled proportionally when a brand instantiates the template.
Elements
Every element shares this skeleton; type-specific fields follow.
{
"id": "heading",
"type": "text",
"role": "heading",
"label": "Headline",
"description": "What this element is for — agents read this.",
"content": "Placeholder the author wrote",
"styles": { "x": 60, "y": 700, "width": 960, "height": "auto", "zIndex": 3, "fontSize": 76 },
"constraints": { … },
"formatOverrides": { "9x16": { "styles": { "y": 1300 } } }
}type—text,image,logo,shape,button,frame.role— the cascade key. For branded templates,heading/subheading/body/cta(plus aliasesheadline,subheadline) pull typography frombrand.typeStylesand color frombrand.colorRolesby variant. Any custom role works if the brand (or concept doc) defines a matching typeStyle. Roles with no match get no typography cascade.content— text on text/button elements (may carry limited rich-text like<b>…</b>and hard\nbreaks), an asset URL (/api/brands/{brandId}/assets/{assetId}) on image/logo/frame elements, or empty. Empty means "unset" — an image slot then falls through touseBrandDefault, a text element renders whatever placeholder the author wrote.locked: true— editor canvas lock: the element can't be selected or dragged on the canvas. Purely an editing affordance — unrelated to the agent content lock inconstraints.clusterGapBefore— per-member rhythm override when the element sits in a cluster (see Clusters and groups).
The styling rule
Inline styles carry layout and element-local rendering scalars: x, y, width, height (number or "auto" for text), zIndex, fontSize, opacity, rotation, textAlign, objectFit, borderRadius, backgroundColor (as an override on class-painted elements), and shapeType: "rectangle" | "circle" on shapes. Do not set fontFamily, fontWeight, lineHeight, letterSpacing, textTransform, or color on branded text elements — those resolve from the brand at render time via role, and baking them pins every brand that instantiates the template.
Visual treatments come from structured fields and classes, not hand-set colors:
| field | values | meaning |
|---|---|---|
cssClass |
sys-panel-dark/light, sys-cta-on-dark/light, sys-gradient-bottom/top/left/right, or a template/brand class |
Backgrounds, CTA chrome, photo-readability gradients. The dark/light pairs swap automatically with the variant. |
knockout |
"auto", "auto-accent", "dark", "light", "accent-dark", "accent-light", false |
Per-line text highlight boxes. auto contrasts with the variant; literal values pin a side; false explicitly turns a knockout off (meaningful in a per-format override). Use this, never a sys-text-box-* class by hand. |
ctaTreatment |
"on-dark", "on-light" |
Pins a button's CTA class so it doesn't swap with the variant. |
tone |
"contrast", "dark", "light" |
Which surface the element sits on. A scheme describes two surfaces — the canvas and its inverted contrast surface (the light card inside a dark design). "contrast" makes every auto color behavior (text cascade, panel/CTA class swap, knockout: "auto", icon tint, logo treatment, shape background fill) resolve against the opposite side, and keep flipping with the scheme. "dark"/"light" pin absolutely. Explicit pins (ctaTreatment, literal knockout values, explicit colors) still win over tone. Set it on the card and the elements sitting on it. Per-format override accepts false = follow the scheme on that format. |
fill |
"text" |
Shape-only: the fill follows the scheme's text color for the shape's surface — separator lines and dividers that should read like the copy next to them, correct on every scheme. Explicit backgroundColor wins. |
styles.tintColor |
hex | Server-side recolor for SVG image assets. |
Image elements
| field | meaning |
|---|---|
useBrandDefault: true |
Two jobs in one flag. (1) An empty slot previews with the brand's default image instead of a gray box. (2) It marks the slot swappable: automated image passes — briefs, the pipeline's imagery step, Vary — only ever replace images in flagged slots. The flag survives content writes, so the slot stays swappable after a real photo lands. A pinned slot (explicit content, no flag) is never touched by any automated pass — no brief can change it. |
constraints.imageGuidance |
Free-text direction the image agent reads when choosing a picture for this slot. |
Precedence: constraints.contentEditable: false outranks useBrandDefault: true — a locked slot is excluded from every automated image pass, including user-directed ones.
Frame elements
A frame is an image slot with authored framing — it renders through the same pipeline as an image but carries its own focal target instead of consulting POI guides.
| field | meaning |
|---|---|
frameHint |
Human description of what fills the slot — shown in the empty-state placeholder and read by agents. |
frameAutoFit |
"poi" (the default) re-fits the asset whenever content changes so its focal point lands on framePoi; "none" leaves position and zoom untouched. |
framePoi |
{ "x": 0.5, "y": 0.4 } — normalized 0–1 within the frame's own box; defaults to the center. |
All three are per-format overridable, and useBrandDefault works on frames the same way it does on images.
Logo elements
| field | meaning |
|---|---|
logoForm |
Which shape of mark the slot wants: horizontal, vertical, emblem, or a custom form. Always set it — without it the resolver can't filter by shape. |
logoTreatment |
on-dark / on-light / full-color. Leave unset to auto-derive from the variant. |
logoColor |
Explicit tint override for recolorable SVG marks. |
logoAssetId |
Escape hatch: pin one exact asset, bypassing the brand's logo system. |
Text and button extras
binding derives a text/button element's content from the metadata of another element's asset — photo credits, contact-card fields:
"binding": { "sourceElementId": "hero-image", "field": "photographer", "prefix": "Photo: ", "fallback": "" }Fields cover photographer / copyright / source / name and the person.* family (fullName, title, email, phone, …); segments composes several fields with separators. A bound element follows its source's asset, so a photo swap updates the credit line without a copy edit.
Constraints — the agent contract
constraints is where an element tells agents what they may do and how much room they have.
{
"contentEditable": true,
"minChars": 8, "idealChars": 26, "maxChars": 60,
"maxLines": 2,
"fontSizeRange": [38, 122],
"idealFontSize": 76,
"guidelines": "Short, bold statement. The event's name belongs in the event line, not here.",
"anchors": [{ "guideId": "safe-left", "edge": "left" }]
}| field | applies to | meaning |
|---|---|---|
contentEditable: false |
any type | The agent lock. No agent changes this element's content: copy passes skip it, image swaps skip it in every scope (the board chat's directed pass included), the Edit node's content patches are refused, and discovery reports it locked. Layout fixes still apply — the lock protects what the element says, not where it sits. Humans can always edit in the canvas. Toggle: inspector → Agent Guidance → "Agents may edit content". |
minChars / idealChars / maxChars |
text, button | The copy budget. Required on every text/button element once a template uses clusters or anchors. Shared across formats by default — copy is written once and lands everywhere — so only add per-format char budgets deliberately. |
maxLines |
text, button | Line ceiling, per format (layout-dependent — commonly overridden in formatOverrides). |
fontSizeRange |
text, button | [min, max] — the hard band brand-fit may shrink/grow within. |
idealFontSize |
text, button | The designed size for the format — what brand-fit grows back toward. |
guidelines |
any | Free-text rules for the copy agent ("bold weekday first, then the date"). |
imageGuidance |
image | Free-text direction for the image agent. |
anchors |
any | Bind an element edge (left/right/top/bottom/center-x/center-y) to a guide. |
What's enforced where: character counts are plain text — markup stripped, leading/trailing whitespace trimmed. The factory's copy guard enforces only maxChars (a violating rewrite is re-asked once, then dropped); minChars, idealChars, and maxLines steer the writer and brand-fit but are never hard-rejected at runtime. minChars ≤ idealChars ≤ maxChars is checked by pnpm validate-templates, which lints the seed system templates at author time — stored brand templates and creatives are not re-validated on save. When explicit per-format char overrides exist, base-scope copy budgets against the tightest intersection across formats.
formatOverrides
Per-format overrides are sparse — only the keys you set:
"formatOverrides": {
"9x16": {
"styles": { "y": 1300, "fontSize": 96 },
"constraints": { "maxLines": 3, "fontSizeRange": [48, 154], "idealFontSize": 96 },
"hidden": true
}
}An override entry never replaces the element. Each scalar field — content, cssClass, knockout, ctaTreatment, tone, fill, useBrandDefault, hidden, the logo and frame fields — resolves per-field: the override wins when it's defined, the base value otherwise. The styles and constraints objects each shallow-merge into their base, key by key — a constraints override that sets only maxLines keeps the base copy budget intact.
Per-format constraints are limited to six fields: minChars, maxChars, idealChars, maxLines, fontSizeRange, idealFontSize. The format-independent ones — contentEditable, guidelines, imageGuidance, anchors — live at base only and cannot vary per format.
hidden: true soft-deletes the element on that one format. Position, fontSize, and line metrics naturally vary per format; content and char budgets are shared unless deliberately overridden (a per-format content override only applies when it's a string).
Guides
Authorial layout rails. Elements and clusters anchor to them; brand-fit snaps against them.
{
"id": "safe-left", "type": "line", "axis": "vertical", "kind": "anchor",
"label": "Safe left margin", "position": 60, "appliesTo": "all",
"formatPositions": { "16x9": { "position": 48 } }
}type—line(withaxis+position) orpoint(withx+y).kind—spatial(visual reference),anchor(things snap to it),instruction(paired with its description),ref-point, orpoi: a point marking where a photo's subject should land. When a slot's asset carries focal-point metadata, thepoi-alignrule solves crop and zoom so the subject hits this point. Each image element scans the doc's POI guides in order and aligns to the first one sitting inside its box on that format — so a guide outside every image box does nothing, and two images that both contain the same point both align to it.appliesTo—"all"(absent counts as"all") or"format"+formatName. A format-scoped guide exists only on that format; anchors bound to it are inert everywhere else.appliesTo: "all"with per-formatformatPositionsis the norm for anchor guides — one conceptual rail, tuned per aspect ratio. Line guides carry{ "position" }per format; point guides carry{ "x", "y" }.
Clusters and groups
Clusters are flexible stacks that re-pack when content changes size. cluster-layout walks memberIds in order, lays each member out along axis separated by gap, then shifts the result so each anchored edge lands on its guide:
{
"id": "main", "axis": "vertical", "gap": 24,
"memberIds": ["eyebrow", "heading", "cta"],
"anchors": [{ "guideId": "cluster-bottom", "edge": "bottom" }, { "guideId": "safe-left", "edge": "left" }],
"formatOverrides": { "16x9": { "gap": 14 } }
}- A main-axis anchor (bottom on a vertical stack) moves the cluster as one unit — longer copy pushes the stack away from the anchored edge instead of spilling.
- A cross-axis anchor (
center-xon a vertical stack) aligns each member independently on the guide — this is how an icon-over-text column stays centered on itself whatever the copy length. gapis measured glyph to glyph off the rendered ink, not between element boxes, so 24 is 24px of visible space whatever slack the font leaves inside the box.- Per-member rhythm:
clusterGapBeforeon an element (or per-format in itsformatOverrides) replaces the cluster's uniform gap for the space before that member. memberIdsmay contain element ids or group ids.- A cluster's own
formatOverridescan carry onlygap,axis, andanchors— membership and order are the same on every format.
What the rule does and doesn't touch:
- It writes
x/yonly, intoformatOverrides[<format>].styles— never base styles, neverwidth/height/fontSize. Sizing belongs to the other rules, which is why a cluster andfit-to-content/overflowcooperate instead of fighting. - Hidden members are skipped and reserve no gap, whether hidden by
styles.visible: false(every format) orformatOverrides[<format>].hidden(that format alone). A group whose visible members are all hidden drops out of the stack whole. - With no anchor, the first visible member holds its position and the rest re-pack after it; the cross axis is left alone, so members keep their own
x. Stacking and placement are separate jobs:anchorsis what makes the stack hold a spot on the canvas. - Two anchors on the same axis: the later one wins. Anchors apply in array order as whole-cluster shifts, so
topplusbottomon a vertical stack does not stretch it or redistribute the gaps. One anchor per axis (one horizontal guide, one vertical) fully places a cluster. - An element or group belongs to at most one cluster, and appears once inside it. Two clusters sharing a member both own its
x/y, and brand-fit halts on the oscillation instead of laying either stack out. The editor makes this unreachable;pnpm validate-templatescatches it in hand-authored JSON.
A worked pass: { "axis": "vertical", "gap": 24, "memberIds": ["eyebrow", "heading", "cta"], "anchors": [{ "guideId": "block-bottom", "edge": "bottom" }] } against a guide at y: 1000, when the heading grows from two lines to four.
| member | visible height | before | after |
|---|---|---|---|
eyebrow |
20 | y 792 | y 680 |
heading |
112 → 224 | y 836 | y 724 |
cta |
28 | y 972 | y 972 |
The CTA's bottom edge stays on the guide and everything above it moves up by the 112px the heading gained. Swap that anchor for { "edge": "top" } on a guide at y: 792 and it inverts: the eyebrow holds at 792 and the CTA travels down from 972 to 1084.
Groups are frozen sub-arrangements — a row whose internal geometry must not re-flow. A cluster treats a group as one member and moves it whole:
{ "id": "todo-row", "memberIds": ["checkbox", "todo-text"], "name": "Todo row", "reusable": true, "clusterGapBefore": 32 }memberIdsare element ids only — groups don't nest, and each element belongs to at most one group.reusable: truemarks the group as a clone prototype: agents call the duplicate-group workflow instead of inventing a new row, clones get fresh element ids, and the source keeps the flag.clusterGapBefore(plus per-formatclusterGapBeforeFormats) gives the group its own breathing room when it sits in a cluster.
brandFit
Which deterministic layout rules run for this template, and which run automatically after every edit (auto: true) versus only on the Brand-fit button:
"brandFit": { "rules": {
"cluster-layout": { "enabled": true, "auto": true },
"anchor-snap": { "enabled": true, "auto": true },
"fit-to-content": { "enabled": true, "auto": true },
"drop-stale-heights": { "enabled": true, "auto": true },
"overflow": { "enabled": true, "auto": true },
"poi-align": { "enabled": true, "auto": true },
"widow": { "enabled": true, "auto": false },
"edge-crowding": { "enabled": true, "auto": false },
"knockout-collision": { "enabled": true, "auto": true }
} }How the engine behaves: rules run in one fixed order per pass (structural rules first, aesthetic passes later, poi-align last), and the first rule to patch an element in a pass wins. The server iterates render → measure → patch to a fixpoint (up to 5 passes), with an oscillation detector that halts and reports when two rules fight over the same element instead of looping. The editor's live Auto pass runs a single pass roughly 200ms after any content or layout change; the Brand-fit button runs every enabled rule, auto or not.
With no brandFit config at all, three rules are on by default — drop-stale-heights and cluster-layout (auto) plus poi-align (button only). Everything else is opt-in. Dangling references never fail a render: a cluster member or anchor pointing at a missing id is silently skipped, and an unknown cssClass renders unstyled — pnpm validate-templates is where those fail loudly, at author time.
The usual set for a cluster-driven template is cluster-layout + anchor-snap + fit-to-content + drop-stale-heights + overflow, plus poi-align when photo slots should frame their subject. widow folds orphaned last words back — it respects authored \n breaks (a two-line "label ⏎ value" element is typography, not a widow) and forgives short display headings.
The authoring goal
Agents change content; brand-fit settles layout deterministically. A well-built template declares its copy budgets, rails its stacks with guides and clusters, flags exactly the photo slots that should follow a brief, locks the elements nothing should touch, and teaches the copy agent its intent in the playbook. If a template only misbehaves under an agent, the missing piece is almost always one of those declarations — not the model.