Voice agent protocol (Preview)
Preview. This page describes version 0.2.0 of the Sent Voice Agents protocol, spoken over the sent.voice.v1 WebSocket subprotocol. A 0.x release can still change frames before 1.0. The canonical specification is the sent-dm-voice-protocol repository: docs/protocol.md, asyncapi.yaml, a JSON Schema (draft-07) per frame under schemas/, valid and invalid examples under examples/, and the conformance scenarios every Sent Voice Agents SDK passes.
An agent session is one phone call while one agent is on it. Sent and the agent exchange frames over a WebSocket: JSON text messages, one frame per message, and binary messages for audio. This page is the reference for those frames. How to get connected, either way round, is in Connect a voice agent.
Every frame has a type. Every frame about a session carries its session_id. Ids follow the REST API's: a session is vs_..., a call is call_..., the same id GET /v3/voice/calls/{id} reads, and an agent is agent_....
Frames
| Frame | Sent by | Belongs to | Session types | Fields |
|---|---|---|---|---|
connection.ready | Sent | connection | protocol_version, heartbeat_interval_ms, max_sessions | |
connection.draining | Sent | connection | none | |
ping | Sent | connection | ts | |
pong | agent | connection | ts | |
error | Sent | connection | error, session_id, frame_type | |
session.start | Sent | session | text, audio | call_id, agent_id, direction, from, to, session_type, started_at, language, audio, max_duration_ms, context, resume |
session.end | Sent | session | text, audio | reason, duration_ms |
user.said | Sent | session | text | text, correction |
agent.say | agent | session | text | text, interruptible, last |
agent.clear | agent | session | text, audio | none |
tool.call | agent | session | text, audio | call_ref, name, arguments |
tool.result | Sent | session | text, audio | call_ref, ok, error |
session.resume | agent | session | text, audio | token, received |
session.resumed | Sent | session | text, audio | received, resume, audio |
| binary audio | both | session | audio | a 2-byte stream number, then PCM |
Order of frames
- Sent's first frame on a connection is
connection.ready. - For each session, Sent's first frame is
session.startand its last issession.end. Aftersession.endthe agent sends nothing more for that session. - On a connection a session is resumed on, Sent's first frame for it is
session.resumed. ping,pong,connection.draininganderrorbelong to the connection and can come at any time.- Frames for one session arrive in the order they were sent, also across a resume. Frames for different sessions can interleave.
Limits
user.saidandagent.saycarry at most 4,096 characters oftext.- A text frame is at most 64 KB. Sent refuses a larger one with
invalid_frame. - An outbound call's
contextis at most 4 KB as JSON. - Each side keeps the last 32 frames it counted for a session, to send again when the session resumes. A session whose connection is lost has
window_msto resume, 15 seconds.
Connection frames
connection.ready
First on a connection the agent opened, and first on a connection Sent opened: the connection is authenticated, and sessions follow.
| Field | Type | Description |
|---|---|---|
protocol_version | string | The version of this protocol Sent speaks on the connection, such as 0.2.0 |
heartbeat_interval_ms | integer | How often Sent sends ping on the connection, 5,000 |
max_sessions | integer | The most sessions Sent puts on the connection at once. 1 on a connection Sent opened |
{ "type": "connection.ready", "protocol_version": "0.2.0", "heartbeat_interval_ms": 5000, "max_sessions": 50 }connection.draining
No new sessions start on this connection. Open a new one now, while the sessions already on this one go on, and send a session.resume on it for each session, with the session's latest token and your received count. Sent closes the drained connection with code 1001 once no session is left on it. No fields.
{ "type": "connection.draining" }ping and pong
Sent sends ping on every connection at heartbeat_interval_ms. Answer each one with pong carrying the same ts, milliseconds since the Unix epoch. Three unanswered pings close the connection with code 4008. Three intervals without a ping mean the connection is dead: open a new one and resume the sessions there.
{ "type": "ping", "ts": 1792411215000 }{ "type": "pong", "ts": 1792411215000 }error
A frame the agent sent wasn't accepted and had no effect. The connection stays open.
| Field | Type | Description |
|---|---|---|
error.code | string | One of the error codes. Sent may add codes |
error.message | string | For people. It may change without notice |
session_id | string | The session the refused frame named, when it named one |
frame_type | string | The type of the refused frame, when it had one |
{
"type": "error",
"error": { "code": "frame_not_allowed", "message": "agent.say is not allowed in an audio session." },
"session_id": "vs_7Hq2Lm9xQa",
"frame_type": "agent.say"
}Session frames
session.start
First for every session: a call has reached the agent.
| Field | Type | Description |
|---|---|---|
session_id | string | The session, vs_ and an id |
call_id | string | The call, as GET /v3/voice/calls/{id} knows it. It stays the same when the call moves to a person |
agent_id | string | The agent, as /v3/voice/agents knows it |
direction | string | inbound for a call to one of your numbers, outbound for a call the agent placed |
from, to | object | The parties |
session_type | string | text or audio, from the agent's setting |
started_at | string | When the session started, ISO 8601 |
language | string | Text sessions: the caller's language as a BCP 47 tag, from the agent's speech.language |
audio | object | Audio sessions: stream, the number in the header of this session's binary messages, and sample_rate, the Hz of the 16-bit mono PCM both ways, 8000, 16000 or 24000 |
max_duration_ms | integer | When Sent ends the session with reason max_duration, counted from started_at, from the agent's limits.max_call_seconds |
context | object | What your backend passed when it placed an outbound call, at most 4 KB as JSON |
resume | object | token and window_ms, how to resume the session on a new connection. Absent on a connection Sent opened |
{
"type": "session.start",
"session_id": "vs_7Hq2Lm9xQa",
"call_id": "call_9f2ab000-0000-4000-8000-000000000001",
"agent_id": "agent_9f2ab000-0000-4000-8000-000000000001",
"direction": "inbound",
"from": { "kind": "number", "value": "+14155550100" },
"to": { "kind": "number", "value": "+14155550199" },
"session_type": "text",
"language": "en-US",
"max_duration_ms": 3600000,
"resume": { "token": "vsr_example_token_000000000000000001", "window_ms": 15000 },
"started_at": "2026-10-23T12:00:00Z"
}session.end
Last for every session: the call has left the agent.
| Field | Type | Description |
|---|---|---|
reason | string | Why the call left the agent, one of the end reasons. Treat a reason you don't know as failed |
duration_ms | integer | How long the session lasted |
{ "type": "session.end", "session_id": "vs_7Hq2Lm9xQa", "reason": "transferred", "duration_ms": 48210 }End reasons
reason | When |
|---|---|
caller_hung_up | The caller hung up |
agent_hung_up | The agent used hangup |
transferred | The agent used transfer |
max_duration | The session reached max_duration_ms from session.start |
agent_unreachable | The agent's connection was lost and the session wasn't resumed in time |
balance_exhausted | The account ran out of balance |
voice_disabled | Phone calls were turned off for the account |
failed | Anything else |
Text sessions
In a text session Sent runs speech-to-text and text-to-speech. When the agent has a greeting setting, Sent speaks it right after session.start. Otherwise the agent can speak first or wait for the caller. While interruptible is true, the default, the caller speaking stops the agent's speech and drops the speech still queued.
user.said
One complete utterance from the caller, once Sent has transcribed it. There are no interim partial results.
| Field | Type | Description |
|---|---|---|
text | string | What the caller said, 1 to 4,096 characters |
correction | boolean | true when this replaces the session's previous user.said, because Sent transcribed the speech again after a pause. If you've already answered the replaced utterance, send agent.clear and answer the correction |
{ "type": "user.said", "session_id": "vs_7Hq2Lm9xQa", "text": "I need to talk to a person" }agent.say
Speak this to the caller. Spoken in the order it arrives.
| Field | Type | Description |
|---|---|---|
text | string | What to say, 1 to 4,096 characters |
interruptible | boolean | Whether the caller speaking stops this speech. Default true |
last | boolean | false when more text for the same utterance follows. Default true. Pieces with last: false and a final last: true are spoken as one utterance, so speech starts before a reply is complete |
{ "type": "agent.say", "session_id": "vs_7Hq2Lm9xQa", "text": "Let me check your order", "last": false, "interruptible": false }Audio sessions
In an audio session the agent runs its own speech. Sent sends the caller's audio and plays the audio the agent sends, as binary messages of 16-bit little-endian mono PCM at the sample_rate in session.start's audio. user.said and agent.say aren't allowed and answer frame_not_allowed.
Audio messages
Each binary message carries audio for one session:
| Bytes | Content |
|---|---|
| 0 to 1 | The session's stream from session.start's audio, big-endian unsigned |
| 2 on | Audio, a whole number of samples |
The 2-byte header keeps the samples aligned, so a reader can view them as 16-bit integers without copying. Send 20 ms of audio per message, which is 640 bytes at 16,000 Hz, and no more than 200 ms. Sent sends the caller's audio for the whole session, silence included, and plays the agent's audio in the order it arrives. Audio a session has no room for is dropped, because late audio is worthless. The sample agents include one that plays the caller's audio back, the quickest way to hear a session work.
agent.clear
Drops the speech or audio the agent sent that Sent hasn't played yet, in either session type: when the caller starts talking over the agent, or after a correction. No fields beyond session_id.
{ "type": "agent.clear", "session_id": "vs_7Hq2Lm9xQa" }Tools
The agent acts on the call with tool.call. Sent answers every tool.call with exactly one tool.result carrying the same call_ref. A tool runs after the speech or audio the agent queued before it, so an agent can say goodbye and then hang up. If the caller interrupts, the queued speech is dropped and the tool runs straight away. To act at once, send agent.clear before the tool.call.
The agent's tools setting decides which tools it may use, and its transfer_targets setting decides where it may transfer. See Tools and transfer targets.
tool.call
| Field | Type | Description |
|---|---|---|
call_ref | string | Chosen by the agent, 1 to 64 characters, unique within the session, and repeated in the tool.result |
name | string | The tool |
arguments | object | The tool's arguments, below |
name | arguments | What happens |
|---|---|---|
transfer | to: a party with kind number, user or agent, listed in transfer_targets | A number is dialled with your number as the caller id while the caller stays with the agent. When it answers, the caller and the target are bridged, then session.end with transferred. When it doesn't connect, ok: false with failed and the message The transfer did not connect., and the session goes on. A user or agent target answers failed with a message that says it's not available |
hangup | none | The call ends. Then session.end with agent_hung_up |
send_dtmf | digits: 0 to 9, *, #, A to D, and w to wait half a second | Sent plays the digits on the call |
hold | held: true to hold the caller, false to resume | The caller hears hold music while held |
{
"type": "tool.call",
"session_id": "vs_7Hq2Lm9xQa",
"call_ref": "t1",
"name": "transfer",
"arguments": { "to": { "kind": "number", "value": "+12125550100" } }
}{ "type": "tool.call", "session_id": "vs_7Hq2Lm9xQa", "call_ref": "t2", "name": "send_dtmf", "arguments": { "digits": "1w2#" } }tool.result
| Field | Type | Description |
|---|---|---|
call_ref | string | The tool.call this answers |
ok | boolean | Whether the tool ran |
error | object | code and message, present when ok is false |
{ "type": "tool.result", "session_id": "vs_7Hq2Lm9xQa", "call_ref": "t1", "ok": true }{
"type": "tool.result",
"session_id": "vs_7Hq2Lm9xQa",
"call_ref": "t1",
"ok": false,
"error": { "code": "target_not_allowed", "message": "The agent may not transfer to this number." }
}{
"type": "tool.result",
"session_id": "vs_7Hq2Lm9xQa",
"call_ref": "t1",
"ok": false,
"error": { "code": "failed", "message": "The transfer did not connect." }
}Tool error codes
error.code | When |
|---|---|
tool_not_allowed | The agent's tools setting doesn't include the tool |
unknown_tool | There's no tool with that name |
invalid_arguments | The arguments don't fit the tool |
target_not_allowed | The transfer target isn't in transfer_targets |
call_not_live | The call has already ended or left the agent |
failed | The tool was allowed but didn't work: a transfer target that was busy, didn't answer or didn't connect, a transfer to a user or an agent, or a tool the call can't take. The caller stays with the agent |
Resume frames
A session whose session.start carries resume can move to a new connection after its connection is lost or while it drains. The flow, what happens while a session is detached, and which frames count are in Resume a session after a lost connection.
session.resume
Sent by the agent on a new connection, after connection.ready.
| Field | Type | Description |
|---|---|---|
token | string | The session's latest resume token, vsr_ and an id. A secret that works once |
received | integer | How many of Sent's counted frames for the session the agent has read |
{ "type": "session.resume", "session_id": "vs_7Hq2Lm9xQa", "token": "vsr_example_token_000000000000000001", "received": 4 }A refused session.resume answers error with connection_full or resume_failed.
session.resumed
The session is on this connection. Sent's frames the agent didn't read follow, in order.
| Field | Type | Description |
|---|---|---|
received | integer | How many of the agent's counted frames Sent read. Send the later ones again if they still matter |
resume | object | The next token and window_ms. Absent once the session has ended, in which case the replay ends with session.end |
audio | object | Audio sessions still live: stream, the session's stream number on this connection, which can differ from before. The sample rate doesn't change |
{
"type": "session.resumed",
"session_id": "vs_7Hq2Lm9xQa",
"received": 2,
"resume": { "token": "vsr_example_token_000000000000000002", "window_ms": 15000 },
"audio": { "stream": 3 }
}Parties
from, to and a transfer's to are parties, in the shape of the REST API's call parties and of an agent's transfer_targets.
kind | value | Description |
|---|---|---|
user | The identity | One of your app users, registered with @sentdm/voice |
number | The number in E.164 format | A phone number |
conference | The room name | A conference room of your account |
agent | The agent id | One of your voice agents |
anonymous | absent | A caller who withheld their number |
Sent may add kinds. Answer a kind you don't know as you would an anonymous caller.
Error codes
When Sent can't accept a frame, it sends error and ignores the frame. The connection stays open.
error.code | When |
|---|---|
invalid_frame | The frame isn't valid JSON, doesn't match its schema, or is too large |
unknown_frame_type | Sent doesn't know the frame's type |
unknown_session | No session with that session_id is on this connection |
frame_not_allowed | The frame isn't allowed in this session type |
session_ended | The session has already ended |
connection_full | A session.resume came on a connection that already carries max_sessions sessions |
resume_failed | A session.resume token isn't valid, or its session can no longer be resumed |
Close codes
| Code | When |
|---|---|
1000 | Sent connected to the agent, and the session ended. From the agent, a close with 1000 ends its sessions at once |
1001 | The connection drained, or Sent is shutting down |
4003 | Phone calls were turned off for the account, the key was revoked, or the agent was deleted |
4008 | Three pings went unanswered |
A close with 1000 from the agent, or with 4003 from Sent, ends the connection's sessions. Any other close detaches them, and they wait window_ms for a resume.
A text session, worked
An inbound call to a number bound to a text agent that connects in. Sent's frames are marked Sent, the agent's agent. The connection was opened earlier and connection.ready already arrived.
// Sent: a call has reached the agent
{ "type": "session.start", "session_id": "vs_7Hq2Lm9xQa", "call_id": "call_9f2ab000-0000-4000-8000-000000000001", "agent_id": "agent_9f2ab000-0000-4000-8000-000000000001", "direction": "inbound", "from": { "kind": "number", "value": "+14155550100" }, "to": { "kind": "number", "value": "+14155550199" }, "session_type": "text", "language": "en-US", "max_duration_ms": 3600000, "resume": { "token": "vsr_example_token_000000000000000001", "window_ms": 15000 }, "started_at": "2026-10-23T12:00:00Z" }
// agent: the agent speaks first, because it has no greeting setting
{ "type": "agent.say", "session_id": "vs_7Hq2Lm9xQa", "text": "Hi, this is Acme support. How can I help?" }
// Sent: a heartbeat, which the agent answers
{ "type": "ping", "ts": 1792411215000 }
{ "type": "pong", "ts": 1792411215000 }
// Sent: the caller's first complete utterance
{ "type": "user.said", "session_id": "vs_7Hq2Lm9xQa", "text": "I need to talk to a person" }
// agent: a reply in two pieces, so speech starts before the reply is complete
{ "type": "agent.say", "session_id": "vs_7Hq2Lm9xQa", "text": "Of course.", "last": false }
{ "type": "agent.say", "session_id": "vs_7Hq2Lm9xQa", "text": "I'll put you through to the support line now." }
// agent: the transfer to a number in transfer_targets runs once the goodbye has played
{ "type": "tool.call", "session_id": "vs_7Hq2Lm9xQa", "call_ref": "t1", "name": "transfer", "arguments": { "to": { "kind": "number", "value": "+12125550100" } } }
// Sent: the number answered and the caller was bridged to it
{ "type": "tool.result", "session_id": "vs_7Hq2Lm9xQa", "call_ref": "t1", "ok": true }
// Sent: the call has left the agent
{ "type": "session.end", "session_id": "vs_7Hq2Lm9xQa", "reason": "transferred", "duration_ms": 48210 }The call goes on between the caller and the support line, and ends as one call: call.completed with the call's whole duration, and GET /v3/voice/calls/{id} with mode agent and the agent's agent_id. Had the number not answered, the tool.result would have been ok: false with failed and the message The transfer did not connect., and the session would have gone on with the agent.
Compatibility and versions
Within sent.voice.v1, Sent only adds: fields, frame types, tools, party kinds, session.end reasons and error codes. So that an agent built against this page keeps working:
- Ignore fields and frame types you don't know.
- Treat a
session.endreason you don't know asfailed, and an error code you don't know as a failure. - Answer a party kind you don't know as you would an
anonymouscaller. - Count a frame type you don't know towards
receivedwhen it carries your session'ssession_id.
connection.ready gives the protocol_version Sent speaks. Releases of the protocol are tags of the repository, such as v0.2.0. While the protocol is in Preview, a 0.x release can still change frames under sent.voice.v1. From 1.0, release N.x is sent.voice.vN: a change that would break an agent gets a new major release and a new subprotocol, and Sent serves both for a deprecation period.
Voice Callback Contract
Reference for the questions Sent POSTs to a voice number's callback URL and the answers your backend returns: the envelope, call.request and call.input, every action and instruction, parsing rules, account rules, and error reasons.
Turn phone calls on for a number POST
Adds voice to one of the numbers you hold, or gives you a new one. Send `number` for a number that is already yours (see `GET /v3/channels`); leave it out to be given a new US number, optionally in a particular `area_code`. Sending both is refused. Nothing registers, so the number can carry calls as soon as this returns. What happens on a call is decided by your `callback_url`: when a call arrives on the number, or a caller presses a key on a menu, Sent POSTs a signed question there and follows the answer. The response carries the `callback_secret` the questions are signed with, the one time it is shown without rotating; verify a question the way you verify a webhook. `POST /v3/channels/voice/{number}/test` sends a test question and reports the verdict. Send `agent_id` instead of `callback_url`, or with it, to have one of your voice agents (`GET /v3/voice/agents`) answer calls to the number. The agent wins over the callback URL for calls to the number; calls your app places from it still follow your app. `routes_to` in the response says which answers, and reads `agent_pending` while the number is still being moved to the agent. Calls follow the move within about an hour, as the provider applies it on its own clock. While numbers cannot be bound to agents, a request with `agent_id` is refused with `502` before any number is obtained or anything is written; send `callback_url` instead. Your first voice number becomes the line app-originated calls are placed from when a voice token names no number; send `default_for_app_calls: true` to give that role to another number. A number you turned off earlier is turned back on, and the same number with a different `callback_url` has its URL replaced and keeps its secret. Read the number's settings with `GET /v3/channels/voice` and change them with `PATCH /v3/channels/voice/{number}`. With `sandbox: true` the request is validated and a simulated number reported with `202`; nothing is written and no number is bought.