@a2ui/voice · captured from a live run, claude-opus-5

One voice turn, traced

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”

2278msspeech starts
6556mssurface complete
4278mscovered by speech
4rows rendered
INPUT AGENT RENDERERS OUTPUT Mic PCM audio audio STT → transcript text Claude one structured call catalog = output schema say + components + data A2UI createSurface Visual renderer tree → DOM Voice renderer gates + referents Screen 4 rows text TTS → speaker audio out action — identical whether tapped or spoken tap
Two renderers, one data model, one return path. The agent produces both channels in a single call. The voice path and the visual path diverge only at the last hop — and converge again on an action the agent cannot distinguish by origin.
1

Speech to text

mic → transcript

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.

2

What the agent sends

renderer → API

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 */ }
  }
}
2b

The catalog, compiled into the schema

catalog → JSON Schema

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.

3

What the model returns

API → renderer

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.

4

The A2UI wire message

agent → renderers

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" } } } }
    ]
  }
}
5

What the voice renderer derives

A2UI → plan

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.

6

Text to speech

renderer → speaker

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.

7

The follow-up, resolved

transcript → action

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.

What the speech engines actually exchange

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.

BoundaryWeb Speech (default)Deepgram
STT in mic, handled by the browser 16 kHz mono PCM frames via AgentMicrophonesendAudio()
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.