A component enters a view → the template presents the layout of the same name. No matching layout → the default layout, and the component renders inline. Nothing is stored, nothing is wired: the names sync.
The anatomy: manifest-only
<name>.json: the manifest is the single contract file. Same folder grammar as components: a component is a Switch of faces; a template is a set of layouts. Each layout is a complete arrangement: typically { "$include": "components/core" } plus that view’s surface, so shared chrome lives once in components/ and every layout includes it.
Layouts vs. states: the rule of thumb: if a component causes it, it’s a layout; if the template itself knows it, it’s a state. Local states (welcome/conversation on hasMessages, a voice layout’s call phases on callState) branch inside whichever layout is showing, via the normal condition vocabulary (04). A state is available in exactly the layouts whose trees include its file: the inclusion is the connection.
Sizing: each layout owns its widths
The app is always the ACTIVE layout’s total: nothing else, ever.Widths are declared with the neutral
appWidth key, always a named org size from styles/semantic/app-sizes.json (chat · chat-slim · rail · panel: served on the theme, resolved by the SDK like any token). Two declaration points, both inside a layout:
- Named sizes only. Raw CSS in
appWidthis an error: retuning a size is one edit in app-sizes. - One declaration per panel. The panel’s
appWidthsizes its box and grows the app: a panel (or its frame) never declareswidth/flexof its own. - Never on a layout root. The root is the arrangement; panels inside it carry the widths.
- Never
visibleWhen-guarded. A conditional arrangement is a layout, selected by name: not a guarded pane. - An overlay declares nothing. A surface rendered over the core (
inset: 0) has noappWidthand needs no layout of its own, it never changes the app’s size. - Give every layout root
overflow: hiddenso a panel mid-slide clips at the edge instead of scrolling.
Template-only primitives
Timeline: renders the conversation (you supply theuser/assistantturn subtrees; per-turn scope carriestext,streaming, …). The conversation bucket is locked to the stream (04).ComponentSlot: where components render. Two forms:
- ✅ Surfaces select by the view (
where { field: "defaultState" }), ❌ never by component type, ❌ never by a component’s internal state key. Both are lint-flagged. - ✅ A layout surfaces its own view. A layout named
productmust contain a surface selectingproduct: the name claims the instance; the surface renders it (guard-enforced). - ✅ A surface’s single occupant fills the surface. A
limit: 1surface gives its occupant the frame’s full height automatically, zero per-face height styling. Multi-occupant surfaces (a rail) keep content-sized instances. - ✅ One instance → one placeholder. While a component’s view matches a layout/surface, it renders there: lifted out of the flow, never painted twice. Its own ✕ switches it back and it returns to the flow.
- ❌ Never size or restyle a component from the template: a component owns its faces (03); the template owns only the framing.
flex: 1 + minHeight: 0 + overflow: auto body).
Voice templates
Declare"service": "voice" in the manifest; the channel instantiates the native service, which projects callState into scope. The call phases (states/idle … states/user-speaking) are LOCAL states, branching inside the layouts: typically a wide core in the default layout and a slim core in the card layouts, both including the same state files. Cards streaming in during a call select layouts by name exactly as in chat. Audio is never wired in a definition.
Naming & discoverability: how the AI picks the app
Canonical guide:The formula. Spatial embeds exactlydocs/nodes/node-discoverability.md, every rule there applies to templates and components verbatim, at higher stakes: apps are ranked against the user’s own words (findIntent), not a planner’s task query. Bad meta makes an entire app invisible. This section is the design-side summary.
`title. whenToUse||description [category]`
and ranks it against what the user literally types/says. Three consequences:
whenToUseIS the selection text: when present it replaces description in the ranking.- The opening words dominate the embedding. Lead with the user’s vocabulary for the job; mechanism/layout words up front (“Two-column split: streamed text…”) sink the app.
- Meta embeds as-is: no LLM rewrite. Editing it changes the content hash → re-embeds on the next train.
whenToUse rules:
The generalist trap. A fallback/home surface must NOT enumerate its siblings’ jobs
(“ask about cards, transfers…”): that vocabulary outranks the focused apps for their
queries. A fallback owns general help, questions, reaching a person, and cedes specific
jobs by property, naming none.
Cross-artifact collisions. Own the modality or the job: a voice surface claiming
“asks to talk / speak to someone” poaches live-support intents. Cede the neighbor by
property.
Self-test before shipping: write the sentence a real user would say for this app’s
job: do its nouns/verbs appear in your
whenToUse’s FIRST sentence? Is the description
one plain line about what it is? Does the category name the domain, not the build?
Template checklist
- Manifest-only: no
<name>.json;manifest.layoutnames the default layout - One layout per component view the app presents, each surfacing its own view; shared chrome in
components/, included by every layout - Widths: named app sizes only, on panels inside layouts, never raw CSS, never on a root, never
visibleWhen-guarded - Local states in
states/, included by the layouts they apply to -
binding.workflow+binding.triggerreal (the app owns them);stateOrderlists states + layouts in picker order;previewseeds each layout’s mock - Flow slot generic (
select: {}); surfaces select bywhere, nevertype -
whenToUseutterance-shaped;description≤120 chars - Preview in Studio: layout pills × local states, then live (07); publish passes lint with 0 errors
Next: 06. Styles & Tokens.

