Generative UI runtime

The engine behind tool-ui: it maps a tool-call payload to a DOM component and renders it, so the assistant can return rich interactive UI instead of plain text. Pure native ES modules, zero dependencies, no build step. It lives under /ai/runtime/ and you activate the whole thing by importing one file:

import Runtime from "/ai/runtime/index.js";

Importing index.js also wires the chat-core bridge (it listens for chat:toolcall events — see below). Every module logs [toolui] / [runtime] breadcrumbs to the console.

See it live at /ai/runtime-demo.

The component contract

A generative-UI component is a plain object. Pin this shape — the dispatcher and every component author conform to it:

const def = {
  name:     "weather-card",                 // unique id
  category: "data",                         // grouping for catalogs
  title:    "Weather card",                 // human label
  match:    (payload) => payload?.type === "weather",   // does this def render it?
  render:   (payload, ctx) => HTMLElement,  // build & return the DOM node
  example:  { type: "weather", city: "Akureyri", temp: 4 }, // sample payload
};

| Field | Type | Purpose | |---|---|---| | name | string (required) | Unique id. Re-registering a name replaces it in place. | | category | string | Grouping for component catalogs. Defaults to "uncategorized". | | title | string | Human-readable label. Defaults to name. | | match(payload) | (any) => boolean (required) | Return true if this def should render the payload. | | render(payload, ctx) | (any, ctx) => HTMLElement (required) | Build and return the DOM node. | | example | any | Sample payload for docs, demos, and tests. |

The ctx shape

Every render receives a ctx:

ctx = {
  sendReceipt(obj),       // interactive components reply to the assistant
  onAction(name, data),   // generic action callback (clicks etc.)
  theme,                  // "dark" (default) | "light" | author value
};

ctx is always normalized before it reaches a component, so you can call ctx.sendReceipt(...) and ctx.onAction(...) without guarding for undefined.

Registering and dispatching

import { registerToolComponent, registerAll, renderToolPayload } from "/ai/runtime/index.js";

registerToolComponent(def);          // one
registerAll([defA, defB]);           // many → returns count registered

const node = renderToolPayload(payload, ctx);   // → HTMLElement
container.appendChild(node);

renderToolPayload walks registered defs in registration order and renders the first whose match(payload) returns true. Register specific components before generic ones.

Fail-safe fallback

If no def matches — or a component's match/render throws — the dispatcher never crashes. It returns a .card containing the pretty-printed JSON payload in a .code-block. Your UI degrades to readable JSON instead of breaking.

Receipts — replying to the assistant

When an interactive component (an approval, a form) resolves, it calls ctx.sendReceipt(obj). The receipt travels back to the assistant as the result of the tool call that produced the component.

sendReceipt does both, always:

  1. dispatches a toolui:receipt DOM CustomEvent (detail = the receipt), so the

chat core or tests can observe it with zero network, and

  1. attempts a POST /api/assistant on the tool-result channel, swallowing

network errors gracefully (logged, never thrown).

Receipt POST shape

POST /api/assistant
Content-Type: application/json

{
  "channel": "tool-result",
  "receipt": {
    "toolCallId": "call_abc",
    "component":  "weather-card",
    "action":     "approve",
    "data":       { "any": true },
    "ts":         1782487573184
  }
}

sendReceipt(obj) returns a Promise<{ ok, status, posted }> and never rejects — components don't have to try/catch. It accepts loose input (tool_call_id, value, name) and normalizes it into the envelope above.

Inline approvals

renderApproval(opts, ctx) is the human-in-the-loop plumbing — a generic Approve/Reject prompt. (A polished Approval Card component can be registered separately; this is the runtime underneath it.)

import { renderApproval } from "/ai/runtime/index.js";

const { element, promise } = renderApproval(
  { title: "Delete 3 records?", message: "This can't be undone.", toolCallId: "call_1" },
  ctx
);
container.appendChild(element);
const { approved } = await promise;   // resolves after the user chooses

Choosing an option resolves the promise and calls ctx.sendReceipt({ action: "approve" | "reject", data: { approved } }).

A genericApprovalComponent def is exported too. It is not auto-registered (first-match-wins would let it shadow a purpose-built card) — register it explicitly, ideally last:

import { registerToolComponent, genericApprovalComponent } from "/ai/runtime/index.js";
registerToolComponent(genericApprovalComponent);   // matches { type: "approval", ... }

The chat-core bridge

Importing index.js registers a listener for chat:toolcall. The chat core dispatches a tool call; the runtime renders it:

document.dispatchEvent(new CustomEvent("chat:toolcall", {
  detail: {
    call,              // original assistant tool call, when available
    payload,           // the tool-call payload to render
    target,            // (optional) element to append the rendered node into
    ctx,               // (optional) partial ctx, merged over the default
    toolCallId,        // (optional) threaded into receipts
  },
}));

The runtime renders with renderToolPayload, appends the node to detail.target (if given), and dispatches toolui:rendered with detail = { element, payload, toolCallId }. The default ctx routes sendReceipt through the receipts transport, so interactive components reach the assistant out of the box.

Conversation primitives

Small, composable building blocks. Each returns an element plus methods, and emits a CustomEvent on change.

ThreadList — createThreadList(opts)

Switch among conversations. In-memory, mirrored to localStorage. Emits thread:select (detail = the thread).

import { createThreadList } from "/ai/runtime/index.js";

const threads = createThreadList({
  threads: [{ id: "t1", title: "Weather" }],
  storageKey: "toolui:threads",   // null disables persistence
  onSelect: (t) => console.log(t.id),
});
sidebar.appendChild(threads.element);
// API: add(thread), remove(id), select(id), rename(id, title), list(), getSelected()

Branch picker — createBranchPicker(opts)

Navigate alternate message versions (‹ k/N ›). Emits branch:change (detail = { index, total }).

import { createBranchPicker } from "/ai/runtime/index.js";

const branch = createBranchPicker({ total: 3, index: 0, onChange: (i, n) => {} });
msg.appendChild(branch.element);
// API: setIndex(i), next(), prev(), getIndex(), getTotal(), setTotal(n)

Reasoning — createReasoning(opts)

A collapsible thinking block (native <details>), closed by default. Emits reasoning:toggle (detail = { open }).

import { createReasoning } from "/ai/runtime/index.js";

const r = createReasoning({ summary: "Show reasoning", text: "Step 1…\nStep 2…" });
turn.appendChild(r.element);
// API: setText(text), setOpen(bool), isOpen()

Suggestions — createSuggestions(items, opts)

Tappable chips that fill (and optionally submit) the composer. Emits suggestion:pick (detail = { text, submit }).

import { createSuggestions } from "/ai/runtime/index.js";

const sug = createSuggestions(
  ["Tell me more", { text: "Send now", submit: true }],
  { composer: inputEl, form: composerForm }   // composer/form optional
);
container.appendChild(sug.element);
// API: setItems(items)

Events at a glance

| Event | Emitted by | detail | |---|---|---| | chat:toolcall | chat core → runtime (you dispatch) | { call?, payload, target?, ctx?, toolCallId? } | | toolui:rendered | runtime, after rendering | { element, payload, toolCallId } | | toolui:receipt | sendReceipt | the normalized receipt | | thread:select | ThreadList | the selected thread | | branch:change | Branch picker | { index, total } | | reasoning:toggle | Reasoning block | { open } | | suggestion:pick | Suggestion chips | { text, submit } |

Design notes

directly. The design system (/styles.css) supplies all classes — components reuse .card, .btn, .code-block, .badge rather than ship their own CSS.

fail silent; pickers clamp their indices. The UI cannot crash on bad input.

and exposes window.ToolUiRuntime for ad-hoc use.