Skip to main content

widget

The widget module lets agents push declarative UI components into the the chat client / web client. The agent calls render(zone, ref|tree, ctx) to mount a widget, update(widget_id, patch) to mutate it live, and close(widget_id) to unmount. Every call publishes a Socket.IO event on the per-session room which the client replays into its widget tree with no extra code.

PropertyValue
Module idwidget
Action count7 (LLM-exposed)
Typeper-app instance, per-session state via WidgetSessionState
TransportSocket.IO (widget:* events on session:{id} room)

Design

  • Per-session isolation - each session has its own WidgetSessionState. Two users in two sessions get two independent widget surfaces.
  • Server-side templating - {{form.X}}, {{state.X}}, {{ctx.X}}, {{item.X}} tokens inside tree or patch are resolved by substitute_tree before publishing - the client renders concrete values.
  • Bidirectional bridge - widget state is also injected into the agent's system prompt under # WIDGET CONTEXT, so form values, selections, and the last widget-triggered tool result are always visible to the next reasoning turn.
  • Compile-time validation - widget trees are validated by the compiler against the closed primitive / action / accent / filter sets.

Configuration

The widget module has no user-facing config fields - all content lives in the ui.widgets: block of app.yaml. The module body just enables it:

tools:
modules:
widget: {}

The full ui.widgets: reference (zones, primitives, actions, expression language, REST API) is in Widgets. Quick recap:

ui:
widgets:
version: 1
chat_side: { tree: { ... } }
workspace_tabs:
- { id: results, title: "Results", tree: { ... } }
modals:
confirm_delete: { tree: { ... } }
inline:
booking_form: { data: { ... }, tree: { ... } }

External widget files under widgets/<name>.yaml in the bundle are auto-merged into inline keyed by file stem (same pattern as skills).

Mount zones

ZonetargetPurpose
inline-Inline widget in the chat flow.
chat_side-Side panel next to the chat.
workspacetab idNamed workspace tab.
modalmodal nameDismissible overlay.

The 7 module actions

Terminology. The widget surface uses the word "action" for two distinct concepts: (a) module actions — the 7 @action-decorated methods on the Python WidgetModule, which the agent calls to mount / update / close widgets; (b) widget action-types — the 15 client-side dispatch verbs (chat, tool, http, open_url, ...) that a widget tree fires back at the daemon when the user interacts. This section covers (a); the 15 action-types live in language/widgets.md.

All risk_level: low, tags=[widget, ui].

ActionSourceVisible paramsPurpose
widget.renderzone, target?, widget_id?, ref?, tree?, ctx={}, turn_id?Mount a widget. Takes ref XOR tree.
widget.updatewidget_id, patch: dictPatch a mounted widget. Dotted paths: data.X, state.X, ctx.X.
widget.closewidget_idUnmount.
widget.errorwidget_id, binding?, messageSurface a binding error without unmounting.
widget.get_statekey? (dotted path)Read state. Returns {value, found}.
widget.set_stateset: dictMerge into session state.
widget.clear-Unmount everything + wipe state.

All return ActionResult(success, data, error). render returns {widget_id} (auto-generated as w_<uuid12> when not supplied). update and close echo the widget_id.

Template substitution scope

Built from the live session by _build_scopes:

Scope
form.*
state.*
ctx.*
item.*
session.session_id
app.*
await widget.update(UpdateParams(
widget_id=wid,
patch={"state.greeting": "Hello {{form.name}}"},
))

Socket.IO event types

Every mutation appends a WidgetEvent to the session's ring buffer with an incrementing seq, then publishes:

{ "type": "widget:<event_type>", "data": { ...payload, "widget_seq": 17 } }
EventEmitted byPayload
widget:renderrenderFull MountedWidget.to_dict (widget_id, zone, target, ref, tree, ctx, turn_id).
widget:updateupdate{widget_id, patch} (patch already substituted).
widget:closeclose{widget_id, was_mounted}.
widget:errorerror{widget_id, binding, message}.
widget:stateset_state{state} (full snapshot post-merge).
widget:clearedclear{}.
widget:snapshotServer → client on join_sessionFull WidgetSessionState.snapshot.

Clients use widget_seq to reconcile after a reconnect: request snapshot, then drop live events with widget_seq <= snapshot.seq.

Prompt injection - # WIDGET CONTEXT

get_prompt_sections injects the current session's widget state every turn:

# WIDGET CONTEXT

## Form values
- **name**: "Alice"
- **last_booking_topic**: "1:1 with Alice"

## Session state
- **selected_sources**: ["s1", "s2", "s3"]

## Last widget tool result
- **rag.query**: {"hits": 12, "top_score": 0.94}

## Currently mounted widgets
- **w_a1b2c3d4e5f6** (zone=chat_side, ref=booking_form)

The agent references these values in subsequent reasoning and tool calls - widgets are a first-class bidirectional bridge between the UI and the LLM.

Integration notes

  • chat client / web first - the client is the primary renderer. React apps that just need files (no widget tree) usually use workspace + preview directly.
  • No SSE - transport is Socket.IO only; widget events flow through the same bus as preview / channels.
  • Compile-time validation - tree shapes are checked at compile against the closed WIDGET_PRIMITIVES / WIDGET_ACTIONS / WIDGET_FILTERS / accent / density / colour sets . Runtime render calls that pass malformed tree return ActionResult(success=False, ...) without publishing.

Cross-references