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

FrameSent byBelongs toSession typesFields
connection.readySentconnectionprotocol_version, heartbeat_interval_ms, max_sessions
connection.drainingSentconnectionnone
pingSentconnectionts
pongagentconnectionts
errorSentconnectionerror, session_id, frame_type
session.startSentsessiontext, audiocall_id, agent_id, direction, from, to, session_type, started_at, language, audio, max_duration_ms, context, resume
session.endSentsessiontext, audioreason, duration_ms
user.saidSentsessiontexttext, correction
agent.sayagentsessiontexttext, interruptible, last
agent.clearagentsessiontext, audionone
tool.callagentsessiontext, audiocall_ref, name, arguments
tool.resultSentsessiontext, audiocall_ref, ok, error
session.resumeagentsessiontext, audiotoken, received
session.resumedSentsessiontext, audioreceived, resume, audio
binary audiobothsessionaudioa 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.start and its last is session.end. After session.end the 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.draining and error belong 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.said and agent.say carry at most 4,096 characters of text.
  • A text frame is at most 64 KB. Sent refuses a larger one with invalid_frame.
  • An outbound call's context is 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_ms to 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.

FieldTypeDescription
protocol_versionstringThe version of this protocol Sent speaks on the connection, such as 0.2.0
heartbeat_interval_msintegerHow often Sent sends ping on the connection, 5,000
max_sessionsintegerThe 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.

FieldTypeDescription
error.codestringOne of the error codes. Sent may add codes
error.messagestringFor people. It may change without notice
session_idstringThe session the refused frame named, when it named one
frame_typestringThe 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.

FieldTypeDescription
session_idstringThe session, vs_ and an id
call_idstringThe call, as GET /v3/voice/calls/{id} knows it. It stays the same when the call moves to a person
agent_idstringThe agent, as /v3/voice/agents knows it
directionstringinbound for a call to one of your numbers, outbound for a call the agent placed
from, toobjectThe parties
session_typestringtext or audio, from the agent's setting
started_atstringWhen the session started, ISO 8601
languagestringText sessions: the caller's language as a BCP 47 tag, from the agent's speech.language
audioobjectAudio 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_msintegerWhen Sent ends the session with reason max_duration, counted from started_at, from the agent's limits.max_call_seconds
contextobjectWhat your backend passed when it placed an outbound call, at most 4 KB as JSON
resumeobjecttoken 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.

FieldTypeDescription
reasonstringWhy the call left the agent, one of the end reasons. Treat a reason you don't know as failed
duration_msintegerHow long the session lasted
{ "type": "session.end", "session_id": "vs_7Hq2Lm9xQa", "reason": "transferred", "duration_ms": 48210 }

End reasons

reasonWhen
caller_hung_upThe caller hung up
agent_hung_upThe agent used hangup
transferredThe agent used transfer
max_durationThe session reached max_duration_ms from session.start
agent_unreachableThe agent's connection was lost and the session wasn't resumed in time
balance_exhaustedThe account ran out of balance
voice_disabledPhone calls were turned off for the account
failedAnything 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.

FieldTypeDescription
textstringWhat the caller said, 1 to 4,096 characters
correctionbooleantrue 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.

FieldTypeDescription
textstringWhat to say, 1 to 4,096 characters
interruptiblebooleanWhether the caller speaking stops this speech. Default true
lastbooleanfalse 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:

BytesContent
0 to 1The session's stream from session.start's audio, big-endian unsigned
2 onAudio, 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

FieldTypeDescription
call_refstringChosen by the agent, 1 to 64 characters, unique within the session, and repeated in the tool.result
namestringThe tool
argumentsobjectThe tool's arguments, below
nameargumentsWhat happens
transferto: a party with kind number, user or agent, listed in transfer_targetsA 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
hangupnoneThe call ends. Then session.end with agent_hung_up
send_dtmfdigits: 0 to 9, *, #, A to D, and w to wait half a secondSent plays the digits on the call
holdheld: true to hold the caller, false to resumeThe 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

FieldTypeDescription
call_refstringThe tool.call this answers
okbooleanWhether the tool ran
errorobjectcode 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.codeWhen
tool_not_allowedThe agent's tools setting doesn't include the tool
unknown_toolThere's no tool with that name
invalid_argumentsThe arguments don't fit the tool
target_not_allowedThe transfer target isn't in transfer_targets
call_not_liveThe call has already ended or left the agent
failedThe 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.

FieldTypeDescription
tokenstringThe session's latest resume token, vsr_ and an id. A secret that works once
receivedintegerHow 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.

FieldTypeDescription
receivedintegerHow many of the agent's counted frames Sent read. Send the later ones again if they still matter
resumeobjectThe next token and window_ms. Absent once the session has ended, in which case the replay ends with session.end
audioobjectAudio 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.

kindvalueDescription
userThe identityOne of your app users, registered with @sentdm/voice
numberThe number in E.164 formatA phone number
conferenceThe room nameA conference room of your account
agentThe agent idOne of your voice agents
anonymousabsentA 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.codeWhen
invalid_frameThe frame isn't valid JSON, doesn't match its schema, or is too large
unknown_frame_typeSent doesn't know the frame's type
unknown_sessionNo session with that session_id is on this connection
frame_not_allowedThe frame isn't allowed in this session type
session_endedThe session has already ended
connection_fullA session.resume came on a connection that already carries max_sessions sessions
resume_failedA session.resume token isn't valid, or its session can no longer be resumed

Close codes

CodeWhen
1000Sent connected to the agent, and the session ended. From the agent, a close with 1000 ends its sessions at once
1001The connection drained, or Sent is shutting down
4003Phone calls were turned off for the account, the key was revoked, or the agent was deleted
4008Three 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.end reason you don't know as failed, and an error code you don't know as a failure.
  • Answer a party kind you don't know as you would an anonymous caller.
  • Count a frame type you don't know towards received when it carries your session's session_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.

On this page