Generative UI telemetry
How often the model's UI calls are right the first time, what it gets wrong, what the UI costs and how long the user waits — recorded per UI call as metadata, shown on the dashboard's Generative UI page.
Every call of a UI tool — ui__render, ui__sandbox, ui__show_*, ui__show — is recorded by the agent loop as one row of telemetry: which tool, on which channel and in which mode, whether the model got it right the first time, what went wrong when it did not, what the call cost, and how long the user waited for the first and for the complete frame. The dashboard's Generative UI page reads it.
It is on by default with AgentGenuiModule.forRoot({ … }) and a store that records it: the in-memory store, @dudousxd/nestjs-agent-store-drizzle and @dudousxd/nestjs-agent-store-mikro-orm. Nothing to migrate: agent_genui_call is the library's own table, created by the store's schema heal like the others — MikroORM's boot heal, or the ensureAgentSchema(db) a Drizzle host already calls.
What is recorded — metadata only
Privacy default
A row never holds the generated HTML/JSX or the props. Only names, counts, timings, an error's category and its message with every quoted value replaced by "…" (a schema's received 'Jane' is stored as received '…'). Health and finance apps can leave it on.
| Field | What it is |
|---|---|
toolName, mode, channel | ui__sandbox, sandbox (or tree, per-component, show), web |
modelId, runId, threadId, actorRef, agentName, step | who and where |
attempt | 1 + the UI calls refused since the turn's last accepted one — a refused ui__render followed by a ui__sandbox is a retry |
outcome, validity | ok/error; first_try (accepted on attempt 1), retried (accepted after a refusal), failed (refused) |
errorCategory, errorMessage | shape, syntax, missing-app, bridge, unknown-component, unavailable, other; the scrubbed message (≤ 300 chars) |
inputTokens, outputTokens, costUsd, costSource | the call's share of the model step that wrote it — the step's usage and cost (from the agent_token_usage ledger's figures: provider-reported, or estimated from the price list) split by the characters each tool call's arguments and the step's text took |
firstFrameMs, completeFrameMs | turn start → the call's first UI frame on the stream (a streaming preview counts), and → its final frame (accepted calls only) |
payloadBytes | the size of the call's arguments |
payload | only with samplePayloads (below) |
The table is agent_genui_call (id = the tool call's id; indexes on created_at and run_id; its rows go with their thread, ON DELETE CASCADE), the same on SQLite, Postgres and MySQL. A call recorded again — a durable step replayed — replaces its row.
Options
AgentGenuiModule.forRoot({
catalog,
sandbox: { kit: true },
componentTools: ['Sandbox'],
// Default: metadata only. For debugging a model's UI, also keep each call's input:
telemetry: { samplePayloads: true },
// Or record nothing:
// telemetry: false,
});genuiTools(catalog, { telemetry }) takes the same option.
samplePayloads stores what the model wrote
With samplePayloads: true the generated code and props — whatever data the view shows — are stored in payload, and error messages are kept verbatim. Turn it on for a debugging session, never where a view may show personal data.
The dashboard page
GET <dashboard api>/genui?from=YYYY-MM-DD&to=YYYY-MM-DD (behind the dashboard's guard like every endpoint; 501 when the bound AGENT_GOVERNANCE_QUERIES do not implement genuiStats) returns:
- totals — UI calls, turns, first attempts, first-try successes and rate, retried, failed, tokens, cost;
- byToolModel — first-try success per tool and model;
- errors — the most common (category, tool, message), with counts and when last seen;
- byDay — UI calls, turns, tokens, cost and first-try rate per UTC day;
- costPerTurn — average, p50 and p95 UI cost of a turn;
- latency — p50/p95 per turn from its start to its first UI frame, and to the first complete frame of an accepted call.
The page shows them as KPI tiles, the tool × model table, the errors table and the daily cost chart.
On the OpenCode engine
OpenCode runs its own loop and calls the tools through the MCP endpoint (its code mode included), so the endpoint records the UI calls it serves, with attempts counted per run (GenuiRunRecorder, exported for an engine of your own). Two fields stay empty there: the model's step is not split per call (inputTokens/outputTokens are 0, no cost — the step's usage is in the token ledger as usual), and the frames are not timed (no latency).
Your own store
A store records telemetry by implementing the optional AgentStore.recordGenuiCalls(rows), and its governance queries serve the page with the optional AgentGovernanceQueries.genuiStats(range). Both are small: summarizeGenuiCalls(rows) (exported from @dudousxd/nestjs-agent-core) turns the rows of a range into the stats, so a store only selects them. GENUI_TELEMETRY_STORE_CONTRACT (@dudousxd/nestjs-agent-testing) is the behaviour to test it against.
See also
- Sandboxed generated UI — the checks whose refusals this counts.
- Cost and governance — the token ledger the cost comes from, and the dashboard.
The same telemetry, table and page ship in @adonis-agora/agent (genui({ telemetry })).
Builtin components
The ready-made component definitions in @dudousxd/nestjs-agent-core/genui/builtins — BUILTIN_COMPONENTS (DataTable, Chart, KpiCards, …) and LAYOUT_COMPONENTS (Stack, Card, Text, …). What each one is for, its props and text fallback, which tools it becomes, and how to pick or override them.
Loading states and skeletons
Two ways a generative UI answer shows that it is on its way — a tool that pushes a skeleton and replaces it in place with the data, an empty state or an error, and a ui__render tree drawn while the model writes it (streaming 'partial' | 'complete', useGenuiNode). Plus how to draw skeletons that do not shift the layout, follow dark mode and reduced motion, and announce themselves.