# AI agents

An AI agent is a [voice app](/guides/voice-apps.md) of kind `ai_agent`. Its `agent` object (instructions, voice, models,
tools, transfers, ...) is the draft; create and edit it with `POST /voice-apps` and `PATCH /voice-apps/{id}`.
The endpoints on this page publish versions, split traffic between them, test agents, and read their
conversations, stats and analytics. A second group, AI providers, covers provider keys, voices and voice
clones, models and prices, tool tests and integrations.

Auth, errors, paging and idempotency work as described in the [overview](/guides/overview.md). Every endpoint here needs
scope `apps:read` (GET) or `apps:write` (everything else).

Base URL: `https://api.contactivity.io/v1/projects/{project_id}`

| Method | Path | Purpose |
|---|---|---|
| GET | `/ai/agents/{app_id}/versions` | List published versions, traffic split, draft state |
| POST | `/ai/agents/{app_id}/versions` | Publish the draft (runs required tests first) |
| GET | `/ai/agents/{app_id}/versions/diff?from=&to=` | Compare two versions or the draft |
| GET | `/ai/agents/{app_id}/versions/{version}` | Fetch a version with its config |
| POST | `/ai/agents/{app_id}/versions/{version}/rollback` | Publish an older version again |
| PUT | `/ai/agents/{app_id}/traffic` | A/B split between published versions |
| GET, POST | `/ai/agents/{app_id}/tests` | List / create test scenarios |
| PATCH, DELETE | `/ai/agents/{app_id}/tests/{id}` | Update / archive a scenario |
| POST, GET | `/ai/agents/{app_id}/tests/runs` | Run scenarios / list runs |
| GET | `/ai/agents/{app_id}/tests/runs/{run_id}` | Fetch a run with its transcript |
| GET | `/ai/agents/{app_id}/calls` | An agent's conversations |
| GET | `/ai/calls/{call_id}` | One conversation with its transcript |
| GET | `/ai/agents/{app_id}/stats?days=` | One agent's totals |
| GET | `/ai/agents/stats?days=` | Totals of every agent |
| GET | `/ai/analytics?from=&to=&voice_app_id=` | Analytics dashboard data |
| GET | `/ai/keys` | Provider keys (state only) |
| PUT, DELETE | `/ai/keys/{provider}` | Set / archive a provider key |
| GET | `/ai/options` | Providers, models, languages and a default agent config |
| GET | `/ai/rates` | AI prices |
| GET | `/ai/usage` | Billed agent usage |
| GET | `/ai/voices?provider=` | Voices of a TTS provider |
| GET | `/ai/voices/{id}/preview` | Audio sample of a voice |
| POST | `/ai/voices/clones/consent` | Consent phrase for a voice clone |
| GET, POST | `/ai/voices/clones` | List / create cloned voices |
| DELETE | `/ai/voices/clones/{id}` | Archive a cloned voice |
| POST | `/ai/tools/test` | Send a signed test call to a tool webhook |
| GET, POST | `/ai/integrations` | List / create integrations |
| PATCH, DELETE | `/ai/integrations/{id}` | Update / archive an integration |
| GET | `/ai/integrations/{id}/tools` | Tools an integration gives an agent |

Money fields (`cost`, `llm_cost`, `speech_cost`, clone `price`) are money objects
`{"amount": "0.0130", "currency": "USD"}`, except in `/ai/options`, which lists plain USD numbers (`/ai/rates` uses money objects with 6 decimals).

## The agent config

The `agent` object of the voice app. Defaults are filled on save, so a stored config is complete. A sub-object
(`llm`, `tts`, `stt`, `turn`, `idle`) given in part keeps the defaults of the fields left out. Unknown fields
are refused. GET `/ai/options` returns a complete default config under `defaults`.

| Field | Type | Notes |
|---|---|---|
| `instructions` | string, required | The system prompt, up to 20000 characters |
| `greeting` | string | Up to 1000; required when `first_message_mode` is `assistant` |
| `first_message_mode` | string | `assistant` (speak the greeting), `assistant_generated`, `user` (wait for the caller) |
| `personality` | string | `none`, `professional`, `friendly`, `concise`, `empathetic`, `energetic` |
| `voice` | string | The TTS provider's voice id (see `/ai/voices`); empty: the default |
| `language` | string | `en` or `en-US` form |
| `auto_language` | boolean | Reply in the caller's language (multilingual transcriber and voice needed) |
| `model` | string | LLM model id of `llm.provider` (an enabled model in `/ai/options`) |
| `llm` | object | `provider` (`openai`, `anthropic`, `google`, `groq`; default `openai`), `temperature` 0-2 (0.7), `max_tokens` 32-4096 (250), `fallbacks` (up to 3 `{provider, model}`) |
| `tts` | object | `provider` (`cartesia`, `elevenlabs`, `openai`, `deepgram`; default `deepgram`), `model` (default `aura-2-thalia-en`), `speed`, `volume` 0.5-2, `instructions` (OpenAI style prompt, up to 500) |
| `stt` | object | `provider` (`deepgram`, `openai`, `elevenlabs`, `parakeet`; default `deepgram`), `model` (`nova-3`), `keyterms` (up to 50), `smart_format`, `filler_words`, `endpointing_ms` 10-1000 |
| `turn` | object | `detection` (`model`, `vad`, `stt`), `min_endpointing_ms` 0-5000, `max_endpointing_ms` 500-20000, `allow_interruptions`, `min_interruption_ms` 100-5000, `min_interruption_words` 0-10, `vad_threshold` 0.1-0.9, `vad_min_silence_ms` 100-3000, `preemptive_generation` |
| `idle` | object | `timeout_secs` 5-120 (15), `message`, `max_prompts` 0-5 (2), `end_message` (messages up to 300) |
| `background_sound` | string | `none`, `office`, `city`, `crowded_room`, `keyboard` |
| `end_call_phrases` | string[] | Up to 10; a reply containing one ends the call |
| `tools` | array | Up to 20; see below |
| `transfers` | array | Up to 10 `{name, description, type: number|extension|sip_uri, target}` for the built-in `transfer_call` tool |
| `transfer_mode` | string | `blind` or `warm` (the person hears a summary first) |
| `max_duration_secs` | integer | 30-3600, default 600 |
| `recording`, `noise_suppression`, `summary` | boolean | Record; denoise the caller; AI summary after the call |
| `extraction` | object | JSON Schema (type object, up to 8 KB) of data extracted after the call |
| `success_criteria` | string | Up to 2000; each call is evaluated against it |
| `voicemail` | object | `{action: hangup|message, message}` for outbound calls answered by a machine |
| `knowledge_base_ids` | string[] | Up to 5 [knowledge bases](/guides/knowledge-bases.md) |
| `pii_redaction` | boolean | Redact personal data in transcripts, summaries and data (default on) |
| `recording_consent` | object | `{enabled, message}`: a consent message spoken first |
| `mcp_servers` | array | Up to 5 `{integration_id, allowed_tools}` of `mcp` integrations |
| `crm_sync` | object | `{integration_id}` of a HubSpot or GoHighLevel integration; gets a note after each call |

A tool (`tools[]`): `name` (unique snake_case, not `end_call`, `transfer_call` or `search_knowledge`),
`description` (up to 1000), `kind` (`webhook` default, `automation`, `integration`), `url` (public http(s), a
signed POST like webhooks), `method` (`POST`), `headers`, `parameters` (JSON Schema, type object, up to 8 KB),
`timeout_secs` 1-30, `speak_before` (up to 200, said while the tool runs). `automation` adds `provider`
(`zapier`, `make`, `n8n`); `integration` uses `integration_id` and `action` (from `/ai/integrations/{id}/tools`)
and no url or headers.

## Versions and traffic

Publishing snapshots the draft as the next version (1, 2, 3, ...) and sends all calls to it. An agent never
published answers with its draft. Version object: `version`, `note`, `author` (user id or `api-key`),
`author_email`, `created_at`; fetch adds `config`.

```sh
# Publish (runs the agent's text test scenarios first; a failing required one refuses the publish)
curl https://api.contactivity.io/v1/projects/$PROJECT_ID/ai/agents/app_us1_abc/versions \
  -H "Authorization: Bearer $CONTACTIVITY_API_KEY" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
  -d '{"note": "Shorter greeting"}'
```

Body (optional): `note` (up to 500 characters), `skip_tests` (publish even when a required test fails). Returns
`201` with the version, plus `test_runs` when tests ran. Errors: `400 invalid_parameter` (`note`),
`400 unknown_parameter`, `400 invalid_json`, `404 not_found`, `409 conflict` (a required test did not pass; the
error carries `test_runs`).

`GET /ai/agents/{app_id}/versions` is paged, newest first, and also carries `traffic` (`[{version, weight}]`) and
`draft_changed`. `GET .../versions/{version}` returns one version with its `config`.

`POST .../versions/{version}/rollback` publishes that version's config as a new version (no tests; default note
`Rollback to version N`). Returns `201`.

`GET .../versions/diff?from=3&to=draft` returns `{from, to, changes: [{path, from, to}]}` with dotted paths such
as `tts.speed`; lists are compared whole. `from` and `to` are version numbers or `draft`.

Split traffic between one or two published versions (weights 1-100, adding up to 100); each call picks one from
a hash of its id:

```sh
curl -X PUT https://api.contactivity.io/v1/projects/$PROJECT_ID/ai/agents/app_us1_abc/traffic \
  -H "Authorization: Bearer $CONTACTIVITY_API_KEY" -H "Content-Type: application/json" \
  -d '{"split": [{"version": 3, "weight": 90}, {"version": 4, "weight": 10}]}'
```

Returns `{split}` sorted by version. Errors: `400 invalid_parameter` (bad split, or a version not published),
`404 not_found`.

## Tests

A test scenario is a simulated caller (`persona`, `goal`) and `criteria` an LLM judges. `text` runs let an LLM
play the caller against the agent's own prompt, model and tools (mocked with `tool_mocks`, or the real
webhooks with `live_tools`). `voice` runs place a real call to the agent. LLM tokens of runs are charged like
agent calls.

Scenario fields:

| Field | Type | Required | Limits |
|---|---|---|---|
| `name` | string | yes | 1-100 |
| `persona` | string | yes | 1-4000 |
| `goal` | string | no | up to 2000 |
| `criteria` | string[] | yes | 1-10 items, each 1-500 |
| `max_turns` | integer | no | 1-30, default 10 |
| `required` | boolean | no | A failing run blocks publish |
| `live_tools` | boolean | no | Call the real tool webhooks |
| `tool_mocks` | object | no | Up to 20 `tool_name: result` (result up to 4000) |

```sh
curl https://api.contactivity.io/v1/projects/$PROJECT_ID/ai/agents/app_us1_abc/tests \
  -H "Authorization: Bearer $CONTACTIVITY_API_KEY" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
  -d '{"name": "Books a cleaning", "persona": "Busy parent, wants Tuesday morning",
       "criteria": ["The agent offers a Tuesday morning time", "The agent confirms the booking"], "required": true}'
```

`POST` returns `201` (at most 20 scenarios per agent: `409 conflict`). `PATCH .../tests/{id}` updates the fields
given (it accepts and ignores the read-only `id`, `created_at`, `updated_at`, `last_run`). `DELETE` archives the scenario
(its runs are kept) and returns `204`. The list is paged, newest first, each scenario with `last_run`.

Run them:

```sh
curl https://api.contactivity.io/v1/projects/$PROJECT_ID/ai/agents/app_us1_abc/tests/runs \
  -H "Authorization: Bearer $CONTACTIVITY_API_KEY" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
  -d '{"kind": "text"}'
```

Body (optional): `scenario_ids` (default all), `kind` (`text` default, or `voice` with `from`, a number of this
project, and `to`, the number routed to the agent, both E.164). Returns `202` with the queued runs as one page.
Errors: `400 invalid_parameter` (`kind`, `from`, `to`, `scenario_ids`; the agent has no scenarios), `404 not_found`.

Run object: `id` (`trun_…`), `scenario_id`, `scenario_name`, `required`, `kind`, `trigger` (`manual`, `publish`),
`status` (`queued`, `running`, `passed`, `failed`, `error`), `score` (0-100), `results`
(`[{criterion, pass, reason}]`), `summary`, `call_sid`, `params`, `cost`, `error`, `created_at`, `finished_at`.
`GET .../tests/runs` lists them (paged); `GET .../tests/runs/{run_id}` adds `transcript` and `llm_usage`.

## Conversations, stats, analytics

`GET /ai/agents/{app_id}/calls` lists an agent's conversations, newest first (paged). Each row: `call_sid`,
`session_sid`, `voice_app_id`, `agent_version` (null: the draft), `from`, `to`, `seconds`, `billed_secs`,
`model`, `byok`, `cost`, `llm_cost`, `speech_cost`, `ended_reason`, `summary`, `data` (extracted), the STT/TTS
providers and models, `latency`, `eval_success`, `eval_sentiment`, `eval_reason`, `created_at`.
`GET /ai/calls/{call_id}` returns one with its `transcript`.

```sh
curl "https://api.contactivity.io/v1/projects/$PROJECT_ID/ai/agents/app_us1_abc/calls?limit=20" \
  -H "Authorization: Bearer $CONTACTIVITY_API_KEY"
```

`GET /ai/agents/{app_id}/stats?days=30` (1-365, default 30): `calls`, `minutes`, `cost`, `avg_seconds`,
`ended_reasons` (`reason: count`) and `by_version`. An unknown agent gives zeros.
`GET /ai/agents/stats?days=30`: one row per agent with calls (`voice_app_id`, `calls`, `minutes`, `cost`), one page.

`GET /ai/analytics?from=&to=&voice_app_id=` (RFC 3339; default the last 30 days, at most 366): `totals` (calls,
minutes, cost, avg_seconds, evaluated, success_rate, transfer_rate, sentiment), `latency` (p50/p95 ms by
component), `end_reasons`, `daily`, `by_agent`, `by_version` (with `voice_app_id`). Days are UTC.
Errors: `400 invalid_parameter` (`from`, `to`).

## AI providers

### Provider keys

Bring your own key for `openai`, `anthropic`, `google`, `groq`, `deepgram`, `cartesia` or `elevenlabs`; the
project's agents then use it instead of the platform's. The API never returns a key.

```sh
curl -X PUT https://api.contactivity.io/v1/projects/$PROJECT_ID/ai/keys/deepgram \
  -H "Authorization: Bearer $CONTACTIVITY_API_KEY" -H "Content-Type: application/json" \
  -d '{"key": "dg_xxxxxxxxxxxxxxxxxxxxxxxx"}'
```

`key`: 20-300 characters, no whitespace (OpenAI: `sk-…`). The key is checked with one request to the provider.
Returns `{provider, configured: true, last4}`. `GET /ai/keys` lists every provider (`provider`, `configured`,
`last4`, `created_at`) as one page. `DELETE /ai/keys/{provider}` archives the key (no longer
used; a new one can be set) and returns `204`; `GET /ai/keys?archived=true` lists archived keys.
Errors: `400 invalid_parameter` (`key`), `404 not_found` (unknown provider).

### Options, rates, usage

- `GET /ai/options`: providers (with your key state), enabled LLM/STT/TTS models with prices, languages,
  personalities, background sounds, first-message modes, and `defaults` (a complete agent config).
- `GET /ai/rates`: `currency`, `voice_engine` (per-minute parts and `bundle_per_min`), `llm_markup_pct`, `models`
  (per-million-token prices and `est_per_min`), `rates_per_min`. Prices are money objects with 6 decimals;
  `llm_markup_pct` is a number.
- `GET /ai/usage`: billed agent conversations, newest first (paged; same row fields as agent calls without
  `from`, `to`, `summary`, `data`).

### Voices

`GET /ai/voices?provider=cartesia|elevenlabs|openai|deepgram` (default `cartesia`) returns the provider's voices
`{id, name, description, language, gender}`, your cloned voices first (`cloned: true`), as one page.
ElevenLabs lists the voices of your own key when you set one. Use `id` as `agent.voice`.

`GET /ai/voices/{id}/preview?provider=&text=&language=` returns audio (`audio/wav`; `audio/mpeg` for ElevenLabs)
of the voice saying `text` (up to 300 characters; `language` two letters, default `en`). At most 20 new previews
per minute per project.

```sh
curl "https://api.contactivity.io/v1/projects/$PROJECT_ID/ai/voices/aura-2-thalia-en/preview?provider=deepgram" \
  -H "Authorization: Bearer $CONTACTIVITY_API_KEY" -o preview.wav
```

Errors: `400 invalid_parameter` (`provider`, `id`, `text`, `language`), `429 rate_limit`,
`502 upstream_error`, `503 service_unavailable` (no key for the provider).

### Voice clones

Cloning needs the speaker's recorded consent.

1. `POST /ai/voices/clones/consent` with `{"speaker_name": "Jane Doe"}` returns `{consent_id, speaker_name,
   phrase, code, expires_at}`. The phrase includes a 6-digit code and is valid 30 minutes (20 phrases per hour).
2. The speaker reads the phrase aloud; record it as a WAV of 2-60 seconds (`consent_clip`).
3. `POST /ai/voices/clones` as `multipart/form-data`:

| Field | Required | Notes |
|---|---|---|
| `provider` | no | `cartesia` (default) or `elevenlabs` |
| `name` | yes | Up to 100 characters |
| `description` | no | Up to 500 |
| `language` | no | Two letters, default `en` |
| `attest` | yes | `true`: you confirm the speaker consented |
| `consent_id` | yes | From step 1 (5 attempts each) |
| `sample` | yes | WAV, MP3 or M4A, 10 seconds to 5 minutes, up to 20 MB |
| `consent_clip` | yes | WAV, 2-60 seconds, up to 5 MB |

```sh
curl https://api.contactivity.io/v1/projects/$PROJECT_ID/ai/voices/clones \
  -H "Authorization: Bearer $CONTACTIVITY_API_KEY" \
  -F name="Jane" -F attest=true -F consent_id=vcc_us1_abc \
  -F sample=@jane.wav -F consent_clip=@consent.wav
```

The clone price is
charged to the balance. Returns `201` with `{id, provider, voice_id, name, description, language, speaker_name,
consent_text, attested_by, byok, price, created_at}`; use `voice_id` as `agent.voice`. At most 50 clones per project.

Errors: `400 invalid_parameter` (a field above; 50-clone limit), `402 insufficient_balance` (with `required`,
`balance`), `422 invalid_parameter` (the consent clip does not match the phrase: the error carries `transcript`
and `phrase`; or the provider rejected the sample), `429 rate_limit` (too many attempts with one phrase),
`502 upstream_error`, `503 service_unavailable`.

`GET /ai/voices/clones` lists them (one page). `DELETE /ai/voices/clones/{id}` archives the voice (no longer offered;
the provider copy is removed) and returns `204`; `409 conflict` (with `agents`) while an agent uses it.

### Tool test

`POST /ai/tools/test` sends the signed request an agent sends for a tool call and reports the answer:

```sh
curl https://api.contactivity.io/v1/projects/$PROJECT_ID/ai/tools/test \
  -H "Authorization: Bearer $CONTACTIVITY_API_KEY" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
  -d '{"url": "https://example.com/tools/lookup", "tool": "lookup_order", "arguments": {"order_id": "A-17"}}'
```

Body: `url` (required, public http(s)), `tool` (snake_case, default `test_tool`), `headers`, `arguments` (object).
Returns `{status, duration_ms, body}` (`status` 0 when the request failed; `body` is the first 4 KB).
Errors: `400 invalid_parameter` (`url`, `tool`, `arguments`, `headers`).

### Integrations

Credentials an agent uses through tools (`tools[].kind: integration`, `mcp_servers`) and `crm_sync`. Calls to them
run in the API, so the secrets never reach the agent. At most 50 per project.

| `kind` | `config` | `secrets` |
|---|---|---|
| `mcp` | `url` (required: the server's Streamable HTTP endpoint) | `headers` |
| `calcom` | `event_type_id` (required) | `api_key` (required) |
| `google_calendar` | `calendar_id` (required) | `service_account_json` (required) |
| `hubspot` | | `api_key` (private app token, required) |
| `gohighlevel` | `location_id` (required), `calendar_id` | `api_key` (private integration token, required) |

Every kind also takes `config.time_zone` (IANA, default `America/New_York`) and `config.duration_mins` (5-480,
default 30). `name` is required (up to 80).

```sh
curl https://api.contactivity.io/v1/projects/$PROJECT_ID/ai/integrations \
  -H "Authorization: Bearer $CONTACTIVITY_API_KEY" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
  -d '{"kind": "calcom", "name": "Front desk", "config": {"event_type_id": 12345}, "secrets": {"api_key": "cal_live_xxxxxxxx"}}'
```

Returns `201` with `{id, kind, name, config, secrets, created_at, updated_at}`; `secrets` holds hints only
(`api_key: "…1234"`, header hints, `service_account_email`). `PATCH /ai/integrations/{id}` replaces the fields
given (`kind` cannot change; a header set to `""` is removed). `DELETE` archives it (agents stop using it) and returns `204`,
or `409 conflict` while an agent uses it. `GET /ai/integrations` is paged. `GET /ai/integrations/{id}/tools` lists the tools it gives an agent
(`name`, `description`, `parameters`; an MCP server is asked live), one page.

Errors: `400 invalid_parameter` (`kind`, `name`, `config.*`, `secrets.*`), `400 unknown_parameter`,
`400 invalid_json`, `404 not_found`, `409 conflict`, `502 upstream_error` (MCP server failed).
