@a2ui/voice · captured from a live run, claude-opus-5
Every payload on this page is real — recorded from a single turn against the live API, not written by hand. It follows one sentence from the microphone to the speaker and back, through the agent, the wire format, and both renderers.
“direct flight to Lisbon next Friday, back Sunday night, under 300 euros”
action the agent cannot distinguish by origin.In: microphone audio. Out: a plain string. That is the entire STT contract — nothing downstream knows or cares that this came from a voice rather than a keyboard.
// Web Speech: e.results[i][0].transcript "direct flight to Lisbon next Friday, back Sunday night, under 300 euros"
Worth noticing: the demo's text box produces the identical string. Every stage after this is modality-blind, which is why the whole thing can be tested without a microphone.
In: the transcript plus conversation history. Out: one request. No tool loop, no framework — the interesting part is output_config.format.
{
"model": "claude-opus-5",
"max_tokens": 4000,
"system": [{ "type": "text", "text": "<3617 chars — the show-or-say rules>",
"cache_control": { "type": "ephemeral" } }],
"messages": [{ "role": "user", "content": "direct flight to Lisbon…" }],
"output_config": {
"effort": "low",
"format": { "type": "json_schema", "schema": /* compiled from the catalog, 5421 bytes */ }
}
}
This is the enforcement. Each catalog component becomes an anyOf variant discriminated by a const. A component that isn't in the catalog isn't rejected after generation — it is ungeneratable.
{
"type": "object",
"description": "Rows the user compares and picks from…",
"properties": {
"id": { "type": "string" },
"component": { "const": "OptionList" }, ← the discriminator
"itemsPath": { "type": "string" },
"itemTitle": { "type": "string" },
"itemDetail": { "type": "string" },
"actionName": { "type": "string" }
},
"required": ["id", "component", "itemsPath", "itemTitle", "itemDetail", "actionName"],
"additionalProperties": false
}
Seven variants compiled from seven catalog components: Column, Heading, OptionList, SeatMap, PaymentConfirm, SensitiveList, Status.
One JSON object, streamed. say is first in the schema and therefore generated first — that ordering is what makes early speech possible.
{
"say": "Four direct round-trips come in under three hundred — they're on the screen.",
"components": [
{ "id": "root", "component": "Column", "children": ["h1", "list"] },
{ "id": "h1", "component": "Heading", "text": "Lisbon, Fri–Sun" },
{ "id": "list", "component": "OptionList",
"itemsPath": "/options", "itemTitle": "title",
"itemDetail": "detail", "actionName": "pick_flight" }
],
"data": {
"options": [
{ "title": "TAP Portugal",
"detail": "two hundred forty-six euros, out Friday seven twenty in the morning,
back Sunday nine forty at night" },
{ "title": "Ryanair",
"detail": "one hundred eighty-nine euros, out Friday six fifteen in the evening,
back Sunday ten thirty at night" },
{ "title": "easyJet", "detail": "two hundred twelve euros, …" },
{ "title": "British Airways", "detail": "two hundred eighty-four euros, …" }
],
"sensitive": [],
"note": ""
}
}
Read the details again. “two hundred forty-six euros, out Friday seven twenty in the morning” — not €246 · 07:20. The prompt asks for values that read well aloud, because a renderer with no display speaks these strings verbatim. Nothing downstream reformats them.
The model's flat form is normalised into real A2UI. itemsPath becomes a binding; actionName becomes a full action with a context template. This is what both renderers actually receive.
{
"version": "v1.0",
"createSurface": {
"surfaceId": "surface_1",
"catalogId": "https://a2ui-voice.dev/catalogs/travel/v0.1/catalog.json",
"dataModel": { "options": [ /* the four rows, verbatim from stage 3 */ ],
"sensitive": [], "note": "" },
"components": [
{ "id": "root", "component": "Column", "children": ["h1", "list"] },
{ "id": "h1", "component": "Heading", "text": "Lisbon, Fri–Sun",
"accessibility": { "label": "Lisbon, Fri–Sun" } },
{ "id": "list", "component": "OptionList",
"items": { "path": "/options" }, ← itemsPath became a binding
"itemTitle": "title", "itemDetail": "detail",
"action": { "event": { "name": "pick_flight",
"context": { "index": "$index", "title": "$item.title" } } } }
]
}
}
In: the message above. Out: not speech — a plan. Which gates are active, and the referent set: the things the user can now point at with words.
{
"blockers": [], // nothing requiresVisual → the turn can be spoken
"surfaceStakes": "none", // no readback needed to commit
"silentComponents": [], // nothing spokenSensitive
"referents": [
{ "n": 1, "title": "TAP Portugal", "action": {"event":{"name":"pick_flight","context":{"index":0,…}} },
{ "n": 2, "title": "Ryanair", "action": {"event":{"name":"pick_flight","context":{"index":1,…}} },
{ "n": 3, "title": "easyJet", "action": {"event":{"name":"pick_flight","context":{"index":2,…}} },
{ "n": 4, "title": "British Airways", "action": {"event":{"name":"pick_flight","context":{"index":3,…}} }
]
}
Each referent carries the action a tap would have fired. That is the mechanism behind the whole claim — the voice path doesn't construct its own event, it reuses the one already attached to the row.
In: a string. Out: audio. The renderer did not write this sentence — the agent did, at stage 3. In the ordinary case nothing is “converted to speech” at all.
// TTS input, verbatim "Four direct round-trips come in under three hundred — they're on the screen." // fired at t=2278ms, while the components were still generating
The renderer speaks only the mechanical extras: announcing a value that changed after the agent stopped (accessibility.live), reading back before a commit, confirming a pick. Everything else is the agent's own words.
In: a two-word transcript. Out: the same event a tap produces.
// STT output "the second one" // voice renderer resolution → status: "dispatched" { "event": { "name": "pick_flight", "context": { "index": 1, "title": "Ryanair" } } }
Byte-identical to a tap on row 2. The agent receives this and cannot tell which happened — which is what lets voice be added to an existing A2UI app without the agent changing at all.
Stages 1 and 6 are the only places the modality is visible. Swapping the engine changes what crosses those two boundaries and nothing else — which is the point of putting them behind an adapter.
| Boundary | Web Speech (default) | Deepgram |
|---|---|---|
| STT in | mic, handled by the browser | 16 kHz mono PCM frames via AgentMicrophone → sendAudio() |
| STT out | e.results[i][0].transcript — a string |
ConversationText { role: "user", content } |
| TTS in | speechSynthesis.speak(new SpeechSynthesisUtterance(text)) |
session.injectAgentMessage(text) |
| TTS out | audio, straight to the OS voice | 24 kHz PCM binary frames → AgentPlayer.queue() |
| Stop talking | speechSynthesis.cancel() — local, instant |
AgentPlayer.interrupt() — local, drops the queue on this tick |
| Barge-in signal | none — it listens or speaks, not both | UserStartedSpeaking |
On interruption. The Voice Agent wire protocol does carry
InjectAgentMessage.behavior with default | queue | interrupt, but the official SDK's
helper takes only a message. Cancellation here is therefore local — which is the half a listener actually
perceives, and it lands a round trip sooner.
One honest gap. 2278ms to first speech is a large improvement on 6556, and still well above the 300–500ms of natural turn-taking. Streaming buys what it can; closing the rest needs a pre-generated surface (run-ahead) or a smaller model for the sentence.