- Dictation. Capture an utterance, get the text back. You decide what to do with it.
- Read-aloud. Hand us text, get audio back. You decide what gets spoken and when.
stop() returns a transcript — it does not submit it, and the agent’s reply stays silent unless you call speak(). Build on those two primitives rather than on continuous conversation.
The division of labour is the same as everywhere else in Patchwork: your app renders the interface, we run the pipeline. Provider keys are ours, the browser only ships and plays audio.
Turn it on
voice is a single object rather than ten members on the hook, and it does nothing until you call something on it.
A working mic button
start() asks the browser for the microphone, so it must be called from a user gesture. stop() ends the take and resolves with the transcript — empty if the take was silent, cancelled, or failed, which is why the example checks before sending. Telling those three apart is below.
cancel() throws the take away. Use it for an escape key or a swipe-away, so a mistake costs nothing.
Reading a reply aloud
voice.speakingId while that clip is sounding, which is how you show a playing state on the right message rather than on all of them:
stopSpeaking() cuts playback short.
Replies you have already spoken are cached for the session, so replaying one costs nothing and returns instantly.
Markdown is handled
Agents write markdown, and markdown read literally sounds like punctuation. We render it into something worth hearing before synthesis — you do not need to strip it yourself.Drawing the microphone
waveformRef attaches a canvas that we draw the live input into while recording:
start().
Picking a voice
PassvoiceId to use a specific synthesizer voice. Omit it and the platform default applies.
{ id, name, category?, preview_url? }. preview_url is a sample you can play in the picker itself.
The state you render against
isTranscribing is the one people forget. There is a gap between releasing the button and the text arriving, and without a state for it the interface looks broken.
Without the React SDK
Three endpoints, authenticated exactly like the text path — the same subject token, andPatchwork-Connection if you use it. See the API reference for the full shapes.
speak returns audio, not JSON — it is the one endpoint in the platform that does.
How errors reach you
The voice controls never throw. Every failure goes to the hook’serror and to onError, the same as the rest of useAgent:
stop() returns "" for a silent take, a cancelled take, and a failed one. The empty string does not tell you which. If you need to tell a silent recording apart from a broken pipeline, watch error — do not infer it from the transcript.
start() only touches the browser, so what fails there is permission or hardware. The server is not reached until stop() transcribes, which is where voice_unavailable actually appears — not at the point you asked for the microphone.
When voice is not configured
Every voice endpoint returns503 voice_unavailable when the workspace’s API has no voice provider configured. This is a normal state, not a fault: a deployment without voice keys still runs agents perfectly well.
Treat it as “hide the mic button”, not “show an error”. The text path is unaffected.
Errors
speak() swallows voice_empty_text deliberately. A reply that is all code or all data renders to an empty script, and a read-aloud button that asked for it should quietly do nothing rather than raise. You will not see that one on error.