Agents
Two doors, one agent#
- Machine:
POST/GET /v2/agents,GET/PATCH/DELETE /v2/agents/{agent_id},POST /v2/agents/{agent_id}/rotate-secretwithvoice:read/voice:write, andPOST /v2/agents/{agent_id}/mcp-servers/{name}/check(MCP tools). - Console: the same operations under
/console/v2/projects/{project_id}/agentswith the account session — what the Agents page uses.
Create a cascade agent#
Model IDs should come from /v2/provider-catalog; account IDs must belong to the project and be verified. Voice IDs are the provider's own voice names.
{
"name": "Support",
"prompt": "Help the caller with {{account_name}}.",
"pipeline_mode": "cascade",
"pipeline_config": {
"stt": { "account_id": "<uuid>", "model": "<catalogue model>" },
"llm": { "account_id": "<uuid>", "model": "<catalogue model>" },
"tts": { "account_id": "<uuid>", "model": "<catalogue model>", "voice": "<provider voice>" }
},
"language": "en-IN",
"greeting": "Hello, this is Support.",
"variables": [{ "name": "account_name", "required": true }]
}A realtime agent uses pipeline_mode: "realtime" and exactly one realtime stage with account_id, model, and voice. Arbitrary pipeline keys are rejected so plaintext credentials cannot be hidden in an agent configuration.
The contract also carries an optional display name, tool declarations and a tool_webhook_url, DTMF, recording and transcript flags, duration and idle limits, retention, backchannel and ambience settings, and a call webhook_url with transcript/recording inclusion flags. Responses add project ID, version, active state, timestamps, and has_webhook_secret.
record_calls asks your carrier to record; the audio lands in your own carrier account and Dvaarik keeps only the carrier's reference to it. A browser call has no carrier, so it has no recording. store_transcript writes the transcript to the Dvaarik database, where it is kept for 90 days and then deleted; the short insight_config summary survives that deletion. See Billing → What we store and Your data.
Backup brain#
A three-part agent can have a second language model, llm_fallback, that answers when its main brain (llm) fails. Add it to pipeline_config beside llm:
"pipeline_config": {
"stt": { "account_id": "<uuid>", "model": "<catalogue model>" },
"llm": { "account_id": "<uuid>", "model": "<catalogue model>" },
"tts": { "account_id": "<uuid>", "model": "<catalogue model>", "voice": "<provider voice>" },
"llm_fallback": { "account_id": "<uuid>", "model": "<catalogue model>" }
}- When it answers: the main brain fails before it says anything in a turn: its provider answers with an error (out of credit, a refused key, an outage), or no answer starts within 4 seconds (9 seconds when it can use actions, and hanging up by itself is one). The backup brain answers that same turn, and the call stays on it until it ends. A main brain that fails halfway through a reply ends that turn as it would with no backup; the next turn switches.
- What it can be: a verified account of this project with a language model, and a model the catalogue offers that provider: another provider, or another model than
llm. A retired model is never a new choice. Another account or provider protects the call best: a backup on the main brain's own account shares that account's trouble, like running out of credit. - Who pays: it runs on your own AI account, and that provider bills you for what it answers. Dvaarik adds nothing for it, and nothing is asked of it until it answers.
- Saves are strict, calls are not: a save with a backup brain that cannot be used is refused with a
422and a sentence that says why. If it stops being usable later (its account disconnected, its model retired), calls run on the main brain alone and never wait for it. A single-agent read (GET /v2/agents/{agent_id}) returnsfallback_status:ready, or why a call cannot use it now (account_not_found,account_unavailable,model_unavailable,model_retired,invalid). It isnullwith no backup brain, and in a list. - An update replaces `pipeline_config`: an update that sends it replaces all of it, so send
llm_fallbackagain to keep the backup brain; leaving it out removes it. The Agents page always sends it back. - Actions: a Gemini backup brain never takes over in the middle of another provider's actions (tool calls): that turn fails as it would with no backup, and the next turn switches.
- A one-model (realtime) agent has no backup brain.
What a call records. GET /v2/voice/sessions/{id} carries timings.fallback on every call of an agent with a backup brain:
timings.fallback | What happened |
|---|---|
{"used": false, "available": true} | It stood by and was not needed |
{"used": false, "available": false, "reason": ...} | The call could not use it (for example account_changed or credentials_unavailable) and ran on the main brain alone |
{"used": true, "from_turn": 2, "reason": "error", "failure": "credits_exhausted", "model": ...} | It answered from that caller line on (0 is the first words). reason is error or timeout; failure is how the main brain failed (null on a timeout) |
call.completed and call.failed carry fallbackUsed (Webhooks), and the session's insights.fallback_used says the same. The call details on the Calls page say from which turn the backup brain answered.
Rehearse it before a real failure does: a test run with force_primary_failure: true never asks the main brain, so every agent reply comes from the backup brain. See Rehearse the backup brain.
The webhook secret#
Creation and rotate-secret return webhook_secret once. It signs the completed-call delivery to the agent's own webhook_url using the same timestamped scheme as project webhooks — see Webhooks.
Your tool webhook#
On a phone call, each of the agent's tools it uses is sent to tool_webhook_url as a POST signed with the agent's webhook_secret (the same scheme as webhooks), with call_id, agent_id, agent_version, tool, arguments, from, to, direction and timestamp. Answer with JSON; the agent reads it.
- Answer within 5 seconds and in at most 8 KB.
- Answer without compression: we ask for
Accept-Encoding: identity, and a gzip, deflate, br, zstd or compress answer is refused unread. - The address is https on a public host, and a redirect is never followed.
- At most 6 tool webhook calls in one call.
When a call to it fails, the agent hears one short line and carries on with the caller. You see why, as a stable code: on an agent test's tool line as reason, and counted per phone call in the session's metadata.tools.webhook_failures ({"timeout": 2}). The Calls page and Test it say each one in words.
reason | What happened, and what to do |
|---|---|
refused | The address is not https on a public host. Use your server's public https address. |
unresolved | Its host name did not resolve. Check it for typos, and that its DNS record exists. |
redirect | Your server answered with a redirect, which is never followed. Save the final address. |
timeout | Your server did not answer within 5 seconds. Answer sooner, and do slow work after you reply. |
unreachable | Your server could not be reached. Check it is running and reachable from the internet. |
http_<status> | Your server answered that status, like http_500. Check your server's logs; for http_401 or http_403, how it verifies the signature. |
oversized | The answer was larger than 8 KB. Send back only what the agent needs to say. |
encoded | Your server answered compressed; answer without compression (we ask for identity). |
bad_answer | The answer was not JSON. Answer with a JSON object. |
budget | The agent had already made 6 tool calls on this call, so this one was not sent. |
error | It failed on our side, not your server's. |
Give it knowledge#
Link up to 3 knowledge bases with knowledge_base_ids on create or update ([] removes them; the version goes up). Small knowledge sits in the prompt; larger knowledge is searched during the call. See Knowledge.
Transfer to a person#
On a phone call, an agent can put the caller through to a person: your front desk, a manager, a sales line. It is a blind transfer: the caller is put through and the agent leaves the call, so it cannot tell whether the person answered.
Turn on can_transfer (off by default) and list at most 10 transfer_destinations, each { name, number, description, say }:
| Field | What it is |
|---|---|
name | 1 to 40 letters, digits, spaces, _ or -, and no two alike (capitals and spacing do not count). The agent chooses by this name. |
number | International E.164, like +14155550100. |
description | When to choose it, at most 200 characters. The agent reads it. |
say | Optional, at most 200 characters: said to the caller just before the transfer. Without it, the agent uses its own words. |
curl -sS -X PATCH https://api.developers.dvaarik.com/v2/agents/$AGENT_ID \
-H "X-Api-Key: $DVAARIK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"can_transfer": true,
"transfer_destinations": [
{
"name": "Billing",
"number": "+14155550100",
"description": "The caller asks about a bill or a refund.",
"say": "Putting you through to billing now."
}
]
}'transfer_destinations replaces the whole list; [] removes it (refused while can_transfer is on). Every change is a new agent version.
The agent never sees a number. Its model is offered a built-in transfer_call action that takes the name of one of your destinations and nothing else, so a caller, a document or a tool result can never make it ring any other number.
- Where it works: phone calls through Twilio and Plivo lines. Not on browser calls or on other phone companies' lines: there the agent is never offered the action.
- Telnyx transfers are coming: Telnyx confirms a transfer only when it is accepted, not when the person answers, so we wait until we can follow the outcome.
- Same rules as an outbound call: a destination may not be a paid-rate or satellite number, nor outside your account's allowed countries.
- Never between your own lines: a call that comes from one of the project's own numbers is never offered a transfer, so two of your agents cannot pass a caller back and forth.
- Who pays: our per-minute charge stops at the transfer (the connected time ends there, rounded up as always). The call to the person runs on your own phone company account, and it bills you for it. The person sees your line's number as the caller.
- Events: your project's webhook endpoints can receive
transfer.started,transfer.completed(the phone company accepted the transfer) andtransfer.failed. Subscribe totransfer.*or*;call.*does not include them. See Transfer events. - When it fails: the agent is told it could not reach the person and carries on with the caller. One call tries at most twice, and not again after a phone company that did not answer in time.
- Afterwards: the call's
call.completedhasendedReasontransferred.
A save is refused, with a sentence that says why, when transfers are on with no destination, a name is used twice, a number is not a real phone number, a number is a paid-rate or satellite one or outside your allowed countries, a destination is one of the agent's own numbers (the call would come straight back to it), or one of your own tools is called transfer_call.
In a test, a transfer is pretend: nobody is rung and the chat ends there. A calls_tool check on transfer_call sees it, and a tool_results.transfer_call answer like {"success": false} rehearses a failure.
Hand the call to another agent#
During a call, an agent can pass the caller to another agent of the same project: a receptionist puts the caller through to sales, sales to billing. From its next reply, the receiving agent's prompt, actions, knowledge, language model (with its own backup brain) and voice take over. It stays one call: no new dial, and one bill.
Turn on can_hand_off (off by default) and list at most 10 handoff_destinations, each { name, agent_id, description, greeting }:
| Field | What it is |
|---|---|
name | 1 to 40 letters, digits, spaces, _ or -, and no two alike (capitals and spacing do not count). The agent chooses by this name. |
agent_id | Another agent of this project. Each agent can be listed once, and never the agent itself. |
description | Required, at most 200 characters: when to hand over. The agent reads it to decide. |
greeting | Optional, at most 200 characters: the receiving agent's first words, in its own voice, like "Hi, this is Sam from sales." Without it, the receiving agent simply carries on the conversation. |
curl -sS -X PATCH https://api.developers.dvaarik.com/v2/agents/$AGENT_ID \
-H "X-Api-Key: $DVAARIK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"can_hand_off": true,
"handoff_destinations": [
{
"name": "Sales",
"agent_id": "<the sales agent id>",
"description": "The caller wants to buy a plan or asks about prices.",
"greeting": "Hi, this is Sam from sales."
}
]
}'The console door takes the same body with your account session, which is what the Agents page sends:
curl -sS -X PATCH https://api.developers.dvaarik.com/console/v2/projects/$PROJECT_ID/agents/$AGENT_ID \
-H "Authorization: Bearer $DVAARIK_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "can_hand_off": true, "handoff_destinations": [{ "name": "Sales", "agent_id": "<the sales agent id>", "description": "The caller wants to buy a plan." }] }'handoff_destinations replaces the whole list; [] removes it (refused while can_hand_off is on). Every change is a new agent version.
The agent never sees an agent id. Its model is offered a built-in hand_off_to_agent action that takes the name of one of your destinations and a short reason, nothing else, so a caller, a document or a tool result can never send the call to any other agent. None of your own actions may be called hand_off_to_agent.
- Who can take the call: an active three-part (cascade) agent of the same project that speaks the same language. A one-model (realtime) agent can neither hand off nor be handed a call.
- The caller's words carry over: the receiving agent reads the conversation so far, the caller's last words included, so the caller never has to repeat it. The previous agent's reason reaches it once, as a quoted note, never as an instruction.
- The listening stays the first agent's: speech to text runs on the first agent's account and model for the whole call. That is why both agents must speak the same language.
- At most 5 hand-offs in one call (a return counts). An agent never hands the call to itself, nor while a transfer to a person is under way.
- When the other agent does not answer: if its model says nothing in time or its voice fails before it is heard, the call goes straight back to the agent it came from, which carries on. That destination is not tried again on that call.
- One bill: our per-minute charge runs on as before. Each agent's language model and voice bill your own AI accounts for what they say.
- The call's own settings stay the first agent's: the time limit, silence timings, recording, transcript, retention, summary (
insight_config) and its ownwebhook_url. The session'sagent_idstays the agent that answered or dialled. - Tools follow the agent: each agent calls its own tool webhook with its own secret, its own MCP servers and its own knowledge. The per-call limits (6 tool webhook calls and 6 MCP tool calls) are shared by every agent of the call. On your own socket, each
tool_callframe on such a call carries theagent_idof the agent that asked. - Where: phone calls, browser calls, your own socket and tests. Typed chats do not hand off yet.
A save is refused (422, with a sentence that says why) when hand-offs are on with no destination, this agent is a one-model agent, a name is used twice, an agent is listed twice or is this agent itself, a destination has no description, or a destination agent is not in this project, was deleted, is a one-model agent, speaks another language, or its AI accounts are not ready. The list is checked again whenever this agent's language or pipeline changes, so a destination whose agent you deleted later must be removed before such a save.
Is each destination ready?#
A single-agent read (GET /v2/agents/{agent_id}) gives each destination a status and the receiving agent's agent_name. Both are null in a list, and both are ignored when you send a read back.
status | What it means | What to do |
|---|---|---|
ready | A call can hand over to it | Nothing |
agent_not_found | That agent is not in this project any more | Choose another agent, or remove the destination |
agent_deleted | That agent was deleted or switched off | Turn it on again, or choose another agent |
not_cascade | That agent is one model that listens and talks | Give it three parts, or choose another agent |
language_mismatch | That agent speaks another language | Use the same language on both, or choose another agent |
self | It is this agent itself | Choose another agent |
AI accounts are checked when you save and when a call hands over, not on a read.
What a handed-over call records#
The session read (GET /v2/voice/sessions/{id}) carries handoffs on a call whose agent may hand off, and null on every other call:
"handoffs": {
"handoffs": 1,
"refused": 0,
"segments": [
{ "agent_id": "<reception id>", "agent_name": "Reception", "agent_version": 3, "from_second": 0, "to_second": 42,
"reason": null, "handed_by": null, "outcome": "handed_off", "fallback_used": false },
{ "agent_id": "<sales id>", "agent_name": "Sales", "agent_version": 7, "from_second": 42, "to_second": 180,
"reason": "wants to buy a plan", "handed_by": "<reception id>", "outcome": "ended", "fallback_used": false }
]
}Each segment is one agent's part, in seconds of the call's billed clock, and together they cover the whole call. reason is why it was handed the call (null for the first agent), handed_by the agent it came from, and outcome handed_off (it passed the call on), ended (the call ended with it) or did_not_answer (it said nothing, so the call went straight back). refused counts hand-offs asked for that could not happen.
On such a call, each agent line of the stored transcript carries agent_id and agent_name, and the call summary covers every agent. call.completed and call.failed carry the segments as handoffs, and each switch sends call.handoff (Hand-off events). The Calls page shows who spoke from when to when, and why.
MCP tools#
Connect up to 3 of your own MCP servers to an agent, and its calls are offered their tools beside the actions your tool webhook serves. A server is reached over the Streamable HTTP transport, on https and a public host. An agent without MCP servers works exactly as before.
Add mcp_servers on create or update, each { name, url, headers, allowed_tools, timeout_seconds }:
| Field | What it is |
|---|---|
name | 1 to 40 lowercase letters, digits, - or _, starting with a letter, with no two - or _ in a row and none at the end. The agent sees each tool as <name>__<tool> (a - reads as _), so two servers never offer the same name. |
url | The server's Streamable HTTP endpoint, https:// on a public host, at most 500 characters. A redirect is never followed. |
headers | Optional, at most 10, such as Authorization. Stored encrypted and never returned: a read shows header_hints, each header's name with only the last four characters of its value. |
allowed_tools | Optional, at most 30 of the server's tool names, spelled as the server spells them. Empty: all its tools. |
timeout_seconds | How long one tool call may take, 1 to 10 seconds. Default 6. |
curl -sS -X PATCH https://api.developers.dvaarik.com/v2/agents/$AGENT_ID \
-H "X-Api-Key: $DVAARIK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"mcp_servers": [
{
"name": "crm",
"url": "https://mcp.example.com/mcp",
"headers": { "Authorization": "Bearer <your server token>" },
"allowed_tools": ["lookup_order", "create_ticket"],
"timeout_seconds": 6
}
]
}'The console door takes the same body with your account session, which is what the Agents page sends:
curl -sS -X PATCH https://api.developers.dvaarik.com/console/v2/projects/$PROJECT_ID/agents/$AGENT_ID \
-H "Authorization: Bearer $DVAARIK_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "mcp_servers": [{ "name": "crm", "url": "https://mcp.example.com/mcp" }] }'- The list is replaced:
mcp_serversreplaces the whole list;[]ornullremoves every server. Every change is a new agent version. - Headers are kept only where they were saved: leave a server's
headersout to keep the saved ones, which works only while it keeps its name and url. A new url with saved headers is refused until you send them again (saved headers are never sent to a different address), or send"headers": {}to remove them. A renamed server starts without headers: send them again with the new name.header_hintsis accepted and ignored, so an agent you read can be sent back as it is. - Your own actions keep their names: an action of yours that starts with a server's prefix (
crm__forcrm, any case) is refused with a422. - A `422` names its field: every agent route answers a validation error as
{"detail": [{loc, msg, type, field, message}]}, like the agent tests, and a refusal after validation as one sentence that names the server.
Check a server#
curl -sS -X POST https://api.developers.dvaarik.com/v2/agents/$AGENT_ID/mcp-servers/crm/check \
-H "X-Api-Key: $DVAARIK_API_KEY"
curl -sS -X POST https://api.developers.dvaarik.com/console/v2/projects/$PROJECT_ID/agents/$AGENT_ID/mcp-servers/crm/check \
-H "Authorization: Bearer $DVAARIK_TOKEN"Check lists the saved server's tools now, with its saved headers and within its own time limit, and calls no tool. It takes voice:write on the machine door; the console door allows 10 a minute. A good answer is remembered, so the next call starts on it.
{
"name": "crm",
"ok": true,
"status": "ok",
"tools": [
{ "name": "lookup_order", "offered_as": "crm__lookup_order", "description": "Find an order by its number." }
],
"dropped_tools": [{ "name": "export_everything", "reason": "too_large" }],
"missing_allowed_tools": ["create_ticket"],
"latency_ms": 412,
"error": null,
"warning": "1 of its tools cannot be offered: see dropped_tools for why."
}tools are the tools the agent will be offered, missing_allowed_tools the names in allowed_tools the server did not list, and warning what a call would do differently (slower than 1.5 seconds, more than 30 tools, names taken).
When Check fails, ok is false, status is a stable code to branch on, and error says why in a sentence to show. New codes may be added: treat one you do not know like error.
status | What to do |
|---|---|
redirect: the server redirected the request | Save the final address as the url. Redirects are never followed. |
encoded: the server sent a compressed answer | Turn off compression (gzip or br) for this endpoint. Only uncompressed answers are read. |
http_401 or http_403: the server refused the request | Replace the headers with a key your server accepts. |
Any other http_<status> | Check your server's logs, and that the url is the MCP endpoint itself. |
timeout: no tool list within the time limit | Make the list answer faster, or raise timeout_seconds (up to 10). |
unresolved: the host name did not resolve | Check the host name for typos, and that its DNS record exists. |
dns_slow: the host name took too long to resolve | Try again in 30 seconds. |
dns_busy: the address could not be checked just now | Try again in a few seconds. Your settings are fine. |
connect_error: the server could not be reached | Check it is running and reachable from the internet. |
refused: not https on a public host | Use the server's public https address. |
oversized: an answer larger than 256 KB | Make each page of the tool list smaller than 256 KB. |
protocol_error: not an MCP server over Streamable HTTP | Use the Streamable HTTP endpoint, often ending in /mcp. An endpoint that only speaks the older SSE transport does not work. |
headers_unavailable: the saved headers could not be opened | Send the headers again. |
input_required: the server asked for input while listing | List the tools without asking for anything: a server that asks the agent for input is not supported. |
sdk_unsupported: the connection could not be prepared safely | Nothing to change on your side. Try again later. |
error: anything else | Check the url is the server's MCP endpoint, then check again. |
Tools that are never offered are listed in dropped_tools with a reason:
reason | What it means |
|---|---|
schema | Its input schema cannot be read. |
ref | A $ref points outside the schema (only #/$defs and #/definitions are read). |
ref_depth | Its $ref links go more than 4 deep, or loop. |
too_deep | Its objects or arrays nest more than 6 deep. |
too_large | Its schema is too large to send on every turn. |
listing_too_large | The tools listed before it used all the work one listing may take. |
name | Its name cannot be used. |
conversion | Its schema could not be turned into a tool the model takes. |
Name the tools the agent needs in allowed_tools: the other tools are then never read, so a big server never runs into listing_too_large.
On a call#
- Ready when the call starts: each server's tool list is remembered for up to 15 minutes (refreshed in the background after 5). The first time, the call waits at most 1.5 seconds for it. A server that does not answer in time is left out of that call: the call never fails because of an MCP server.
- Limits: at most 30 MCP tools reach the agent, within the same prompt budget as its own actions (32,000 characters); a tool whose name is already taken is not offered. The agent makes at most 6 MCP tool calls in one call, counted apart from the tool webhook's own 6. Each tool call is made once, never retried, within the server's
timeout_seconds. - Data, not instructions: the agent is told that the tools whose names hold a double underscore come from a third-party server, and that their descriptions and results are data. A result is cut to 8 KB; images, audio and links are named, never passed on.
- When a tool fails, the agent hears the same sentence as for a failing tool webhook and carries on with the caller.
- Where: phone calls, browser calls and tests. Typed chats do not use MCP tools yet.
- Not yet: stdio servers, OAuth sign-in (put a token in a header), MCP resources and prompts, and servers that ask the agent for input.
Test it before it takes calls#
Save typed test chats with checks and run them on your own AI account, with no call placed: see Test an agent.
Delete#
DELETE deactivates the agent and returns { id, name, deleted, calls }; historical sessions keep their agent id and version. Numbers bound to the agent stop answering.
Use one project per boundary
Provider secrets stay encrypted and project API keys stay on your server.