InfraVoice

Agents API

An agent is the unit InfraVoice runs on a call: a system prompt, provider slots (LLM, speech-to-text, voice), optional tools, knowledge bases and a phone binding. You create an agent, wire it up, then publish it so live calls pick up the change.

Base URL: https://your-infravoice-host/api/v1 Auth: Authorization: Bearer usf-… — the single organization key from Account settings. It authenticates as your organization's owner, so workspace_id defaults to your primary workspace almost everywhere (a dashboard JWT works identically). The same key works on every other page in this reference too. Response envelope: every endpoint on this page is served by a Go service, so the payload is always {"success":true,"data":{…},"error":null,"meta":…,"timestamp":…}. The Python-backed services (telephony, call bridge, voice pipeline) return raw JSON with no envelope — if you write one client for the whole platform, branch on the presence of success/data rather than assuming either shape. Drafts: every write on this page edits a draft. Nothing reaches a live call until POST /api/v1/agents/{id}/versions/publish.


Endpoints

Agent CRUD

MethodEndpointPurpose
POST/api/v1/agentsCreate an agent (draft)
GET/api/v1/agentsList agents in a workspace
GET/api/v1/agents/{id}Agent + providers + telephony + knowledge bases
PUT/api/v1/agents/{id}Partial update of the draft
DELETE/api/v1/agents/{id}Delete the agent

Versions

MethodEndpointPurpose
GET/api/v1/agents/{id}/versionsList published versions + draft_dirty
GET/api/v1/agents/{id}/versions/diffDiff two versions, or a version vs. the draft
GET/api/v1/agents/{id}/versions/{version}One version's full snapshot
POST/api/v1/agents/{id}/versions/publishSnapshot the draft and make it live
POST/api/v1/agents/{id}/versions/{version}/rollbackRestore an old version and republish it

Providers and credentials

MethodEndpointPurpose
POST/api/v1/agents/{id}/providersSet (upsert) one slot
GET/api/v1/agents/{id}/providersList configured slots
DELETE/api/v1/agents/{id}/providers/{type}Remove a slot
GET/api/v1/providersBrowse the catalog of supported providers (may be empty — see below)
GET/api/v1/providers/{id}One catalog entry, by its UUID
POST/api/v1/providers/user-configLegacy: store a key/model per account or agent_id
GET/api/v1/providers/user-configLegacy: list those configs (never returns a key)
GET/api/v1/providers/resolveDebug: effective config for a type (+ optional agent_id)
POST/api/v1/agents/credentialsCreate a reusable workspace credential
GET/api/v1/agents/credentialsList credentials (requires workspace_id)
GET/api/v1/agents/credentials/{credID}Fetch one
PUT/api/v1/agents/credentials/{credID}Update or rotate
DELETE/api/v1/agents/credentials/{credID}Delete and unlink from agents

Prompts, tools, knowledge, checklist

MethodEndpointPurpose
POST/api/v1/agents/snippetsCreate a workspace prompt snippet
GET/api/v1/agents/snippetsList snippets (requires workspace_id), merged with seeds
GET/api/v1/agents/snippets/{id}Fetch one snippet
PUT/api/v1/agents/snippets/{id}Update a snippet
DELETE/api/v1/agents/snippets/{id}Delete a snippet
GET/api/v1/agents/{id}/handbookResolved snippets attached to an agent, in order
PUT/api/v1/agents/{id}/handbookReplace the agent's ordered snippet list
POST/api/v1/agents/{id}/toolsCreate a tool
GET/api/v1/agents/{id}/toolsList the agent's tools
GET/api/v1/agents/{id}/tools/{toolID}Fetch one tool
PUT/api/v1/agents/{id}/tools/{toolID}Update a tool
DELETE/api/v1/agents/{id}/tools/{toolID}Delete a tool
POST/api/v1/agents/{id}/knowledge-basesAttach a KB with a delivery mode
GET/api/v1/agents/{id}/knowledge-basesList bindings
PUT/api/v1/agents/{id}/knowledge-bases/{kbID}Change mode or enable/disable
DELETE/api/v1/agents/{id}/knowledge-bases/{kbID}Detach
GET/api/v1/agents/{id}/checklistRead the live-call checklist template
PUT/api/v1/agents/{id}/checklistReplace the ordered pointers

Telephony, flow mode, tests

MethodEndpointPurpose
PUT/api/v1/agents/{id}/telephonySet the telephony config
GET/api/v1/agents/{id}/telephonyRead it back (token masked)
GET/api/v1/agents/{id}/modeRead the current mode
PUT/api/v1/agents/{id}/modeSwitch single_prompt / flow
GET/api/v1/agents/{id}/flowGet the latest flow graph
PUT/api/v1/agents/{id}/flowSave the graph (creates a new flow version)
POST/api/v1/agents/{id}/flow/validateDry-run validation without saving
GET/api/v1/agents/{id}/test-casesList test cases
POST/api/v1/agents/{id}/test-casesCreate a test case
PUT/api/v1/agents/{id}/test-cases/{tcId}Update a test case
DELETE/api/v1/agents/{id}/test-cases/{tcId}Delete a test case
POST/api/v1/agents/{id}/test-runsStart a run
GET/api/v1/agents/{id}/test-runsList runs (paginated)

Secrets are one-way. Provider api_key, twilio_auth_token and tool auth_token are encrypted at rest (AES-256-GCM) and only ever read back masked (******** + last 4; a bare ******** inside version snapshots). No endpoint returns a stored secret in plaintext — if you lose a key, rotate it at the source and write a new one.


Create an agent

An agent lives in exactly one workspace. Requests for an agent outside your workspaces return 404, not 403 — the platform does not confirm that a foreign id exists.

Quickstart

The shortest path from a bare organization key to an agent that can answer a phone call.

1. Create the agent. Only name and system_prompt are required.

Shell
curl -X POST https://your-infravoice-host/api/v1/agents \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Support Line",
    "system_prompt": "You are Ava, the support agent for Acme. Be brief and warm. Never invent order numbers.",
    "welcome_message": "Hi, this is Ava at Acme — how can I help?",
    "language": "en",
    "allow_human_transfer": true
  }'

Keep data.id — every call below is scoped to it.

2. Set the three required providers (llm, asr, tts), one request per slot:

Shell
AGENT=agt_0d5f...   # data.id from step 1
KEY="Authorization: Bearer usf-YOUR_ORG_KEY"
 
curl -X POST https://your-infravoice-host/api/v1/agents/$AGENT/providers \
  -H "$KEY" -H "Content-Type: application/json" \
  -d '{"provider_type":"llm","provider_name":"openai","model_name":"gpt-4o-mini","api_key":"sk-...","credential_label":"OpenAI – prod"}'
 
curl -X POST https://your-infravoice-host/api/v1/agents/$AGENT/providers \
  -H "$KEY" -H "Content-Type: application/json" \
  -d '{"provider_type":"asr","provider_name":"deepgram","model_name":"nova-2","api_key":"...","credential_label":"Deepgram – prod"}'
 
curl -X POST https://your-infravoice-host/api/v1/agents/$AGENT/providers \
  -H "$KEY" -H "Content-Type: application/json" \
  -d '{"provider_type":"tts","provider_name":"elevenlabs","voice_id":"21m00Tcm...","api_key":"...","credential_label":"ElevenLabs – prod"}'

3. (Optional) Add tools, knowledge bases and a handbookPOST /agents/{id}/tools, POST /agents/{id}/knowledge-bases, PUT /agents/{id}/handbook.

4. Bind a phone number and transfer behaviour.

Shell
curl -X PUT https://your-infravoice-host/api/v1/agents/$AGENT/telephony \
  -H "$KEY" -H "Content-Type: application/json" \
  -d '{
    "twilio_account_sid": "AC...",
    "twilio_auth_token": "...",
    "twilio_phone_number": "+14155550123",
    "call_direction": "inbound",
    "human_transfer_mode": "phone",
    "human_transfer_number": "+14155550188"
  }'

5. Publish. Until you do, everything above is a draft and calls behave as if none of it happened.

Shell
curl -X POST https://your-infravoice-host/api/v1/agents/$AGENT/versions/publish \
  -H "$KEY" -H "Content-Type: application/json" \
  -d '{"notes":"initial launch"}'
JSON
{
  "success": true,
  "data": {
    "version_number": 1,
    "published_at": "2026-07-26T10:14:22Z",
    "message": "Version 1 is now live."
  },
  "error": null
}

Create fields

POST https://your-infravoice-host/api/v1/agents

FieldRequiredDescription
nameyesDisplay name. 400 MISSING_FIELDS if empty.
system_promptyesThe agent's instructions — personality, scope and rules.
workspace_idnoDefaults to your primary workspace. A workspace you are not a member of returns 403.
descriptionnoInternal note, never spoken.
welcome_messagenoThe first line the agent speaks when a call connects.
languagenoPrimary language hint, e.g. en.
max_turnsnoHard ceiling on conversation turns before the call ends.
connection_typenoHow the agent is reached (telephony vs. web session).
interrupt_enablednoWhether the caller can barge in over the agent's speech.
vad_thresholdnoVoice-activity sensitivity — lower is more sensitive to quiet speech.
silence_timeout_msnoHow long silence must last before the turn is treated as finished.
allow_human_transfernoEnables the hand-off-to-human tool. Pair with the telephony config.
metadatanoFree-form object holding the Advanced Pipeline Settings.

Everything the dashboard calls "advanced" lives in metadata, so the API surface stays stable as tuning knobs come and go: llm_temperature, llm_max_tokens, the context_* window controls, tts_* playback controls, dtmf_* keypad handling, amd_* answering-machine detection, noise_cancellation_enabled. Unknown keys are stored, not rejected — a typo is silently ignored, so check the object you get back.

Response201 with the full agent:

JSON
{
  "success": true,
  "data": {
    "id": "agt_0d5f7c1a",
    "workspace_id": "ws_2b91",
    "user_id": "usr_77c1",
    "name": "Support Line",
    "system_prompt": "You are Ava, the support agent for Acme. …",
    "welcome_message": "Hi, this is Ava at Acme — how can I help?",
    "language": "en",
    "max_turns": 0,
    "interrupt_enabled": true,
    "vad_threshold": 0.5,
    "silence_timeout_ms": 800,
    "connection_type": "telephony",
    "is_active": true,
    "allow_human_transfer": true,
    "mode": "single_prompt",
    "metadata": { "llm_temperature": 0.4 },
    "handbook_snippet_ids": [],
    "created_at": "2026-07-26T10:02:11Z",
    "updated_at": "2026-07-26T10:02:11Z"
  },
  "error": null
}

New agents start in mode: "single_prompt" — one system prompt drives the whole conversation. The graph-based alternative is described under Flow mode and tests.

List agents

GET https://your-infravoice-host/api/v1/agents

QueryDescription
workspace_idAuto-filled from your primary workspace when omitted. A workspace you are not a member of returns 403.
limitAny value greater than 0 turns on pagination. Capped at 200.
offsetRow offset for pagination.

With limit set, the envelope's meta carries {page, per_page, total, total_pages}; without it you get the unpaginated array.

Shell
curl -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  "https://your-infravoice-host/api/v1/agents?limit=50"

Get, update, delete

GET /api/v1/agents/{id} returns the assembled view a dashboard needs — the agent row plus its sub-resources:

JSON
{
  "success": true,
  "data": {
    "agent": { "id": "agt_0d5f7c1a", "name": "Support Line", "mode": "single_prompt" },
    "providers": [
      { "provider_type": "llm", "provider_name": "openai", "model_name": "gpt-4o-mini", "api_key": "********f3a1", "credential_id": "cred_9a2", "credential_label": "OpenAI – prod" },
      { "provider_type": "asr", "provider_name": "deepgram", "model_name": "nova-2", "api_key": "********8c40" },
      { "provider_type": "tts", "provider_name": "elevenlabs", "voice_id": "21m00Tcm", "api_key": "********11de" }
    ],
    "telephony": { "twilio_phone_number": "+14155550123", "twilio_auth_token": "********9b2c" },
    "knowledge_bases": []
  },
  "error": null
}

PUT /api/v1/agents/{id} is a partial update: every field is optional and an omitted field is left unchanged. You can update name, description, system_prompt, welcome_message, language, max_turns, connection_type, interrupt_enabled, vad_threshold, silence_timeout_ms, is_active, allow_human_transfer, metadata and handbook_snippet_ids (sending [] clears the list; sending a list replaces it wholesale).

Shell
curl -X PUT https://your-infravoice-host/api/v1/agents/$AGENT \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"system_prompt":"You are Ava … Always confirm the caller'\''s order number before looking it up."}'

DELETE /api/v1/agents/{id} removes the agent and returns {"message":"agent deleted"}.


Publishing & versions

Every write on this page — the agent row, providers, tools, telephony, knowledge-base bindings, the handbook — edits the draft. The draft is what GET /agents/{id} returns. It is not what answers calls: calls are served by the most recently published version, an immutable snapshot taken at publish time. Any mutation flips draft_dirty to true and invalidates the voice pipeline's cache for that agent, so the next call re-reads configuration — that is what makes a published change take effect immediately; it does not promote a draft.

Saving is not shipping. A prompt edit, a swapped voice, a new tool, a changed transfer number — none of it affects a live call until POST /api/v1/agents/{id}/versions/publish. If a change "didn't take", check GET /api/v1/agents/{id}/versions for draft_dirty: true first.

The safe sequence: edit the draft → GET /versions and confirm draft_dirtyGET /versions/diff to review → POST /versions/publish with notes → if it goes wrong, POST /versions/{version}/rollback.

List versions

Shell
curl -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  https://your-infravoice-host/api/v1/agents/$AGENT/versions
JSON
{
  "success": true,
  "data": {
    "agent_id": "agt_0d5f7c1a",
    "live_version": 3,
    "draft_dirty": true,
    "versions": [
      { "version_number": 3, "status": "published", "published_at": "2026-07-25T16:40:02Z", "published_by": "usr_77c1", "notes": "shorter greeting", "is_live": true },
      { "version_number": 2, "status": "published", "published_at": "2026-07-24T09:12:44Z", "published_by": "usr_77c1", "notes": "added order-lookup tool", "is_live": false },
      { "version_number": 1, "status": "published", "published_at": "2026-07-23T11:01:09Z", "published_by": "usr_77c1", "notes": "initial launch", "is_live": false }
    ]
  },
  "error": null
}

Diff before publishing

GET /api/v1/agents/{id}/versions/diff takes from and to, each an integer version number or the literal draft (0 also means draft). With both omitted it defaults to from=live_version, to=draft — exactly the "what am I about to ship?" question.

Shell
curl -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  "https://your-infravoice-host/api/v1/agents/$AGENT/versions/diff"
JSON
{
  "success": true,
  "data": {
    "from": 3,
    "to": "draft",
    "changes": [
      { "field": "system_prompt", "action": "changed", "before": "You are Ava …", "after": "You are Ava … Always confirm the caller's order number …" },
      { "field": "agent.metadata.llm_temperature", "action": "changed", "before": 0.7, "after": 0.4 },
      { "field": "tools.lookup_order", "action": "added", "before": null, "after": { "endpoint_url": "https://api.acme.com/orders" } }
    ],
    "summary": "2 changed, 1 added"
  },
  "error": null
}

Diffs cover agent scalars, agent.metadata.<key>, providers.<type>, tools.<name> and telephony.

Secret rotations are invisible in a diff. Key fields are stripped from both sides before comparison, so changing only an api_key produces an empty diff. Publish it anyway — the snapshot does capture the new key.

Publish and inspect

POST /api/v1/agents/{id}/versions/publish with an optional {"notes":"…"}. The new version_number is the previous maximum plus one, published_by comes from the token identity, and the snapshot captures the agent, providers, tools, telephony and knowledge-base bindings together — you cannot publish half a change.

GET /api/v1/agents/{id}/versions/{version} returns that snapshot:

JSON
{
  "success": true,
  "data": {
    "id": "ver_31ac",
    "agent_id": "agt_0d5f7c1a",
    "version_number": 2,
    "status": "published",
    "snapshot": {
      "agent": { "name": "Support Line", "system_prompt": "You are Ava …" },
      "providers": [ { "provider_type": "llm", "provider_name": "openai", "api_key": "********" } ],
      "tools": [],
      "telephony": { "twilio_phone_number": "+14155550123", "twilio_auth_token": "********" },
      "knowledge_bases": []
    },
    "published_at": "2026-07-24T09:12:44Z",
    "published_by": "usr_77c1",
    "notes": "added order-lookup tool",
    "created_at": "2026-07-24T09:12:44Z"
  },
  "error": null
}

A non-integer {version} returns 400 invalid_version.

Roll back

POST /api/v1/agents/{id}/versions/{version}/rollback restores that snapshot into the draft tables and immediately publishes it as a new version. History stays linear: rolling back to v2 while v4 is live produces v5 whose contents equal v2. v2 does not "become live again".

Rollback overwrites your draft. Any unpublished work in progress is destroyed. Diff draft against the live version first if you are not sure what you are about to lose.


Providers & credentials

A provider slot tells the agent which vendor and model to use for one job. Slots are keyed one row per type — posting the same provider_type twice updates it rather than adding a second.

SlotJobRequired to take a call
llmGenerates replies and decides when to call a toolyes
asrTranscribes the caller's speechyes
ttsSpeaks the agent's repliesyes
analysis_llmOptional second model for post-call analysis and QA, so heavy analysis never competes with the conversational modelno

A missing provider fails the call with 422. An agent without all three of llm, asr and tts is not "degraded" — the voice pipeline rejects the session up front with 422 instead of retrying forever. Configure all three, then publish, before you point a phone number at the agent.

Set a provider

POST https://your-infravoice-host/api/v1/agents/{id}/providers

FieldRequiredDescription
provider_typeyesOne of llm, asr, tts, analysis_llm, opener_llm. Validated: anything else returns 400 INVALID_PROVIDER_TYPE and writes nothing.
provider_nameyesVendor id from the catalog, e.g. openai, deepgram, elevenlabs.
api_keynoThe vendor key. Encrypted at rest; only ever read back masked. See the three write modes below.
credential_idnoLink an existing reusable workspace credential instead of sending a key.
save_as_credentialnoDefaults to true whenever a new api_key is sent — the key becomes a reusable credential automatically. Set false to keep it on this agent only.
credential_labelnoName for the auto-created credential. Worth setting; the default label is not self-explanatory in a long list.
base_urlnoOverride for OpenAI-compatible or self-hosted endpoints. Falls back to the linked credential's base_url. Must be a public http(s)/ws(s) address — a private, loopback or cloud-metadata host is rejected with 400 INVALID_BASE_URL.
model_namenoe.g. gpt-4o-mini, nova-2.
voice_idnoTTS voice identifier.
languagenoPer-provider language hint.
confignoFree-form object for vendor-specific options.

How the key field behaves — three distinct modes, and mixing them up is the usual cause of "my key disappeared":

  1. credential_id set — the effective key comes from that reusable workspace credential. A credential belonging to another workspace is rejected with 403 FORBIDDEN.
  2. api_key set — stored encrypted. Because save_as_credential defaults to true, it is normally also promoted to a workspace credential and linked, so the next agent can reuse it.
  3. api_key empty or omitted — the existing key or credential link is preserved. This is deliberate: it lets you change model_name or voice_id without re-sending the secret.

Response200 with the slot, key masked:

JSON
{
  "success": true,
  "data": {
    "provider_type": "llm",
    "provider_name": "openai",
    "model_name": "gpt-4o-mini",
    "base_url": "",
    "api_key": "********f3a1",
    "credential_id": "cred_9a2e",
    "credential_label": "OpenAI – prod",
    "config": {}
  },
  "error": null
}

Changing a model_name without touching the key:

Shell
curl -X POST https://your-infravoice-host/api/v1/agents/$AGENT/providers \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"provider_type":"llm","provider_name":"openai","model_name":"gpt-4o"}'

DELETE /api/v1/agents/{id}/providers/{type} clears a slot and returns {"message":"provider removed"}. Deleting llm, asr or tts from a published agent and republishing will break its calls.

analysis_llm is written exactly like the others. The analysis schema — the structured fields extracted from each conversation — is not configured here; it belongs to the conversation/QA surface. See Conversations. What lives on the agent is only the model that does the work, plus the live-call checklist.

Provider catalog

GET https://your-infravoice-host/api/v1/providers?type=tts lists the vendors a platform administrator has registered, with their model and language lists. It is a read-only, tenant-wide catalog containing no secrets — use it to populate a picker or to check a provider_name before you write it.

The catalog is optional, and on many deployments it is empty. It is seeded by platform administrators, not created by using the product, so GET /api/v1/providers legitimately answers "data": null and GET /api/v1/providers/{id} then has no id to be called with — every lookup returns 404 NOT_FOUND. Nothing on this page depends on it: POST /agents/{id}/providers takes a provider_name string, never a catalog id. Read the catalog if it is populated; do not build a required step on it. Catalog ids are plain UUIDs — the prv_… shape in older examples was never real, so do not validate on a prefix.

JSON
{
  "success": true,
  "data": [
    {
      "id": "9f1c4e2a-6b70-4a51-9d3e-0c2f5a7b8e11",
      "name": "elevenlabs",
      "provider_type": "tts",
      "description": "ElevenLabs neural voices",
      "base_url": "https://api.elevenlabs.io",
      "auth_type": "api_key",
      "is_active": true,
      "default_config": {},
      "supported_models": ["eleven_turbo_v2", "eleven_multilingual_v2"],
      "supported_languages": ["en", "es", "fr"]
    }
  ],
  "error": null
}

Two older endpoints, POST/GET /api/v1/providers/user-config, store a key against your account rather than an agent. /agents/{id}/providers plus credentials is the surface to build on.

POST /providers/user-config needs a provider_id from the shared provider catalog (GET /api/v1/providers) — it is a foreign key, not a free-form vendor name. An id that is not in the catalog returns 404 PROVIDER_NOT_FOUND, and on a deployment whose catalog has not been seeded the catalog is empty, so this endpoint cannot be used at all there. Use /agents/{id}/providers, which takes a provider_name and needs no catalog. Because a usf- key authenticates as your organization's owner, all key-authenticated calls share one user-config namespace.

Debugging what a slot resolves to

GET https://your-infravoice-host/api/v1/providers/resolve?type=llm&agent_id={id} answers "which vendor, model and auth type would actually be used?". It masks the api_key, and returns 404 NOT_CONFIGURED when nothing is set for that type.

JSON
{
  "success": true,
  "data": {
    "provider_type": "llm",
    "provider_name": "openai",
    "base_url": "",
    "api_key": "********f3a1",
    "model_name": "gpt-4o-mini",
    "auth_type": "bearer",
    "config": null,
    "source": "agent_providers"
  }
}

source tells you which store answered: agent_providers for a slot written by POST /agents/{id}/providers, user_config for the legacy per-account row. The per-agent slot wins when both exist, because that is the one the runtime reads. Pass agent_id — without it only the legacy store is consulted, and a modern agent has nothing there.

An empty base_url is not an error: it means the slot names no override and the runtime uses its own built-in endpoint for that provider_name. auth_type reports bearer unless the catalog holds an entry for that vendor saying otherwise.

Resolving is workspace-scoped like every other by-id read: an agent_id in another workspace returns 403, not the config.

Reusable credentials

Putting the same OpenAI key on twelve agents means rotating it twelve times. A workspace credential stores the secret once; agents link to it by credential_id, and a rotation propagates to all of them and busts each one's pipeline cache.

POST https://your-infravoice-host/api/v1/agents/credentials

FieldRequiredDescription
provider_typeyesllm, asr, tts or analysis_llm.
provider_nameyesVendor id, e.g. openai.
labelyesHuman name shown in the dashboard.
api_keynoThe secret. Write-only — encrypted at rest and never echoed back.
workspace_idnoDefaults to your primary workspace; membership enforced with 403.
base_url, model_name, voice_id, language, confignoDefaults inherited by every agent that links this credential.
Shell
curl -X POST https://your-infravoice-host/api/v1/agents/credentials \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"provider_type":"llm","provider_name":"openai","label":"OpenAI – prod","api_key":"sk-...","model_name":"gpt-4o-mini"}'
JSON
{
  "success": true,
  "data": {
    "id": "cred_9a2e",
    "workspace_id": "ws_2b91",
    "provider_type": "llm",
    "provider_name": "openai",
    "label": "OpenAI – prod",
    "model_name": "gpt-4o-mini",
    "masked_key": "********f3a1",
    "is_active": true,
    "created_at": "2026-07-26T10:20:03Z"
  },
  "error": null
}

Attach it to as many agents as you like — no key in the request body:

Shell
curl -X POST https://your-infravoice-host/api/v1/agents/$AGENT/providers \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"provider_type":"llm","provider_name":"openai","credential_id":"cred_9a2e"}'

PUT /api/v1/agents/credentials/{credID} rotates the secret for every linked agent at once. All fields are optional; omitting api_key (or sending it empty) keeps the current key, so you can rename a credential without handling the secret.

Shell
curl -X PUT https://your-infravoice-host/api/v1/agents/credentials/cred_9a2e \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"api_key":"sk-new-key-...","label":"OpenAI – prod (rotated Jul)"}'

GET /api/v1/agents/credentials requires workspace_id. Unlike most list endpoints it is not auto-filled — call it as ?workspace_id=ws_…&type=llm, or you get 400 MISSING_PARAM. Read workspace_id off any agent you already have. The same applies to GET /api/v1/agents/snippets. Listing returns masked_key only; there is no read path for the plaintext, by design.


Tools & knowledge

Four sub-resources shape what the agent knows and can do mid-call: the handbook (shared prompt text), custom tools (HTTP calls), knowledge bases, and the live-call checklist.

These endpoints replace, they don't append. handbook.snippet_ids, checklist.pointers and agent.handbook_snippet_ids all overwrite the existing list. Read, modify, write.

Prompt snippets & handbook

system_prompt is the whole instruction set in the simple case. When several agents must share the same rules — compliance language, escalation policy, brand voice — put those paragraphs in snippets and attach them as a handbook. Snippet bodies are prepended to the system prompt, in the order you list them, at the start of each session.

Create with title, body and an optional category; workspace_id defaults to your primary workspace.

Shell
curl -X POST https://your-infravoice-host/api/v1/agents/snippets \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"Escalation policy","category":"policy","body":"If the caller mentions a legal complaint, do not answer — transfer to a human immediately."}'

Listing merges your snippets with platform-shipped ones. Those carry is_seed: true and are read-only: editing or deleting one returns 403 READ_ONLY. Copy a seed into a new snippet if you need to change its wording.

Shell
curl -X PUT https://your-infravoice-host/api/v1/agents/$AGENT/handbook \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"snippet_ids":["snp_escalation","snp_brand_voice"]}'
JSON
{
  "success": true,
  "data": {
    "agent_id": "agt_0d5f7c1a",
    "snippets": [
      { "id": "snp_escalation", "title": "Escalation policy", "body": "If the caller mentions …", "category": "policy", "is_seed": false },
      { "id": "snp_brand_voice", "title": "Brand voice", "body": "Warm, concise, never salesy.", "category": "style", "is_seed": true }
    ],
    "missing_ids": []
  },
  "error": null
}

missing_ids lists attached ids whose snippet has since been deleted — a deleted snippet does not silently vanish from the agent, it shows up here so you can notice. Order in snippet_ids is the concatenation order into the prompt; null or an omitted list is treated as empty. This endpoint is equivalent to sending handbook_snippet_ids on PUT /agents/{id}.

Custom tools

A tool lets the agent do something mid-call: look up an order, book a slot, write to your CRM. The contract:

  1. The function_schema you provide is handed to the LLM as a callable function. Its description and per-parameter descriptions are the only thing the model has to go on — write them for a reader who cannot see your API.
  2. Mid-call, the model decides to call the tool and produces arguments matching that schema.
  3. InfraVoice issues an HTTP request to endpoint_url using http_method, your headers, and the credential implied by auth_type / auth_token, shaping the body from request_body.
  4. response_path points at the part of the JSON payload worth handing to the model, so a large payload doesn't flood the conversation.
  5. That value goes back into the conversation and the agent speaks its next turn.

Because a caller is waiting through step 3, timeout_ms is a latency decision, not just a safety net: keep it tight (a second or two) and have your endpoint return something the agent can say rather than something it has to wait for. retry_count multiplies that wait, so use it only for genuinely idempotent calls.

FieldRequiredDescription
nameyesFunction name the model calls. Keep it snake_case and descriptive.
descriptionyesWhen the model should use this tool. The most important field on the object.
function_schemanoJSON Schema of the function's parameters, in the standard function-calling shape.
endpoint_urlnoThe HTTPS endpoint InfraVoice calls.
http_methodnoGET, POST, …
headersnoStatic headers sent with every invocation. Headers that would impersonate an internal caller (X-Internal-Service, Host, Cookie, Connection, Content-Length, Transfer-Encoding, Upgrade) are stripped before the request is sent.
auth_typenoHow auth_token is presented to your endpoint.
auth_tokennoSecret. Encrypted at rest, returned masked.
request_bodynoBody template sent to your endpoint.
response_pathnoPath into the JSON response selecting the value handed back to the model.
timeout_msnoPer-invocation timeout. The caller hears this as silence.
retry_countnoRetries on failure.
connector_bindingnoBinds the tool to a managed connector instead of a raw URL.
is_activeno (update only)Disable a tool without deleting it.
Shell
curl -X POST https://your-infravoice-host/api/v1/agents/$AGENT/tools \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "lookup_order",
    "description": "Look up the status of a customer order by its order number. Use this whenever the caller asks where their order is.",
    "function_schema": {
      "type": "object",
      "properties": {
        "order_number": { "type": "string", "description": "The order number, e.g. AC-10293" }
      },
      "required": ["order_number"]
    },
    "endpoint_url": "https://api.acme.com/v1/orders/status",
    "http_method": "POST",
    "auth_type": "bearer",
    "auth_token": "acme_live_...",
    "request_body": { "order": "{{order_number}}" },
    "response_path": "data.status",
    "timeout_ms": 2500,
    "retry_count": 0
  }'
JSON
{
  "success": true,
  "data": {
    "id": "tool_44b1",
    "agent_id": "agt_0d5f7c1a",
    "name": "lookup_order",
    "description": "Look up the status of a customer order …",
    "endpoint_url": "https://api.acme.com/v1/orders/status",
    "http_method": "POST",
    "auth_type": "bearer",
    "auth_token": "********e_...",
    "response_path": "data.status",
    "timeout_ms": 2500,
    "retry_count": 0,
    "is_active": true
  },
  "error": null
}

PUT /api/v1/agents/{id}/tools/{toolID} takes the same fields, all optional, plus is_active. DELETE returns {"message":"tool deleted"}.

Knowledge bases

Knowledge bases are created and populated on the Knowledge Base surface; here you only bind an existing one to an agent and choose how its content is delivered into the conversation.

Shell
curl -X POST https://your-infravoice-host/api/v1/agents/$AGENT/knowledge-bases \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"knowledge_base_id":"kb_7f21","mode":"system_context"}'
JSON
{
  "success": true,
  "data": {
    "id": "akb_5c30",
    "agent_id": "agt_0d5f7c1a",
    "knowledge_base_id": "kb_7f21",
    "mode": "system_context",
    "is_active": true,
    "created_at": "2026-07-26T10:31:44Z",
    "updated_at": "2026-07-26T10:31:44Z"
  },
  "error": null
}

mode has exactly two values, and it is the only field here that can be wrong:

modeDelivery
system_context (default when omitted)The knowledge base's content is folded into the agent's system prompt for every turn.
toolThe agent is given a lookup tool and queries the knowledge base only when it decides it needs to.

Anything else returns 400 INVALID_MODE.

{kbID} is the binding id, not the knowledge-base id. Update and detach take the id returned by the attach call (akb_… above), not knowledge_base_id. Using the KB id there is the most common 404 on this resource.

PUT accepts mode and is_active, both optional — flipping is_active to false is the way to test whether a KB is helping or hurting without losing the binding. DELETE returns {"message":"knowledge base detached"}.

Live-call checklist

A checklist is an ordered list of things the agent is supposed to accomplish on a call — verify identity, confirm the address, offer the upgrade. During a live call, a team leader watching the monitor sees each pointer tick off as it is satisfied. PUT replaces the whole list; a pointer sent without an id gets a generated uuid, so keep ids stable when editing an existing checklist and historical calls stay comparable.

Shell
curl -X PUT https://your-infravoice-host/api/v1/agents/$AGENT/checklist \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"pointers":[
    {"label":"Verified caller identity","hint":"Full name and order number confirmed"},
    {"label":"Explained the resolution","hint":"Caller repeated it back or agreed"},
    {"label":"Offered the callback option"}
  ]}'
JSON
{
  "success": true,
  "data": {
    "agent_id": "agt_0d5f7c1a",
    "pointers": [
      { "id": "9f1c…", "label": "Verified caller identity", "hint": "Full name and order number confirmed" },
      { "id": "2a70…", "label": "Explained the resolution", "hint": "Caller repeated it back or agreed" },
      { "id": "c418…", "label": "Offered the callback option", "hint": "" }
    ],
    "is_active": true,
    "created_at": "2026-07-26T10:35:00Z",
    "updated_at": "2026-07-26T10:35:00Z"
  },
  "error": null
}

GET returns an empty pointers array when nothing is configured, rather than a 404.


Telephony binding

The per-agent telephony sub-resource decides which number the agent answers on and what happens when it hands a caller to a human.

FieldDescription
twilio_account_sidTwilio account the number belongs to.
twilio_auth_tokenSecret — encrypted at rest, always returned masked.
twilio_phone_numberThe number in E.164, e.g. +14155550123.
webhook_urlWhere telephony events are delivered.
call_directioninbound (default), outbound or both. Lowercase — "INBOUND" is rejected with 400 INVALID_TELEPHONY.
human_transfer_numberThe number a caller is transferred to in phone mode. E.164.
human_transfer_modephone (default — dial a number) or panel (route to a live agent in the agent panel). Any other value returns 400 INVALID_TELEPHONY.
panel_project_idRequired for panel mode: the project whose human agents receive the call. Must belong to your workspace; a foreign project id is rejected.
transcribe_human_segmentDefault false. Transcribe the portion of the call after a human takes over.
generate_call_summaryDefault true. Produce a whole-call summary when the call ends.
Shell
curl -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  https://your-infravoice-host/api/v1/agents/$AGENT/telephony

GET returns 404 NOT_FOUND when the agent has no telephony row yet — expected for a brand-new agent, not an error to alarm at. As everywhere on this page, the change is a draft until you publish. For number provisioning and call control see the Telephony API.


Flow mode & tests

Instead of one system prompt, an agent can run a flow: a graph of nodes (welcome, conversation, question, decision, tool call, variable, transfer, end) with edges between them. The agent's mode decides which engine runs.

Flows live under the agent. Every flow route is a sub-resource of /api/v1/agents/{id} — there is no top-level flows collection to call.

Save and validate a flow

GET /flow never 404s: an agent with no flow yet returns an empty version-0 graph, so a builder UI has something to render. PUT /flow stores a new version each time (MAX(version)+1) and rejects an empty node list with 400 EMPTY_FLOW, so a blank canvas can never silently replace a working flow. An invalid graph, though, does save — is_valid simply comes back false with validation_errors populated, which is why you should call /flow/validate first:

Shell
curl -X POST https://your-infravoice-host/api/v1/agents/$AGENT/flow/validate \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"graph":{"nodes":[{"id":"n1","type":"welcome","position":{"x":0,"y":0},"data":{"text":"Hi, Acme here."}}],"edges":[],"start_node_id":"n1"},"variables":[]}'
JSON
{
  "success": true,
  "data": {
    "is_valid": false,
    "errors": [
      { "node_id": "n1", "edge_id": "", "code": "NO_OUTGOING_EDGE", "message": "welcome node has no outgoing edge" }
    ]
  },
  "error": null
}
CodeMeaning
NO_START_NODEstart_node_id is missing or points at nothing.
INVALID_SOURCE / INVALID_TARGETAn edge references a node that isn't in the graph.
NO_OUTGOING_EDGEA welcome, conversation, question, decision, tool_call or variable node is a dead end.
UNREACHABLEA non-terminal node has no inbound edge. Terminal types are transfer and end.
MISSING_FIELDwelcome/conversation without text or system_prompt, question without question, tool_call without tool_id.

Switching mode is a separate call — saving a flow does not activate it:

Shell
curl -X PUT https://your-infravoice-host/api/v1/agents/$AGENT/mode \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"mode":"flow"}'

Any value other than single_prompt or flow returns 400 INVALID_MODE. Then publish a version, as usual.

Simulation tests

Before publishing a prompt change you can run the agent against scripted personas. Test cases are stored per agent; a run executes some or all of them and is dispatched to the simulator asynchronously. A test case takes name (required), plus persona, goal, assertions[], max_turns (default 20) and tags[]. Updates apply only the non-empty fields you send.

Shell
curl -X POST https://your-infravoice-host/api/v1/agents/$AGENT/test-runs \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"agent_version":3,"case_ids":[]}'

An empty case_ids runs every case. An agent with no test cases returns 400 NO_CASES. Poll GET /api/v1/agents/{id}/test-runs (limit defaults to 20, with meta pagination) for status.


Errors

Errors use the same envelope with success: false: {"success":false,"data":null,"error":{"code":"…","message":"…"}}.

HTTPCodeMeaning
400MISSING_FIELDSname or system_prompt missing on agent create.
400MISSING_PARAMA required query parameter is absent — most often workspace_id on the credentials and snippets list endpoints.
400EMPTY_FLOWPUT /flow with an empty node list.
400INVALID_MODEOn the agent: mode was neither single_prompt nor flow. On a knowledge-base binding: mode was neither system_context nor tool.
400INVALID_PROVIDER_TYPEprovider_type on a provider slot was not one of llm, asr, tts, analysis_llm, opener_llm.
400INVALID_TELEPHONYPUT /telephony with an unsupported call_direction or human_transfer_mode, or panel mode without panel_project_id.
400INVALID_BASE_URLA provider or credential base_url that is malformed or points at a private/internal address.
400invalid_versionA non-integer version number in a version path.
400NO_CASESA test run was started for an agent with no test cases.
401Missing or malformed Authorization header, or an unknown/revoked usf- key.
403FORBIDDENYou are not a member of the target workspace, or you linked a credential_id from another workspace.
403READ_ONLYAttempt to edit or delete a platform seed snippet.
404NOT_FOUNDThe id does not exist or belongs to another workspace — foreign ids are deliberately indistinguishable from missing ones. Also returned by GET /telephony before any telephony row exists, and by PUT /knowledge-bases/{kbID} when {kbID} is not one of this agent's binding ids.
404PROVIDER_NOT_FOUNDPOST /providers/user-config with a provider_id that is not in the shared provider catalog.
404NOT_CONFIGUREDGET /providers/resolve found nothing configured for that type.
422Raised by the voice pipeline, not this API: a call was attempted for an agent missing one of llm, asr or tts. Fix the provider slots and publish.