Voice agents
A voice agent is a program of yours that takes phone calls to your numbers over a WebSocket, instead of your callback URL deciding each call. You describe the agent to Sent once with POST /v3/voice/agents: whether it works in text or in audio, how the caller is spoken to, what it may do to a call, and where it may transfer one. Then you bind a number to it. Every call to that number reaches your agent as a session, and the call.* webhooks and GET /v3/voice/calls report it like any other call.
This guide covers the agent record. How the agent connects, and what it exchanges with Sent while a call is on, is in Connect a voice agent and the voice agent protocol.
Prerequisites
Phone calls must be enabled for your account. This is set by Sent: contact support@sent.dm to request it. You can create agents before that, but no call reaches them and no agent can connect until calls are on.
You also need:
- A number with calls turned on. See Voice in the channels guide.
- A webhook subscribed to
callevents, so you learn when an agent takes a call and when the call ends. See thecallevents. - A WebSocket client in the language your agent is written in. The protocol is JSON frames, plus binary messages for audio, so no SDK is needed. The sample agents in the protocol repository show the frames in use.
All requests authenticate with the x-api-key header. Agent ids look like agent_9f2ab000-0000-4000-8000-000000000001. Anything else in the path is refused with 400.
Text or audio
Every agent has a session_type, which decides what travels between Sent and the agent while a call is on.
session_type | What the agent exchanges | Choose it when |
|---|---|---|
text | Sent turns the caller's speech into text and sends it to the agent as user.said frames. The agent answers with agent.say frames, which Sent speaks to the caller in the voice and language from speech. | Your agent is a language model or a script that works in text, and you don't run speech recognition or synthesis yourself |
audio | Sent sends the caller's audio as 16-bit PCM and plays the audio the agent sends back, at audio.sample_rate. | Your agent runs its own speech, for example a speech-to-speech model |
A text agent carries speech and may carry a greeting. An audio agent carries audio. Sending the other type's settings is refused with 400.
Create an agent
POST /v3/voice/agents creates an agent and returns it with its signing secret:
curl -X POST "https://api.sent.dm/v3/voice/agents" \
-H "x-api-key: $SENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Receptionist",
"session_type": "text",
"websocket_url": "wss://agent.example.com/calls",
"greeting": "Thanks for calling Example. How can I help?",
"tools": ["hangup", "transfer"],
"transfer_targets": [{ "kind": "number", "value": "+12125550100" }],
"fallback": { "kind": "number", "value": "+12125550100" }
}'Only name and session_type are required. Every other field has a default.
| Field | Description |
|---|---|
name | A name for your own reference, 1 to 100 characters. Required |
session_type | text or audio. Required. See Text or audio |
websocket_url | Where Sent connects to your agent for each call: an absolute wss:// URL on a public host, without credentials, at most 2,048 characters. Leave it out and your agent connects to Sent instead. See Connect in, or be connected to |
speech | Text agents only. setting is standard, the one setting accepted. voice and language are names from GET /v3/voice/voices, emma and en-US when left out. See Voices and languages |
audio | Audio agents only. sample_rate is the samples per second of the audio exchanged, 8000, 16000 or 24000, 16000 when left out |
greeting | Text agents only. What the caller hears as soon as the call is answered, before the agent's first turn, 1 to 4,096 characters. Leave it out and the agent speaks first |
tools | What the agent may do to a call: any of transfer, hangup, send_dtmf and hold. ["hangup"] when left out. transfer needs at least one entry in transfer_targets. See Tools and transfer targets |
transfer_targets | Where the agent may transfer a call, up to 20 parties, each { "kind": ..., "value": ... }. The agent can only transfer to a party listed here |
fallback | Who takes a call the agent can't: a phone number, as { "kind": "number", "value": "+12125550100" }. Leave it out and such a call is hung up. See Fallback |
limits | max_call_seconds, max_concurrent_calls and allowed_countries. See Limits |
transcripts | store and retention_days. See Transcripts |
The response is the agent as stored, every default filled in, plus signing_secret:
{
"success": true,
"data": {
"id": "agent_9f2ab000-0000-4000-8000-000000000001",
"name": "Receptionist",
"session_type": "text",
"websocket_url": "wss://agent.example.com/calls",
"speech": { "setting": "standard", "voice": "emma", "language": "en-US" },
"audio": null,
"greeting": "Thanks for calling Example. How can I help?",
"tools": ["transfer", "hangup"],
"transfer_targets": [{ "kind": "number", "value": "+12125550100" }],
"fallback": { "kind": "number", "value": "+12125550100" },
"limits": { "max_call_seconds": 3600, "max_concurrent_calls": 10, "allowed_countries": null },
"transcripts": { "store": false, "retention_days": 30 },
"signing_secret": "whsec_MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=",
"created_at": "2026-10-23T12:00:00+00:00",
"updated_at": null
},
"error": null,
"meta": {
"request_id": "req_7X9zKp2jDw",
"timestamp": "2026-10-23T12:00:00+00:00",
"version": "v3"
}
}signing_secret is shown here and by POST /v3/voice/agents/{id}/rotate-secret, nowhere else. Store it now. It's the key Sent signs the handshake with when it connects to your websocket_url, so an agent that is connected to needs it to verify that a connection came from Sent. See Verify the handshake.
id is also what the agent connects to Sent as. tools comes back as a set in one fixed order, whatever order you sent, so two bodies naming the same tools read the same.
| Status | Meaning |
|---|---|
201 | The agent, with signing_secret |
202 | Sandbox: what would have been written, under an id and a secret nothing stores |
400 VALIDATION_001 | A field is missing or out of range. error.details names each field at fault |
409 BUSINESS_031 | Your account holds as many voice agents as it may. The message says how many. Delete one first |
What is refused
A 400 carries details keyed by the path of each field at fault, so one response reports every problem:
{
"success": false,
"status": 400,
"error": {
"code": "VALIDATION_001",
"message": "The agent is invalid",
"details": {
"tools[1]": ["Must be one of transfer, hangup, send_dtmf, hold."],
"transfer_targets": ["Required when tools includes transfer: name at least one target."]
},
"doc_url": "https://docs.sent.dm/reference/api/error-catalog"
},
"meta": {
"request_id": "req_7X9zKp2jDw",
"timestamp": "2026-10-23T12:00:00+00:00",
"version": "v3"
}
}The rules, by field:
name: 1 to 100 characters after trimming.session_type:textoraudio, matched ignoring case.websocket_url: an absolute URL with thewssscheme, no username or password, at most 2,048 characters, on a public host. A literal private, loopback or link-local address is refused here. A hostname is accepted here and judged when Sent connects, so a name that resolves to a private address fails the call, not the request.speech.setting:standard.speech.voiceandspeech.language: a voice name and a language fromGET /v3/voice/voices, matched ignoring case.speechon an audio agent is refused.audio.sample_rate:8000,16000or24000.audioon a text agent is refused.greeting: 1 to 4,096 characters, text agents only.tools[i]: one of the four tools. A tool named twice counts once.transfer_targets: at most 20 entries.kindisnumber,useroragent. Anumberis written in E.164 and must be in a country Sent allows calls to. Auseris an identity: letters, digits,-and_. Anagentis an agent id of one of your own agents, and not the agent being written. A party listed twice counts once.fallback:kindisnumberonly, with the same number rules.limits.max_call_seconds: 60 to 14,400.limits.max_concurrent_calls: from 1 up to a cap on your account.limits.allowed_countries: at least one ISO 3166-1 alpha-2 code when the field is sent, a code listed twice counts once.transcripts.retention_days: 1 to 365.
Voices and languages
GET /v3/voice/voices lists the names a text agent's speech.voice and speech.language accept:
curl "https://api.sent.dm/v3/voice/voices" \
-H "x-api-key: $SENT_API_KEY"{
"success": true,
"data": {
"voices": [
{
"name": "emma",
"language": "en-US",
"description": "US English, female. The voice a call uses when the agent names none.",
"default": true
},
{
"name": "joanna",
"language": "en-US",
"description": "US English, female.",
"default": false
}
],
"languages": ["en-US"]
},
"error": null
}| Field | Description |
|---|---|
voices[].name | The value to send as speech.voice |
voices[].language | The language the voice speaks |
voices[].default | true on the voice an agent uses when it names none |
languages | The BCP 47 tags accepted as speech.language. The language is what Sent listens for as well as what it speaks |
The catalogue grows as voices are added, and a name is never removed without notice, so read it rather than hard-coding a list. The names are Sent's own. An audio agent uses none of this, because it runs its own speech.
Connect in, or be connected to
An agent reaches Sent in one of two ways, decided by websocket_url. The frames are the same either way.
websocket_url | Who connects | What authenticates |
|---|---|---|
| Left out | Your agent opens one long-lived WebSocket to wss://voice.sent.dm/v1/agents/connect and holds it. Every call to a number bound to the agent arrives on it as a session | Your account API key, as a bearer token on the handshake |
| Set | Sent opens one WebSocket to your URL for each call, and closes it when the call leaves the agent | The handshake Sent sends is signed with the agent's signing_secret, the way your webhooks are |
Choose the first when your agent is a service you run that can hold a connection open. Choose the second when you'd rather be called, for example from a serverless platform that accepts WebSockets. The handshake, the frames and how a session survives a lost connection are in Connect a voice agent.
Tools and transfer targets
tools says what the agent may do to a call, beyond talking. The agent asks with a tool.call frame, and Sent answers with a tool.result.
| Tool | What it does |
|---|---|
hangup | Ends the call. The session ends with reason agent_hung_up |
transfer | Dials a party in transfer_targets while the caller stays with the agent. When the target answers, the caller and the target are bridged, the session ends with reason transferred, and the call goes on without the agent. A target that doesn't connect answers the tool with failed and the message The transfer did not connect., and the caller stays with the agent |
send_dtmf | Plays keypad tones on the call, to work a menu on the other end |
hold | Puts the caller on hold with music, and takes them off again |
A tool the agent isn't allowed answers tool_not_allowed, and a transfer to a party that isn't listed answers target_not_allowed. Keep tools to what the agent needs. An agent that only answers questions needs ["hangup"], the default.
transfer_targets is the whole list of where the agent may send a call:
{
"tools": ["hangup", "transfer"],
"transfer_targets": [
{ "kind": "number", "value": "+12125550100" },
{ "kind": "user", "value": "support-desk" },
{ "kind": "agent", "value": "agent_4c1d2e3f-0000-4000-8000-000000000002" }
]
}kind | value | Where the call goes |
|---|---|---|
number | A phone number in E.164 format | The number is dialled with your number as the caller id, and the caller is bridged to it once it answers |
user | The identity of one of your app users | Accepted as a target. A transfer to it answers failed with a message that says it's not available, and the caller stays with the agent |
agent | The id of another of your agents | Accepted as a target. A transfer to it answers failed with a message that says it's not available, and the caller stays with the agent |
Only a number target connects a transfer. List a user or an agent when you want the agent to know about them, and have the agent treat a failed result as the caller still being on the line.
An agent that lists another agent as a target holds that agent: the other one can't be deleted until it's removed from the list. See Delete an agent.
Fallback
fallback is who takes a call to the agent's number when the agent can't. It's a phone number. A call goes there when:
- the agent connects in and isn't connected when the call arrives,
- the agent is connected to and the connection to
websocket_urlfails or isn't accepted in time, - the agent's connection is lost during a call and the session isn't resumed within the resume window.
Without a fallback, such a call ends as FAILED with reason agent_unreachable, and you receive call.failed. A fallback leg to a phone number costs money for as long as it runs, like any other phone leg, and needs a balance that affords it. The call record names the number in connected_number and is billed for both legs, listed in legs. See The Call Record. A call the agent refuses for being over max_concurrent_calls doesn't go to the fallback: it fails with agent_busy.
Limits
| Field | Description |
|---|---|
max_call_seconds | The longest a call to the agent may last, 60 to 14,400 seconds, 3,600 when left out. The session ends with reason max_duration when it's reached, and the call ends COMPLETED. A call also ends when your balance stops affording it |
max_concurrent_calls | How many calls the agent may have going at once, from 1, 10 when left out. A call past it isn't offered to the agent: it fails with agent_busy. Your account has a cap on this value, and a body over it is 400 |
allowed_countries | ISO 3166-1 alpha-2 codes of the countries the agent may call. Leave it out to allow every country. An empty list is refused. A transfer is checked against transfer_targets, not against this list |
Transcripts
transcripts.store and transcripts.retention_days are stored on the agent: whether Sent keeps the transcript of every call the agent takes, and for how many days, 1 to 365. They default to false and 30. No endpoint reads a transcript, so an agent that needs a record of a call keeps its own: a text agent sees every utterance as user.said and agent.say frames.
Bind a number to the agent
A call reaches the agent through one of your voice numbers. Bind the number with agent_id on PATCH /v3/channels/voice/{number}, or on POST /v3/channels/voice when you turn calls on for the number:
curl -X PATCH "https://api.sent.dm/v3/channels/voice/%2B12125550100" \
-H "x-api-key: $SENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "agent_id": "agent_9f2ab000-0000-4000-8000-000000000001" }'{
"success": true,
"data": {
"number": "+12125550100",
"status": "ACTIVE",
"default_for_app_calls": true,
"callback_url": "https://example.com/voice",
"agent_id": "agent_9f2ab000-0000-4000-8000-000000000001",
"routes_to": "agent_pending",
"created_at": "2026-07-22T12:00:00+00:00",
"updated_at": "2026-10-23T12:00:00+00:00"
},
"error": null
}routes_to says who answers calls to the number:
routes_to | Who answers |
|---|---|
agent | The agent in agent_id |
agent_pending | That agent, once the provider has finished moving the number to it. The response to the bind often reads this, because the provider applies the move on its own clock. Read the number again: it settles to agent once the provider has recorded the move |
callback | Your callback_url, as in Answering calls |
none | Nobody. Calls to the number are declined |
Calls follow the change within about an hour, both ways: a call to the number in that hour may still reach your callback, or be declined, and after an unbind may still reach the agent. routes_to says what Sent has recorded, not which calls have moved yet, so bind a number before you need it and test with a call.
The agent wins over callback_url for calls to the number. Calls your app places from the number, with a voice token bound to it, still go through your callback, so one number can serve both. Send "agent_id": null to unbind the agent, after which callback_url answers again. An agent_id that isn't one of your agents is 404. A number the provider couldn't move to or from the agent is 502 with BUSINESS_027, and nothing is written, so repeat the request. The rest of the number's settings are in Managing channels.
List and read agents
GET /v3/voice/agents lists your agents, newest first, in pages of page_size (1 to 100, default 20) starting at page 1:
curl "https://api.sent.dm/v3/voice/agents?page=1&page_size=20" \
-H "x-api-key: $SENT_API_KEY"The response carries the page under agents and a pagination object, as every paginated list does. GET /v3/voice/agents/{id} reads one agent, in the shape the create returned. Neither carries signing_secret: it's never on a read. An agent that isn't yours is 404, not 403.
Change an agent
PATCH /v3/voice/agents/{id} changes the fields you name and answers with the agent as stored, the same shape GET returns:
curl -X PATCH "https://api.sent.dm/v3/voice/agents/agent_9f2ab000-0000-4000-8000-000000000001" \
-H "x-api-key: $SENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"greeting": null,
"tools": ["hangup", "transfer", "hold"],
"limits": { "max_call_seconds": 1800, "max_concurrent_calls": 10 }
}'| Body | Effect |
|---|---|
| Key left out | Left exactly as it is |
Key sent as null | Cleared. websocket_url makes the agent connect in. greeting and fallback go. transfer_targets empties. speech, audio, tools, limits and transcripts return to their defaults |
| Key sent with a value | Replaced whole. Send the whole limits object, not the one key you're changing, because the keys you leave out return to their defaults |
session_type changed | speech, audio and greeting are dropped unless the body sends them, because the other type can't carry them |
The result is checked by the same rules as a new agent, so a PATCH that leaves transfer in tools while emptying transfer_targets is 400. A body that changes nothing is 400 too, and so is a body that names no field at all. The signing secret is never touched here. A change reaches calls that start after it. Calls already on the agent keep the settings they started with.
With sandbox: true nothing is written: the request is validated against the stored agent and the agent is reported with 200 as it would read after the change.
Rotate the signing secret
POST /v3/voice/agents/{id}/rotate-secret generates a new signing_secret and returns it:
curl -X POST "https://api.sent.dm/v3/voice/agents/agent_9f2ab000-0000-4000-8000-000000000001/rotate-secret" \
-H "x-api-key: $SENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'{
"success": true,
"data": {
"signing_secret": "whsec_MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
},
"error": null
}The previous secret stops signing the moment the response is returned, so install the new one on your agent straight after the call. Handshakes in between fail verification: rotate at a quiet moment, and let the verifier accept both secrets while the switch is under way if calls can't wait. The secret only matters to an agent Sent connects to. An agent that connects in authenticates with your API key, and rotating its secret changes nothing it uses. With sandbox: true a secret is generated and returned with 202, and nothing is written.
Delete an agent
DELETE /v3/voice/agents/{id} removes the agent. The response is 204. Calls it already took keep their agent_id, so your call history stays readable.
An agent that something still points at isn't deleted:
| Response | Meaning |
|---|---|
404 RESOURCE_013 | Your account has no such agent |
409 CONFLICT_014 | A voice number of yours routes calls to the agent, or another of your agents lists it in transfer_targets. The message names the numbers and the agents |
To delete it, first unbind each number with PATCH /v3/channels/voice/{number} and "agent_id": null, and take the agent out of the other agents' transfer_targets or delete them. With sandbox: true nothing is written: the agent is looked up and the answer is the one a real delete would give.
Calls an agent answers
A call to a number bound to an agent is a call like any other, with two more facts on it. The call record and every call.* event carry mode, which is agent for a call an agent answers and inapp for a call your callback decides, and an agent call carries agent_id as well. A call.agent_connected event is sent once the agent's session starts, after call.answered. Filter your calls with GET /v3/voice/calls?mode=agent&agent_id=agent_....
| What happens | How it's reported |
|---|---|
| The agent takes the call | call.answered, then call.agent_connected |
| The agent hangs up, or the caller does | call.completed, with duration_seconds and price |
| The agent transfers the call | The session ends transferred once the target answers. The call goes on between the caller and the target, and ends COMPLETED as one call. A transfer that didn't connect leaves the call with the agent, and nothing is reported |
The agent is over max_concurrent_calls | call.failed with reason agent_busy |
The agent isn't connected, or can't be reached, and has no fallback | call.failed with reason agent_unreachable |
The agent isn't connected, or can't be reached, and has a fallback | The fallback number is dialled, and the call ends COMPLETED once it ends |
POST /v3/voice/calls/{id}/hangup and POST /v3/voice/calls/{id}/recordings on a live agent call answer 409 with CONFLICT_015. The agent ends its own calls with the hangup tool. See Calls, recordings, and participants.
Next steps
Connect a voice agent
The handshake either way, sessions, heartbeats, and resuming a session after a lost connection
Voice agent protocol
Every frame, tool, reason and error code, with a worked text session
Voice numbers
Turn calls on for a number, bind it to an agent, and read what answers it
Call events
call.agent_connected, and mode and agent_id on every call event
Voice Recipes
Six worked answers for your voice callback, taken from the Sent voice example app: ring an app user with a phone fallback, a basic IVR menu, a hold, a recorded call, a room per call, and routing by department.
Connect a voice agent (Preview)
How your voice agent connects to Sent, or is connected to, for each call: the WebSocket handshake either way, what Sent sends first, sessions, heartbeats, draining, and how a session survives a lost connection.