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/14-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.

