Unoverse Design: Agent Rulebook
Condensed, non-negotiable rules for building Unoverse components and templates. Full journey: README, docs 01–09. Deep reference:docs/unoverse/UNOVERSE_AUTHORING.md, UNOVERSE_STATE_MODEL.md (§5b = the reaction contract), UNOVERSE_LAYERS.md, UNOVERSE_CONFORMANCE.md.
1. The architecture in one paragraph
UI is data: neutral JSON inrx/, rendered natively per platform by a dumb, style-free SDK. Templates are MCP Apps (manifest = the envelope; sends = tools/call; answers = elicitations; state on the MCP /stream). Interaction follows the reaction contract: a component writes ONLY its own slice; templates are pure views that react via state selectors; inline is the universal default. You author data; you never touch the SDK, transport, or platform code.
2. The anatomy (both kinds share the folder grammar)
Two component tiers: design system = the installed marketplace package (generic, org-neutral, any org); org =
rx/<project>/components/ (org-private, that client’s own, discoverable only in their apps). Names UNIQUE across all tiers (lint error on collision); org may reference design-system, design-system NEVER references org (lint). URIs: unoverse://components/<org>/<name> (org) · unoverse://components/<name> (design system; bare also resolves any unique name).
3. Three homes: everything a component shows (slop rule)
- Static content → hardcoded LITERALS in the layout (
value, literalitems: []on Each,src). Never props, neverstate. stateblock → SCALAR internal view-state only (step,phase,progressPct) with initial values. An array/object/URL instateis slop: the linter rejects it.props→ ONLYinput: trueworkflow-fed data (a finder’s matchedproducts),default= the preview mock. Usually empty.
defaultState lives in the manifest, not the state block.
Prop names = the data contract. Source data (content rows, node outputs) seeds component state as-is, by name: no projection, no mapping; a bind whose name the source doesn’t carry silently renders the preview default (tell: title streams, image/tagline stay mock). Content-attached cards MUST use the writer vocabulary: title tagline description bodyCopy introParagraph primaryImage images link callToAction, never invent (image/subtitle/category/location are misses). Fix = rename the prop to the source field, never add glue. Guard: content-card-hydration.test.ts; law: UNOVERSE_MCP_TEMPLATE_PROTOCOL.md §Content-attached cards; walkthrough: docs/design/03 §Prop names.
Naming = discoverability (docs/design/05 §Naming; canonical: docs/nodes/14): Spatial embeds title. whenToUse||description [category] against the USER’S OWN WORDS. title = the thing itself; description = what it IS, ≤120; whenToUse = utterance-shaped, outcome-first, opening words dominate; category = job domain. Disqualify by property, NEVER name a sibling; a fallback surface never enumerates siblings’ jobs (generalist trap).
Briefs (AI-fed components): a brief sits ON the node that renders what it describes, { description, maxLength } next to the bound element, { description, minItems, maxItems } on the Each (JSON Schema’s own words, the brief IS the schema fragment), plain-string context on a face/partial root; NEVER in the manifest or a separate file. The platform compiles briefs into the component’s MCP tool schema (keys pass through verbatim as native JSON Schema; Each template binds→items schema): the schema IS the instruction channel; grounding (fill only from Spatial results, never invent) is injected by the compiler. Closed shape, lint-enforced (docs/design/03).
Lifecycle hooks (the ONE code carve-out): a component may fetch its own data at a
platform fire point: onStart (instance created) or onEnterView (instance entered a
named layout, layouts: [...]-scoped). Declared in the manifest’s lifecycle array;
nothing undeclared runs. Full authored copy NEVER travels through a model: a card
carries universal_id and the PLATFORM dereferences it: name the platform-ready
handler: getDetail (zero code) rather than making the AI read a record and retype it
into props. Bespoke needs DECLARE their calls in <handler>.yaml beside the manifest (filename = the HANDLER, lowercased: named for what it DOES, never for the phase)
(calls: = the list an api/run.yaml is, run by the same runtime → host allowlist,
credentials, retries, transports; returns: projects them to partial props). The manifest
MUST declare allowedHosts (lint error otherwise: no list, no network) and names its
credentials by type. <phase>.js is the LEGACY form, still running, never the one to
copy. A detail view’s content is onEnterView, never onStart: hydrating at
creation fetches a body copy for every card in a grid. NOTHING TO WIRE UP: a component
changing its own view writes defaultState, and that write IS the event the hook reacts
to (no signal to author, no action to chain). Fire points are platform-owned
(authors add handlers, never moments); lint errors on an unknown phase, an unknown handler
name, layouts: on a phase with no view, an un-opted handler file, or an opt-in that
resolves to nothing (lifecycle hooks).
4. The reaction contract: state-selected UI (STATE_MODEL §5b)
- A component is a
Switchof views, one active. State is local; the view (defaultState) is the interface: the only thing a template sees. A component’s internal state (step,phase) is private. A surface-only component omits inline/default faces + declares a surfaced arrival (manifest.defaultState = one of its cases): it renders ONLY while surfaced and retires invisibly when the conversation moves on (lint-recognized). - Lifetime (OPTIONAL manifest flag):
"lifetime": "conversation"= a durable, conversation-scoped surface (cart / itinerary / composed page), keyed by the CONVERSATION (re-calls hydrate the SAME slice, merge not re-place) and exempt from the new-turn reset; it stays until replaced, self-closed, or a template swap (the hard refresh boundary, a new shell retires every surface, durable included). Default"turn"= the universal reset above. Values closed toturn | conversation(lint-checked). - A template is in exactly ONE state at a time: the latest surfaced view (derived, never stored; "" = conversation/welcome base). Exactly one reaction surface renders: the active state’s. Layouts sync by NAME: the active layout = the layout named after that view (a card entering
productselects theproductlayout); a layout must surface its own view inside (guard). Other states’ data persists untouched and re-presents when active again. Surfaces never stack; panel combinations cannot exist. Lint: one surface per view, surfaces claim ONE view byeq. - A surface’s single occupant FILLS the surface (
limit: 1→ the SDK gives the instance the frame’s full height; the face’sheight: "full"resolves against it). NEVER per-faceminHeight: fullhacks to fill a panel; rails (no limit) stay content-sized. - A view changes two ways, both
setValueinto its own slice: arrival (manifestdefaultState) or user interaction. Close = it sets itself back (inline); its expanded face carries its own ✕. - One instance → one placeholder. Every view has a placeholder: the flow =
inline; a reaction surface = a named view. While a view matches a surface, the instance lifts out of the flow into the surface (the SDK renders it in exactly one place: never both). NohideBelow/overlay trick to hide a flow copy. An unmatched view stays in the flow. Many instances are fine: the template decides a placeholder’s layout (flow list / one focus / rail) viaselect. - Templates react via
ComponentSlot.select.where: { field: "defaultState", eq: "<name>" }(+limit: 1, most-recent-wins), nevertype-pinned, never by id, never on a component’s internal state key (all lint-flagged). Template-focus is DERIVED (does anything match?), never stored. setTemplateValue= ONLY the template’s own chrome (panels, draft). A component writing template state to open a surface is the deprecated bridge: linted.- Reserved behaviors:
templateswaps the shell; inline is the universal default. State names are otherwise OPEN: keep them consistent per org.
5. Non-negotiable style/structure rules
- Closed primitive set:
Box Stack Row Column Each Switch ComponentSlot Timeline · Text Image Button Input Markdown Skeleton Icon · Ref $include. Conditions:eq ne intruthy only. Compose; never invent. - LAW 1: zero raw values, no
px/rem/em/#hex; semantic token names only; style KEYS are closed (no web-isms); dimension VALUES must be real space-scale steps (0 1 1.5 2 3 4 5 6 7 8 10 12 16 20 24 28 40 50 75 90 100 120 140 160 180 200,full,auto), an invented step is silently broken CSS. - Derived values in the node/workflow: no arithmetic in definitions.
- A component owns its faces and size; the template owns only the framing. No component-type rules in templates.
- A Switch case never re-guards its own discriminant; one discriminant per axis, no boolean soup.
- Never hand-roll transport: the SDK’s MCP path is the only one.
- Locked state is read-only: conversation/lifecycle (project
isStreaming/isEmpty), voice (service: "voice"in the manifest; branch on the projectedcallState), host chrome (host props). - Icon quirk: literal glyph =
icon: "phone"; bound =bind: { name: field }. An atom’sbindis field-lookup ONLY:Ref propsremaps FIELDS, never carries literals. To pass a literal, useRef with:{ "type": "Ref", "ref": "button", "with": { "label": "Learn more", "icon": "arrowRight" }, "action": { … } }, a bind whose field is awithkey becomes a hardcoded attribute; a truthywithkey drops a matchingvisibleWhenguard (unprovided key ⇒ the piece stays hidden);{{key}}bindings take the literal. - Sizing in one sentence: the app = the ACTIVE LAYOUT’s total, nothing else, ever (core panel + that layout’s surface panel). The core surface (chat column) = a panel that is ALWAYS open (
appWidth); each state’s panel declares its width on its surface; ONE state active ⇒ the width is always one of a small known set, bounded by construction: nothing can combine, overflow, squeeze, or clip. Host animates between known widths; the core never moves. Values = the org’s STANDARD SIZES (styles/semantic/app-sizes.json:chat/rail/panel, theme-resolved like any token, lint-checked), raw CSS appWidth is DEAD (lint error). ONE declaration per panel (panel/frame never declares width/flex: lint error); an overlay (inset: 0, e.g. focus) declares NOTHING and needs no layout; appWidth NEVER on a layout root, NEVER visibleWhen-guarded (a conditional arrangement is a LAYOUT); manifestwidth/focusWidthare DEAD (lint error); nevermaxWidthin the tree to size the app; root getsoverflow: hidden(05).
6. Workflow checklist
- Read the matching journey doc (03 component / 05 template / 06 styles); study the exemplars:
productfinder/planfinder(components),acmechatlayout(template). - Author to the anatomy in §2; put every shown thing in its ONE home (§3).
- Publish from Studio: lint 0 errors required; it enforces §2–§5 with doc-cited messages. Justify any warning.
- Preview in Studio: mock (prop defaults + state picker + Inline/Focused toggle), then live. Debug order: stream log → state inspector → definition. Never edit on a guess.
7. Error → fix quick table
Full table: 09, Troubleshooting.

