Custom UI driven by events
Build any UI affordance over `runtime.events` and `CUSTOM` UI events.
The DARTC gemmapod.ui.event topic carries a richer event stream than
"chat went brrr". Use it to build:
- live cart panels (
STATE_SNAPSHOT/STATE_DELTA) - 3D companions reacting to model mood (
CUSTOM) - payment confirmation sheets (
CUSTOM name="checkout.requested") - approval flows for tool calls (
TOOL_CALL_*+ a host-side modal) - generative dashboards (
MESSAGES_SNAPSHOT+STATE_SNAPSHOT) - presentation overlays (
CUSTOM name="presentation.show")
This guide lays out the pattern.
The runtime auto-applies state events
You don't need to handle STATE_SNAPSHOT and STATE_DELTA
manually. The runtime applies them to runtime.state and emits a
consolidated state.changed event:
runtime.events.on("state.changed", ({ state }) => {
renderMyUI(state);
});Same for MESSAGES_SNAPSHOT — auto-applied into runtime.chat
history, and a chat.history event fires.
Subscribe to the typed UI event stream
For everything else (run lifecycle, tool calls, activity, custom):
runtime.events.on("ui.event", ({ event }) => {
switch (event.type) {
case "RUN_STARTED": setBusy(true); break;
case "RUN_FINISHED": setBusy(false); break;
case "RUN_ERROR": showError(event.message); break;
case "TOOL_CALL_START":
addLiveTool({ id: event.toolCallId, name: event.toolCallName });
break;
case "TOOL_CALL_RESULT":
finishLiveTool({ id: event.toolCallId, content: event.content });
break;
case "CUSTOM":
handleCustom(event.name, event.value);
break;
}
});Custom events — the escape hatch
CUSTOM is the official escape hatch for app-specific UI updates that
don't fit the typed catalogue. The host signs and emits:
{
"type": "CUSTOM",
"threadId": "conv_…",
"runId": "run_…",
"name": "checkout.requested",
"value": { "totalCents": 4200, "currency": "USD" }
}The host page filters by event.name and updates the UI:
runtime.events.on("ui.event", ({ event }) => {
if (event.type !== "CUSTOM") return;
if (event.name === "checkout.requested") {
showCheckoutSheet(event.value);
} else if (event.name === "companion.mood") {
setCompanionMood(event.value.mood);
}
});A few naming conventions worth following:
- Lowercase dot-separated names:
checkout.requested,companion.react,presentation.show. - The
valueis an opaque payload — keep it serialisable JSON. - Don't put model-generated text in
CUSTOMpayloads. Text ridesTEXT_MESSAGE_*events.
Real example: the gemmapod.com hero
The live gemmapod.com hero shows a 3D cube companion next to the chat
that reacts to model state. It's driven entirely by CUSTOM events:
| Custom event name | Effect on the page |
|---|---|
companion.react | Mood + stage + facial expression |
companion.say | Speech bubble text |
companion.mood | Just the mood field |
presentation.show | Slide-in panel with title/body/items + status |
No new DARTC topics. No parallel websocket. Each CUSTOM event is a
signed DARTC envelope on the same channel as the chat itself.
These events are emitted by UI tools registered in the host daemon.
The host ships generic tools (show_presentation, set_state, send_custom_event)
that work for any host. Companion-specific tools (react_companion, say_companion)
are opt-in — hosts without a 3D avatar don't need them.
See UI tools and companion plugins for how to register, omit, or build your own UI tools.
See also
- Restaurant pod example — full
STATE_SNAPSHOTcart demo - UI tools and companion plugins
- State + snapshots guide
- AG-UI bridge guide
- DARTC UI events reference