WidgetTree: a small, declarative JSON document built from typed block primitives. The backend produces the tree (authored verbatim for tours and surveys, or composed by an LLM for guides) and pushes it to the browser over Socket.IO; @firstflow/react walks the tree and renders DOM.
You rarely hand-write a tree. But the shape is the contract between the server that decides what to show and the browser that shows it, so understanding it explains how dynamic text, conditional visibility, and button actions actually work and why some actions resolve in the browser while others travel back to the server.
How it works
AWidgetTree is a render document, not a component. It declares a single root block, an optional initialState bag, and a header. The browser renderer is a passive interpreter: it reads the tree, seeds widget state from initialState, and renders each block by type. Nothing in the tree calls your code directly interactivity is expressed as actions that the renderer dispatches, and refs that read live values out of the state bag.
The tree never originates in the browser. Eligibility triggers, audience, schedule, frequency, and any classifier gate is evaluated server-side in the conversation router. When a user qualifies, the backend resolves the experience’s flow to a result, composes a WidgetTree, validates it against the server’s mirror of the block schema, and emits a widget.show event to that user’s Socket.IO room. The browser SDK is a passive consumer: it renders what arrives and reports interactions back. That is why you cannot force a widget to appear purely from client code see Realtime.
The two production paths differ only in who fills the tree. Guides declare a skeleton with placeholders and let an LLM fill it. Tours, surveys, and announcements are authored block-by-block in the flow editor and rendered verbatim. Both end at the same widgetTreeSchema, so the browser renderer treats every tree identically once it arrives.
The WidgetTree shape
The top-level document is intentionally tiny one root block plus state and a header. It lives in@firstflow/widget-kit and is mirrored as a Zod schema on the backend so a composed tree is rejected before it can reach a browser.
initialState is the starting value of the widget’s state bag a flat key/value object that block bind targets write into and ref reads pull from. A text_input bound to { ref: "form.email" } writes the typed value at form.email; a text segment { ref: "form.email" } reads it back. State is per-widget and lives in the browser for the life of the rendered widget.
schemaVersion distinguishes a legacy v0/v1 translated widget from a v2 authored tree. Only v2 trees may declare slots, the placeholder manifest used by guides (covered below). The widgetId is the stable identity the server uses when it later pushes a state.patch or a replacement tree to the same widget.
Block primitives
A block is a discriminated union ontype. Blocks nest: layout blocks hold children, and every block can carry the universal BlockMixin fields. They fall into four working categories.
Every block accepts these universal fields (
BlockMixin):
Conditionally render the block. The block is omitted from the DOM when the predicate evaluates false against the current state bag. See Predicates below.
Layout weight, honored only when the block is a direct child of a
row. Children share the row’s width in proportion to their flex (flex: 2 takes twice the space of flex: 1). It is a proportion, never a pixel size, so layouts stay responsive. Ignored outside a row.Explicit render width in px (set by composer drag-resize). Unset means natural size. Prefer
flex for responsive layouts.Explicit render height in px. Unset means natural size.
This block’s own alignment on its parent container’s cross axis (CSS
align-self). Distinct from a row’s align, which positions all of the row’s children.block shape the renderer consumes:
Dynamic values: refs
Text and inputs bind to the state bag with a ref a{ ref: "path" } pointer into initialState plus anything inputs have written since. A text block accepts a plain string, a single ref, or a TextSegment[] that interleaves literal runs with refs for mid-sentence values.
text that reads { ref: "form.email" } updates live as the user types into the input bound to the same path. Refs are how a widget shows what the user just selected, echoes their name, or reflects a slider value without any host code.
Predicates: conditional rendering
A predicate is a small boolean expression evaluated against the state bag. It powersvisible (show/hide a block) and disabledWhen (disable an interactive block). Operands are refs or literals; the comparison and logical operators are fixed:
Actions: what happens on interaction
Interactive blocks carry actions. Abutton declares an ordered actions array; pill_group, checkbox, slider, and rating carry a single action. Actions split into three reaches that determine where they resolve.
Local resolve entirely in the browser
Local resolve entirely in the browser
Mutate widget state or the widget frame without touching the server:
state.setwrite a value at a path in the state bag.state.resetreset the bag (optionally to a provided object).widget.dismisshide the widget.widget.minimizecollapse the widget to its resting state; it stays alive and can re-expand.tree.replaceswap the entire rendered tree for a new one carried in the action.noneexplicit no-op (an input still writes itsbind, but no action fires). Use it when a separate Submit button owns the action.
Host delegate to your application
Host delegate to your application
Hand control to your app via the widget’s handlers (see Widget):
submitcomplete the widget;{ name, payload?, submits? }. Withsubmits: true, analytics also emitsff_widget_submit(state keys only, never values).emitfire a named host event with an optional payload.send_prompthand a populated prompt to your chat surface; your app decides what to do with it. FirstFlow does not own the user’s chat input.open_urlopen a URL (target: "_self" | "_blank").flow.next/flow.backadvance or rewind a multi-step flow. The SDK only emits intent; the server walks the flow and pushes the next tree.
Remote delegate to the backend
Remote delegate to the backend
A single
remote action { kind: "remote"; name; payload? } sends a named intent back to the backend for server-side handling.onAction / onAppEvent handlers; local actions never surface to you. The split matters operationally: flow.next looks like a button click but is a server-side navigation the browser emits widget.action, the backend’s flow navigation listener advances the step and composes the next tree. A click that “does nothing” usually means a host action with no handler wired, not a broken widget.
Action ordering and terminal actions
Abutton can run several actions on one click. The runtime sorts them so non-terminal actions run first in author order, and the single terminal action one that completes or navigates away from the widget runs last. The terminal set is flow.next, flow.back, widget.dismiss, submit, and tree.replace. Only one terminal action may run per click; pinning it last prevents acting on an already-unmounted widget.
widget.minimize is intentionally not terminal it only collapses the frame, so it can be combined freely with any other action.
Compound blocks
Two primitives express patterns that would be tedious to hand-build.repeat renders a template block once per item in a ref’d array, so a tree can render a list whose length is unknown at author time:
checklist tracks completion across checklist_item children, keeping an array of checked item ids on its bind ref. Items can auto-complete from host-app events (completionEvent), and the block can show progress, a continue button, and a completion action useful for an onboarding “getting started” list driven by real product events:
Guides and slots
Guides are the one experience type whose tree an LLM fills. A v2 guide tree declares aslots manifest typed placeholders the author binds primitives to via { ref: "slots.<id>" }:
WidgetCompositionService calls Anthropic (claude-sonnet-4-6) to fill each declared slot from the conversation context, merges the values into initialState under slots.*, and pushes the finished tree. From the renderer’s perspective a filled slot is just an ordinary slots.* key in the state bag the same ref mechanism that powers any other dynamic value. This is why guides feel generated while tours and surveys feel fixed: the manifest is authored, the values are composed per conversation, and both land in the same state bag the renderer reads.
Limitations and edge cases
The browser renderer is deliberately constrained, which keeps composed widgets safe and predictable:- No arbitrary HTML or scripts. A tree is a fixed union of typed blocks. Anything outside the schema is rejected by
widgetTreeSchemaon the server before it can be pushed, and is not rendered by the browser. There is no escape hatch into raw markup. flexonly inside arow. Settingflexon a block that is not a row child has no effect; usewidth/heightfor explicit sizing.- One terminal action per click. Author multiple actions freely, but exactly one of the terminal kinds runs, and it runs last regardless of author order.
- State is per-widget and browser-local. The state bag lives only as long as the rendered widget. To persist an answer, fire a host
submit/emitor aremoteactionstate.*alone does not reach your backend. send_promptis host-owned. FirstFlow hands you the prompt; rendering it into a chat is your app’s job, because FirstFlow does not own the user’s chat surface.- Composition is validated, not trusted. Even an AI-composed guide tree must pass the same
widgetTreeSchemaas an authored one; a malformed tree is dropped rather than rendered.
Next
Render and handle a tree with the FirstflowWidget component, build or inspect trees directly with@firstflow/widget-kit, and see how trees arrive in the browser in Realtime. For where trees come from, read Flows & nodes and Experiences.