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 call events, so you learn when an agent takes a call and when the call ends. See the call events.
  • 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_typeWhat the agent exchangesChoose it when
textSent 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
audioSent 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.

FieldDescription
nameA name for your own reference, 1 to 100 characters. Required
session_typetext or audio. Required. See Text or audio
websocket_urlWhere 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
speechText 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
audioAudio agents only. sample_rate is the samples per second of the audio exchanged, 8000, 16000 or 24000, 16000 when left out
greetingText 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
toolsWhat 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_targetsWhere the agent may transfer a call, up to 20 parties, each { "kind": ..., "value": ... }. The agent can only transfer to a party listed here
fallbackWho 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
limitsmax_call_seconds, max_concurrent_calls and allowed_countries. See Limits
transcriptsstore 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.

StatusMeaning
201The agent, with signing_secret
202Sandbox: what would have been written, under an id and a secret nothing stores
400 VALIDATION_001A field is missing or out of range. error.details names each field at fault
409 BUSINESS_031Your 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: text or audio, matched ignoring case.
  • websocket_url: an absolute URL with the wss scheme, 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.voice and speech.language: a voice name and a language from GET /v3/voice/voices, matched ignoring case. speech on an audio agent is refused.
  • audio.sample_rate: 8000, 16000 or 24000. audio on 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. kind is number, user or agent. A number is written in E.164 and must be in a country Sent allows calls to. A user is an identity: letters, digits, - and _. An agent is an agent id of one of your own agents, and not the agent being written. A party listed twice counts once.
  • fallback: kind is number only, 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
}
FieldDescription
voices[].nameThe value to send as speech.voice
voices[].languageThe language the voice speaks
voices[].defaulttrue on the voice an agent uses when it names none
languagesThe 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_urlWho connectsWhat authenticates
Left outYour 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 sessionYour account API key, as a bearer token on the handshake
SetSent opens one WebSocket to your URL for each call, and closes it when the call leaves the agentThe 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.

ToolWhat it does
hangupEnds the call. The session ends with reason agent_hung_up
transferDials 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_dtmfPlays keypad tones on the call, to work a menu on the other end
holdPuts 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" }
  ]
}
kindvalueWhere the call goes
numberA phone number in E.164 formatThe number is dialled with your number as the caller id, and the caller is bridged to it once it answers
userThe identity of one of your app usersAccepted 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
agentThe id of another of your agentsAccepted 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_url fails 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

FieldDescription
max_call_secondsThe 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_callsHow 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_countriesISO 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_toWho answers
agentThe agent in agent_id
agent_pendingThat 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
callbackYour callback_url, as in Answering calls
noneNobody. 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 }
  }'
BodyEffect
Key left outLeft exactly as it is
Key sent as nullCleared. 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 valueReplaced 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 changedspeech, 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:

ResponseMeaning
404 RESOURCE_013Your account has no such agent
409 CONFLICT_014A 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 happensHow it's reported
The agent takes the callcall.answered, then call.agent_connected
The agent hangs up, or the caller doescall.completed, with duration_seconds and price
The agent transfers the callThe 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_callscall.failed with reason agent_busy
The agent isn't connected, or can't be reached, and has no fallbackcall.failed with reason agent_unreachable
The agent isn't connected, or can't be reached, and has a fallbackThe 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

On this page