Agents
Agents API
Create and manage the AI voice agents that handle calls. See Building agents for the concepts.
Agents are exposed on both API surfaces (see Two API surfaces):
- Dashboard API (
/api/agents) — full CRUD, the complete agent object,{ success, data }envelope. Auth with a user JWT +X-Org-Idor an API key. - Integration API (
/api/v1/agents) — a read‑only slim list for dropdowns in automation tools, flat JSON,X-API-Keyonly.
There is a third way to create an agent, and picking the wrong one is the usual reason a create behaves unexpectedly:
| You are… | Use | Auth |
|---|---|---|
| An organization automating your own agents | POST /api/agents — this page | X-API-Key, or JWT + X-Org-Id |
| An app provisioning agents inside orgs that installed it | POST /api/app-agents | Authorization: Bearer <app key> |
| Populating a dropdown | GET /api/v1/agents | X-API-Key |
The first two take different request bodies. POST /api/agents requires seven fields and rejects a body missing any of them; POST /api/app-agents takes a flattened spec where nothing is required and every provider silently defaults. Full field lists: Agent field reference and Bundled agents & teams.
Dashboard API
All endpoints are organization‑scoped — authenticate with an API key (org implicit, no X-Org-Id needed) or a user JWT plus X-Org-Id.
Two things to know when using a key here:
- Writes need a privileged key.
POST,PUT,DELETEand version‑restore require a key with theowner,admin, ordeveloperrole. Aviewerormemberkey can read agents but gets403 — "This API key's role can't modify agents". Reads work with any valid key. Don't match on the message text — other endpoints word the same refusal differently. Match on the403status, and read the key's role fromGET /api/v1/me. - The AI builder routes are dashboard‑only.
/api/agents/copilot/*and/api/agents/{id}/simulate/*require a user JWT — they back interactive panels in the agent builder, not programmatic use. An API key gets401on those.
| Method | Path | Purpose |
|---|---|---|
GET | /api/agents | List your organization's agents (full objects) |
POST | /api/agents | Create an agent |
GET | /api/agents/{id} | Get one agent |
PUT | /api/agents/{id} | Update an agent |
DELETE | /api/agents/{id} | Soft‑delete an agent |
GET | /api/agents/{id}/stats | Call stats for an agent |
GET | /api/agents/{id}/app-tools | Tools from installed apps this agent can run as flow tool nodes |
GET | /api/agents/{id}/versions | Version history (see Agent versioning) |
POST | /api/agents/{id}/versions/{version}/restore | Roll back to a version |
GET | /api/agents/{id}/versions/{version}/diff | Diff a version against current |
GET | /api/agents/{id}/versions/{version}/perf | Per‑version performance |
GET | /api/agents/public | List publicly‑listed agents (no auth) |
GET /api/agents supports these query params, and they combine — every one narrows both the page and the total in the envelope, so total is always the count for the filter you asked for:
| Param | Values | Notes |
|---|---|---|
limit | 1–200 | Default 50. Anything larger is clamped to 200. |
offset | ≥ 0 | Page with offset = page × limit. |
search | any text | Case‑insensitive substring over name and description. |
isActive | true / false | Paused agents excluded / only. |
createdBy | me | Only agents created by the calling user (an API key resolves to the user who owns the key). Omit for the whole org. |
env | dev / staging / prod | Matches metadata.environment. |
status | draft / published | draft = has unpublished config or flow changes. |
kind | single / flow | flow = multi‑context graph agent. |
sort | new / old / az / za | Created newest/oldest first, or by name. Omit for most‑recently‑updated first. |
An unrecognised value for createdBy, env, status, kind or sort is ignored rather than rejected — the filter simply doesn't apply.
List agents
curl "https://api.telenow.ai/api/agents?isActive=true&limit=50" \
-H "X-API-Key: vai_live_…"
{
"success": true,
"data": { "agents": [ /* full agent objects */ ], "total": 12, "limit": 50, "offset": 0 }
}
Create an agent
POST /api/agents
An agent is assembled from provider slots. There are two ways to build the voice:
- Cascade (default) — separate STT → LLM → TTS providers. Set
sttProvider,llmProvider/llmModel, andttsProvider/ttsVoice. - Realtime (speech-to-speech) — one model listens, thinks and speaks. Set
s2sConfig. The cascade fields stay required.s2sConfignames an engine to try: the call builds STT, LLM and TTS first and only then attempts the bind, so a blank cascade slot fails the bind and kills the call whichever engine you picked. They go unused while the engine holds — and the engine does not always hold, so see when the cascade runs instead below.
Plus telephonyProvider/telephonyConfig for the carrier. Use the Catalog API (GET /api/catalog) to discover valid provider, model, and voice identifiers — and GET /api/catalog/s2s for the realtime models and their voices.
curl -X POST https://api.telenow.ai/api/agents \
-H "X-API-Key: vai_live_…" \
-H "Content-Type: application/json" \
-d '{
"name": "Support Bot",
"systemPrompt": "You are Acme'\''s friendly support agent…",
"llmProvider": "openai",
"llmModel": "gpt-4o-mini",
"sttProvider": "deepgram",
"ttsProvider": "elevenlabs",
"ttsVoice": "EXAVITQu4vr4xnSDxMaL",
"sessionConfig": {}
}'
Request fields
Field names are camelCase. This table is the summary; every field, its default when omitted, and the full contents of sessionConfig and metadata are in the Agent field reference.
Seven fields are required. Omit one and you get a 422 from the decoder naming it, before any of our own validation runs.
| Field | Type | Notes |
|---|---|---|
name | string | Display name. Does not have to be unique |
llmProvider | string | From GET /api/catalog |
llmModel | string | From GET /api/catalog |
sttProvider | string | Speech-to-text provider |
ttsProvider | string | Text-to-speech provider |
ttsVoice | string | A voice id that provider offers |
sessionConfig | object | Runtime behaviour. {} is valid and is the normal starting value — every key inside is optional. All 24 keys |
Everything else is optional:
| Field | Type | Notes |
|---|---|---|
systemPrompt | string | Persona/instructions. Omitted = the agent runs with none |
description | string | — |
isPublic | boolean | List under /api/agents/public. Default false |
llmConfig, sttConfig, ttsConfig | object | Provider-specific settings, including BYOK apiKey |
s2sConfig | object | Realtime (speech-to-speech) engine — see below |
fallbackConfig | object | Provider fallback chains, e.g. {"tts": [{"provider": "elevenlabs", "voice": "…"}]}. If the primary fails mid-call the next entry takes over. Keys the schema doesn't name are dropped on write, so it can never carry credentials. Only active when the deployment has features.providerFallback on |
telephonyProvider, telephonyConfig | string / object | Default carrier, opener, who speaks first, AMD, voicemail |
metadata | object | Where almost every feature lives — tools, context variables, post-call analysis, caller memory, flow graphs. All keys |
tags | array/object | Labels |
isActive is update-only — a new agent is always active. orgId comes from your key and userId is stamped as the creator; neither is a request field.
Response: 201 Created with { "success": true, "data": { /* the agent */ } }.
The minimum that works
Everything omitted here is genuinely optional. This creates a usable agent:
curl -X POST https://api.telenow.ai/api/agents \
-H "X-API-Key: vai_live_…" -H "Content-Type: application/json" \
-d '{
"name": "Support Bot",
"llmProvider": "openai", "llmModel": "gpt-4o-mini",
"sttProvider": "deepgram",
"ttsProvider": "elevenlabs", "ttsVoice": "EXAVITQu4vr4xnSDxMaL",
"sessionConfig": {}
}'
201 Created does not mean your provider ids are valid
This is the most common surprise. Only some of what you send is checked at create time:
| Checked at create | Not checked — stored exactly as sent |
|---|---|
s2sConfig provider / model / voice, against GET /api/catalog/s2s | llmProvider, llmModel |
s2sConfig.fallback (see below) | sttProvider, ttsProvider, ttsVoice |
metadata.piiPolicy — an unsatisfiable policy is a 400 | telephonyProvider, telephonyConfig |
every key inside sessionConfig |
A typo in a cascade provider returns 201, and the first symptom is a call that will not connect. Validate against GET /api/catalog before you write. After the save a background voice-stack preflight runs and flags a broken provider on the agent's health badge — it reports, it does not block the write.
Unknown keys are ignored everywhere, silently. There is no strict mode. A misspelled key inside sessionConfig or metadata is not an error — it is simply never read. If a setting is not taking effect, read the agent back and diff it against what you sent.
Errors
| Status | When |
|---|---|
401 | Missing or invalid credentials. The copilot and simulate routes also return this for an API key — they are JWT-only |
403 | The key's role is viewer or member. Writes need owner, admin or developer. Match on the status, not the message — other endpoints word the same refusal differently; read the role from GET /api/v1/me |
400 | Either a validated field is wrong — an s2sConfig combination the catalog does not list, fallback: false with no reachable realtime key, an unsatisfiable metadata.piiPolicy — or the body is not valid JSON at all |
422 | The body parsed, but a required field is missing or has the wrong JSON type. Note the split: malformed JSON is 400, a well-formed body with a bad field is 422 |
415 | No Content-Type: application/json header |
There is no optimistic-concurrency check on this surface: two clients writing the same agent, last write wins. ifUnmodifiedSince — echo the updatedAt you read and get a 409 if the agent moved — exists only on the app-key PATCH /api/app-agents/{id}/config, because that is the surface where an app reads, spends real time thinking, then writes back. Every write here creates a version, so a clobbered change is recoverable from version history.
Three rules that catch people out
- Config objects are replaced whole, never merged.
{"sessionConfig": {"maxDuration": 600}}on an update erases every other session key. Read the agent, change the one key, send the whole object back. The same is true ofmetadata,llmConfig,ttsConfig,s2sConfig,telephonyConfig,fallbackConfigandtags. - On update,
nullmeans "leave unchanged", not "clear". To clear a config object send{}. - Requests are camelCase; responses are snake_case. You send
ttsVoice, you readtts_voice. Posting a read-back agent straight toPUTis safe.
Realtime (speech-to-speech) agents
s2sConfig replaces the STT→LLM→TTS cascade with a single realtime model for the whole call — lowest voice-to-voice latency, and the model speaks in its own voice rather than through a TTS engine.
{
"s2sConfig": {
"provider": "openai",
"model": "gpt-realtime",
"voice": "marin"
}
}
| Key | Notes |
|---|---|
provider | openai or gemini (google is accepted as an alias for gemini). Presence of a non-empty provider is what turns the engine on. |
model | Required when provider is set. A model key from GET /api/catalog/s2s. |
voice | Optional. A voice id offered by that model. Omit to use the engine's default. |
apiKey | Optional BYOK. Encrypted at rest and never returned on a public read. |
fallback | Optional boolean, default true. May a runtime failure of the engine hand the live call to the cascade? See below. |
Discover the valid combinations with GET /api/catalog/s2s — it returns each provider's models and, per model, the voices that model accepts. Passing a provider, model or voice that endpoint does not list returns 400 at create/update time rather than failing later on a call.
This validation covers s2sConfig only. llmProvider, llmModel, sttProvider, ttsProvider and ttsVoice are stored as you send them — a typo is accepted with a 201, and the first sign of trouble is a call that fails to connect. Check them against GET /api/catalog yourself before you write them. (After a save, the agent's voice-stack health check runs in the background and will flag a broken provider; it does not block the write.)
This field is replaced whole, not merged. Like every other config blob, whatever you send becomes the stored value. So {"s2sConfig": {"voice": "cedar"}} does not "just change the voice" — it would store an object with no provider, silently leaving the agent with no realtime engine. Send the complete object every time. (The API rejects that particular mistake with a 400 rather than accepting it.)
Setting fallback on its own does not work. Because this field is replaced whole, {"s2sConfig": {"fallback": false}} stores an object with no provider — which means no realtime engine at all, the exact opposite of what you asked for. Send the complete object every time:
{ "s2sConfig": { "provider": "openai", "model": "gpt-realtime", "voice": "marin", "fallback": false } }
Turning it off. Send "s2sConfig": {} — an empty object. Sending null means "leave unchanged" (the same tri-state as llmConfig/ttsConfig/telephonyConfig), so null will not disable the engine.
When the cascade runs instead. s2sConfig is a per-call attempt, not a stored guarantee. Each case below falls through to the agent's STT→LLM→TTS stack; the call is never dropped for it.
Never binds — the whole call runs the cascade:
| Case | How to check |
|---|---|
| The deployment has the realtime engine switched off | features.s2s in GET /api/catalog, or enabled on GET /api/catalog/s2s |
| The channel isn't supported. It binds on web calls and the standard PSTN carriers (Plivo, Twilio, Exotel, Vonage, Vobiz, Smartflo, SIP). LiveKit and WhatsApp calling always cascade — and so does every text channel (chat, WhatsApp and Instagram messaging), where there is no audio leg to hand a realtime model and the LLM is the only brain | the agent's telephonyProvider, and the channel you call on |
| A simulation or dry run | — |
s2sConfig.model is blank — the partial-write trap above | read the agent back |
s2sConfig.provider is not a registered realtime engine | GET /api/catalog/s2s |
| A flow agent on an engine that cannot swap prompt and tools mid-session (Gemini Live). The graph would be silently ignored, so the call rides the cascade rather than misroute | GET /api/catalog/s2s |
The engine's connection setup fails — a rejected BYOK apiKey, a provider outage, a region refusal | the call still connects; the audio is the cascade's |
Binds, then hands the live call back:
- The engine hits a fatal error, or its socket closes with no reconnect left in the budget. The dialogue so far is already in session history, so the cascade picks the conversation up mid-call — the caller hears a different voice rather than a dropped line. This one needs provider fallback enabled on the deployment (
features.providerFallback); without it the call ends instead. - On a warm transfer or agent handoff, the target agent runs the cascade if it has no usable
s2sConfig, if its realtime provider is unavailable, or if it is a flow agent on an engine without mid-call session updates.
That is why the cascade slots are required rather than optional: they are the floor the call stands on, not a second choice you opt into.
Turning the fallback off — "fallback": false. Set it when finishing the call in a different voice is worse than not finishing it. What changes:
- The engine cannot start (no reachable API key). The call is refused rather than answered on the cascade. Inbound, the caller gets the carrier's busy/failure treatment instead of a wrong-voice agent. Outbound, the dial fails — and because a missing key is true of the whole account rather than of one number, a campaign pauses instead of burning through its target list. The cause is written to the call's voice-stack incidents, so the Call detail page names it beside the STT, LLM and TTS faults it already shows.
- The engine starts and then dies mid-call (a fatal error, or a socket that closes past its reconnect budget). The call ends rather than handing the live conversation to the cascade. That reason is in the server logs only — no incident row is written for this case.
The engine's connection is retried where the vendor is actually reached: the mid-call reconnect budget. There is no retry around the initial key check, because that check is local — it reads your key and the environment, makes no network call, and so cannot fail transiently.
Two things it does not do. It does not affect the static rows in the table above — a text channel, an unsupported carrier or a deployment with the engine switched off still runs the cascade, because those are facts about where the call is running rather than failures of the engine. And it does not make the cascade fields optional: they are still built on every call before the engine is reached, so a blank one is still a bind failure.
Because a missing key becomes fatal with this flag on, the dashboard blocks publishing an agent that has fallback: false, no realtime API key of its own, and no platform key behind its provider.
It also bars the agent from text channels. A realtime engine never runs on chat, WhatsApp messaging or Instagram DMs — those carry no audio, so the cascade LLM is the only brain there is. An agent that has switched the cascade off therefore has nothing that can answer a message, so instead of accepting DMs and silently never replying, the platform refuses the configuration:
- Binding it to a WhatsApp channel or an Instagram account returns
400. - A
POST /api/v1/chatturn against it returns400. - Setting
"fallback": falseon an agent already bound to WhatsApp or Instagram returns400, naming the channel. Unbind it first, or leave the fallback on.
All four return the same sentence, so you can match on the status and show it as-is. Voice is unaffected — this is only about channels where the realtime engine cannot run at all.
On a warm transfer or agent handoff, if the target agent has fallback: false and its realtime engine will not start, the handoff is refused rather than completed onto the cascade. The tool call returns an error, and the caller stays with the agent they were already talking to — nothing is spoken and the current agent can recover.
It governs the error rows above — the failed connection setup, the fatal error, the dead socket. It deliberately does not govern the static rows. Those describe where the call is running rather than a failure of the engine, and every text channel is one of them: honouring false there would leave the agent silently unable to answer chat, WhatsApp or Instagram at all, for a flag about voice.
The cascade fields stay required either way. bind_session_providers builds STT, LLM and TTS before it reaches the realtime engine whatever this flag says, so a blank one is still a bind failure and still a dead call. fallback decides what happens after the engine fails, not whether the slots have to be fillable.
Omitted means true. The flag was added after realtime agents already existed, and every one of them was built when degrading to the cascade was the only behaviour there was — so an agent that does not carry the key keeps that behaviour. A migration stamps "fallback": true onto existing realtime agents to make the setting visible in the builder; it changes no call.
In the dashboard. Under Voice engine → Realtime voice both builders show a "Fall back to the standard pipeline" checkbox, which is this field. Leave it on and the Listening and Voice steps stay open, labelled as the fallback — the stack you are choosing is the one a mid-call engine failure lands the caller on. Turn it off and those steps collapse, because then nothing will drive them.
Hearing it. POST /api/agents/{id}/voice-preview returns a WAV of this agent speaking — through the realtime engine when s2sConfig names one, and through TTS otherwise. See Voice preview.
Response field casing
Request fields are camelCase (s2sConfig, ttsVoice); the agent object in responses uses the snake_case column names (s2s_config, tts_voice). Reading an agent and posting it straight back is safe — unrecognised keys are ignored, and an omitted config means "leave unchanged".
Agent reads carry your secrets. GET /api/agents and GET /api/agents/{id}, called by a member or by any key belonging to the agent's organization, return your bring-your-own-key credentials inside llm_config, stt_config, tts_config, s2s_config and telephony_config — and tool secrets inside metadata — decrypted. Treat those responses as credentials: don't log them, don't cache them in a browser, don't forward them to a third party.
A caller outside the organization reading a public agent gets the same object with those keys removed, not masked. That response is safe to log — but it is not a config you can post back unchanged.
Tools & custom models
- Tools (function calling, including call transfer) are defined in
metadata.tools— see Tools & function calling. - Custom / self‑hosted models are configured via
llmProvider: "customllm"(OpenAI‑compatible) or"customapi"(your agentic backend) plusllmConfig— see Custom LLM & API. - Context variables (
{placeholder}substitution) are configured inmetadata— see Context variables.
Update an agent
PUT /api/agents/{id}
Send only the fields you want to change (all are optional on update, including isActive to enable/disable the agent). Returns the updated agent. Every update creates a new version — see Agent versioning.
Get / delete / stats
GET /api/agents/{id} # the agent (public agents are viewable unauthenticated)
DELETE /api/agents/{id} # soft-deletes the agent
GET /api/agents/{id}/stats # totals: sessions, messages, duration
Integration API (slim list)
GET /api/v1/agents
Built for dynamic dropdowns in automation platforms (Zapier, Make, n8n, viaSocket). Authenticate with X-API-Key only — the org comes from the key. The response is flat JSON (no success/data envelope) and a slim projection: it returns ids and labels only. The full system prompt and provider configuration are deliberately excluded so they're never shipped to a third‑party app.
curl "https://api.telenow.ai/api/v1/agents?is_active=true&limit=100" \
-H "X-API-Key: vai_live_…"
{
"agents": [
{
"id": "…",
"name": "Support Bot",
"description": "Handles tier‑1 support",
"is_active": true,
"created_at": "2026-01-01T12:00:00Z",
"updated_at": "2026-01-02T09:30:00Z"
}
],
"total": 12
}
Query params: is_active (also accepts isActive / active), limit (default 100, max 200), offset (default 0).
Full /api/v1 surface
The Integration API exposes a curated slice of functionality. All endpoints take X-API-Key and return flat JSON.
| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/me | Connection test — returns org_id, org_name, key_id, key_name, key_role |
GET | /api/v1/hooks | List REST‑hook subscriptions (filter with ?source=) |
POST | /api/v1/hooks | Subscribe a target_url to events |
DELETE | /api/v1/hooks/{id} | Unsubscribe (idempotent) |
GET | /api/v1/agents | Slim agent list (this page) |
GET | /api/v1/numbers | Slim owned‑number list |
GET | /api/v1/calls | Call history (filters: agent_id, status, sort, limit, offset) |
GET | /api/v1/calls/{id} | One call + transcript |
GET | /api/v1/events/sample | Latest real (or canned) payload for an event type (?type=) |
POST | /api/v1/chat | One chat turn (creates the session on first call) |
GET | /api/v1/chat/{id}/messages | Full transcript of a chat session |
POST | /api/v1/chat/{id}/end | End a chat session (idempotent) |
See Automation platforms for connector setup, Webhooks for hooks, and the Chat API guide for the /chat endpoints.
Using an agent
Once created, attach the agent to a phone number, start a web call, place an outbound call, publish it, or talk to it over text via the Chat API.
Tips
- Validate provider/model/voice ids against the Catalog before creating an agent — bad ids fail at create time.
- Use
/api/v1/meas a cheap health check that a key is valid and to read its role before attempting writes. - The slim
/api/v1/agentslist is the right source for "pick an agent" dropdowns; use the DashboardGET /api/agents/{id}when you actually need the full configuration.