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-Id or an API key.
  • Integration API (/api/v1/agents) — a read‑only slim list for dropdowns in automation tools, flat JSON, X-API-Key only.

There is a third way to create an agent, and picking the wrong one is the usual reason a create behaves unexpectedly:

You are…UseAuth
An organization automating your own agentsPOST /api/agentsthis pageX-API-Key, or JWT + X-Org-Id
An app provisioning agents inside orgs that installed itPOST /api/app-agentsAuthorization: Bearer <app key>
Populating a dropdownGET /api/v1/agentsX-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, DELETE and version‑restore require a key with the owner, admin, or developer role. A viewer or member key can read agents but gets 403 — "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 the 403 status, and read the key's role from GET /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 gets 401 on those.
MethodPathPurpose
GET/api/agentsList your organization's agents (full objects)
POST/api/agentsCreate 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}/statsCall stats for an agent
GET/api/agents/{id}/app-toolsTools from installed apps this agent can run as flow tool nodes
GET/api/agents/{id}/versionsVersion history (see Agent versioning)
POST/api/agents/{id}/versions/{version}/restoreRoll back to a version
GET/api/agents/{id}/versions/{version}/diffDiff a version against current
GET/api/agents/{id}/versions/{version}/perfPer‑version performance
GET/api/agents/publicList 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:

ParamValuesNotes
limit1200Default 50. Anything larger is clamped to 200.
offset≥ 0Page with offset = page × limit.
searchany textCase‑insensitive substring over name and description.
isActivetrue / falsePaused agents excluded / only.
createdBymeOnly agents created by the calling user (an API key resolves to the user who owns the key). Omit for the whole org.
envdev / staging / prodMatches metadata.environment.
statusdraft / publisheddraft = has unpublished config or flow changes.
kindsingle / flowflow = multi‑context graph agent.
sortnew / old / az / zaCreated 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, and ttsProvider/ttsVoice.
  • Realtime (speech-to-speech) — one model listens, thinks and speaks. Set s2sConfig. The cascade fields stay required. s2sConfig names 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.

FieldTypeNotes
namestringDisplay name. Does not have to be unique
llmProviderstringFrom GET /api/catalog
llmModelstringFrom GET /api/catalog
sttProviderstringSpeech-to-text provider
ttsProviderstringText-to-speech provider
ttsVoicestringA voice id that provider offers
sessionConfigobjectRuntime behaviour. {} is valid and is the normal starting value — every key inside is optional. All 24 keys

Everything else is optional:

FieldTypeNotes
systemPromptstringPersona/instructions. Omitted = the agent runs with none
descriptionstring
isPublicbooleanList under /api/agents/public. Default false
llmConfig, sttConfig, ttsConfigobjectProvider-specific settings, including BYOK apiKey
s2sConfigobjectRealtime (speech-to-speech) engine — see below
fallbackConfigobjectProvider 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, telephonyConfigstring / objectDefault carrier, opener, who speaks first, AMD, voicemail
metadataobjectWhere almost every feature lives — tools, context variables, post-call analysis, caller memory, flow graphs. All keys
tagsarray/objectLabels

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 createNot checked — stored exactly as sent
s2sConfig provider / model / voice, against GET /api/catalog/s2sllmProvider, llmModel
s2sConfig.fallback (see below)sttProvider, ttsProvider, ttsVoice
metadata.piiPolicy — an unsatisfiable policy is a 400telephonyProvider, 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

StatusWhen
401Missing or invalid credentials. The copilot and simulate routes also return this for an API key — they are JWT-only
403The 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
400Either 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
422The 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
415No 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

  1. 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 of metadata, llmConfig, ttsConfig, s2sConfig, telephonyConfig, fallbackConfig and tags.
  2. On update, null means "leave unchanged", not "clear". To clear a config object send {}.
  3. Requests are camelCase; responses are snake_case. You send ttsVoice, you read tts_voice. Posting a read-back agent straight to PUT is 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"
  }
}
KeyNotes
provideropenai or gemini (google is accepted as an alias for gemini). Presence of a non-empty provider is what turns the engine on.
modelRequired when provider is set. A model key from GET /api/catalog/s2s.
voiceOptional. A voice id offered by that model. Omit to use the engine's default.
apiKeyOptional BYOK. Encrypted at rest and never returned on a public read.
fallbackOptional 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:

CaseHow to check
The deployment has the realtime engine switched offfeatures.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 brainthe agent's telephonyProvider, and the channel you call on
A simulation or dry run
s2sConfig.model is blank — the partial-write trap aboveread the agent back
s2sConfig.provider is not a registered realtime engineGET /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 misrouteGET /api/catalog/s2s
The engine's connection setup fails — a rejected BYOK apiKey, a provider outage, a region refusalthe 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/chat turn against it returns 400.
  • Setting "fallback": false on an agent already bound to WhatsApp or Instagram returns 400, 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 metadatadecrypted. 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) plus llmConfig — see Custom LLM & API.
  • Context variables ({placeholder} substitution) are configured in metadata — 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.

MethodPathPurpose
GET/api/v1/meConnection test — returns org_id, org_name, key_id, key_name, key_role
GET/api/v1/hooksList REST‑hook subscriptions (filter with ?source=)
POST/api/v1/hooksSubscribe a target_url to events
DELETE/api/v1/hooks/{id}Unsubscribe (idempotent)
GET/api/v1/agentsSlim agent list (this page)
GET/api/v1/numbersSlim owned‑number list
GET/api/v1/callsCall history (filters: agent_id, status, sort, limit, offset)
GET/api/v1/calls/{id}One call + transcript
GET/api/v1/events/sampleLatest real (or canned) payload for an event type (?type=)
POST/api/v1/chatOne chat turn (creates the session on first call)
GET/api/v1/chat/{id}/messagesFull transcript of a chat session
POST/api/v1/chat/{id}/endEnd 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/me as a cheap health check that a key is valid and to read its role before attempting writes.
  • The slim /api/v1/agents list is the right source for "pick an agent" dropdowns; use the Dashboard GET /api/agents/{id} when you actually need the full configuration.