Skip to main content
A template is a set of LAYOUTS β€” full arrangements selected by the component views they’re named after. Its manifest IS its envelope. Components render pieces; a template is the shell around them β€” the chat layout, the voice surface. It owns nothing: conversation and component data live in the store, so templates are swappable mid-conversation. The connection between components and templates is one rule:
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

There is no <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.
A template with a single layout is simply always in it β€” the feature is invisible until you add a second file.

πŸ“ 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:
So the width is one of a small known set by construction β€” the default layout is the core alone; a rail layout is core + rail; layouts can even use a different core (a slim call column beside a panel). The host animates between the totals; nothing inside ever resizes. The rules (all lint-enforced):
  • Named sizes only. Raw CSS in appWidth is an error β€” retuning a size is one edit in app-sizes.
  • One declaration per panel. The panel’s appWidth sizes its box and grows the app β€” a panel (or its frame) never declares width/flex of 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 no appWidth and needs no layout of its own β€” it never changes the app’s size.
  • Give every layout root overflow: hidden so a panel mid-slide clips at the edge instead of scrolling.

🧩 Template-only primitives

  • Timeline β€” renders the conversation (you supply the user/assistant turn subtrees; per-turn scope carries text, streaming, …). The conversation bucket is locked to the stream (04).
  • ComponentSlot β€” where components render. Two forms:
Rules that bite:
  • βœ… 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 product must contain a surface selecting product β€” the name claims the instance; the surface renders it (guard-enforced).
  • βœ… A surface’s single occupant fills the surface. A limit: 1 surface 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.
In-layout surfaces without a layout are still normal: an overlay (e.g. a wizard in a focus overlay over the chat) lives inside the core, claims its view, and changes no width β€” it needs no layout file. Reach for a layout when the arrangement changes; keep a surface in-core when only a layer appears. Rich layers cap their height and scroll inside (header/footer pinned, 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: docs/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.
The formula. Spatial embeds exactly `title. whenToUse||description [category]` and ranks it against what the user literally types/says. Three consequences:
  • whenToUse IS 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.
One job per field β€” never blend: 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.layout names 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.trigger real (the app owns them); stateOrder lists states + layouts in picker order; preview seeds each layout’s mock
  • Flow slot generic (select: {}); surfaces select by where, never type
  • whenToUse utterance-shaped; description ≀120 chars
  • Preview in Studio β€” layout pills Γ— local states, then live (07); publish passes lint with 0 errors

Next: 06 β€” Styles & Tokens.