A component enters a public view → the app walks its declared states top-down and enters the first one any hosted component matches. That state’s layout draws. No match → the base arrangement, and the component renders inline. Nothing is stored, nothing is wired: names match, order decides.
The anatomy: manifest-only
<name>.yaml: the manifest is the single contract file. Same grammar as components (03): a component declares a view tree; an app declares a states: tree. Each layout is a complete arrangement: typically { "$include": "components/core" } plus that state’s slot, so shared chrome lives once in components/ and every layout includes it.
- Top-level order IS the priority ladder. The base comes first; the rest are reaction states in priority order. When hosted components sit in different views, the app enters the FIRST of its states any component matches (04 rule 4). A higher state cancels the claims below it, so release always lands on the base.
stateOrderis derived from this tree; it is no longer authored (legacy: an authoredstateOrderlist survives only for apps without a tree). - Nesting IS containment.
welcomeexists only insidemain: the compiler strips a declared base substate from every non-base arrangement, so no hand-written guard polices it. The substate’s own file keeps its condition (visibleWhen: isEmpty); the first declared substate is the viewer’s landing default. - The shell is not declared. The conversation timeline is
main’s own content, included by the shared chrome and legitimately drawn beside the rail, the page, and under focus. Declaring a state CONTAINS it: declare only what should exist in the base alone. - Each state owns its layout, same-name default.
focus: {}drawslayouts/focus; writelayout:only when the filename differs. An app with a single state is simply always in it. - Reaction is by name-match with the hosted component’s public views;
reactsTo: <otherName>on a state is the rare escape hatch for vocabulary mismatches.
welcome on an empty conversation, a voice layout’s call phases on callState) branch inside the base via the normal condition vocabulary (04); listing them on the ladder is wrong twice: they’d outrank reactions, and they’d draw inside reaction states.
Sizing: each layout owns its widths
The app is always the ACTIVE state’s layout total: nothing else, ever.Widths are declared with the neutral
appWidth key, always a named org size from styles/semantic/app-sizes.yaml (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 state with its own layout: not a guarded pane. - An overlay declares nothing. A surface rendered over the core (
inset: 0) has noappWidthand needs no state 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.
App-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:
- ✅ Slots select on the public axis (
where { field: "view" }; legacy alias:defaultState), ❌ never by component type, ❌ never by a component’s private state key. Both are lint-flagged. - ✅ A state’s layout surfaces its own view. The layout a state owns must contain a slot selecting that state’s view: the tree claims the instance; the slot renders it (guard-enforced).
- ✅ A slot’s single occupant fills the slot. A
limit: 1slot gives its occupant the frame’s full height automatically, zero per-layout height styling. Multi-occupant slots (a rail) keep content-sized instances. - ✅ One instance → one placeholder. While a component’s view matches an app state, it renders in that state’s slot: 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 app: a component owns its states (03); the app owns only the framing.
flex: 1 + minHeight: 0 + overflow: auto body).
Voice apps
Declare"service": "voice" in the manifest; the channel instantiates the native service, which projects callState into scope. The call phases (layouts/idle … layouts/user-speaking) are drawings of the base, branching inside its layouts: typically a wide core in the base and a slim core beside a card slot, both including the same files. Cards streaming in during a call are placed by the ladder 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 apps 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?
App checklist
- Manifest-only: no
<name>.yaml; the manifest declares thestates:tree, base first, reactions in priority order;layoutnames the base layout - One layout per state (same-name default), each surfacing its own view; shared chrome in
components/, included by every layout - Contained moods (welcome, call phases) nested under the base, never on the ladder; the timeline is base content, not a substate
- Widths: named app sizes only, on panels inside layouts, never raw CSS, never on a root, never
visibleWhen-guarded -
binding.workflow+binding.triggerreal (the app owns them);previewseeds each state’s mock - Flow slot generic (
select: {}); reaction slots select on the public axis (view), nevertype -
whenToUseutterance-shaped;description≤120 chars - Preview in studio: state pills, then live (07); publish passes lint with 0 errors
Next: 06. Styles & Tokens.

