Skip to main content

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 in rx/, 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)

  1. Static content → hardcoded LITERALS in the layout (value, literal items: [] on Each, src). Never props, never state.
  2. state blockSCALAR internal view-state only (step, phase, progressPct) with initial values. An array/object/URL in state is slop: the linter rejects it.
  3. props → ONLY input: true workflow-fed data (a finder’s matched products), default = the preview mock. Usually empty.
Arrival 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 Switch of 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 to turn | 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 product selects the product layout); 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 by eq.
  • A surface’s single occupant FILLS the surface (limit: 1 → the SDK gives the instance the frame’s full height; the face’s height: "full" resolves against it). NEVER per-face minHeight: full hacks to fill a panel; rails (no limit) stay content-sized.
  • A view changes two ways, both setValue into its own slice: arrival (manifest defaultState) 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). No hideBelow/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) via select.
  • Templates react via ComponentSlot.select.where: { field: "defaultState", eq: "<name>" } (+ limit: 1, most-recent-wins), never type-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: template swaps the shell; inline is the universal default. State names are otherwise OPEN: keep them consistent per org.

5. Non-negotiable style/structure rules

  1. Closed primitive set: Box Stack Row Column Each Switch ComponentSlot Timeline · Text Image Button Input Markdown Skeleton Icon · Ref $include. Conditions: eq ne in truthy only. Compose; never invent.
  2. 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.
  3. Derived values in the node/workflow: no arithmetic in definitions.
  4. A component owns its faces and size; the template owns only the framing. No component-type rules in templates.
  5. A Switch case never re-guards its own discriminant; one discriminant per axis, no boolean soup.
  6. Never hand-roll transport: the SDK’s MCP path is the only one.
  7. Locked state is read-only: conversation/lifecycle (project isStreaming/isEmpty), voice (service: "voice" in the manifest; branch on the projected callState), host chrome (host props).
  8. Icon quirk: literal glyph = icon: "phone"; bound = bind: { name: field }. An atom’s bind is field-lookup ONLY: Ref props remaps FIELDS, never carries literals. To pass a literal, use Ref with: { "type": "Ref", "ref": "button", "with": { "label": "Learn more", "icon": "arrowRight" }, "action": { … } }, a bind whose field is a with key becomes a hardcoded attribute; a truthy with key drops a matching visibleWhen guard (unprovided key ⇒ the piece stays hidden); {{key}} bindings take the literal.
  9. 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); manifest width/focusWidth are DEAD (lint error); never maxWidth in the tree to size the app; root gets overflow: hidden (05).

6. Workflow checklist

  1. Read the matching journey doc (03 component / 05 template / 06 styles); study the exemplars: productfinder/planfinder (components), acmechatlayout (template).
  2. Author to the anatomy in §2; put every shown thing in its ONE home (§3).
  3. Publish from Studio: lint 0 errors required; it enforces §2–§5 with doc-cited messages. Justify any warning.
  4. 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.