<!-- UiriX Public API v1 — human documentation, 26 pages, generated 2026-09-30T20:48:25.171Z. Read START_HERE first. Contract: /api/ext/v1/openapi.yaml -->


<!-- ===== START_HERE.md ===== -->

# Start here — how a UiriX agent works, and the order things happen in

Read this page before the reference pages. It explains the model behind the API: what an agent is
made of, which step feeds which, and which endpoint does each step. Every endpoint named here has
its full contract in the linked page. Base URL, auth and conventions: [README](README.md).

**Download the documentation from the API itself** (no credential needed): `GET {base}/docs/download` — a zip of every page plus `openapi.yaml`; `GET {base}/docs/all.md` — every page in one markdown file; `GET {base}/docs/pages` — the page index, `GET {base}/docs/pages/START_HERE.md` — one page; `GET {base}/openapi.yaml` / `openapi.json` — the contract. On dev: `https://dev-dashboard.uirix.com/api/ext/v1/docs/download`.

## The model in one paragraph

An **agent** is a phone / chat / voice-chat assistant that belongs to one **account**. It carries a
**knowledge summary** (the only knowledge it reads at runtime, built from the documents and pages
you give it), three **instruction prompts** (one per surface: inbound phone, chat, voice chat),
**greetings**, a **voice**, a set of **capabilities** (the tools it may call: leads, tickets,
orders, transfers, links…), and the owner's **refinement rules** ("Improve agent"). Knowledge and
instructions are **built**: you change inputs, then rebuild; the built outputs are what the agent
runs. Conversations, calls, webhooks and metrics sit on top of that.

## The lifecycle, in order

| Step | What happens | Endpoint(s) | Page |
|---|---|---|---|
| 1. Account | A tenant exists with a balance and quotas; keys are issued to it. | `GET /accounts`, `GET /accounts/{acc}/limits`, keys via `POST /users/{usr}/credentials` (master key) | [Accounts & Users](accounts-and-users.md), [Authentication](authentication.md) |
| 2. Create the agent | The only creation path is the V4 build: give a website URL and a voice, the platform crawls the site, summarises it, writes the greeting and the three prompts. Poll the build steps until done. | `POST /accounts/{acc}/agents/build` → `GET /agents/{agt}/build` (steps), `POST /agents/{agt}/build` (re-run from a step) | [Agents → Creating](agents.md#creating-an-agent--only-the-v4-build) |
| 3. Knowledge base | Add what the agent should know: upload files, post text, crawl one page or a whole site. Every document starts `processing` and becomes `indexed`. | `POST /agents/{agt}/kb/documents` (file / text / url), `GET /agents/{agt}/kb/documents` | [Agents → Knowledge base](agents.md#knowledge-base) |
| 4. "Keep as is" documents | A guide whose exact wording matters (button names, steps, prices, FAQ answers) can be excluded from the summariser: its full text then follows the summary in the prompt, on every surface. Flag it on create, with `PATCH`, or by opening the text with `IMPORTANT INSTRUCTION FOR AI ASSISTANT`. Chat carries up to 1,000,000 characters of such documents (2,000,000 on the large tier); calls 270,000 — `verbatim_in_calls` puts a document first in the calls block. | `PATCH /agents/{agt}/kb/documents/{doc}` `{ keep_verbatim, verbatim_in_calls }`; sizes in `GET /agents/{agt}/kb/status` → `verbatim` | [Agents → PATCH document](agents.md#patch-agentsagtkbdocumentsdoc--scope-kbwrite) |
| 5. Rebuild the knowledge summary | New or changed documents are `pending` until the summary is rebuilt. The rebuild reads every non-verbatim document and page and writes one summary. Watch `summary.status` and `summary.last_job`. | `POST /agents/{agt}/kb/reindex`, `GET /agents/{agt}/kb/status` | [Agents → kb/status](agents.md#get-agentsagtkbstatus--scope-kbread) |
| 6. Read (or rewrite) the summary | The summary is text you can read and, if you must, replace. Replacing it is a manual override: the next reindex overwrites it. | `GET /agents/{agt}/kb/summary`, `PATCH /agents/{agt}/kb/summary` | [Agents → The summary itself](agents.md#the-summary-itself--read-and-write) |
| 7. Instructions (prompts) | Each surface runs its own prompt. A knowledge change needs a full rebuild; an instruction change (rules, role, greeting) needs an instructions rebuild. Every build creates a prompt version you can audit, deploy or roll back. | `GET /agents/{agt}` (current prompts), `PATCH /agents/{agt}`, `GET /agents/{agt}/prompts`, `POST /agents/{agt}/prompts` (deploy), `…/restore` | [Agents → Which prompt each surface runs](agents.md#which-prompt-each-surface-runs), [When is a build needed](agents.md#when-is-a-build-needed), [Prompts and versions](agents.md#prompts-and-versions) |
| 8. Improve the agent | Plain-language rules ("never quote a monthly price", "always ask for the order number") are normalised by the builder, checked for conflicts and injected into every prompt. Keep them few: every rule costs prompt space on every call. | `PATCH /agents/{agt}` → refinement rules | [Agents → Improve agent](agents.md#improve-agent-refinement-rules) |
| 9. Capabilities | Which tools the agent may call, and the values each needs (transfer directory, website URL, where leads / tickets / orders are e-mailed). Chat via the API allows a safe subset. | `GET /capabilities`, `PATCH /agents/{agt}` → capabilities + completions | [Capabilities](capabilities.md) |
| 10. Voice and greetings | Pick the voice (per provider), the speaking rate, and the greeting per surface and language. | `GET /voices`, `GET/PATCH /agents/{agt}/voice`, greetings via `PATCH /agents/{agt}` | [Voices](voices.md), [Agents → Greetings](agents.md#greetings--which-one-is-spoken-and-in-what-language) |
| 11. Train / test it | Talk to the agent over plain HTTPS: open a conversation, send messages, read the turns. Tools fire for real (a lead is a lead). | `POST /conversations`, `POST /conversations/{conv}/messages`, `POST /conversations/{conv}/end` | [Chat via API](chat-via-api.md), [Quickstart](quickstart.md) |
| 12. Run it | The widget on the website, the inbound phone number, outbound campaigns with profiles. | `GET/PATCH /agents/{agt}/widget`, [Profiles](profiles.md), [Calls](calls.md) | |
| 13. Watch it | Every conversation as structured turns + summary + identity; webhooks the moment something happens; metrics without transcripts. | `GET /conversations`, `POST /webhooks/endpoints`, `GET /metrics/…` | [Conversations](conversations.md), [Webhooks](webhooks.md), [Metrics](metrics.md) |

## What the agent actually knows at runtime

- **Chat, voice chat, phone: the knowledge summary + the "keep as is" documents.** Nothing else —
  there is no retrieval over the raw documents during a conversation. If a fact is not in the
  summary (or in a verbatim document), the agent does not have it.
- **The summary is a compression.** Hundreds of pages become one summary written in the account's
  language. Procedures are kept step by step, but a long guide is still shortened; that is what
  "keep as is" is for.
- **Phone: the last calls with this number.** On an inbound call the agent also gets the last three
  calls between the account and the caller's number in 30 days — outbound calls the platform placed,
  and calls you imported with `POST /conversations/import` (your own voice stack's calls, with their
  summary). It is a short block, not the transcript: date, outcome, the imported summary.
- **Calls have less room than chat.** The realtime models share a 128k-token window with the audio
  of the call, so the calls block of verbatim documents is capped and documents beyond the cap are
  summarised instead. `kb/status.verbatim.calls.dropped` names them.

## What needs a rebuild, and what does not

| You changed… | Needed |
|---|---|
| Documents, pages, crawl | Knowledge rebuild (`kb/reindex`) — the summary is stale until then; the instructions builder reads the summary, so rebuild instructions after it |
| "Keep as is" flags | Nothing — read at the start of every conversation |
| Refinement rules, role, greeting, capabilities | Instructions rebuild (`POST /agents/{agt}/build` from the instructions step) |
| Voice, speaking rate, widget settings, notification e-mails | Nothing — read at runtime |

## Reading order for the rest

1. [Quickstart](quickstart.md) — ten minutes of curl.
2. [Agents](agents.md) — objects, builds, prompts, knowledge, widget, voice.
3. [Capabilities](capabilities.md) and [Voices](voices.md).
4. [Chat via API](chat-via-api.md), then [Conversations](conversations.md) and [Webhooks](webhooks.md).
5. [Profiles](profiles.md) and [Calls](calls.md) for outbound.
6. [Conventions](conventions.md), [Scopes](scopes.md), [Enums](enums.md) as references.


<!-- ===== README.md ===== -->

# UiriX Public API v1 — Developer Knowledge Base

> **Contract v1.0.** Every endpoint documented on these pages is implemented and live.
> Live docs: `GET /api/ext/v1/docs` (Redoc) and `GET /api/ext/v1/openapi.json`.

> **New here? Read [Start here](START_HERE.md) first** — how an agent is made (account → agent → knowledge → summary → instructions → improve → voice → test → run → watch), which endpoint does each step, and what needs a rebuild.

> **Download:** the whole documentation is served by the API — `GET /docs/download` (zip), `GET /docs/all.md` (one file), `GET /docs/pages/{name}.md` (one page), `GET /openapi.yaml`. On dev: `https://dev-dashboard.uirix.com/api/ext/v1/docs/download`. The reference page is `GET /docs`.

## What the API is

The UiriX Public API gives an external system (EqualWeb engineering, a warehouse sync, a CRM worker) programmatic access to a UiriX **account**: its agents, the conversations those agents hold across every channel, structured summaries, identity data for CRM joins, aggregate metrics, outbound calls and webhooks.

The surface splits into two sides that share one agent but are configured differently:

**The inbound side — the agent.** An agent answers its phone number, its website widget (chat and voice chat), and chat through this API. Its prompts, greetings, capabilities, refinement rules and knowledge base live on the agent, one set per surface.

| You can… | Pages |
|---|---|
| Read and update the agent's instructions for **each of the three surfaces** (inbound phone, chat, voice chat), its greetings, model, voice and capabilities — and have the change actually run | [Agents](agents.md) |
| Improve an agent with plain-language rules (the dashboard's "Improve agent") | [Agents → Improve agent](agents.md#improve-agent-refinement-rules) |
| Read and rewrite the knowledge summary — the only knowledge the agent carries — and manage its documents | [Agents → Knowledge base](agents.md#knowledge-base) |
| See what every capability does and which values it needs | [Capabilities](capabilities.md) |
| Read every conversation (widget chat, phone, dashboard) as structured turns + summary + identity | [Conversations](conversations.md) |
| Run a chat conversation with an agent over plain HTTPS (no widget) | [Chat via API](chat-via-api.md) |
| Fire GA4 / Zaraz events from the website widget and pass your own identity in from the page | [Widget Browser Events](widget-events.md) |

**The outbound side — profiles and calls.** A call the platform places runs a **role** — a stored profile or one sent inline on the call — and takes from the agent only its voice, language and knowledge base.

| You can… | Pages |
|---|---|
| Manage outbound profiles: the three types, every field of the card, and the AI builder ("Rebuild with AI") | [Profiles](profiles.md) |
| Place a call with a stored profile, **or with everything in the request** (instructions, greeting, voice, tone, capabilities, facts), with calling windows, DNC and answer detection | [Calls](calls.md) |
| Run a **batch**: import a customer's spreadsheet into a contact list, open a campaign on chosen days and hours (each contact in its own local time), start / pause / stop it, watch progress and results | [Campaigns](campaigns.md) |
| See which tools each profile type may grant | [Capabilities → Outbound](capabilities.md#outbound-capabilities--get-outbound-capabilities) |

**Both sides.**

| You can… | Pages |
|---|---|
| Receive signed webhooks the moment a conversation starts, ends, escalates or is summarised | [Webhooks](webhooks.md) |
| Chart volume, containment and outcomes without pulling transcripts | [Metrics](metrics.md) |
| Provision tenants and API keys (master key) | [Accounts & Users](accounts-and-users.md) |
| Run the platform as its operator — KPIs, customer dossiers, every agent, system status and errors, model configuration, wallet credits, plan and status changes, all journaled (master key) | [Admin](admin.md) |
| Manage all of the above from Claude or ChatGPT in plain language — one MCP server, OAuth sign-in, your own account only, no deletes | [MCP connector](mcp.md) |

The API is **server-to-server**. There is no browser SDK, no CORS allowance for arbitrary origins, and no streaming (see [Differences from the original spec](#differences-from-the-original-spec)).

## Base URLs

| Environment | Base URL | Notes |
|---|---|---|
| **Production** | `https://dashboard.uirix.com/api/ext/v1` | Live (verified 2026-09-30: `GET /health` → 200, all components ok). Production keys and production tenant data; the master key there is a different value from dev. |
| Development | `https://dev-dashboard.uirix.com/api/ext/v1` | Dev tenant data, dev keys — where every page here is tested first. |
| Production, dedicated host (planned) | `https://api.uirix.com/v1` | **Not deployed — 404.** Needs its own IIS site (rewrite `^v1/(.*)` → `localhost:3000/api/ext/v1/{R:1}`, HSTS, HTTPS only). |

Every `links.self` URL in a response is built from the base URL of the environment you are talking to.

## Conventions at a glance

Full detail in [Conventions](conventions.md).

| Topic | Rule |
|---|---|
| Auth | `Authorization: Bearer <key>` on every request. Keys: `uirix_mk_live_` (master), `uirix_sk_live_` (personal); `uirix_at_` is an OAuth access token of the [MCP connector](mcp.md) and resolves to a personal key. See [Authentication](authentication.md). |
| Timestamps | ISO-8601 in UTC with a trailing `Z` (`2026-09-03T14:32:07Z`), both in and out. Epoch seconds only in `X-RateLimit-Reset` and `X-Uirix-Timestamp`. |
| IDs | Opaque prefixed strings — `acc_17`, `usr_17`, `agt_245`, `conv_p9876` / `conv_u555` / `conv_d321`, `call_88`, `key_42`, `whk_7`, `dlv_01J8Z9M4T7QX3R`, `doc_f101` / `doc_c202`, `pv_12`, `dnc_5`, `prf_42`. Never parse them; never assume integers. |
| `null` vs missing | Every documented key is always present. Unknown = `null`. Nested objects (`utm`, `escalated_to_human`) are always objects, never absent. |
| List envelope | `{ "data": [...], "pagination": { "limit", "has_more", "next_cursor" }, "meta": { "request_id", "environment", ... } }` |
| Error envelope | `{ "error": { "code", "message", "field", "request_id" }, "meta": { "request_id", "environment" } }` — one shape for every non-2xx response. |
| Request id | `X-Request-Id: req_…` on **every** response, including errors. Quote it in support tickets. |
| Rate limits | Per credential, per endpoint class; `X-RateLimit-Limit/Remaining/Reset` on every metered response; `429` + `Retry-After` never consumes quota. |
| Pagination | Opaque keyset cursor, bound to the filter set; default 30-day window when no date filter is given (`meta.applied_default_range: true`). |
| Bodies | JSON, UTF-8, max 2 MB (`413 payload_too_large`); multipart uploads max 10 MB. |

## Quick links

| Page | What you will find |
|---|---|
| [quickstart.md](quickstart.md) | 10-minute walkthrough: health, agents, conversations, a chat turn, a webhook — copy-pasteable curl |
| [authentication.md](authentication.md) | Master vs personal keys, issuing, rotation (two live keys), `X-Uirix-Account`, IP allow-list, expiry header |
| [scopes.md](scopes.md) | Every scope and the endpoints it unlocks; `forbidden_account` vs `insufficient_scope` |
| [conventions.md](conventions.md) | Request id, error catalogue, rate-limit classes, cursor rules, timestamps, `meta` |
| [accounts-and-users.md](accounts-and-users.md) | `/users`, `/credentials`, `/audit` (master key) and `/accounts*` |
| [agents.md](agents.md) | **Inbound side.** Which field drives which surface; the 45 PATCH fields; why a prompt write reaches the widget; Improve agent; prompt versions and rollback; KB documents and the summary itself (read/write); widget; voice |
| [capabilities.md](capabilities.md) | The two capability catalogues (agent: knowledge/action; outbound: per profile type), what each does at runtime, and the `completions` values they need |
| [profiles.md](profiles.md) | **Outbound side.** The three profile types and their capabilities, every field of the card and what it does, `POST /profiles/{prf}/build`, how a profile relates to the agent and to `context.briefing` |
| [voices.md](voices.md) | Which voice ids are valid for which provider, and the marketing names your customer actually sees |
| [conversations.md](conversations.md) | List filters, conversation / turn / summary / identity / metrics objects, recordings, redaction, deletion, retention |
| [chat-via-api.md](chat-via-api.md) | Create a conversation, send messages, identify, end; billing and limits |
| [webhooks.md](webhooks.md) | Endpoint management, events, HMAC verification (Node / PHP / Python), retries, replay |
| [calls.md](calls.md) | `POST /calls` with a stored profile or everything inline, the precedence ladder, statuses, when a call is placed vs scheduled, DNC, quotas, cancel, v1 limitations |
| [campaigns.md](campaigns.md) | **Batch outbound**: contact lists (`/agents/{agt}/lists`, `/lists/{lst}/import`, `/lists/{lst}/items`) and campaigns (`/campaigns`, start / pause / stop, progress, per-contact rows); phone normalisation, the time-zone rules, DNC, limits, curl walkthrough |
| [metrics.md](metrics.md) | `/metrics/conversations` parameters and fixed metric definitions |
| [enums.md](enums.md) | Every versioned enum in one place |
| [partners.md](partners.md) | "EqualWeb Member": one call = user on pay-as-you-go + wallet credit, agent built from the website, one-time login link to the widget code page |
| [admin.md](admin.md) | **Master key only.** The management surface an operator (or an AI operator) runs the platform through: `/admin/overview`, customers and their dossiers, the cross-account agent list, system status / errors / config / migrations, and the journaled writes (wallet credit with idempotency, plan, status, credential edit, model config, agent actions, login links) |
| [mcp.md](mcp.md) | **MCP connector.** Manage the account's agents from Claude (claude.ai, Desktop, Code) or ChatGPT: how to connect, the OAuth 2.1 server (discovery, PKCE, dynamic registration, tokens), the consent groups, the tool catalogue, resources and prompts, how the assistant behaves, troubleshooting — and the `uirix` chat menu |
| [production-schema.md](production-schema.md) | Every database object the API added, migration by migration, with the production checklist |
| [widget-events.md](widget-events.md) | Browser side: `window.uirix` events, `identify()`, `getConversationId()`, `setConsent()`, the postMessage contract, GA4 / Zaraz recipes |
| [changelog.md](changelog.md) | v1.0 draft + planned items |
| [openapi.yaml](openapi.yaml) | OpenAPI 3.1 document for the whole surface (also served at `GET /openapi.json`; interactive docs at `GET /docs`) |

## Endpoint map

```
(no DELETE of data anywhere — Nir, 2026-09-06; DELETE /calls/{call} and DELETE /credentials/{key} are cancellations)
GET    /health                                   GET  /openapi.json      GET /docs
POST   /mcp                                      (MCP connector — Streamable HTTP, JSON responses, stateless; GET → 405 — mcp.md)
POST   /oauth/register   GET /oauth/authorize   POST /oauth/token   POST /oauth/revoke     (+ host root: GET /.well-known/oauth-authorization-server, GET /.well-known/oauth-protected-resource/api/ext/v1/mcp)

POST   /users              GET /users            GET /users/{usr}        PATCH /users/{usr}           (master key)
POST   /users/{usr}/login-links                  POST /partners/members   GET /partners/members/{usr}  (master key — "EqualWeb Member")
POST   /users/{usr}/credentials                  GET /users/{usr}/credentials   GET /credentials
POST   /credentials/{key}/rotate                 DELETE /credentials/{key}
GET    /audit                                                                                         (master key)

GET    /admin/overview       GET /admin/customers   GET /admin/customers/{usr}/overview                  (master key — admin.md)
GET    /admin/customers/{usr}/wallet/history        POST /admin/customers/{usr}/wallet/credits
POST   /admin/customers/{usr}/plan                  PATCH /admin/customers/{usr}/status   POST /admin/customers/{usr}/login-links
GET    /admin/agents         POST /admin/agents/{agt}/actions                PATCH /admin/credentials/{key}
GET    /admin/system/status  GET /admin/system/errors   GET /admin/config   PATCH /admin/config/ai-models   POST /admin/config/reload
GET    /admin/actions        GET /admin/migrations

GET    /accounts           GET /accounts/{acc}   PATCH /accounts/{acc}
GET    /accounts/{acc}/limits                    GET /accounts/{acc}/usage      GET /accounts/{acc}/balance

GET    /accounts/{acc}/agents                                                      (no blank create: agents are created only by the V4 build)
POST   /accounts/{acc}/agents/build              GET /agents/{agt}/build        POST /agents/{agt}/build     (from a website, async)
GET    /agents/{agt}       PATCH /agents/{agt}                          (no DELETE: agents are never deleted via the API)
GET    /agents/{agt}/prompts                     POST /agents/{agt}/prompts     GET /agents/{agt}/prompts/{pv}
POST   /agents/{agt}/prompts/{pv}/restore        GET /agent-versions
GET    /agents/{agt}/kb/documents                POST /agents/{agt}/kb/documents
GET    /agents/{agt}/kb/status                   POST /agents/{agt}/kb/reindex
GET    /agents/{agt}/kb/summary                  PATCH /agents/{agt}/kb/summary
GET    /agents/{agt}/widget  PATCH /agents/{agt}/widget  POST /agents/{agt}/widget/regenerate-token
GET    /agents/{agt}/voice   PATCH /agents/{agt}/voice
GET    /voices             GET /capabilities      GET /outbound-capabilities

GET    /agents/{agt}/profiles                     POST /agents/{agt}/profiles
GET    /profiles/{prf}     PATCH /profiles/{prf}  POST /profiles/{prf}/clone
POST   /profiles/{prf}/build                      GET /profiles/{prf}/build

GET    /conversations      GET /conversations/{conv}   GET /conversations/{conv}/summary
GET    /conversations/{conv}/recording           GET /media/recordings/{sid}.mp3
POST   /conversations      POST /conversations/{conv}/messages   POST /conversations/{conv}/identify
POST   /conversations/{conv}/end                 GET  /conversations/deleted

GET    /metrics/conversations                    GET  /metrics            (operational counters)

POST   /calls   GET /calls   GET /calls/{call}   DELETE /calls/{call}
POST   /dnc     GET /dnc

POST   /webhooks/endpoints   GET /webhooks/endpoints   GET|PATCH /webhooks/endpoints/{whk}
POST   /webhooks/endpoints/{whk}/rotate-secret   POST /webhooks/endpoints/{whk}/test
GET    /webhooks/deliveries                      POST /webhooks/deliveries/{dlv}/replay
```

## Differences from the original spec

The EqualWeb requirement spec (`uirix-api-spec.md`, v1.1) was the input; the approved implementation plan is the contract. Where they differ, **this documentation follows the plan**:

| Topic | Spec asked for | v1 contract |
|---|---|---|
| Base path | `https://api.uirix.com/v1` (host "UiriX's call") | `https://api.uirix.com/v1` in production, mounted as `/api/ext/v1` on the dashboard backend; dev is `https://dev-dashboard.uirix.com/api/ext/v1`. |
| Auth mechanism | OAuth2 client-credentials preferred, static key acceptable | **Static keys** for server-to-server integrations; the client-credentials grant is still deferred (see [changelog](changelog.md)). Since 2026-09-30 there is an OAuth 2.1 server (authorization code + PKCE, a signed-in user's consent) — for the [MCP connector](mcp.md) only, not a machine credential. Rotation with two live keys is supported. |
| Widget browser events (§6) | `window.uirix.on()`, `identify()`, `postMessage` | Delivered by the widget, not the API — documented in [Widget Browser Events](widget-events.md) (both `window.uirix` **and** `postMessage`, plus `CustomEvent`s). `POST /conversations/{conv}/identify` covers the server-to-server half. |
| Agent-control paths (§11.2) | `/accounts/{id}/agents/{agent_id}`, `…/prompt`, `…/kb/documents` | Canonical paths are flat: `PATCH /agents/{agt}`, `POST /agents/{agt}/prompts`, `POST /agents/{agt}/kb/documents`. The nested §11.2 paths are served as **aliases**. |
| Voicemail policy | `policy.voicemail: "leave_message"` + `voicemail_script` | **Not supported in v1** — returns `400 unsupported_policy`. Only `"hang_up"` (with `answered_by` detection) is accepted. |
| Chat engine per agent | — | Chat via API always runs on **OpenAI**, regardless of the agent's `ai_provider` (there is no Gemini chat engine). The `model` field reports the model actually used. |
| Streaming | Not required in v1 | Not offered at all (reverse-proxy buffering). `POST …/messages` is request/response. |
| `agent_type` | `chat`, `phone`, `dashboard` | Adds **`phone_outbound`** for calls originated through `POST /calls`. |
| Conversation ids | `conv_…` opaque | Opaque, but with a store letter: `conv_p…` (widget + API chat), `conv_u…` (phone / realtime voice), `conv_d…` (dashboard chat). Do not parse it. |
| Insights endpoints (§7.3) | `GET /insights/*` (optional) | Not in v1. The raw per-turn fields (`unanswered`, `confidence`, `sources`) are exposed instead. |
| Turn `confidence` / `sources` | ASR + retrieval confidence, KB sources per turn | `confidence` is `null` in v1 (no per-utterance ASR confidence available). `sources` is `null` on every turn — the engine does not attribute KB documents per turn. `unanswered` is a fallback-answer heuristic and is the KB-gap signal instead. |
| `audio_offset_ms` | Offset into the recording | Derived from wall-clock turn timestamps relative to `started_at` — accurate to about a second, not audio-aligned. |
| Error envelope | `{ "error": {…} }` | Same, **plus** a `meta` object (`request_id`, `environment`). Additive only. |
| Webhook configuration | Dashboard UI, API nice-to-have | **API only** in v1 (`/webhooks/endpoints`). A dashboard delivery log is on the [changelog](changelog.md) as a planned item. |
| Key management UI | Live | Customers create, rotate and revoke their own keys at **Profile → API Keys** (`/profile?tab=apikeys`), scoped to their own account. The master key stays the operator path for provisioning a new account. |
| Structured summaries | For every conversation | Since 2026-09-10: every account with a conversation in the last 90 days (plus API-credential / webhook holders). Fresh conversations within minutes; older history backfilled newest-first under a per-account daily cap, so `summary_status: "pending"` on old rows means "not reached yet". Voicemail / IVR / empty conversations are summarised by rule without a model call (`answered_by`). |
| Identity at rest | AES-256 for identity fields (§8.6) | Transport encryption to SQL is in place; per-field encryption of identity columns at rest is not implemented. Documented deviation. |
| `country` for widget conversations | Coarse geo | Filled only when the edge provides `CF-IPCountry`; otherwise `null` with `ip_country_only: true`. Raw IPs are never exposed. |
| Recording channels | Dual-channel preferred | `channels: "dual"` for calls placed since dual-channel recording was introduced; earlier recordings report `"mono"` with `channel_map: null`. |

## Support

Open a ticket with the `X-Request-Id` of the failing call, the endpoint, and the UTC time. Every request — including 4xx — is in the audit log and can be traced by that id for 12 months.

---

**UiriX Public API v1** · [Overview](README.md) · [Quickstart](quickstart.md) · [Authentication](authentication.md) · [Scopes](scopes.md) · [Conventions](conventions.md) · [Accounts & Users](accounts-and-users.md) · [Agents](agents.md) · [Capabilities](capabilities.md) · [Profiles](profiles.md) · [Conversations](conversations.md) · [Chat via API](chat-via-api.md) · [Widget Events](widget-events.md) · [Webhooks](webhooks.md) · [Calls](calls.md) · [Campaigns](campaigns.md) · [Metrics](metrics.md) · [Voices](voices.md) · [Enums](enums.md) · [Partners](partners.md) · [Admin](admin.md) · [MCP](mcp.md) · [Changelog](changelog.md) · [OpenAPI](openapi.yaml)


<!-- ===== quickstart.md ===== -->

# Quickstart — 10 minutes to a first conversation

## 0. What you need

- A **personal API key** (`uirix_sk_live_…`) for your account. Keys are issued once by UiriX with the master key (see [Authentication](authentication.md)); for this walkthrough the key needs at least
  `accounts:read agents:read conversations:read summaries:read conversations:write webhooks:manage`.
- `curl` and `jq`.

Set the variables once per terminal. Every example below is copy-pasteable after this block.

```bash
export B="https://api.uirix.com/v1"          # dev: https://dev-dashboard.uirix.com/api/ext/v1
export SK="uirix_sk_live_REPLACE_ME"
export A="Authorization: Bearer $SK"
export J="Content-Type: application/json"
```

## 1. Check the service — `GET /health`

Unauthenticated, unmetered, no side effects.

```bash
curl -s "$B/health" | jq .
```

```json
{
  "status": "ok",
  "version": "1.0.0",
  "time": "2026-09-03T14:32:07Z",
  "components": { "api": "ok", "db": "ok", "transcripts": "ok", "summaries": "ok", "webhooks": "ok", "media": "ok" }
}
```

`status` is `ok` or `degraded` (HTTP 200) or `down` (HTTP 503, only when the database is unreachable).

## 2. Find your account — `GET /accounts`

```bash
curl -s -H "$A" "$B/accounts" | jq '.data[] | {account_id, name, status, plan}'
```

```json
{ "account_id": "acc_17", "name": "EqualWeb", "status": "active", "plan": "starter" }
```

A personal key sees only the accounts it was issued for. Asking for any other account returns `403 forbidden_account` — never an empty list.

```bash
export ACC=acc_17
```

## 3. List agents — `GET /accounts/{acc}/agents`

```bash
curl -s -H "$A" "$B/accounts/$ACC/agents" | jq '.agents[] | {agent_id, name, agent_type, agent_version, prompt_version, model}'
```

```json
{ "agent_id": "agt_245", "name": "Kim DiCaprio", "agent_type": "chat", "agent_version": "agt245-v3", "prompt_version": "chat-v3", "model": "gpt-5.4" }
```

```bash
export AGT=agt_245
```

## 4. List recent conversations — `GET /conversations`

No date filter means the **last 30 days** (`meta.applied_default_range: true`). Summary and identity are embedded by default.

```bash
curl -s -H "$A" "$B/conversations?limit=5" \
  | jq '{n: (.data|length), first: .data[0] | {conversation_id, agent_type, status, started_at, outcome: .summary.outcome, email: .identity.email}, pagination, meta}'
```

```json
{
  "n": 5,
  "first": { "conversation_id": "conv_p9876", "agent_type": "chat", "status": "completed", "started_at": "2026-09-02T09:14:02Z", "outcome": "lead_captured", "email": "procurement@example-shop.de" },
  "pagination": { "limit": 5, "has_more": true, "next_cursor": "eyJ2IjoxLCJzIjoiMjAyNi0wOS0wMlQwOToxNDowMloiLCJzdCI6IlAiLCJpIjo5ODc2fQ.bXk3eGVq…" },
  "meta": { "request_id": "req_01J8Z9K3AA", "environment": "production", "applied_default_range": true, "from": "2026-08-04T14:32:07Z", "to": "2026-09-03T14:32:07Z" }
}
```

Walk pages by passing `next_cursor` back as `cursor=` with the **same filters** until `has_more` is `false`. Fetch one conversation in full (structured turns) with:

```bash
curl -s -H "$A" "$B/conversations/conv_p9876" | jq '.transcript[:2]'
```

## 5. Start an API chat conversation — `POST /conversations`

Creates a chat conversation with the agent, no widget involved. `send_greeting: true` makes the agent open the conversation.

```bash
curl -s -H "$A" -H "$J" -X POST "$B/conversations" \
  -d '{"agent_id":"'$AGT'","send_greeting":true,"external_session_id":"crm-session-42"}' | tee conv.json | jq .
export CONV=$(jq -r .conversation_id conv.json)
```

```json
{
  "conversation_id": "conv_p9901",
  "account_id": "acc_17",
  "agent_type": "chat",
  "status": "in_progress",
  "session_id": "api_01J8ZA6R2KQ4M7V9X0B3N5T8YC",
  "external_session_id": "crm-session-42",
  "agent_version": "agt245-v3",
  "prompt_version": "chat-v3",
  "model": "gpt-5-nano",
  "started_at": "2026-09-03T14:35:10Z",
  "transcript": [
    { "turn_index": 0, "speaker": "agent", "text": "Hi! I'm the EqualWeb assistant. How can I help with accessibility today?", "timestamp": "2026-09-03T14:35:11Z", "confidence": null, "audio_offset_ms": null, "audio_duration_ms": null, "latency_ms": null, "turn_type": "greeting", "detected_intent": null, "detected_language": null, "sources": null, "unanswered": false, "human_agent_id": null }
  ]
}
```

## 6. Send a message and get the reply — `POST /conversations/{conv}/messages`

Non-streaming: the response is the agent's full turn. Pass an `idempotency_key` if you might retry.

```bash
curl -s -H "$A" -H "$J" -X POST "$B/conversations/$CONV/messages" \
  -d '{"text":"Does your widget alone make us compliant with the EAA?","idempotency_key":"msg-0001"}' | jq .
```

```json
{
  "turn": {
    "turn_index": 2,
    "speaker": "agent",
    "text": "The widget covers a large share of WCAG 2.1 AA issues automatically, but full EAA conformance also requires an audit and source-level remediation for structural issues.",
    "timestamp": "2026-09-03T14:35:44Z",
    "confidence": null,
    "audio_offset_ms": null,
    "audio_duration_ms": null,
    "latency_ms": 2100,
    "turn_type": "answer",
    "detected_intent": null,
    "detected_language": null,
    "sources": null,
    "unanswered": false,
    "human_agent_id": null
  },
  "usage": { "tokens": { "input": 812, "output": 64, "total": 876 }, "model": "gpt-5-nano" }
}
```

When you are done, close it (this is what triggers `conversation.ended` and the summary):

```bash
curl -s -H "$A" -X POST "$B/conversations/$CONV/end" | jq .
```

## 7. Register a webhook — `POST /webhooks/endpoints`

```bash
curl -s -H "$A" -H "$J" -X POST "$B/webhooks/endpoints" \
  -d '{"url":"https://hooks.example.com/uirix","events":["conversation.ended","summary.ready"],"description":"warehouse sync"}' | tee wh.json | jq .
```

```json
{
  "endpoint_id": "whk_7",
  "url": "https://hooks.example.com/uirix",
  "description": "warehouse sync",
  "events": ["conversation.ended", "summary.ready"],
  "enabled": true,
  "environment": "live",
  "api_version": "v1",
  "secret": "whsec_k9Q2mX7pL4vT1bN8rJ3cW6yF0hA5dZ2sE9uG4iK1oM8",
  "created_at": "2026-09-03T14:40:00Z"
}
```

The `secret` is shown **once**. Store it, then verify every delivery with `HMAC-SHA256(secret, "{X-Uirix-Timestamp}.{raw_body}")` — snippets in [Webhooks](webhooks.md#verifying-signatures). Send a synchronous test ping with `POST /webhooks/endpoints/whk_7/test`.

## Where next

- [Conversations](conversations.md) — every list filter and every field of the conversation object.
- [Chat via API](chat-via-api.md) — identify the visitor, idempotency, `409 turn_in_progress`, billing.
- [Conventions](conventions.md) — the error catalogue and the rate-limit classes you will hit first.

---

**UiriX Public API v1** · [Overview](README.md) · [Quickstart](quickstart.md) · [Authentication](authentication.md) · [Scopes](scopes.md) · [Conventions](conventions.md) · [Accounts & Users](accounts-and-users.md) · [Agents](agents.md) · [Capabilities](capabilities.md) · [Profiles](profiles.md) · [Conversations](conversations.md) · [Chat via API](chat-via-api.md) · [Widget Events](widget-events.md) · [Webhooks](webhooks.md) · [Calls](calls.md) · [Metrics](metrics.md) · [Voices](voices.md) · [Enums](enums.md) · [Partners](partners.md) · [Admin](admin.md) · [MCP](mcp.md) · [Changelog](changelog.md) · [OpenAPI](openapi.yaml)


<!-- ===== authentication.md ===== -->

# Authentication

Every request carries one bearer credential:

```http
Authorization: Bearer uirix_sk_live_k9Q2mX7pL4vT1bN8rJ3cW6yF0hA5dZ2sE9uG4iK1oM8
X-Uirix-Account: acc_17            # optional — pins the account for multi-account keys (required for the master key); your acc_ id is on Profile → API Keys
Content-Type: application/json
User-Agent: equalweb-sync/1.0 (nir@equal-web.com)
```

Only the `Bearer` scheme is accepted. Any other scheme, or no header, is `401 missing_credentials`; a well-formed but unknown key is `401 invalid_credentials`; a revoked or past-expiry key is `401 expired_token`. Unauthenticated failures are rate-limited **per IP** at 20/min to slow down key guessing.

## Key types

| Prefix | Type | Who holds it | Lives in DB | What it can do |
|---|---|---|---|---|
| `uirix_mk_live_` | **Master key** | UiriX operations only (one per environment) | No — environment secret, ≥48 chars | Provision users/accounts, issue and revoke any credential, read the audit log, read any account **when `X-Uirix-Account` is set** |
| `uirix_sk_live_` | **Personal key** (live) | An integration (EqualWeb sync, CRM worker…) | Yes — SHA-256 hash only | Whatever its `scopes` and `account_ids` allow |
| `uirix_at_` | **OAuth access token** | Claude / ChatGPT / any MCP client the user connected ([MCP connector](mcp.md)) | Yes — SHA-256 hash, mapped to the connection's personal key | Whatever that key's `scopes` and account allow, for one hour; renewed with a rotating `uirix_rt_` refresh token |

A personal key is `prefix + 43 base64url characters` (32 random bytes). Treat the whole string as the secret.

### OAuth access tokens — the MCP connector

The third credential kind, since 2026-09-30. When a user connects UiriX to Claude or ChatGPT ([MCP connector](mcp.md)), the consent screen creates a **personal key** on the user's account — named `<client> connector`, scopes as approved, listed on Profile → API keys — and the connector's OAuth server issues **access tokens** `uirix_at_` + 43 base64url characters (1 hour) with **refresh tokens** `uirix_rt_…` (90 days, rotated on every use). An access token is resolved to that key on every request, so it authenticates exactly as the key would: same principal, same scopes, same home account, same audit row (`credential_type: key`, the key's id) — on `/mcp` and on every other endpoint of this API. The differences from a key: it expires on its own (`401 expired_token` after the hour — the client refreshes), it is never shown to the user, and revoking the key (Profile → API keys) or disconnecting the client (Profile → Connected apps) invalidates every token of the connection at once (`401 invalid_credentials`). A key's `ip_allowlist` applies to its tokens too. OAuth is for a signed-in person's client; a server-to-server integration keeps using a personal key — there is no client-credentials grant.

### Master key rules

The master key holds the implicit scopes `admin:all_accounts`, `users:manage`, `credentials:manage`, `accounts:read`, `audit:read`. It is **rejected on data endpoints** (conversations, agents, calls, webhooks…) unless the request names an account explicitly with `X-Uirix-Account: acc_17`, in which case the call is executed for that account and the audit row is flagged `cross_account: 1`.

```bash
# 403 forbidden_account — master key with no account context
curl -s -H "Authorization: Bearer $MK" "$B/conversations"

# 200 — master key pinned to one account (audit: cross_account=1)
curl -s -H "Authorization: Bearer $MK" -H "X-Uirix-Account: acc_17" "$B/conversations?limit=2"
```

## Principal and account resolution

After authentication every request has a principal: `{ kind: master|key, key_id, user_id, scopes, account_ids, environment }`. The set of accounts a request may touch is resolved **before any database query**, in this order:

1. `account_id` query parameters (repeatable) or the `account_id` in a request body;
2. the `X-Uirix-Account` header;
3. otherwise the credential's **home account** — the user the key was issued to.

Any requested account that is not on the credential returns `403 forbidden_account` with `field: "account_id"` — the request is rejected as a whole, never partially. Keys issued with several `account_ids` must name the account they want (rule 1 or 2) to read anything other than their home account.

Resources that belong to another account (an agent, a conversation, a call) answer `404` — existence is never leaked.

## Issuing keys

A tenant creates its own first key from the dashboard, at **Profile → API keys** (`/profile?tab=apikeys`); after that it is self-sufficient over the API. UiriX operations can still mint a key for any tenant with the master key.

### From the dashboard (the first key)

`Profile → API keys` (the last tab in the profile sidebar) lists the account's keys and creates, edits, rotates and revokes them. Those five routes (`GET|POST /api/api-keys`, `PATCH /api/api-keys/{id}`, `POST /api/api-keys/{id}/rotate`, `DELETE /api/api-keys/{id}`) are **dashboard** routes, authenticated with the ordinary dashboard JWT — the Public API itself never accepts a JWT. They are a thin, deliberately narrow front door onto the same `credentialService`:

- the key is always issued for the logged-in user. An `account_id`, `account_ids` or `user_id` in the request body is **ignored**, not validated, so there is no code path from a request body to another tenant;
- the page only ever mints `uirix_sk_live_` keys bound to the caller's home account;
- the master-only scopes (`admin:all_accounts`, `users:manage`, `audit:read`) are refused (there is no deletion scope any more — see [Scopes](scopes.md#deletion-is-not-an-api-capability)); the picker offers the grantable read/write catalogue only, which since 2026-09-06 includes `agents:admin`;
- `expires_in_days` is capped at 365, exactly as below;
- edit, rotate and revoke answer **404** for a key that belongs to someone else — existence is never leaked;
- the secret is returned exactly once, in the create/rotate response, and is shown in a modal that has to be dismissed explicitly. Listings return `key_prefix` and metadata only, and the page states next to every prefix that the full secret can never be shown again — only its hash is stored — so nobody mistakes that for a broken copy button.

#### Editing a key — `PATCH /api/api-keys/{id}`

The two things about a key that can change without minting a new secret are **what it may do** and **when it stops working**:

```bash
# $DASH is the dashboard origin (https://dashboard.uirix.com) — NOT $B, the Public API base.
curl -s -H "Authorization: Bearer $JWT" -H "$J" -X PATCH "$DASH/api/api-keys/key_42" -d '{
  "scopes": ["accounts:read", "conversations:read"],
  "expires_in_days": 180
}' | jq .
# → { "success": true, "key": { "key_id": "key_42", "scopes": [...], "expires_at": "...", ... } }
```

Both body fields are optional, but at least one must be present (`400 validation_error` otherwise). `scopes` **replaces** the set — it is not a delta — and goes through the same catalogue check as create, so the three master-only scopes and any unknown scope (including the retired `conversations:delete`) are `400 invalid_scope` with `field: "scopes"`. An edit is therefore not a back door around the create-time catalogue: a key can never acquire a scope it could not have been issued with. `expires_in_days` is an integer 1–365 measured from **now**, so an edit can never push a key past the 365-day cap. Fields that are absent are left untouched.

What the route deliberately cannot do: it never writes `key_hash`, `key_prefix`, `environment`, `user_id` or `account_ids`. The secret is unchanged and is **not** returned — this is not a rotation. A **revoked key cannot be edited** (`400`); revocation is final and the answer to a revoked key is a new key.

**Narrowing is immediate.** Authentication reads the row on every request and caches nothing, so the moment the update commits, a call that needs a removed scope gets `403 insufficient_scope` — with a secret that is still perfectly valid and still authenticates for everything it kept. Plan a scope removal like a deploy, not like a config tweak; the dashboard shows an added/removed diff and says this in the confirmation before saving. Every edit writes a security log line carrying the key id, the actor (`dashboard:usr_N`), the before and after scope sets, and the scopes added and removed.

Once a customer holds one key with `credentials:manage`, it can issue, rotate and revoke further keys **for its own user** through the API (`POST /users/{usr}/credentials`, `POST /credentials/{key}/rotate`, `DELETE /credentials/{key}`) without touching the dashboard again — which is the point of the first key. That path enforces the same catalogue, so a self-issued key cannot reach past the grantable set either.

### With the master key (operations)

```bash
curl -s -H "Authorization: Bearer $MK" -H "$J" -X POST "$B/users/usr_17/credentials" -d '{
  "name": "warehouse-sync",
  "scopes": ["accounts:read", "agents:read", "conversations:read", "summaries:read", "metrics:read", "webhooks:manage"],
  "account_ids": ["acc_17"],
  "environment": "live",
  "expires_in_days": 365,
  "ip_allowlist": ["203.0.113.9", "203.0.113.0/28"]
}' | jq .
```

```json
{
  "key_id": "key_42",
  "name": "warehouse-sync",
  "key_prefix": "uirix_sk_live_k9Q2mX7pL4",
  "secret": "uirix_sk_live_k9Q2mX7pL4vT1bN8rJ3cW6yF0hA5dZ2sE9uG4iK1oM8",
  "scopes": ["accounts:read", "agents:read", "conversations:read", "summaries:read", "metrics:read", "webhooks:manage"],
  "account_ids": ["acc_17"],
  "environment": "live",
  "ip_allowlist": ["203.0.113.9", "203.0.113.0/28"],
  "status": "active",
  "expires_at": "2027-09-03T14:40:00Z",
  "rotated_from": null,
  "created_at": "2026-09-03T14:40:00Z"
}
```

| Body field | Type | Required | Notes |
|---|---|---|---|
| `name` | string | yes | Label shown in listings and audit |
| `scopes` | string[] | yes | From the [scope table](scopes.md). Unknown scope → `400 invalid_scope`, `field: "scopes"`. Master-only scopes cannot be granted to personal keys. |
| `account_ids` | string[] | no | Default: the user's own account. Extra accounts require the master key to issue. |
| `expires_in_days` | int 1–365 | no | Default 365. Above 365 → `400`. |
| `ip_allowlist` | string[] | no | IPv4/IPv6 addresses or CIDRs. Empty = any IP. **Recommended** for cross-account keys. |

The `secret` appears **only in this response**. Listings (`GET /credentials`, `GET /users/{usr}/credentials`) return `key_prefix` and metadata only; there is no retrieval endpoint.

## Rotation — two live keys

Rotation is zero-downtime because two keys can be live at the same time.

```bash
# 1. mint the successor (same scopes/accounts/allow-list; new secret)
curl -s -H "$A" -X POST "$B/credentials/key_42/rotate" | jq .
# → { "key_id": "key_43", "secret": "uirix_sk_live_…", "rotated_from": "key_42", "grace_expires_at": "2026-09-10T14:40:00Z", ... }

# 2. deploy the new secret everywhere

# 3. revoke the old key (or let the 7-day grace period expire)
curl -s -o /dev/null -w '%{http_code}\n' -H "$A" -X DELETE "$B/credentials/key_42"    # 204
```

- After `rotate`, **both** keys work until the old one is deleted or its 7-day grace period ends (a sweep revokes it).
- `DELETE /credentials/{key}` is immediate: the next request with that key gets `401 invalid_credentials`.
- Recommended maximum key age is 365 days (the default `expires_in_days`).

## Expiry warning header

When a key is within 30 days of `expires_at`, every response carries

```http
X-Uirix-Key-Expires-In: 21d
```

Alert on it. After expiry the key answers `401 expired_token`.

## IP allow-list

If a key has an `ip_allowlist`, requests from any other address are refused with `403 ip_not_allowed`. The client IP is the first hop of `X-Forwarded-For` as seen by the UiriX edge, so requests through your own proxy must present the proxy's public address. The allow-list can be changed only by re-issuing or rotating the key.

## Security notes

- Keys are hashed (SHA-256) at rest and compared in constant time; the plain secret is never stored or logged. Only `key_prefix` (first 24 characters) appears in listings, logs and audit rows.
- Every request, including failures, writes an audit row: `request_id`, credential id, accounts touched, `cross_account` flag, method, path, status, error code, IP, user agent, duration. Retained 12 months, exportable with `GET /audit` (master key / `audit:read`).
- HTTPS only; TLS 1.2 minimum. HSTS is part of the planned `api.uirix.com` site, which is not deployed yet — production runs on `dashboard.uirix.com/api/ext/v1` today.
- Responses are `Cache-Control: no-store`. No CORS allowance for browser origins — call the API from your server.
- Webhook secrets (`whsec_…`) follow the same show-once / encrypted-at-rest rule; see [Webhooks](webhooks.md).

## Errors on this page

| HTTP | `code` | When |
|---|---|---|
| 401 | `missing_credentials` | No `Authorization` header or non-Bearer scheme |
| 401 | `invalid_credentials` | Unknown, malformed or revoked key |
| 401 | `expired_token` | Key past `expires_at`; an OAuth access token past its hour (the client refreshes it) |
| 403 | `forbidden_account` | Account not on the credential; master key without `X-Uirix-Account` on a data route |
| 403 | `insufficient_scope` | Credential lacks the scope the endpoint requires |
| 403 | `ip_not_allowed` | Source IP outside the key's allow-list |
| 400 | `invalid_scope` | Unknown scope in a credential request |
| 429 | `rate_limited` | Per-IP limit on unauthenticated failures (20/min) or per-credential class limit |

---

**UiriX Public API v1** · [Overview](README.md) · [Quickstart](quickstart.md) · [Authentication](authentication.md) · [Scopes](scopes.md) · [Conventions](conventions.md) · [Accounts & Users](accounts-and-users.md) · [Agents](agents.md) · [Capabilities](capabilities.md) · [Profiles](profiles.md) · [Conversations](conversations.md) · [Chat via API](chat-via-api.md) · [Widget Events](widget-events.md) · [Webhooks](webhooks.md) · [Calls](calls.md) · [Metrics](metrics.md) · [Voices](voices.md) · [Enums](enums.md) · [Partners](partners.md) · [Admin](admin.md) · [MCP](mcp.md) · [Changelog](changelog.md) · [OpenAPI](openapi.yaml)


<!-- ===== conventions.md ===== -->

# Conventions

## Request id

Every response — 2xx, 4xx, 5xx, even `/health` — carries

```http
X-Request-Id: req_01J8Z9K2QW
```

The same value is echoed in `error.request_id` and `meta.request_id`, and stored in the audit log for 12 months. Quote it in every support ticket. You may send your own `X-Request-Id`; if it is a printable string ≤ 64 characters it is used, otherwise the server mints one.

## Response envelopes

**Single object** — the object itself, plus `meta`:

```json
{ "conversation_id": "conv_p9876", "…": "…", "meta": { "request_id": "req_01J8Z9K3AA", "environment": "production" } }
```

**List** — `data` + `pagination` + `meta`:

```json
{
  "data": [ { "…": "…" } ],
  "pagination": { "limit": 50, "has_more": true, "next_cursor": "eyJ2IjoxLCJz…" },
  "meta": { "request_id": "req_01J8Z9K3AA", "environment": "production", "applied_default_range": false, "from": "2026-08-01T00:00:00Z", "to": "2026-09-01T00:00:00Z" }
}
```

**Error** — one shape for every non-2xx response:

```json
{
  "error": {
    "code": "invalid_date_range",
    "message": "'from' must be earlier than 'to'.",
    "field": "from",
    "request_id": "req_01J8Z9K2QW"
  },
  "meta": { "request_id": "req_01J8Z9K2QW", "environment": "production" }
}
```

`field` is the offending parameter or body key, or `null`. `message` is for humans and may change; branch on `code`, which is stable.

`meta.environment` is `"production"` on every response, including errors.

## Error catalogue

The complete list of `error.code` values. Codes are stable for the life of v1; new codes may be added (treat unknown codes by HTTP status).

| HTTP | `code` | `field` | When |
|---|---|---|---|
| 400 | `invalid_json` | `null` | Body is not valid JSON |
| 400 | `invalid_date` | param | Not strict ISO-8601 (`2026-13-01`, missing time part) |
| 400 | `invalid_date_range` | `from` | `from` ≥ `to`, or window unsupported |
| 400 | `invalid_cursor` | `cursor` | Tampered, expired (> 7 days), issued under different filters, or issued to another credential |
| 400 | `invalid_limit` | `limit` | Out of bounds or not an integer |
| 400 | `invalid_id` | param | Id has the wrong prefix/shape (e.g. `conv_x1`) |
| 400 | `unsupported_filter` | param | Unknown filter, unknown enum value, or `include` value not allowed on this endpoint (e.g. `include=transcript` on the list) |
| 400 | `unsupported_field` | body key | Unknown key in a PATCH/POST body (e.g. `user_id` on `PATCH /agents/{agt}`) |
| 400 | `missing_param` | param | Required parameter absent (e.g. `from`/`to` on metrics) |
| 400 | `validation_error` | body key | Wrong type or out-of-range value (e.g. `"temperature": "hot"`) |
| 400 | `empty_update` | `null` | PATCH body `{}` |
| 400 | `invalid_scope` | `scopes` | Unknown or non-grantable scope |
| 400 | `invalid_url` | `url` | Not `https://`, private/loopback/link-local host, or a UiriX host (KB URLs, webhook URLs) |
| 400 | `invalid_phone` | field | Not E.164 (`0501234567`, `+972 50-123-4567`) |
| 400 | `invalid_email` | field | Malformed email |
| 400 | `invalid_type` | field | Wrong JSON type (e.g. `equalweb_agent_id: 1619` instead of `"1619"`) |
| 400 | `invalid_text` | `text` | Empty or whitespace-only message |
| 400 | `text_too_long` | `text` | Message above 10,000 characters |
| 400 | `unsupported_event` | `events` | Unknown webhook event name |
| 400 | `unsupported_policy` | `policy.voicemail` | `leave_message` requested (not supported in v1) |
| 400 | `not_api_conversation` | `null` | `POST …/messages` / `/identify` / `/end` on a conversation not created via the API |
| 401 | `missing_credentials` | `null` | No bearer credential |
| 401 | `invalid_credentials` | `null` | Unknown / revoked key |
| 401 | `expired_token` | `null` | Key past `expires_at` |
| 402 | `payment_required` | `null` | Account balance / subscription does not allow a billable action (chat turn, call) |
| 403 | `forbidden_account` | `account_id` | Account not on the credential (see [Scopes](scopes.md)) |
| 403 | `insufficient_scope` | `null` | Missing scope (message names it) |
| 403 | `ip_not_allowed` | `null` | Source IP outside the key's allow-list |
| 403 | `plan_limit_reached` | `null` | Account plan does not allow more agents |
| 403 | `dnc_blocked` | `to_e164` | Number is on the account's do-not-call list |
| 403 | `signature_invalid` | `null` | Signed media URL signature mismatch |
| 403 | `url_expired` | `null` | Signed media URL past `exp` |
| 404 | `not_found` | `null` | Unknown route |
| 404 | `conversation_not_found` | `null` | Conversation missing or belongs to another account |
| 404 | `agent_not_found` | `agent_id` | Agent missing or belongs to another account |
| 404 | `recording_not_found` | `null` | Conversation has no recording (chat, or call not recorded) |
| 404 | `profile_not_found` | `profile_id` | Outbound profile missing, inactive, belongs to another account, or (on `POST /calls`) to another agent |
| 404 | `list_not_found` | `list_id` | Unknown contact list, or one belonging to another account |
| 404 | `campaign_not_found` | `campaign_id` | Unknown campaign, or one belonging to another account |
| 409 | `turn_in_progress` | `null` | A message is already being processed on this conversation |
| 409 | `conversation_already_ended` | `null` | `/end` or `/messages` on a closed conversation |
| 409 | `import_exists` | `external_session_id` | `POST /conversations/import` with an `external_session_id` already imported for the account — `error.conversation_id` names it |
| 409 | `email_exists` | `email` | `POST /users` with an existing email |
| 409 | `version_label_exists` | `label` | Prompt version label already used for this agent + type |
| 409 | `agent_has_no_phone_number` | `agent_id` | `POST /calls` for an agent without an active number |
| 409 | `from_number_not_owned` | `from_e164` | Caller id not owned by the account |
| 409 | `call_not_cancellable` | `null` | `DELETE /calls/{call}` on a call already answered/finished |
| 409 | `call_deferral_refused` | `null` | `POST /calls` could not place the call now and the request never asked it to wait. Refused rather than answered `202` with a time you did not choose; carries `would_have_been_scheduled_at` and `recipient_timezone` |
| 409 | `agent_build_in_progress` | `agent_id` | `POST /agents/{agt}/build` while a build of that agent is still running (carries `started_at`, `step`); poll `GET /agents/{agt}/build` |
| 409 | `agent_has_profiles` | `agent_id` | dashboard delete of an agent that still owns outbound profiles (not reachable through the API) |
| 409 | `member_exists` | the external id | `POST /partners/members` / `…/convert` for a customer that is already a partner member (carries `user_id`, `matched_on`); one membership per customer |
| 409 | `profile_build_in_progress` | `profile_id` | `POST /profiles/{prf}/build` while a build of that profile is still running (carries `started_at`); poll `GET /profiles/{prf}/build` |
| 409 | `list_name_exists` | `name` | `POST /agents/{agt}/lists` with a name the agent already has (case-insensitive) — see [Campaigns](campaigns.md) |
| 409 | `list_limit_exceeded` | `rows` | `POST /lists/{lst}/import` would take the list past 10,000 contacts; nothing is imported (carries `items_count`, `would_add`, `max_items`) |
| 409 | `campaign_state_conflict` | `null` | `start` / `pause` / `stop` not allowed from the campaign's current status, a `start_date` / `end_date` in the way, or nothing left to call (carries `status`) |
| 410 | `conversation_deleted` | `null` | Erased in the dashboard or by operations — tombstone exists (not reachable through the API since 2026-09-06) |
| 410 | `transcript_expired` | `null` | Erased by retention; body carries `retention_expires_at` |
| 410 | `recording_expired` | `null` | Recording past retention; body carries `retention_expires_at` |
| 413 | `payload_too_large` | `null` | JSON body > 2 MB or upload > 10 MB |
| 429 | `rate_limited` | `null` | Class limit exceeded; `Retry-After` set |
| 429 | `call_quota_exceeded` | `null` | Daily call quota reached; `Retry-After` set |
| 429 | `quota_exceeded` | `null` | A daily account quota is spent (carries `quota`, `limit`, `used`, `resets_at`); `Retry-After` counts to 00:00 UTC. See [Accounts → Daily quotas](accounts-and-users.md#daily-quotas) |
| 409 | `build_concurrency_exceeded` | `null` | The account already has `max_concurrent_builds` builds running (carries `limit`, `running`); wait for one to finish |
| 500 | `internal_error` | `null` | Unexpected failure; always carries `request_id`, never a stack trace |
| 503 | `dialer_unavailable` | `null` | `POST /campaigns/{cmp}/start` · `pause` · `stop` when the outbound dialer did not answer; the campaign is unchanged — retry |
| 503 | — | — | `/health`, when the database is down (`status: "down"`) |

`410` bodies for retention add the timestamp so you know it is gone for good:

```json
{ "error": { "code": "transcript_expired", "message": "Transcript was deleted by the retention policy.", "field": null, "request_id": "req_…", "retention_expires_at": "2026-08-30T00:00:00Z" }, "meta": { "…": "…" } }
```

## Rate limits

Limits are **per credential** (per IP for unauthenticated failures and media), sliding one-minute windows, grouped by endpoint class:

| Class | Endpoints | Limit |
|---|---|---|
| `read` | Every GET not listed below | 60 / min |
| `metrics` | `GET /metrics/conversations` | 20 / min |
| `audio` | `GET /conversations/{conv}/recording` | 30 / min |
| `backfill` | `GET /conversations` with `from` older than 30 days (needs `conversations:backfill`) | 10 / min, `limit` ≤ 500 |
| `write` | POST / PATCH not in `calls`, `build` or `chat` — configuration writes | 60 / min |
| `calls` | `POST /calls`, `DELETE /calls/{call}` | 20 / min (plus the account's daily/concurrent quotas) |
| `build` | Anything that starts a crawl, a model build or an outbound HTTP call: `POST /accounts/{acc}/agents/build`, `POST /agents/{agt}/build`, `POST /profiles/{prf}/build`, `POST /profiles/{prf}/clone`, `POST /agents/{agt}/kb/documents`, `POST /agents/{agt}/kb/reindex`, `POST /webhooks/endpoints/{whk}/test`, `POST /webhooks/deliveries/{dlv}/replay`, `POST /partners/members` | 5 / min (plus the account's daily quotas, since 2026-09-06) |
| `chat` | `POST /conversations`, `POST /conversations/{conv}/messages` | 30 / min (plus `max_chat_messages_per_day`) |
| `admin` | Every `/admin/*` route (master key only — see [Admin](admin.md)), whatever the verb | 120 / min |
| unauthenticated | 401 responses, per IP | 20 / min |
| media | `GET /media/recordings/*`, per IP | 30 / min |

There is no separate burst allowance: each class is a flat sliding count over the last minute.

Headers on every metered response (not on `/health`, `/docs`, `/openapi.json`):

```http
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 41
X-RateLimit-Reset: 1787412000      # unix seconds when the window resets
Retry-After: 12                    # 429 only, seconds
```

**429 semantics.** A `429 rate_limited` is safe to retry after `Retry-After` and **does not consume quota** — after the window resets `X-RateLimit-Remaining` is back to the full limit, not reduced by the rejected calls. Limits are checked before the request does any work, so a 429 has no side effects.

## Pagination

Lists use an opaque **keyset cursor**, never page numbers.

| Param | Default | Notes |
|---|---|---|
| `limit` | 50 (`/conversations`), 50 elsewhere | 1–200; up to 500 on `/conversations` with `conversations:backfill` |
| `cursor` | — | Value of `pagination.next_cursor` from the previous page |
| `sort` | `-started_at` | `started_at`, `-started_at`, `updated_at`, `-updated_at` on `/conversations` |

Rules:

- **Terminal state** is `has_more: false` **and** `next_cursor: null`. Nothing else ends a walk.
- A cursor encodes the filter set it was issued under (dates, `account_id`, `agent_type`, `status`, `sort`, `include`, `limit`…). Reusing it with **any** different filter is `400 invalid_cursor`. Reusing it with a different credential is also `400 invalid_cursor`.
- Cursors are signed and valid for **7 days** (the spec minimum is 24 h). Expired or tampered → `400 invalid_cursor`, never `500`.
- Ordering is stable (`started_at`, store, id): walking `limit=10` through 100 conversations yields exactly 100 distinct ids with no gaps or duplicates, even while new conversations arrive.
- A cursor issued without explicit dates stays valid across midnight: the default window is materialised into explicit `from`/`to` when the cursor is minted.

### Default 30-day window

`GET /conversations` without `from`, `to` **or** `updated_since` returns the **last 30 days** of `started_at` and says so:

```json
"meta": { "applied_default_range": true, "from": "2026-08-04T14:32:07Z", "to": "2026-09-03T14:32:07Z" }
```

`meta.from` / `meta.to` are always present on that list — echoing your parameters when you passed them. `from` is inclusive, `to` exclusive. `from` older than 30 days puts the request in the `backfill` class and requires `conversations:backfill`.

## Timestamps and time zones

- All timestamps are ISO-8601 **UTC with `Z`**: `2026-09-03T14:32:07Z`. Millisecond precision may appear (`…07.123Z`); never offsets other than `Z`, never epoch-only fields (except the two unix-seconds headers `X-RateLimit-Reset` and `X-Uirix-Timestamp`).
- Inputs must be strict ISO-8601 with a time part and a zone (`Z` or `±hh:mm`); dates without a time are `400 invalid_date`. Inputs with an offset are converted to UTC.
- Only `/metrics/conversations` buckets in a chosen IANA time zone (`timezone=Asia/Jerusalem`); the bucket labels are local dates, every timestamp is still UTC. See [Metrics](metrics.md).
- A calling window for an outbound call — which you must ask for; `POST /calls` has no default one — is evaluated in the **recipient's** time zone. See [Calls](calls.md).

## Identifiers

Opaque strings with a resource prefix. Compare them as strings; never parse, never assume numeric or sortable.

| Prefix | Resource |
|---|---|
| `acc_` / `usr_` | Account / user (same underlying tenant; `acc_17` and `usr_17` refer to one tenant) |
| `agt_` | Agent |
| `conv_p` · `conv_u` · `conv_d` | Conversation — widget & API chat · phone & realtime voice · dashboard chat |
| `call_` | Outbound call |
| `key_` | API credential |
| `whk_` · `dlv_` | Webhook endpoint · delivery (stable across retries) |
| `doc_f` · `doc_c` | KB document — uploaded file / text · crawled page |
| `pv_` | Prompt version |
| `dnc_` | Do-not-call entry |
| `lst_` · `cmp_` | Contact list · campaign ([Campaigns](campaigns.md)); `itm_` (a contact on a list) and `qcl_` (a contact's row in a campaign) are read-only |

## `null` vs missing

Every key documented for an object is **always present**. Unknown or not applicable → `null`. Arrays that are empty are `[]`, not `null`, unless the docs say `array | null` (e.g. `turn.sources`). Nested objects — `identity.utm`, `summary.escalated_to_human`, `channel_detail` — are always objects with all their keys. A deprecated field keeps being sent as `null` for one full major version before removal.

## Request bodies and encoding

- `Content-Type: application/json; charset=utf-8`; bodies up to 2 MB; multipart uploads up to 10 MB.
- Unknown body keys are rejected (`400 unsupported_field`) rather than ignored — typos never silently no-op.
- UTF-8 everywhere; Hebrew, emoji and RTL text round-trip byte-for-byte.
- `DELETE` exists only as a cancellation (`DELETE /calls/{call}`, `DELETE /credentials/{key}`); no data is deleted through the API (2026-09-06).
- **A `POST` with no body must still send `Content-Type: application/json` and `{}` (or `Content-Length: 0`).** The production edge (IIS ARR) answers a body-less POST with an HTML `411 Length Required` before the API sees it — seen on `POST /credentials/{key}/rotate` on 2026-09-10. Every example in these pages sends `{}`.

## Versioning

The contract is `v1`; the path segment is the major version. Additive changes (new keys, new enum values flagged in [Enums](enums.md), new endpoints) ship without a version bump. Breaking changes go to `/v2`. Webhook payloads carry `api_version: "v1"`. Every conversation carries the `agent_version` / `prompt_version` / `model` that produced it — those are the agent's versions, not the API's.

---

**UiriX Public API v1** · [Overview](README.md) · [Quickstart](quickstart.md) · [Authentication](authentication.md) · [Scopes](scopes.md) · [Conventions](conventions.md) · [Accounts & Users](accounts-and-users.md) · [Agents](agents.md) · [Capabilities](capabilities.md) · [Profiles](profiles.md) · [Conversations](conversations.md) · [Chat via API](chat-via-api.md) · [Widget Events](widget-events.md) · [Webhooks](webhooks.md) · [Calls](calls.md) · [Metrics](metrics.md) · [Voices](voices.md) · [Enums](enums.md) · [Partners](partners.md) · [Admin](admin.md) · [MCP](mcp.md) · [Changelog](changelog.md) · [OpenAPI](openapi.yaml)


<!-- ===== scopes.md ===== -->

# Scopes

A credential carries an explicit list of scopes. Read and write scopes are separate on purpose: an analytics credential physically cannot modify an agent. Master-only scopes are implicit on the master key and cannot be granted to personal keys.

There is **no deletion scope**: since 2026-09-06 (Nir) nothing is deleted through the API, every `DELETE` route for data is gone and `conversations:delete` no longer exists. The two remaining `DELETE` verbs are cancellations — `DELETE /calls/{call}` (`calls:write`) and `DELETE /credentials/{key}` (`credentials:manage`). (`agents:admin` was non-grantable from 2026-09-04 to 2026-09-06 and is grantable again — user-level.)

## Read scopes

| Scope | Unlocks |
|---|---|
| `accounts:read` | `GET /accounts`, `GET /accounts/{acc}`, `GET /accounts/{acc}/limits` |
| `agents:read` | `GET /agents/{agt}/build`, `GET /accounts/{acc}/agents`, `GET /agents/{agt}`, `GET /agents/{agt}/prompts[/{pv}]`, `GET /agent-versions`, `GET /agents/{agt}/widget`, `GET /agents/{agt}/voice`, `GET /voices`, `GET /capabilities`, `GET /outbound-capabilities`, `GET /agents/{agt}/profiles`, `GET /profiles/{prf}`, `GET /profiles/{prf}/build`, `GET /agents/{agt}/connectors` |
| `kb:read` | `GET /agents/{agt}/kb/documents`, `GET /agents/{agt}/kb/status` |
| `conversations:read` | `GET /conversations` (all `include` values), `GET /conversations/{conv}` (transcript + summary + identity + metrics), `GET /conversations/deleted` |
| `conversations:backfill` | Lets `GET /conversations` use `from` older than 30 days with `limit` up to 500 (backfill rate class, 10 req/min). Requires `conversations:read` as well. |
| `summaries:read` | `GET /conversations/{conv}/summary`; `GET /conversations?include=summary` (summary only — no transcript, no identity). Without `conversations:read`, `GET /conversations/{conv}` is `403`. |
| `audio:read` | `GET /conversations/{conv}/recording` (signed URL). Highest-sensitivity asset — issue it on its own key. |
| `metrics:read` | `GET /metrics/conversations` |
| `usage:read` | `GET /accounts/{acc}/usage`, `GET /accounts/{acc}/balance` |
| `calls:read` | `GET /calls`, `GET /calls/{call}`; batch outbound ([campaigns](campaigns.md)): `GET /agents/{agt}/lists`, `GET /lists/{lst}`, `GET /lists/{lst}/items`, `GET /campaigns`, `GET /campaigns/{cmp}`, `GET /campaigns/{cmp}/calls` |
| `dnc:read` | `GET /dnc` |

## Write scopes

Every scope above — read and write — is grantable to a personal key. The master-only scopes (`admin:all_accounts`, `users:manage`, `audit:read`) are implicit on the master key and are never granted.

| Scope | Unlocks | Grantable |
|---|---|---|
| `accounts:write` | `PATCH /accounts/{acc}` (external ids, default redaction, retention) | yes |
| `agents:write` | `POST /agents/{agt}/build` (re-run), `PATCH /agents/{agt}`, `POST /agents/{agt}/prompts`, `POST /agents/{agt}/prompts/{pv}/restore`, `PATCH /agents/{agt}/widget`, `POST /agents/{agt}/widget/regenerate-token`, `PATCH /agents/{agt}/voice` (+ the §11.2 aliases), `POST /agents/{agt}/profiles`, `PATCH /profiles/{prf}`, `DELETE /profiles/{prf}`, `POST /profiles/{prf}/clone` | yes |
| `agents:admin` | `POST /accounts/{acc}/agents/build` (create from a website — the only creation route). No blank create, no delete: agents are created only by the V4 build and are never deleted (Nir, 2026-09-06) | yes (since 2026-09-06 — user-level; the master key is for creating accounts) |
| `kb:write` | `POST /agents/{agt}/kb/documents`, `POST /agents/{agt}/kb/reindex`, `PATCH /agents/{agt}/kb/summary` (no delete — dashboard only) | yes |
| `conversations:write` | `POST /conversations`, `POST /conversations/{conv}/messages`, `POST /conversations/{conv}/identify`, `POST /conversations/{conv}/end`, `POST /conversations/import` (a finished external call → the call log) | yes |
| `calls:write` | `POST /calls`, `DELETE /calls/{call}`; batch outbound ([campaigns](campaigns.md)): `POST /agents/{agt}/lists`, `POST /lists/{lst}/import`, `POST /campaigns`, `POST /campaigns/{cmp}/start` (needs `"confirmed": true`), `POST /campaigns/{cmp}/pause`, `POST /campaigns/{cmp}/stop`. A sandbox key is refused on every one of these writes | yes |
| `dnc:write` | `POST /dnc` (no delete — lifting a do-not-call entry is a dashboard action) | yes |
| `webhooks:manage` | Everything under `/webhooks/*` — endpoints (create, read, patch; no delete — disable with `enabled: false`), secrets, test, deliveries, replay | yes |
| `credentials:manage` | `GET /credentials`, `POST /users/{usr}/credentials` **for the credential's own user only**, `POST /credentials/{key}/rotate`, `DELETE /credentials/{key}` (own keys) | yes |

## Deletion is not an API capability

**Product ruling, 2026-09-04, extended 2026-09-06 (Nir).** First `conversations:delete` was withheld from personal keys; then, on the afternoon of 6 September, every data `DELETE` route was removed outright — conversations, subjects, outbound profiles, knowledge documents, do-not-call entries and webhook endpoints. The paths answer `404 not_found` for every key, the master key included, and `conversations:delete` is no longer a scope: `POST /users/{usr}/credentials` and the dashboard key page answer `400 invalid_scope` with `field: "scopes"` if it appears. Deleting is the account owner's dashboard action; the retention sweep is the only unattended erasure, and it writes tombstones (`GET /conversations/deleted`) and fires `conversation.deleted` like before.

`NON_GRANTABLE_WRITE_SCOPES` in `ext-api/middleware/scopes.ts` is now empty and stays as the single place a future non-grantable scope would be declared.

`agents:write`, `kb:write`, `conversations:write` and `calls:write` are unaffected — updating an agent, its knowledge base or a conversation is a different act from destroying one.

## Master-only scopes

Implicit on `uirix_mk_live_…`; never grantable.

| Scope | Unlocks |
|---|---|
| `admin:all_accounts` | Cross-account access: `GET /accounts` lists every account; data endpoints work for any account named in `X-Uirix-Account`. Every such call is audit-logged with `cross_account: 1`. |
| `users:manage` | `POST /users`, `GET /users`, `GET /users/{usr}`, `PATCH /users/{usr}`, and credential issuance for **any** user (including `account_ids` beyond the user's own account). |
| `audit:read` | `GET /audit` (JSON / CSV export). |
| *(master key itself)* | The management surface `/admin/*` ([admin.md](admin.md)) — platform overview, customer dossiers, cross-account agents, system status/errors, config, migrations, and the journaled writes (wallet credits, plan/status changes, login links, model changes, agent actions). It is not a scope: only the master key reaches it, a personal key gets `403 insufficient_scope` whatever scopes it holds. Rate class `admin` (120/min). |

## Scopes through the MCP connector

The [MCP connector](mcp.md) (Claude, ChatGPT) signs a user in with OAuth and creates a personal key with the scopes approved on the consent screen. The screen offers **user-level scopes only**, in four groups:

| Consent group | Default | Scopes granted |
|---|---|---|
| Read | on, cannot be switched off | `accounts:read`, `usage:read`, `agents:read`, `kb:read`, `conversations:read`, `summaries:read`, `metrics:read`, `calls:read`, `dnc:read` |
| Changes to agents | on | `agents:write`, `agents:admin`, `kb:write`, `conversations:write` |
| Outbound calls | off | `calls:write`, `dnc:write` |
| Webhooks | on | `webhooks:manage` |

Never granted through the connector: the master-only scopes (`admin:all_accounts`, `users:manage`, `audit:read`), `/admin/*`, `credentials:manage`, `audio:read` and `conversations:backfill`. `accounts:write` is user-level but no connector tool uses it; the running server's `scopes_supported` says whether it is offered. Approving the screen again with the same client rewrites the key's scopes; a group left off makes its tools answer `insufficient_scope`. A personal key used with the connector instead of OAuth carries its own scopes and sees no consent screen.

## Endpoints without a scope

`GET /health`, `GET /openapi.json`, `GET /docs` need no credential. `GET /media/recordings/{sid}.mp3` is authorised by its signed URL, not by a bearer key.

## `403 forbidden_account` vs `403 insufficient_scope`

Both are 403, but they mean different things and are checked in this order:

| Check | Error | Meaning | Fix |
|---|---|---|---|
| 1. Does the credential cover every account the request names (or implies)? | `forbidden_account` (`field: "account_id"`) | You asked for `acc_99` but the key is issued for `acc_17`; or you used the master key on a data route without `X-Uirix-Account`. | Use the right key, or pass an account the key covers. |
| 2. Does the credential hold the scope this endpoint requires? | `insufficient_scope` (message names the missing scope) | The key covers the account but was issued without, e.g., `agents:write`. | Issue/rotate a key with the scope. |

Neither error ever degrades to an empty `200`: a silent empty page hides integration bugs for weeks.

```json
{
  "error": {
    "code": "insufficient_scope",
    "message": "This endpoint requires scope 'agents:write'.",
    "field": null,
    "request_id": "req_01J8Z9K2QW"
  },
  "meta": { "request_id": "req_01J8Z9K2QW", "environment": "production" }
}
```

## Recommended key layouts

| Consumer | Scopes |
|---|---|
| Warehouse / analytics sync | `accounts:read agents:read conversations:read conversations:backfill summaries:read metrics:read` |
| CRM enrichment worker (webhook receiver + occasional read) | `conversations:read summaries:read webhooks:manage` |
| Low-privilege dashboard | `summaries:read metrics:read` |
| Agent-ops (prompt / KB deploys, outbound profiles) | `agents:read agents:write kb:read kb:write` |
| Outbound dialer | `calls:read calls:write dnc:read dnc:write conversations:read agents:read` — add `agents:write` only if it authors profiles rather than just naming them |
| Audio review tool | `conversations:read audio:read` — on its **own** key with an IP allow-list |

---

**UiriX Public API v1** · [Overview](README.md) · [Quickstart](quickstart.md) · [Authentication](authentication.md) · [Scopes](scopes.md) · [Conventions](conventions.md) · [Accounts & Users](accounts-and-users.md) · [Agents](agents.md) · [Capabilities](capabilities.md) · [Profiles](profiles.md) · [Conversations](conversations.md) · [Chat via API](chat-via-api.md) · [Widget Events](widget-events.md) · [Webhooks](webhooks.md) · [Calls](calls.md) · [Campaigns](campaigns.md) · [Metrics](metrics.md) · [Voices](voices.md) · [Enums](enums.md) · [Partners](partners.md) · [Admin](admin.md) · [MCP](mcp.md) · [Changelog](changelog.md) · [OpenAPI](openapi.yaml)


<!-- ===== accounts-and-users.md ===== -->

# Accounts & Users

Two layers:

- **Master-key endpoints** (`/users`, `/credentials`, `/audit`) — tenant provisioning and key management. `POST /users`, `GET /users*`, `PATCH /users/{usr}` and `GET /audit` need the master key (`users:manage` / `audit:read`). The credential endpoints are also usable by a personal key holding `credentials:manage`, **for its own user only**.
- **Account endpoints** (`/accounts*`) — what an integration reads about the tenant it serves: profile, limits, usage, balance.

An account and a user are the same tenant seen from two sides: `usr_17` provisions and owns keys; `acc_17` is what conversations, agents and calls belong to. The numeric part is the same.

Variables used below: `$B` base URL, `$MK` master key, `$SK` personal key, `$J` = `Content-Type: application/json`.

---

## Users (master key)

### User object

| Field | Type | Notes |
|---|---|---|
| `user_id` | string | `usr_17` |
| `account_id` | string | `acc_17` — the account this user owns |
| `email` | string | Unique |
| `name` | string | |
| `company_name` | string \| null | |
| `plan` | `trial` \| `starter` \| … | Plan slug — the plan row's **name**, lower-cased (spaces → `_`). **What you read back is not what you sent:** `plan: "trial"` at creation puts the user on the trial plan row, which is named *Guest*, so every read answers `plan: "guest"` with `status: "trial"` (the status comes from `plans.is_trial`). Decide "is this a trial" by `status`, never by the plan slug. |
| `status` | `active` \| `suspended` \| `trial` | |
| `language` | BCP-47 \| null | UI language |
| `timezone` | IANA \| null | |
| `currency_code` | string | `USD`, `ILS`… |
| `external_ids` | object | Free-form map (`hubspot_company_id`, `equalweb_customer_id`…). Values are strings. |
| `limits` | object | `{ "max_concurrent_calls": 2, "max_calls_per_day": 200, "max_agent_creates_per_day": 10, "max_builds_per_day": 30, "max_kb_documents_per_day": 100, "max_chat_messages_per_day": 2000, "max_webhook_tests_per_day": 100, "max_concurrent_builds": 2 }` — see [Daily quotas](#daily-quotas) |
| `retention` | object | `{ "transcript_days": 730, "summary_days": 1095, "recording_days": 90 }` |
| `default_redaction` | boolean | Store/serve transcripts redacted by default |
| `created_at` / `updated_at` | ISO-8601 | |

### `POST /users` — provision a tenant

| Body field | Type | Required | Notes |
|---|---|---|---|
| `email` | string | yes | Must be unique |
| `name` | string | yes | |
| `password` | string | no | If omitted a random password is generated and **never returned** |
| `plan` | `trial` \| `starter` \| `payg` | yes | |
| `company_name` | string | no | |
| `language` | BCP-47 | no | |
| `timezone` | IANA | no | |
| `currency_code` | string | no | Default `USD` |
| `external_ids` | object | no | String values |

```bash
curl -s -H "Authorization: Bearer $MK" -H "$J" -X POST "$B/users" -d '{
  "email": "ops@example-shop.de", "name": "Example Shop", "plan": "starter",
  "company_name": "Example Shop GmbH", "language": "de", "timezone": "Europe/Berlin", "currency_code": "USD",
  "external_ids": { "hubspot_company_id": "5256925284" }
}' | jq .
```

```json
{
  "user_id": "usr_18", "account_id": "acc_18", "email": "ops@example-shop.de", "name": "Example Shop",
  "company_name": "Example Shop GmbH", "plan": "starter", "status": "active", "language": "de",
  "timezone": "Europe/Berlin", "currency_code": "USD",
  "external_ids": { "hubspot_company_id": "5256925284" },
  "limits": { "max_concurrent_calls": 2, "max_calls_per_day": 200 },
  "retention": { "transcript_days": 730, "summary_days": 1095, "recording_days": 90 },
  "default_redaction": false,
  "created_at": "2026-09-03T15:00:00Z", "updated_at": "2026-09-03T15:00:00Z",
  "meta": { "request_id": "req_01J8ZB1A2B", "environment": "production" }
}
```

**Errors:** `409 email_exists` (`field: "email"`) · `400 validation_error` (bad email, unknown plan) · `403 insufficient_scope` (personal key).

### The master key needs `X-Uirix-Account` on every data route

Creating the user is a master-key route, but **reading that user's agents, conversations or calls is not**: the master key is refused on data routes unless the request names the account — `-H "X-Uirix-Account: acc_3828"` — and the audit row is then flagged `cross_account: 1`. Without the header the answer is `403 forbidden_account`, not an empty list. See [Authentication](authentication.md).

### `plan: "payg"` and `wallet_credit_usd` (2026-09-06)

`plan: "payg"` puts the user on the pay-as-you-go plan (`plans.is_payg = 1`: $10 once at the start is the usage allowance, the wallet pays beyond it, paused under $5, never a monthly charge) with **no card** and a wallet credit — `wallet_credit_usd` (default: the configured partner credit, $89; `0` = none), recorded in `wallet_history`. `plan_expires_at` is 2036-01-01; the wallet ends the trial, not a date. Without a card the customer cannot buy a phone number (`402 payment_method_required` in the dashboard). `wallet_credit_usd` on any other plan is `400 validation_error`. The full partner flow — user + agent + login link in one call — is [Partners](partners.md).

### `POST /users/{usr}/login-links` — one-time dashboard login (master key)

See [Partners → login links](partners.md#post-usersusrlogin-links--a-login-link-on-demand--master-key): `{ "agent_id" }` lands on the agent's widget code page, `{ "next" }` on any in-app path; single use, expires in minutes.

### `GET /users` · `GET /users/{usr}` · `PATCH /users/{usr}`

- `GET /users?status=&limit=&cursor=` — cursor over `created_at, id`. Returns the list envelope of user objects.
- `GET /users/{usr}` — one user object.
- `PATCH /users/{usr}` — body may contain `status`, `external_ids`, `limits` (`max_concurrent_calls`, `max_calls_per_day`, and since 2026-09-06 the daily quotas `max_agent_creates_per_day`, `max_builds_per_day`, `max_kb_documents_per_day`, `max_chat_messages_per_day`, `max_webhook_tests_per_day`, plus `max_concurrent_builds`; `0` = no quota), `retention`, `default_redaction`. Unknown keys → `400 unsupported_field`.

```bash
curl -s -H "Authorization: Bearer $MK" -H "$J" -X PATCH "$B/users/usr_17" \
  -d '{"external_ids":{"hubspot_company_id":"5256925284"},"limits":{"max_calls_per_day":50}}' | jq '{external_ids, limits}'
```

**Errors:** `404 not_found` · `400 unsupported_field` / `validation_error` · `403 insufficient_scope`.

---

## Credentials

Full key semantics (types, rotation, expiry header, allow-list) are in [Authentication](authentication.md); this section is the endpoint reference.

### Credential object

| Field | Type | Notes |
|---|---|---|
| `key_id` | string | `key_42` |
| `user_id` | string | Owner |
| `name` | string | |
| `key_prefix` | string | First 24 characters of the secret — the only part ever shown again |
| `secret` | string | **Only on `POST …/credentials` and `POST …/rotate`** |
| `scopes` | string[] | |
| `account_ids` | string[] | Accounts the key may touch |
| `ip_allowlist` | string[] | |
| `status` | `active` \| `revoked` | |
| `expires_at` | ISO-8601 | |
| `last_used_at` | ISO-8601 \| null | |
| `rotated_from` | string \| null | `key_id` this key replaced |
| `created_at` / `revoked_at` | ISO-8601 \| null | |

### `POST /users/{usr}/credentials` — issue a key

Master key for any user; `credentials:manage` for the caller's own user (issuing for another user → `403 forbidden_account`). Body: `name`, `scopes[]`, `account_ids?`, `environment?`, `expires_in_days?` (≤ 365), `ip_allowlist?`.

```bash
curl -s -H "Authorization: Bearer $MK" -H "$J" -X POST "$B/users/usr_17/credentials" \
  -d '{"name":"warehouse-sync","scopes":["conversations:read","summaries:read","conversations:backfill"],"expires_in_days":365}' | jq .
```

Response `201` — the credential object **with `secret`** (see the example in [Authentication](authentication.md#issuing-keys)).

**Errors:** `400 invalid_scope` (`field: "scopes"`) · `400 validation_error` (`expires_in_days` > 365) · `403 forbidden_account` · `404 not_found`.

### `GET /users/{usr}/credentials` · `GET /credentials`

List credentials (no secrets, no hashes). `GET /credentials` returns the keys of the caller's own user; with the master key it lists every key (`?user_id=` to filter).

```bash
curl -s -H "Authorization: Bearer $SK" "$B/credentials" | jq '.data[] | {key_id, name, key_prefix, status, expires_at}'
```

### `POST /credentials/{key}/rotate`

Mints a successor with identical scopes / accounts / allow-list and a new secret. Old key stays live for 7 days (`grace_expires_at`) or until deleted.

```json
{ "key_id": "key_43", "secret": "uirix_sk_live_…", "key_prefix": "uirix_sk_live_pZ8wQ1nB", "rotated_from": "key_42", "grace_expires_at": "2026-09-10T15:05:00Z", "expires_at": "2027-09-03T15:05:00Z", "…": "…" }
```

**Errors:** `404 not_found` · `403 forbidden_account` (another user's key) · `400 validation_error` (key already revoked).

### `DELETE /credentials/{key}`

Immediate revoke → `204`. Next use → `401 invalid_credentials`.

---

## Audit (master key)

### `GET /audit`

| Param | Type | Notes |
|---|---|---|
| `from`, `to` | ISO-8601 | Default: last 24 h |
| `credential_id` | string | `key_42` or `master` |
| `account_id` | string | Rows that touched this account |
| `status_code` | int | |
| `format` | `json` \| `csv` | Default `json` (list envelope with cursor); `csv` streams a file |
| `limit`, `cursor` | | JSON only |

```bash
curl -s -H "Authorization: Bearer $MK" "$B/audit?from=2026-09-02T00:00:00Z&to=2026-09-03T00:00:00Z&format=csv" -o audit.csv
curl -s -H "Authorization: Bearer $MK" "$B/audit?credential_id=key_42&limit=1" | jq '.data[0]'
```

```json
{
  "request_id": "req_01J8Z9K3AA", "credential_type": "key", "credential_id": "key_42",
  "account_ids": ["acc_17"], "cross_account": false, "method": "GET", "path": "/v1/conversations",
  "status_code": 200, "error_code": null, "ip": "203.0.113.9", "user_agent": "equalweb-sync/1.0",
  "duration_ms": 184, "created_at": "2026-09-02T09:14:02Z"
}
```

`credential_type` ∈ `master` | `key` | `none`. Master-key rows always have `cross_account: true`. Rows are retained 12 months. `/health`, `/docs` and `/openapi.json` are not audited.

**Errors:** `403 insufficient_scope` (personal key) · `400 invalid_date_range`.

---

## Accounts

### Account object

| Field | Type | Notes |
|---|---|---|
| `account_id` | string | `acc_17` |
| `name` | string | |
| `domains` | string[] | Widget allowed domains |
| `status` | `active` \| `suspended` \| `trial` | |
| `plan` | string | Plan slug |
| `created_at` / `updated_at` | ISO-8601 | |
| `default_language` | BCP-47 \| null | |
| `agent_types_enabled` | string[] | Subset of `chat`, `phone`, `phone_outbound`, `dashboard`, derived from the account's agents and phone numbers |
| `external_ids` | object | Free-form string map — populate it so the account joins to your CRM without name matching |
| `default_redaction` | boolean | |
| `retention` | object | `{ "transcript_days", "summary_days", "recording_days" }` |

### `GET /accounts`

| Param | Notes |
|---|---|
| `status` | `active` \| `suspended` \| `trial` |
| `updated_since` | ISO-8601 |
| `limit`, `cursor` | |

A personal key sees only its own accounts; the master key sees every account (audit `cross_account: 1`).

```bash
curl -s -H "Authorization: Bearer $SK" "$B/accounts" | jq .
```

```json
{
  "data": [
    {
      "account_id": "acc_17", "name": "EqualWeb",
      "domains": ["equalweb.com", "www.equalweb.com", "login.equalweb.com"],
      "status": "active", "plan": "starter", "created_at": "2025-11-04T00:00:00Z", "updated_at": "2026-08-03T11:20:00Z",
      "default_language": "en-US", "agent_types_enabled": ["chat", "phone", "dashboard"],
      "external_ids": { "equalweb_customer_id": null, "hubspot_company_id": "5256925284" },
      "default_redaction": false,
      "retention": { "transcript_days": 730, "summary_days": 1095, "recording_days": 90 }
    }
  ],
  "pagination": { "limit": 50, "has_more": false, "next_cursor": null },
  "meta": { "request_id": "req_01J8ZB2C3D", "environment": "production" }
}
```

**Errors:** `403 insufficient_scope` (`accounts:read`).

### `GET /accounts/{acc}`

One account object. **Errors:** `403 forbidden_account` · `404 not_found`.

### `PATCH /accounts/{acc}` — scope `accounts:write`

| Body field | Type | Notes |
|---|---|---|
| `external_ids` | object | Merged key-by-key; set a key to `null` to clear it. Values must be strings. |
| `default_redaction` | boolean | |
| `transcript_retention_days` | int | 30–730 |
| `summary_retention_days` | int | 30–1095 |
| `recording_retention_days` | int | 1–365 |

```bash
curl -s -H "Authorization: Bearer $SK" -H "$J" -X PATCH "$B/accounts/acc_17" \
  -d '{"external_ids":{"equalweb_customer_id":"55231"},"default_redaction":false,"transcript_retention_days":365}' | jq '{external_ids, default_redaction, retention}'
```

**Errors:** `400 unsupported_field` · `400 validation_error` · `403 forbidden_account` / `insufficient_scope`.

### `GET /accounts/{acc}/limits` — scope `accounts:read`

Since 2026-09-06 the object also carries `quotas` — today's daily counters (`{ limit, used }` per kind, `day`, `resets_at`, `max_concurrent_builds`, `builds_in_progress`) — and `rate_limits.build` / `rate_limits.chat`. See [Daily quotas](#daily-quotas).

```json
"quotas": {
  "day": "2026-09-06", "resets_at": "2026-09-07T00:00:00.000Z",
  "agent_creates": { "limit": 10, "used": 1 }, "builds": { "limit": 30, "used": 3 },
  "kb_documents": { "limit": 100, "used": 0 }, "chat_messages": { "limit": 2000, "used": 412 },
  "webhook_tests": { "limit": 100, "used": 2 },
  "max_concurrent_builds": 2, "builds_in_progress": 1
}
```

### Daily quotas

Nir, 2026-09-06: "what stops someone sending create-agent, create-agent, create-agent?" Every operation that costs money or leaves the server now has a per-account daily quota on top of the per-minute rate class.

| Quota (per account, UTC day) | Default | Counts |
|---|---|---|
| `max_agent_creates_per_day` | 10 | `POST /accounts/{acc}/agents/build` |
| `max_builds_per_day` | 30 | agent build + re-run, `POST /profiles/{prf}/build`, `POST /agents/{agt}/kb/reindex` |
| `max_kb_documents_per_day` | 100 | `POST /agents/{agt}/kb/documents` (file, text or URL) |
| `max_chat_messages_per_day` | 2000 | `POST /conversations` (counts as one) and every `POST /conversations/{conv}/messages`; idempotent replays are free |
| `max_webhook_tests_per_day` | 100 | `POST /webhooks/endpoints/{whk}/test`, `POST /webhooks/deliveries/{dlv}/replay` |
| `max_concurrent_builds` | 2 | builds running at the same time (agent + profile); the next one is `409 build_concurrency_exceeded` |
| `max_calls_per_day` / `max_concurrent_calls` | 200 / 2 | `POST /calls` (unchanged, see [Calls](calls.md)) |

`0` means no quota. A refused request is `429 quota_exceeded` with `quota`, `limit`, `used`, `resets_at` (next 00:00 UTC) and a `Retry-After` header counting to it; nothing is created and the counter does not move. Quotas are per **account**, whichever key is used; the master key changes them with `PATCH /users/{usr} { "limits": { … } }`. Today's counters are in `GET /accounts/{acc}/limits` under `quotas`.

The per-minute classes live in memory on process 3000 (a restart resets a minute, which is harmless); the daily counters live in the database (`ext_account_usage_daily`, one atomic update per consumption) so a day survives a restart and two requests cannot both take the last unit. Schema: [production-schema.md](production-schema.md).

```json
{
  "account_id": "acc_17",
  "max_concurrent_calls": 2,
  "max_calls_per_day": 200,
  "calls_today": 14,
  "calls_in_progress": 1,
  "calls_remaining_today": 186,
  "counts_campaign_calls": false,
  "outbound_plan": {
    "limit": 2000, "used": 312, "remaining": 1688,
    "period_start": "2026-09-01T00:00:00.000Z", "campaigns_allowed": true, "enforced_on_api_calls": false
  },
  "rate_limits": { "read": 60, "metrics": 20, "audio": 30, "backfill": 10, "write": 60, "calls": 20 },
  "meta": { "request_id": "req_01J8ZB3E4F", "environment": "production" }
}
```

`calls_today` counts calls created since 00:00 UTC. Exceeding `max_calls_per_day` makes `POST /calls` answer `429 call_quota_exceeded`; see [Calls](calls.md).

**Since 2026-09-30 — "how many calls may I still place?"**

| Field | Meaning |
|---|---|
| `calls_remaining_today` | `max(0, max_calls_per_day − calls_today)` — what `POST /calls` will still accept until 00:00 UTC. `null` when the account has no daily cap (`max_calls_per_day` `0`) |
| `counts_campaign_calls` | Always `false`: `calls_today`, `calls_in_progress` and therefore `calls_remaining_today` count the calls placed through `POST /calls` (the `api_calls` table). Calls a **dashboard campaign** places are not in them and do not reduce the daily cap |
| `outbound_plan.limit` | The plan's monthly outbound-call allowance; `null` when the plan has no cap (or the account has no subscription) |
| `outbound_plan.used` | Outbound calls counted against it in the current period: `outbound_campaign` usage rows since `period_start`. Calls placed through `POST /calls` run on the dialer's hidden job and are written the same way, so they count here |
| `outbound_plan.remaining` | `max(0, limit − used)`; `null` with no cap |
| `outbound_plan.period_start` | Start of the counting period (UTC), the way the dashboard computes it: the subscription's reset date, or its creation date while the reset date lies in the future. `null` when the subscription has neither |
| `outbound_plan.campaigns_allowed` | `false` on the `Guest` plan, which the dashboard refuses to run campaigns for |
| `outbound_plan.enforced_on_api_calls` | Always `false`. The cap is checked when a **dashboard campaign** is created or started (`403 Outbound calls limit reached`); `POST /calls` does not check it — its gates are the daily cap above and the balance (`402 payment_required`, see [balance](#get-accountsaccbalance--scope-usageread)) |

`outbound_plan` is context, not a promise: read it to tell the user how much of the plan they have used, not to predict a refusal from `POST /calls`.

### `GET /accounts/{acc}/usage` — scope `usage:read`

| Param | Notes |
|---|---|
| `from`, `to` | ISO-8601, required |
| `group_by` | `agent` (default) \| `agent_type` \| `date` |

```bash
curl -s -H "Authorization: Bearer $SK" "$B/accounts/acc_17/usage?from=2026-08-27T00:00:00Z&to=2026-09-03T00:00:00Z&group_by=agent" | jq .
```

```json
{
  "data": [
    { "agent_id": "agt_245", "conversations": 312, "voice_seconds": 18420, "tokens": 1842000, "cost_usd": 41.27 },
    { "agent_id": "agt_246", "conversations": 40,  "voice_seconds": 0,     "tokens": 160200,  "cost_usd": 2.11 }
  ],
  "totals": { "conversations": 352, "voice_seconds": 18420, "tokens": 2002200, "cost_usd": 43.38 },
  "meta": { "request_id": "req_01J8ZB4G5H", "environment": "production", "from": "2026-08-27T00:00:00Z", "to": "2026-09-03T00:00:00Z", "group_by": "agent", "currency_code": "USD" }
}
```

`cost_usd` is the billed amount (after the account's session multipliers) and is **always USD** — `meta.currency_code` is `USD` and the account's own payment currency is `meta.account_currency_code` (before 30 Sept 2026 `currency_code` carried the account currency next to USD amounts, which read as a mislabelled currency). `conversations` counts billed sessions, i.e. the number of calls and chats.

**`group_by`** takes one to three dimensions, comma-separated and each once — `agent` (default), `agent_type`, `channel`, `date` (UTC day) and `month` (UTC `YYYY-MM`); every row carries one key per dimension asked for. `group_by=month,agent,channel` answers "cost and number of calls per month, per agent and per channel" in one call; rows are ordered by month / date first, then by cost.

| `channel` | What was billed |
|---|---|
| `phone_inbound` | inbound phone calls |
| `phone_outbound` | outbound calls (campaigns, manual, test, API) |
| `widget_chat` | website text chat |
| `widget_voice` | website voice chat |
| `widget_mixed` | a website session that used both |
| `api_chat` | chat through the API |
| `dashboard_chat` / `dashboard_voice` | the dashboard's chat / realtime tabs |
| `ai_operations` | platform AI work that is not a conversation: agent builds, knowledge processing, summaries, config (folded into `agent_type=dashboard` before) |

`agent_type` keeps its coarse buckets (`phone`, `phone_outbound`, `chat` — website text, website voice and chat via the API together —, `dashboard`); use `channel` to separate them. **Errors:** `400 missing_param` · `400 invalid_date_range` · `400 validation_error` (`field: group_by`: unknown, repeated or more than three dimensions) · `403`.

### `GET /accounts/{acc}/balance` — scope `usage:read`

```json
{
  "account_id": "acc_17", "wallet_balance": 182.40, "currency_code": "USD", "monthly_spending": 43.38, "plan_limit": 500.00, "status": "active",
  "remaining": 639.02, "can_place_calls": true, "calls_blocked_reason": null, "min_remaining_for_calls": 5,
  "meta": { "…": "…" }
}
```

`status` is the subscription status that gates billable actions; when it is not `active`, `POST /conversations`, `POST …/messages` and `POST /calls` answer `402 payment_required`.

**Since 2026-09-30 — "does the balance allow calls?"** The last four fields are the rule every billable surface applies (`BalanceLimitService`), evaluated live on the numbers above:

| Field | Meaning |
|---|---|
| `remaining` | `max(plan_limit − monthly_spending, 0) + wallet_balance`, in the account currency. Unused plan credit plus the wallet; the wallet is already net of spending beyond the plan, so nothing is counted twice. A missing `plan_limit` counts as `0` |
| `can_place_calls` | `true` when the subscription is neither `canceled` nor `suspended` **and** `remaining` is **above** `min_remaining_for_calls` |
| `calls_blocked_reason` | `null` when calls are allowed; otherwise `subscription_canceled`, `subscription_suspended` or `insufficient_balance` — the reasons behind `402 payment_required` on `POST /calls`. `remaining` is still reported when the subscription is what blocks |
| `min_remaining_for_calls` | The buffer: `5` (same unit as the wallet). `remaining` of exactly `5` is blocked |

`can_place_calls` says nothing about the daily cap or the plan's monthly outbound allowance — those are in [`GET /accounts/{acc}/limits`](#get-accountsacclimits--scope-accountsread). The gate itself keeps a 10-minute cache of its answer, so a call placed within minutes of a top-up or a plan change can still be refused for a moment; this endpoint reads the current values.

---

**UiriX Public API v1** · [Overview](README.md) · [Quickstart](quickstart.md) · [Authentication](authentication.md) · [Scopes](scopes.md) · [Conventions](conventions.md) · [Accounts & Users](accounts-and-users.md) · [Agents](agents.md) · [Capabilities](capabilities.md) · [Profiles](profiles.md) · [Conversations](conversations.md) · [Chat via API](chat-via-api.md) · [Widget Events](widget-events.md) · [Webhooks](webhooks.md) · [Calls](calls.md) · [Metrics](metrics.md) · [Voices](voices.md) · [Enums](enums.md) · [Partners](partners.md) · [Admin](admin.md) · [MCP](mcp.md) · [Changelog](changelog.md) · [OpenAPI](openapi.yaml)


<!-- ===== admin.md ===== -->

# Admin — the management surface (master key only)

**Since 2026-09-30.** `/admin/*` lets the platform be run through the API: by a person with curl, or by an AI operator (Claude) holding `PUBLIC_API_MASTER_KEY`. One call answers "how is the platform doing", one call answers "tell me everything about this customer", and a small set of **journaled** writes covers what an operator actually does day to day — credit a wallet, move a plan, suspend, edit a key, switch a model, poke an agent.

Every route on this page:

- needs the **master key** (`uirix_mk_live_…`). A personal key gets `403 insufficient_scope` whatever its scopes — there is no scope that unlocks `/admin`.
- runs in its own rate class, **`admin` — 120 requests / minute**, whatever the verb ([Conventions → Rate limits](conventions.md#rate-limits)).
- sits inside the ordinary pipeline: the usual envelope, `X-Request-Id`, and a row in the [audit log](accounts-and-users.md#audit-master-key) like any other request.

Variables used below: `$B` base URL (`https://dev-dashboard.uirix.com/api/ext/v1`), `$MK` master key, `$J` = `Content-Type: application/json`.

---

## Safety rules

| Rule | What it means |
|---|---|
| **No deletes** | Nothing on this page deletes anything — not a customer, not an agent, not a key, not a journal row. Suspending is the strongest thing an operator can do to a customer. |
| **`confirm: true`** | Every write needs `"confirm": true` in the body. Without it the answer is `400 confirmation_required` (`field: "confirm"`) and **nothing happens**. An operator — human or model — must state that it means it. |
| **A reason** | Money, plan and status changes need a `reason` (5–500 characters). It is stored with the change and shown in `GET /admin/actions`. |
| **Journal** | Every write leaves one row in `dbo.ext_admin_actions`: the request id, the action, the target, the state **before and after**, the reason, and the idempotency key. Read it with `GET /admin/actions`. The journal has no foreign keys on purpose: the history outlives the rows it describes. |
| **Idempotency** | A wallet credit sent with `idempotency_key` can be retried safely: the same key answers the **original** result (`replayed: true`, HTTP 200 instead of 201) and credits nothing. The same key on a different customer is `409 idempotency_key_reused`. |
| **Caps** | A single credit is between $0.01 and **$1,000** (`PUBLIC_API_ADMIN_MAX_CREDIT_USD` in the root `.env` overrides the cap). Never a negative amount. |
| **No secrets, ever** | The config snapshot echoes an explicit allow-list of env NAMES and reports missing secrets by name only; log lines and health bodies pass through a scrubber (`Bearer …`, `uirix_*_…`, `sk-…`, `whsec_…`, `otp=…`, `"password"/"secret"/"token": …`) before they leave. Login links are returned once and never journaled. |
| **Reuse, not re-implementation** | The wallet credit is the partner-credit mechanics (`users.wallet_balance` + a `wallet_history` `topup` row, one transaction); the plan change copies the pay-as-you-go convert flow; the status change *is* `PATCH /users/{usr}` `status`; agent actions run the same `updateAgentFields` the dashboard and `PATCH /agents/{agt}` run; the credential edit is the dashboard's key editor with `name` and `ip_allowlist` added. |

---

## Reads

| Endpoint | Answers |
|---|---|
| `GET /admin/overview` | Platform KPIs in one call |
| `GET /admin/customers` | Search / filter customers, keyset paging |
| `GET /admin/customers/{usr}/overview` | The customer dossier |
| `GET /admin/customers/{usr}/wallet/history` | `wallet_history` rows, newest first |
| `GET /admin/agents` | Every agent across every account, with `cost_30d` |
| `GET /admin/system/status` | Services, workers, queues, config problems, db latency |
| `GET /admin/system/errors` | Error lines from the newest log file of one service |
| `GET /admin/config` | Flags, models per section, pricing, allow-listed env, versions |
| `GET /admin/actions` | The journal |
| `GET /admin/migrations` | Both migration ledgers vs. the files on disk |

### `GET /admin/overview`

No parameters. Every section is bounded by a 10-second deadline: a slow aggregate degrades to `null` and is named in `problems` instead of failing the screen.

| Key | Content |
|---|---|
| `customers` | `total`, `active`, `trial`, `suspended`, `payg`, `new_last_30d`, `partner_members`, `logged_in_last_30d` |
| `agents` | `total`, `complete`, `large_tier`, `widget_enabled`, `with_phone_number`, `new_last_30d`, `by_provider` (`{ openai, gemini, gpt_live }`), `by_language_top5`, `by_config_status` |
| `conversations` | `last_24h` / `last_7d` / `last_30d`, each `{ total, by_channel }` over `vw_ext_conversations` (channels `phone`, `widget`, `api`, `dashboard`), plus `active_now` |
| `usage` | `last_7d` / `last_30d`: `cost_usd` (what the model calls cost), `billed_usd` (what accounts were charged — `billing_cost_usd`, falling back to the cost), `sessions`. Same definitions and exclusions as `GET /accounts/{acc}/usage`; imported calls are not usage |
| `revenue` | `last_30d`: `usd`, `transactions`, `wallet_topups_usd`, `plans_usd` — `transactions` rows with `status = "completed"` (`status_counted` says so) |
| `wallet` | `total_balance_usd` across all accounts, `accounts_positive`, `accounts_negative`, `negative_total_usd` |
| `top_accounts_30d` | Five accounts by `billed_usd`, with `email`, `name`, `company_name`, `cost_usd`, `sessions` |
| `open_items` | `webhook_outbox_pending` (+ `webhook_outbox_oldest_at`), `webhook_deliveries_failed_24h`, `webhook_deliveries_dead_24h`, `summaries_pending_over_15_min`, `agents_building`, `webhook_endpoints_auto_disabled` |
| `services` | One probe per process — `dashboard:3000`, `admin:3001`, `twilio:3002`, `outbound:3003` — `GET /health` with a 2-second timeout: `status` (`up` / `down`), `http_status`, `ms`, `detail` (the health body), `error` |
| `api_health` | The Public API's own `GET /health` report (components `api`, `db`, `transcripts`, `summaries`, `webhooks`, `media`) |
| `problems` | Sections that timed out or failed (empty when everything answered) |
| `generated_at` | UTC |

### `GET /admin/customers`

| Parameter | Notes |
|---|---|
| `q` | Case-insensitive *contains* over `users.email`, `name`, `company_name` and `external_ids.equalweb_customer_id`; `usr_17` / `acc_17` / `17` also match the id |
| `plan` | Plan slug (`starter`, `payg`, `guest`, `pro` …) or a numeric `plans.id` |
| `status` | `active` · `suspended` · `trial` (trial is derived from the plan, as on `GET /users`) |
| `partner` | A partner name (`external_ids.partner`), or `any` / `none` |
| `limit`, `cursor` | Keyset over `(created_at, id)`, newest first; the cursor is bound to the filter set |

Each row is the [User object](accounts-and-users.md#user-object) plus: `plan_id`, `plan_name`, `plan_price`, `plan_expires_at`, `subscription_status`, `payment_gateway` (`cardcom` or `null` — whether a card is on file), `wallet_balance`, `monthly_spending`, `agents_count`, `last_conversation_at`, `last_login`.

### `GET /admin/customers/{usr}/overview`

The dossier. `usr_17` and `acc_17` both work.

| Key | Content |
|---|---|
| `user` | The User object + `last_login`, `plan_expires_at` |
| `balance` | As `GET /accounts/{acc}/balance` |
| `limits` | As `GET /accounts/{acc}/limits` — call limits, **today's quota counters** (`quotas.agent_creates.used` …), rate classes |
| `partner` | `partner`, `member_since`, `equalweb_customer_id`, `is_member` |
| `agents` | Each agent's summary (as `GET /accounts/{acc}/agents`) + `ai_provider`, `use_large_model`, `agent_language`, `voice`, `config_status`, `public_chat_enabled`, `public_voice_enabled`, `widget_enabled`, `knowledge` (`files`, `pages`, `has_summary`, `summary_stale`) |
| `api_keys` | Prefix and metadata only — never the hash, never a secret |
| `webhook_endpoints` | As `GET /webhooks/endpoints` (no signing secrets) |
| `usage` | `last_7d`, `last_30d` totals + `by_agent_type_30d` |
| `wallet_history` | The last 20 entries |
| `subscription` | The newest row: plan, status, `reset_date`, `payment_gateway`, charge dates, cancellation fields |
| `transactions` | The last 5 — type, amounts, status, card brand / last 4, error message. **No gateway payloads, no card token** |
| `invoices` | The last 5 — number, type, amount, `invoice_url` |

### `GET /admin/customers/{usr}/wallet/history`

`limit`, `cursor`. Rows: `entry_id`, `amount_usd`, `type` (`topup` / `usage`), `balance_before`, `balance_after`, `description`, `reference_id`, `created_at`.

### `GET /admin/agents`

| Parameter | Notes |
|---|---|
| `q` | Contains over agent `name`, `display_name`, the owner's `email` / `company_name`; `agt_245` / `245` match the id |
| `provider` | `openai` · `gemini` · `gpt_live` (the last only where GPT-Live is enabled) |
| `language` | `agent_language` code (`he`, `en` …) |
| `tier` | `standard` · `large` (`use_large_model`) |
| `enabled` | `true` = `config_status = complete` |
| `has_phone` | `true` / `false` — an active number is attached |
| `account_id` | One account (`acc_17`) |
| `limit`, `cursor` | Keyset over `(updated_at, id)`, newest first |

Row: `agent_id`, `account_id`, `name`, `display_name`, `owner_email`, `owner_company`, `ai_provider`, `use_large_model`, `tier`, `agent_language`, `voice`, `config_status`, `enabled`, `public_chat_enabled`, `public_voice_enabled`, `widget_enabled`, `phone_number`, `agent_version`, `cost_30d` (`billed_usd`, `cost_usd`, `sessions` — `usage_logs` + `public_sessions`, last 30 days), `created_at`, `updated_at`.

### `GET /admin/system/status`

| Key | Content |
|---|---|
| `services` | The four health probes (as on the overview) |
| `api_health` | The Public API's health report |
| `workers` | `phase2` (webhook producers / dispatcher / summariser): `enabled`, `tick_ms`, `last_tick` (`at`, `duration_ms`, `ticks_run`, the tick's counters — `null` until the first tick of this process); `phase3` (call scheduler): `enabled`, `tick_ms`, `retention_sweep`; the raw `EXT_WORKERS` / `EXT_RETENTION_SWEEP` values |
| `queues` | `webhook_outbox` (`pending`, `oldest_pending_at`), `webhook_deliveries` (`pending`, `failed_24h`, `dead_24h`), `summaries` (`pending`, `pending_over_15_min`, split `calls` / `chats`), `api_calls` (`queued`, `scheduled`, `in_progress`), `builds_in_progress` (`total`, per account — this process only) |
| `config_problems` | `master_key_problem` (+ message), `missing_secrets` and `missing_optional_secrets` — **names only** — `recording_secret_configured`, `cursor_secret_configured`, `sandbox_account_configured` |
| `db` | `ok`, `latency_ms` of `SELECT 1` |
| `process` | `pid`, `node`, `uptime_seconds`, `memory_mb`, `deployment` |

### `GET /admin/system/errors`

| Parameter | Notes |
|---|---|
| `service` | **required** — `dashboard` · `admin` · `twilio` · `outbound` (folders `dashboard-backend`, `admin-backend`, `twilio`, `twilio-outbound` under `D:\Servers_Logs\realtime-agent`; an allow-list, never a path) |
| `minutes` | Window, default 60, max 10080 (7 days). Lines whose timestamp cannot be parsed are kept |
| `limit` | Default 200, max 500 |

Reads the **newest log file** of the service (the files directly in its folder plus those of the newest `YYYY-MM-DD` sub-folder, by modification time), scans its last 2 MB, keeps the lines containing `error` (any case), newest first, capped at `limit` lines / 256 KB, secrets scrubbed. Read-only. Answers `file`, `file_modified_at`, `file_size_bytes`, `scanned_bytes`, `scanned_whole_file`, `files_available`, `count`, `truncated`, `lines` (`{ at, text }`).

### `GET /admin/config`

| Key | Content |
|---|---|
| `flags` | Booleans: `GPT_LIVE_ENABLED`, `EXT_WORKERS`, `EXT_RETENTION_SWEEP`, `OUTBOUND_SESSION_POOL`, `IMAP_ENABLED`, `EQUALWEB_OAUTH` (configured = both client id and secret present) — as read by the dashboard backend. See [Feature flags](#feature-flags-read-only--the-manual-procedure) |
| `ai_config.standard` / `ai_config.large` | Per section of `shared/config/ai-config.json` / `ai-config-large.json`: `model`, `reasoningEffort`, and the `whisperModel` / `chatModel` / `backendModel` family where a section has them |
| `model_pricing` | `models` (name → prices), `multipliers`, `tokenCosts`, `payg`, `sessionTypes` from `model-pricing.json` |
| `env` | Allow-listed values only: `NODE_ENV`, `DASHBOARD_DOMAIN`, `CDN_DOMAIN`, `PUBLIC_API_BASE_URL`, `PUBLIC_API_DEFAULT_PARTNER`, `PUBLIC_API_SANDBOX_ACCOUNT_ID`, `PORT` |
| `versions` | `services.{dashboard,admin,twilio,outbound}` (`package.json` name + version), `git` (`commit`, `branch`; `null` when git does not answer within a second) |
| `files` | The config directory, each file's `modified_at`, and the `backups` written by `PATCH /admin/config/ai-models` |

### `GET /admin/actions`

| Parameter | Notes |
|---|---|
| `from`, `to` | ISO-8601; default the last 30 days |
| `action` | e.g. `wallet_credit`, `plan_change`, `status_change`, `credential_patch`, `config_ai_model`, `config_reload`, `login_link_issued`, `agent_set_provider`, `agent_set_tier`, `agent_set_language`, `agent_set_voice`, `agent_widget_enable`, `agent_widget_disable`, `agent_reindex_kb` |
| `target_type` | `user` · `agent` · `credential` · `config` |
| `target_id` | `usr_17` / `acc_17` / `agt_245` / `key_42` (sets the type too) or a bare integer |
| `limit`, `cursor` | Keyset over `(created_at, id)`, newest first |

Row: `action_id` (`adm_…`), `request_id` (join it to `GET /audit`), `action`, `target_type`, `target_id`, `before`, `after`, `reason`, `idempotency_key`, `created_at`.

### `GET /admin/migrations`

`legacy` (`dbo.schema_migrations`, `npm run migrate:legacy`) and `ext_api` (`dbo.ext_api_migrations`, `npx tsx src/scripts/migrate-ext-api.ts`): `applied` rows (name, `applied_at`; the legacy ledger also `applied_by`, `duration_ms`), `pending` (files on disk not yet in the ledger), `drifted` (legacy files whose checksum no longer matches the ledger), `recorded_but_missing_on_disk`.

---

## Writes

Every write: master key, `"confirm": true`, one journal row. Responses carry `action_id` (`adm_…`) — the journal row.

### `POST /admin/customers/{usr}/wallet/credits` — credit the wallet

| Body | Type | Required | Notes |
|---|---|---|---|
| `amount_usd` | number | yes | 0.01 – 1,000 (the cap is `PUBLIC_API_ADMIN_MAX_CREDIT_USD`). Never negative |
| `reason` | string | yes | 5–500 characters; stored as `Admin credit - master: <reason>` on the `wallet_history` row |
| `idempotency_key` | string | no | 1–120 characters, unique across the journal. Send one for every credit an automated operator makes |
| `confirm` | `true` | yes | |

One transaction: `users.wallet_balance += amount_usd`, a `wallet_history` row (`type: topup`, `balance_before` / `balance_after`), the journal row. **201** with `user_id`, `amount_usd`, `balance_before`, `balance_after`, `wallet_history_id`, `action_id`, `replayed: false`, `credited_at`. A replay of the same `idempotency_key` for the same customer is **200** with the original numbers and `replayed: true`. Errors: `400 confirmation_required` · `400 validation_error` (`amount_usd`, `reason`) · `404 not_found` · `409 idempotency_key_reused`.

### `POST /admin/customers/{usr}/plan` — move the customer to another plan

| Body | Type | Required | Notes |
|---|---|---|---|
| `plan_id` | integer | yes | `plans.id` (`GET /admin/config` does not list plans; `GET /admin/customers/{usr}/overview → subscription` shows the current one; the dev ids are 1 Starter, 2 Pro, 3 Business, 4 Guest (trial), 11 PAYG) |
| `reason` | string | yes | |
| `confirm` | `true` | yes | |

Exactly what the partner convert flow does: the newest `subscriptions` row gets `plan_id`, `status = active`, `reset_date` one month out; `users.plan_expires_at` becomes 2036 for pay-as-you-go (the wallet ends it) and one month out for every other plan. Pay-as-you-go also clears the Cardcom fields (`payment_gateway`, `next_charge_date`, cancellation) — the only path that touches them; no other plan change does.

Refusals: `404 plan_not_found` · `400 validation_error` (plan not active) · **`409 card_required`** when the target plan has a price, is not pay-as-you-go, is not a trial plan, and the subscription has no `payment_gateway` — the customer must add a card in the dashboard first (or move to pay-as-you-go). Trial plans are exempt because `POST /users` puts a tenant on the trial plan with no card.

**200**: the User object + `previous_plan_id`, `plan_id`, `subscription` (the after-state), `action_id`.

### `PATCH /admin/customers/{usr}/status` — suspend / reactivate

Body: `status` (`active` · `suspended`), `reason`, `confirm`. Writes `users.status` (`Active` / `Suspended`, as the dashboard reads it) and nothing else — `subscriptions.status` gates billing and is left alone. **200**: the User object + `previous_status`, `action_id`. `trial` cannot be set (it is derived from the plan).

### `POST /admin/customers/{usr}/login-links` — a one-time dashboard login link

Body: `agent_id` (optional — land on that agent's Website-code page; must belong to the customer), `next` (optional in-app path, default `/dashboard`), `reason` (optional), `confirm`. Same mechanics as `POST /users/{usr}/login-links` (single use, expires in minutes). **201**: `login_url`, `expires_at`, `lands_on`, `action_id`. The journal stores `lands_on` and `expires_at` — **never the link**.

### `PATCH /admin/credentials/{key}` — edit a personal key

Body (at least one field): `name` (1–200), `scopes` (grantable scopes only — a master-only scope is `400 invalid_scope`), `expires_in_days` (1–365, measured from now), `ip_allowlist` (IPs / CIDRs; `[]` clears it), `reason` (optional), `confirm`. The secret is untouched; narrowing is immediate. A revoked key is `400 validation_error` — the answer to a revoked key is a new key. **200**: the key object (prefix and metadata, never the secret) + `before` + `action_id`.

### `PATCH /admin/config/ai-models` — change the model of one section

| Body | Type | Required | Notes |
|---|---|---|---|
| `tier` | `standard` · `large` | yes | `ai-config.json` / `ai-config-large.json` |
| `section` | string | yes | A section of that file that has a `model` (`chat`, `summary`, `publicWidget`, `realtime`, `twilioVoice`, `autoConfigBuild` …) |
| `model` | string | yes | Must be priced in `model-pricing.json` — an unpriced model would bill nothing |
| `reasoningEffort` | `minimal` · `low` · `medium` · `high` | no | Only for sections that already carry `reasoningEffort` (the realtime voice sections) |
| `reason` | string | no | |
| `confirm` | `true` | yes | |

Writes a timestamped backup next to the file (`ai-config.json.bak-20260930T101500Z`), rewrites the file, calls `aiConfigService.reloadConfig()` so the dashboard backend (3000) picks it up **in place**, and journals the change. **200**: `before`, `after`, `backup`, `reloaded: ["dashboard:3000"]`, **`restart_required: ["twilio:3002", "outbound:3003", "admin:3001"]`** — those processes read the file at start-up. Feature flags are not writable here (see below).

### `POST /admin/config/reload`

Body: `reason` (optional), `confirm`. Drops the in-process config cache of the dashboard backend (after a manual edit of the JSON files). Same `restart_required` answer.

### `POST /admin/agents/{agt}/actions` — operate on an agent

The master key may address any agent; the account is resolved internally. Every action runs the same code the dashboard and `PATCH /agents/{agt}` run, so validation lives there (a voice that does not exist for the provider, a provider not enabled on this platform …).

| `action` | `value` | Effect |
|---|---|---|
| `set_provider` | `openai` · `gemini` · `gpt_live` | `ai_provider`. The agent's current voice must exist for the new provider; when it does not, send `voice` (a voice id of the new provider, `GET /voices?provider=…`) in the same body and both are applied in one write — the shared validation refuses the switch alone (`400 validation_error`, field `voice`) |
| `set_tier` | `standard` · `large` | `use_large_model` |
| `set_language` | one of the 17 codes | `agent_language` (and the legacy `language` column, as the dashboard does) |
| `set_voice` | a voice id (`GET /voices?provider=…`) | `voice` (+ the `voice_configs` mirror) |
| `widget_enable` / `widget_disable` | — | `widget_settings.enabled` |
| `reindex_kb` | — | Starts the knowledge-summary rebuild (**202**). An operator action: the customer's daily build quota is not consumed |

Body: `action`, `value` (where the table says so), `voice` (with `set_provider` only), `reason`, `confirm`. **200** (202 for `reindex_kb`): `agent_id`, `account_id`, `action`, `value`, `before`, `after` (`ai_provider`, `use_large_model`, `agent_language`, `voice`, `widget_enabled`, `config_status`, `agent_version`), `result`, `action_id`. A behavioural change mints a new `agent_version`, like any other write.

---

## Feature flags (read-only) — the manual procedure

`GET /admin/config → flags` **reports** the flags; nothing on the API changes them. They are environment variables in the single root `.env`, read by each process at start-up:

| Flag | Value that switches it on | Read by |
|---|---|---|
| `GPT_LIVE_ENABLED` | `1` | dashboard (3000), twilio (3002) — offers the `gpt_live` provider |
| `EXT_WORKERS` | anything but `off` (unset = on) | dashboard (3000) — the Public API's background tick and call scheduler |
| `EXT_RETENTION_SWEEP` | `on` | dashboard (3000) — the daily transcript / recording retention sweep (irreversible deletes; opt-in) |
| `OUTBOUND_SESSION_POOL` | `on` | outbound (3003) — pre-warmed model sessions for campaign calls |
| `IMAP_ENABLED` | `true` | admin (3001) — the support-mailbox poller |
| `EQUALWEB_OAUTH_CLIENT_ID` + `EQUALWEB_OAUTH_CLIENT_SECRET` | both present | dashboard (3000) — "Sign in with EqualWeb" |

To change one: **Nir edits the root `.env`** (never a sub-directory `.env`), then restarts the process(es) that read it — on the dev box `dashboard/backend` runs as `npm run dev:once` / `tsx` without a watcher, the other three as services. Confirm afterwards with `GET /admin/config` (3000's view) and `GET /admin/system/status → services` (every process answered its `/health`). An AI operator does not edit `.env`: it reports the flag it needs changed and who reads it.

---

## Operator cookbook

Fifteen things an operator does, with the existing endpoints and the new ones together.

```bash
# 1. Morning check — is everything up, what is stuck, what did we earn?
curl -s -H "Authorization: Bearer $MK" "$B/admin/overview" | jq '{services: [.services[] | {name, status, ms}], open: .open_items, revenue: .revenue.last_30d, usage: .usage.last_7d, problems}'

# 2. Something is off — services, queues, missing secrets, db latency
curl -s -H "Authorization: Bearer $MK" "$B/admin/system/status" | jq '{services: [.services[] | select(.status != "up")], queues, config_problems, db}'

# 3. Read the last hour of errors from the phone service, then the outbound one
curl -s -H "Authorization: Bearer $MK" "$B/admin/system/errors?service=twilio&minutes=60" | jq '.lines[:20]'
curl -s -H "Authorization: Bearer $MK" "$B/admin/system/errors?service=outbound&minutes=60&limit=50" | jq '.count, .file'

# 4. Find a customer by e-mail fragment, company or EqualWeb customer id
curl -s -H "Authorization: Bearer $MK" "$B/admin/customers?q=equalweb" | jq '.data[] | {user_id, email, plan, status, wallet_balance, agents_count, last_conversation_at}'

# 5. Everything about one customer
curl -s -H "Authorization: Bearer $MK" "$B/admin/customers/usr_1001/overview" | jq '{user: .user.email, balance, quotas: .limits.quotas, agents: [.agents[] | {agent_id, name, ai_provider, widget_enabled}], subscription, transactions}'

# 6. Who spent the most this month, and which agents cost the most
curl -s -H "Authorization: Bearer $MK" "$B/admin/overview" | jq '.top_accounts_30d'
curl -s -H "Authorization: Bearer $MK" "$B/admin/agents?limit=200" | jq '[.data[] | {agent_id, name, owner_email, billed: .cost_30d.billed_usd}] | sort_by(-.billed) | .[:10]'

# 7. Every Gemini agent on the large tier, every agent with a phone number
curl -s -H "Authorization: Bearer $MK" "$B/admin/agents?provider=gemini&tier=large" | jq '.data[] | {agent_id, name, owner_email, voice}'
curl -s -H "Authorization: Bearer $MK" "$B/admin/agents?has_phone=true" | jq '.data[] | {agent_id, name, phone_number}'

# 8. Credit $25 of goodwill — idempotent, so a retry cannot credit twice
curl -s -H "Authorization: Bearer $MK" -H "$J" -X POST "$B/admin/customers/usr_1001/wallet/credits" \
  -d '{"amount_usd": 25, "reason": "Goodwill after the 29 Sept outage (ticket 4412)", "idempotency_key": "ticket-4412-goodwill", "confirm": true}' | jq .
curl -s -H "Authorization: Bearer $MK" "$B/admin/customers/usr_1001/wallet/history?limit=5" | jq '.data'

# 9. Move a card-less customer to pay-as-you-go (plan 11); a paid plan without a card is 409 card_required
curl -s -H "Authorization: Bearer $MK" -H "$J" -X POST "$B/admin/customers/usr_1001/plan" \
  -d '{"plan_id": 11, "reason": "Customer asked for pay-as-you-go on 30 Sept", "confirm": true}' | jq '{plan, previous_plan_id, subscription}'

# 10. Suspend a customer for non-payment, and later reactivate
curl -s -H "Authorization: Bearer $MK" -H "$J" -X PATCH "$B/admin/customers/usr_1001/status" \
  -d '{"status": "suspended", "reason": "Two failed charges, no answer to billing e-mails", "confirm": true}' | jq '{status, previous_status, action_id}'
curl -s -H "Authorization: Bearer $MK" -H "$J" -X PATCH "$B/admin/customers/usr_1001/status" \
  -d '{"status": "active", "reason": "Card updated, charge succeeded", "confirm": true}' | jq .status

# 11. Log in as the customer to see what they see (one-time link, lands on the agent's Website-code page)
curl -s -H "Authorization: Bearer $MK" -H "$J" -X POST "$B/admin/customers/usr_1001/login-links" \
  -d '{"agent_id": "agt_245", "reason": "Support session, ticket 4412", "confirm": true}' | jq '{login_url, expires_at}'

# 12. Narrow a key that a contractor no longer needs, and give it 30 days to live
curl -s -H "Authorization: Bearer $MK" "$B/credentials?user_id=usr_1001" | jq '.data[] | {key_id, name, scopes, expires_at}'
curl -s -H "Authorization: Bearer $MK" -H "$J" -X PATCH "$B/admin/credentials/key_42" \
  -d '{"scopes": ["conversations:read", "summaries:read"], "expires_in_days": 30, "reason": "Contractor off-boarding", "confirm": true}' | jq '{scopes, expires_at, before}'

# 13. Switch an agent to the large tier and rebuild its knowledge summary
curl -s -H "Authorization: Bearer $MK" -H "$J" -X POST "$B/admin/agents/agt_245/actions" \
  -d '{"action": "set_tier", "value": "large", "reason": "Hebrew answers too short on standard", "confirm": true}' | jq '{before, after}'
curl -s -H "Authorization: Bearer $MK" -H "$J" -X POST "$B/admin/agents/agt_245/actions" \
  -d '{"action": "reindex_kb", "reason": "Summary stale after the site relaunch", "confirm": true}' | jq .result

# 14. Which model runs where, then move the summariser of the large tier and restart what needs it
curl -s -H "Authorization: Bearer $MK" "$B/admin/config" | jq '{flags, chat: .ai_config.standard.chat, summary_large: .ai_config.large.summary, git: .versions.git}'
curl -s -H "Authorization: Bearer $MK" -H "$J" -X PATCH "$B/admin/config/ai-models" \
  -d '{"tier": "large", "section": "summary", "model": "gpt-5.6-terra", "reason": "D-4 ruling", "confirm": true}' | jq '{before, after, backup, restart_required}'

# 15. What did the operator do this week, and are both migration ledgers current?
curl -s -H "Authorization: Bearer $MK" "$B/admin/actions?from=2026-09-23T00:00:00Z&limit=100" | jq '.data[] | {created_at, action, target_id, reason}'
curl -s -H "Authorization: Bearer $MK" "$B/admin/migrations" | jq '{legacy_pending: .legacy.pending, legacy_drifted: .legacy.drifted, ext_pending: .ext_api.pending}'
```

---

## Errors specific to this page

| Code | Status | When |
|---|---|---|
| `confirmation_required` | 400 | A write without `"confirm": true` (`field: "confirm"`). Nothing happened |
| `plan_not_found` | 404 | `plan_id` is not a plan |
| `card_required` | 409 | Paid plan, not pay-as-you-go, not a trial, and no payment gateway on the subscription (`plan_id`, `plan_price` in the error) |
| `idempotency_key_reused` | 409 | The key was already used for another action or another customer |
| `insufficient_scope` | 403 | A personal key — there is no scope for `/admin` |
| `unsupported_filter` | 400 | Unknown query parameter, unknown `service`, a provider not enabled here |
| `validation_error` | 400 | Amount / reason / section / model / value out of range; a revoked key |

Every other code is the shared catalogue in [Conventions](conventions.md#error-catalogue).

---

**UiriX Public API v1** · [Overview](README.md) · [Quickstart](quickstart.md) · [Authentication](authentication.md) · [Scopes](scopes.md) · [Conventions](conventions.md) · [Accounts & Users](accounts-and-users.md) · [Agents](agents.md) · [Capabilities](capabilities.md) · [Profiles](profiles.md) · [Conversations](conversations.md) · [Chat via API](chat-via-api.md) · [Widget Events](widget-events.md) · [Webhooks](webhooks.md) · [Calls](calls.md) · [Metrics](metrics.md) · [Voices](voices.md) · [Enums](enums.md) · [Partners](partners.md) · [Admin](admin.md) · [MCP](mcp.md) · [Changelog](changelog.md) · [OpenAPI](openapi.yaml)


<!-- ===== agents.md ===== -->

# Agents — inbound phone, chat and voice chat

> **This page is the inbound side.** An agent's configuration drives three surfaces: **inbound phone** calls to its number, **widget chat** (and chat through this API), and **widget voice** (voice chat). Each surface has its own prompt, its own greeting and its own compiled copy, and the tables below say which field drives which. **Outbound calls are a different object** — a call placed by `POST /calls` runs a [profile](profiles.md) (stored or inline) and inherits from the agent only its voice, language and knowledge base. Nothing on this page except `outbound_greeting` and `voice` affects an outbound call.

## Which field drives which surface

| Surface | Prompt the runtime reads | Opening line | Tools | Improve (refinement rules) |
|---|---|---|---|---|
| **Inbound phone** | `instructions` | `greeting` | `capabilities` | `incoming_refinement_rules` |
| **Widget chat / chat via API** | `public_chat_build_output.systemPrompt`, else `public_chat_instructions`, else `instructions` | `public_chat_greeting` | `capabilities` (phone-only tools filtered out) | `public_chat_refinement_rules` |
| **Widget voice** | `public_voice_build_output.systemPrompt`, else `public_voice_instructions`, else `instructions` | `public_voice_greeting` | `capabilities` (safety allow-list) | `public_voice_refinement_rules` |
| Outbound call | the profile — see [Profiles](profiles.md) | profile `greeting_message` → `outbound_greeting` → `greeting` | profile `allowed_capabilities` | profile `refinement_rules` |

All three surfaces share: the **knowledge summary** ([what the agent actually reads](#what-the-agent-actually-reads)), `language`, the model (`use_large_model`, `ai_provider`, `temperature`), and the `voice` for the two voice surfaces. **Writing a prompt field through this API changes what that surface runs** — including the compiled copy, since 2026-09-05; see [Which prompt each surface runs](#which-prompt-each-surface-runs).

Every behavioural change to an agent produces a new immutable **`agent_version`**; every prompt deploy produces a new **`prompt_version`**. Both are stamped on each conversation at start and never rewritten, so `group_by=agent_version` in [Metrics](metrics.md) is an A/B harness for free.

> **`prompt_version` on a conversation is not reliable today — do not build on it.**
>
> An agent has **three** live prompts at once (`incoming`, `chat`, `voice`), but the agent row
> carries a single `current_prompt_version`, and that is the label stamped on every conversation
> whatever surface it ran on. The column holds whichever prompt type was deployed *last*, so a
> phone call can report a widget-voice label. On agent 308 the three v3 prompts were deployed
> sixteen milliseconds apart and the voice one landed last, so every phone call since reports
> `voice-v3` while actually running `incoming-v3`. Of the stamped phone conversations on this
> deployment, **none** carries a label matching the prompt that ran.
>
> The authority is the version history, not this field. `GET /agents/{agt}/prompts` records
> `deployed_at` and `retired_at` per prompt type, so the prompt actually in force for a
> conversation is the row whose `prompt_type` matches the surface and whose window contains the
> conversation start. That history is complete — every row has a deploy time and no retired row
> is missing its retirement — so nothing is lost while this is being fixed.
>
> Until then, `group_by=prompt_version` in [Metrics](metrics.md) compares labels rather than
> prompts. `agent_version` is not affected.


Variables: `$B` base URL, `$SK` personal key, `$MK` master key, `$J` = `Content-Type: application/json`.

> **Creating an agent is `agents:admin` and happens only through the V4 build route (Nir, 2026-09-06).** Grant the scope to a personal key at issue or by editing the key; the master key satisfies it too, but its own job is creating *accounts* (`POST /users`). Deleting an agent is not possible through the API, and the dashboard offers no delete either. `$SK` in the examples below is a personal key holding `agents:read` / `agents:write` / `kb:*`.

## Agent objects

**Summary object** (returned by `GET /accounts/{acc}/agents`):

| Field | Type | Notes |
|---|---|---|
| `agent_id` | string | `agt_245` |
| `account_id` | string | |
| `agent_type` | `chat` \| `phone` \| `phone_outbound` \| `dashboard` | Primary channel: `phone` when the agent owns an active phone number, otherwise `chat` |
| `channels` | string[] | Enabled channels, e.g. `["chat", "voice"]` |
| `name` | string | The agent's name — what callers and visitors are told |
| `display_name` | string \| null | The dashboard's **Display Name (Internal Use)** (Settings → Basic Information): a label for your own reference, e.g. "Client X Agent"; never spoken or shown to a caller or visitor. `null` when not set |
| `business_name` | string \| null | The dashboard's **Business Name** (Settings → Basic Information): the business the agent represents, e.g. "Acme Dental". It is what tells two agents with the same persona name apart, so the list view carries it too (added 30 Sept 2026 — it used to be on the full object only). `null` when not set |
| `enabled` | boolean | |
| `agent_version` | string | e.g. `agt245-v3`. Pre-existing agents start at `agt{n}-v1`. |
| `prompt_version` | string | Label of the active prompt version for the primary channel |
| `model` | string | Model actually used for chat, resolved from `use_large_model` / `ai_provider` |
| `languages` | string[] | BCP-47 |
| `escalation` | object | `{ "enabled": bool, "targets": string[], "business_hours": string \| null }` |
| `knowledge_bases` | object[] | `[{ "kb_id", "name", "documents", "last_indexed_at", "sync_status" }]`. `documents` counts what was uploaded, not what the agent knows — `sync_status` is the field that says that (`synced` / `stale` / `pending`). See [What the agent actually reads](#what-the-agent-actually-reads) |
| `lead_capture` | object | `{ "enabled": bool, "fields": string[] }` |
| `created_at` / `updated_at` | ISO-8601 | |

**Full object** (`GET /agents/{agt}`) adds every configurable field — `instructions`, `public_chat_instructions`, `public_voice_instructions`, the build outputs, `temperature`, `max_tokens`, `use_large_model`, `ai_provider`, `capabilities`, `capabilities_status`, `custom_capabilities`, `greeting`, `outbound_greeting`, `public_chat_greeting`, `public_voice_greeting`, `role`, `tone_of_voice`, `language`, `agent_language`, `fallback_answer`, `knowledge_areas`, `knowledge_sources`, `business_*`, `goals`, `policies`, `allowed_actions`, `subroles`, `main_role`, `completions`, `interaction_mode`, `onboarding_step`, `config_status`, the refinement rules, plus nested `voice` and `widget` objects. It never returns the widget `public_token` — use `GET /agents/{agt}/widget`.

### Custom capabilities

`custom_capabilities` (full object only, since 2026-09-10) lists the customer-defined tools bound to the agent through a connector — the ones the dashboard's capability builder creates, which the runtimes hand to the model as function tools beside the catalogue `capabilities`. Until now the API did not list them at all, so an integration could not tell which tools its agent actually had. Read-only here; they are created and edited in the dashboard.

| Field | Type | Notes |
|---|---|---|
| `id` | string | `ccap_40` |
| `capability_key` | string | The stable key, e.g. `custom_serpapi_get_google_indexed_page_count_1772719544134_0` |
| `function_name` | string | The tool name the model sees — and the name a [`tool` turn](conversations.md#tool-turns) carries in the transcript |
| `name`, `display_name`, `description` | string \| null | As shown in the dashboard; `display_name` is the internal label (see the summary object) |
| `status` | `active` \| `draft` | The capability's own state |
| `live` | boolean | **What actually runs:** `true` only when `status` is `active` **and** the connector is `connected` — exactly the rule every runtime applies before injecting the tool. A `draft`, or an active capability on a disconnected connector, is listed with `live: false` |
| `connector_status` | string \| null | The bound connector's state (`connected`, `active`, …); `null` when no connector is bound |
| `scopes` | string[] | Surfaces it is offered on: `["all"]`, or any of `incoming_calls`, `outbound_calls`, `public_chat`, `public_voice` |
| `usage_count` | int | Times the tool ran |
| `last_used_at`, `created_at` | ISO-8601 \| null | |

## `GET /accounts/{acc}/agents` — scope `agents:read`

```bash
curl -s -H "Authorization: Bearer $SK" "$B/accounts/acc_17/agents" | jq .
```

```json
{
  "account_id": "acc_17",
  "agents": [
    {
      "agent_id": "agt_245", "account_id": "acc_17", "agent_type": "chat", "channels": ["chat", "voice"],
      "name": "Kim DiCaprio", "enabled": true,
      "agent_version": "agt245-v3", "prompt_version": "chat-v3", "model": "gpt-5.4",
      "languages": ["en", "he"],
      "escalation": { "enabled": true, "targets": ["sales"], "business_hours": null },
      "knowledge_bases": [ { "kb_id": "kb_agt245", "name": "equalweb.com crawl", "documents": 412, "last_indexed_at": "2026-08-18T02:00:00Z", "sync_status": "synced" } ],
      "lead_capture": { "enabled": true, "fields": ["email", "name", "phone"] },
      "created_at": "2026-01-12T10:00:00Z", "updated_at": "2026-08-03T11:20:00Z"
    }
  ],
  "meta": { "request_id": "req_01J8ZC1A2B", "environment": "production" }
}
```

**Errors:** `403 forbidden_account` · `403 insufficient_scope`.

## Creating an agent — only the V4 build

There is one way to create an agent through the API: `POST /accounts/{acc}/agents/build`, the dashboard's V4 wizard run server-side (Nir, 2026-09-06: "only V4 creates an agent"). The blank / from-template `POST /accounts/{acc}/agents` was removed the same day; its path answers `404 not_found`. An agent is never deleted through the API; the dashboard offers no delete either.

## `POST /accounts/{acc}/agents/build` — create an agent from a website — scope `agents:admin`

> **Quotas (2026-09-06).** Rate class `build` (5 / min per key). Per account and UTC day: `max_agent_creates_per_day` (default 10) and `max_builds_per_day` (30) are both consumed by a create; a re-run (`POST /agents/{agt}/build`) consumes a build only. At most `max_concurrent_builds` (2) builds run at once — the next request is `409 build_concurrency_exceeded` with the running build keys. A refused request creates nothing: `429 quota_exceeded` carries `quota`, `limit`, `used`, `resets_at` and `Retry-After`. Today's counters: `GET /accounts/{acc}/limits`. See [Accounts → Daily quotas](accounts-and-users.md#daily-quotas).

The dashboard's agent-creation wizard (V4, the current flow: URL and voice → create → crawl → knowledge → greeting → instructions → ready), run for you by the server. You send what the wizard's first screen asks for; the API creates the agent **at once** and then runs the remaining steps in the background — the same code paths the wizard uses, under the account owner's plan and limits.

| Body field | Type | Required | Notes |
|---|---|---|---|
| `website_url` | string | yes | Public http(s) URL the agent learns from. `equalweb.com` is accepted and becomes `https://equalweb.com/`. Private and loopback hosts are refused |
| `language` | string | yes | Agent language code — one of the 17 (`he`, `en`, `ar`, …; see [Enums](enums.md#agents--knowledge-base)) |
| `voice.gender` | `female` \| `male` | yes, unless `voice.name` | Picks the default voice: the first active voice of that gender in the provider's catalogue (Gemini `zephyr` / `puck`; OpenAI `shimmer` / `ash`) |
| `voice.name` | string | no | A voice id of the provider (`GET /voices?provider=gemini`). Its gender must agree with `voice.gender` if both are sent |
| `provider` | `gemini` \| `openai` \| `gpt_live` | no | **Default `gemini`**. `gpt_live` (GPT-Live-1, 13 Sept 2026) is accepted only where the platform has it switched on; elsewhere it is a `400 validation_error`. |
| `name` | string ≤100 | no | Agent name. Default: the voice's marketing name in the agent's language (what the wizard does) |
| `main_role` | string | no | An active role id (`secretary` default, `customer_service`, `technical_support`, `sales`, `general`, `general_consultant`, `custom`) |
| `addons` | string[] | no | `customer_support`, `orders`, `scheduling` — each adds its capabilities and knowledge areas to the base set |
| `max_pages` | integer 1–200 | no | Pages the crawl may read. Default 15; the plan's own crawl cap still binds |

**Defaults the agent is created with** (from the wizard's `v4-defaults.json`): large model, tone `service_friendly`, role `Voice Agent`, temperature 1, barge-in on, and the **ready-made capability set** `end_call`, `create_lead`, `send_email`, `send_sms`, `send_website_link` (+ the addons' — `scheduling` adds `schedule_google_calendar_meeting`, `reschedule_appointment`, `cancel_appointment`; `customer_support` adds `create_ticket`, `escalate_issue`; `orders` adds `create_order`), knowledge areas `MyBusiness`, `faq`, `services_catalog` (+ the addons'). The website URL and the addon list are kept on the agent (`completions`), so the dashboard can resume the wizard on it. **Agents built through the API differ from the wizard in three points** (29 Sept 2026): `send_sms` is left out (partner members have no phone number), the knowledge area `products_catalog` (Product Descriptions) is added, and `completions["#lead_email"]` is set to the account's e-mail so the Contact-me action (`create_lead`) carries a visible address. The greeting step also writes the agent's **short description** (`agents.description`: 1–2 sentences from the knowledge summary, in the agent's language — the wizard's step 5 does the same); it is `result.description` and `agent.description` in the build state, and a failure is only a warning.

```bash
curl -s -H "Authorization: Bearer $MK" -H "$J" -X POST "$B/accounts/acc_17/agents/build" \
  -d '{"website_url":"https://www.equalweb.com","language":"he","voice":{"gender":"female"}}' | jq '{agent_id, status, step: .build.step}'
```

```json
{ "agent_id": "agt_412", "status": "building", "step": "crawl" }
```

`202`. The response carries the full `agent` object (draft: stock greeting, no instructions yet) and the build state below. **Errors:** `400 validation_error` with `field` (`website_url`, `language`, `voice.gender`, `voice.name`, `provider`, `main_role`, `addons`, `max_pages`; the message lists what is accepted) · `400 unsupported_field` · `403 plan_limit_reached` (the plan's agent limit) · `403 insufficient_scope` (a key without `agents:admin`).

### `GET /agents/{agt}/build` — the steps — scope `agents:read`

Poll it until `status` is `done` or `error`. The chain takes **4–9 minutes**: crawl 30–90 s, knowledge summary 30–120 s, greeting a few seconds, instructions 2–5 minutes (six model calls, one per surface and greeting). Poll every 10–15 s.

```json
{
  "agent_id": "agt_412",
  "status": "building",
  "build": {
    "status": "building", "step": "knowledge",
    "steps": {
      "create":       { "status": "done",    "started_at": "…", "finished_at": "…", "detail": { "agent_id": 412, "capabilities": ["end_call", "create_lead", "send_email", "send_website_link"], "knowledge_areas": ["MyBusiness", "faq", "services_catalog", "products_catalog"], "voice": "zephyr", "provider": "gemini", "language": "he" }, "error": null },
      "crawl":        { "status": "done",    "detail": { "pages_saved": 15, "pages_seen": 42, "max_pages": 15 }, "error": null, "…": "…" },
      "knowledge":    { "status": "running", "detail": null, "error": null, "…": "…" },
      "greeting":     { "status": "pending", "…": "…" },
      "instructions": { "status": "pending", "…": "…" },
      "ready":        { "status": "pending", "…": "…" }
    },
    "input": { "website_url": "https://www.equalweb.com/", "language": "he", "provider": "gemini", "voice": { "gender": "female", "name": "zephyr" }, "name": "ליאן", "main_role": "secretary", "addons": [], "max_pages": 15 },
    "started_at": "…", "finished_at": null, "requested_by": "master", "warnings": [], "error": null,
    "result": { "pages_crawled": 15, "summary_characters": null, "greeting": null, "description": null, "instruction_characters": null }
  },
  "agent": { "website_url": "https://www.equalweb.com/", "config_status": "draft", "onboarding_step": 1, "greeting": "שלום! איך אוכל לעזור לך היום?", "description": null, "instruction_characters": null, "has_chat_prompt": false, "has_voice_prompt": false, "kb": { "crawl": { "…": "…" }, "summary": { "…": "…" }, "vector_sync": null } },
  "meta": { "…": "…" }
}
```

**What each step does and writes**

| Step | What happens | Writes | Fails the build? |
|---|---|---|---|
| `create` | The agent row with the defaults above, its voice config | `agents`, `voice_configs` | yes — then `POST` itself answers 4xx and no agent exists |
| `crawl` | Reads up to `max_pages` pages of the website (billed as `knowledge` usage) | knowledge-base pages | yes — `error` says why (blocked bots, no readable text, the plan's page cap, a crawl already running) |
| `knowledge` | Builds the agent's knowledge summary — the one text every surface reads ([What the agent actually reads](#what-the-agent-actually-reads)) | the summary (readable at `GET /agents/{agt}/kb/summary`) | yes |
| `greeting` | Writes an opening line and the business name from the summary, in the agent's language | `greeting`, `business_name` | **no** — a warning; the stock greeting for the language stays. Set one with `PATCH /agents/{agt}` |
| `instructions` | The wizard's build: the phone prompt, widget-chat prompt and widget-voice prompt, each with its greeting, from the role, capabilities and knowledge. Since 2026-09-10 each prompt is composed as: the normalised refinement rules → `WHAT YOU CANNOT DO` (from the capabilities NOT selected, the price list, the fallback) → `CALLER TYPES` / `VISITOR TYPES` → `LOOKUPS YOU CAN PERFORM` (active custom capabilities) → the model's body, under a budget of 7,000 characters (phone, voice) / 8,000 (chat); a body over budget is compressed once, then a second time with a tighter target, and a prompt still over is stored whole — accepted with a warning up to a ceiling of 7,500 (phone, voice) / 8,500 (chat), above it with the warning that names what to reduce; nothing is ever cut. `detail` carries `refinement` (per surface: `rules`, `conflicts`, `unclear`), `prompt_sizes` (`total`, `platform`, `body`, `budget`, `bodyBudget`, `overBudget`, `ceiling`, `overCeiling`, `compressionPasses` per surface) and `warnings`; the non-fatal notes (a refinement conflict, a prompt over budget after the compression passes, a widget prompt kept from the previous build) are also appended to `build.warnings` | `instructions`, `public_chat_*`, `public_voice_*`, the compiled copies, `config_status = complete`, `onboarding_step = 7` | yes |
| `ready` | Confirms the row says `complete` | — | yes |

`status` is `done` when every step is done (or greeting failed with a warning), `error` when a step failed — `build.step` names it, `build.error` says why, later steps stay `pending`. On `error` the agent **exists** in `draft`: fix the cause and re-run from that step with `POST /agents/{agt}/build` (an agent cannot be deleted through the API).

The state lives in the API process and is kept for an hour after the build ends; after a restart `status` is `none` and `build` is `null`, while `agent` (read from the agent row: `config_status` `draft`→`complete`, `onboarding_step` 1→7, the knowledge status) still tells you where it got to.

### `POST /agents/{agt}/build` — re-run from a step — scope `agents:write`

| Body field | Type | Notes |
|---|---|---|
| `from_step` | `crawl` \| `knowledge` \| `greeting` \| `instructions` | Default `crawl`. Earlier steps are marked `skipped` |
| `website_url` | string | Only with `from_step: crawl`; default: the URL the agent was created with |
| `max_pages` | integer 1–200 | Default 15 |

`202` with the same state object. Use it after an `error`, after adding documents (`from_step: knowledge`), or after changing the agent's role or capabilities (`from_step: instructions` — note the build runs only while `config_status` is not `complete`; a completed agent answers `skipped: already_complete` in the step detail, so change the prompt with `PATCH /agents/{agt}` or `POST /agents/{agt}/prompts` instead). **Errors:** `409 agent_build_in_progress` (carries `started_at`, `step`) · `400 validation_error` (`from_step`, `website_url`, `max_pages`) · `404 agent_not_found`.

## `GET /agents/{agt}` — scope `agents:read`

Full object. **Errors:** `404 agent_not_found` (also for agents of other accounts).

## `PATCH /agents/{agt}` — scope `agents:write`

Partial update. Exactly the **45 fields** below are accepted; any other key is `400 unsupported_field` with `field` naming it; `{}` is `400 empty_update`. Fields marked **bump** produce a new `agent_version`; prompt fields also produce a new `prompt_version` (see [Prompts](#prompts-and-versions)).

| Family | Fields | Bumps `agent_version` |
|---|---|---|
| Identity & presentation (15) | `name`, `display_name`, `description`, `business_name`, `business_type`, `greeting`, `outbound_greeting`, `role`, `tone_of_voice`, `language`, `agent_language`, `channels`, `interaction_mode` | no |
| Role & behaviour metadata (9) | `main_role`, `subroles`, `goals`, `policies`, `allowed_actions`, `completions`, `knowledge_areas`, `knowledge_sources`, `fallback_answer` | no |
| Prompts (3) | `instructions` (inbound phone), `public_chat_instructions` (widget/API chat), `public_voice_instructions` (widget voice) — **mirrored into the compiled copy**, see [Which prompt each surface runs](#which-prompt-each-surface-runs) | **yes** + new `prompt_version` of type `incoming` / `chat` / `voice` |
| Build outputs (3) | `ai_build_output`, `public_chat_build_output`, `public_voice_build_output` — the compiled copies; write these only when you know their JSON shape | **yes** |
| Improve agent (3) | `incoming_refinement_rules`, `public_chat_refinement_rules`, `public_voice_refinement_rules` — an array of rules, **applied** to that surface's prompt, see [Improve agent](#improve-agent-refinement-rules) | **yes** + new `prompt_version` |
| Model (4) | `temperature`, `max_tokens`, `use_large_model`, `ai_provider` | **yes** (`model` in responses changes with `use_large_model`) |
| Capabilities (2) | `capabilities` — an array of ids from [`GET /capabilities`](capabilities.md), **validated**; `capabilities_status` | **yes** |
| Voice (4) | `voice`, `speaking_rate`, `barge_in`, `voice_locale` | **yes** (mirrored to the voice config) |
| Wizard state (2) | `onboarding_step`, `config_status` | no |

```bash
curl -s -H "Authorization: Bearer $SK" -H "$J" -X PATCH "$B/agents/agt_245" \
  -d '{"instructions":"You are the EqualWeb assistant. Answer briefly. Hand off to a human when asked.","temperature":0.3}' | jq '{agent_version, prompt_version, model: .agent.model}'
```

```json
{ "agent": { "agent_id": "agt_245", "…": "…" }, "agent_version": "agt245-v4", "prompt_version": "incoming-v5", "meta": { "…": "…" } }
```

Response `200` — `{ agent, agent_version, prompt_version }`. Edits made in the UiriX dashboard go through the same versioning, so every behavioural change is attributable.

**Errors:** `400 unsupported_field` · `400 validation_error` (wrong type / range; voice/provider mismatch — see [Voices](voices.md); an unknown id in `capabilities`, carrying `unknown_capability`; a refinement-rules value that is not an array of strings) · `400 empty_update` · `404 agent_not_found` · `403 insufficient_scope`.

### Which prompt each surface runs

An agent carries **three prompts** and, for each, a **compiled copy** written by the dashboard's builder:

| Surface | Raw prompt (what you write) | Compiled copy | What the runtime reads |
|---|---|---|---|
| Inbound phone | `instructions` | `ai_build_output` | the raw prompt — the compiled copy is not read on a call |
| Widget chat / chat via API | `public_chat_instructions` | `public_chat_build_output` | the compiled copy's `systemPrompt` when one exists, else the raw prompt |
| Widget voice | `public_voice_instructions` | `public_voice_build_output` | the compiled copy's `systemPrompt` when one exists, else the raw prompt (since 2026-09-05; before that, a generic assistant) |

To exercise the voice or phone prompt **by text**, create the conversation with `POST /conversations { "channel": "voice" | "phone" }` ([Chat via API](chat-via-api.md#post-conversations--create)): the turns run under that surface's prompt and greeting with the same knowledge block and capability gate.

Until 2026-09-05 this meant a `PATCH` to `public_chat_instructions` on an agent the wizard had built answered `200` and changed nothing the widget ran. **Now a prompt write is mirrored into its compiled copy**: `PATCH /agents/{agt}`, `POST /agents/{agt}/prompts` and `/restore` all replace the compiled copy's `systemPrompt` with the text you sent, keeping its other keys (guardrails, response templates, build metadata). The text you write is the text that runs, on every surface. Three details:

- **If you send the compiled copy yourself** (`public_chat_build_output` in the same request), yours is kept as sent — the API never overrides an explicit value with a derived one. This is what the dashboard does.
- **No compiled copy is invented.** An agent that has none keeps having none; the runtime reads the raw prompt (chat) or falls back to it (voice). Fabricating one would drop the strict-role preamble the chat engine only applies when no compiled copy exists.
- **A rebuild in the dashboard overwrites both** the raw prompt and the compiled copy with the builder's output, so anything written here is replaced the next time someone rebuilds the agent. That is the same as today, and the same as for the knowledge summary.

The compiled copy is what the runtime *appends to*: capability instructions, the knowledge summary and the greeting are added at conversation time, whatever the prompt says. So the prompt you write is the persona and the rules — not the whole conversation setup.

### `GET /agents/{agt}/demo` — a page that shows the agent on the customer's site — scope `agents:read`

One link, no login, that a customer opens to see **their own website with the agent's widget open on it** — chat and voice working, on the real widget with the real token. The widget opens as a **full-height side panel on the left** that pushes the site to the right (the widget's AI View; on a phone it is full screen). Put it behind a "See your agent" button the moment `GET /agents/{agt}/build` reports `done` (the page opens earlier too, but says the agent is still being built and refreshes itself).

```bash
curl -s -H "Authorization: Bearer $SK" "$B/agents/agt_3702/demo" | jq .
```

```json
{
  "agent_id": "agt_3702",
  "demo_url": "https://dashboard.uirix.com/demo/3702-9f2c81a0e4d15b7c3a6f0d2e8b9c1a47",
  "ready": true,
  "build_status": "complete",
  "website_url": "https://www.customer.com",
  "screenshot_url": "https://cdn.uirix.com/site-snapshots/3702.jpg?v=1790510000",
  "widget": { "public_token": "pk_9f2c…", "enabled": true, "allowed_domains": ["customer.com"] }
}
```

| Field | Meaning |
|---|---|
| `demo_url` | The page. Stable for the agent: the token is signed against the widget's `public_token`, so it stays valid until the token is regenerated (`POST /agents/{agt}/widget/regenerate-token`), which revokes every demo link handed out |
| `ready` | `true` once the build finished (`build_status` = `complete`). Before that the page shows a "still building" note and polls |
| `website_url` | The site the agent was built from — the backdrop of the page |
| `screenshot_url` | The backdrop: a screenshot of the site's home page, taken during the build. `null` when it could not be taken (the page then shows the widget on a plain backdrop). The first call renders a missing screenshot and can take a few seconds |
| `widget` | The widget the page uses. An agent that had no widget yet (built with `POST /accounts/{acc}/agents/build`) gets one here: a token, the site's domain, enabled — the same thing the site code needs |

Why a screenshot and not the live site: most sites refuse to be shown inside a frame (`X-Frame-Options`, `frame-ancestors`), and a picture behaves the same on every site. The widget on top is live.

**Errors:** `404 agent_not_found` · `403 insufficient_scope` · `429 rate_limited`.

### When is a build needed

Some inputs change the agent the moment you write them; others are inputs to a build and change nothing until that build runs. This is the whole list of writable inputs and which kind each is:

| You write | Takes effect | Build needed |
|---|---|---|
| `instructions`, `public_chat_instructions`, `public_voice_instructions` (`PATCH`, `POST /prompts`, `/restore`) | **on write**, on the next conversation of that surface — the raw column and the compiled copy's `systemPrompt` are both set | none |
| `*_refinement_rules` | **on write** — the rules are reviewed (normalised wording, duplicates merged, conflicts reported) and the header is prepended to the prompt and the compiled copy | none |
| `greeting`, `outbound_greeting`, `public_chat_greeting`, `public_voice_greeting`, `voice`, `language`, model fields, `capabilities`, `completions` | **on write** | none |
| `*_build_output` written directly | **on write** — you are writing the compiled copy itself | none |
| KB documents: upload, text, crawl (`POST /kb/documents`), delete | **not until the summary is rebuilt** — the agent keeps answering from the old summary | `POST /agents/{agt}/kb/reindex`, then poll `GET /kb/status` until `pending_sources` is `0` and `version` moved. Nothing rebuilds on its own |
| `PATCH /kb/summary` (the summary text itself) | **on write** | none |
| Outbound profile: `system_instruction_template`, `greeting_message`, `allowed_capabilities`, `completions`, `tone`, `override_voice` | **on the next call** | none |
| Outbound profile: `focuses`, `behavior`, `refinement_rules`, `description` | **not until the profile is built** — they are inputs to the script | `POST /profiles/{prf}/build`, then poll `GET /profiles/{prf}/build` until `done`; the profile's `updated_at` moves |
| Inline `profile` on `POST /calls` | **that call** | none |

**What the mirror produces.** The prompt write is **verbatim**: the text you sent becomes the prompt, character for character, with no AI compilation and nothing added, reworded or dropped from it. At conversation time the runtime wraps it in a fixed frame it adds around every prompt — the speech rules, the capability instruction blocks for the agent's `capabilities`, the knowledge summary, the tool protocol and the greeting directive. That frame surrounds your text; it does not edit it. So the agent says what you wrote — with one thing to know about the **first words**: on a phone call the opening line is pinned by the greeting directive (`greeting` inbound, `outbound_greeting` / profile `greeting_message` outbound), not by the first line of `instructions`. If a specific sentence must be the first thing the agent says, put it in the greeting field for that surface.

The dashboard's **Rebuild with AI** is different: it is the AI builder, which regenerates the prompts and the compiled copies from the agent's knowledge base, capabilities and settings, and **overwrites** whatever was written here. It is not exposed in the API. After a dashboard rebuild, re-apply anything you need from the API.

### Improve agent (refinement rules)

Writing a `*_refinement_rules` list runs the same review the dashboard's **Improve agent** tab runs (since 2026-09-11): the list you send is stored verbatim; the build model normalises it — one imperative rule per line, duplicates merged, a rule that contradicts another or cannot be understood is reported rather than silently injected — and the NORMALISED rules are prepended to that surface's prompt **and** its compiled copy under a header the runtime treats as highest priority. The review (what each rule became, `conflicts`, `unclear`) is recorded in `ai_build_output._metadata.refinement.{phone|chat|voice}`, which `GET /agents/{agt}` returns inside `ai_build_output`. The response carries the new `prompt_version` and the stored list.

```bash
curl -s -H "Authorization: Bearer $SK" -H "$J" -X PATCH "$B/agents/agt_245" \
  -d '{"public_chat_refinement_rules":["Never quote a price on chat","Always ask for the order number before discussing a delivery"]}' | jq '{prompt_version, rules: .agent.public_chat_refinement_rules}'
```

After this, `public_chat_instructions` (and the compiled copy's `systemPrompt`) begins:

```
CUSTOM REFINEMENT INSTRUCTIONS (HIGHEST PRIORITY)
- Never quote a price on chat.
- Always ask for the order number before discussing a delivery.

<the prompt as it was>
```

The wording under the header is the normalised form (here a full stop was added); the count and the order follow your list. If the model cannot be reached the rules are injected as sent, and the review says so (`source`).

| You send | Effect |
|---|---|
| a list of rules | the header is (re)written with exactly these rules; a previous header is replaced, never stacked |
| `[]` | the header is removed; the prompt underneath is untouched |
| rules **and** the prompt in one request | the header goes on the new prompt |

Each write mints a new `prompt_version` for that surface (the prompt text changed) and is attributed to your key, so "who added this rule" is answerable from `GET /agents/{agt}/prompts`. Up to 50 rules of up to 1,000 characters each; whitespace is normalised, blank rules dropped. Rules are per surface — a rule for phone does not apply to chat unless you set it there too. The dashboard tab reads the same list, so rules added here appear there and vice versa.

**At build time the rules are normalised (since 2026-09-10).** A build (`POST /accounts/{acc}/agents/build`, `POST /agents/{agt}/build` from `instructions`, or the dashboard's Rebuild with AI) does not inject the stored list verbatim: a model pass rewrites every rule as one imperative sentence in the agent's language, merges duplicates, and reports the pairs that cannot both be followed (`conflicts`, with a one-line explanation) and the rules whose meaning it could not read (`unclear` — a garbled translation, a test note, a fragment). The rewritten rules are what the prompt carries, in the same header block as above (so a later write here replaces it cleanly); the stored list is untouched, and a rule the pass drops is kept verbatim. The report is in the build state — `steps.instructions.detail.refinement.{phone,chat,voice}` (`rules`, `conflicts`, `unclear`, `source`) — and every conflict and unclear rule is also a line in `build.warnings`, so a poller sees them without opening the detail. The same block is also prepended by the builder as the first of four platform sections (`CUSTOM REFINEMENT INSTRUCTIONS`, `WHAT YOU CANNOT DO`, `CALLER TYPES` / `VISITOR TYPES`, `LOOKUPS YOU CAN PERFORM`), which is why a built prompt starts with them.

### Greetings — which one is spoken, and in what language

An agent has one opening line per surface. They are independent fields; none of them
falls back to another except where stated.

| Field | Surface | Empty means |
|---|---|---|
| `greeting` | inbound phone calls, and the fallback for outbound | the agent improvises an opener |
| `outbound_greeting` | outbound (campaign) calls | **fall back to `greeting`** |
| `public_chat_greeting` | widget / API chat (max 1000 characters) | **fall back to `greeting`** |
| `public_voice_greeting` | widget voice (max 500 characters, spoken — no emoji) | **fall back to `public_chat_greeting`, then `greeting`** |

`outbound_greeting` exists because `greeting` is written for people who called *you*
("thank you for calling …"), which is wrong the moment the platform places the call. It
is `null` on every agent that has not set one, and the dialler then speaks `greeting`
unchanged — so adding the field changed no existing agent's behaviour. Set it to give
outbound its own words:

```bash
curl -s -H "Authorization: Bearer $SK" -H "$J" -X PATCH "$B/agents/agt_308" \
  -d '{"outbound_greeting":"Hi, this is Kim from EqualWeb. Is now a good moment?"}' | jq '.agent.outbound_greeting'
```

For an outbound call the line actually spoken is the first of these that is set:

1. the campaign profile's `greeting_message` (per campaign, set in the dashboard)
2. `outbound_greeting` (this field)
3. `greeting` (the inbound line, borrowed)
4. a generic opener derived from the campaign category

**Widget greetings.** `public_chat_greeting` and `public_voice_greeting` are writable (`null` or `""` clears; longer than the limit is `400 validation_error`). The widget reads its opener from the compiled copy first (`public_chat_build_output.responseTemplates.greeting_initial`, `public_voice_build_output.responseTemplates.voice_greeting_initial`), so the write is **mirrored into that copy** — and a clear removes the key there — otherwise a built agent would keep speaking the old line. It takes effect on the next chat / voice session.

```bash
curl -s -H "Authorization: Bearer $SK" -H "$J" -X PATCH "$B/agents/agt_308" \
  -d '{"public_chat_greeting":"Hi! I am Kim. What can I help you with?","public_voice_greeting":"Hi, this is Kim. How can I help?"}' | jq '.agent | {public_chat_greeting, public_voice_greeting}'
```

**Language.** The greeting fields hold *one* string, not one per language — `language`
decides what is spoken. The agent opens in its own language (or the contact's, when the
call list gives one), and the stored line is translated on the fly if it was written in
another language. So `PATCH {"language":"he"}` makes an agent open in Hebrew even though
its greeting is still stored in English, and rewriting the greeting in Hebrew afterwards
is what pins the exact Hebrew wording:

```bash
curl -s -H "Authorization: Bearer $SK" -H "$J" -X PATCH "$B/agents/agt_308" \
  -d '{"language":"he","outbound_greeting":"שלום, מדבר קים מאיקוול ווב. זה זמן טוב?"}' | jq '.agent | {language, outbound_greeting}'
```

A line already written in the agent's language is spoken verbatim, word for word. A line
in a different language is translated faithfully — same meaning, names and business name —
which trades exact wording for actually being understood. Write the greeting in the
agent's language when the wording matters.

## Deleting an agent — not through the API

There is no `DELETE /agents/{agt}` (Nir, 2026-09-06): an agent is never deleted through the Public API. The path answers `404 not_found` for every key, the master key included. The dashboard offers no agent delete either (Nir, 2026-09-06); the API can disable its widget (`PATCH /agents/{agt}/widget` with `"enabled": false`) or retire its prompts, but cannot destroy it, its knowledge base or its conversation history. Until 2026-09-06 the route existed behind `agents:admin`.

## §11.2 aliases

The nested paths from the EqualWeb spec are served as aliases of the flat ones (same scopes, same bodies, same responses):

| Alias | Canonical | Notes |
|---|---|---|
| `GET /accounts/{acc}/agents/{agt}` | `GET /agents/{agt}` | |
| `PATCH /accounts/{acc}/agents/{agt}` | `PATCH /agents/{agt}` | |
| `GET /accounts/{acc}/agents/{agt}/build` | `GET /agents/{agt}/build` | |
| `POST /accounts/{acc}/agents/{agt}/build` | `POST /agents/{agt}/build` | |
| `GET /accounts/{acc}/agents/{agt}/prompts` | `GET /agents/{agt}/prompts` | |
| `POST /accounts/{acc}/agents/{agt}/prompts` | `POST /agents/{agt}/prompts` | |
| `POST /accounts/{acc}/agents/{agt}/prompt` | `POST /agents/{agt}/prompts` | Singular, the spelling in the spec |
| `GET /accounts/{acc}/agents/{agt}/kb/documents` | `GET /agents/{agt}/kb/documents` | |
| `POST /accounts/{acc}/agents/{agt}/kb/documents` | `POST /agents/{agt}/kb/documents` | |

The account comes from the path instead of the `X-Uirix-Account` header; everything else is
identical. Prefer the flat paths — they are the canonical ones, and they are the only form
the OpenAPI document describes in full.

---

## Prompts and versions

Each agent has three prompt slots, versioned independently:

| `type` | Field on the agent | Used by |
|---|---|---|
| `incoming` | `instructions` | Inbound and outbound phone calls |
| `chat` | `public_chat_instructions` | Widget chat and chat via API |
| `voice` | `public_voice_instructions` | Widget voice |

A **prompt version** is immutable. Deploying writes the body to the agent field, retires the previously active version, creates the new row, updates the agent's `current_prompt_version` and bumps `agent_version`.

### What you actually need to know

**Care about the active version. The rest is an audit trail.**

Each of the three slots has exactly one active version at any moment, and that version's
body is what the agent is running. `GET /agents/{agt}` gives it to you directly —
`instructions` and friends hold the live text, and `prompt_versions` gives you the active
label per slot:

```json
"prompt_versions": { "incoming": "incoming-v2", "chat": "chat-v2", "voice": "voice-v2" }
```

If all you want is "what is this agent saying and how do I change it", you never need the
prompt endpoints at all: read the fields on the agent, write them with `PATCH /agents/{agt}`.
The endpoints below exist for the two jobs the agent object cannot do — **finding out who
changed a prompt and when**, and **putting an old one back**.

#### What creates a version

Only prompt text does. Every write to `instructions`, `public_chat_instructions` or
`public_voice_instructions` — through `PATCH /agents/{agt}`, through `POST /agents/{agt}/prompts`,
or in the dashboard — retires the active version for that slot and mints a new one
attributed to whoever made the call.

A refinement-rules write also does, because it rewrites the prompt text (see [Improve
agent](#improve-agent-refinement-rules)). Nothing else does. Changing the model,
capabilities or voice bumps `agent_version` but mints no prompt version, so
`agent_version` moves ahead of the prompt labels and that is expected. One `PATCH` that
sets all three prompts mints three versions and bumps `agent_version` once.

#### The `v1` rows are not deploys

Every agent that existed before prompt versioning was introduced carries a version
labelled exactly `v1` with `created_by: "migration_003"`. It is a **backfill artifact**,
not a deployment: it holds whatever text was in the agent's prompt column on the day
versioning was switched on, and there is no record of how that text got there.

Its `deployed_at` is the **agent's creation date**, which is the one field on these rows
that reliably misleads. It does not mean the prompt was deployed then; it means nobody
knows when it was deployed, and the agent's birthday was the only honest lower bound
available. Do not chart it as a deploy, and do not compute "time since last change" from
it.

You can recognise these rows by all three of `version_label: "v1"`,
`created_by: "migration_003"`, and a `deployed_at` equal to the agent's `created_at`.
Versions minted since are labelled `{type}-v{n}` (`incoming-v2`, `chat-v3`) and attributed
to a real caller.

The numbering counts rows, not deploys, so on an agent that carries a backfill row the plain
`v1` already occupies the first slot and the next version it mints is `-v2`. An agent created
after versioning was introduced has no backfill row and starts at `-v1`. Either way the label
is an identifier, not an ordinal you should do arithmetic on — sort by `deployed_at`.

#### `deployed_at` and `retired_at`

| Field | Meaning |
|---|---|
| `deployed_at` | When this version became the active one. On a `migration_003` row, the agent's creation date instead — see above. |
| `retired_at` | When it stopped being active, because a newer version replaced it. `null` on the active version. |
| `is_active` | Exactly one version per slot is `true`. |

A version's `retired_at` equals its successor's `deployed_at`, so the history reads as a
contiguous timeline per slot: the version in force at any past moment is the one whose
`deployed_at` is at or before it and whose `retired_at` is after it or `null`. That is how
you resolve the `prompt_version` stamped on a conversation back to the exact text that
produced it.

### Prompt version object

| Field | Type | Notes |
|---|---|---|
| `prompt_version_id` | string | `pv_12` |
| `agent_id` | string | |
| `type` | `incoming` \| `chat` \| `voice` | |
| `version_label` | string | Unique per agent + type; this is the `prompt_version` stamped on conversations |
| `body` | string | The prompt text. **Only on `GET /prompts/{pv}`** — `null` in list responses |
| `model` | string \| null | Whatever the deployer passed as `model`; `null` unless they did. Not the agent's model |
| `is_active` | boolean | Exactly one `true` per slot |
| `deployed_at` | ISO-8601 | When it became active. On a `migration_003` row, the agent's creation date |
| `retired_at` | ISO-8601 \| null | When it was replaced; `null` while active |
| `restored_from` | string \| null | `pv_…` when created by `/restore` |
| `created_by` | string | `key_42` (a personal key), `master`, `dashboard:usr_17` (dashboard edit), or `migration_003` (backfill) |
| `notes` | string \| null | Free text supplied at deploy |

### `GET /agents/{agt}/prompts` — the audit trail — scope `agents:read`

Every version of every slot, newest `deployed_at` first. Filter to one slot with
`?type=incoming|chat|voice`. Bodies are **not** included — this is the index; fetch a body
with `GET /agents/{agt}/prompts/{pv}`.

The whole history is returned in one response: there is no cursor and `has_more` is always
`false`, because an agent accumulates one row per prompt edit and that stays small.

```bash
curl -s -H "Authorization: Bearer $SK" "$B/agents/agt_245/prompts?type=chat" | jq '.data[] | {version_label, is_active, created_by, deployed_at, retired_at}'
```

```json
{ "version_label": "chat-v3", "is_active": true,  "created_by": "key_42",       "deployed_at": "2026-08-03T11:20:00Z", "retired_at": null }
{ "version_label": "chat-v2", "is_active": false, "created_by": "dashboard:usr_17", "deployed_at": "2026-06-14T09:00:00Z", "retired_at": "2026-08-03T11:20:00Z" }
{ "version_label": "v1",      "is_active": false, "created_by": "migration_003", "deployed_at": "2026-01-11T17:10:37Z", "retired_at": "2026-06-14T09:00:00Z" }
```

That last row is the backfill artifact described above: its `deployed_at` is the agent's
creation date, not a deploy.

`created_by` tells you which credential made the change — `key_42` for a personal API key,
`master` for the master key, `dashboard:usr_17` for an edit made in the UiriX dashboard by
that user, `migration_003` for the backfill. It is the answer to "who changed this prompt".

### `GET /agents/{agt}/prompts/{pv}` — one version, with its body — scope `agents:read`

The same object plus `body`, the exact prompt text that was live while this version was
active. Use it to diff two versions, or to read what a conversation actually ran on:
take the conversation's `prompt_version` label, find the matching row in the list above,
and fetch it.

A `pv_…` belonging to another agent is `404`, not `403` — ids do not leak across agents.

### `POST /agents/{agt}/prompts` — deploy — scope `agents:write`

| Body field | Type | Required | Notes |
|---|---|---|---|
| `type` | `incoming` \| `chat` \| `voice` | yes | |
| `body` | string | yes | The prompt text |
| `label` | string | no | Default `{type}-v{n}`. Must be unique per agent + type |
| `model` | string | no | Recorded on the version (does not change the agent's model) |
| `notes` | string | no | |

```bash
curl -s -H "Authorization: Bearer $SK" -H "$J" -X POST "$B/agents/agt_245/prompts" \
  -d '{"type":"chat","body":"You are the EqualWeb website assistant…","label":"chat-v4","notes":"tighter EAA answer"}' | jq .
```

```json
{ "prompt_version_id": "pv_13", "prompt_version": "chat-v4", "agent_version": "agt245-v5", "type": "chat", "deployed_at": "2026-09-03T15:10:00Z", "meta": { "…": "…" } }
```

**Errors:** `409 version_label_exists` (`field: "label"`) · `400 validation_error` · `404 agent_not_found`.

### `POST /agents/{agt}/prompts/{pv}/restore` — the rollback path — scope `agents:write`

Puts an old prompt back. This is what you call when a prompt change made an agent worse
and you want the previous behaviour back without pasting text around.

It does not rewind anything. It **deploys a new version** carrying the old body: the old
row stays exactly as it was, the currently active row is retired normally, and the new row
records where the text came from in `restored_from`. History is append-only, so a rollback
is itself auditable and a rollback can be rolled back.

```bash
curl -s -H "Authorization: Bearer $SK" -X POST "$B/agents/agt_245/prompts/pv_9/restore" | jq .
```

```json
{ "prompt_version_id": "pv_14", "prompt_version": "chat-v2-restored", "agent_version": "agt245-v6", "type": "chat", "restored_from": "pv_9", "deployed_at": "2026-09-04T09:02:11Z", "meta": { "…": "…" } }
```

The body is optional; the only field it takes is `label`. Without one the new version is
labelled `{old_label}-restored`. The `type` is taken from the source version, so you cannot
restore a chat prompt into the voice slot.

Restoring the same version twice needs an explicit `label` the second time: the derived
`{old_label}-restored` is already taken, and duplicate labels are `409 version_label_exists`.

**Errors:** `404 not_found` (`field: "pv"` — no such version on this agent) · `409 version_label_exists` · `404 agent_not_found`.

### `GET /agent-versions?account_id=acc_17` — scope `agents:read`

Deploy markers for charts, across every agent in the account: one row per **prompt deploy**,
newest first, so you can mark up a metrics timeline with "the prompt changed here".

This is the same underlying history as `GET /agents/{agt}/prompts`, account-wide and
paginated. Use this one to annotate charts; use the per-agent one to read an agent's story.

| Param | Notes |
|---|---|
| `account_id` | Required unless the key covers one account |
| `agent_id` | Optional filter |
| `from`, `to` | On `deployed_at` |
| `limit`, `cursor` | Standard keyset pagination |

```json
{
  "data": [
    { "agent_id": "agt_308", "agent_version": "agt308-v9", "prompt_version": "chat-v2", "type": "chat",  "model": null, "deployed_at": "2026-09-04T12:32:12Z", "retired_at": null,                     "is_active": true,  "created_by": "key_42",        "change": "prompt:chat" },
    { "agent_id": "agt_308", "agent_version": "agt308-v1", "prompt_version": "v1",      "type": "chat",  "model": null, "deployed_at": "2026-01-11T17:10:37Z", "retired_at": "2026-09-04T12:32:12Z", "is_active": false, "created_by": "migration_003", "change": "prompt:chat" }
  ],
  "pagination": { "limit": 50, "has_more": true, "next_cursor": "eyJ2IjoxL…" },
  "meta": { "…": "…" }
}
```

Three things about this response that will otherwise surprise you:

- **`agent_version` here is not the version that was in force at `deployed_at`.** On an
  active row it is the agent's *current* `agent_version`, which has usually moved on since
  — non-prompt edits bump it too. On a retired row it is a value stamped at backfill time,
  and it is `null` on rows that were already retired before the column existed. Use
  `deployed_at` for the timeline and `prompt_version` for identity; do not reconstruct
  history from `agent_version` on this endpoint. (The `agent_version` stamped on a
  *conversation* is a different thing and is reliable — it was recorded when the
  conversation started.)
- **One `PATCH` produces several rows sharing one `agent_version`.** Setting all three
  prompts in one request mints three versions and bumps `agent_version` once, so three rows
  carry the same label, seconds apart.
- **`change` is always `prompt:{type}`.** It is derived from the row's own prompt type and
  nothing else. It never lists other fields that changed in the same request, so a `PATCH`
  that set a prompt *and* the temperature still reports `change: "prompt:incoming"`. It is a
  label for the marker, not a diff.

`model` is whatever was recorded on the prompt version at deploy time, which is `null`
unless the caller supplied `model` to `POST /agents/{agt}/prompts`. It is not the agent's
current model — read that from `GET /agents/{agt}`.

---

## Knowledge base

Documents come from three sources and share one lifecycle: `processing` → `indexed` (or `failed`). Read the next section before you build on that: uploading a document and the agent knowing it are two different events.

### What the agent actually reads

A crawl and the files you upload are **inputs**. They are fetched, parsed, stored and listed — and then, as a separate step, the platform builds **one large summary** of everything it holds for that agent. **That summary is what the agent carries into a conversation, and it is the only knowledge it carries.** Dashboard chat, the public widget, inbound and outbound phone, chat through this API: every session is opened with that one text, and with nothing else from the knowledge base.

There is no retrieval at conversation time. Individual documents and pages are **never read** while a conversation is running: nothing is searched when a customer asks a question, and no document is fetched to answer a turn. That is also why `sources` is `null` on every chat turn ([Chat via API](chat-via-api.md)) — there is nothing per-turn to attribute.

Four consequences worth designing around:

- **A document that is not yet in the summary is invisible to the agent.** It can be uploaded, parsed, `status: "indexed"` and listed by `GET /kb/documents`, and the agent still does not know it exists. `indexed` means "we stored and parsed this", never "the agent knows this".
- **A stale summary answers from old knowledge, not from no knowledge.** The agent does not go quiet about what changed; it answers confidently out of whatever was true when the summary was last built. A price you replaced last month is still the price it quotes. That is the failure mode to watch for — it does not look like a failure.
- **A stale summary is still used.** Every consumer loads the active summary with no staleness check of any kind. Nothing at runtime notices, refuses, warns or writes a log line about it. Staleness is visible only where you go and look: `summary.stale` and `pending_sources` on `GET /kb/status`, `sync_status` on the agent object, and a banner in the dashboard.
- **Nothing rebuilds the summary on its own.** There is no worker, no queue and no schedule. Adding sources does not start a build — it only makes the current summary out of date. A rebuild happens when a person or an integration asks for one, and not before.

So the shape of a correct integration is: upload or crawl → ask for a rebuild → confirm with `GET /kb/status` that `pending_sources` is `0` and `version` moved. Uploading alone changes nothing about what the agent says.

### Document object

| Field | Type | Notes |
|---|---|---|
| `doc_id` | string | `doc_f101` (file / text) or `doc_c202` (crawled page) |
| `kind` | `file` \| `text` \| `page` | |
| `title` | string | File name, text title, or page title |
| `source_url` | string \| null | Pages only |
| `status` | `processing` \| `indexed` \| `failed` | |
| `indexed` | boolean | |
| `word_count` | int \| null | |
| `language` | BCP-47 \| null | |
| `last_indexed_at` | ISO-8601 \| null | |
| `error` | string \| null | When `failed` |
| `keep_verbatim` | boolean | Files and text only. `true` = kept as written: the document is **not** summarised, its full text follows the summary in the agent's prompt (chat: up to 1,000,000 characters per agent, 2,000,000 on the large tier; calls: 270,000). Set on create or with `PATCH …/kb/documents/{doc}`; a document whose text opens with `IMPORTANT INSTRUCTION FOR AI ASSISTANT` or `do not summarize` is flagged automatically at upload. |
| `verbatim_in_calls` | boolean | Files and text only. Placed first in the calls block (calls have less room than chat). |
| `created_at` | ISO-8601 | |

### `GET /agents/{agt}/kb/documents` — scope `kb:read`

`?kind=&status=&limit=&cursor=`. List envelope of document objects.

### `POST /agents/{agt}/kb/documents` — scope `kb:write`

Three request shapes, one response: `202 { "doc_id", "status": "processing" }` (site crawls return `"doc_id": null, "crawl_id": "…"` and surface pages as `doc_c…` documents when done).

| Shape | Request |
|---|---|
| File | `multipart/form-data`, field `file` (PDF, DOCX, TXT, MD, CSV…; ≤ 10 MB); optional form fields `keep_verbatim=true`, `verbatim_in_calls=true` |
| Text | JSON `{ "title": "Opening hours", "text": "…", "language": "en", "keep_verbatim": true, "verbatim_in_calls": true }` — stored as `{title}.md`; the two flags are optional |
| URL | JSON `{ "url": "https://www.equalweb.com/pricing/", "mode": "page" }` or `"mode": "site"` (crawl from that URL) |

```bash
curl -s -H "Authorization: Bearer $SK" -X POST "$B/agents/agt_245/kb/documents" -F "file=@pricing.pdf" | jq .
curl -s -H "Authorization: Bearer $SK" -H "$J" -X POST "$B/agents/agt_245/kb/documents" -d '{"title":"Opening hours","text":"We are open Sun–Thu 09:00–18:00 Israel time.","language":"en"}' | jq .
curl -s -H "Authorization: Bearer $SK" -H "$J" -X POST "$B/agents/agt_245/kb/documents" -d '{"url":"https://www.equalweb.com/pricing/","mode":"page"}' | jq .
```

```json
{ "doc_id": "doc_f101", "status": "processing", "meta": { "…": "…" } }
```

URLs are validated against SSRF before anything is queued: private, loopback, link-local and UiriX hosts, and non-`http(s)` schemes are `400 invalid_url`.

**Errors:** `413 payload_too_large` · `400 invalid_url` · `400 validation_error` (missing `title`/`text`) · `404 agent_not_found`.

### `PATCH /agents/{agt}/kb/documents/{doc}` — scope `kb:write`

Flags a file or text document "keep as is". Body: `{ "keep_verbatim": true, "verbatim_in_calls": true }` (both optional; `verbatim_in_calls` only counts while `keep_verbatim` is true). Returns the document object. Takes effect on the next conversation — no rebuild needed. Unflagging puts the document back into the summary queue.

```bash
curl -s -H "Authorization: Bearer $SK" -H "$J" -X PATCH "$B/agents/agt_245/kb/documents/doc_f101" -d '{"keep_verbatim":true,"verbatim_in_calls":true}' | jq .
```

**Errors:** `400 validation_error` (a `doc_c…` page — pages are always summarised) · `404 document_not_found`.

`GET /agents/{agt}/kb/status` reports the block sizes under `verbatim`: `{ documents, chat: { included, characters, cap }, calls: { included, characters, cap, dropped: ["doc_f…"] } }` — `dropped` lists the documents that did not fit the calls cap (they are still summarised).

### No `DELETE /agents/{agt}/kb/documents/{doc}` — removed 2026-09-06

> **No deletion through the API (Nir, 2026-09-06).** Nothing is deleted through the Public API: there is no `DELETE` route for conversations, subjects, outbound profiles, knowledge documents, do-not-call entries or webhook endpoints. Those paths answer `404 not_found` for every key, the master key included. Deleting is a dashboard action by the account owner. The two `DELETE` verbs that remain — `DELETE /calls/{call}` (cancel a call that has not been dialled) and `DELETE /credentials/{key}` (revoke a key) — are cancellations, not deletions of data.

A knowledge document is removed in the dashboard (Knowledge tab). Removing one there marks the summary stale (`summary.stale: true`); the agent keeps speaking from the old summary until `POST /agents/{agt}/kb/reindex` (or the dashboard) rebuilds it.

### `GET /agents/{agt}/kb/status` — scope `kb:read`

```json
{
  "agent_id": "agt_245",
  "crawl": { "status": "idle", "pages_done": 412, "pages_total": 412, "current_url": null, "mode": "static", "last_run_at": "2026-08-18T02:00:00Z", "error": null, "failed_pages": [] },
  "summary": { "status": "ready", "stale": false, "version": 7, "sources_count": 412, "pending_sources": 0, "updated_at": "2026-08-18T02:04:10Z", "last_job": null },
  "vector_sync": null,
  "meta": { "…": "…" }
}
```

**`summary.status` describes the summary. It never means a job is running.** There is no build queue to look at, so the API does not pretend to see one: every value is worked out at read time from a single count — how many sources are not yet folded into the summary.

| `summary.status` | What is true | What to do |
|---|---|---|
| `ready` | A summary exists and every source is in it | Nothing |
| `out_of_date` | A summary exists, and `pending_sources` sources are not in it. **The agent is answering from the older one right now** | Ask for a rebuild |
| `not_built` | No summary at all, and sources are waiting. The agent carries **no** knowledge into a conversation | Ask for a rebuild |
| `absent` | No summary and no sources. The agent answers from its instructions alone | Add a document |

Nothing here can time out or get stuck, because nothing is running. An agent whose sources were never folded in stays `out_of_date` for as long as nobody asks for a rebuild — one production agent has read that way since 7 July, with 178 sources pending and a summary that predates all of them. Do not poll waiting for it to clear: **request the rebuild, then poll.**

`summary.last_job` (since 2026-09-11) is the one place a job IS visible: the last summary build this backend process ran for the agent — `{ "status": "running" | "done" | "failed", "error", "started_at", "ended_at" }`, or `null` when none ran since the process started (a restart forgets it). Poll it after a rebuild: `failed` with its `error` means stop waiting; a summary job that failed used to leave `not_built` with every source pending and nothing to say why. Large knowledge bases are summarised in batches; a single request never carries more than ~850 000 characters of sources.

`stale` is the same warning as a boolean: `true` when sources are pending, and also when something destructive happened (a document deleted, a page refreshed). `sources_count` is how many sources went into the **last build**, not how many the knowledge base holds; `pending_sources` is how many are waiting for the next one; `version` increments per build and is the cheapest way to confirm that a rebuild actually happened; `updated_at` is when the current summary was built.

`crawl` is process-local: it reports the crawl this API process is running, or the last one it ran. After a restart it reads `idle` again even though a crawl really did happen, so treat `idle` as "nothing running here", not as proof that a crawl finished. `vector_sync` is always `null` — the OpenAI vector-store sync was retired in September 2026 and nothing replaced it; the field is kept so clients written against the contract do not break if one ever lands.

### `POST /agents/{agt}/kb/reindex` — scope `kb:write`

Asks for the summary to be rebuilt from every source that is not in it yet. `202 { "agent_id", "status": "rebuild_requested" }`.

The `202` means the request was accepted, **not** that the summary was rebuilt — the build runs after the response, takes minutes on a large knowledge base, and a failure is written to the server log rather than back to you. Confirm the result, do not assume it: poll `GET /kb/status` until `pending_sources` is `0` and `version` has moved. If `version` has not moved after a few minutes, the build did not succeed and asking again is the right move.

A rebuild merges the pending sources into the existing summary rather than starting from scratch, so a summary that has drifted over many partial builds is not the same text as one built fresh.

### The summary itself — read and write

Because the summary is the only knowledge the agent carries, reading it is the only way to know what an agent knows, and writing it is the most direct control over that — more direct than uploading a document and asking for a rebuild.

#### `GET /agents/{agt}/kb/summary` — scope `kb:read`

```json
{
  "agent_id": "agt_245",
  "text": "EqualWeb provides web accessibility solutions…",
  "version": 7, "sources_count": 412, "characters": 18240,
  "stale": false, "updated_at": "2026-08-18T02:04:10Z",
  "meta": { "…": "…" }
}
```

`text` is the exact string the model receives, whole. An agent with no summary answers `200` with `text: null`, `version: null`, `characters: 0` — a real state (the agent answers from its instructions alone), not an error.

#### `PATCH /agents/{agt}/kb/summary` — scope `kb:write`

Body `{ "text": "…" }` (1 to 1,000,000 characters). Replaces the summary: `version` increments, `stale` is cleared (a hand-written summary is current by definition), and the next conversation on any surface runs on the new text. When the agent has **no** summary yet the write **creates** it and answers `201`; otherwise `200`. Both return the summary object plus `created` (boolean).

```bash
curl -s -H "Authorization: Bearer $SK" -H "$J" -X PATCH "$B/agents/agt_245/kb/summary" \
  -d '{"text":"Opening hours: Sun–Thu 09:00–18:00. Returns within 30 days with receipt. …"}' | jq '{version, characters, created}'
```

Two things to know. A written summary is **not tied to the documents**: `sources_count` and `pending_sources` describe the last build, and a rebuild (`POST /kb/reindex`, or the dashboard) **overwrites** what you wrote by merging the pending sources into it — the same way a rebuild overwrites a compiled prompt. And the text is delivered verbatim, so write it the way you want the model to read it: facts, in the agent's language, no markup the model would read aloud.

---

## Connectors

### `GET /agents/{agt}/connectors` — scope `agents:read`

Which connectors the agent has — Google Calendar, Microsoft 365 Calendar, HubSpot, custom HTTP APIs — and whether each is connected. **Read-only**: a connector is set up by an OAuth flow in the dashboard (agent → Connectors) and holds the customer's tokens, so the API neither creates nor edits one, and never returns its configuration.

It exists because some capabilities cannot work without one: `GET /outbound-capabilities` marks `schedule_calendar_meeting` with [`required_connector`](capabilities.md#required-connectors). Read this before you promise a booking.

```json
{
  "data": [
    {
      "connector_id": "con_41",
      "type": "google-calendar",
      "name": "Google Calendar",
      "category": "calendar",
      "status": "active",
      "connected": true,
      "calendar": { "id": "primary", "name": "Sales calendar" },
      "connected_at": "2026-09-12T08:41:07.000Z",
      "last_sync": null
    },
    {
      "connector_id": "con_58",
      "type": "hubspot",
      "name": "HubSpot CRM",
      "category": "crm",
      "status": "connected",
      "connected": true,
      "calendar": null,
      "connected_at": "2026-09-20T13:02:44.000Z",
      "last_sync": "2026-09-29T06:15:00.000Z"
    }
  ],
  "calendars_connected": true,
  "meta": { "request_id": "req_01J8ZB3E4F", "environment": "production", "agent_id": "agt_245" }
}
```

| Field | Meaning |
|---|---|
| `connector_id` | `con_{n}`. Opaque and read-only — no endpoint takes it |
| `type` | The connector type: `google-calendar`, `microsoft-365-calendar`, `hubspot`, `http_api`, … |
| `name`, `category` | Display name and group (`calendar`, `crm`, …); free text, may be `null` |
| `status` | The stored state. Calendars use `active` when connected and `pending` while the OAuth flow was started and not finished; HubSpot and HTTP-API connectors use `connected`; other values (`error`, `inactive`, …) mean not connected |
| `connected` | `true` for `active` or `connected` — the same test the runtime applies |
| `calendar` | Calendar connectors only, and only while connected: `{ id, name }` of the calendar the agent books into (`id` is `primary` unless the user picked another one). Otherwise `null`. A value that looks like an e-mail address is left out (`name: null`) — Google names the primary calendar after its owner |
| `connected_at` | When the connection was set up (UTC); `null` while not connected |
| `last_sync` | Last synchronisation of connectors that sync (UTC); `null` for calendars |
| `calendars_connected` | `true` when at least one `google-calendar` or `microsoft-365-calendar` connector is `active` — the condition `schedule_calendar_meeting` needs |

**Never returned:** access or refresh tokens, API keys, OAuth state, base URLs, discovered endpoints, or the `config` column itself. Only the two calendar fields above are read out of it, and only for the two calendar types.

```bash
curl -s -H "Authorization: Bearer $SK" "$B/agents/agt_245/connectors" | jq '{calendars_connected, connectors: [.data[] | {type, status, connected}]}'
```

**Errors:** `404 agent_not_found` (an agent of another account is `404`, never `403`) · `400 invalid_id` · `403 insufficient_scope` · `401`.

**What happens on a call when the capability is granted but no calendar is connected.** Nothing is blocked when you save the profile or place the call — the profile and the call answer with a [`connector_not_connected` warning](profiles.md#warnings) instead. On the call itself the calendar tool is still handed to the model (it is offered whenever `allowed_capabilities` lists it), and the model is told to check the calendar; the tool then answers `No calendar connected` and books nothing. The model can only apologise. Connect a calendar first, or leave `schedule_calendar_meeting` out.

---

## Widget

### `GET /agents/{agt}/widget` — scope `agents:read`

```json
{
  "agent_id": "agt_245",
  "public_token": "pk_7f3a9c…",
  "embed_snippet": "<script src=\"https://cdn.uirix.com/uirixwidget.js\" data-token=\"pk_7f3a9c…\" async></script>",
  "enabled": true,
  "allowed_domains": ["equalweb.com", "www.equalweb.com"],
  "widget_color": "#3B82F6",
  "widget_position": "bottom-right",
  "updated_at": "2026-08-03T11:20:00Z",
  "meta": { "…": "…" }
}
```

### `PATCH /agents/{agt}/widget` — scope `agents:write`

Body: any of `enabled`, `allowed_domains`, `widget_color`, `widget_position`. Does **not** bump `agent_version`.

### `POST /agents/{agt}/widget/regenerate-token` — scope `agents:write`

Mints a new `public_token`; the old one stops working immediately (existing embeds break until updated). `200` with the widget object.

---

## Voice

### `GET /agents/{agt}/voice` — scope `agents:read`

```json
{
  "agent_id": "agt_245",
  "voice_id": "marin", "voice_locale": "en-US", "speaking_rate": 1.0, "barge_in": true,
  "turn_detection_silence_duration_ms": 600, "provider": "openai", "model": "gpt-realtime",
  "updated_at": "2026-08-03T11:20:00Z",
  "meta": { "…": "…" }
}
```

### `PATCH /agents/{agt}/voice` — scope `agents:write`

Body: any of `voice_id`, `voice_locale`, `speaking_rate` (0.5–2.0), `barge_in`, `turn_detection_silence_duration_ms`, `turn_detection_prefix_padding_ms`, `vad_sensitivity`. **Bumps `agent_version`** and returns `{ voice, agent_version }`.

**Which surfaces read which field.** `voice_id` and `speaking_rate` are the agent's voice identity and apply everywhere, including outbound calls. The turn-detection fields (`turn_detection_silence_duration_ms`, `turn_detection_prefix_padding_ms`, `vad_sensitivity`) and `barge_in` apply to the **widget voice and the dashboard** only. **Outbound phone calls do not read them**: an outbound call takes its turn detection, temperature, response-token limit and noise reduction from the platform's global outbound settings, per model tier (`use_large_model`), and nothing per agent or per call changes those (ruling of 2026-09-05). Setting `turn_detection_silence_duration_ms` on an agent therefore changes its widget, not its calls.

```bash
curl -s -H "Authorization: Bearer $SK" -H "$J" -X PATCH "$B/agents/agt_245/voice" -d '{"speaking_rate":1.1,"barge_in":true}' | jq '{agent_version, rate: .voice.speaking_rate}'
```

**Errors (widget & voice):** `400 unsupported_field` / `validation_error` · `404 agent_not_found` · `403 insufficient_scope`.

---

**UiriX Public API v1** · [Overview](README.md) · [Quickstart](quickstart.md) · [Authentication](authentication.md) · [Scopes](scopes.md) · [Conventions](conventions.md) · [Accounts & Users](accounts-and-users.md) · [Agents](agents.md) · [Capabilities](capabilities.md) · [Profiles](profiles.md) · [Conversations](conversations.md) · [Chat via API](chat-via-api.md) · [Widget Events](widget-events.md) · [Webhooks](webhooks.md) · [Calls](calls.md) · [Metrics](metrics.md) · [Voices](voices.md) · [Enums](enums.md) · [Partners](partners.md) · [Admin](admin.md) · [MCP](mcp.md) · [Changelog](changelog.md) · [OpenAPI](openapi.yaml)


<!-- ===== calls.md ===== -->

# Outbound Calls & Do-Not-Call

> **This page is the outbound side.** A call placed here does not run the agent's inbound configuration ([Agents](agents.md)): it runs a **role** — a stored [profile](profiles.md), an inline `profile` sent on the request, or, when you send neither, the agent's phone prompt — and inherits from the agent only its voice, language and knowledge base.

`POST /calls` originates **one** phone call from a UiriX voice agent to a number, with the case context in a briefing. The call is asynchronous: you get a `call_id` immediately, poll `GET /calls/{call}` or receive `call.*` webhooks, and the resulting conversation is an ordinary `conv_u…` record with `agent_type: "phone_outbound"`. A **batch** — a contact list worked through on chosen days and hours, each contact in its own local time — is a [campaign](campaigns.md) (`/lists`, `/campaigns`); use `POST /calls` when you decide call by call, as in payment-recovery, churn-save and callback flows.

There are three ways to tell the call what to be:

| You send | The call runs |
|---|---|
| `profile_id` | the stored profile's script, gating and tone — the campaign shape |
| `profile` (inline) | exactly what you sent: instructions, greeting, voice, tone, capabilities, facts — no stored object needed |
| both | the stored profile, with every inline field you sent replacing the profile's value |
| neither | the agent's own phone prompt (`instructions`) and `outbound_greeting` |

`context.briefing` layers above all four: it is the facts and instructions for **this** call.

Scopes: `calls:write` (originate, cancel), `calls:read`, `dnc:write`, `dnc:read`. Variables: `$B` base URL, `$SK` personal key, `$J` = `Content-Type: application/json`.

## `POST /calls` — originate

| Body field | Type | Required | Notes |
|---|---|---|---|
| `account_id` | string | no | Defaults to the credential's home account |
| `agent_id` | string | yes | Agent with an active phone number (`409 agent_has_no_phone_number`) |
| `to_e164` | string | yes | Strict E.164 (`400 invalid_phone`); checked against DNC (`403 dnc_blocked`) |
| `profile_id` | string | no | A stored outbound profile belonging to **this** agent. Supplies the role, the task, the capability gating and the tone for this call. See [profiles](profiles.md) |
| `profile` | object | no | The **inline profile**: the same things, sent on the call itself. Any subset of the six keys below; with or without `profile_id`. See [Everything in the request](#everything-in-the-request-the-inline-profile) |
| `profile.instructions` | string ≤ 20,000 | no | **The role.** Replaces the stored profile's template, or the agent's phone prompt when there is no profile |
| `profile.greeting` | string ≤ 1,000 | no | The opening line, first in the greeting chain. `[Customer Name]` is filled from `context.customer_name` |
| `profile.voice` | string | no | A `voice_id` valid for the agent's provider ([`GET /voices`](voices.md)); `400` otherwise |
| `profile.tone` | object | no | `{ formality, empathy, persistence }` — rendered as a prompt block with concrete behaviour per level. Enums in [Enums](enums.md#tone) |
| `profile.allowed_capabilities` | string[] | no | The tool gate for this call: any active id from [`GET /outbound-capabilities`](capabilities.md); `[]` = talk only; omitted = the profile's / no restriction |
| `profile.completions` | object | no | Facts injected as authoritative data, and what the `send_*` tools send (`zoom_meeting_link`, `payment_link_base_url`, …). Merged over the profile's, yours winning; values stored as strings |
| `from_e164` | string | no | Caller id; must be a number owned by the account (`409 from_number_not_owned`). Default: the agent's number |
| `context` | object | no | See below, and [Per-call context vs the agent's configuration](#per-call-context-vs-the-agents-configuration) |
| `context.purpose` | string | no | Free label (`payment_recovery`, `churn_save`…) |
| `context.matter_key` | string ≤ 128 | no | Threads calls on the same case; filterable |
| `context.briefing` | string ≤ 8,000 | no | **Instruction for this call.** Goal, tone, who to be, what to say first, what not to say. **Overrides** the agent's stored persona, greeting and language where they disagree; never overrides the platform's safety, disclosure or tool rules |
| `context.language` | BCP-47, one of the 17 agent languages (`he`, `he-IL`, `Hebrew` all accepted; anything else → `400 validation_error`) | no | **The language of the whole call.** Overrides the agent's stored language and locks it: the opening line, every turn, the closing and any voicemail message are spoken in it, and the agent does not switch even if it believes the customer spoke another language (changed 2026-09-05; before, it was a start language the agent could drift from). Any of the 17 supported languages. Default: the agent's language, with the agent free to adapt to the customer |
| `context.timezone` | IANA | no | **Overrides** the recipient timezone derived from the number |
| `context.*` | string/number/bool | no | Any other keys (e.g. `hubspot_contact_id`) are stored, echoed back, and given to the agent as facts about the case |
| `policy.scheduled_at` | ISO-8601 \| null | no | Dial at exactly this instant. `null` (or absent) = **now** |
| `policy.earliest_local_time` | `HH:MM` | no | **No default.** Send it and you are asking for a calling window, enforced in the recipient's timezone — see [When a call is placed, and when it waits](#when-a-call-is-placed-and-when-it-waits) |
| `policy.latest_local_time` | `HH:MM` | no | **No default.** Same |
| `policy.max_attempts` | int 1–3 | no | Default 1. Retries apply to `no_answer` / `busy` only |
| `policy.retry_after_minutes` | int ≥ 30 | no | Default 240 |
| `policy.voicemail` | `hang_up` | no | Default `hang_up`. **`leave_message` → `400 unsupported_policy`** (v1) |
| `policy.recording` | boolean | no | Default `true`. When `true` the agent's first turn is the recording disclosure and `disclosure_played` is recorded |
| `callback_url` | string \| null | no | `https://` URL that receives the `call.*` events for this call in addition to your webhook endpoints |

```bash
curl -s -H "Authorization: Bearer $SK" -H "$J" -X POST "$B/calls" -d '{
  "account_id": "acc_17",
  "agent_id": "agt_301",
  "to_e164": "+12125550147",
  "from_e164": "+16467131717",
  "context": {
    "purpose": "payment_recovery",
    "hubspot_contact_id": "14406701",
    "matter_key": "dunning:example.com:2026-09",
    "briefing": "Customer'\''s card was declined twice; goal: get the card updated. Tone: helpful, no pressure. Do not quote amounts.",
    "language": "en-US"
  },
  "policy": { "scheduled_at": null, "earliest_local_time": "09:30", "latest_local_time": "17:00", "max_attempts": 2, "retry_after_minutes": 240, "voicemail": "hang_up", "recording": true },
  "callback_url": "https://hooks.example.com/uirix/calls"
}' | jq .
```

```json
{
  "call_id": "call_88",
  "status": "scheduled",
  "scheduled_at": "2026-09-04T13:30:00Z",
  "scheduled_reason": "outside_requested_window",
  "recipient_timezone": "America/New_York",
  "links": { "self": "https://api.uirix.com/v1/calls/call_88" },
  "meta": { "request_id": "req_01J8ZF0A1B", "environment": "production" }
}
```

`202`. The response also carries `profile_id`, the inline `profile` as accepted (normalised, or `null`) and **`profile_source`** — `agent`, `profile`, `inline` or `profile+inline` — so the record says where the role came from. `status` is `queued` when the call goes now — which is the default — and `scheduled` only when **this request** asked it to wait. A `scheduled` answer always carries `scheduled_reason` (`requested_time` or `outside_requested_window`) naming which part of your request put it in the future, so "scheduled" is never something you have to work out for yourself. `scheduled_reason` is `null` exactly when `status` is `queued`. The request above asked for a 09:30–17:00 window and arrived outside it — see [When a call is placed, and when it waits](#when-a-call-is-placed-and-when-it-waits).

The `202` also carries a top-level **`warnings`** array — only when there is something to say — if the tool gate this call runs on (the inline `profile.allowed_capabilities`, else the stored profile's) grants a capability that needs a connector the agent has not connected, e.g. `schedule_calendar_meeting` with no calendar: `{ "code": "connector_not_connected", "capability": "schedule_calendar_meeting", "message": "…" }`. The call is queued regardless; see [Profiles → Warnings](profiles.md#warnings) and `GET /agents/{agt}/connectors`.

**Errors:** `400 invalid_phone` (`field: "to_e164"`) · `400 unsupported_policy` (`field: "policy.voicemail"`) · `400 validation_error` (`earliest_local_time: "25:00"`, `max_attempts` out of range; `profile.allowed_capabilities` naming an unknown id — carries `unknown_capability`; `profile.completions` with a `#transfer_number` / `#extension_list` that is not international format, or `transfer_call` without a target; `profile.voice` not on the agent's provider; `profile.tone` outside its enums; `profile.instructions` over 20,000 characters) · `400 unsupported_field` (an unknown key inside `profile`) · `403 dnc_blocked` · `404 agent_not_found` · `404 profile_not_found` · `400 validation_error` (`field: "profile_id"` — the profile belongs to another agent, or is not active) · `409 agent_has_no_phone_number` · `409 from_number_not_owned` · `409 call_deferral_refused` (the call could not go now and you did not ask it to wait — it is refused, never quietly moved) · `429 call_quota_exceeded` (+ `Retry-After`) · `402 payment_required` · `429 rate_limited` (calls class, 20/min).

## Call object

| Field | Type | Notes |
|---|---|---|
| `call_id` | string | `call_88` |
| `account_id`, `agent_id` | string | |
| `profile_id` | string \| null | The stored profile this call was placed with, or `null` |
| `profile` | object \| null | The inline profile as accepted (normalised: capability list de-duplicated, completions as strings, empty values dropped), or `null` |
| `profile_source` | `agent` \| `profile` \| `inline` \| `profile+inline` | Where the role of this call came from. `agent` = nothing named, the agent's phone prompt ran |
| `to_e164`, `from_e164` | string | |
| `status` | enum | See below |
| `scheduled_at` | ISO-8601 \| null | |
| `recipient_timezone` | IANA | Derived from `to_e164` unless overridden. Always reported; it only *gates* the call when you asked for a window |
| `attempts` | int | Dial attempts made |
| `max_attempts` | int | |
| `next_attempt_at` | ISO-8601 \| null | Set after `no_answer` / `busy` when attempts remain |
| `answered_by` | `human` \| `voicemail` \| `machine` \| `unknown` \| null | Carrier answer-detection result. **Always `null` since 2026-09-05** — detection is off for outbound; see [Voicemail](#voicemail-what-the-agent-says-to-a-machine). Values other than `null` appear only on calls placed before that date |
| `disclosure_played` | boolean | Recording disclosure delivered as the first turn |
| `dnc_requested` | boolean | The recipient asked not to be called again |
| `dnc_flagged` | boolean | The number was auto-appended to the DNC list from this call |
| `conversation_id` | string \| null | `conv_u…` once the call connected |
| `matter_key` | string \| null | |
| `context` | object | As submitted |
| `policy` | object | As applied (defaults filled in). A call placed **without** a window reads back `earliest_local_time: "00:00"`, `latest_local_time: "23:59"` — that is the whole local day, i.e. no window, not a window you were given |
| `callback_url` | string \| null | |
| `error` | object \| null | `{ "code", "message" }` when `failed` |
| `created_at`, `started_at`, `ended_at` | ISO-8601 \| null | |
| `duration_seconds` | int \| null | Connected time |
| `links` | object | `{ "self", "conversation" }` |

## Statuses

| `status` | Meaning | Terminal |
|---|---|---|
| `queued` | Accepted, waiting for a dialer slot (concurrency cap) | |
| `scheduled` | Waiting for `scheduled_at` — because you asked, via `policy.scheduled_at` or a window you supplied | |
| `ringing` | Dialling | |
| `in_progress` | Connected, agent talking | |
| `completed` | Connected call ended normally | ✔ |
| `no_answer` | Not answered after the last attempt | ✔ |
| `busy` | Busy after the last attempt | ✔ |
| `voicemail` | Answered by voicemail/machine; hung up per `policy.voicemail: "hang_up"` | ✔ |
| `failed` | Carrier/system failure (see `error`) | ✔ |
| `cancelled` | Cancelled via `DELETE /calls/{call}` | ✔ |

Typical flow: `queued` → `ringing` → `in_progress` → `completed`. With `max_attempts: 2`: `ringing` → (no answer) `scheduled` (`next_attempt_at` = now + `retry_after_minutes`) → `ringing` → `no_answer`. A retry honours whatever window the call was placed with; a call placed without one has no window to honour, so it simply retries `retry_after_minutes` later.

## Per-call context vs the agent's configuration

`context` is an instruction for **this call**; the agent's saved setup is a **default**. Where the two disagree the per-call value wins — you do not have to `PATCH` the agent first, and you should not, because a `PATCH` changes every other call the agent is on.

Precedence, highest first. Rung 1 is the platform and is never for sale; rungs 2–4 are this request; rungs 5–7 are configuration.

| # | Rung | Set by | Wins over |
|---|---|---|---|
| 1 | Safety, disclosure, consent, privacy, the tool list, how a call must end | The platform | Everything |
| 2 | **`context.briefing`** — identity, opening line, language, what to say and avoid | This request | 3–7 |
| 3 | **`context.language`** — the language spoken and transcribed | This request | 4–7 |
| 4 | **Inline `profile`** — instructions, greeting, voice, tone, gating, facts, field by field | This request | 5–7, for each field sent |
| 5 | **Stored profile** — the role, the task, the capability gating, its greeting, its tone | `profile_id` on this request, or a campaign | 6, 7 |
| 6 | Agent configuration — outbound greeting, inbound greeting, `language`, phone prompt | `PATCH /agents/{agent}` | 7 |
| 7 | Product defaults — a category greeting, English | — | — |

What that means in practice:

- **A briefing that contradicts the persona wins.** "You are David from Acme, speak only Hebrew, open by saying this is a test call" replaces the stored name, the stored language and the stored opening line for the duration of the call. The agent does not fall back to its saved greeting.
- **A briefing that only describes the task does not replace the opener** (since 2026-09-05). "Present the five decisions one at a time" is the job for the call; the agent still opens with the stored greeting (profile `greeting_message` → `outbound_greeting` → `greeting`) and then starts on the job. Before this, any briefing made the model treat the opener as optional and most calls skipped it. To change the first sentence, put the sentence itself in the briefing, or in `profile.greeting`.
- **A briefing cannot change the rules.** It is text from an API consumer, so it can never weaken a safety, recording-disclosure, consent or privacy rule, add or unlock a tool, change how the call must end, or make the agent reveal its instructions or another customer's data. Anything in the briefing that asks for one of those is ignored and the rest is carried out; the call is not failed and the customer is not told.
- **`context.language` is the language of the whole call.** The opener is spoken in it — translated faithfully if the stored greeting is written in another one — and so is every turn after it. The agent does **not** switch if it thinks it heard another language: you chose the language, and one misheard syllable used to be enough to flip a call (call 1121, 2026-09-05, where the agent read out a scripted "Oh, English? Sure!"). If the person genuinely cannot follow, the agent stays in the sent language, simplifies, and offers a callback. Only a call **without** `context.language` adapts to the customer, starting from the agent's own language.
- **`context.briefing` beats `context.language`** if the briefing itself names a language. Both are yours; the more specific one wins.
- **An inline field replaces exactly that field.** `profile.greeting` on a call with `profile_id` swaps the opener and leaves the stored script alone; `profile.instructions` swaps the script and leaves the stored greeting alone. Without `profile_id`, inline fields replace the agent's phone prompt and outbound greeting the same way.
- **A profile replaces the persona; it does not add to it.** Naming `profile_id` makes that profile the agent's *complete* instruction for the call. The stored persona is not used at all — not blended, not underneath. The agent still supplies its voice, its language and its knowledge base, and nothing else. So anything the role needs must be written into the profile itself: a thin profile written on the assumption that the persona still applies will produce a thin call. This is deliberate, and it is what lets one call carry one clean role.
- **The profile is the job; the briefing is today's facts.** They layer rather than compete. A profile says what this kind of call is for; `context.briefing` says who you are calling and what is true right now. Where they disagree the briefing wins, because it is the more specific of the two and it is rung 2. Neither can touch rung 1.
- **A profile belongs to one agent, and another agent's profile is refused** with `400 validation_error` naming the owning agent. A profile is written against one agent's persona and capabilities, so lending it to a different agent would grant a role that agent cannot perform — which is how a model ends up confidently promising something the system cannot deliver.
- **Campaign profiles are unaffected.** A campaign's own greeting override still beats the agent's greeting for campaign calls (rung 4 over rung 5). Only a per-call briefing outranks it, and campaigns do not send one.
- **Reserved keys are not repeated as data.** `briefing`, `language` and `timezone` drive behaviour, so they are not also read to the agent as facts about the customer. Every other key you send is (`purpose`, `matter_key`, your own ids). The whole `context` is stored and echoed back on `GET /calls/{call}` either way.

The conversation record's `language` (`GET /conversations/{conv}`) is derived from the call itself — set `context.language` and speak that language and the record follows. Until the conversation has been summarised it falls back to the agent's configured language, so a record read seconds after the call ends can still show the agent default.

## What the agent knows on a call

Every outbound call carries the agent's **knowledge summary** ([Agents → What the agent actually reads](agents.md#what-the-agent-actually-reads)) — the one text built from its documents and crawl, or written with `PATCH /agents/{agt}/kb/summary`. It sits in the prompt below the role (profile or agent prompt) and above the per-call sections, framed as facts the agent may draw on and explicitly not as instructions: it cannot change the role, the opener, the tools or the briefing, and the agent is told never to invent a fact that is not in it. A profile whose template the builder generated with the knowledge base already contains it, and it is not added twice.

Until 2026-09-05 the OpenAI outbound paths loaded the summary and did not put it in the prompt, so a call without a profile — or with a profile built without the knowledge base — ran with no company knowledge at all and improvised. Rewrite the summary for voice (short facts, no markup) if calls are your main channel; it is delivered verbatim.

## Everything in the request: the inline profile

An integration that already holds the script, the opener, the voice and the tone in its own system does not need to mint a UiriX profile per campaign — or per call — to hand them over. Send them on the call:

```bash
curl -s -H "Authorization: Bearer $SK" -H "$J" -X POST "$B/calls" -d '{
  "agent_id": "agt_308",
  "to_e164": "+972521234567",
  "profile": {
    "instructions": "You are Dana from EqualWeb billing. You are calling about an overdue invoice. Confirm the amount, offer a payment plan before a link, never threaten collection action, and end the call once a promise or a refusal is clear.",
    "greeting": "Hi, this is Dana from EqualWeb. Is this [Customer Name]?",
    "voice": "marin",
    "tone": { "formality": "formal", "empathy": "high", "persistence": "medium" },
    "allowed_capabilities": ["send_payment_link", "create_payment_promise", "send_sms", "end_call"],
    "completions": { "payment_link_base_url": "https://pay.example.com/inv/4417" }
  },
  "context": { "customer_name": "Noa Levi", "briefing": "Invoice 4417, EUR 820, 34 days overdue. Card declined twice on 12 Aug.", "matter_key": "dunning:4417" }
}' | jq '{call_id, profile_source, profile}'
```

```json
{ "call_id": "call_91", "profile_source": "inline", "profile": { "instructions": "You are Dana…", "greeting": "Hi, this is Dana…", "voice": "marin", "tone": { "formality": "formal", "empathy": "high", "persistence": "medium" }, "allowed_capabilities": ["send_payment_link", "create_payment_promise", "send_sms", "end_call"], "completions": { "payment_link_base_url": "https://pay.example.com/inv/4417" } } }
```

What each key does on the call — the same semantics as the matching profile field:

| Key | On the call |
|---|---|
| `instructions` | Becomes the system prompt. **Replaces** the stored profile's template, or the agent's phone prompt; it does not layer on either. Write the whole role. The platform still appends its own rules, the knowledge summary, the tool protocol and the greeting directive. |
| `greeting` | The opening line, spoken in the call's language (translated if written in another). Ahead of the profile's `greeting_message` and the agent's `outbound_greeting`. |
| `voice` | The voice for this call. Validated against the agent's provider at request time — the dialer will not silently fall back. |
| `tone` | Rendered as a `TONE FOR THIS CALL` block with concrete behaviour per level (how to address the customer, how to handle a concern, how many times to make the ask). It shapes *how* the agent speaks; it cannot change the rules or the tools, and a `context.briefing` may still override it. |
| `allowed_capabilities` | The tool gate: exactly these tools, `[]` for none. `send_sms` must be listed to exist. A `send_*_link` tool also needs its `completions` value. |
| `completions` | Facts the model treats as authoritative (`[IMPORTANT DATA TO USE IN CONVERSATION]`) and the payloads of the `send_*` tools. Merged over the profile's, yours winning. |

What it is **not**: it is not stored on the agent or on any profile. It lives on this call's record (`GET /calls/{call}` → `profile`), is read by the dialer once, and a campaign call dialled from the dashboard never carries one. `profile_source` on the call object records which shape ran.

Use a stored profile when many calls share a script and you want one place to edit it; use the inline profile when the script lives in your system, or differs per call. Use `context.briefing` for the per-call facts either way.

## Handing the call to a person: `transfer_call`

"Get Meir on the phone and transfer the call to me." The agent dials Meir, listens, and — only once a **live human** has answered and confirmed they are the person asked for (or agrees to talk) — hands the live call to a number **you give for this call**. Voicemail, a phone menu, a recording, or nobody having spoken yet: it does not transfer.

```bash
curl -s -H "Authorization: Bearer $SK" -H "$J" -X POST "$B/calls" -d '{
  "agent_id": "agt_308",
  "to_e164": "+972521234567",
  "profile": {
    "allowed_capabilities": ["transfer_call"],
    "completions": { "#transfer_number": "+972501234567" }
  },
  "context": { "customer_name": "Meir", "briefing": "Ask for Meir. When he confirms it is him and agrees to talk, say you are connecting him and transfer the call. If he is not available, do not transfer: take a message and end the call." }
}'
```

| Where | What |
|---|---|
| `profile.allowed_capabilities: ["transfer_call"]` | Required. Like `send_sms`, the tool exists only when listed. |
| `profile.completions["#transfer_number"]` | The default target: **one number in international format** (`+972501234567`). It is never read aloud and never shown to the model. |
| `profile.completions["#extension_list"]` | Optional, several targets, one `Name - +E164` per line (the format inbound agents use). The agent picks by name; a name the list does not know falls back to `#transfer_number`. |
| `context.briefing` | Who to ask for and what to do when they are not available. The platform adds the safety rules itself (confirmed human only). |

Refused with `400 validation_error` (`field: "profile.completions"`): a number that is not international format, or `transfer_call` with no target on a call without a stored profile. A stored profile may carry the same keys in its `completions`.

What happens: the agent says one short sentence, the platform announces "connecting your call" in the call's language and dials the target with the agent's number as caller id (25 s ring, up to 60 min). If the target is busy, does not answer or fails, the person on the line hears one apology and the call ends; nothing is retried. The outcome is recorded on the call as `transferred` (or `transfer_failed`; the operator's queue row and usage log), and the conversation transcript (`GET /conversations/{conv}`) shows the `transfer_call` tool turn, whose result names the destination and never the number.

## When a call is placed, and when it waits

**A calling window is a campaign concept.** A campaign works down a list of people who never asked to be rung, so it needs a 09:00–20:00 guard: nobody is watching it dial, and it will happily reach a stranger at 06:00. `POST /calls` is the opposite — one call, chosen by an operator who already knows what time it is at the other end. So a one-off API call **does not inherit a calling window**, and by default it is dialled the moment you ask for it, whatever the local hour: 03:00, 20:25, Sunday.

There are exactly three outcomes, and every wait among them is one you asked for:

| What you send | What happens | `status` | `scheduled_reason` |
|---|---|---|---|
| Nothing about time | Dialled **now** | `queued` | `null` |
| `policy.scheduled_at` | Waits for **exactly that instant**, to the second — nothing rounds it into a window | `scheduled` | `requested_time` |
| `policy.earliest_local_time` and/or `policy.latest_local_time` | You asked for a window, so it is enforced in the recipient's own time zone. Inside it: dialled now. Outside it: waits for the next window start, and `scheduled_at` (UTC) tells you when | `queued` or `scheduled` | `null` or `outside_requested_window` |

**A call is never quietly deferred.** `202` with a `scheduled_at` you did not ask for is neither dialling nor refusing: it reads as success while the call silently does not happen. So if anything would put a call in the future that you did not ask to postpone, the request is **refused** with `409 call_deferral_refused`, and the error names the instant it declined to defer to (`would_have_been_scheduled_at`) and the recipient's zone. Retry it deliberately with `policy.scheduled_at`, or ask for a window. You will not see this today — with no window inherited there is nothing left to defer a call you placed for now — and that is the point: it exists so a future gate has to announce itself instead of going quiet.

**Half a window is still a window you asked for.** Send only `earliest_local_time` and the other edge becomes the end of the recipient's local day (and vice versa) — never the campaign 09:00–20:00.

**The recipient's time zone.** `recipient_timezone` = `context.timezone` if you send one, else derived from the country/area code of `to_e164` (`+972…` → `Asia/Jerusalem`, `+1212…` → `America/New_York`), else UTC. It is always reported back, but it only *gates* anything when you asked for a window. Windows are half-open, `[earliest, latest)`, and must not cross midnight (`400 validation_error`).

**Retries.** A retry (`no_answer` / `busy` with attempts left) honours the window the call was placed with. A call placed without one has no window to honour, so it simply retries `retry_after_minutes` later.

**Changed 2026-09-04.** Until this release `POST /calls` inherited the campaign window (`09:00`–`20:00` in the recipient's zone). A call placed at 20:25 local came back `202 { "status": "scheduled" }` for 09:00 the next morning — deliberately placed, silently postponed, and it looked like it had worked. That default is gone. Campaigns are unaffected: their windows live on the campaign, not here.

**You are still responsible for when you call.** Removing the window removes a guess, not the law: local calling-hour rules, consent and do-not-call obligations are yours, and the [DNC list](#do-not-call-list) is still enforced on every request. If you want the platform to hold a call to business hours, ask for it — that is what `earliest_local_time` / `latest_local_time` are for.

## `GET /calls/{call}` · `GET /calls` — scope `calls:read`

```bash
curl -s -H "Authorization: Bearer $SK" "$B/calls/call_88" | jq '{status, answered_by, conversation_id, attempts, disclosure_played, duration_seconds}'
```

```json
{ "status": "completed", "answered_by": "human", "conversation_id": "conv_u601", "attempts": 1, "disclosure_played": true, "duration_seconds": 149 }
```

`GET /calls` filters: `account_id`, `matter_key`, `status` (repeatable), `agent_id`, `to_e164`, `from`, `to` (on `created_at`; default last 30 days), `limit`, `cursor`. Sorted `-created_at`.

**Errors:** `404 not_found` (unknown or another account's call).

## `DELETE /calls/{call}` — cancel — scope `calls:write`

Cancels anything not yet answered: `queued`, `scheduled` or `ringing` → `200 { "call_id", "status": "cancelled" }`. Any other status → `409 call_not_cancellable`.

## Do-not-call list

Per-account list of E.164 numbers. An originate request to a listed number is `403 dnc_blocked` before anything is dialled.

### DNC object

| Field | Type | Notes |
|---|---|---|
| `dnc_id` | string | `dnc_5` |
| `phone_e164` | string | |
| `reason` | string \| null | |
| `source` | `api` \| `agent_request` \| `import` | |
| `source_call_id` | string \| null | For `agent_request` |
| `created_at` | ISO-8601 | |

### `POST /dnc` — scope `dnc:write`

```bash
curl -s -H "Authorization: Bearer $SK" -H "$J" -X POST "$B/dnc" -d '{"phone_e164":"+12125550147","reason":"customer request via email"}' | jq .
```

```json
{ "dnc_id": "dnc_5", "phone_e164": "+12125550147", "reason": "customer request via email", "source": "api", "source_call_id": null, "created_at": "2026-09-03T16:20:00Z", "meta": { "…": "…" } }
```

| Body field | Type | Required | Notes |
|---|---|---|---|
| `phone_e164` | string | yes | E.164, e.g. `+12125550147` |
| `reason` | string | no | Free text, stored and returned |
| `source` | `api` \| `import` | no | Default `api`. `agent_request` is reserved for numbers the platform adds when a caller asks to be removed, and is rejected here |
| `account_id` | string | no | Required for a key covering more than one account, and for the master key |

`201`; re-adding an existing number returns the existing entry with `200`. **Errors:** `400 invalid_phone` · `400 unsupported_filter` (`source: "agent_request"`).

### `GET /dnc` — scope `dnc:read`

Filters: `account_id`, `phone_e164`, `source`, `limit`, `cursor`.

### No `DELETE /dnc/{dnc}` — removed 2026-09-06

> **No deletion through the API (Nir, 2026-09-06).** Nothing is deleted through the Public API: there is no `DELETE` route for conversations, subjects, outbound profiles, knowledge documents, do-not-call entries or webhook endpoints. Those paths answer `404 not_found` for every key, the master key included. Deleting is a dashboard action by the account owner. The two `DELETE` verbs that remain — `DELETE /calls/{call}` (cancel a call that has not been dialled) and `DELETE /credentials/{key}` (revoke a key) — are cancellations, not deletions of data.

A do-not-call entry is a protection; lifting it is deliberately not something an integration can do. It is removed in the dashboard by the account owner, and the removal is audited.

### Automatic append

When a completed call's analysis detects that the recipient asked not to be called again, the number is appended with `source: "agent_request"` and `source_call_id`, and the call record gets `dnc_requested: true`, `dnc_flagged: true`. The next `POST /calls` to that number is `403 dnc_blocked`.

## Quotas and limits

| Limit | Default | Where |
|---|---|---|
| `max_calls_per_day` | 200 per account (UTC day) | `GET /accounts/{acc}/limits`; exceeded → `429 call_quota_exceeded` + `Retry-After` (seconds until 00:00 UTC) |
| `max_concurrent_calls` | 2 per account | Excess calls wait in `queued` — no error |
| Rate class `calls` | 20 req/min per credential | `POST /calls`, `DELETE /calls/{call}` |
| Briefing length | 8,000 characters (any script — since 2026-09-06 the briefing no longer travels in the dialer's TwiML, see below) | `400 validation_error` |
| Ring timeout | 25 s per attempt | fixed in v1 |

Limits are adjustable per account by UiriX (`PATCH /users/{usr}` with the master key).

**How many calls may I still place?** `GET /accounts/{acc}/limits` answers `calls_remaining_today` (the daily cap minus `calls_today`; `null` when the account has no daily cap) and `GET /accounts/{acc}/balance` answers `can_place_calls`. `calls_today` counts calls placed through `POST /calls` only — **campaign calls placed from the dashboard are not in it** (`counts_campaign_calls: false`). See [Accounts → limits](accounts-and-users.md#get-accountsacclimits--scope-accountsread) and [balance](accounts-and-users.md#get-accountsaccbalance--scope-usageread).

**Where the per-call data travels (and the limit that used to exist).** The dialer places the call with an inline TwiML document, which Twilio caps at 4,000 bytes. Until 2026-09-06 the per-call data — `context.briefing`, `purpose`, the readable `context` — travelled inside that document, base64-encoded, so a briefing of about 900 Hebrew characters (or 1,800 Latin ones) was the practical ceiling; a longer one (call_1132, 2,255 Hebrew characters) was accepted with `202` and the call failed at dial with Twilio error `32018`, the recipient never rung. For a few hours on 6 September `POST /calls` refused such a payload up front. **Now the per-call data is stored with the call and read from the database when the call connects**, on both the OpenAI and the Gemini path; the TwiML carries only ids. The briefing has no byte limit any more — only the 8,000-character cap above, which is about what a prompt can carry usefully. Long standing instructions still belong in a profile template (`POST /profiles`); the briefing is for what is specific to this one call.

## `matter_key`

Repeated calls on the same case carry the same `matter_key` (`dunning:example.com:2026-09`). `GET /calls?matter_key=` returns the series in chronological order, and the key is echoed on `call.*` webhook payloads, so one case is one queryable thread.

## `answered_by`

Reported on the call record and on `call.*` payloads:

| Value | Meaning |
|---|---|
| `human` | A person answered; conversation ran |
| `voicemail` | Voicemail greeting detected; hung up (`status: "voicemail"`) |
| `machine` | Other machine/IVR detected; hung up |
| `unknown` | Detection inconclusive; treated as human |
| `null` | Not answered yet / never answered |

## Recording and disclosure

With `policy.recording: true` (default) the call is recorded (dual-channel: caller / agent) and the agent's **first turn is the recording disclosure** appropriate to the recipient's jurisdiction; the record carries `disclosure_played: true`. The recording is available through `GET /conversations/{conversation_id}/recording` with `audio:read`. `policy.recording: false` disables recording for that call.

## Voicemail: what the agent says to a machine

**There is no carrier-side answering-machine detection on outbound calls (since 2026-09-05).** Twilio's detection decided in the first three seconds of a call and, twice in one afternoon, classified a person answering in a room with a second voice as a machine, 12 ms before the agent's first word, so the call died silent. It is off for every outbound call, campaigns included. `answered_by` is therefore `null` on every call placed since, and `status: voicemail` is no longer produced by a verdict.

Instead the **agent decides from what it hears**. The prompt frame tells it: a recorded greeting or a beep means a voicemail; stop talking, wait for the beep, say the message, then end the call; a live person who answers with a short word is greeted and waited for; never hang up on a machine without leaving the message. The message is yours — **it comes from your instructions**, and if they carry none the agent leaves a short identification in the call language and says it will call back.

To control it, put the message on the **first line** of the prompt the call runs on, in this exact syntax:

```
VOICEMAIL_MESSAGE: Hi, this is Dana from EqualWeb about invoice 4417. Please call us back on 03-1234567. Thank you.
```

| Where the call's prompt comes from | Where the line goes |
|---|---|
| No profile (the agent's own prompt) | first line of `instructions` (`PATCH /agents/{agt}`) |
| A stored profile | first line of `system_instruction_template` |
| An inline `profile.instructions` | first line of that text |

`context.briefing` does **not** count — it is a separate block and the runtime reads the line from the system prompt only. The runtime extracts the text, substitutes it wherever the prompt says `{{voicemailMessage}}`, and quotes it word for word inside the voicemail block of the frame (*"After the beep, say EXACTLY this message…"*), so the first line alone is enough. The message is spoken as written; with `context.language` set it is spoken in that language, so write it in that language. The one thing the platform cannot do is hear the beep for the model: there is no tone detection on the audio stream, so recognising the recording and the beep is the model's own ear.

## `callback_url`

If set, the `call.*` events for this call are also POSTed to `callback_url` with the same payload and headers as [webhooks](webhooks.md) (retries, stable `X-Uirix-Delivery`, transcript cap). The signature uses the secret of the account's webhook endpoint subscribed to `call.*`; if the account has no such endpoint the request is **unsigned** (`X-Uirix-Signature` absent) — treat `callback_url` as a convenience and prefer a webhook endpoint for anything you act on automatically.

## `call.*` events

| Event | When | Notes |
|---|---|---|
| `call.completed` | `status: "completed"` | Full conversation object + call fields; `answered_by`, `disclosure_played`, `dnc_requested` |
| `call.no_answer` | `status` ∈ `no_answer` \| `busy` \| `voicemail` after the last attempt | Call object |
| `call.failed` | `status: "failed"` | Call object with `error` |

`conversation.ended` and `summary.ready` fire as well for the underlying `conv_u…` conversation.

## v1 limitations

- **Voicemail messages are not supported**: `policy.voicemail: "leave_message"` and `voicemail_script` return `400 unsupported_policy`. Detection works (`answered_by: "voicemail"`), the call is hung up. Leaving a scripted message is a separate, later item ([changelog](changelog.md)).
- Ring timeout is fixed at 25 s; `max_attempts` ≤ 3.
- One call per request. Batches are [campaigns](campaigns.md): contact lists, a calling window per campaign, start / pause / stop and progress.
- Answer detection is asynchronous: the first second of a machine-answered call may reach the agent before the hang-up.

---

**UiriX Public API v1** · [Overview](README.md) · [Quickstart](quickstart.md) · [Authentication](authentication.md) · [Scopes](scopes.md) · [Conventions](conventions.md) · [Accounts & Users](accounts-and-users.md) · [Agents](agents.md) · [Capabilities](capabilities.md) · [Profiles](profiles.md) · [Conversations](conversations.md) · [Chat via API](chat-via-api.md) · [Widget Events](widget-events.md) · [Webhooks](webhooks.md) · [Calls](calls.md) · [Campaigns](campaigns.md) · [Metrics](metrics.md) · [Voices](voices.md) · [Enums](enums.md) · [Partners](partners.md) · [Admin](admin.md) · [MCP](mcp.md) · [Changelog](changelog.md) · [OpenAPI](openapi.yaml)


<!-- ===== campaigns.md ===== -->

# Contact Lists & Campaigns (batch outbound)

> **This page is the batch side of outbound.** [`POST /calls`](calls.md) places **one** call. A **campaign** works through a **list** of people — hundreds or thousands — under one [profile](profiles.md), on the days and hours you choose, in **each recipient's own local time**. Everything on this page runs on the same dialer, the same billing and the same do-not-call list as the dashboard's *Outbound* tab.

The flow is always the same five steps, and **only the fourth one dials**:

1. `POST /agents/{agt}/lists` — an empty list.
2. `POST /lists/{lst}/import` — append contacts, as many requests as needed (max 1,000 rows each).
3. `POST /campaigns` — a **draft**: the list is snapshotted into the dialer's queue. Nothing is dialled.
4. `POST /campaigns/{cmp}/start` with `{"confirmed": true}` — real calls begin, inside the days and window.
5. `GET /campaigns/{cmp}` and `GET /campaigns/{cmp}/calls` — progress, report, one row per contact. `pause` / `stop` when needed.

Scopes: **`calls:read`** for every `GET`, **`calls:write`** for every `POST` — the same two scopes as `POST /calls`, the *Outbound calls* group of the [MCP connector](mcp.md#scopes-and-consent-groups). A **sandbox key cannot write** here (`403 sandbox_unsupported`): a campaign ends in real calls and there is no simulated dialer. Rate class: creating, starting, pausing and stopping a campaign share the `calls` class (20/min); everything else uses `read` / `write`. Variables below: `$B` base URL, `$SK` personal key, `$J` = `Content-Type: application/json`.

**Nothing here can be deleted or edited through the API** (no `DELETE`, no `PATCH`): a list is filled by importing, a campaign is ended by `stop`. Removing lists and campaigns is a dashboard action of the account owner.

## Identifiers

| Prefix | What | Notes |
|---|---|---|
| `lst_{n}` | a contact list | |
| `cmp_{n}` | a campaign | |
| `itm_{n}` | a contact on a list | read-only, informational |
| `qcl_{n}` | a contact's row inside a campaign | read-only, informational |

A list, campaign, agent or profile of another account is **`404`** — `list_not_found`, `campaign_not_found`, `agent_not_found`, `profile_not_found` — never `403`: the API does not confirm that an id exists elsewhere. Every route resolves the account like the rest of the API (`account_id` in the body or query, or `X-Uirix-Account`, or the credential's home account).

## Lists

### `POST /agents/{agt}/lists` — create · `calls:write`

| Body field | Type | Required | Notes |
|---|---|---|---|
| `name` | string 1–255 | yes | Unique per agent, case-insensitive → `409 list_name_exists` |
| `description` | string ≤ 2000 \| null | no | |
| `account_id` | string | no | |

`201`:

```json
{ "list_id": "lst_88", "account_id": "acc_17", "agent_id": "agt_301", "name": "October renewals",
  "description": null, "status": "processing", "items_count": 0, "created_at": "2026-10-01T08:30:00.000Z" }
```

### `GET /agents/{agt}/lists` · `GET /lists/{lst}` — read · `calls:read`

The first is a keyset page (`limit`, `cursor`), newest first. `GET /lists/{lst}` adds `timezones`: how the contacts are spread over time zones (`{"Asia/Jerusalem": 41, "America/New_York": 3, "unknown": 2}`; see [Time zones](#time-zones-the-window-is-the-recipients)).

### `POST /lists/{lst}/import` — append contacts · `calls:write`

```json
{ "default_country": "IL",
  "rows": [
    { "phone": "052-1234567", "name": "Dana Levi", "external_reference": "crm-1041", "city": "Haifa", "plan": "gold" },
    { "phone": "+12125550147", "first_name": "John", "last_name": "Smith", "timezone": "America/New_York" }
  ] }
```

| Row key | Notes |
|---|---|
| `phone` | **Required.** String or number, any common format. See below |
| `name` | Display name. When absent, `first_name` + `last_name` |
| `first_name`, `last_name` | Also kept under `extra` |
| `external_reference` | Your id for the contact (≤ 255) |
| `timezone` | IANA name (`Asia/Jerusalem`). Overrides the zone the number implies. Not a valid IANA name → that row is `invalid` |
| *any other key* | Stored as `extra` and given to the agent as a **fact about this contact** (`city`, `balance`, `plan`…). ≤ 50 keys, ≤ 8,000 characters a row |

**Phone numbers** are normalised to E.164. A number that starts with `+` (or `00`) is read as international. Without a prefix it is read as a **national number of `default_country`** (ISO 3166-1 alpha-2, `IL`, `US`, …); if that fails, as an international number that lost its `+` (`972521234567`, what a spreadsheet does). With no `default_country` and no prefix the row is `invalid` with the reason *"no country prefix: start the number with '+' or pass default_country"*. Scientific notation from a spreadsheet (`9.72541E+11`) and numeric cells are handled.

**The import appends.** Numbers already in the list, and numbers repeated inside the request (first one wins), are `duplicates` — compared by their E.164 form, so `052-1234567` and `+972521234567` are the same contact. **Numbers on the account's [do-not-call list](calls.md#do-not-call-list) are never imported** (`dnc_skipped`). A row that cannot be used never aborts the batch: it is listed in `invalid`.

Limits: **1,000 rows per request** (`400 validation_error`, `field: "rows"`), **10,000 contacts per list** — an import that would pass it is refused whole, importing nothing (`409 list_limit_exceeded`, with `items_count`, `would_add`, `max_items`). Imports into one list run one at a time.

`200`:

```json
{ "list_id": "lst_88", "received": 6, "imported": 3, "duplicates": 1, "dnc_skipped": 1,
  "invalid": [ { "row": 4, "phone": "12", "reason": "not a valid phone number for IL or as an international number" } ],
  "timezones": { "Asia/Jerusalem": 2, "America/New_York": 1 },
  "items_count": 3,
  "warnings": ["1 number is on the do-not-call list and was not imported."] }
```

`received = imported + duplicates + invalid.length + dnc_skipped` — every row is in exactly one bucket. `invalid[].row` is the **1-based** position in `rows`.

### `GET /lists/{lst}/items` — the contacts · `calls:read`

Keyset page, import order: `{ "item_id", "phone_e164", "name", "timezone", "status", "extra" }`. `status` is `new`, `contacted` (a campaign has tried) or `do_not_call`.

## Campaigns

### `POST /campaigns` — create a draft · `calls:write`

| Body field | Type | Default | Notes |
|---|---|---|---|
| `agent_id` | string | — | The agent must own an **active phone number** → `409 agent_has_no_phone_number` |
| `list_id` | string | — | A list **of this agent** (another agent's list → `400`, `field: "list_id"`) |
| `profile_id` | string | — | An **active** outbound [profile](profiles.md) **of this agent** (`prf_…`): the script the calls run under |
| `name` | string 1–255 | — | |
| `days` | int[] 0–6 | `[1,2,3,4,5]` | **0 = Sunday … 6 = Saturday**, in the *recipient's* local calendar |
| `window` | `{from, to}` `HH:MM` | `09:00`–`18:00` | The calling hours in the *recipient's* local time. `from` < `to`; overnight windows are not supported |
| `start_date`, `end_date` | `YYYY-MM-DD` \| null | null | `end_date` not in the past, not before `start_date`. See [dates](#dates) |
| `max_calls_per_hour` | int 1–300 | 30 | See [limits](#limits-and-what-the-dialer-enforces) |
| `max_concurrent_calls` | int 1–5 | 1 | |
| `max_attempts` | int 1–3 | 2 | Attempts per contact |

Also checked: the plan can run outbound campaigns (Guest plan, or the monthly outbound quota used up → `403 plan_limit_reached`), and the list has at least one **callable** contact (a list that is empty, or whose every contact is on the do-not-call list, is `400`).

`201`:

```json
{ "campaign_id": "cmp_412", "status": "draft", "account_id": "acc_17", "agent_id": "agt_301", "list_id": "lst_88",
  "profile_id": "prf_12", "name": "October renewals", "days": [0,1,2,3,4], "window": { "from": "10:00", "to": "17:00" },
  "start_date": null, "end_date": null, "max_calls_per_hour": 30, "max_concurrent_calls": 1, "max_attempts": 2,
  "contacts": 44, "dnc_skipped": 1, "timezones": { "Asia/Jerusalem": 42, "unknown": 2 }, "created_at": "2026-10-01T08:40:00.000Z" }
```

`contacts` is the number of queued rows; `dnc_skipped` the contacts of the list left out because their number is on the do-not-call list. The queue is a **snapshot**: contacts imported afterwards join a campaign that is `running`, `paused` or `completed`, but **not a `draft`** — import first, then create the campaign.

### `GET /campaigns` · `GET /campaigns/{cmp}` — read · `calls:read`

`GET /campaigns?agent_id&status&limit&cursor` — newest first, each row with `progress`; archived campaigns are hidden unless `status=archived`. `GET /campaigns/{cmp}` adds `report`:

```json
{ "campaign_id": "cmp_412", "status": "running", "agent_id": "agt_301", "list_id": "lst_88", "profile_id": "prf_12",
  "name": "October renewals", "days": [0,1,2,3,4], "window": { "from": "10:00", "to": "17:00" },
  "start_date": null, "end_date": null, "max_calls_per_hour": 30, "max_concurrent_calls": 1, "max_attempts": 2,
  "progress": { "total": 44, "pending": 20, "in_progress": 1, "completed": 15, "no_answer": 4, "busy": 1, "voicemail": 2, "failed": 1, "cancelled": 0 },
  "report": { "total_calls": 44, "outcomes": { "completed": 15, "failed": 1, "no_answer": 4, "busy": 1, "voicemail": 2 },
              "avg_duration_seconds": 74.2, "avg_rating": 4.1, "cost_usd": 3.4125 },
  "created_at": "2026-10-01T08:40:00.000Z" }
```

`progress` counts the contacts **right now**: `pending` includes contacts waiting for a retry or for their window; `in_progress` is dialling or talking. `report` carries the figures of the dashboard's campaign report; `cost_usd` is what the campaign has cost the account.

Campaign `status`: `draft` · `running` · `paused` · `stopped` (final) · `completed` (the queue drained) · `failed` · `cancelled` · `archived`; `scheduled` exists for dashboard scheduling and is never set by the API.

### `GET /campaigns/{cmp}/calls` — one row per contact · `calls:read`

`?status=pending|in_progress|completed|voicemail|no_answer|busy|failed|cancelled&limit&cursor`, in queue order:

```json
{ "queued_call_id": "qcl_9001", "phone_e164": "+972521234567", "name": "Dana Levi", "status": "completed",
  "attempts": 1, "outcome": "lead_captured", "answered_by": "human", "called_at": "2026-10-04T08:12:31.000Z",
  "duration_seconds": 61, "next_attempt_at": null, "conversation_id": "conv_u55812" }
```

`outcome` is the call's outcome from its structured summary ([enums](enums.md)) when there is one, else what the dialer recorded, else `null`. `conversation_id` (`conv_u…`) appears once the call has a transcript — read it with `GET /conversations/{conv}`. A contact waiting for a retry shows `status: "pending"` with `next_attempt_at`.

### `POST /campaigns/{cmp}/start` · `pause` · `stop` — `calls:write`

| Action | Allowed from | Result |
|---|---|---|
| `start` | `draft`, `paused` | `running` |
| `pause` | `running` | `paused` — waiting contacts stay queued |
| `stop` | `draft`, `running`, `paused` | `stopped` — **final**; calls in progress are ended; create a new campaign to go again |

Asking for the state the campaign is already in answers `200` with `"changed": false` and touches nothing (safe to retry). Any other combination is `409 campaign_state_conflict` (the current status is in `error.status`). The answer is `{ "campaign_id", "status", "changed", "meta" }`.

**`start` places real calls to real people and costs money**, so the body must say so: `{"confirmed": true}` — without it `400 confirmation_required` and nothing happens. Before the dialer is told:

1. the plan and the agent's phone number are checked again (`403 plan_limit_reached`, `409 agent_has_no_phone_number`);
2. the balance gate applies: `402 payment_required` — the same one as `POST /calls`;
3. the dates are honoured (`409` before `start_date` or after `end_date`);
4. contacts that went on the do-not-call list **since the campaign was created** are cancelled (`dnc_cancelled` in the answer) — the dialer itself does not look at the list, so this is the last moment the API can stop them;
5. a campaign with nothing left waiting is `409`.

If the dialer does not answer, `503 dialer_unavailable` and the campaign is unchanged — retry.

#### Dates

`start_date` / `end_date` are **guards, not a scheduler**: nothing starts by itself. `start` is refused (`409`) before `start_date` and after `end_date`, compared in UTC. A running campaign is not stopped by `end_date`; the dialer keeps working through queued contacts, and finishes the campaign only when none is waiting (contacts added later re-open a completed campaign). To end a campaign at a given moment, call `stop`.

#### Limits and what the dialer enforces

`max_attempts` is enforced per contact: after a `no_answer`, `busy`, `failed` or voicemail the contact is re-queued **30 minutes** after the first attempt, **60** after the second, and so on, until `max_attempts` is used; a retry goes through the same window check, in the contact's own time zone. `max_calls_per_hour` and `max_concurrent_calls` are **stored and returned**, but the dialer today applies its **platform-wide** ceilings for concurrency and hourly rate and does not read the two per-campaign values — treat them as requests, not guarantees.

## Time zones: the window is the recipient's

`window` and `days` are evaluated **per contact, in that contact's own local time and calendar**, every time the dialer picks the next contact. A Sunday-to-Thursday, 10:00–17:00 campaign over Israeli and New York contacts calls Jerusalem from 10:00 Israel time and New York from 10:00 New York time — and a contact whose local day is Friday is skipped, whatever the day is where the campaign runs. A contact is callable when its local time is `from` ≤ time ≤ `to` (minute resolution) on an allowed day; the others wait, and the campaign stays `running`.

**Where a contact's zone comes from**, in order — every contact gets one, so **no contact is ever callable at any hour**:

1. the row's `timezone` (IANA name) in the import;
2. the number itself: for `+1` numbers the **area code** (Eastern, Central, Mountain, Pacific, Arizona, Alaska, Hawaii, Canadian provinces), otherwise the **calling country**;
3. the import's `default_country` zone;
4. the account's own time zone, else UTC.

Steps 3–4 are guesses. Contacts that fell to them are reported under **`unknown`** in `timezones` (import, campaign creation and list read), and the import answers a warning — send a `timezone` per row, or a `default_country`, to place them correctly. IANA zones follow **daylight saving time**; a stored legacy offset (`UTC-5`, from lists made in the dashboard, which knows one fixed offset per country) does not, so `POST /campaigns` re-resolves a legacy or missing zone from the phone number when it can. The dialer understands both forms, so existing dashboard campaigns are unaffected.

## Do-not-call

| Moment | What happens |
|---|---|
| **Import** | Numbers on the account's DNC list are skipped and counted in `dnc_skipped`; they never enter the list |
| **Campaign creation** | Contacts flagged `do_not_call`, or whose number is on the DNC list, are not queued (`dnc_skipped`) — this also applies to campaigns created in the dashboard |
| **DNC entry added later** | The entry is mirrored onto the list's contacts (`status: "do_not_call"`). `start` cancels the campaign's queued rows for those numbers (`dnc_cancelled`) |
| **Running campaign** | The dialer does not re-check the list per call. To stop a number that just asked not to be called, `pause` and `start` the campaign again (the sweep runs on every `start`), or `stop` it |

One gap is documented rather than closed: contacts that reach a **running** campaign's list some other way than this API's import (added by hand in the dashboard, or by a CRM sync) are queued by a database trigger that does not read the do-not-call list. The API's import filters the list before it inserts, and `start` sweeps the queue, so an API-driven campaign is covered end to end; a mixed one should be paused and started again after such additions.

The list itself is managed through [`/dnc`](calls.md#do-not-call-list).

## Limits

| Limit | Value | Error |
|---|---|---|
| Rows per import request | 1,000 | `400 validation_error` (`field: "rows"`) |
| Contacts per list | 10,000 | `409 list_limit_exceeded` |
| `max_calls_per_hour` | 1–300 | `400 validation_error` |
| `max_concurrent_calls` | 1–5 | `400 validation_error` |
| `max_attempts` | 1–3 | `400 validation_error` |
| Time zone column | IANA names up to 50 characters (migration `014_outbound_contact_timezone_iana`) | — |

## Errors

| Status | Code | When |
|---|---|---|
| 400 | `validation_error`, `unsupported_field`, `invalid_id` | Body shape; window/days/date rules; bad `default_country`; a list/profile of another agent, an inactive profile, an empty list |
| 400 | `confirmation_required` | `start` without `"confirmed": true` |
| 402 | `payment_required` | `start`: the balance or subscription does not allow calls |
| 403 | `insufficient_scope`, `forbidden_account`, `sandbox_unsupported` | Scope, account, sandbox key |
| 403 | `plan_limit_reached` | Guest plan, or the monthly outbound quota is used up (`error.limit`, `error.current`) |
| 404 | `agent_not_found`, `list_not_found`, `campaign_not_found`, `profile_not_found` | Unknown id, or another account's |
| 409 | `list_name_exists` | Same name on the same agent |
| 409 | `list_limit_exceeded` | The import would pass 10,000 contacts; nothing imported |
| 409 | `agent_has_no_phone_number` | The agent owns no active number |
| 409 | `campaign_state_conflict` | The action is not allowed from the current status; dates; nothing left to call |
| 429 | `rate_limited` | `calls` class 20/min |
| 503 | `dialer_unavailable` | The outbound dialer did not answer; nothing changed |

## Walkthrough

```bash
# 1. a list on the agent
LST=$(curl -s -H "Authorization: Bearer $SK" -H "$J" -X POST "$B/agents/agt_301/lists" \
  -d '{"name":"October renewals"}' | jq -r .list_id)

# 2. the customer's spreadsheet, as rows (max 1,000 per request; repeat for more)
curl -s -H "Authorization: Bearer $SK" -H "$J" -X POST "$B/lists/$LST/import" -d '{
  "default_country": "IL",
  "rows": [
    { "phone": "052-1234567", "name": "Dana Levi", "external_reference": "crm-1041", "plan": "gold" },
    { "phone": "0541234567",  "name": "Avi Cohen" },
    { "phone": "+12125550147", "first_name": "John", "last_name": "Smith", "timezone": "America/New_York" }
  ] }' | jq '{imported, duplicates, dnc_skipped, invalid, timezones, items_count}'

# 3. a draft — Sunday to Thursday, 10:00-17:00 in each contact's own time. Dials nothing.
CMP=$(curl -s -H "Authorization: Bearer $SK" -H "$J" -X POST "$B/campaigns" -d '{
  "agent_id": "agt_301", "list_id": "'$LST'", "profile_id": "prf_12",
  "name": "October renewals", "days": [0,1,2,3,4], "window": { "from": "10:00", "to": "17:00" }
}' | jq -r .campaign_id)

# 4. the owner approved it: start (real calls, real money)
curl -s -H "Authorization: Bearer $SK" -H "$J" -X POST "$B/campaigns/$CMP/start" -d '{"confirmed": true}'

# 5. watch it
curl -s -H "Authorization: Bearer $SK" "$B/campaigns/$CMP" | jq '{status, progress, report}'
curl -s -H "Authorization: Bearer $SK" "$B/campaigns/$CMP/calls?status=completed&limit=50" | jq '.data[] | {phone_e164, outcome, conversation_id}'

# pause / stop
curl -s -H "Authorization: Bearer $SK" -H "$J" -X POST "$B/campaigns/$CMP/pause" -d '{}'
curl -s -H "Authorization: Bearer $SK" -H "$J" -X POST "$B/campaigns/$CMP/stop" -d '{}'
```

## Limitations

- No update, no delete, no removal of a single contact through the API — dashboard actions.
- A campaign's contacts are fixed at creation, apart from what a running, paused or completed campaign receives through later imports.
- `max_calls_per_hour` / `max_concurrent_calls` are not enforced per campaign (see [Limits](#limits-and-what-the-dialer-enforces)).
- One `profile_id` per campaign; per-contact facts go in the row's extra fields.
- No campaign-level webhooks: poll `GET /campaigns/{cmp}`. The `call.*` events belong to `POST /calls`; a campaign call's conversation is an ordinary `conv_u…` record (`agent_type: "phone_outbound"`).

---

**UiriX Public API v1** · [Overview](README.md) · [Quickstart](quickstart.md) · [Authentication](authentication.md) · [Scopes](scopes.md) · [Conventions](conventions.md) · [Accounts & Users](accounts-and-users.md) · [Agents](agents.md) · [Capabilities](capabilities.md) · [Profiles](profiles.md) · [Conversations](conversations.md) · [Chat via API](chat-via-api.md) · [Widget Events](widget-events.md) · [Webhooks](webhooks.md) · [Calls](calls.md) · [Campaigns](campaigns.md) · [Metrics](metrics.md) · [Voices](voices.md) · [Enums](enums.md) · [Partners](partners.md) · [Admin](admin.md) · [MCP](mcp.md) · [Changelog](changelog.md) · [OpenAPI](openapi.yaml)


<!-- ===== capabilities.md ===== -->

# Capabilities

> Scope `agents:read` for both catalogues. Reference data, identical for every account — no account parameter.

Two different things are called a "capability" on this platform, and they must not be mixed up:

| | **Agent capabilities** | **Outbound capabilities** |
|---|---|---|
| Belongs to | the **agent** — inbound phone, widget chat, widget voice, chat via API | an **outbound profile**, or the inline `profile` on `POST /calls` |
| Field | `capabilities` on `PATCH /agents/{agt}` | `allowed_capabilities` on a profile / inline profile |
| Catalogue | `GET /capabilities` | `GET /outbound-capabilities` |
| Grouped by | kind: `knowledge` or `action` | profile **type**: `collections`, `sales`, `events` |
| What it does | knowledge → an instruction block; action → a **tool** the model may call | every entry is a **tool** the model may call on that call |
| Values it may need | `agents.completions[required_field.id]` | profile `completions[required_field.id]` |

Granting `send_payment_link` to a chat agent, or `create_lead` to a collections profile, is exactly the mistake this page exists to prevent. Since 2026-09-05 both fields are **validated**: an unknown id, or an id a profile type may not grant, is `400 validation_error` naming it. Before that an unknown id was stored, matched no tool at call time and granted nothing — with no error to tell you.

---

## Agent capabilities — `GET /capabilities`

What an **inbound** agent knows and does. Source of truth: `shared/config/capabilities-unified.json`, the same file every runtime reads.

```bash
curl -s -H "Authorization: Bearer $SK" "$B/capabilities?kind=action" | jq '.data[] | {id, label, required_field: .required_field.id}'
```

| Parameter | Values |
|---|---|
| `kind` | `knowledge` \| `action` — omit for both |
| `include_inactive` | `true` to include retired entries. Never offer one as a new choice; ask only to explain an id an existing agent already holds |

### Entry

| Field | Type | Meaning |
|---|---|---|
| `id` | string | The value to put in `capabilities` |
| `kind` | `knowledge` \| `action` | See below |
| `label`, `category`, `description` | string | For humans |
| `active` | boolean | `false` = retired |
| `requires_function_calling` | boolean | Actions only: the model is given a callable tool |
| `required_field` | object \| null | A value the capability needs before it can do anything: `{ id, label, question, placeholder, help_text, input_type }`. Put the value in `agents.completions` under `required_field.id` (e.g. `"#appointment_link": "https://calendly.com/…"`) |
| `required_connector` | object \| null | A **connected connector** the capability needs — `{ types, any_of, message }`; see [Required connectors](#required-connectors). Today only `schedule_google_calendar_meeting` has one |

### What each kind actually does at runtime

**`knowledge`** (`MyBusiness`, `faq`, `policies`, `products_catalog`, `services_catalog`, …) does two things. At build time it tells the knowledge-summary builder what to extract from your documents. At conversation time it adds an instruction block telling the agent how to answer that subject — and the agent's `interaction_mode` (`passive` / `active` / `aggressive`) picks which variant of that block, from "answer only what was asked" to "use it to drive a sale". A knowledge capability gives the agent **no new facts**: the facts come from the knowledge summary ([What the agent actually reads](agents.md#what-the-agent-actually-reads)). It shapes how the agent uses what it already knows.

**`action`** (`send_email`, `send_sms`, `create_lead`, `transfer_call`, `schedule_appointment`, `send_website_link`, …) gives the model a **tool**. The model may call it when the conversation warrants; the platform executes it. An action with a `required_field` does nothing useful until that value exists in `completions`: `send_appointment_link` with no `#appointment_link` is a tool that sends nothing. Some actions are surface-specific — `transfer_call` and `send_sms` are phone tools and are filtered out of the chat build; the widget voice additionally runs a safety allow-list and never exposes `transfer_call`.

Which surfaces read `agents.capabilities`: inbound phone, widget chat, chat via API, widget voice, and the dashboard chat and realtime tabs. **Outbound calls do not** — they use the profile's `allowed_capabilities` (next section). An agent's `capabilities` has no effect on a call placed with `POST /calls`.

---

## Outbound capabilities — `GET /outbound-capabilities`

The tools an **outbound call** may use, grouped by the **profile type** that unlocks them. Source of truth: `shared/config/outbound-capabilities-with-instructions.json` (tools) and `shared/Outboundconfig/all-roles-config.json` (roles), the files the dashboard wizard and the dialer read.

```bash
curl -s -H "Authorization: Bearer $SK" "$B/outbound-capabilities?category=collections" | jq '.data[0] | {groups, capabilities: [.capabilities[].id], focuses: [.focuses[].id]}'
```

| Parameter | Values |
|---|---|
| `category` | `collections` \| `sales` \| `events` \| `appointments` — omit for all |
| `include_inactive` | `true` to include the hidden `appointments` type and retired tools |

### The three profile types

Each type unlocks **one group of tools plus the communication group**, and has its own list of **focuses** (the goals the builder writes the script around) and **roles** (starting scripts the dashboard offers):

| Type | Tool group | Tools (active) | Focuses (examples) |
|---|---|---|---|
| `collections` | `payment` + `communication` | `send_payment_link`\*, `create_payment_promise`, `mark_dispute`, `send_bank_transfer_details`\*, `send_sms`, `send_email`, `end_call` | `collect_payment_immediately`, `secure_payment_promise`, `understand_payment_issue`, `update_payment_method` |
| `sales` | `sales` + `communication` | `send_zoom_link`\*, `send_instant_meeting`\*, `send_product_info`\*, `send_coupon_code`\*, `send_proposal`\*, `send_testimonials`\*, `send_pricing_info`\*, `send_free_trial`\*, `schedule_calendar_meeting`, `send_sms`, `send_email`, `end_call` | `build_rapport`, `identify_pain_points`, `qualify_lead`, `promote_product_service`, `schedule_demo`, `close_deal` |
| `events` | `events` + `communication` | `send_directions_googlemaps`\*, `send_directions_waze`\*, `update_rsvp`, `update_guest_count`, `send_event_ticket`\*, `send_event_details`\*, `send_sms`, `send_email`, `end_call` | `secure_rsvp`, `build_event_excitement`, `share_event_details` |
| `appointments` | `scheduling` + `communication` | **hidden** — every scheduling tool is inactive until calendar integration lands; only the communication tools remain | — |

\* needs a value in `completions` under `required_field.id` (`payment_link_base_url`, `zoom_meeting_link`, `event_ticket_link`, …). The endpoint reports the exact key per tool.

This is the rule the dashboard wizard applies (step 4 of the profile card) and the rule the API enforces: **a `sales` profile cannot grant `send_payment_link`**, and the `400` lists what it may grant instead. `focuses` are checked the same way against the type's list.

### Entry

| Field | Meaning |
|---|---|
| `id` | the type |
| `active` | `false` for `appointments` |
| `groups` | the tool groups this type unlocks |
| `capabilities[]` | `{ id, group, label, description, active, required_field, required_connector }` — the ids `allowed_capabilities` may hold. `required_field` is the value the tool needs in `completions`; `required_connector` the connected connector it needs (see [Required connectors](#required-connectors)); either is `null` when the tool needs neither |
| `focuses[]` | `{ id, label, description, active }` — the ids `focuses` may hold |
| `roles[]` | `{ id, label, description }` — the starting scripts the dashboard offers for this type; informational, the API takes no `role` |

`meta.category_groups` carries the type → groups map itself.

### What the gating actually gates

`allowed_capabilities` is read at call time as the tool gate, with three distinct states:

| Value | Meaning |
|---|---|
| `null` (or the field never set) | **no restriction** — the runtime offers its full tool set |
| `[]` | **nothing** — the call can only talk (and end) |
| `["send_sms", "end_call"]` | exactly what is listed |

**Outward-facing tools are never implied**: `send_sms`, `send_email`, `schedule_callback` and `transfer_call` exist on a call only when the profile lists them, because an action that reaches outside the call must not follow from the absence of a restriction (since 2026-09-05 for the last two; before that `send_email` and `schedule_callback` were on every call whatever the profile said). The end-of-call tool and the two record-only internals (`log_call_outcome`, `rate_call_performance`) are always available. The `send_*_link` tools need **both** the profile's permission and their `completions` value — granting `send_payment_link` without `payment_link_base_url` yields a tool that has nothing to send, and a `payment_link_base_url` without the grant yields no tool. Every successful `send_email` is recorded on the call (recipient, subject, time). The same gate applies on the OpenAI and the Gemini outbound paths.

The inline `profile` on `POST /calls` has no type, so its `allowed_capabilities` may name any **active** outbound capability of any group; the gate at call time is the same.

`transfer_call` (group `communication`, so every profile type may list it) hands the live call to a number given per call: `profile.completions["#transfer_number"]` (one E.164 number) or `#extension_list` (`Name - +E164` lines). It transfers only after a live human has confirmed; voicemail, a phone menu or silence never transfers. See [Calls → Handing the call to a person](calls.md#handing-the-call-to-a-person-transfer_call).

---

## Required connectors

A few tools cannot work on their own: they act through a service the customer connected to the agent in the dashboard (agent → Connectors). The catalogue says which, in `required_connector` on the entry:

```json
{
  "id": "schedule_calendar_meeting",
  "group": "sales",
  "label": "Schedule Calendar Meeting (Sales)",
  "required_field": null,
  "required_connector": {
    "types": ["google-calendar", "microsoft-365-calendar"],
    "any_of": true,
    "message": "Calendar integration required. Connect Google Calendar or Microsoft 365 Calendar"
  }
}
```

| Field | Meaning |
|---|---|
| `types` | Connector types that satisfy the requirement, as they appear in `type` on `GET /agents/{agt}/connectors` |
| `any_of` | Always `true`: **any one** connected connector of these types is enough (Google **or** Microsoft 365) |
| `message` | What to tell the user, from the catalogue; may be `null` |

Two different requirements, two different fields — a capability may have one, both or neither: **`required_field`** is a value you supply (a link, a number) in `completions`; **`required_connector`** is something the *customer* connects and you can only read. The tools that need a value are listed above with a `*`; `schedule_calendar_meeting` (sales) and `schedule_google_calendar_meeting` (agent) are the ones that need a connector.

**Nothing is validated against it.** Granting the tool to a profile or a call on an agent with no connected calendar is accepted, and answered with a non-blocking `warnings` entry (`connector_not_connected`) so the caller can fix it — see [Profiles → Warnings](profiles.md#warnings) and `POST /calls`. Check first with `GET /agents/{agt}/connectors` (`calendars_connected`).

**At runtime**, a granted `schedule_calendar_meeting` without a connected calendar is still offered to the model. The model checks the calendar as its instructions say, the tool answers `No calendar connected` (the runtime tries Google first, then Microsoft 365), nothing is booked and the call goes on. The API does not stop you from configuring that; the warning is how you find out before the call does.

---

**UiriX Public API v1** · [Overview](README.md) · [Quickstart](quickstart.md) · [Authentication](authentication.md) · [Scopes](scopes.md) · [Conventions](conventions.md) · [Accounts & Users](accounts-and-users.md) · [Agents](agents.md) · [Capabilities](capabilities.md) · [Profiles](profiles.md) · [Conversations](conversations.md) · [Chat via API](chat-via-api.md) · [Widget Events](widget-events.md) · [Webhooks](webhooks.md) · [Calls](calls.md) · [Metrics](metrics.md) · [Voices](voices.md) · [Enums](enums.md) · [Partners](partners.md) · [Admin](admin.md) · [MCP](mcp.md) · [Changelog](changelog.md) · [OpenAPI](openapi.yaml)


<!-- ===== changelog.md ===== -->

# Changelog

## 2026-09-30

- **`public_chat_greeting` and `public_voice_greeting` are writable.** `PATCH /agents/{agt}` now accepts 45 fields (was 43): the Website Chat opener (max 1000 characters) and the Voice Chat opener (max 500, the column width). `null` or `""` clears (chat falls back to `greeting`, voice to the chat greeting, then `greeting`). The write is mirrored into `public_chat_build_output.responseTemplates.greeting_initial` / `public_voice_build_output.responseTemplates.voice_greeting_initial`, because the widgets read the compiled copy first. The MCP `update_agent` and the `uirix` menu (Train the agent, item 5) cover all four greetings.
- **Outbound `transfer_call`: hand the live call to a person.** A new outbound capability (group `communication`, listed in `GET /outbound-capabilities`) for calls like "get Meir on the phone and transfer the call to me". Grant it on the call: `POST /calls` `profile: { "allowed_capabilities": ["transfer_call"], "completions": { "#transfer_number": "+972501234567" } }` (or `#extension_list` with `Name - +E164` lines) plus a `context.briefing` saying whom to ask for. The agent transfers only after a live human confirmed they are the person asked for; voicemail, a phone menu, a recording or silence never transfers. A number that is not international format, or `transfer_call` without a target, is `400 validation_error` (`field: "profile.completions"`). The call's outcome is recorded as `transferred` (or `transfer_failed` when the target was busy or did not answer; the caller then hears one apology and the call ends), and the conversation transcript shows the `transfer_call` tool turn. Works on OpenAI, Gemini and GPT-Live agents; see [calls.md](calls.md#handing-the-call-to-a-person-transfer_call).
- **MCP connector — manage your agents from Claude or ChatGPT.** One MCP server, `POST /mcp` (Streamable HTTP, JSON responses, stateless — `GET` is `405`, no SSE), whose tools are loopback calls to this API with the user's own token: the tool catalogue of [MCP](mcp.md) (account, agents and builds, prompts and versions, knowledge and summary, widget, voice, demo page, conversations, metrics, a trial chat, outbound profiles, calls, DNC, webhooks, capabilities), the documentation as `uirix://docs/{page}` resources, and four prompts (`review_agent`, `daily_report`, `add_knowledge_from_url`, `improve_from_conversations`). Sign-in is a new **OAuth 2.1 authorization server**: discovery at `/.well-known/oauth-protected-resource/api/ext/v1/mcp` and `/.well-known/oauth-authorization-server` on the dashboard host, `POST /oauth/register` (dynamic registration, public clients), `GET /oauth/authorize` (PKCE `S256` required) → the dashboard's consent page (`/connect/authorize`; groups read / changes to agents / outbound calls / webhooks), `POST /oauth/token` (access `uirix_at_…` 1 h, refresh `uirix_rt_…` 90 d, rotating), `POST /oauth/revoke`. A connection creates a personal key `<client> connector` with the approved scopes — Profile → API keys; disconnect on Profile → Connected apps. An access token is a third credential kind, accepted on every endpoint and resolving to that key ([Authentication](authentication.md#oauth-access-tokens--the-mcp-connector)). User-level scopes only — never `/admin/*`, `users:manage`, `audit:read`, `credentials:manage`, `audio:read`, `conversations:backfill`; nothing is deleted; the server's instructions make the assistant ask before `create_agent_from_website`, `rebuild_agent` and `create_call`. New rate class `mcp` (120/min). Database: ext-api migration `013_oauth_connectors`. The OAuth2 *client-credentials* grant for integrations stays deferred (planned items below). Connect from claude.ai, Claude Desktop, Claude Code or ChatGPT developer mode: [MCP](mcp.md).
- **The `uirix` menu — MCP connector, round 2.** A server-rendered menu tool, `uirix_menu` (read-only; behind `GET /accounts/{acc}/agents` and `GET /agents/{agt}`), with three levels: no arguments → the agents list (name · type / channels · status · conversations this week, plus "Create a new agent from a website"); `agent_id` → that agent's main menu of eight sections (1 Train the agent · 2 Enrich knowledge · 3 Channels & settings · 4 Conversations & insights · 5 Test the agent · 6 Outbound calls · 7 Webhooks & integrations · 8 Rebuild from knowledge, costs money · 0 Choose another agent); `agent_id` + `section` → the section's numbered sub-menu (+ 0 Back). `content.text` is display text in English without tool names; `structuredContent` maps every item to the tools to run next, whether it needs confirmation or costs money, and what to ask — as returned by the server. New prompt `uirix` ("Show me the UiriX menu"). The server's instructions gain a **menu mode**: `uirix` in any case or language, a greeting or "what can you do?" opens the menu; a number or free text picks; the current agent is kept until another is chosen; the assistant answers in the user's language (numbers kept, agent names as they are); the money confirmations are unchanged. Dashboard: a card **Connect UiriX to Claude or ChatGPT** at the top of Profile → API keys and the page `/profile/connect` — the server URL with a copy button, instructions per client (claude.ai, Claude Desktop, Claude Code, ChatGPT), what the consent asks for, "after connecting, type `uirix` in the chat", your connections with disconnect, troubleshooting. [MCP → The `uirix` menu](mcp.md#the-uirix-menu).
- **`business_name` on the agent list.** `GET /accounts/{acc}/agents` (and every response that uses the summary object — agent create, the admin agent view) now carries `business_name` next to `name` and `display_name`; before, only `GET /agents/{agt}` had it, so two agents with the same persona name ("Aria") could not be told apart from a list. Additive; `null` when not set. The MCP connector uses it: `list_agents`, `get_prompts` and the `uirix` menu now show `Aria — Acme Dental (internal: Client X Agent)` (name, the business it represents, the private *Display Name (Internal Use)*), and the assistant is told to match what the user says against all three.
- **The MCP menu's "Train the agent" section is now a training flow.** `uirix_menu` section `train` has seven items: 1 show what the agent is (instructions, capabilities including custom ones, knowledge summary, agent age and whether it has conversations), 2 train with test questions (the user's own list or ~10 questions written from the agent's knowledge, one `test_chat` each, a question / answer / verdict table, fixes routed by type — a fact goes to the knowledge summary read-modify-write plus a verbatim text document, a behaviour becomes a refinement rule — and a re-run of the misses; it is the way to train a new agent that has no conversations), 3 improve from recent conversations (now checks first that there are any), 4 fix one thing (a rule or a fact), 5 tone / greeting, 6 rewrite one channel's instructions, 7 restore a version. The server instructions gained a "Training an agent" section. No API change.
- **MCP menu: "Reach a person for me" (Outbound calls, item 5).** The user says "get me Meir" in the chat; the assistant confirms person / number / goal, places `create_call` with a briefing script (a live person, a receptionist, a phone menu, a voicemail — the message goes on the first line of an inline `profile.instructions` as `VOICEMAIL_MESSAGE:`), polls `get_call`, reads the transcript with `get_conversation` and reports what was heard. Optional hand-over to the user's own phone with the new outbound `transfer_call` (`profile.allowed_capabilities: ["transfer_call"]`, `profile.completions: {"#transfer_number": "+…"}`) — see calls.md. Server instructions gained "Reaching a person by phone". No API change beyond `transfer_call`.
- **The MCP server identifies itself properly.** `initialize` now returns a `description` and three `icons` (512 / 128 PNG and SVG, same origin as the endpoint) next to the name, and the first 512 characters of `instructions` carry the "uirix → `uirix_menu`" rule. The dashboard serves `/favicon.svg`, `/favicon.ico`, `/apple-touch-icon.png` and the two icon PNGs (its favicon was the Vite logo). Branding table and the values to paste into the vendors' forms: [mcp.md](mcp.md#branding-and-listing-fields-claude--chatgpt).
- **`GET /accounts/{acc}/usage`: combined and finer breakdowns, and an honest currency (Nir, 30 Sept: "the connector shows cost by type since January but cannot split website text chat from website voice chat, nor month × agent × channel, and the currency reads GBP while the field is cost_usd").** `group_by` now takes one to three dimensions, comma-separated (`month,agent,channel`): `agent`, `agent_type`, **`channel`** (new: `phone_inbound`, `phone_outbound`, `widget_chat`, `widget_voice`, `widget_mixed`, `api_chat`, `dashboard_chat`, `dashboard_voice`, `ai_operations` — the last is platform AI work such as builds, knowledge and summaries that used to be counted as "dashboard"), `date` and **`month`** (new, `YYYY-MM`). A single value answers exactly as before. `meta.currency_code` is now always `USD` (the amounts are `cost_usd`) and the account's payment currency moved to `meta.account_currency_code` — before, `currency_code` carried the account currency beside USD amounts. `400 validation_error` on an unknown / repeated / more-than-three `group_by`. MCP: `get_usage` takes `group_by` as an array, and the `uirix` menu gained Conversations & insights → 5 "What it costs". **Outbound call ending rule** added to the MCP skill (never hang up without a polite goodbye, after every outcome incl. voicemail and a declined transfer).
- **Batch outbound in the Public API: lists and campaigns (new page [campaigns.md](campaigns.md)).** `POST /agents/{agt}/lists`, `GET /agents/{agt}/lists`, `GET /lists/{lst}`, `POST /lists/{lst}/import` (≤ 1,000 rows per request, ≤ 10,000 per list, appends and de-duplicates by E.164, `default_country` for numbers without a prefix, an IANA time zone per row), `GET /lists/{lst}/items`; `POST /campaigns` (a DRAFT — dials nothing; `days`, a `window` in the recipient's LOCAL time, `max_attempts`…), `POST /campaigns/{cmp}/start` (`confirmed: true`, real calls), `pause`, `stop`, `GET /campaigns`, `GET /campaigns/{cmp}` (progress + report), `GET /campaigns/{cmp}/calls`. Scopes are the existing `calls:read` / `calls:write`. **Time zones:** every contact gets an IANA zone (row → phone number → `default_country` → account → UTC; guessed ones are reported under `unknown`) and the dialer now reads IANA names with DST (legacy `UTC±N` strings keep working; `UTC+05:30` used to be read as 5 h). **Do-not-call:** numbers on the account's list are skipped at import and filtered when a campaign is created (the dashboard import ignored the list before). Migration 014 widens `outbound_queued_calls.contact_timezone` to `VARCHAR(50)` — production must run it before the first API campaign. `max_calls_per_hour` / `max_concurrent_calls` are stored but the dialer applies its global caps.
- **`GET /agents/{agt}/connectors`** (which calendars / connectors are connected — never tokens), **`required_connector`** on the capability catalogues (`schedule_calendar_meeting` needs Google Calendar or Microsoft 365), a non-blocking **`warnings: [connector_not_connected]`** on profile create / update / clone and on `POST /calls`, and richer **`/limits`** (`calls_remaining_today`, `outbound_plan`, `counts_campaign_calls: false`) and **`/balance`** (`remaining`, `can_place_calls`, `calls_blocked_reason`).
- **`PATCH /agents/{agt}` accepts a build output sent as a JSON object** (was a 500).
- **Management surface for the operator — `/admin/*`, master key only.** The platform can now be run through the API by a person with curl or by an AI operator holding the master key. Reads: `GET /admin/overview` (customers, agents by provider / language, conversations 24h / 7d / 30d by channel, cost and billed 7d / 30d, revenue 30d, wallet total, top accounts, open items, the four services' health), `GET /admin/customers` (search by e-mail / name / company / `equalweb_customer_id`, filters by plan / status / partner), `GET /admin/customers/{usr}/overview` (the dossier: balance, limits and today's quota counters, agents, keys, webhooks, usage, wallet history, subscription, transactions, invoices), `GET /admin/customers/{usr}/wallet/history`, `GET /admin/agents` (every agent across accounts with `cost_30d`), `GET /admin/system/status`, `GET /admin/system/errors` (the newest log file of a service, error lines newest first, secrets scrubbed), `GET /admin/config` (flags, model per section for both tiers, pricing, allow-listed env, versions — never a secret), `GET /admin/migrations`, `GET /admin/actions`. Writes — every one needs `"confirm": true` and is journaled in `ext_admin_actions` with before / after / reason: `POST /admin/customers/{usr}/wallet/credits` (idempotent on `idempotency_key`, capped by `PUBLIC_API_ADMIN_MAX_CREDIT_USD`), `POST /admin/customers/{usr}/plan` (`409 card_required` for a paid plan without a card), `PATCH /admin/customers/{usr}/status`, `POST /admin/customers/{usr}/login-links`, `PATCH /admin/credentials/{key}`, `PATCH /admin/config/ai-models` + `POST /admin/config/reload` (timestamped backup, in-place reload on 3000, `restart_required` for the other processes), `POST /admin/agents/{agt}/actions` (`set_provider`, `set_tier`, `set_language`, `set_voice`, `widget_enable`, `widget_disable`, `reindex_kb`). Nothing is deleted. New rate class `admin` (120/min). New error codes `confirmation_required`, `plan_not_found`, `card_required`, `idempotency_key_reused`. Database: ext-api migration `012_admin_actions`. See [Admin](admin.md).
- **Docs fix:** agents.md said an agent is disabled with `PATCH /agents/{agt}` `{"enabled": false}`; the route is `PATCH /agents/{agt}/widget`.
- **Docs fix:** README.md / README.he.md still listed production as "planned, 404". `https://dashboard.uirix.com/api/ext/v1` has been live since September (verified 2026-09-30: `/health` → 200); only the dedicated host `api.uirix.com` is still not deployed. The footer navigation of every page now links Widget Events, Partners and Admin; scopes.md states that `/admin/*` is reachable by the master key only.

## 2026-09-29

- **Agents look things up on their own website during a conversation.** When a question about the business is not covered by the agent's knowledge base, the agent now searches the business's own website — its crawled pages first, then a live search restricted to the business's domains — and answers from what it finds, with a link to the page. On for every agent on chat (widget and `POST /conversations/{conv}/messages`) and on the GPT-Live-1 voice surfaces (inbound phone, voice widget); at most five searches per conversation; nothing outside the business's site is ever searched. The search appears in transcripts as `speaker: "tool"` turns named `search_website` (see [Conversations › Tool turns](conversations.md#tool-turns)).
- **The demo page shows the widget exactly as saved.** `demo_url` pages now build the widget from the agent's saved widget settings (position, AI View, launcher colours/icon/size, tooltip) instead of a fixed left-side layout — what a member changes on the Website-code page is what the demo shows.
- **`leads_created` on `GET /metrics/conversations`** — successful `create_lead` actions counted from the tool log the moment they happen, next to `leads_captured` (the summary's verdict, which needs the conversation to have ended and been summarised). Use `leads_created` for a live "Leads captured" tile. Idle chat and API conversations (quiet for 30 minutes, never closed) are now summarised as well — from 29 Sept 2026 on, not retroactively — so `leads_captured` no longer misses leads from a visitor who simply closed the tab.
- **Videos for every voice, Gemini included.** `GET /voices` now returns `video_talking_url` and `video_quiet_url` for every OpenAI and Gemini voice — the two face clips the UiriX widget plays (talking while the agent speaks, quiet while it listens) — and `video_url` is no longer `null` for Gemini voices (it falls back to the talking clip). Loop the quiet clip, switch to the talking one while audio plays, keep the URLs as returned.
- **API-built agents get a short description.** The build's greeting step now also writes `agents.description` (1–2 sentences from the knowledge summary, in the agent's language), exactly as the dashboard wizard does; it is `result.description` and `agent.description` in `GET /agents/{agt}/build`. Until now an API-built agent kept the placeholder "AI Voice Agent - <name>".
- **API-built agents: Product Descriptions in the focus, no Send-SMS task, Contact-me addressed to the account's e-mail.** `POST /partners/members` and `POST /accounts/{acc}/agents/build` create the agent with the knowledge area `products_catalog` added, `send_sms` left out (partner members have no phone number) and `completions["#lead_email"]` = the account's e-mail; the wizard in the dashboard is unchanged.
- **Voice widget: live captions.** The voice widget now shows what the agent says as it speaks, under the header, for OpenAI and Gemini agents alike, in the language's own direction (Hebrew right-to-left, English left-to-right). No change to the embed code.
- **New widgets put the launcher at the bottom-left** (`widget_settings.widget_position = 'bottom-left'`), on the same side as the AI View panel. Existing widgets nobody ever saved take the same default.
- **New widgets start with the microphone enabled and the AI View on the left** (`widget_settings.enable_microphone = 1`, `ai_view = 'left'`): the Website-code page of an agent created through `POST /partners/members` or `POST /accounts/{acc}/agents/build` no longer opens with voice input off. Existing widgets nobody ever saved take the same defaults.
- **The voice widget of an API-built agent opens in the agent's language.** The public voice session spoke `public_voice_build_output.responseTemplates.voice_greeting_initial` first and, when that key was missing, a stock English line — only the dashboard's Settings save writes the key, so every agent built through `POST /partners/members` or `POST /accounts/{acc}/agents/build` greeted in English, and a Hebrew Gemini agent then carried on in English (an EqualWeb member's demo page, 29 Sept). The session now falls back to the agent's own `public_voice_greeting` (then the chat greeting, then the phone greeting), all written in the agent's language by the build; migration 012 copies the voice greeting into the build output of existing agents.
- **Agent build: a site whose short host redirects every path to `www` no longer fails with "0 pages".** The crawler's soft-404 probe fetched `https://r2m.co.il/<random>`, was redirected to the home page and learned it as the site's error page; the start page then matched that signature and nothing was saved (an EqualWeb member's agent, 29 Sept). Three fixes in the crawler: a probe answer that is another page (a redirect to the home page) teaches nothing; the start page can never be a soft-404 — a matching signature is discarded instead; and when the start URL redirects to another origin of the same site (apex → `www`, `http` → `https`) the crawl seeds its sitemap and probes from the origin the site really lives on.
- **The crawl-step failure says why.** `GET /agents/{agt}/build` → `steps[].error` of a crawl that saved nothing now carries the crawler's own reasons for the pages it tried (`Pages tried: <url> — <reason>; …`) before the generic advice, instead of asserting bot protection.
- **The API build crawls in the agent's language**: the crawl's `Accept-Language` follows the build's `language` (a Hebrew agent of an English-speaking account crawls in Hebrew), not the account user's.
- **Docs: `language` on `POST /partners/members` lists the 17 codes.** Validation is unchanged — every one of the platform's 17 languages (`en he es fr de it pt ru ar zh ja ko hi tr nl pl sv`) was and is accepted, and a region suffix (`pt-BR`, `zh-TW`) is reduced to its language; the field's row in partners.md and its OpenAPI description now say so instead of "BCP-47, e.g. he, en".

## 2026-09-28

- **"Sign in with EqualWeb"** on the dashboard login page (NLS-3574): UiriX is an OAuth 2.0 client (authorization code + PKCE) of the EqualWeb portal — the button is a plain same-window link to `/api/auth/equalweb[?to=setup|knowledge|usage|number|code]`, the portal signs the customer in and returns to `/api/auth/equalweb/callback`, and the member (matched by `external_ids.equalweb_customer_id`) lands on the account home or the asked page. Callback to register on the portal: `https://dev-dashboard.uirix.com/api/auth/equalweb/callback` (dev). See [Partners → Sign in with EqualWeb](partners.md#sign-in-with-equalweb--oauth-sign-in-from-the-equalweb-portal-nls-3574).

## 2026-09-27

- **The agent demo page.** `GET /agents/{agt}/demo` → `demo_url`: a page on the dashboard host, no login, showing the customer's own site (a screenshot of the home page) with the agent's real widget open on it — chat and voice working. Also `demo_url` on every partner-member response. Ready when the build is done; valid until the widget token is regenerated. See [Agents](agents.md#get-agentsagtdemo--a-page-that-shows-the-agent-on-the-customers-site--scope-agentsread).
- **Hear the voices.** Every OpenAI and Gemini voice now has an English MP3 sample of the voice saying its marketing line: `sample_url` on each `GET /voices` item, and `GET /voices/{voice_id}/sample` (the CDN URL as JSON, or the MP3 bytes with `download=true`; rendered on first request). Public files, no key to play. See [Voices → Hear the voice](voices.md#hear-the-voice).
- **New widgets default the launcher icon to the voice's photo** (`widget_settings.button_icon = "voice-avatar"`) — a member's site code no longer ships a generic chat icon. `PATCH /agents/{agt}/widget` can still set any icon.
- **`POST /partners/members` defaults `provider` to `gemini`** (was `openai`), like the wizard and `POST /accounts/{acc}/agents/build`. Send `provider: "openai"` to keep OpenAI.
- A member without a card can now add one from the dashboard billing page (the Cardcom form saves it as the default card), after which `POST /phone-numbers/purchase` in the dashboard works; the API `402 payment_method_required` on numbers is unchanged.

- **Pay-as-you-go members may have up to ten agents** (was one). A partner adds agents to the member's account with `POST /accounts/{acc}/agents/build` (scope `agents:admin`); the membership, wallet and plan stay one per customer, `POST /partners/members` with the same customer id is still `409 member_exists`, and the eleventh agent is `403 plan_limit_reached`. See [Partners → More agents for a member](partners.md#more-agents-for-a-member). Database: migration `010_payg_ten_agents` (`plans.max_agents` 1 → 10 on the PAYG row).

## 2026-09-25

- **`recording_url` and `twilio_call_sid` on `POST /conversations/import`** — for calls placed straight through Twilio with the platform's account and the agent's number: send Twilio's `RecordingSid` / `RecordingUrl` and the call plays in the dashboard and through `GET /conversations/{conv}/recording` like a native call (`has_recording: true`). Recordings in another Twilio account are refused. Also on `upsert` (a recording sent earlier is kept when omitted). `channel_detail.import` gains `updated_at`, `revision`, `recording_sid`.
- **`agent_name`** on every conversation object (list and detail), next to `uirix_agent_id` — the agent's current name.
- **`POST /conversations/import` — `upsert: true`** replaces an earlier import with the same `external_session_id` in place (same `conversation_id`, `200`, `channel_detail.import.revision`); the way to correct an import, since nothing is deleted through the API. The response now carries `warnings` — `summary_truncated_for_agent` when the summary is longer than the **600 characters** the phone agent reads (one limit in both runtime blocks; the summary itself is stored and returned whole). Both blocks now tell the agent to say what the last call was about and what was agreed next when the caller returns a call, not merely to acknowledge it. The returning-call block matches the inbound agent only (no longer any agent of the account).
- **`POST /conversations/import`** (scope `conversations:write`) — write a finished phone call that happened outside UiriX (your own voice stack, a human agent) into the call log of an agent. The call then counts in the agent's **returning-call context**: when that number calls the agent, the last three calls with the number in 30 days are loaded (imported calls and the platform's own outbound calls, matched on `identity.phone_e164` for the agent or any agent of the account, newest first), an imported summary carried whole (up to 300 characters, outcome first). Idempotent on `external_session_id` per account (`409 import_exists` + `conversation_id`). Runs nothing, bills nothing, fires no webhook, counts as no usage. Answers with the conversation detail. See [Conversations](conversations.md#post-conversationsimport--scope-conversationswrite).
- **`channel_detail.origin`** gains the value `import`; new keys `channel_detail.source` (`import` | null) and `channel_detail.import` (`{ external_session_id, direction, outcome, imported_at }` | null). `GET /conversations?origin=import` lists imported calls; `origin=native` excludes them. The `origin` filter is now documented on the list.
- **Returning-call context (phone runtime)** now carries the last **three** calls with the number instead of one, and tells the agent to answer "what did we talk about?" from the record. The block grew from 400 to at most 700 characters. The `[RECENT CALLS HISTORY]` block (last 3 calls with the number for the agent, transcript preview) includes imported calls too, labelled and with their summary — see [How the agent uses an imported call](conversations.md#how-the-agent-uses-an-imported-call).

## 2026-09-13

- **Third voice provider: `gpt_live`** (GPT-Live-1, OpenAI's full-duplex voice model) — `ai_provider: "gpt_live"` on `PATCH /agents/{agt}` and `provider: "gpt_live"` on agent build / partner members; `GET /voices?provider=gpt_live` lists its 22 voices (12 new: `quartz`, `ripple`, `vesper`, `willow`, `gleam`, `bossa`, `stone`, `meridian`, `tempo`, `beacon`, `delta`, `cinder`; 10 shared with `openai`), and `GET /voices` without a provider lists every provider offered (`meta.providers`). `voice.model` reads `gpt-live-1` for such an agent. Offered only where the platform has GPT-Live switched on; elsewhere the value is a `400 validation_error` and the catalogue is not listed. Inbound phone calls run on it today; the voice widget, the dashboard voice tab and outbound calls follow.
- **Documentation download from the API** — `GET /docs/download` (zip of every markdown page + `openapi.yaml`), `GET /docs/all.md` (every page in one file), `GET /docs/pages` (index) and `GET /docs/pages/{name}.md` (one page), `GET /openapi.yaml`. Unauthenticated, like `/docs`. The Redoc page now opens with the Start-here guide and a link bar to these downloads.
- **Start here** — `DOCS/api/START_HERE.md`: the agent lifecycle in order with the endpoint of each step, what the agent knows at runtime and what needs a rebuild; also the intro of the reference page.
- **"Keep as is" documents** — `keep_verbatim` / `verbatim_in_calls` on the document object, on `POST …/kb/documents` (JSON text shape and multipart form fields) and on the new `PATCH /agents/{agt}/kb/documents/{doc}` (files and text only). A flagged document skips the summariser and its full text follows the summary in the agent's prompt on every channel; `GET …/kb/status` → `verbatim` reports what fits chat and calls. A document whose text opens with `IMPORTANT INSTRUCTION FOR AI ASSISTANT` or `do not summarize` is flagged at upload.

## 2026-09-11

### Review fix pass (afternoon)

- **`GET /agents/{agt}/kb/status` → `crawl.failed_pages`** — the pages the last crawl of this backend process could not use, each with its reason (HTTP status, bot protection, soft 404, save error; capped at 20). The dashboard's crawl result lists the same.
- **`GET /agents/{agt}/kb/status` → `summary.last_job`** — `{ status: running | done | failed, error, started_at, ended_at, progress: { batch, batches } | null }` for the last summary job the backend ran for the agent (null when none since the process started). A failed job used to leave `not_built` with every source pending and no trace. Also fixed: a knowledge base over ~850 000 characters on the large tier could not be summarised at all (the request exceeded the model API's 1 048 576-character instructions limit); it is batched now.
- **Agent build warnings** — a prompt that lands between its budget and its hard ceiling (7,000 → 7,500 phone / voice, 8,000 → 8,500 chat) is stored silently; `warnings` carries a size line only when the prompt is still over the ceiling after the compression passes. `promptSizes.compressionPasses` still says how many passes ran.
- **Docs: `display_name` on the agent summary** — the dashboard's "Display Name (Internal Use)" has been on `GET /accounts/{acc}/agents` items and on `GET /agents/{agt}` since the list was introduced; the field table now says so and explains it (your own label, never spoken or shown to a caller or visitor). The OpenAPI description on the full object said the opposite ("the name the agent gives when it introduces itself") — corrected, and the summary schema carries the description too, so the live reference (`GET /docs`) shows it.
- **Read model** — `vw_ext_conversations` now carries a chat's last-activity time (what `duration_seconds` is computed from), the outbound runtime's `answered_by` verdict and the chat `model` fallback itself (migration `011_view_activity_model`); the read service joins only the view. No field changes.
- **`answered_by` on summaries honours the runtime** — on an outbound call where the 3003 runtime detected an IVR or a voicemail live, the structured summary reports that verdict over its transcript rule.
- **`PATCH /agents/{agt}` with `*_refinement_rules` runs the dashboard's review** — the rules you send are stored verbatim, the prompt receives the NORMALISED wording (duplicates merged, imperative form), and the review — conflicts, unclear rules, what each rule became — is recorded in `ai_build_output._metadata.refinement.{phone|chat|voice}`. See [Improve agent](agents.md#improve-agent-refinement-rules).
- **Cursors on from-only walks** — page 2 of `GET /calls` and `GET /webhooks/deliveries` with `from` but no `to` (or neither) returned `400 invalid_cursor` (the resolved `to = now` was part of the fingerprint); fixed the same way `/conversations` was.
- **Chat via API transcripts carry `speaker: "tool"` turns** like the conversation reads do.
- **Tool turns from dashboard voice sessions** (the agent's own Realtime tab) are recorded too.

### Tool turns on the voice channels, summaries of short conversations, prompt budget ceiling

- **Tool turns on phone calls and the voice widget** — every tool the agent runs on an inbound phone call (OpenAI and Gemini) and on the voice widget is now recorded like the chat tools and appears on `GET /conversations/{conv}` as `speaker: "tool"` turns, in the webhook transcripts and in what the summariser reads. Tool calls before 2026-09-11 on those channels are not on record. Outbound campaign calls still record none. [Conversations › Tool turns](conversations.md#tool-turns).
- **Structured summaries of short conversations** — a conversation with one real customer request (a question or an ask of ~25 characters of real words, greetings and thanks aside) always gets a model summary with a real `intent`, an `outcome` that says what happened and `action_items` when a follow-up was asked for; `no_intent` is reserved for greeting-only / noise. Fixed underneath: the prompt used to reach the model with a literal `{{TRANSCRIPT}}` (and `{{INTENTS}}`) instead of the transcript, which is why real conversations came back as "There was no conversation to summarize" — summaries written by the model before 2026-09-11 are affected and will be re-generated on request.
- **Agent build: prompt budget** — a phone / widget prompt over its budget after the first compression pass gets a second, tighter pass; still over, it is stored whole and `build.warnings` says so (accepted up to a ceiling of 7,500 characters for phone and voice, 8,500 for chat; above it the warning names what to reduce). Nothing is ever cut. `prompt_sizes` carries `ceiling`, `overCeiling` and `compressionPasses`.

## 2026-09-10

### Summaries, tool turns, custom capabilities, three read-side fixes (evening, 10 Sept)

Follow-up to the production analysis of agents 308 / 324 / 318 (DOCS/ANALYSIS_*_2026-09-10.md §7 / §9):

- **`custom_capabilities` on `GET /agents/{agt}`** — the customer-defined tools the runtimes inject, with `function_name` (the name the model sees), `status`, `live` (active **and** connector connected — the injection rule), `connector_status`, `scopes`, `usage_count`, `last_used_at`. Ids are `ccap_…`. See [Agents › Custom capabilities](agents.md#custom-capabilities).
- **Tool turns in transcripts** — every tool a chat agent runs (widget and chat via API) is now recorded and appears on `GET /conversations/{conv}` as `speaker: "tool"` turns (`turn_type: "tool_call"` / `"tool_result"`, e.g. `get_google_indexed_page_count(website_url=mertzig.lu)` → `… → {"total_results":68100}`), by time, counted in `turn_index`, secrets masked. Also in the webhook transcripts and in what the summariser reads. Turns carry 15 keys now (`language_mismatch` was undocumented). Finding: the phone service and the voice widget keep no record of their tool calls yet, so `conv_u…` has none until they adopt the same table. [Conversations › Tool turns](conversations.md#tool-turns).
- **Structured summaries for everyone, with a backfill.** Eligibility is now every account with a conversation in the last 90 days (was: API-credential / webhook holders only, which left 213 inbound and 4 700 outbound conversations of one account `pending` forever). Fresh conversations (≤ 72 h) as before; older ones backfilled newest-first, 20 per tick and 300 model calls per account per day (`EXT_SUMMARY_*` env), logged per tick.
- **Summary language = the account's language** (`users.language`, then the agent's), never the transcript's; `language` still reports the customer's. Same rule for the per-call emoji summary written by the phone service.
- **Voicemail / IVR / silence classified before the model** — deterministic summary (`outcome: "no_intent"`, `summary_model: "uirix-summary-v1@rules"`), no tokens spent. New summary fields: `answered_by` (`human` · `voicemail` · `ivr` · `silence` · `unknown`), `human_requested` (phrase match, en / he / es), `identity_extracted` (`name`, `email`, `phone`, `company`, `website` the customer stated). [Enums](enums.md#answered_by).
- **Identity written from the conversation.** `identity_extracted` is merged into `conversation_identity` fill-only: nulls are filled (`email_source: "in_conversation"`), a value from `identify()` / `authenticated` / `crm_prefill` is never replaced, the caller id stays authoritative on phone calls. Before: 388 of 388 inbound calls of agent 308 had `identity.name` / `email` null although the summaries quoted them.
- **Fix — `GET /conversations` cursor with `from` only** answered `400 invalid_cursor` on every second page: the open upper bound was materialised as `now + 24 h` and fingerprinted, so it moved between requests. Fingerprinted from the sent bounds now; `updated_since`-only walks fixed the same way.
- **Fix — chat `duration_seconds`** is `last message − started_at` (was `ended_at − started_at`, where a chat's `ended_at` is a session close time that later bookkeeping moved by up to 99 days). `ended_at` itself is unchanged.
- **Fix — chat `model` was `null`** on 1 859 of 1 860 conversations: the turn's model is now written on the session row on every turn, and read back from the turn metadata for older conversations.

### Production test pass — six fixes (16:00–19:30, 10 Sept)

Nir pulled the release to production and asked for a thorough run of the API with the master key. Sixty-plus requests across every resource; what broke and what changed:

- **Login links pointed at dev-dashboard.** `loginLinkService` consulted only `PUBLIC_DASHBOARD_URL` / `FRONTEND_URL` / `DASHBOARD_URL`; production sets `DASHBOARD_DOMAIN`, which is now honoured (as `https://<domain>`). Affects `POST /users/{usr}/login-links`, `POST /partners/members`, `GET /partners/members/{usr}`.
- **The audit log, the per-IP limiter and `ip_allowlist` saw Cloudflare, not the caller.** Behind Cloudflare + IIS ARR, Express's `req.ip` was the edge address (172.70.x). Every IP use in the API now reads `CF-Connecting-IP` first (`clientIp()` in `ipMatch.ts`).
- **`external_session_id` never matched.** The value is stored in `identity.custom`; the `GET /conversations?external_session_id=` filter compared the platform's own `api_…` session id and the detail returned that id. Both now use the customer's value (the old match is kept for older rows).
- **Profile builds failed on gpt-5.6** (`temperature does not support 0.7`): the greeting call in `outboundBuildService` bypassed `completionLimits`. Wrapped. Same fix in the legacy V3 builder's greeting call.
- **`embed_snippet` on `GET /agents/{agt}/widget` was dead.** It emitted `<script … data-token>`, a form the loader ignores (it initialises from `window.uirix`). It is now the two-line `window.uirix` + loader form, versioned `?v=3.2.1` past the CDN cache.
- **Master key + unknown account.** `X-Uirix-Account: acc_17` (no such user) answered `200` with empty lists; it is `404 account_not_found` now.

Seen, not code: a body-less `POST` gets an HTML `411` from the production edge (documented under [Conventions → Request bodies](conventions.md#request-bodies-and-encoding)); `POST /users` with `plan: "trial"` reads back as `plan: "guest", status: "trial"`; the webhook `test` delivery row keeps `attempt 0` / `last_response_code null` although the ping was delivered; `https://www.uirix.com` is refused as a private address by the build's SSRF guard from inside production.

Verified working end to end on production: auth and scope enforcement, account isolation, key issue / rotate (grace) / revoke, pagination (40 ids, no duplicates), agent build (5 pages, 4 min), chat via API with summary, KB text + URL documents, prompt deploy, daily quotas (`429 quota_exceeded`), build concurrency (`409`), webhooks (create / patch / rotate / test → delivered), DNC, calls validation, EqualWeb member create + convert.

## 2026-09-07

### Voices carry their faces (00:45, 7 Sept)

Nir: the avatar art is already on the CDN and each file is named after the voice, so `GET /voices` now returns `image_url` (WebP), `image_jpg_url` and `video_url` for every voice. A partner wizard can render the same faces as the UiriX "create your voice agent" page straight from the response. The URLs are public and need no key; `image_url` is present for all 43 voices, the JPEG and the talking loop only where the file exists (video: the 13 OpenAI voices today). Documented in [Voices](voices.md).

### FIX: JSON mode broke every agent build (00:20, 7 Sept)

The 2026-09-06 switch to the Responses API moved the system prompt into `instructions`, which the API does not count as input, so `text.format: json_object` was refused unless the input itself contained the word "json" — and the V4 instructions step, the last step of every agent build, always failed. The request builder now adds one developer line asking for a single JSON object, only when the input does not already mention json. Found by the CMO on two partner members; both rebuilt clean from `from_step: instructions`.

## 2026-09-06

### The sandbox is not part of this documentation (21:00, 6 Sept)

Nir: withhold it until we have tested it. `GET /sandbox`, `POST /sandbox/reseed`, the `uirix_sk_test_` key environment and the "live vs sandbox" comparison are removed from these pages and from `openapi.yaml`. Nothing was removed from the code, and no sandbox account exists today (`PUBLIC_API_SANDBOX_ACCOUNT_ID` is empty, so the daily generator is off). `meta.environment` is therefore always `"production"`. The page is kept internally at `DOCS/api/_internal/sandbox.md` and will come back when it is tested.

### The member's widget is ready to paste, and the partner credit is $89 (21:30, 6 Sept)

Nir: the link alone was not enough. `POST /partners/members` now also configures the widget: `allowed_domains` is the apex host of `website_url`, the `public_token` is minted and the widget is enabled, so the login link lands on a site-code page the customer can copy from immediately. Both partner responses carry a `widget` object (`public_token`, `embed_snippet`, `enabled`, `allowed_domains`). Send `widget_enabled: false` to leave it off. The default partner wallet credit is now **$89** (was $1,000); `wallet_credit_usd` still overrides per call.

### "EqualWeb Member": partner onboarding in one call, pay-as-you-go plan, login links (20:00, 6 Sept)

Nir's brief: a partner's single screen (company, email, password, website URL, language, voice) creates the user, creates the agent with a progress indicator, and ends in a secure login into the UiriX dashboard on the site-code page. **New:** `POST /partners/members` (master key; rate class `build`) creates the user on the **pay-as-you-go plan** (`plans.is_payg`, $10 once at the start as the allowance, wallet beyond it, $5 floor, no monthly charge) with the **partner wallet credit** (default $89, `wallet_credit_usd` to override) instead of a card, starts the V4 build, and returns a **single-use login link** to `/agent/{id}/WebsiteCode`; `GET /partners/members/{usr}` reports build progress and issues a fresh link on every read; `POST /users/{usr}/login-links` issues a link on demand; `POST /users` accepts `plan: "payg"` + `wallet_credit_usd`. Links reuse the dashboard's one-time login (`refresh_tokens`, `device_info` `ext_api_login_link:…`, TTL `PUBLIC_API_LOGIN_LINK_TTL_MINUTES` = 10, exchanged for a 15-minute session, phone step skipped). **Dashboard side, same day:** the pay-as-you-go tab on the pricing page (first checkout = month + $100 deposit in one Cardcom transaction, `transactions.transaction_type = payg_first`), plans compared by id, and **no card → no phone number** (`402 payment_method_required` on purchase; `monthly_cost` from Twilio). Docs: [partners.md](partners.md). Schema: legacy migration `004_payg_plan.sql` (`plans.is_payg` + the PAYG row). Env: `PUBLIC_DASHBOARD_URL` (link origin; falls back to `FRONTEND_URL` / dev), `WALLET_MOCK_TOPUP` (the free mock top-up is off unless `on`).

### Daily quotas, build concurrency and two new rate classes (17:00, 6 Sept) — action may be required

Nir: "what stops someone sending create-agent, create-agent, create-agent?" Three layers now do. **Rate classes:** `build` (5 / min per key) for everything that starts a crawl, a model build or an outbound HTTP call — agent build and re-run, profile build and clone, KB documents and reindex, webhook test and replay; `chat` (30 / min) for `POST /conversations` and `POST /conversations/{conv}/messages`. Plain configuration writes stay in `write` (60 / min). **Daily quotas per account** (UTC day, `0` = none, master key sets them through `PATCH /users/{usr} { "limits": … }`): `max_agent_creates_per_day` 10, `max_builds_per_day` 30, `max_kb_documents_per_day` 100, `max_chat_messages_per_day` 2000, `max_webhook_tests_per_day` 100. Over one: `429 quota_exceeded` with `quota`, `limit`, `used`, `resets_at` and `Retry-After`; nothing is created. **Concurrency:** `max_concurrent_builds` (2) builds at once per account, the next is `409 build_concurrency_exceeded`. `GET /accounts/{acc}/limits` gained `quotas` and `rate_limits.build` / `rate_limits.chat`. Schema: migration `010_api_quotas` — six columns on `ext_account_settings` and the new `ext_account_usage_daily` table; the full production list is in [production-schema.md](production-schema.md).

### Nothing is deleted through the API — six `DELETE` routes removed (15:00, 6 Sept) — action may be required

Nir's ruling: "no deletions from the API." Removed from the code, the OpenAPI document and these pages: `DELETE /conversations/{conv}`, `DELETE /subjects`, `DELETE /profiles/{prf}`, `DELETE /agents/{agt}/kb/documents/{doc}`, `DELETE /dnc/{dnc}`, `DELETE /webhooks/endpoints/{whk}`. The paths answer `404 not_found` for every key, the master key included. `conversations:delete` is no longer a scope (`400 invalid_scope` at issue or edit); `409 profile_in_use` is gone with its route. What stays: `DELETE /calls/{call}` (cancel an undialled call) and `DELETE /credentials/{key}` (revoke a key) — cancellations, not data deletions; `GET /conversations/deleted` and the `conversation.deleted` webhook, fed by dashboard deletes, operations and the retention sweep. Alternatives through the API: `PATCH … is_active: false` for a profile, `PATCH … enabled: false` for a webhook endpoint; a knowledge document or a do-not-call entry is removed in the dashboard.

## 2026-09-05

Twenty changes across the night of 5–6 September (newest first within each day). Three of them make writes that used to answer `200` and change nothing actually reach a conversation; three add validation that may refuse a request that used to pass; the rest are additive — including a new way to create an agent: from a website, the dashboard wizard run server-side. The documentation was reorganised at the same time: **inbound** (the agent's own surfaces — phone, widget chat, widget voice) and **outbound** (profiles and `POST /calls`) are now separated throughout, and every page says what a field *does*, not only what type it has.

### Prompt writes now reach widget chat and widget voice — behaviour change

Widget chat, chat via this API and widget voice run a **compiled copy** of the prompt (`public_chat_build_output` / `public_voice_build_output`), not the raw `public_chat_instructions` / `public_voice_instructions` columns. A write to the raw column through `PATCH /agents/{agt}` or `POST /agents/{agt}/prompts` therefore answered `200` while the widget kept running the old text — on every agent the dashboard wizard had built. Widget voice was worse: the raw column was never read at all, and an agent without a compiled copy ran a generic assistant.

Now a prompt write (`PATCH`, `POST /prompts`, `/restore`) is **mirrored into the compiled copy's `systemPrompt`**, keeping its other keys; and the widget voice falls back to `public_voice_instructions`, then `instructions`, when no compiled copy exists. The text you write is the text that runs, on all three surfaces. Phone was already correct and is unchanged. See [Agents → Which prompt each surface runs](agents.md#which-prompt-each-surface-runs).

### Refinement rules are applied — "Improve agent" from the API

`incoming_refinement_rules`, `public_chat_refinement_rules`, `public_voice_refinement_rules` were documented as "stored but inert". They now do what the dashboard's **Improve agent** tab does: the rules are prepended to that surface's prompt and compiled copy under the `CUSTOM REFINEMENT INSTRUCTIONS (HIGHEST PRIORITY)` header, byte-for-byte the dashboard's format, and a new prompt version is minted. An empty list removes the header. See [Agents → Improve agent](agents.md#improve-agent-refinement-rules).

### `capabilities` are validated, and both catalogues are published — may refuse a request

`PATCH /agents/{agt}` now refuses an unknown id in `capabilities` with `400 validation_error` (`unknown_capability`). Every live agent already conformed. `GET /capabilities` lists the ids with what each does and the `completions` key it needs; `GET /outbound-capabilities` lists the outbound tools per profile type. See [Capabilities](capabilities.md).

### Outbound profiles: the three types, every field writable, and the builder — may refuse a request

- `category` must be a profile **type** (`collections`, `sales`, `events`; `appointments` hidden) — it was free text.
- `allowed_capabilities` and `focuses` are validated against the type's list, the same rule as the dashboard wizard; the `400` carries the allowed list. Every live profile already conformed.
- `focuses`, `tone`, `behavior`, `refinement_rules` are **writable** (they were read-only as "inert"). `override_realtime_params` stays read-only.
- **New `POST /profiles/{prf}/build`** runs the dashboard's "Rebuild with AI": the card's inputs become a new `system_instruction_template` and `greeting_message`. `202`, background, `GET /profiles/{prf}/build` for the state, `409 profile_build_in_progress` while one runs.
- **`tone` reaches the call**: on a call placed through `POST /calls` it is rendered as a prompt block with concrete behaviour per level. Campaign calls are unchanged.

See [Profiles](profiles.md).

### `POST /calls` — everything in the request (the inline `profile`)

A new `profile` object on `POST /calls` carries `instructions`, `greeting`, `voice`, `tone`, `allowed_capabilities` and `completions` on the call itself — with a stored `profile_id` (a sent field wins, an omitted one keeps the profile's) or without one. The call object echoes it as `profile` and says where the role came from in **`profile_source`** (`agent` / `profile` / `inline` / `profile+inline`). Validation: unknown capability, a voice the agent's provider lacks, a tone value outside its enum, and instructions over 20,000 characters are `400`. See [Calls → Everything in the request](calls.md#everything-in-the-request-the-inline-profile).

### Knowledge summary read and write — `GET|PATCH /agents/{agt}/kb/summary`

The summary is the only knowledge an agent carries into a conversation, and until now it could be rebuilt but not read or written. `GET` returns the text (with `text: null` when there is none); `PATCH { text }` replaces it, bumping `version` and clearing `stale` — and **creates** it (`201`) when the agent has none. These endpoints existed in code since 2026-09-04 without documentation, OpenAPI or tests; all three now exist. See [Agents → The summary itself](agents.md#the-summary-itself--read-and-write).

### Create an agent from a website: `POST /accounts/{acc}/agents/build` (02:00, 6 Sept)

The dashboard's agent-creation wizard (V4: URL and voice → create → crawl → knowledge → greeting → instructions → ready) is now an API call. Send `website_url`, `language` and `voice.gender` (`female` / `male`; optionally `voice.name`), and the API creates the agent at once — provider **Gemini by default**, main role secretary, the wizard's ready-made capability set (`end_call`, `create_lead`, `send_email`, `send_sms`, `send_website_link`, plus optional `addons`), the voice's marketing name as the agent name — and then runs the wizard's own steps in the background: crawl the site, build the knowledge summary, generate the greeting, build the three prompts. `202` with `agent_id`; `GET /agents/{agt}/build` reports every step (`pending` / `running` / `done` / `failed` / `skipped`, with what it produced or why it failed) until `done` or `error`; `POST /agents/{agt}/build` re-runs from a step. The whole chain is 4–9 minutes. A failed greeting is a warning (the stock greeting stays); any other failure leaves the agent in `draft` for a re-run or a delete. Guarded by `agents:admin` (user-level since 05:30; the blank `POST /accounts/{acc}/agents` was removed at 11:00). New error `409 agent_build_in_progress`. See [Agents → Create an agent from a website](agents.md#post-accountsaccagentsbuild--create-an-agent-from-a-website--scope-agentsadmin-master-key-only).

### Knowledge summary: pages are marked, an empty answer is never saved (10:50, 6 Sept)

Two defects in the automatic knowledge-summary path (the one the build, `POST /kb/reindex` and the dashboard wizard's build step use), found on the first agent built through the API (agt_3564): (1) the pages and files a summary covered were never marked as summarised, so every later build re-summarised all of them as "new" (total_sources doubled each time); (2) when the model returned nothing ("No response"), that text was saved as the ACTIVE summary and the good one was deactivated — `GET /agents/{agt}/kb/summary` answered "No response" while the build reported 71,437 characters. Now the sources are marked, and an empty / refused / under-100-character answer is discarded with an error while the existing summary stays.

### `channel` on `POST /conversations`: test the voice and phone prompts by text (12:30, 6 Sept)

Nir's request: "if they send a voice conversation, load the voice instructions and the knowledge." `POST /conversations` takes `channel: chat | voice | phone` (default `chat`). The turns then run under that surface's prompt — `voice`: `public_voice_build_output` → `public_voice_instructions` → `instructions`, with the voice greeting; `phone`: `instructions`, with the agent's greeting — through the chat engine, with the same knowledge block and capability gate. The surface is echoed as `channel` / `prompt_surface` on the created conversation and `prompt_surface` on every turn. A text approximation: the runtime-only parts of a live call are not reproduced. No tool calls are exposed (Nir: not needed).

### Public and API chat run on the `publicWidget` model, and are priced by it (11:40, 6 Sept)

Nir's ruling: widget chat and chat via the API are the "public chat" setting — the `publicWidget` section of the tier config — not the dashboard Chat tab's `chat` section. Until today the engine read temperature, max_tokens and the billing label from `publicWidget` but the OpenAI call itself was made with `chat.model` (gpt-4o-mini standard / gpt-4o large), and every chat call was priced at the STANDARD chat model's rate whatever ran. Now the call goes to `publicWidget.model` of the agent's tier, the cost is priced by the model actually called, the usage row records that model, and `model` on the agent objects reports it. Later the same morning (Nir): every text model is **gpt-5.6-luna** on the standard tier and **gpt-5.6-terra** on the large tier — chat, summary, build, greeting, classification, all of them. The gpt-5.6 models cannot call function tools on the old chat-completions endpoint, so the chat engine now sends every gpt-5.x call through OpenAI's Responses API, where luna and terra call create_lead, send_email and the rest natively — one model for the whole conversation. Behaviour note: gpt-5.6 tends to confirm the details ("is this the best email, shall I save it?") before calling the lead tool where gpt-4o saved at once.

### Only the V4 build creates an agent through the API (11:00, 6 Sept)

Nir's ruling, in his words: "only V4 creates an agent; an agent cannot be deleted — not from the API, not from the dashboard." The blank / from-template `POST /accounts/{acc}/agents` is removed from the code, the OpenAPI document and these pages (404 now); `POST /accounts/{acc}/agents/build` is the one creation route. The dashboard has no agent delete in its UI, and that stays so. `agents:admin` now guards the build route only.

### `context.language` is validated (09:40, 6 Sept)

`POST /calls` with a language the platform does not speak (`"xx"`) was accepted with `202` and the call ran under a strict-language block naming a language nobody speaks. Now it is `400 validation_error` on `context.language`; the message lists the 17 agent languages. `he`, `he-IL` and `Hebrew` are all accepted and mean the same thing. Found by the CMO's QA pass.

### No agent deletion through the API — `DELETE /agents/{agt}` removed (09:30, 6 Sept)

Nir's ruling: an agent is never deleted through the Public API. `DELETE /agents/{agt}` and its §11.2 alias are gone from the code, the OpenAPI document and these pages; the path answers `404 not_found` for every key, the master key included. The dashboard offers no agent delete in its UI either. Creation stayed at the time; at 11:00 the blank `POST /accounts/{acc}/agents` was removed too, leaving `POST /accounts/{acc}/agents/build` as the only creation route. `agents:admin` covers creation only.

### `agents:admin` is grantable to a personal key again; the call never ends on a one-word answer; `end_call` gets a rated close (05:30, 6 Sept)

Three changes. **Scopes:** on Nir's ruling, `agents:admin` (create and build-from-website) is a user-level permission again and may be granted to a personal key at issue or by editing one; the master key's job is creating accounts. Only `conversations:delete` stays master-only. **Ending a call:** both providers' end-of-call rules now carry a guard — never end within the first exchange; a one-word or short answer ("yes", "okay", "English", "כן") is an answer, not a farewell; end only on an explicit goodbye or after the task is done and the customer confirms nothing else (call_1212: the agent hung up two seconds after a one-word reply). **`end_call` without a rating:** when a profile grants the bare `end_call` and the model uses it, the dialer now synthesises the close from the transcript — rating 4 for a polite goodbye, 1 for a refusal, 3 otherwise, and a summary quoting the last exchange — so the call no longer ends with `rating null / summary ""` (call_1211). Nothing is asked of the model.

### Gemini outbound catches up with OpenAI; the call language wins over the template (03:30, 6 Sept)

On a call with `context.language` (or a list item's language), a Gemini agent used to run the adaptive rule ("switch if the customer speaks another language") — the very rule the CEO retired for OpenAI on 5 Sept — had no pinned opening line (the model improvised one from the template), rendered the briefing as a fact about the customer, had no voicemail block and wrote the end-of-call summary in the agent's language rather than the call's. **Now both providers build the same call**: the strict CALL LANGUAGE block, the greeting ladder (`profile.greeting` → agent `outbound_greeting` → `greeting`) with the greeting-first directive, the briefing as the PER-CALL INSTRUCTION block that outranks the stored persona and opener, the `VOICEMAIL_MESSAGE:` line and the voicemail block, the knowledge summary as the shared facts block, the summary language taken from the call. Two changes for both providers: the very first line of the prompt now names the call language and says it overrides whatever language the template or opener is written in; and a stored opener in another script (a Hebrew line on an English call, agent 308 on calls 1129/1131) is now translated into the call language instead of being said verbatim. See [Calls → How the agent decides what to say](calls.md#per-call-context-vs-the-agents-configuration).

### The briefing leaves the TwiML: no more size ceiling on the per-call context (02:40, 6 Sept)

The dialer places a call with an inline TwiML document that Twilio caps at 4,000 bytes, and until tonight the per-call data (`context.briefing`, `purpose`, the readable `context`) rode inside it base64-encoded. A 2,255-character Hebrew briefing (call_1132) was accepted with `202` and then failed at dial with Twilio `32018`; the recipient was never rung. Since 02:40 the per-call data is **read from the database when the call connects** — on the OpenAI and the Gemini path alike, for API and campaign calls — and the TwiML carries ids only. The byte-budget `400` that `POST /calls` returned for two hours (an interim check, withdrawn the same night) is gone; `context.briefing` is limited to **8,000 characters** in any script. Rows queued before the restart still dial correctly (the TwiML values remain the fallback). See [Calls → Quotas and limits](calls.md#quotas-and-limits).

### Outbound calls now carry the agent's knowledge summary; noise reduction on (22:20)

The OpenAI outbound prompt paths loaded the agent's knowledge summary and then dropped it, on the assumption that the profile template had embedded it — true only for profiles built with the knowledge base. A no-profile call, and every profile built without it, ran with no company knowledge and improvised facts. Ruling of the CEO: every call carries it. The summary is now appended on every outbound prompt path (the Gemini path already did), below the role and above the per-call sections, framed as facts and inside the hardened content wrapper; never doubled when the template already has it. Also on, per the settings ruling: `noise_reduction: far_field` in the global outbound settings of both tiers. See [Calls → What the agent knows on a call](calls.md#what-the-agent-knows-on-a-call).

### Outbound: the opener is never cut, and outward-facing tools are granted by the profile only (19:30)

Two rulings after the evening's test calls. **Barge-in holds during the greeting**: the recipient's "hello?" over the opener used to restart it (three times on one call, full greeting nine seconds late); the opening line now finishes, and from then on interruption follows the agent's `barge_in` flag, which the outbound runtime had loaded and never read. To make that hold real, OpenAI's own server-side interruption (`turn_detection.interrupt_response`) is off in the global outbound settings — it had been cutting the opener 0.45 s after the recipient's first sound, before the platform could decide anything — and the platform decides when to cut; a caller who spoke over the opener is answered as soon as it ends. **Tool gate**: `send_email` and `schedule_callback` were in an "automatic" tier that ignored the profile, so a profile allowing only `end_call` still let the model email any address and arm a callback (one was armed on a test call, for a time already past; it could not have dialled). Both are now granted only when listed, on the OpenAI and Gemini paths; link tools need the grant as well as their `completions` value; the Gemini path adopts the fail-closed gate (`[]` = nothing). Every successful `send_email` is recorded on the call. Also fixed: a callback for a time already past is refused with a message the agent can act on, and the end-of-call write no longer overwrites a callback the agent scheduled. See [Capabilities → What the gating actually gates](capabilities.md#what-the-gating-actually-gates).

### Answering-machine detection is off for outbound calls — behaviour change (18:50)

Twilio's detection classified a person as a machine on two of four test calls today, in each case within three seconds of the answer and before the agent's first word, and the runtime then muted the agent to wait for a beep that never came. Ruling of the CEO: no hang-up and no muting on a verdict. Detection is now off for every outbound call (campaigns and API alike, `outbound-config.json → callSettings.detectAnsweringMachine: false`), so `answered_by` is `null` from now on. The agent's prompt frame gained a voicemail block: it recognises a recorded greeting or a beep itself, waits for the beep, says the `VOICEMAIL_MESSAGE:` line from the first line of its prompt (or a short identification if there is none), and ends the call. Also removed: a fixed half-second wait between the session opening and the agent's first word, measured on today's calls. See [Calls → Voicemail](calls.md#voicemail-what-the-agent-says-to-a-machine).

### `context.language` is now the language of the whole call — behaviour change (18:15)

Until today a language sent on `POST /calls` was a *start* language: the agent greeted in it and then adapted to whatever it believed the customer spoke, with a scripted acknowledgement for the switch. On a test call the agent heard one unclear syllable, decided the customer had switched to English, and read the script out. Now a sent language locks the call: first word to last, including any voicemail message, no switching and no acknowledgement. A call without `context.language` is unchanged (agent default, adaptive). Ruling of the CEO, 2026-09-05. Also on 2026-09-05: outbound turn detection is taken from the platform's global outbound settings per model tier — the per-agent voice row no longer overrides it on calls (it still applies to the widget). The voicemail message syntax (`VOICEMAIL_MESSAGE:` first line) is documented for the first time under [Calls → Voicemail](calls.md#voicemail-what-the-agent-says-to-a-machine).

### OpenAPI: two live operations were missing from the published document — fixed

`GET /conversations/{conv}` (the single-conversation read with turns) and `POST /conversations` (create a chat conversation) were **absent from `openapi.yaml` and `GET /openapi.json`** although both routes have been live since Phase 1. Cause: the document is merged from per-track fragments, and a path defined by two fragments (`/conversations/{conv}` in conversations.yaml and privacy.yaml; `/conversations` in conversations.yaml and chat.yaml) was *replaced* by the last fragment instead of merged, so the earlier fragment's operations vanished. The merger now merges operations per path; both operations are back, and the document again describes every live operation and nothing else. Found by the CMO's spec read of 2026-09-05.

### Errors

New: `409 profile_build_in_progress`. `400 validation_error` may now carry `unknown_capability`, `unknown_focus`, `allowed_capabilities`, `allowed_focuses`.

## 2026-09-04

Seven changes to the v1 contract. One removes something you may already have been granted;
one changes when `POST /calls` dials; the rest are additive.

### `POST /calls` no longer inherits a calling window — behaviour change

A one-off call placed through the API is now **dialled when you ask for it**, whatever the
local hour at the other end.

Until today `POST /calls` defaulted to `09:00`–`20:00` in the recipient's time zone — the
window a *campaign* needs, because a campaign works down a list of people who never asked to
be rung. A call placed at 20:25 by an operator who meant to place it came back
`202 { "status": "scheduled" }` for 09:00 the next morning: not dialled, not refused, and it
read as success. That default is gone.

- Send no `policy.earliest_local_time` / `policy.latest_local_time` and the call is placed now.
  It reads back as `earliest_local_time: "00:00"`, `latest_local_time: "23:59"` — the whole
  local day, i.e. no window.
- Send either edge and you are asking for a window: it is enforced in the recipient's zone
  exactly as before, and a call outside it is `scheduled` for the next window start.
- `policy.scheduled_at` is unchanged, and now lands on **exactly** the instant you named —
  there is no default window left to round it into.
- The `202` gained **`scheduled_reason`**: `requested_time`, `outside_requested_window`, or
  `null` when the call goes now. A `scheduled` call always names why.
- New error `409 call_deferral_refused`: if anything would postpone a call you did not ask to
  postpone, the request is refused with the instant it declined to defer to, instead of a
  `202` carrying a time you did not choose.

**If you were relying on the old default**, add `"earliest_local_time": "09:00",
"latest_local_time": "20:00"` to `policy` and you have exactly the previous behaviour.
Campaigns are unaffected — their windows live on the campaign, not here. Calling-hour law,
consent and do-not-call obligations are yours either way; the DNC list is still enforced on
every request.

### Outbound call profiles — new

`GET|POST /agents/{agt}/profiles` and `GET|PATCH|DELETE /profiles/{prf}`, plus `POST /profiles/{prf}/clone`. Ids are `prf_{n}`. Reads need `agents:read`, writes `agents:write`.

A profile is the briefing an outbound call runs under — the role, the task and the capability gating. The product has had them since January; they were simply invisible through the API, so the only way to brief a campaign was to rewrite the agent's own prompt with `PATCH /agents/{agt}`, which changes that agent on every channel for every caller. Name a `profile_id` on `POST /calls` instead.

Three things worth reading before you build on it:

- **A profile replaces the agent's stored persona; it does not layer over it.** The runtime uses the profile's `system_instruction_template` **or** the agent's prompt, never both. The agent still supplies voice, language and knowledge base. Write the whole persona into the profile.
- **`context.briefing` on `POST /calls` does layer**, above the profile. The profile is the job; the briefing is the facts for that one call. Anything that varies per recipient belongs in the briefing, or you will be creating a profile per customer.
- **A profile is pinned to one agent** and `POST /calls` refuses one written against a different agent. This will not be relaxed: a profile grants a role against one agent's capabilities, and pointing it elsewhere is how a model ends up confidently promising something the system cannot deliver.

`profile_code` is generated server-side and is not accepted in a request body — codes are not unique in the database, so two accounts could otherwise collide on a name like `pci_euro`. `label` is your own text, is searchable with `?q=`, and has no uniqueness requirement. `category` is free text and is **not** validated against any list.

Several stored fields — `focuses`, `tone`, `behavior`, `refinement_rules`, `override_realtime_params` — round-trip faithfully but are read by nothing on the call path; they are input to the dashboard's profile builder, which v1 does not expose. They are documented as inert rather than omitted, so nobody spends a day wondering why setting `tone` changed nothing. See [Profiles](profiles.md).

New error codes: `404 profile_not_found`, `409 profile_in_use`.

### `agents:admin` and `conversations:delete` can no longer be granted — action may be required

Creating or deleting an agent, and deleting a conversation, are now things only the account
owner does in the dashboard, or UiriX operations does with the master key. The two scopes
still exist and the routes still enforce them, but **no personal key can hold them any more**:

- issuing or editing a key with either name in `scopes` is `400 invalid_scope` (`field: "scopes"`);
- **keys that already held them were stripped** when the ruling landed.

If your integration called `POST /accounts/{acc}/agents`, `DELETE /agents/{agt}`,
`DELETE /conversations/{conv}` or `DELETE /subjects` with a personal key, it now gets
`403 insufficient_scope`. There is no replacement scope. Move that step to the dashboard, or
have UiriX run it. Everything else is untouched — `agents:write`, `kb:write`,
`conversations:write` and `calls:write` still do what they did, and updating an agent is
unaffected. See [Scopes](scopes.md#write-scopes-that-cannot-be-granted).

### `GET /voices` — new

The catalogue of voice ids that are valid for each provider, with the marketing names your
customer sees. Scope `agents:read`, no account parameter — it is reference data.

You need it because OpenAI and Gemini voice ids **do not overlap**, so the valid set changes
with the agent's `ai_provider`. It also answers a question that previously had no answer at
all: the dashboard shows a customer "Chloe" and the API wants `shimmer`, and the mapping
between the two lives in `localized_names`, per language. See [Voices](voices.md).

### `PATCH /agents/{agt}` now rejects a voice the provider does not have

Previously, setting `ai_provider` without also setting `voice` left the agent holding a voice
the new provider had never heard of. Nothing complained; the agent simply stopped being able
to start a session.

That write is now `400 validation_error` with `field: "voice"`, and the message names both
the offending voice and some valid choices. **Change provider and voice in the same request.**
The check also rejects a `voice` in neither catalogue and an `ai_provider` that is not
`openai` or `gemini`. It runs before anything is written, so a rejected `PATCH` changes
nothing — including the other fields in the same body.

If you switch providers programmatically, this may turn a call that used to return `200` into
a `400`. That is the point: the `200` was producing broken agents.

### `outbound_greeting` — new field on the agent

An agent had one `greeting`, and the onboarding wizard writes an *inbound* sentence into it
("thank you for calling…"), which is wrong the moment the platform places the call.
`outbound_greeting` is the outbound opener. It is writable through `PATCH /agents/{agt}` and
returned on `GET /agents/{agt}`.

It is `null` on every agent that has not set one, and `null` means "fall back to `greeting`",
so **no existing agent changed behaviour**. For an outbound call the line spoken is the first
of these that is set:

1. the campaign profile's greeting (per campaign, set in the dashboard)
2. `outbound_greeting`
3. `greeting` (the inbound line, borrowed)
4. a generic opener derived from the campaign category

**Language behaviour on outbound also changed.** Setting `language` on the agent previously
did not change the opening line — the prompt pinned the stored greeting verbatim, so a Hebrew
agent still opened in English. Now a greeting already written in the agent's language is
spoken word for word, and one written in another language is translated on the fly. So
`PATCH {"language":"he"}` makes the agent open in Hebrew even while the stored line is
English. If the exact wording matters, write the greeting in the agent's language.

### `PATCH /agents/{agt}` field descriptions in the OpenAPI document

Every one of the 43 updatable fields now carries a description in `openapi.yaml`, so a
generated client shows what a field does instead of just its type. Two of those descriptions
record behaviour that was not written down anywhere before:

- **`incoming_refinement_rules`, `public_chat_refinement_rules`, `public_voice_refinement_rules`
  are stored but inert over the API.** Writing one returns `200` and changes nothing about how
  the agent behaves, because refinement rules only reach the agent when the builder recompiles
  them into the `*_build_output` fields, and this API has no rebuild route. A rebuild in the
  dashboard is required. This is not new behaviour — it is newly documented.
- **`ai_build_output`, `public_chat_build_output` and `public_voice_build_output` are live on
  write**, because they are what the agent actually runs on. They are also overwritten by the
  next dashboard rebuild.

See [Agents](agents.md#refinement-rules-are-stored-but-do-not-change-the-agent).

### Documentation

Prompt versioning is now written down: what mints a version, why the `v1` rows dated to the
agent's creation are a backfill artifact rather than a deploy, and how to use the version
history for audit and rollback. See [Agents](agents.md#prompts-and-versions).

## v1.0 (draft, 2026-09-03) — initial contract

First published contract for the UiriX Public API v1, derived from the EqualWeb requirement spec v1.1 (2026-08-21 / §11 added 2026-09-01) and the approved implementation plan (2026-09-03).

- Base URLs: `https://api.uirix.com/v1` (production), `https://dev-dashboard.uirix.com/api/ext/v1` (development).
- Static bearer keys: master (`uirix_mk_live_`), personal live (`uirix_sk_live_`); two-live-key rotation; `X-Uirix-Account`; `X-Uirix-Key-Expires-In`; IP allow-list.
- 24 scopes (11 read, 10 write, 3 master-only).
- Uniform error envelope + `X-Request-Id`; 50-code error catalogue; per-class rate limits with non-consuming `429`.
- Keyset cursor pagination bound to the filter set; default 30-day window with `meta.applied_default_range`.
- Unified conversation object over three stores (`conv_p` / `conv_u` / `conv_d`), 14-key turn schema, summary object with 8 outcomes, 23-key identity block, metrics block, `retention_expires_at`, `links`.
- Chat via API: create / messages (idempotent, non-streaming) / identify / end.
- Agents: list, create (blank / template), get, PATCH with 43 whitelisted fields and `agent_version` bumps, delete; prompt versions (deploy / list / get / restore); `/agent-versions`; KB documents (file / text / URL), status, reindex; widget; voice. §11.2 nested paths as aliases.
- Webhooks: endpoints CRUD, secret rotation with `X-Uirix-Signature-Previous`, `ping` test, 9 events, HMAC-SHA256 over `"{timestamp}.{raw_body}"` with a 300 s replay window, retry ladder 1m/5m/15m/1h/6h/24h (7 attempts), dead letter + replay with stable delivery id, 500-turn transcript cap.
- Outbound calls: `POST /calls` with context / policy, 10 statuses, quiet hours in the recipient's time zone, DNC list with auto-append, quotas via `/accounts/{acc}/limits`, cancel, `matter_key`, `answered_by`, `disclosure_played`, `callback_url`.
- Metrics: `/metrics/conversations` with 14 fixed-definition metrics, up to 3 `group_by` dimensions, IANA time zone bucketing.
- Recordings via signed, expiring URLs; redaction (`?redact`, per-account default, Luhn at ingestion); soft delete with tombstones and `410`; `DELETE /subjects`; retention 730 / 1095 / 90 days.
- OpenAPI 3.1 document (`openapi.yaml`, served at `GET /openapi.json`).

## Planned

Not part of the v1.0 contract; listed so integrations can anticipate them. None of these will change existing v1 responses.

| Item | Notes |
|---|---|
| OAuth2 client-credentials (`POST /oauth/token`) | Short-lived bearer tokens as an alternative to static keys. Deferred — static keys with rotation cover Phases 1–3. |
| Streaming replies for chat via API | Server-sent / chunked agent turns. Blocked by the current reverse-proxy layer; will ship as a separate endpoint when the transport allows. |
| Voicemail scripts (`policy.voicemail: "leave_message"`, `voicemail_script`) | Requires end-of-greeting detection plus scripted playback. Until then the value returns `400 unsupported_policy`. |
| Dashboard delivery log | Recent webhook deliveries (status, response code, attempts) visible in `dashboard.uirix.com`, mirroring `GET /webhooks/deliveries`. |
| Per-turn `confidence` | ASR / answer confidence once a reliable signal exists; stays `null` in v1. |
| Per-field encryption of identity columns at rest | Not implemented; transport encryption to the database is in place. |
| Insights endpoints (`/insights/unanswered`, `/insights/kb_gaps`, `/insights/intents`) | Conveniences over the per-turn fields already exposed. |
| Batch / campaign calling | Not planned for v1; single-call primitive only. |
| Widget browser events (`window.uirix.on()`, `identify()`, `postMessage`) | Separate widget project — not an API change, but referenced here because it completes the `identity.equalweb_agent_id` round trip for widget conversations. |

---

**UiriX Public API v1** · [Overview](README.md) · [Quickstart](quickstart.md) · [Authentication](authentication.md) · [Scopes](scopes.md) · [Conventions](conventions.md) · [Accounts & Users](accounts-and-users.md) · [Agents](agents.md) · [Capabilities](capabilities.md) · [Profiles](profiles.md) · [Conversations](conversations.md) · [Chat via API](chat-via-api.md) · [Widget Events](widget-events.md) · [Webhooks](webhooks.md) · [Calls](calls.md) · [Metrics](metrics.md) · [Voices](voices.md) · [Enums](enums.md) · [Partners](partners.md) · [Admin](admin.md) · [MCP](mcp.md) · [Changelog](changelog.md) · [OpenAPI](openapi.yaml)


<!-- ===== chat-via-api.md ===== -->

# Chat via API

Run a text conversation with an agent over plain HTTPS — no widget, no browser. Conversations created here are ordinary `conv_p…` conversations with `channel_detail.channel = "api"`: they appear in `GET /conversations`, in the dashboard's Chat tab, get summaries, fire webhooks and are billed like widget chat.

Four calls, all scope `conversations:write`:

```
POST /conversations                      create (optionally with the agent's greeting)
POST /conversations/{conv}/messages      send a user message, receive the agent's turn
POST /conversations/{conv}/identify      attach CRM identity (idempotent merge)
POST /conversations/{conv}/end           close → conversation.ended + summary
```

Variables: `$B` base URL, `$SK` personal key, `$J` = `Content-Type: application/json`.

## `POST /conversations` — create

> **Testing the voice or phone prompt by text (`channel`, since 2026-09-06).** A conversation created with `"channel": "voice"` or `"phone"` answers every turn under that surface's instructions, greeting and the same knowledge block, through the chat engine. It is a text approximation of that surface: the runtime-only additions of a live call (speech rules, time-of-day context, the opening-line directive, the realtime model's own manner) are not reproduced, and the model is the text model of the agent's tier, not the realtime one. Billing, transcript and `channel_detail.channel: "api"` are unchanged; the surface is kept on `identity.custom.prompt_surface`.

| Body field | Type | Required | Notes |
|---|---|---|---|
| `agent_id` | string | yes | `agt_245`; must belong to an account on the credential |
| `send_greeting` | boolean | no | Default `false`. `true` → the agent's greeting becomes turn 0 |
| `identity` | object | no | Same keys as [`/identify`](#post-conversationsconvidentify--attach-identity); applied before the first turn |
| `custom` | object | no | Free-form key/value → `identity.custom` |
| `external_session_id` | string ≤ 128 | no | Your own correlation id; filterable on `GET /conversations` |
| `language` | BCP-47 | no | Hint for the agent; default = agent language |
| `channel` | `chat` \| `voice` \| `phone` | no | **Which surface's prompt the turns run on** (default `chat`). `voice` runs the widget-voice prompt (`public_voice_build_output` → `public_voice_instructions` → `instructions`) and greeting; `phone` runs the phone prompt (`instructions`) and the agent's `greeting`. Same knowledge block, same capability gate. Lets you test the voice and phone instructions by text, without a call. Echoed as `channel` and `prompt_surface` on the created conversation and as `prompt_surface` on every turn response |
| `account_id` | string | no | Which account to create under. Required for a key covering more than one account, and for the master key; a single-account key may omit it |

```bash
curl -s -H "Authorization: Bearer $SK" -H "$J" -X POST "$B/conversations" -d '{
  "agent_id": "agt_245",
  "send_greeting": true,
  "external_session_id": "crm-session-42",
  "identity": { "equalweb_agent_id": "1619", "email": "procurement@example-shop.de", "email_source": "crm_prefill" },
  "custom": { "plan": "free", "site_id": "55231" }
}' | jq .
```

```json
{
  "conversation_id": "conv_p9901",
  "account_id": "acc_17",
  "agent_type": "chat",
  "status": "in_progress",
  "session_id": "api_01J8ZA6R2KQ4M7V9X0B3N5T8YC",
  "external_session_id": "crm-session-42",
  "agent_version": "agt245-v3",
  "prompt_version": "chat-v3",
  "model": "gpt-5-nano",
  "started_at": "2026-09-03T14:35:10Z",
  "transcript": [
    { "turn_index": 0, "speaker": "agent", "text": "Hi! I'm the EqualWeb assistant. How can I help with accessibility today?", "timestamp": "2026-09-03T14:35:11Z", "confidence": null, "audio_offset_ms": null, "audio_duration_ms": null, "latency_ms": null, "turn_type": "greeting", "detected_intent": null, "detected_language": null, "sources": null, "unanswered": false, "human_agent_id": null }
  ],
  "links": { "self": "https://api.uirix.com/v1/conversations/conv_p9901", "dashboard": "https://dashboard.uirix.com/conversations/conv_p9901" },
  "meta": { "request_id": "req_01J8ZA6R3M", "environment": "production" }
}
```

`201`. `session_id` is minted server-side (`api_` + ULID). `agent_version`, `prompt_version` and `model` are stamped now and never change for this conversation, even if the agent is redeployed mid-conversation.

**Errors:** `404 agent_not_found` (`field: "agent_id"`; also for another account's agent) · `402 payment_required` (subscription not active / balance exhausted) · `403 insufficient_scope` · `400 unsupported_field` / `validation_error` · `400 invalid_phone` / `invalid_email` / `invalid_type` (identity).

## `POST /conversations/{conv}/messages` — send a message

**Non-streaming**: the request returns when the agent's full reply is ready (typically 2–8 s; up to 15 s with tool calls). Tools configured on the agent (lead capture, handoff, connectors) run inside this call.

| Body field | Type | Required | Notes |
|---|---|---|---|
| `text` | string 1–10,000 chars | yes | The user's message |
| `idempotency_key` | string ≤ 128 | no | Same key within **24 h** → the original reply is returned, nothing is re-run or re-billed |

```bash
curl -s -H "Authorization: Bearer $SK" -H "$J" -X POST "$B/conversations/conv_p9901/messages" \
  -d '{"text":"Does your widget alone make us compliant with the EAA?","idempotency_key":"msg-0001"}' | jq .
```

```json
{
  "turn": {
    "turn_index": 2,
    "speaker": "agent",
    "text": "The widget covers a large share of WCAG 2.1 AA issues automatically, but full EAA conformance also requires an audit and source-level remediation for structural issues. Would you like a demo?",
    "timestamp": "2026-09-03T14:35:44Z",
    "confidence": null,
    "audio_offset_ms": null,
    "audio_duration_ms": null,
    "latency_ms": 2100,
    "turn_type": "answer",
    "detected_intent": null,
    "detected_language": null,
    "sources": null,
    "unanswered": false,
    "human_agent_id": null
  },
  "usage": { "tokens": { "input": 812, "output": 64, "total": 876 }, "model": "gpt-5-nano" },
  "meta": { "request_id": "req_01J8ZA7K9P", "environment": "production" }
}
```

The user's own message is the `speaker: "user"` turn immediately before this one in the stored transcript; read the full transcript with `GET /conversations/{conv}`, where any tool the agent ran during the turn (`create_lead`, a custom capability…) appears between the two as `speaker: "tool"` turns — see [Conversations › Tool turns](conversations.md#tool-turns) — so `turn_index` on the detail can be higher than here. The first user message of a conversation emits `conversation.started`.

**Concurrency.** One turn at a time per conversation. A second message while one is being processed is `409 turn_in_progress` — wait for the first response and retry (the same `idempotency_key` will not help; it is a different message).

**Errors:** `400 invalid_text` (empty) · `400 text_too_long` · `400 unsupported_field` · `400 not_api_conversation` (a widget/phone/dashboard conversation id) · `404 conversation_not_found` · `409 turn_in_progress` · `409 conversation_already_ended` · `402 payment_required` · `410 conversation_deleted`.

## `POST /conversations/{conv}/identify` — attach identity

Idempotent **merge** into the identity block: keys you send overwrite, keys you omit are untouched, `null` clears. Works at any point during or after the conversation (before deletion).

| Key | Type | Validation |
|---|---|---|
| `equalweb_agent_id` | string | **Must be a JSON string.** Round-trips verbatim: `"1619"` → `"1619"`, `"-1"` → `"-1"`, `"007"` → `"007"`. A number is `400 invalid_type` |
| `visitor_id`, `session_id` | string | |
| `email` | string | Syntax-checked → `400 invalid_email` |
| `email_source` | enum | `in_conversation` \| `lead_form` \| `authenticated` \| `crm_prefill` |
| `name`, `company` | string | |
| `phone_e164` | string | Strict E.164 (`+972501234567`). `0501234567` or `+972 50-123-4567` → `400 invalid_phone` |
| `auth_user_id`, `auth_user_email` | string | |
| `page_url`, `page_title`, `referrer`, `landing_page`, `user_agent` | string | |
| `utm` | object | Any of the 9 UTM keys; missing keys stay `null` |
| `country` | ISO-3166-1 alpha-2 | |
| `custom` | object | Flat key/value, string/number/boolean values |
| `do_not_sell` | boolean | |

```bash
curl -s -H "Authorization: Bearer $SK" -H "$J" -X POST "$B/conversations/conv_p9901/identify" -d '{
  "equalweb_agent_id": "1619",
  "email": "qa.lead@example.com", "email_source": "crm_prefill",
  "name": "Dana Levi", "company": "EqualWeb", "phone_e164": "+972501234567",
  "auth_user_id": "usr_8812",
  "page_url": "https://www.equalweb.com/pricing/?x=1",
  "utm": { "utm_source": "google", "gclid": "Cj0" },
  "custom": { "plan": "free", "site_id": "55231" }
}' | jq '.identity | {equalweb_agent_id, email, phone_e164, utm_medium: .utm.utm_medium, site_id: .custom.site_id}'
```

```json
{ "equalweb_agent_id": "1619", "email": "qa.lead@example.com", "phone_e164": "+972501234567", "utm_medium": null, "site_id": "55231" }
```

`200` with the full identity block (all 23 keys). Identity values are also made available to the agent's prompt context, so a name supplied here can be used in replies.

**Errors:** `400 invalid_type` / `invalid_email` / `invalid_phone` / `unsupported_field` · `400 not_api_conversation` · `404 conversation_not_found` · `410`.

## `POST /conversations/{conv}/end` — close

Marks the conversation `completed`, sets `ended_at`, queues `conversation.ended`, and queues the structured summary (`summary.ready` follows when it is generated).

```bash
curl -s -H "Authorization: Bearer $SK" -X POST "$B/conversations/conv_p9901/end" | jq .
```

```json
{ "conversation_id": "conv_p9901", "status": "completed", "ended_at": "2026-09-03T14:41:02Z", "duration_seconds": 352, "turn_count": 6, "meta": { "…": "…" } }
```

Conversations you never close are marked `abandoned` after 30 minutes without a message and closed by the idle sweep (with `conversation.ended` and a summary), so `/end` is a courtesy that gets you the summary sooner and a `completed` status instead of `abandoned`.

**Errors:** `409 conversation_already_ended` · `400 not_api_conversation` · `404 conversation_not_found` · `410`.

## Billing

- Each `POST …/messages` turn is billed as a **widget chat session turn**: the same session type and multiplier as the widget (`session_type = "chat"`, `channel = "api"`). It appears in `GET /accounts/{acc}/usage` under the agent, and in the dashboard's Chat tab.
- Chat via API always runs on **OpenAI**, whatever the agent's `ai_provider` (Gemini-voice agents included). `usage.model` and the conversation's `model` report the model actually used — the tier's `publicWidget.model` (`gpt-5.6-luna` standard, `gpt-5.6-terra` large since 2026-09-06), called through the Responses API.
- Idempotent replays are not re-billed. `409 turn_in_progress`, `4xx` validation failures and `429` are never billed.
- A subscription that is not active, or an exhausted balance, answers `402 payment_required` on create and on every message. The check is cached for up to 10 minutes after a top-up.

## Limits

Rate class `chat`: 30 requests / min per key on `POST /conversations` and `POST /conversations/{conv}/messages` (since 2026-09-06). Per account and UTC day: `max_chat_messages_per_day` (default 2000) — a create counts as one message, every message one more, an idempotent replay nothing. Over it: `429 quota_exceeded` (`quota: "chat_messages"`, `resets_at`, `Retry-After`), no engine call, no billing. See [Accounts → Daily quotas](accounts-and-users.md#daily-quotas).

| Limit | Value |
|---|---|
| `text` length | 1–10,000 characters |
| Concurrent turns per conversation | 1 (`409 turn_in_progress`) |
| Idempotency window | 24 h per `idempotency_key` per conversation |
| Idle timeout | 30 min → `abandoned` |
| Request body | 2 MB |
| Rate class | `write` — 60/min per credential (widget per-token limits do **not** apply to API conversations) |
| Response time | Non-streaming; plan for up to 15 s per turn |

---

**UiriX Public API v1** · [Overview](README.md) · [Quickstart](quickstart.md) · [Authentication](authentication.md) · [Scopes](scopes.md) · [Conventions](conventions.md) · [Accounts & Users](accounts-and-users.md) · [Agents](agents.md) · [Capabilities](capabilities.md) · [Profiles](profiles.md) · [Conversations](conversations.md) · [Chat via API](chat-via-api.md) · [Widget Events](widget-events.md) · [Webhooks](webhooks.md) · [Calls](calls.md) · [Metrics](metrics.md) · [Voices](voices.md) · [Enums](enums.md) · [Partners](partners.md) · [Admin](admin.md) · [MCP](mcp.md) · [Changelog](changelog.md) · [OpenAPI](openapi.yaml)


<!-- ===== conversations.md ===== -->

# Conversations

One conversation object, whatever the channel. UiriX stores conversations in three places; the API normalises them into one shape and one id space:

| Id prefix | Source | `agent_type` |
|---|---|---|
| `conv_p…` | Widget chat & voice, and chat via API | `chat` (`channel_detail.channel` = `widget` \| `api` \| `dashboard_preview`) |
| `conv_u…` | Phone calls (Twilio inbound / outbound) and dashboard realtime voice | `phone`, `phone_outbound`, or `dashboard` (realtime test sessions) |
| `conv_d…` | Dashboard chat tab | `dashboard` |

Variables: `$B` base URL, `$SK` personal key.

## Status

Computed at read time, with **N = 30 minutes** of inactivity as the abandonment threshold:

| `status` | Rule |
|---|---|
| `in_progress` | No `ended_at` and activity within the last 30 min |
| `abandoned` | No `ended_at` and no activity for ≥ 30 min (a background sweep then closes it and emits `conversation.ended`) |
| `failed` | Closed with an error reason, or the call ended `failed` / `busy` / `no-answer` |
| `completed` | Everything else with an `ended_at` |

## `GET /conversations` — scope `conversations:read`

`summaries:read` alone may call this with `include=summary` only (no identity, no metrics).

| Param | Type | Notes |
|---|---|---|
| `account_id` | string, repeatable | Accounts to read. Default: the credential's home account. Any account not on the credential → `403 forbidden_account`. |
| `agent_id` | string, repeatable | `agt_245` |
| `agent_type` | enum, repeatable | `chat` \| `phone` \| `phone_outbound` \| `dashboard` |
| `channel` | enum, repeatable | `widget` \| `api` \| `dashboard_preview` \| `phone` \| `dashboard` |
| `origin` | enum, repeatable | `native` \| `api` \| `import` — who started the conversation; `import` = written by `POST /conversations/import` |
| `from` | ISO-8601 | Inclusive lower bound on `started_at` |
| `to` | ISO-8601 | Exclusive upper bound on `started_at` |
| `updated_since` | ISO-8601 | Conversations whose status, summary, identity or transcript changed after this time. **Preferred for incremental sync.** |
| `status` | enum, repeatable | `in_progress` \| `completed` \| `abandoned` \| `failed` |
| `language` | BCP-47, repeatable | Matches `language` prefix-wise (`en` matches `en-US`) |
| `outcome` | enum, repeatable | Summary outcome, see [Enums](enums.md) |
| `escalated` | boolean | `summary.escalated_to_human.value` |
| `has_identity` | boolean | At least one of `identity.email`, `identity.phone_e164`, `identity.equalweb_agent_id` is non-null — the "lead" filter |
| `has_recording` | boolean | |
| `external_session_id` | string | API-chat conversations created with that id |
| `include` | csv | `summary`, `identity`, `metrics`. Default `summary,identity`. `transcript` is **not** available on the list (`400 unsupported_filter`). |
| `limit` | int | 1–200, default 50; up to 500 with `conversations:backfill` |
| `cursor` | string | From `pagination.next_cursor`, same filters only |
| `sort` | enum | `-started_at` (default) \| `started_at` \| `-updated_at` \| `updated_at` |

No `from` / `to` / `updated_since` → last 30 days with `meta.applied_default_range: true`. `from` older than 30 days → backfill rate class (needs `conversations:backfill`). Unknown enum values → `400 unsupported_filter`.

```bash
curl -s -H "Authorization: Bearer $SK" \
  "$B/conversations?agent_type=chat&from=2026-08-01T00:00:00Z&to=2026-09-01T00:00:00Z&has_identity=true&include=summary,identity,metrics&limit=1" | jq .
```

```json
{
  "data": [
    {
      "conversation_id": "conv_p9876",
      "account_id": "acc_17",
      "agent_type": "chat",
      "uirix_agent_id": "agt_245",
      "agent_version": "agt245-v3",
      "prompt_version": "chat-v3",
      "model": "gpt-5-nano",
      "status": "completed",
      "started_at": "2026-08-20T09:14:02Z",
      "ended_at": "2026-08-20T09:21:47Z",
      "updated_at": "2026-08-20T09:22:10Z",
      "duration_seconds": 465,
      "turn_count": 14,
      "language": "en-US",
      "channel_detail": { "channel": "widget", "widget_version": null, "device": "desktop" },
      "summary": {
        "summary": "Visitor asked whether the EqualWeb widget alone satisfies the European Accessibility Act for an e-commerce site with 40k SKUs. The agent explained that the widget plus remediation and an audit are needed, and offered a demo. The visitor supplied a work email and asked for enterprise pricing.",
        "intent": "pricing_enterprise",
        "topics": ["EAA", "e-commerce", "pricing", "audit"],
        "sentiment": "positive",
        "sentiment_score": 0.6,
        "outcome": "lead_captured",
        "action_items": [ { "text": "Send enterprise pricing for a 40k-SKU e-commerce site", "assignee": "sales", "due": null } ],
        "escalated_to_human": { "value": false, "at": null, "to": null },
        "resolved": true,
        "handoff_reason": null,
        "summary_status": "ready",
        "summary_model": "uirix-summary-v1@gpt-5-nano"
      },
      "identity": {
        "account_id": "acc_17",
        "equalweb_agent_id": "1619",
        "visitor_id": "vis_9f2c81a0",
        "session_id": "ses_7723ab19",
        "email": "procurement@example-shop.de",
        "email_source": "in_conversation",
        "name": null,
        "company": null,
        "phone_e164": null,
        "caller_id_name": null,
        "called_number_e164": null,
        "auth_user_id": null,
        "auth_user_email": null,
        "page_url": "https://www.equalweb.com/pricing/",
        "page_title": "Pricing | EqualWeb",
        "referrer": "https://www.google.com/",
        "landing_page": "https://www.equalweb.com/european-accessibility-act/",
        "utm": { "utm_source": "google", "utm_medium": "cpc", "utm_campaign": "eaa-eu-enterprise", "utm_term": "european accessibility act compliance", "utm_content": "rsa_v3", "gclid": "Cj0KCQjw_example", "fbclid": null, "li_fat_id": null, "msclkid": null },
        "country": "DE",
        "user_agent": "Mozilla/5.0 …",
        "custom": { "plan": "free", "site_id": "55231" },
        "ip_country_only": true,
        "do_not_sell": false
      },
      "metrics": {
        "containment": true, "escalation": false,
        "avg_agent_latency_ms": 1840, "p95_agent_latency_ms": 3120,
        "low_confidence_turns": null, "unanswered_turns": 0, "rephrase_count": null,
        "abandoned_at_turn_index": null, "abandoned_after_ms": null,
        "csat": null, "csat_comment": null
      },
      "has_recording": false,
      "retention_expires_at": "2028-08-20T09:14:02Z",
      "links": {
        "self": "https://api.uirix.com/v1/conversations/conv_p9876",
        "dashboard": "https://dashboard.uirix.com/conversations/conv_p9876"
      }
    }
  ],
  "pagination": { "limit": 1, "has_more": true, "next_cursor": "eyJ2IjoxLCJzIjoiMjAyNi0wOC0yMFQwOToxNDowMloiLCJzdCI6IlAiLCJpIjo5ODc2LCJmIjoiM2E5ZjJjODEiLCJ0IjoxNzg3NDEyMDAwfQ.p9QxL2kV…" },
  "meta": { "request_id": "req_01J8Z9K3AA", "environment": "production", "applied_default_range": false, "from": "2026-08-01T00:00:00Z", "to": "2026-09-01T00:00:00Z" }
}
```

**Errors:** `400 invalid_date` / `invalid_date_range` / `invalid_cursor` / `invalid_limit` / `unsupported_filter` · `403 forbidden_account` / `insufficient_scope` · `429 rate_limited`.

## `POST /conversations/import` — scope `conversations:write`

Writes a **finished phone call that happened outside UiriX** — your own voice stack, a human agent — into the call log of one of your agents. Two effects:

1. **The agent knows about it on the next call.** When that number calls the agent, the call is part of the returning-call context the agent loads (see [How the agent uses it](#how-the-agent-uses-an-imported-call) below).
2. **It is listed like any other call.** `GET /conversations` returns it with `channel_detail.origin = "import"` and `channel_detail.source = "import"`, the dashboard's Calls list shows it with an **Imported** badge.

Nothing runs: no agent, no model, no summariser when you send a `summary`, no `conversation.started` / `conversation.ended` webhook, no billing and no usage (`GET /usage` does not count it). `agent_version`, `prompt_version` and `model` are `null` — the agent never ran.

| Field | Type | Notes |
|---|---|---|
| `agent_id` | string, **required** | The agent whose call log the call joins (`agt_…`). Usually the inbound agent the customer will call back |
| `account_id` | string | Optional when the credential has one account |
| `channel` | `phone` | Only phone calls can be imported today. Default `phone` |
| `direction` | `outbound` \| `inbound` | `outbound` — you called the number (`agent_type: "phone_outbound"`); `inbound` — the number called you (`agent_type: "phone"`). Default `outbound` |
| `identity` | object, **required** | `phone_e164` **required** (E.164 — the key the returning-call lookup matches on), plus any of `email`, `email_source`, `name`, `company`, `caller_id_name`, `called_number_e164` (your own number on the call), `country`, `custom` (free key/value, e.g. your customer id or domain, at most 20 keys), `do_not_sell`, `equalweb_agent_id`. Same rules as [identify](chat-via-api.md#post-conversationsconvidentify--attach-identity) |
| `started_at`, `ended_at` | ISO-8601, **required** | UTC or with an offset; `ended_at` ≥ `started_at`, `started_at` not in the future |
| `duration_s` | int | Defaults to `ended_at − started_at` |
| `turns` | Turn[] | `{ "role": "agent" \| "user", "text", "at" }` — the transcript as spoken, in order, at most 500 turns of 4 000 characters. `at` is optional; without it the turn has no timestamp, latency or audio offset |
| `summary` | string ≤ 4 000 | Short text: what was offered, what the customer said, the next step, any code given. **This is what the agent reads on the next call.** Omitted → the platform writes one from the turns within minutes (`summary_status: "pending"` until then); an import needs a `summary`, `turns`, or both |
| `outcome` | string ≤ 80 | Free text, kept verbatim in `channel_detail.import.outcome` and read by the agent; when it is one of the [summary `outcome` values](enums.md#outcome-8-values) it is set on `summary.outcome` too |
| `language` | BCP-47 | The language of the call; reported as `language` |
| `external_session_id` | string 1–128, **required** | Your id of the call — the **idempotency key**, per account. A repeat answers `409 import_exists` with `error.conversation_id` naming the existing conversation — unless `upsert` is true. Returned as `external_session_id` on the detail and matched by the `external_session_id` list filter |
| `recording_url` | string | The Twilio recording of the call — the `RecordingSid` (`RE…`) or the `RecordingUrl` Twilio gives you (`…/Accounts/AC…/Recordings/RE…`). **Only a recording in the platform's Twilio account** (the account the agent's number belongs to — the case when you place calls through Twilio with the platform's credentials and the agent's number) can be sent: it then plays in the dashboard and through `GET /conversations/{conv}/recording` exactly like a native call, and `has_recording` is `true`. A recording in another account is `400 validation_error`. Record the call in Twilio first (`Record=true` on the REST call, or `<Record>` / `record="record-from-answer-dual"` in TwiML); an unrecorded call has no `RE…` to send |
| `twilio_call_sid` | string | The Twilio call sid (`CA…`) when the call went through Twilio — shown as the call id in the dashboard |
| `upsert` | boolean | `true` = if this `external_session_id` was imported before, **replace the record in place** (same `conversation_id`): direction, agent, times, turns, summary, outcome; identity fields are merged (what you send wins, what you omit stays). Answers `200` instead of `201`; `channel_detail.import.revision` counts the writes. This is how to correct an import — there is no delete |

```bash
curl -s -X POST -H "Authorization: Bearer $SK" -H "Content-Type: application/json" "$B/conversations/import" -d '{
  "agent_id": "agt_308",
  "channel": "phone",
  "direction": "outbound",
  "identity": {
    "phone_e164": "+972544808088",
    "email": "dana@example-shop.de",
    "name": "Dana Levi",
    "company": "Example Shop",
    "custom": { "equalweb_customer_id": "cust_4711", "domain": "example-shop.de" }
  },
  "started_at": "2026-09-24T10:00:00Z",
  "ended_at": "2026-09-24T10:01:35Z",
  "duration_s": 95,
  "turns": [
    { "role": "agent", "text": "Hi, this is Dana from EqualWeb. Do you have two minutes?", "at": "2026-09-24T10:00:03Z" },
    { "role": "user", "text": "Sure, what is it about?", "at": "2026-09-24T10:00:09Z" },
    { "role": "agent", "text": "We can offer the Pro plan at 20% off for the first year with code EW-PRO-20.", "at": "2026-09-24T10:00:12Z" },
    { "role": "user", "text": "Send me the audit report first and call me on Monday.", "at": "2026-09-24T10:01:20Z" }
  ],
  "summary": "Offered the Pro accessibility plan at 20% off for the first year. The customer asked to see the audit report first and wants a call back on Monday. Discount code given: EW-PRO-20.",
  "outcome": "callback_monday",
  "external_session_id": "ew-call-20260924-000123"
}' | jq '{conversation_id, agent_type, status, started_at, duration_seconds, turn_count, channel_detail, external_session_id, summary: .summary.summary, phone: .identity.phone_e164}'
```

```json
{
  "conversation_id": "conv_u3841",
  "agent_type": "phone_outbound",
  "status": "completed",
  "started_at": "2026-09-24T10:00:00.000Z",
  "duration_seconds": 95,
  "turn_count": 4,
  "channel_detail": {
    "channel": "phone", "origin": "import", "source": "import",
    "import": { "external_session_id": "ew-call-20260924-000123", "direction": "outbound", "outcome": "callback_monday", "imported_at": "2026-09-25T08:12:44.310Z" },
    "widget_version": null, "device": null
  },
  "external_session_id": "ew-call-20260924-000123",
  "summary": "Offered the Pro accessibility plan at 20% off for the first year. The customer asked to see the audit report first and wants a call back on Monday. Discount code given: EW-PRO-20.",
  "phone": "+972544808088"
}
```

The response is the full conversation object of `GET /conversations/{conv}` (transcript, summary, identity, metrics) — `uirix_agent_id` and `agent_name` say which agent's log the call joined — plus `warnings`, an array that is empty or carries `{ "code": "summary_truncated_for_agent", "message": "…" }` when the summary is longer than what the agent reads (see below). `summary.summary_model` is `uirix-summary-v1@import` when you supplied the summary; `summary.answered_by` is `human`; `summary.intent` stays `null` (no model judged it).

**Summary length.** `summary` is stored and returned whole (up to 4 000 characters), but the phone agent reads **the first 600 characters** — the same limit in both runtime blocks below, with the `outcome` placed in front of the summary in the returning-call block. Put the essentials (offer, next step, any code) in the first 600 characters; the import answers with a `summary_truncated_for_agent` warning when they are not.

**Errors:** `400 validation_error` (a required field, `turns[i].at`, neither summary nor turns) · `400 invalid_phone` · `400 invalid_date` / `invalid_date_range` · `400 unsupported_field` · `403 insufficient_scope` · `404 agent_not_found` · `409 import_exists` (+ `conversation_id`; send `upsert: true` to replace) · `410 conversation_deleted` (the earlier import was deleted in the dashboard; it cannot be replaced).

### How the agent uses an imported call

On every inbound call the phone runtime (3002) gives the agent **two blocks** about the caller's number, and an imported call is part of both:

**1. `[RECENT CALLS HISTORY]` — the last 3 calls with this number.** Placed in front of the agent's instructions.

| Rule | Value |
|---|---|
| Match key | the caller id of the inbound call = `identity.phone_e164` of the import (E.164), in either column of the call record — an outbound import as the called number, an inbound import as the caller |
| Scope | **the inbound agent only** (`agent_id`) — a call found by the number must belong to the agent being called; import to the agent the customer will call |
| Window | none — the last three calls whatever their age |
| How many | **3**, newest first by start time, among calls that stored a transcript (an import always does; `turns` may be empty) |
| What is carried | per call: date, type (`📥 Imported outbound call` / `📥 Imported inbound call` for an import), duration, and for an import **`Summary:` = the first 600 characters of your `summary`** followed by the first 10 turns of its transcript; native calls show their first 10 turns |

**2. `[RETURNING CALL CONTEXT]` — the last 3 calls the business had with the number.** Appended to the tail of the prompt.

| Rule | Value |
|---|---|
| Match key | the caller id of the inbound call = `identity.phone_e164` of the import (E.164, with or without `+`). An inbound import matches on the caller column, an outbound import on the called column |
| Scope | **the inbound agent only** (`agent_id`), like block 1 — never a sibling agent's call (changed 25 Sept 2026; until then any agent of the account counted) |
| Window | the last **30 days** (`INBOUND_CALLBACK_CONTEXT_DAYS`); an older import is not loaded here (it still appears in block 1) |
| How many | up to **3 calls**, newest first by `ended_at` — imported calls and the platform's own outbound calls (campaigns, `POST /calls`) in one list |
| What is carried | the newest call: date, and for an import **the first 600 characters of `outcome` + `summary`** (the outcome in front, then the summary — a code within the first 600 characters survives; native calls carry the first sentence of their own summary); the earlier calls: date + one clause each (≤ 110 characters). The block is capped at 1 300 characters, the instruction sentence is never cut |
| Instruction | "If the caller says they are returning a call, acknowledge it and tell them in one or two sentences what the last call was about and what was agreed next (from this record), then continue from that point; do not restart with a generic pitch or a menu of options. If they ask what was discussed before, answer from this record." — block 1 carries the same instruction |
| Never | the name, e-mail, company or custom fields — only the summary text, the outcome you sent and the transcript turns. Do not put secrets in the summary or the turns |

Acceptance check (run on 25 Sept 2026 with a robot caller, in Hebrew): import one call for a number, call the agent from that number and ask "what did we talk about last time?" — the agent answered with the offer, the report, the Monday callback and the coupon code from the imported summary. The inbound call's own record carries `metadata.callback_context` (`calls`, `imported: true`) in the database; the 3002 log shows `🔍 RAW HISTORY DATA` (`imported: true` on the entry) and `📲 Returning-call context found` (`calls`, `imported`).

## The conversation object

| Field | Type | Notes |
|---|---|---|
| `conversation_id` | string | `conv_p…` / `conv_u…` / `conv_d…` |
| `account_id` | string | |
| `agent_type` | enum | `chat` \| `phone` \| `phone_outbound` \| `dashboard` |
| `uirix_agent_id` | string | `agt_245` — the agent whose log the conversation belongs to |
| `agent_name` | string \| null | The agent's current name (`agents.name`), for display; the id is `uirix_agent_id` |
| `agent_version` | string | Stamped at start, immutable |
| `prompt_version` | string | Stamped at start, immutable. **Unreliable today:** it reports the agent's single `current_prompt_version`, so it does not identify which of the three live prompts actually ran. Use the version history from `GET /agents/{agt}/prompts` instead — see [Agents](agents.md) |
| `model` | string \| null | Model id used for the conversation. Chat: the model that actually answered the turns (since 2026-09-10 recovered for older conversations from the turn metadata, so it is no longer `null` on chat history) |
| `status` | enum | See [Status](#status) |
| `started_at` | ISO-8601 | |
| `ended_at` | ISO-8601 \| null | Phone: when the call ended. Chat: when the session was closed — which can be long after the last message |
| `updated_at` | ISO-8601 | Last change to status / transcript / summary / identity — drives `updated_since` |
| `duration_seconds` | int \| null | Phone / dashboard: `ended_at − started_at`. **Chat: last message − started_at** (since 2026-09-10) — the time the visitor was actually talking, because a chat session's close time is set by later bookkeeping and produced "durations" of weeks. `null` while the conversation is open |
| `turn_count` | int | |
| `language` | BCP-47 | Detected by the summariser once ready; the agent's configured language before that |
| `channel_detail` | object | `{ "channel", "origin", "source", "import", "widget_version", "device" }`. See below |
| `channel_detail.origin` | `native` \| `api` \| `import` | Who started the conversation: `api` when the platform placed it (`POST /calls`, `POST /conversations`), `native` when a person did, `import` when it was written by [`POST /conversations/import`](#post-conversationsimport--scope-conversationswrite) — a call the platform never ran. Independent of `channel`, which is the transport — an API-placed phone call is `channel: "phone"`, `origin: "api"` |
| `channel_detail.source` | `import` \| null | `import` for an imported conversation, `null` otherwise |
| `channel_detail.import` | object \| null | What the importer recorded: `{ "external_session_id", "direction", "outcome", "imported_at", "updated_at", "revision", "recording_sid" }`; `null` on every other conversation |
| `summary` | object | Always present when included — see [Summary object](#summary-object) |
| `identity` | object | Always present when included — see [Identity block](#identity-block) |
| `metrics` | object | When included — see [Metrics block](#metrics-block) |
| `has_recording` | boolean | |
| `retention_expires_at` | ISO-8601 | When the transcript is scheduled for deletion |
| `links` | object | `{ "self", "dashboard" }` — `self` uses the base URL of the environment you called |
| `external_session_id` | string \| null | Detail only; API-chat conversations |
| `transcript` | Turn[] | Detail only |
| `transcript_text` | string | Detail only, when `transcript_format=text` (derived rendering) |
| `redacted` | boolean | Detail only |
| `redaction_rules_applied` | string[] | Detail only; e.g. `["EMAIL", "PHONE"]` |

### Turn schema

Every turn carries the same **15 keys**, `null` where not applicable — never omitted.

| Key | Type | Semantics |
|---|---|---|
| `turn_index` | int | 0-based, contiguous, no gaps — tool turns are counted in the sequence |
| `speaker` | `user` \| `agent` \| `human_agent` \| `system` \| `tool` | `system` = automated notices (transfer, timeout, business-hours message, handoff marker). `tool` = a tool the agent ran — see [Tool turns](#tool-turns) |
| `text` | string | Verbatim message (chat) or transcribed speech (phone). Tool turns: a one-line rendering of the call / result |
| `timestamp` | ISO-8601 | Wall-clock time of the turn |
| `confidence` | float 0–1 \| null | **`null` in v1** (no per-utterance ASR / answer confidence). Never substituted with 1.0 |
| `audio_offset_ms` | int \| null | Phone only: `timestamp − started_at`, derived from wall-clock (≈ 1 s accuracy). `null` for chat and dashboard |
| `audio_duration_ms` | `null` | Always `null` — per-turn durations are not measured. Use `audio_offset_ms` for position in the recording |
| `latency_ms` | int \| null | Agent turns: milliseconds from the preceding user turn to this turn. `null` on greetings and non-agent turns |
| `turn_type` | string \| null | `greeting`, `question`, `answer`, `clarification`, `handoff`, `form_fill`, `closing`; `tool_call` / `tool_result` on `tool` turns |
| `language_mismatch` | boolean | Phone: the transcript's script contradicts the call language (treat the text with suspicion). `false` elsewhere |
| `detected_intent` | `null` | Always `null`. Conversation-level intent is on the summary instead |
| `detected_language` | `null` | Always `null`. Use the conversation-level `language` |
| `sources` | `null` | Always `null` — the engine does not attribute KB documents per turn. `unanswered` is the KB-gap signal instead |
| `unanswered` | boolean | Agent turns: the agent emitted its fallback answer or could not answer |
| `human_agent_id` | string \| null | `human_agent` turns |

Per `agent_type`:

| | `chat` / `dashboard` | `phone` / `phone_outbound` |
|---|---|---|
| `audio_offset_ms` | `null` | numbers (`audio_duration_ms` is `null` on every channel) |
| `latency_ms` | measured | measured (speech end → agent speech start, approximate) |
| Handoff / transfer | `speaker: "system"`, `turn_type: "handoff"` | same; a `transfer_call` tool result becomes a `system` handoff turn |
| Recording disclosure (outbound, `recording: true`) | — | first agent turn, `turn_type: "greeting"` |
| Tool turns | recorded since 2026-09-10 (chat), 2026-09-11 (voice widget) | recorded since 2026-09-11 (inbound calls, OpenAI and Gemini) — see below |

### Tool turns

Since 2026-09-10 every tool the agent runs on a chat surface (widget chat and chat via the API — `create_lead`, `send_email`, `create_ticket`, `create_order`, since 2026-09-29 `search_website` (the agent searching its own website when the knowledge base has no answer; the result turn carries the pages it found, `found`, `tier` = `local` | `live` | `none` | `limit`) and the agent's [custom capabilities](agents.md#custom-capabilities)), and since 2026-09-11 every tool it runs on the voice widget and on an inbound phone call (`send_sms`, `send_email`, `transfer_call`, `schedule_google_calendar_meeting`, `end_call`, `send_call_summary`, `create_lead` / `create_ticket` / `create_order`, the link tools and the custom capabilities, on the OpenAI and the Gemini path alike), is written to an audit table and merged into the detail transcript as two turns with `speaker: "tool"`, placed by time between the message turns and counted in `turn_index`:

| `turn_type` | `text` | `timestamp` |
|---|---|---|
| `tool_call` | `get_google_indexed_page_count(website_url=mertzig.lu)` — the function and its arguments (`key=value`, at most six keys, values shortened) | when the call started |
| `tool_result` | `get_google_indexed_page_count → {"total_results":68100}` — the result, or `→ error: …` when the call failed | when the result came back |

This is how a reader tells what the agent looked up from what it made up: an agent turn that follows a `tool_result` is grounded in it; an agent claim with no tool turn before it is the agent's own. Secret-looking argument keys (`api_key`, `token`, `password`, `authorization`…) are masked at write time and never stored; card numbers are scrubbed like every other transcript. Tool turns carry the same 15 keys as every other turn with `latency_ms`, `audio_offset_ms` and `unanswered` unset, are included in `transcript_text`, in the `conversation.ended` / `conversation.escalated` webhook transcripts and in what the structured summariser reads, and are masked by [redaction](#redaction) like any other `text`. They are not part of `turn_count`, and the list endpoint's `include=metrics` is computed from the message turns alone.

Tool calls made on chat before 2026-09-10, and on the phone or the voice widget before 2026-09-11, are not on record anywhere. Outbound campaign calls (`conv_u…` of an outbound agent) do not record their tool calls yet.

### Summary object

Embedded in list and detail (`include=summary`) and standalone at `GET /conversations/{conv}/summary`. The summariser runs once a conversation has ended — and, since 29 Sept 2026, also for a widget or API chat that has been quiet for 30 minutes without a closing turn (the same rule that makes its `status` `abandoned`), so a visitor who simply closed the tab still gets a summary, an outcome and a place in the metrics. This applies to chats that went quiet from that date on; older abandoned chats are not summarised retroactively. **Never omitted**: before the summariser runs it is returned with `summary_status: "pending"` and null members.

| Field | Type | Notes |
|---|---|---|
| `summary` | string \| null | 2–5 plain-language sentences, no markdown |
| `intent` | string \| null | One primary intent from the versioned enum — the v1 list is published with the summaries release; see [Enums](enums.md#intent) |
| `topics` | string[] | 0–8 tags |
| `sentiment` | `positive` \| `neutral` \| `negative` \| `mixed` \| null | |
| `sentiment_score` | float −1..1 \| null | |
| `outcome` | enum \| null | `resolved`, `lead_captured`, `demo_requested`, `escalated`, `abandoned`, `no_intent`, `spam`, `unresolved` |
| `action_items` | object[] | `[{ "text", "assignee": string \| null, "due": ISO-8601 \| null }]` |
| `escalated_to_human` | object | Always `{ "value": bool, "at": ISO-8601 \| null, "to": string \| null }` |
| `resolved` | boolean \| null | The agent's own judgement that the need was met |
| `handoff_reason` | enum \| null | `low_confidence`, `user_requested_human`, `out_of_scope`, `angry_user`, `pricing_authority`, `technical_bug`, `after_hours`, `other` |
| `answered_by` | enum \| null | Who or what was on the other end, decided by rule before any model call (since 2026-09-10): `human`, `voicemail`, `ivr`, `silence`; `unknown` on a summary written before the classifier existed; `null` while pending. On an outbound call where the runtime itself detected an IVR or a voicemail live, that verdict wins over the transcript rule (since 2026-09-11). See [Enums](enums.md#answered_by) |
| `human_requested` | boolean \| null | The customer asked for a human / representative / live agent (deterministic phrase match in English, Hebrew and Spanish, user turns only); `null` on older summaries |
| `identity_extracted` | object | Always `{ "name", "email", "phone", "company", "website" }`, each a string or `null` — contact details the **customer stated** in the conversation, never inferred. What the model extracted is also merged into the [identity block](#identity-block) in fill-only mode: a null is filled (`email_source: "in_conversation"`), a value that came from `identify()`, `authenticated` or `crm_prefill` is never replaced, and on phone calls the caller id stays authoritative for `phone_e164` |
| `summary_status` | `pending` \| `ready` \| `failed` | |
| `summary_model` | string \| null | `uirix-summary-v1@<model>`; `uirix-summary-v1@rules` when the summary was written by rule without a model call (voicemail / IVR / silence) |

Pending shape:

```json
{ "summary": null, "intent": null, "topics": [], "sentiment": null, "sentiment_score": null, "outcome": null, "action_items": [], "escalated_to_human": { "value": false, "at": null, "to": null }, "resolved": null, "handoff_reason": null, "answered_by": null, "human_requested": null, "identity_extracted": { "name": null, "email": null, "phone": null, "company": null, "website": null }, "summary_status": "pending", "summary_model": null }
```

> Every key is always present. **Language:** `summary` and `action_items[].text` are written in the **account's language** (`users.language`, falling back to the agent's language) whatever language the conversation was held in; `language` reports the customer's language. A conversation that never happened — a voicemail greeting, an IVR menu, one utterance looped three times, or under 40 characters of real words — gets a deterministic summary (`outcome: "no_intent"`, `answered_by` set, `summary_model: "uirix-summary-v1@rules"`) without a model call.

> **Who gets summarised.** Every account with a conversation that ended in the last 90 days, plus every account holding an active API credential or webhook endpoint. Fresh conversations (ended within 72 hours) are summarised within minutes; older ones are backfilled newest-first under a per-account daily cap (300 model calls a day by default), so `summary_status: "pending"` on old history means "not reached yet", not "never". `summary` alone may still carry the free-text call summary the product already produced.

### Identity block

Always present (when included) with **all 23 keys**, for every `agent_type`. Structured identity fields are never redacted — that is the point of the block; they are protected by scope instead.

| Key | Type | chat | phone | dashboard | Source |
|---|---|:--:|:--:|:--:|---|
| `account_id` | string | ✔ | ✔ | ✔ | Always |
| `equalweb_agent_id` | string \| null | ✔ | ✔ | ✔ | Passed through **verbatim as a string** from `identify` / widget (`"1619"`, `"-1"`, `"007"` survive) |
| `visitor_id` | string \| null | ✔ | — | ✔ | Stable per-browser id |
| `session_id` | string \| null | ✔ | ✔ | ✔ | Widget session, `api_…` for API chat, call SID for phone |
| `email` | string \| null | ✔ | ✔ | ✔ | Supplied in conversation, lead form, `identify`, or authenticated |
| `email_source` | enum \| null | ✔ | ✔ | ✔ | `in_conversation`, `lead_form`, `authenticated`, `crm_prefill` |
| `name` | string \| null | ✔ | ✔ | ✔ | |
| `company` | string \| null | ✔ | ✔ | ✔ | |
| `phone_e164` | string \| null | ✔ | ✔ | ✔ | **E.164 always** (`+12125550147`). Phone: the caller (inbound) / callee (outbound) |
| `caller_id_name` | string \| null | — | ✔ | — | CNAM when the carrier provides it |
| `called_number_e164` | string \| null | — | ✔ | — | The UiriX number dialled (inbound) / used as caller id (outbound) |
| `auth_user_id` | string \| null | — | — | ✔ | Logged-in dashboard user id, or value from `identify` |
| `auth_user_email` | string \| null | — | — | ✔ | Authenticated email |
| `page_url` | string \| null | ✔ | — | ✔ | Where the conversation started, query string included |
| `page_title` | string \| null | ✔ | — | ✔ | |
| `referrer` | string \| null | ✔ | — | ✔ | |
| `landing_page` | string \| null | ✔ | — | ✔ | First page of the session |
| `utm` | object | ✔ | — | ✔ | Always an object with all 9 keys: `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`, `gclid`, `fbclid`, `li_fat_id`, `msclkid` |
| `country` | ISO-3166-1 alpha-2 \| null | ✔ | ✔ | ✔ | Phone: from the number. Widget: edge geo header when available, else `null` |
| `user_agent` | string \| null | ✔ | — | ✔ | |
| `custom` | object \| null | ✔ | ✔ | ✔ | Free-form key/value from the page or `identify` |
| `ip_country_only` | boolean | ✔ | ✔ | ✔ | Always `true` — raw IPs are never exposed |
| `do_not_sell` | boolean | ✔ | ✔ | ✔ | CCPA/CPRA flag (settable via `identify`) |

> Every key is present on every conversation; the ones this channel cannot supply are `null`. The `✔` columns above say which channels populate which key. Anything you set yourself through `POST /conversations/{conv}/identify` is returned verbatim.

### Metrics block

`include=metrics`. Per-conversation quality signals.

| Key | Type | Notes |
|---|---|---|
| `containment` | boolean \| null | `status = completed` and not escalated |
| `escalation` | boolean | |
| `avg_agent_latency_ms`, `p95_agent_latency_ms` | int \| null | From `turn.latency_ms` |
| `low_confidence_turns` | int \| null | `null` in v1 (no confidence signal) |
| `unanswered_turns` | int | Count of `turn.unanswered = true` |
| `rephrase_count` | `null` | Always `null` — not computed |
| `abandoned_at_turn_index`, `abandoned_after_ms` | int \| null | For `abandoned` conversations |
| `csat` | number \| null | From the call rating, when one was collected |
| `csat_comment` | `null` | Always `null` — free-text ratings are not exposed |

> Every key is present. A signal that was not computed for this conversation is `null` rather than absent, so treat `null` as "not measured", never as zero.

## `GET /conversations/{conv}` — scope `conversations:read`

| Param | Type | Notes |
|---|---|---|
| `include` | csv | Default `transcript,summary,identity,metrics` |
| `redact` | boolean | Apply [redaction](#redaction); default = account's `default_redaction` |
| `transcript_format` | `turns` (default) \| `text` | `text` adds `transcript_text`, a derived rendering; `transcript` (turns) remains the source of truth |

```bash
curl -s -H "Authorization: Bearer $SK" "$B/conversations/conv_u555" | jq '{agent_type, status, has_recording, turns: (.transcript|length), first: .transcript[0], identity: {phone: .identity.phone_e164, called: .identity.called_number_e164}}'
```

```json
{
  "agent_type": "phone",
  "status": "completed",
  "has_recording": true,
  "turns": 22,
  "first": { "turn_index": 0, "speaker": "agent", "text": "Thanks for calling EqualWeb, how can I help?", "timestamp": "2026-08-20T13:02:04Z", "confidence": null, "audio_offset_ms": 1200, "audio_duration_ms": null, "latency_ms": null, "turn_type": "greeting", "detected_intent": null, "detected_language": null, "sources": null, "unanswered": false, "human_agent_id": null },
  "identity": { "phone": "+12125550147", "called": "+16467131717" }
}
```

**Errors:** `404 conversation_not_found` (also for other accounts' conversations) · `400 invalid_id` · `410 conversation_deleted` · `410 transcript_expired` · `403 insufficient_scope` (`summaries:read` alone).

## `GET /conversations/{conv}/summary` — scope `summaries:read`

Returns `{ "conversation_id", "summary_status", …summary object…, "updated_at" }`. Never `404` for "not summarised yet" — that is `summary_status: "pending"`. **Errors:** `404 conversation_not_found` · `410`.

## `GET /conversations/{conv}/recording` — scope `audio:read`

Phone conversations only. Returns a **signed, expiring URL** — never a permanent link, never raw bytes through the API.

| Param | Notes |
|---|---|
| `ttl_seconds` | 900–3600, default 900. Values above the cap are clamped. |

```bash
curl -s -H "Authorization: Bearer $SK" "$B/conversations/conv_u555/recording?ttl_seconds=900" | jq .
```

```json
{
  "conversation_id": "conv_u555",
  "url": "https://api.uirix.com/v1/media/recordings/RE7f3a9c1b2d.mp3?acc=acc_17&exp=1787415600&sig=9f86d081884c7d65…",
  "expires_at": "2026-08-21T15:00:00Z",
  "format": "mp3",
  "sample_rate_hz": 8000,
  "duration_ms": 312400,
  "size_bytes": 2501120,
  "language": "en-US",
  "locale": "en-US",
  "channels": "dual",
  "channel_map": { "0": "caller", "1": "agent" },
  "redacted": false,
  "retention_expires_at": "2026-11-18T13:02:04Z",
  "meta": { "…": "…" }
}
```

`channels` is `"dual"` (caller / agent separated) for calls placed since dual-channel recording was introduced and `"mono"` with `channel_map: null` for older recordings. `size_bytes` may be `null` when not yet known.

**`GET /media/recordings/{sid}.mp3?acc=&exp=&sig=`** streams the audio (`audio/mpeg`). It is authorised by the signature alone (no bearer header), rate-limited per IP at 30/min, and answers `403 url_expired` after `exp` and `403 signature_invalid` on any mismatch.

**Errors:** `404 recording_not_found` (chat conversation, or call not recorded) · `410 recording_expired` (+ `retention_expires_at`) · `403 insufficient_scope` · `429 rate_limited` (audio class, 30/min).

## Redaction

`?redact=true` on detail (and the account's `default_redaction` policy) masks patterns in `turn.text` and `summary.summary`:

| Pattern | Replacement |
|---|---|
| Email addresses | `[EMAIL]` |
| Phone numbers | `[PHONE]` |
| Luhn-valid 13–19 digit sequences | `[CARD]` — **always, at ingestion**; never persisted in the clear, on any channel |
| National ID / SSN patterns | `[ID]` |
| IBAN / account numbers | `[ACCOUNT]` |

A redacted response sets `redacted: true` and lists `redaction_rules_applied` (e.g. `["EMAIL", "PHONE"]`). `identity.email` / `identity.phone_e164` are **not** masked. `turn_index` values and `turn_count` are unchanged by redaction. When `default_redaction` is on, `?redact=false` still returns the masked text (the policy wins). Webhook payloads follow the account policy.

## Deletion, tombstones and erasure

### No `DELETE /conversations/{conv}` — removed 2026-09-06

> **No deletion through the API (Nir, 2026-09-06).** Nothing is deleted through the Public API: there is no `DELETE` route for conversations, subjects, outbound profiles, knowledge documents, do-not-call entries or webhook endpoints. Those paths answer `404 not_found` for every key, the master key included. Deleting is a dashboard action by the account owner. The two `DELETE` verbs that remain — `DELETE /calls/{call}` (cancel a call that has not been dialled) and `DELETE /credentials/{key}` (revoke a key) — are cancellations, not deletions of data.

A conversation still disappears in two ways that are not API calls: the account owner deletes it in the dashboard, or the retention sweep erases it (see [Retention](#retention)). Both write a tombstone and fire `conversation.deleted`; from then on every read of that id is `410 conversation_deleted`.

### `GET /conversations/deleted?since=` — scope `conversations:read`

Tombstones for consumers that missed the webhook.

```bash
curl -s -H "Authorization: Bearer $SK" "$B/conversations/deleted?since=2026-09-01T00:00:00Z" | jq .
```

```json
{
  "data": [ { "conversation_id": "conv_p9876", "account_id": "acc_17", "deleted_at": "2026-09-02T08:00:00Z", "reason": "user_request" } ],
  "pagination": { "limit": 50, "has_more": false, "next_cursor": null },
  "meta": { "…": "…" }
}
```

`reason` ∈ `user_request` | `retention` | `account_closed`.

### No `DELETE /subjects` — removed 2026-09-06

Erasure by data subject (email / phone) is not offered through the API. A subject-erasure request is handled by UiriX operations on the account owner's instruction; each affected conversation becomes a tombstone (`reason: "user_request"`) and fires `conversation.deleted`, so consumers that watch the webhook or poll `GET /conversations/deleted` see it the same way.

## Retention

| Data | Default | Range | Field |
|---|---|---|---|
| Structured transcript | 730 days | 30–730 | `retention.transcript_days` |
| Summary + metadata | 1095 days | 30–1095 | `retention.summary_days` |
| Call recordings | 90 days | 1–365 | `retention.recording_days` |
| Aggregate metrics (no PII) | indefinite | — | — |

Configured per account (`PATCH /accounts/{acc}`). `retention_expires_at` is exposed on every conversation and recording object. When retention deletes a transcript the conversation fires `conversation.deleted` with `reason: "retention"` and reads answer `410 transcript_expired`.

> `retention_expires_at` is computed and returned on every conversation and recording from the account's retention settings. **Automatic deletion at that moment is an operator-enabled sweep, not a guarantee this API makes** — treat `retention_expires_at` as the policy date, and do not assume data has already been destroyed once it has passed.

---

**UiriX Public API v1** · [Overview](README.md) · [Quickstart](quickstart.md) · [Authentication](authentication.md) · [Scopes](scopes.md) · [Conventions](conventions.md) · [Accounts & Users](accounts-and-users.md) · [Agents](agents.md) · [Capabilities](capabilities.md) · [Profiles](profiles.md) · [Conversations](conversations.md) · [Chat via API](chat-via-api.md) · [Widget Events](widget-events.md) · [Webhooks](webhooks.md) · [Calls](calls.md) · [Metrics](metrics.md) · [Voices](voices.md) · [Enums](enums.md) · [Partners](partners.md) · [Admin](admin.md) · [MCP](mcp.md) · [Changelog](changelog.md) · [OpenAPI](openapi.yaml)


<!-- ===== enums.md ===== -->

# Enums (v1)

> **Contract v1.0.** Enum values are stable for v1. New values may be **added** and will be announced in the [changelog](changelog.md); consumers should treat unknown values as "other" rather than fail. Values are never removed or renamed within v1. The machine-readable source is `GET /openapi.json`.

## Conversation

### `agent_type`

| Value | Meaning |
|---|---|
| `chat` | Website widget chat, and chat via API (`conv_p…`) |
| `phone` | Inbound phone call (`conv_u…`) |
| `phone_outbound` | Outbound call originated by `POST /calls` or a campaign (`conv_u…`) |
| `dashboard` | Dashboard chat tab (`conv_d…`) or dashboard realtime voice test (`conv_u…`) |

### `status`

| Value | Meaning |
|---|---|
| `in_progress` | Open, activity within 30 min |
| `completed` | Closed normally |
| `abandoned` | Open with no activity for ≥ 30 min (closed by the idle sweep) |
| `failed` | Closed by an error, or call `failed` / `busy` / `no-answer` |

### `channel_detail.channel`

`widget` · `api` · `dashboard_preview` · `phone` · `dashboard`

### `channel_detail.origin`

`native` (a person started it) · `api` (the platform placed it) · `import` (written by `POST /conversations/import` — a call the platform never ran)

### `channel_detail.device`

`desktop` · `mobile` · `null`

## Turn

### `speaker`

`user` · `agent` · `human_agent` · `system` · `tool`

`tool` (since 2026-09-10) is a tool the agent ran, merged into the transcript from the tool-call audit — see [Conversations › Tool turns](conversations.md#tool-turns).

### `turn_type`

`greeting` · `question` · `answer` · `handoff` · `closing` · `tool_call` · `tool_result` · `null`

`question` is only ever set on a user turn; `greeting`, `answer` and `closing` only on an agent turn; `tool_call` and `tool_result` only on a `tool` turn.

## Summary

### `intent`

The **26** values of the v1 intent enum, in full. Lowercase snake_case. `intent` is `null` on
any conversation that has no structured summary — see [Conversations](conversations.md).

New values may be **added** within v1 and will be announced in the [changelog](changelog.md);
existing values are never removed or renamed. Treat an unknown value as `other` rather than
failing — that is what `other` is for.

This table is the published list. The API does not serve the enum as data: it is not in
`GET /openapi.json`, so read it from here.

| Value | When the summariser picks it |
|---|---|
| `pricing_general` | Asked what it costs, plan comparison, discounts. |
| `pricing_enterprise` | Volume, multi-site or enterprise pricing; asked for a quote. |
| `sales_demo` | Asked for a demo, a call, a meeting or a trial walkthrough. |
| `sales_evaluation` | Comparing vendors, asking about differentiators or references. |
| `product_capability` | Can the product do X — feature questions, scope of functionality. |
| `compliance_scope` | Which regulation or standard is covered and whether it is sufficient. |
| `compliance_audit` | Audit, certification, VPAT, accessibility statement, legal exposure. |
| `integration_technical` | API, SDK, embedding, CMS or platform integration questions. |
| `support_technical` | Something is broken, an error, unexpected behaviour. |
| `support_howto` | How do I configure or use an existing feature. |
| `billing` | Invoices, payment methods, charges, refunds, plan changes. |
| `account_access` | Login, password, MFA, seats, permissions. |
| `account_cancel` | Wants to cancel, downgrade or delete the account. |
| `onboarding` | Getting started, first configuration, setup guidance. |
| `appointment_booking` | Book, move or cancel an appointment or service slot. |
| `order_status` | Where is my order, delivery, shipment or ticket. |
| `returns_refund` | Return an item, request a refund, warranty claim. |
| `complaint` | Dissatisfaction with the product, the service or a previous interaction. |
| `partnership` | Reseller, affiliate, agency or integration partnership enquiry. |
| `careers` | Jobs, applications, recruiting. |
| `press_research` | Journalist, analyst, student or research enquiry. |
| `human_request` | Explicitly asked to speak to a person, with no other discernible topic. |
| `privacy_request` | Data access, erasure, do-not-sell or other privacy rights request. |
| `spam` | Advertising, abuse, prompt injection or nonsense. |
| `no_intent` | Greeting only, an accidental open, or no discernible request. |
| `other` | A real request that none of the values above describes. |

### `outcome` (8 values)

| Value | Meaning |
|---|---|
| `resolved` | Need met, no human involved |
| `lead_captured` | Contact details captured |
| `demo_requested` | Demo / meeting requested |
| `escalated` | Handed off to a human |
| `abandoned` | User left before resolution |
| `no_intent` | No discernible request |
| `spam` | Spam / abuse |
| `unresolved` | Real request, not resolved, not escalated |

### `sentiment`

`positive` · `neutral` · `negative` · `mixed` (+ `sentiment_score` −1..1)

### `handoff_reason`

`low_confidence` · `user_requested_human` · `out_of_scope` · `angry_user` · `pricing_authority` · `technical_bug` · `after_hours` · `other`

### `answered_by`

`human` · `voicemail` · `ivr` · `silence` · `unknown` · `null`

Set by rule before any model call (since 2026-09-10). `voicemail` and `ivr` occur on phone conversations only; `silence` = no user turn, one utterance looped, or under 40 characters of real words; `unknown` = a summary written before the classifier existed; `null` while the summary is pending. Not the same enum as the call object's `answered_by` (`machine` there is the dialler's detection; `ivr` here is the transcript's).

### `summary_model`

`uirix-summary-v1@<model>` when a model wrote the summary · `uirix-summary-v1@rules` when it was written by rule (no model call).

### `summary_status`

`pending` · `ready` · `failed`

## Identity

### `email_source`

`in_conversation` · `lead_form` · `authenticated` · `crm_prefill`

### `utm` keys (always present)

`utm_source` · `utm_medium` · `utm_campaign` · `utm_term` · `utm_content` · `gclid` · `fbclid` · `li_fat_id` · `msclkid`

## Accounts & credentials

### Account / user `status`

`active` · `suspended` · `trial`

### Credential `environment`

`live` → `meta.environment` is `production`

### Credential `status`

`active` · `revoked`

### `credential_type` (audit)

`master` · `key` · `none`

## Agents & knowledge base

### Prompt `type`

`incoming` (phone) · `chat` (widget / API chat) · `voice` (widget voice)

### Agent language

`en` · `he` · `ar` · `es` · `fr` · `de` · `it` · `pt` · `ru` · `zh` · `ja` · `ko` · `hi` · `tr` · `nl` · `pl` · `sv`

### Agent build (`POST /accounts/{acc}/agents/build`)

- `status`: `building` · `done` · `error` — and `none` on `GET` when the process holds no state (after a restart, or an hour after the end)
- `step` / keys of `steps`: `create` · `crawl` · `knowledge` · `greeting` · `instructions` · `ready`
- a step's `status`: `pending` · `running` · `done` · `failed` · `skipped`
- `voice.gender`: `female` · `male` — `provider`: `gemini` (default) · `openai` · `gpt_live` (GPT-Live-1, offered only where the platform has it enabled — `GET /voices?provider=gpt_live` is `400` otherwise)
- `main_role`: `secretary` (default) · `customer_service` · `technical_support` · `sales` · `general` · `general_consultant` · `custom`
- `addons`: `customer_support` · `orders` · `scheduling`
- `from_step` (re-run): `crawl` · `knowledge` · `greeting` · `instructions`

### KB document `kind`

`file` · `text` · `page`

### KB document `status`

`processing` · `indexed` · `failed`

### KB status

- `crawl.status`: `idle` · `running` · `failed` — process-local; `idle` after a restart even if a crawl ran
- `summary.status`: `ready` · `out_of_date` · `not_built` · `absent` — describes the summary, never a running job. See [Agents › Knowledge base](agents.md#what-the-agent-actually-reads)
- `vector_sync`: always `null` (the vector-store sync was retired in September 2026); there is no `vector_sync.status`

## Calls

### Call `status`

`queued` · `scheduled` · `ringing` · `in_progress` · `completed` · `no_answer` · `busy` · `voicemail` · `failed` · `cancelled`

### `answered_by`

`human` · `voicemail` · `machine` · `unknown` · `null`

### `policy.voicemail`

`hang_up` (v1). `leave_message` is rejected with `400 unsupported_policy`.

### DNC `source`

`api` · `agent_request` · `import`

## Deletion

### Tombstone `reason`

`user_request` · `retention` · `account_closed`

## Webhooks

### Events

`conversation.started` · `conversation.ended` · `conversation.escalated` · `summary.ready` · `conversation.deleted` · `call.completed` · `call.failed` · `call.no_answer` · `ping` (test only, not subscribable)

### Delivery `status`

`pending` · `delivered` · `failed` · `replaying`

### Endpoint `disabled_reason`

`consecutive_failures` · `user` · `null`

## Recording

### `channels`

`dual` · `mono`

### `format`

`mp3`

## Health

### `status` / component values

`ok` · `degraded` · `down`

## Scopes

Read: `accounts:read` · `agents:read` · `kb:read` · `conversations:read` · `conversations:backfill` · `summaries:read` · `audio:read` · `metrics:read` · `usage:read` · `calls:read` · `dnc:read`

Write, grantable: `accounts:write` · `agents:write` · `kb:write` · `conversations:write` · `calls:write` · `dnc:write` · `webhooks:manage` · `credentials:manage`

There is no deletion scope. Since 2026-09-06 (Nir) nothing is deleted through the API — the data `DELETE` routes are gone and `conversations:delete` no longer exists (`agents:admin` is grantable, user-level). Every listed scope is grantable except the three master-only ones.

Master-only: `admin:all_accounts` · `users:manage` · `audit:read`

See [Scopes](scopes.md) for what each unlocks.

## Metrics

### `granularity`

`day` · `week` · `month`

### `group_by` dimensions

`date` · `account_id` · `agent_id` · `agent_type` · `intent` · `outcome` · `language` · `agent_version`

### Metric names

`conversations` · `unique_visitors` · `messages` · `escalations` · `escalation_rate` · `containment_rate` · `resolved_rate` · `leads_captured` · `leads_created` · `avg_handling_time_seconds` · `median_handling_time_seconds` · `avg_agent_latency_ms` · `abandonment_rate` · `unanswered_rate` · `avg_csat`

## Outbound profiles

### Profile `category` — deliberately NOT an enum

`category` on an outbound profile is **free text**, capped at 50 characters. There is no closed set, no database constraint, and nothing in the call runtime branches on the value. Live accounts hold `sales`, `collections` and `events` alongside values like `PCI euro`, so any list published here would reject rows the product itself created.

Do not write a client that expects an invalid category to be rejected — it will not be. See [Profiles](profiles.md).

### `allowed_capabilities` ids

The gating list is a set of known ids, but the API does not validate it: an unknown id is stored and simply matches no tool. The current ids are listed in [Profiles](profiles.md#what-the-gating-actually-gates). **An empty array means no restriction, not no capabilities.**

## Error codes

The full catalogue with HTTP statuses is in [Conventions](conventions.md#error-catalogue).

---

**UiriX Public API v1** · [Overview](README.md) · [Quickstart](quickstart.md) · [Authentication](authentication.md) · [Scopes](scopes.md) · [Conventions](conventions.md) · [Accounts & Users](accounts-and-users.md) · [Agents](agents.md) · [Capabilities](capabilities.md) · [Profiles](profiles.md) · [Conversations](conversations.md) · [Chat via API](chat-via-api.md) · [Widget Events](widget-events.md) · [Webhooks](webhooks.md) · [Calls](calls.md) · [Metrics](metrics.md) · [Voices](voices.md) · [Enums](enums.md) · [Partners](partners.md) · [Admin](admin.md) · [MCP](mcp.md) · [Changelog](changelog.md) · [OpenAPI](openapi.yaml)


<!-- ===== IMPLEMENTATION_STATUS.md ===== -->

# UiriX Public API v1 — implementation status

**Last updated:** 2026-09-30 · **Base URL (dev):** `https://dev-dashboard.uirix.com/api/ext/v1` · Health: `GET /health`

All four phases are implemented and running on the dev server. Commit `6e6377d4c` (not pushed) holds the work up to the early hours of 2026-09-06; everything after it is live on dev and uncommitted by the product owner's instruction. All four dev processes were restarted after the last config change.

## 2026-09-30 — the operator's management surface (`/admin/*`, master key only)

| Requirement | Where | State |
|---|---|---|
| Run the platform through the API with the master key — a person with curl or an AI operator: KPIs, customer search and dossier, every agent across accounts, system status / errors / config / migrations | `ext-api/routers/admin.ts`; `ext-api/services/adminReadService.ts`, `adminSystemService.ts`; rate class `admin` (120/min) in `middleware/rateLimit.ts` | done + integration-tested (`ext-api.admin.test.ts`, 21 tests) |
| Journaled writes: wallet credit (idempotent), plan change (`409 card_required`), suspend / reactivate, login link, credential edit, model config (backup + reload + `restart_required`), agent actions | `ext-api/services/adminWriteService.ts`, `adminJournalService.ts`; ext-api migration `012_admin_actions` (applied on dev) | done |
| Nothing deleted, every write `confirm: true`, no secret ever returned | `400 confirmation_required`; `ext_admin_actions` before / after / reason; `scrubSecrets` on log lines, health bodies and the config snapshot; env values from an allow-list of names | done |
| Feature flags stay a manual `.env` change | `GET /admin/config → flags` reports them; the procedure (who reads which flag, what to restart) is in [admin.md](admin.md) | done (docs) |

## 2026-09-27 → 29 — EqualWeb integration pass (partner members, demo, media, defaults, leads)

| Requirement | Where | State |
|---|---|---|
| Hear every voice before choosing it | `sample_url` on `GET /voices`, `GET /voices/{voice_id}/sample` (JSON; `download=true` streams the MP3) — `services/voiceSampleService.ts`, files in `CDN/shared/samples` | done |
| Videos for the Gemini voices too | `video_talking_url` / `video_quiet_url` on every voice, `video_url` falls back to the talking clip — `ext-api/routers/voices.ts`, clips in `CDN/avatars/voices/<Name>/` | done |
| A demo page the customer clicks after the build | `GET /agents/{agt}/demo` → `demo_url` (+ `demo_url` on partner members); `/demo/{token}` renders the site screenshot with the real widget from the SAVED settings — `services/agentDemoService.ts`, `siteSnapshotService.ts`, `pages/AgentDemo.tsx`, `utils/widgetLoaderConfig.ts` | done |
| "Sign in with EqualWeb" (NLS-3574) | OAuth 2.0 authorization code + PKCE, uiriX as client — `services/equalwebOAuthService.ts`, `controllers/equalwebAuthController.ts`, `EQUALWEB_*` env; the button appears only when configured | done — waits for EqualWeb's callback registration and DNS |
| Member defaults: Gemini, ten agents, microphone on, AI View left, launcher bottom-left, voice photo as the icon | `POST /partners/members`, migrations 010–014, the four widget INSERT paths | done |
| API-built agents: Product Descriptions in the focus, no Send-SMS task, Contact-me → the account's e-mail, short description | `API_BUILD_OVERLAY` (`ext-api/services/agentBuildService.ts`) + `V4SetupOverlay` (`services/v4SetupAgentService.ts`); the greeting step drives `generateDescription` (`routes/v4.ts`) | done |
| Crawl of a site whose apex host redirects every path to www | `services/crawlerService.ts`: the soft-404 probe ignores redirected answers, the start page is never a soft-404, the crawl moves to the real origin; the build error carries the crawler's reasons; the crawl runs in the agent's language | done |
| The voice widget opens in the agent's language | `services/publicVoiceGreeting.ts` fallback + migration 012 | done |
| Live captions in the voice widget (accessibility) | `CDN/voice-landing.html`: fixed four-line box under the sound wave, word-by-word at a speaking pace, direction by language, OpenAI + Gemini | done |
| "Leads captured" for a partner dashboard | `leads_created` on `GET /metrics/conversations` (successful `create_lead` actions, immediate) next to `leads_captured`; idle widget/API chats (30 min quiet) are summarised too | done |
| Languages | Nothing limits the API to fewer than the 17 platform languages; `language` on `POST /partners/members` documents the 17 codes and the region-suffix rule | done (docs only) |

Legacy migrations for this pass: `011_widget_button_voice_avatar`, `012_public_voice_greeting_into_build_output`, `013_widget_defaults_mic_on_ai_view_left`, `014_widget_default_position_bottom_left` (ledger `schema_migrations`, `npm run migrate:legacy`; see DOCS/DATABASE_MIGRATIONS.md and DOCS/DEPLOYMENT_NOTES_2026_09.md §7).

## 2026-09-25 — conversation import (EqualWeb request T-20260924-98)

| Requirement | Where | State |
|---|---|---|
| Import a finished external call into the call log so the inbound agent knows about it | `POST /conversations/import` — `ext-api/services/conversationImportService.ts` (+ router); one `usage_logs` row (`outbound_manual` / `twilio_incoming_call`, local-clock timestamps like 3002/3003, `metadata.source = 'import'`), identity in `conversation_identity` store U, idempotent on `external_session_id` (`409 import_exists`), no cost / webhook / usage | done + integration-tested (`ext-api.conversationImport.test.ts`) |
| The imported call in the inbound "last calls" context | 3002 `services/callbackContext.ts`: last **3** calls (was 1), imported rows in either direction, whole imported summary (≤ 300 chars), block ≤ 700 chars; unit + integration tests | done |
| Visible in the dashboard, marked | Calls list: `imported` flag on `GET /api/usage/sessions/:agentId`, **Imported** badge (17 locales); excluded from the usage stats | done |
| `GET /conversations` → `channel_detail.source = "import"` | plus `origin = "import"`, `channel_detail.import`, `origin` list filter | done |

## 2026-09-05 — "finish the API" pass

What Nir asked for, and where each item landed. Full consumer-facing detail in [changelog.md](changelog.md).

| Requirement | Where | State |
|---|---|---|
| Update the agent's instructions on the 3 surfaces (phone, chat, voice chat) **and have it take effect** | `ext-api/services/promptSurfaces.ts` (`syncBuildOutputPrompt`), `agentUpdateService.applyToRuntime`, `promptVersionService.deploy({syncBuildOutput})`; widget-voice fallback in `controllers/realtimeController.ts` (both provider branches) | done + tested (`ext-api.agentRuntime.test.ts`) |
| Read and update the KB summary | `GET|PATCH /agents/{agt}/kb/summary` — existed since 09-04 undocumented; now upserts (201 on create), documented, in OpenAPI, tested | done |
| Update all capabilities incl. Improve Agent | `capabilities` validated (`capabilityCatalogService.ts`), `GET /capabilities`; `*_refinement_rules` APPLIED (dashboard header, byte-identical) and versioned | done + tested |
| Profiles: every field of the card writable incl. Improve, per-type capabilities | `routers/outboundProfiles.ts` rewrite: `category` enum, caps/focuses validated per type, `tone`/`behavior`/`focuses`/`refinement_rules` writable; `GET /outbound-capabilities` | done + tested (`ext-api.profiles.build.test.ts`) |
| Profile build ("Rebuild with AI") | `POST|GET /profiles/{prf}/build`, async, `setProfileBuilder` seam, `409 profile_build_in_progress` | done + tested with a stubbed builder — **not yet run against the real model from the API** |
| Agent build from a website (V4 wizard server-side) | `POST /accounts/{acc}/agents/build`, `GET|POST /agents/{agt}/build`, async step runner (`setAgentBuildRunner` seam), `409 agent_build_in_progress`; create step = `services/v4SetupAgentService.setupV4Agent` shared with `POST /api/v4/setup-agent` | done + 14 tests (real create step, stubbed background steps) — **the full chain against crawl/summary/model not yet run from the API** (CMO test, 6 Sept) |
| Outbound call with a profile OR everything in the request | `POST /calls` → `profile` inline object; migration 009 `api_calls.call_profile_json`; 3003 `callProfileLoader.ts` + `applyInlineCallProfile` in both OpenAI connect paths and the Gemini path; tone block for API-placed calls (`shared/services/outboundCallProfile.ts`) | done + tested at the API (`ext-api.phase3.test.ts`, mocked dialer) — **the 3003 side has not been exercised on a real call** |
| Docs: inbound vs outbound clearly separated, behaviour explained | `README.md` restructured, `agents.md` (surface matrix, "Which prompt each surface runs", "Improve agent", summary read/write), new `capabilities.md`, `profiles.md` rewritten, `calls.md` (inline profile, 7-rung ladder), `enums.md`, `conventions.md`, `scopes.md`, `README.he.md`, OpenAPI rebuilt (62 paths, 111 schemas) | done |

Product-behaviour changes outside the API that this pass made, for the owner to know:

- **Widget voice** now falls back to `public_voice_instructions`, then `instructions`, when an agent has no `public_voice_build_output`. Before, such an agent (one created blank through `POST /accounts/{acc}/agents`, or by the legacy V3 chat builder — never a V4-wizard agent, whose build writes the compiled copy) ran the literal `You are a helpful assistant.` No agent with a build output changes.
- **API-placed outbound calls** now receive a `TONE FOR THIS CALL` block from the profile's `tone_settings` (or the inline `tone`). Campaign calls are byte-for-byte unchanged (same `api_call_id` marker as `override_voice`).
- `agentReadService` now reads `capabilities-unified.json` (the file the UI and runtimes use) instead of the stale `capabilities-unified-complete.json`; `lead_capture.fields` is populated for `create_lead` as a result.

Known gap left deliberately untouched: the **dashboard's** own instruction editor (`PUT /api/agents/:id/instructions`, the agent's Instructions tab) still writes only the raw column, so an edit there does not reach the widget's compiled copy — the same defect the API had. Fixing it is a dashboard change and was out of scope.

## Phase status

| Phase | Scope | State |
|---|---|---|
| 0 | Router, request-id, error envelope, auth (master + personal keys), scopes, tenant resolution, rate-limit classes, audit log, health, OpenAPI + docs, migration runner | done |
| 1 | Users, credentials with rotation, accounts, agents CRUD, prompt versions, knowledge base, widget, voice, unified conversation read, chat via API | done |
| 2 | Webhook endpoints and secrets, outbox, dispatcher with retries and dead-letter and replay, watermark pollers, structured summaries, metrics, agent versions | done |
| 3 | Outbound calls, DNC, recordings with signed urls, redaction, deletion and tombstones, retention | done |
| 3 | Sandbox (built, **not documented and not configured** — withheld 2026-09-06 until tested; see `_internal/sandbox.md`) | built, unpublished |

## Migrations applied to the dev database

| File | Contents |
|---|---|
| `001_ext_api_core.sql` | `api_keys`, `api_audit_log`, `ext_account_settings`, `ext_api_migrations` |
| `002_conversation_columns_and_indexes.sql` | Version, summary, soft-delete and emission columns on the three conversation stores; `public_conversations.channel`; indexes; the `vw_ext_conversations` view |
| `003_identity_versions.sql` | `conversation_identity`, agent version columns, `agent_prompt_versions` (179 seeded rows) |
| `004_webhooks.sql` | `webhook_endpoints`, `webhook_outbox`, `webhook_deliveries`, `ext_watermarks` |
| `005_calls_dnc.sql` | `dnc_numbers`, `api_calls`, `outbound_jobs.is_api_hidden` |
| `006_tombstones_sandbox.sql` | `conversation_tombstones` |
| `007_call_profile_record.sql` | `usage_logs.profile_id`, `usage_logs.profile_resolution` + filtered index |
| `008_view_call_profile.sql` | `vw_ext_conversations` recreated with both columns |
| `009_call_inline_profile.sql` | `api_calls.call_profile_json` — the inline profile of a `POST /calls` |
| `010_api_quotas.sql` | Six quota columns on `ext_account_settings` (`max_agent_creates_per_day`, `max_builds_per_day`, `max_kb_documents_per_day`, `max_chat_messages_per_day`, `max_webhook_tests_per_day`, `max_concurrent_builds`) + the new `ext_account_usage_daily` counter table. Full production list: [production-schema.md](production-schema.md) |
| `011_view_activity_model.sql` | `vw_ext_conversations` recreated with `last_activity_at`, `answered_by` and the chat `model` fallback (`public_sessions.metadata.$.model`) — the read service joins only the view |

Re-running the runner reports every file as already applied. Each file is also idempotent at the SQL level, verified by clearing the ledger rows and re-running against an already-migrated database.

## Verification

```
2026-09-05
dashboard/backend        npx tsc --noEmit    clean
dashboard/backend        npx eslint .        0 errors (288 warnings, pre-existing style)
dashboard/backend        npx vitest run      28 files, 597 tests passed
dashboard/backend        npm run openapi:check  up to date (62 paths, 115 schemas)
webhooks/twilio-outbound npx tsc --noEmit    clean
(admin/backend, webhooks/twilio, dashboard/frontend: untouched on 2026-09-05; clean on 2026-09-04)
```

Every route answers 401 with the standard envelope when unauthenticated, which confirms it is mounted and gated.

## Database constraints this implementation had to work around

These are properties of the live database, not choices. They will bite anyone extending the API.

- **Compatibility level 100** on a SQL Server 2019 instance. `OPENJSON` (needs 130) and `PERCENTILE_CONT` (needs 110) do not exist. `JSON_VALUE`, `ISJSON` and `JSON_MODIFY` do. Turn counts use `DATALENGTH` arithmetic over the `{"role":` marker, validated against 400 real transcripts with zero mismatches; medians use `ROW_NUMBER()` with `COUNT(*) OVER (...)`.
- **Mixed timestamps in `usage_logs`.** Phone rows are written with `GETDATE()` in Asia/Jerusalem; everything else is UTC. The view converts per session type, so always read through it.
- **Balance triggers.** `trg_usage_logs_update_balance` bills when `ended_at` goes from null to a value; `trg_public_sessions_update_balance` bills on an increase in `billing_cost_usd`. No new trigger was added to `usage_logs`, `public_sessions` or `users`, and no update path touches those columns incidentally. The idle sweep therefore emits the abandoned event without setting `is_active = 0`; status is derived at read time anyway.
- **`usage_logs.session_type = 'chat'` duplicates the dashboard conversation store** and is excluded from the view.
- **Roughly 1,059 of 1,751 `usage_logs` rows are internal platform operations** (config, knowledge, build, summary, assistant) whose transcripts contain UiriX system prompts. `GET /conversations` excludes them with a hard allow-list and no override flag: exposing them would be a prompt leak.
- **`usage_logs.agent_id` is a NO_ACTION foreign key** and blocks tenant deletion until the rows are cleared.
- **`outbound_queued_calls` has a unique constraint on `(job_id, phone_number)`**, so a second call to the same number recycles a terminal queue row.

## Open decisions for the product owner

1. ~~`publicWidget.model` is not the model actually called~~ — **resolved 2026-09-06**: the engine calls the tier's `publicWidget.model` (gpt-5.6-luna / gpt-5.6-terra) through the Responses API and prices by it; `chat.model` is the dashboard Chat tab only.
2. **`external_session_id` has no column** and is stored inside the identity `custom` object.
3. **Metrics exclude soft-deleted conversations**, which contradicts one sentence in the metrics document but is required for totals to equal a paged count over the same window.
4. **The retention sweep is opt-in** (`EXT_RETENTION_SWEEP=on`). It deletes irreversibly and unattended for every account that has a settings row.
5. **The recording disclosure is prompt-guided, not TwiML-enforced.** A hard guarantee needs a change in the prompt builders.
6. **The deferred dashboard "API keys" page is no longer deferred — it shipped.** `Profile → API keys` (`/profile?tab=apikeys`, plus an entry in the header user menu) lets a customer mint its own first key, so the master key is no longer the only way to onboard a tenant. It is `GET|POST /api/api-keys`, `POST /api/api-keys/{id}/rotate` and `DELETE /api/api-keys/{id}` on the dashboard JWT, delegating to the same `ext-api/services/credentialService`; it issues for `req.userId` only (ids in the body are ignored, not validated), refuses the three master-only scopes, caps expiry at 365 days, and answers 404 on another tenant's key. Tests: `src/__tests__/apiKeys.test.ts` (18), including a key issued through the page authenticating against `GET /api/ext/v1/accounts`.

   The open question was **which scopes a customer should be able to self-grant**. It is now **partly settled — product ruling, 2026-09-04, amended 2026-09-06 (Nir): `agents:admin` is grantable again, user-level; only `conversations:delete` stays master-only**. Original text of the ruling: creating or deleting an agent, and deleting a conversation, happen only in the browser by the account owner; the API may read and update but must never be the thing that destroys an agent or a customer conversation. So both were made non-grantable at the time; the 2026-09-06 amendment restored `agents:admin` (it now guards only the V4 build route — an agent cannot be deleted through the API at all), and on the afternoon of 2026-09-06 every data `DELETE` route was removed outright (conversations, subjects, profiles, KB documents, DNC, webhook endpoints) and `conversations:delete` left the catalogue. It no longer exists and the routes still guard on them, because the master key satisfies every data scope through `admin:all_accounts` once a request is pinned to an account — the capability still exists for the operator, it just cannot be handed to a customer key. Implemented as `NON_GRANTABLE_WRITE_SCOPES` in `ext-api/middleware/scopes.ts`, with `GRANTABLE_SCOPES = READ_SCOPES + WRITE_SCOPES` (`NON_GRANTABLE_WRITE_SCOPES` is empty); both grant paths (`credentialService.validateScopes` for the API, `apiKeysController.normaliseScopes` for the dashboard create **and** edit) read that one list, so there is no second catalogue to keep in step. Keys that held either scope were stripped in September; `agents:admin` was re-granted by hand to the CMO key on 2026-09-06. Tests: `ext-api.phase1.agents.test.ts` (`describe('non-grantable write scopes')`, 4 cases) and `ext-api.phase3.test.ts` (`describe('non-grantable conversations:delete')`, 3 cases); the create/delete/erase routes themselves are still covered, now driven by the master key.

   **Still open**, and untouched by the ruling: whether `conversations:write`, `credentials:manage` and the remaining write scopes should be self-grantable. Two follow-ups the ruling exposed, both outside the ruling's own files: `GET /api/api-keys` returns `catalog.write_scopes: [...WRITE_SCOPES]`, which now advertises exactly the grantable set; and `ALL_SCOPES` is derived from `GRANTABLE_SCOPES`, so `isScope('conversations:delete')` answers `false` — correct, it is not a scope — harmless today because nothing outside `scopes.ts` reads either symbol.

## Redaction coverage

Luhn-at-ingestion is wired into the three transcript paths in `OutboundCallManager`, into all three writers in `publicSessionService` (`appendMessage`, `setTranscript`, `updateVoiceTranscript` — `appendMessage` alone covers the widget, chat via the API and `chatTurnService`), into both transcript branches of the inbound `mediaStream.ts`, and into `chatController` for the dashboard chat tab and its voice variant (`messages` rows plus the `sessionManager` / direct-write transcripts). What is still uncovered, and is the whole of what is left: `geminiMediaStream.ts` (inbound Gemini), `GeminiOutboundWebSocketHandler.ts` (outbound Gemini) and `OutboundWebSocketHandler.ts` (the outbound OpenAI **websocket** handler, a fourth writer that the three in `OutboundCallManager` do not cover). Read-time `?redact=true` masks regardless of ingestion.

The detector itself is deliberately narrow, because a mask cannot be undone: a run is a card only when it is written like one (one kind of separator, groups of 3-6 digits), opens with a real issuer prefix at a length that issuer uses, **and** passes Luhn. Luhn alone accepts one in ten arbitrary sixteen-digit runs, which would have turned order numbers and the tail of a spaced Israeli IBAN into `[CARD]`. Cards are masked before the row is written; the model is still handed the raw text for the turn it is answering, so the agent's answer is unaffected.

## Track detail

Each track wrote its own record: `phase1-tenants.md`, `phase1-agents.md`, `phase1-conversations.md`, `phase1-chat.md`, `phase2.md`, `phase3.md` in this directory. `openapi.yaml` is generated from `openapi.fragments/*.yaml` by `npm run openapi:build` in `dashboard/backend`; `npm run openapi:check` fails if it is stale. All 54 paths are backed by an implementation.


<!-- ===== mcp.md ===== -->

# MCP connector — manage your agents from Claude or ChatGPT

**Since 2026-09-30.** UiriX is an MCP server ([Model Context Protocol](https://modelcontextprotocol.io)). Add it to Claude — claude.ai, Claude Desktop, Claude Code — or to ChatGPT, sign in to your UiriX account once, and manage your agents in plain language: "update the instructions", "what were yesterday's leads", "add this page to the knowledge and rebuild", "enable the widget", "call this list" — or type `uirix` in the chat for a numbered menu of everything the connector can do ([The `uirix` menu](#the-uirix-menu)). Every tool is one call to the [Public API](README.md) with the permissions you approved: the same scopes, the same rate limits, the same audit log.

| Topic | Rule |
|---|---|
| Server URL, production | `https://dashboard.uirix.com/api/ext/v1/mcp` |
| Server URL, development | `https://dev-dashboard.uirix.com/api/ext/v1/mcp` — dev accounts and dev keys, for testing a client |
| Transport | Streamable HTTP, `POST` only, JSON responses, stateless. No SSE: `GET` answers `405`. |
| Sign-in | OAuth 2.1 — PKCE `S256` required, dynamic client registration — through the UiriX login and a consent screen. A personal key (`Authorization: Bearer uirix_sk_live_…`) is accepted as well. |
| Reach | **Your account only.** User-level: no partner accounts, no master key, nothing under `/admin/*`. |
| Deletes | None. Nothing is deleted through the API ([Scopes](scopes.md#deletion-is-not-an-api-capability)), so nothing is deleted through the connector; `cancel_call` cancels a call that has not been placed yet. |
| Money | `create_agent_from_website`, `rebuild_agent` and `create_call` spend money; `test_chat` is billed like a widget chat. The server instructs the assistant to ask you before running them. |
| Limits | Rate class `mcp`, **120 requests / minute** on the endpoint, on top of the API's per-class limits and daily quotas on every underlying call ([Conventions → Rate limits](conventions.md#rate-limits)). |
| Audit | Each tool call is one Public API request made with your token (the loopback carries `X-Uirix-Via: mcp/<tool>`), so validation, scopes and the [audit row](authentication.md#security-notes) are the API's own, under the connection's key. |

## Connect

### claude.ai

1. **Settings → Connectors → Add custom connector.** Name `UiriX`, URL `https://dashboard.uirix.com/api/ext/v1/mcp`. Leave the client id / secret fields empty — the server registers the client itself.
2. **Connect.** A UiriX window opens: sign in the way your account signs in (password, 2FA, Google), then the consent screen lists what Claude asks for, by group ([Scopes and consent groups](#scopes-and-consent-groups)). **Allow** returns you to Claude.
3. In a chat, switch UiriX on in the connectors / tools menu. **After connecting, type `uirix` in the chat** — the menu of your agents and what can be done with each ([The `uirix` menu](#the-uirix-menu)) — or just ask.

Custom connectors need a paid Claude plan until UiriX is listed in the Claude connector directory.

### Claude Desktop

The same connector: a connector added on claude.ai is available in Claude Desktop under the same Claude account, and can be added from Desktop's Settings → Connectors in the same way.

After connecting, type `uirix` in the chat.

### Claude Code

```bash
claude mcp add --transport http uirix https://dashboard.uirix.com/api/ext/v1/mcp
```

Then, inside a session, run `/mcp`, choose `uirix` and authenticate — the browser opens the UiriX login and consent screen. Without a browser (a server, CI), use a personal key from Profile → API keys instead of OAuth — the key's own scopes apply and there is no consent screen:

```bash
claude mcp add --transport http uirix https://dashboard.uirix.com/api/ext/v1/mcp \
  --header "Authorization: Bearer uirix_sk_live_…"
```

After connecting, type `uirix` in the chat. Claude Code also exposes the server's `uirix` prompt as a slash command (`/mcp__uirix__uirix`), which opens the same menu.

### ChatGPT

Settings → Connectors → Advanced → **Developer mode** on → add an MCP server: name `UiriX`, server URL `https://dashboard.uirix.com/api/ext/v1/mcp`, authentication OAuth. ChatGPT sends you to the same UiriX login and consent screen. Tools that write are annotated as such, so ChatGPT asks for your confirmation before running each one.

After connecting, type `uirix` in the chat.

### Any MCP client

| Topic | Rule |
|---|---|
| Transport | Streamable HTTP: JSON-RPC over `POST` to the server URL, `Accept: application/json, text/event-stream`; the answer is always a complete `application/json` body. |
| Sessions | Stateless — no `Mcp-Session-Id`, a fresh server per request. `initialize` is answered; nothing has to be remembered between calls. |
| Streaming | None. `GET` (the SSE stream) and `DELETE` answer `405 Method Not Allowed`, by design: the endpoint works behind reverse proxies that buffer streams. |
| Auth | `Authorization: Bearer uirix_at_…` (OAuth) or `Bearer uirix_sk_live_…` (personal key). Without a valid token: `401` with `WWW-Authenticate: Bearer resource_metadata="https://dashboard.uirix.com/.well-known/oauth-protected-resource/api/ext/v1/mcp"` — the standard MCP cue to run the OAuth flow below. |
| Tool errors | An API error inside a tool comes back as a tool result with `isError: true` carrying the API's `error.code` and `message` (`insufficient_scope`, `validation_error`, `not_found`…), so the model can correct itself. |
| Large results | Transcripts are cut to a number of turns with a note to continue with `offset`; lists return `next_cursor`. |

## The `uirix` menu

**Since 2026-09-30.** You do not have to know what to ask for. Type `uirix` in the chat and the assistant shows a numbered menu that the UiriX server renders (the model translates and presents it; it does not improvise it): your agents first, then what can be done with the one you pick, then that section's choices — and then it asks exactly what it needs for the item you chose. The server's text is English; the assistant presents it in your language, keeps the numbers and shows agent names as they are.

| Step | What you see | What to reply |
|---|---|---|
| 1 — `uirix` | `UiriX — your agents`: a numbered list — `Aria — Acme Dental (internal: Client X Agent) · chat agent (chat, voice) · 12 documents (synced) · enabled`: the agent's name, **the business it represents** (the dashboard's *Business Name*), your private *Display Name (Internal Use)* when you set one, then type / channels, knowledge and status — and a last item, **Create a new agent from a website** | A number, an agent's name, or the business it is for |
| 2 — the agent | `Managing: Aria — Acme Dental (agt_245)`, a status line (internal name, channels, language, documents, enabled) and the **main menu** — the eight sections below plus **0 Choose another agent** | A number, or what you want in your own words |
| 3 — the section | The section's numbered sub-menu plus **0 Back** | A number or free text |
| 4 — the item | The assistant asks only for what the item needs (a page address, a message, a number to call…), does it, reports what changed, and offers `0` back · `uirix` menu | — |

Item wording as returned by the server; the running server's menu is authoritative.

| # | Section | What it offers |
|---|---|---|
| 1 | **Train the agent** | 1 Show what the agent is (instructions, capabilities incl. custom ones, knowledge summary, age and conversation count) · 2 Train with test questions — your list or ~10 written from the agent's knowledge, each run in its own test chat, a question / answer / verdict table, fixes routed by type (a fact → knowledge summary + a text document, a behaviour → a refinement rule), re-run of the misses; works for a new agent with no conversations · 3 Improve from recent conversations (checks first that there are any) · 4 Fix one thing — a rule ("do not say hello") or a fact ("the exact address is …") · 5 Change the tone or one of the four greetings (inbound phone `greeting`, `outbound_greeting`, Website Chat `public_chat_greeting`, Voice Chat `public_voice_greeting`) — current wording shown first, written in the agent's language, confirmed before saving · 6 Rewrite the instructions for one channel · 7 Restore a previous version |
| 2 | **Enrich knowledge** | 1 Add a web page or a whole site (crawled, then re-indexed) · 2 Add text (paste) · 3 What does the agent know? — the documents and the knowledge summary · 4 Correct the knowledge summary · 5 Re-index now |
| 3 | **Channels & settings** | 1 Website widget (on / off, domains, position, colours) · 2 Voice (choose from the catalogue) · 3 Language · 4 Demo link |
| 4 | **Conversations & insights** | 1 Today / this week · 2 Read one conversation · 3 Numbers (leads, escalations, containment) · 4 Daily report · 5 What it costs — per month, agent and channel (website text chat vs voice chat, phone in / out, chat via the API, platform AI operations), amounts in USD, number of calls |
| 5 | **Test the agent** | Asks for your message(s) — up to 5 — and runs a trial chat with the agent; billed like a widget chat |
| 6 | **Outbound calls** | 1 Profiles (list / create / update / build) · 2 Place a call (number + profile; confirmed first) · 3 Call log / status / cancel · 4 Do-not-call list · 5 Reach a person for me — the agent calls, handles a receptionist / phone menu / voicemail (leaves your message) and you get the outcome in the chat; optionally it transfers the call to your own number. Each call is placed with a profile built inline for that call (opening line, instructions, voicemail message, completions) and its gating (`allowed_capabilities`) set explicitly — never a stored profile · **6 Call a list or a spreadsheet (bulk campaign)** — attach or paste an Excel / CSV / a list of people and the assistant reads it itself, tells you what it found (rows, columns, phone and name columns, bad numbers, duplicates, countries, extra columns) and then walks you through it in order: **calling days and local hours** (every person is called only inside the window in *their own* time zone), dates, attempts and pace; a **stored profile** for the campaign — reuse one or build one together (category, focuses, capabilities and what each needs, a connected calendar checked first with `list_connectors`, tone, opening line, voice, voicemail message; the gating is set explicitly); a **pre-flight** of the plan limits and the balance; importing the list (`create_list` → `import_list_rows` in batches of up to 1000, with the counts of imported / duplicates / invalid / do-not-call / time zones reported); a **draft campaign** shown to you with its summary — and only after your explicit yes the campaign starts (real calls, costs money); then monitoring · **7 Campaigns: status, pause, stop, results** — progress and report, one row per contact, transcripts of the calls that were answered, pause (reversible) and stop (final). Needs the **Outbound calls** consent group, off by default (the reads of items 6–7 need only the always-on Read group) |
| 7 | **Webhooks & integrations** | 1 Endpoints · 2 Test an endpoint · 3 Deliveries / replay |
| 8 | **Rebuild from knowledge** (costs money) | Shows the build status, then regenerates the instructions from the knowledge — only after your explicit confirmation; it overwrites instructions edited by hand |
| 0 | **Choose another agent** | Back to the agents list |

**Create a new agent from a website** — the last item of the agents list — asks for the site address, the language and the voice, confirms the cost, and builds the agent the way the dashboard wizard does.

| Rule | Detail |
|---|---|
| Numbers or words | Reply `2`, or "improve it from the conversations", or "add https://example.com/pricing to the knowledge" — free text is matched to the item, and a request that is already clear is done without the menu |
| Any language | `uirix` works in any case and any language (Hebrew "יואיריקס" included); the menu comes back in your language, numbers stay, agent names stay as they are. A greeting or "what can you do?" opens the menu too |
| `uirix` | Back to the menu at any time. The agent you were managing stays selected until you choose another |
| `0` | One level up: from a sub-menu to the main menu, from the main menu to the agents list |
| Money | Anything that spends money is confirmed with you first — creating an agent from a website, rebuilding from knowledge, placing a call, **starting a campaign** — and the tools behind those items refuse to run without that confirmation (`confirmed: true`). Creating a campaign is a draft and dials nothing. A trial chat is billed like a widget chat; the assistant says so |
| Nothing is deleted | The menu has no delete items; "cancel" in the call log cancels a call that has not been placed yet, and a campaign ends with **stop** (its contacts and results stay readable) |
| No tool names | The menu names actions, not tools; which tool sits behind each item is in the [tool table](#tools) and in the Skill `DOCS/mcp/uirix-agent-manager/SKILL.md` |

Clients with a prompt list also get the prompt `uirix` — "Show me the UiriX menu" — as a shortcut ([Prompts](#prompts)).

## Sign-in: the OAuth 2.1 server

For integrators and anyone writing a client. Claude and ChatGPT do all of this on their own.

```
1. GET  https://dashboard.uirix.com/.well-known/oauth-protected-resource/api/ext/v1/mcp   → which authorization server
2. GET  https://dashboard.uirix.com/.well-known/oauth-authorization-server               → its endpoints and capabilities
3. POST /api/ext/v1/oauth/register                                                        → client_id (dynamic registration)
4. GET  /api/ext/v1/oauth/authorize?response_type=code&client_id=…&redirect_uri=…&code_challenge=…&code_challenge_method=S256&state=…&scope=…
        → 302  https://dashboard.uirix.com/connect/authorize?request=<id>   (login if needed, then the consent screen)
        → 302  <redirect_uri>?code=…&state=…                                (or ?error=access_denied&state=… on Deny)
5. POST /api/ext/v1/oauth/token   grant_type=authorization_code + code + code_verifier      → access token + refresh token
6. POST /api/ext/v1/mcp           Authorization: Bearer uirix_at_…
```

### Discovery

| URL | Serves |
|---|---|
| `https://dashboard.uirix.com/.well-known/oauth-protected-resource/api/ext/v1/mcp` | Protected-resource metadata (RFC 9728): the resource, its authorization server, the scopes it understands. Also served at `/.well-known/oauth-protected-resource`. |
| `https://dashboard.uirix.com/.well-known/oauth-authorization-server` | Authorization-server metadata (RFC 8414): the four endpoints below, `code_challenge_methods_supported: ["S256"]`, grant and response types, `scopes_supported`. Also served at `/.well-known/oauth-authorization-server/api/ext/v1/oauth`. |

Both live on the dashboard host root (not under `/api/ext/v1`) and need no credential. On dev, the same paths on `https://dev-dashboard.uirix.com`.

### Endpoints

| Endpoint | Purpose | Notes |
|---|---|---|
| `POST /api/ext/v1/oauth/register` | Dynamic client registration (RFC 7591) | Public clients only: `token_endpoint_auth_method: "none"`, no client secret. `redirect_uris` must be `https://…` URLs or `localhost`. `client_name` is what the consent screen shows and what names the key the connection creates. Answers the `client_id` with the registered metadata. |
| `GET /api/ext/v1/oauth/authorize` | Authorization request | `response_type=code`, `client_id`, `redirect_uri` (one of the registered ones), `code_challenge` + `code_challenge_method=S256` (**required** — requests without PKCE, or with `plain`, are refused), `state`, `scope` (space-separated, see below), optional `resource`. Redirects the browser to the dashboard's consent page; the request is valid for 10 minutes. |
| `POST /api/ext/v1/oauth/token` | Token endpoint | `grant_type=authorization_code` with `code`, `code_verifier`, `redirect_uri`, `client_id` — or `grant_type=refresh_token` with `refresh_token`, `client_id`. Answers `access_token`, `token_type: "bearer"`, `expires_in`, `refresh_token`, `scope`. A code is single-use and valid for 5 minutes. |
| `POST /api/ext/v1/oauth/revoke` | Revocation (RFC 7009) | `token` (access or refresh) and `client_id`. |

### Tokens

| Token | Shape | Lifetime | Notes |
|---|---|---|---|
| Access | `uirix_at_` + 43 base64url characters | 1 hour | Sent as `Authorization: Bearer …`. Resolves to the personal key the connection created, so it is accepted wherever that key is — the MCP endpoint and every Public API endpoint — with that key's scopes and account. Hashed at rest (SHA-256), like keys. |
| Refresh | `uirix_rt_` + 43 | 90 days | **Rotating:** every refresh answers a new refresh token and retires the old one. Re-using a retired one within 60 seconds is tolerated (a network retry); later re-use is treated as theft and revokes the whole family — the client has to sign in again. |
| Authorization code | opaque | 5 minutes, single use | Bound to the client, the `redirect_uri` and the `code_challenge`. |

Lifetimes are server settings (`OAUTH_ACCESS_TOKEN_TTL_SECONDS`, `OAUTH_REFRESH_TOKEN_TTL_DAYS` in the root `.env`; the values above are the defaults). An expired access token answers `401 expired_token` (with the `WWW-Authenticate` challenge on `/mcp`) — refresh it; a revoked or unknown token answers `401 invalid_credentials` — sign in again. Expired rows are swept daily.

### What a connection creates

Approving the consent screen creates a **personal API key** on your account, named `<client name> connector` (for Claude: `Claude connector`), with exactly the scopes you approved. It is listed on **Profile → API keys** next to the keys you made yourself (`last_used_at` moves with every tool call), and every access and refresh token of the connection is mapped to it.

- Connecting the same client again (another device, a re-authorisation) **reuses** the key and sets its scopes to the new approval; it does not create a second key.
- **Disconnect** on **Profile → Connected apps** — revokes every token of the client and the key — or revoke the key on Profile → API keys. The next tool call answers `401 invalid_credentials` and the client has to sign in again. Removing the connector in Claude or ChatGPT alone leaves the key in place until it is revoked here.
- Every audit row of the connection names that key (`credential_type: key`), so what the assistant did is traceable like any integration's calls.

The consent page and the Connected-apps card are dashboard routes, authenticated with the dashboard session, not the Public API: `GET /api/oauth/requests/{id}`, `POST /api/oauth/requests/{id}/decision`, `GET /api/oauth/connections`, `DELETE /api/oauth/connections/{client_id}`.

### Scopes and consent groups

The connector can request **user-level scopes only** — the ones in the table, shown on the consent screen in four groups:

| Consent group | Default | Scopes |
|---|---|---|
| **Read** | on, cannot be switched off | `accounts:read`, `usage:read`, `agents:read`, `kb:read`, `conversations:read`, `summaries:read`, `metrics:read`, `calls:read`, `dnc:read` |
| **Changes to agents** | on | `agents:write`, `agents:admin`, `kb:write`, `conversations:write` |
| **Outbound calls** | off | `calls:write`, `dnc:write` — `calls:write` is the one switch behind `create_call`, `cancel_call` **and the whole batch side: `create_list`, `import_list_rows`, `create_campaign`, `start_campaign`, `pause_campaign`, `stop_campaign`**. The batch reads (`list_lists`, `get_list`, `list_list_items`, `list_campaigns`, `get_campaign`, `list_campaign_calls`) need `calls:read` and `list_connectors` needs `agents:read`, both in the always-on Read group — no new scope |
| **Webhooks** | on | `webhooks:manage` |

`accounts:write` is a user-level scope no v1 tool uses; whether the screen offers it is the running server's `scopes_supported`. **Never granted** through the connector, whatever a client asks for: `admin:all_accounts` (and everything under `/admin/*`), `users:manage`, `audit:read`, `credentials:manage`, `audio:read`, `conversations:backfill` — they are not on the consent screen and not in any issued token.

A group you switch off makes the tools behind it answer `insufficient_scope`; the assistant will tell you to reconnect UiriX with that permission. A personal key used instead of OAuth carries its own scopes and sees no consent screen.

## Tools

The catalogue as specified for v1 — **60 tools: 33 read-only and 27 that write**; the running server's `tools/list` is authoritative and the source of each tool's input schema. Tools that write are annotated `readOnlyHint: false` (never `destructiveHint`), which is what makes ChatGPT confirm them; the two tools that reach the phone network (`create_call`, `start_campaign`) also carry `openWorldHint: true`. The batch tools (lists and campaigns, [Campaigns](campaigns.md)) sit in the existing **Outbound calls** group: no new scope.

| Group | Tool | What it does | API behind it | Writes | Costs money |
|---|---|---|---|---|---|
| Menu | `uirix_menu` | The server-rendered menu ([The `uirix` menu](#the-uirix-menu)): no arguments → the agents list; `agent_id` → that agent's main menu; `agent_id` + `section` → the section's sub-menu. `content.text` is for display (English, no tool names); `structuredContent` maps every item to the tools to run next, whether it needs confirmation or costs money, and what to ask the user — as returned by the server | `GET /accounts/{acc}/agents` / `GET /agents/{agt}` | no | no |
| Account | `get_account` | The account with its limits and balance | `GET /accounts/{acc}` + `…/limits` + `…/balance` | no | no |
| Account | `get_usage` | Usage and cost for a period | `GET /accounts/{acc}/usage?from&to` | no | no |
| Agents | `list_agents` | The account's agents — name, **business name**, internal display name, type, channels, versions, knowledge state | `GET /accounts/{acc}/agents` | no | no |
| Agents | `get_agent` | One agent, with its build on request | `GET /agents/{agt}` (`?include=build`) | no | no |
| Agents | `update_agent` | Allowed fields only: name, description, language, greeting, tone, main role, capabilities… | `PATCH /agents/{agt}` | yes | no |
| Agents | `create_agent_from_website` | Create an agent from a website (the V4 build) | `POST /accounts/{acc}/agents/build` | yes | **yes** — asks first |
| Agents | `get_build_status` | Build steps and result | `GET /agents/{agt}/build` | no | no |
| Agents | `rebuild_agent` | Re-run the build: regenerates the instructions from the knowledge | `POST /agents/{agt}/build` | yes | **yes** — asks first |
| Instructions | `get_prompts` | The prompts per surface | `GET /agents/{agt}/prompts` | no | no |
| Instructions | `update_prompts` | Write instructions / refinement rules per channel | `POST /agents/{agt}/prompts` | yes | no |
| Instructions | `list_prompt_versions` | The prompt versions (audit trail) | `GET /agents/{agt}/prompts`, one version `GET …/prompts/{pv}` | no | no |
| Instructions | `restore_prompt_version` | Roll back to a version | `POST /agents/{agt}/prompts/{pv}/restore` | yes | no |
| Knowledge | `list_documents` | Documents and crawled pages | `GET /agents/{agt}/kb/documents` | no | no |
| Knowledge | `add_document` | Add text or a URL (crawl) | `POST /agents/{agt}/kb/documents` | yes | no ¹ |
| Knowledge | `get_kb_status` | Summary status, pending sources, last job | `GET /agents/{agt}/kb/status` | no | no |
| Knowledge | `reindex_kb` | Rebuild the knowledge summary | `POST /agents/{agt}/kb/reindex` | yes | no ¹ |
| Knowledge | `get_kb_summary` | Read the summary | `GET /agents/{agt}/kb/summary` | no | no |
| Knowledge | `update_kb_summary` | Replace the summary text | `PATCH /agents/{agt}/kb/summary` | yes | no |
| Channels | `get_widget` | Widget settings and embed snippet | `GET /agents/{agt}/widget` | no | no |
| Channels | `update_widget` | Change widget settings (enable / disable, layout, colours…) | `PATCH /agents/{agt}/widget` | yes | no |
| Channels | `get_voice` | The agent's voice settings | `GET /agents/{agt}/voice` | no | no |
| Channels | `set_voice` | Change the voice | `PATCH /agents/{agt}/voice` | yes | no |
| Channels | `list_voices` | The voice catalogue, per provider | `GET /voices?provider` | no | no |
| Channels | `get_demo_url` | The agent's demo page | `GET /agents/{agt}/demo` | no | no |
| Conversations | `list_conversations` | Conversations with filters, summaries included | `GET /conversations?agent_id&channel&from&to&include=summary` | no | no |
| Conversations | `get_conversation` | Turns + summary + identity | `GET /conversations/{conv}` | no | no |
| Conversations | `get_metrics` | Aggregates for a period | `GET /metrics/conversations?from&to&agent_id` | no | no |
| Test | `test_chat` | A trial chat with the agent | `POST /conversations` + `…/messages` + `…/end` | yes | billed like a widget chat |
| Outbound | `list_profiles` | The agent's outbound profiles | `GET /agents/{agt}/profiles` | no | no |
| Outbound | `get_profile` | One profile | `GET /profiles/{prf}` | no | no |
| Outbound | `create_profile` | Author a profile (the answer carries `warnings` — `connector_not_connected` — when a calendar capability is granted on an agent with no connected calendar) | `POST /agents/{agt}/profiles` | yes | no |
| Outbound | `update_profile` | Change a profile (same `warnings`) | `PATCH /profiles/{prf}` | yes | no |
| Outbound | `build_profile` | The AI builder ("Rebuild with AI") | `POST /profiles/{prf}/build` | yes | no ¹ |
| Outbound | `create_call` | Place **one** call (a person or a handful at most — a list is a campaign, never a loop of this tool) | `POST /calls` | yes | **yes** — asks first; needs `calls:write` |
| Outbound | `list_calls` | Calls and their status | `GET /calls` | no | no |
| Outbound | `get_call` | One call | `GET /calls/{call}` | no | no |
| Outbound | `cancel_call` | Cancel a call not yet placed | `DELETE /calls/{call}` | yes | no |
| Outbound | `list_dnc` | The do-not-call list | `GET /dnc` | no | no |
| Outbound | `add_dnc` | Add a number to it | `POST /dnc` | yes | no |
| Outbound | `list_connectors` | Which connectors the agent has (Google Calendar, Microsoft 365 Calendar, HubSpot, HTTP APIs) and whether each is connected, plus `calendars_connected` — read it before granting or promising a calendar capability. Never returns tokens or settings. Needs `agents:read` | `GET /agents/{agt}/connectors` | no | no |
| Campaigns | `list_lists` | The agent's contact lists (name, size, created) | `GET /agents/{agt}/lists` | no | no |
| Campaigns | `get_list` | One list with its contacts per time zone (`unknown` = the zone was guessed) | `GET /lists/{lst}` | no | no |
| Campaigns | `list_list_items` | The contacts of a list, with the extra facts imported with them | `GET /lists/{lst}/items` | no | no |
| Campaigns | `create_list` | An empty contact list on an agent | `POST /agents/{agt}/lists` | yes | no |
| Campaigns | `import_list_rows` | Append up to 1000 people (phone, name, `external_reference`, `timezone`, any other column as a fact for the agent) and a `default_country`; the assistant parses the user's Excel / CSV itself and sends batches. Answers imported / duplicates / invalid (the first 50 spelled out, the count complete) / `dnc_skipped` / time zones. Re-sending a batch is safe | `POST /lists/{lst}/import` | yes | no |
| Campaigns | `list_campaigns` | The account's campaigns with status and progress, filter by agent or status | `GET /campaigns` | no | no |
| Campaigns | `get_campaign` | One campaign: status, settings, progress buckets, report (outcomes, average duration, cost so far) | `GET /campaigns/{cmp}` | no | no |
| Campaigns | `list_campaign_calls` | One row per contact (status, attempts, outcome, `conversation_id` to read the transcript with `get_conversation`) | `GET /campaigns/{cmp}/calls` | no | no |
| Campaigns | `create_campaign` | A **draft** from a list and a stored profile: `days` (0 = Sunday), `window` in the **recipient's local time**, dates, attempts, pace. Dials nothing | `POST /campaigns` | yes | no |
| Campaigns | `start_campaign` | Start or resume: places **real calls** inside each recipient's window. Refuses without `confirmed: true`; annotated `openWorldHint: true` | `POST /campaigns/{cmp}/start` | yes | **yes** — asks first; needs `calls:write` |
| Campaigns | `pause_campaign` | Pause a running campaign (reversible) | `POST /campaigns/{cmp}/pause` | yes | no |
| Campaigns | `stop_campaign` | Stop for good — calls in progress end, it cannot start again | `POST /campaigns/{cmp}/stop` | yes | no |
| Webhooks | `list_webhooks` | Endpoints | `GET /webhooks/endpoints` | no | no |
| Webhooks | `create_webhook` | Register an endpoint | `POST /webhooks/endpoints` | yes | no |
| Webhooks | `update_webhook` | Change / disable an endpoint | `PATCH /webhooks/endpoints/{whk}` | yes | no |
| Webhooks | `test_webhook` | Send a test delivery | `POST /webhooks/endpoints/{whk}/test` | yes | no ¹ |
| Webhooks | `list_deliveries` | The delivery log | `GET /webhooks/deliveries` | no | no |
| Webhooks | `replay_delivery` | Replay a delivery | `POST /webhooks/deliveries/{dlv}/replay` | yes | no ¹ |
| Reference | `list_capabilities` | Both capability catalogues | `GET /capabilities`, `GET /outbound-capabilities` | no | no |

¹ Starts a crawl, a model build or an outbound HTTP request: the API's `build` rate class (5 / min) and the account's daily quotas apply ([Conventions → Rate limits](conventions.md#rate-limits)).

Every tool needs the scope of the endpoint behind it ([Scopes](scopes.md)); a missing one is an `insufficient_scope` tool error, not an HTTP 403.

### Resources

`uirix://docs/{page}` — the documentation pages the assistant can read while it works, the same markdown as `GET /docs/pages/{page}.md`: `START_HERE`, `agents`, `capabilities`, `conversations`, `profiles`, `calls`, `campaigns`, `webhooks`.

### Prompts

Ready-made tasks a client can offer as slash commands or shortcuts. The exact wording is the running server's `prompts/list`.

| Prompt | Arguments | Task |
|---|---|---|
| `uirix` | — | "Show me the UiriX menu" — fetches the menu and presents it, the same as typing `uirix` in the chat ([The `uirix` menu](#the-uirix-menu)) |
| `review_agent` | `agent` | Review one agent end to end — knowledge status, instructions, channels, recent conversations — and propose changes |
| `daily_report` | `agent?`, `day?` | The day's conversations, outcomes and metrics for one agent or the whole account (default: yesterday) |
| `add_knowledge_from_url` | `agent`, `url` | Add the page to the knowledge base, rebuild the summary, report what changed |
| `improve_from_conversations` | `agent`, `days` | Read the last `days` of conversations and propose refinement rules or instruction changes |

## How the assistant behaves

The server sends every client the same instructions (the "skill"); the Claude Skill `DOCS/mcp/uirix-agent-manager/SKILL.md` is a short wrapper around them. What they make the assistant do:

- **Menu mode.** `uirix` (in any case or language), a greeting or "what can you do?" makes it fetch the menu from the server and present it in your language — numbers kept, agent names as they are, no tool names. A number or free text picks; after every action it offers `0` back · `uirix` menu; the agent you picked stays current until you choose another ([The `uirix` menu](#the-uirix-menu)).
- **Start by listing your agents and confirming which one you mean.** It never invents ids and never modifies an agent you did not name.
- **Build in this order:** knowledge (documents / crawl) → knowledge summary → instructions (prompts) → refinement rules → channels (widget / voice / phone) → test (`test_chat`) → go live → monitor (conversations, metrics).
- **Knows what needs what:** a knowledge change needs `reindex_kb`; a change to the summary or to the instructions applies immediately; `rebuild_agent` regenerates the instructions from the knowledge and costs money.
- **Asks before spending money:** before `rebuild_agent`, `create_agent_from_website`, `create_call` and `start_campaign`.
- **A list is a campaign, never a loop.** When you attach or paste an Excel / CSV / a list of people and ask to call them all, the assistant does not call them one by one with `create_call` (rate limit 20 a minute, 200 a day, no calling windows, no progress tracking). It follows the bulk-calling flow — the same text as the menu item *Call a list or a spreadsheet* and the section "Bulk calling" of the server instructions:
  1. **Reads the file itself** and tells you what it found — rows, columns, the phone and name columns, phone-format problems, duplicates, the countries seen (it asks for a default country when numbers carry no prefix) and the extra columns, which become facts the agent can use on the call — and asks you to confirm the columns.
  2. **Time zones, always.** Every recipient is called only inside the calling window *in their own local time* (daylight saving is handled by the platform). It asks for the days of the week and the local hours (it suggests 09:00–18:00 on weekdays, Sunday–Thursday for recipients in Israel, and never assumes), the start and end dates, the attempts per person (1–3) and the pace. Numbers whose zone had to be guessed are reported under `unknown`. For a single `create_call` the same idea is `policy.earliest_local_time` / `latest_local_time`.
  3. **A one-off custom call or a stored profile?** A campaign needs a stored profile (`profile_id`): reuse one (`list_profiles`) or build one together — the category (collections / sales / events), the focuses, the capabilities of the outbound catalogue, and for every capability the value it needs (payment link, bank details, Zoom / meeting / product / pricing / free-trial link, Waze / Google Maps, an event ticket; SMS and e-mail are built in; a transfer needs the number). A calendar meeting needs a connected calendar: the assistant calls `list_connectors` first, tells you what is connected, and never promises a booking that cannot happen (`warnings: connector_not_connected` in a profile answer means exactly that). Then the tone, the opening line, the voice, the behaviour rules and the voicemail message; `create_profile` → `build_profile` (costs money, confirmed) → `get_profile`, with `allowed_capabilities` set explicitly to what was agreed.
  4. **Pre-flight** with `get_account`: `limits.calls_remaining_today` (for one-off calls; campaign calls are not counted in `calls_today`), `limits.outbound_plan` (the monthly plan allowance) and `balance` (`can_place_calls`, `calls_blocked_reason`, `remaining`) against the number of contacts. An account that cannot place calls is told why and the flow stops.
  5. **Asks whether to import the list** — `create_list`, then `import_list_rows` in batches of at most 1000, adding up imported / duplicates / invalid / do-not-call / time zones for the report; numbers on the do-not-call list are skipped automatically and named.
  6. **Asks whether to open a campaign** with those settings — `create_campaign` (a draft, nothing is dialled), a summary (contacts, zones with counts, days, local window, dates, attempts, profile), your explicit yes, and only then `start_campaign` (real calls, costs money). A start outside a recipient's window simply waits for it.
  7. **Monitors** — `get_campaign`, `list_campaign_calls`, transcripts of interesting calls with `get_conversation`, the outcomes (reached / no answer / voicemail / failed / transferred), `pause_campaign` / `stop_campaign` on request, and acts on what was agreed in the transcripts.
- **Summarises transcripts** instead of pasting them, and answers in your language.
- **On `insufficient_scope`** it tells you to reconnect UiriX with that permission.

## Troubleshooting

| Symptom | Meaning | What to do |
|---|---|---|
| `401` with `WWW-Authenticate: Bearer resource_metadata=…` | No token, or a token the server does not know — the client must run (or re-run) OAuth | Connect / re-authenticate; in Claude Code `/mcp` → `uirix` → authenticate |
| `401 expired_token` | The access token is older than an hour | The client refreshes with its refresh token; when the refresh fails too, sign in again |
| `401 invalid_credentials` after a disconnect | The key behind the connection was revoked, or the refresh token was re-used after its rotation | Connect again |
| Tool error `insufficient_scope` | The consent did not include the group that tool needs (or the personal key lacks the scope) | Disconnect on Profile → Connected apps and connect again with that group switched on; for a personal key, edit its scopes on Profile → API keys |
| `405 Method Not Allowed` on `GET` | No SSE, by design | Nothing — Claude and ChatGPT use `POST`; a custom client must speak Streamable HTTP with JSON responses |
| `429 rate_limited` | 120 requests / minute on the endpoint, or an API class limit behind a tool | Wait for `Retry-After`; nothing was consumed |
| "The connector sees only my account" | User-level by design: one account, its own agents | Cross-account work is the Public API with a key that carries the accounts ([Authentication](authentication.md#principal-and-account-resolution)) |
| The consent page says the request is gone (`404` / `410`) | The authorization request is older than 10 minutes or was already decided | Start the connection again from the client |
| claude.ai refuses to add a custom connector | Custom connectors need a paid Claude plan until UiriX is in the connector directory | Upgrade the Claude plan |
| `uirix` gets a plain answer, no menu | UiriX is not switched on in this chat, or the client did not load its tools | Switch UiriX on in the connectors / tools menu (Claude Code: `/mcp` → `uirix` connected), then type `uirix` again — or run the `uirix` prompt, which opens the same menu |

---

**UiriX Public API v1** · [Overview](README.md) · [Quickstart](quickstart.md) · [Authentication](authentication.md) · [Scopes](scopes.md) · [Conventions](conventions.md) · [Accounts & Users](accounts-and-users.md) · [Agents](agents.md) · [Capabilities](capabilities.md) · [Profiles](profiles.md) · [Conversations](conversations.md) · [Chat via API](chat-via-api.md) · [Widget Events](widget-events.md) · [Webhooks](webhooks.md) · [Calls](calls.md) · [Campaigns](campaigns.md) · [Metrics](metrics.md) · [Voices](voices.md) · [Enums](enums.md) · [Partners](partners.md) · [Admin](admin.md) · [MCP](mcp.md) · [Changelog](changelog.md) · [OpenAPI](openapi.yaml)

## Branding and listing fields (Claude / ChatGPT)

What the server itself sends in `initialize`: `serverInfo` = name `uirix`, title `UiriX`, a `description`, `websiteUrl`, and three `icons` (512×512 PNG, 128×128 PNG, SVG) served from the same origin as the endpoint (`/uirix-icon-512.png`, `/uirix-icon-128.png`, `/favicon.svg` of the dashboard host; the dashboard also serves `/favicon.ico` and `/apple-touch-icon.png`). The first 512 characters of the server `instructions` carry the rule "type uirix → call `uirix_menu`".

What each vendor reads (researched 30 Sept 2026, see the claims' status in [MCP_CONNECTOR_PLAN_2026_10.md](../MCP_CONNECTOR_PLAN_2026_10.md)):

| | Claude (claude.ai / Desktop) | ChatGPT (developer mode connector) |
|---|---|---|
| Name | typed by the user in "Add custom connector" | typed by hand |
| Description | not shown | typed by hand |
| Logo | not documented; community reports say the favicon of the registrable domain (`uirix.com`), not `serverInfo.icons` | optional upload in the dialog (Manage → edit); `serverInfo.icons` is not used |
| From the server | tool titles and hints, `instructions` (truncated by some clients) | tool names / titles / descriptions / annotations, `instructions` (first 512 characters) |

Values to paste when the vendor asks for them: name **UiriX**; short description **Manage your UiriX AI voice and chat agents**; long description = the `serverInfo.description` text above; icon = `https://dashboard.uirix.com/uirix-icon-512.png` (upload the file for ChatGPT); website `https://uirix.com`. Directory listings (Claude directory, ChatGPT app directory) need the submission forms: name, descriptions, privacy and support URLs, a square logo, a test account — and for ChatGPT a domain-verification token at `/.well-known/openai-apps-challenge` (an IIS rule to port 3000 is needed, today only `oauth-*` is forwarded).


<!-- ===== metrics.md ===== -->

# Metrics

`GET /metrics/conversations` returns aggregates so you never have to download transcripts just to chart volume. Daily counts here equal what you would get by paginating `GET /conversations` for the same day (±0). Scope `metrics:read`; rate class `metrics` (20/min).

## `GET /metrics/conversations`

| Param | Type | Required | Notes |
|---|---|---|---|
| `from` | ISO-8601 | **yes** | Inclusive, on `started_at`. Missing → `400 missing_param` |
| `to` | ISO-8601 | **yes** | Exclusive |
| `granularity` | `day` \| `week` \| `month` | no | Default `day`. Only used when `date` is in `group_by`. `hour` → `400 unsupported_filter` |
| `group_by` | csv | no | Up to **3** of `date`, `account_id`, `agent_id`, `agent_type`, `intent`, `outcome`, `language`, `agent_version`. More than 3 or unknown → `400 unsupported_filter` |
| `account_id` | repeatable | no | Filter; default = credential's accounts |
| `agent_id` | repeatable | no | Filter |
| `agent_type` | repeatable | no | Filter |
| `language` | repeatable | no | Filter |
| `metrics` | csv | no | Subset of the 15 metrics; default all |
| `timezone` | IANA | no | Default `UTC`. Bucketing (`date`) respects it |

```bash
curl -s -H "Authorization: Bearer $SK" \
  "$B/metrics/conversations?from=2026-08-01T00:00:00Z&to=2026-08-21T00:00:00Z&group_by=date,agent_type&timezone=UTC" | jq .
```

```json
{
  "data": [
    {
      "date": "2026-08-20", "agent_type": "chat",
      "conversations": 214, "unique_visitors": 198, "messages": 1842,
      "escalations": 19, "escalation_rate": 0.0888, "containment_rate": 0.9112, "resolved_rate": 0.74,
      "leads_captured": 22, "leads_created": 24,
      "avg_handling_time_seconds": 412, "median_handling_time_seconds": 305,
      "avg_agent_latency_ms": 1780, "abandonment_rate": 0.21, "unanswered_rate": 0.061, "avg_csat": null
    },
    {
      "date": "2026-08-20", "agent_type": "phone",
      "conversations": 37, "unique_visitors": 37, "messages": 486,
      "escalations": 14, "escalation_rate": 0.3784, "containment_rate": 0.6216, "resolved_rate": 0.59,
      "leads_captured": 9, "leads_created": 9,
      "avg_handling_time_seconds": 268, "median_handling_time_seconds": 231,
      "avg_agent_latency_ms": 940, "abandonment_rate": 0.08, "unanswered_rate": 0.11, "avg_csat": 4.2
    }
  ],
  "meta": {
    "request_id": "req_01J8ZG1A2B", "environment": "production",
    "from": "2026-08-01T00:00:00Z", "to": "2026-08-21T00:00:00Z",
    "granularity": "day", "timezone": "UTC", "timezone_applied": "UTC",
    "group_by": ["date", "agent_type"]
  }
}
```

Every row carries **all 15 metric keys** (or the requested subset); a metric that cannot be computed is `null`, never omitted. Rows with zero conversations for a group are not emitted.

**Errors:** `400 missing_param` (`field: "from"` / `"to"`) · `400 invalid_date` / `invalid_date_range` · `400 unsupported_filter` (bad `group_by`, `granularity`, `metrics`) · `403 forbidden_account` / `insufficient_scope` · `429 rate_limited`.

## Metric definitions

Published once and held fixed for v1.

| Metric | Definition |
|---|---|
| `conversations` | Count of conversations with `started_at` in the bucket (all statuses) |
| `unique_visitors` | Distinct `visitor_id`; for phone, distinct `phone_e164` |
| `messages` | Total turns (user + agent + system) |
| `escalations` | Conversations with `summary.escalated_to_human.value = true` (or an escalation event) |
| `escalation_rate` | `escalations ÷ conversations` |
| `containment_rate` | **`completed` conversations with no escalation ÷ all `completed` conversations** |
| `resolved_rate` | Conversations with `summary.resolved = true` ÷ conversations with `summary_status = "ready"` |
| `leads_captured` | Conversations with `summary.outcome = "lead_captured"` — the summary's verdict, so it needs the conversation to have ended and been summarised (a few minutes later) |
| `leads_created` | Successful `create_lead` actions (the Contact-me / lead form the agent ran), counted from the tool log the moment they happen — exact and immediate, so it can exceed `leads_captured` while summaries are pending, and a conversation that created two leads counts twice. Use this for a "Leads captured" tile. |
| `avg_handling_time_seconds` | Mean of `ended_at − started_at` over `status = "completed"` |
| `median_handling_time_seconds` | Median of the same |
| `avg_agent_latency_ms` | Mean of `turn.latency_ms` over agent turns |
| `abandonment_rate` | Conversations with `status = "abandoned"` ÷ conversations. **Abandonment = the user stopped responding for more than N = 30 minutes with no closing turn** |
| `unanswered_rate` | Agent turns with `unanswered = true` ÷ all agent turns |
| `avg_csat` | Mean of `metrics.csat` where collected; `null` when none |

Rates are fractions `0–1` with 4 decimals. Aggregates never contain PII.

**Deleted conversations are excluded.** A conversation that has been soft-deleted (in the dashboard, by operations on a subject-erasure request, or by the retention sweep — never through the API) drops out of these aggregates, so a historical figure can fall after a deletion. If you need numbers that never move, snapshot them when you read them.

## `group_by`

| Dimension | Row key | Notes |
|---|---|---|
| `date` | `date` (`YYYY-MM-DD`, or the first day of the week/month) | Local date in `timezone` |
| `account_id` | `account_id` | |
| `agent_id` | `agent_id` | |
| `agent_type` | `agent_type` | |
| `intent` | `intent` | From the summary; `null` bucket for pending summaries |
| `outcome` | `outcome` | `null` bucket for pending summaries |
| `language` | `language` | |
| `agent_version` | `agent_version` | The A/B harness: `group_by=agent_version,outcome` separates two prompt versions' containment rates; overlay deploy markers from `GET /agent-versions` |

> Group by `agent_version`, not `prompt_version`. `agent_version` is stamped correctly; `prompt_version` currently reports one label for all three of an agent's live prompts, so grouping by it compares labels rather than prompts. See the note in [Agents](agents.md).

No `group_by` → a single row for the whole range.

## Time zone

`timezone` takes an IANA name (`Asia/Jerusalem`, `America/New_York`). A conversation started at `22:30Z` on day D lands in day D with `timezone=UTC` and in day D+1 with `timezone=Asia/Jerusalem`. `meta.timezone` echoes your input; `meta.timezone_applied` is what was actually used — an unrecognised zone falls back to `UTC` and the two differ, so check them. Timestamps in the response remain UTC; only bucket labels are local.

---

**UiriX Public API v1** · [Overview](README.md) · [Quickstart](quickstart.md) · [Authentication](authentication.md) · [Scopes](scopes.md) · [Conventions](conventions.md) · [Accounts & Users](accounts-and-users.md) · [Agents](agents.md) · [Capabilities](capabilities.md) · [Profiles](profiles.md) · [Conversations](conversations.md) · [Chat via API](chat-via-api.md) · [Widget Events](widget-events.md) · [Webhooks](webhooks.md) · [Calls](calls.md) · [Metrics](metrics.md) · [Voices](voices.md) · [Enums](enums.md) · [Partners](partners.md) · [Admin](admin.md) · [MCP](mcp.md) · [Changelog](changelog.md) · [OpenAPI](openapi.yaml)


<!-- ===== partners.md ===== -->

# Partners — "EqualWeb Member"

One call turns a partner's sign-up screen into a working UiriX customer: the account, the
agent built from the customer's website, and a login link that drops the customer on the
agent's widget "site code" page to copy the embed snippet. Master key only.

Product decisions behind it (Nir, 2026-09-06): the customer lands on the **pay-as-you-go plan**
(`plans.is_payg = 1`: $10 once at the start is the plan's usage allowance, everything beyond it comes out
of the wallet, paused under $5; there is no monthly charge) with a **partner wallet credit instead of a card** (default $89,
`shared/config/model-pricing.json → payg.partnerCreditUsd`). There is **no card, so there is no phone
number**: `POST /phone-numbers/purchase` answers `402 payment_method_required` until the customer
adds one in the dashboard; chat and the widget voice run on the wallet. The plan's `plan_expires_at`
is 2036-01-01 — the wallet, not a date, ends the trial.

`UiriX Public API v1` · base `https://api.uirix.com/v1` (dev: `https://dev-dashboard.uirix.com/api/ext/v1`)

## `POST /partners/members` — create a member — master key

Rate class `build` (5 / min). The new account's own quotas apply (one agent create + one build). The member starts with one agent; the plan allows **ten** — see [More agents for a member](#more-agents-for-a-member).

| Field | Type | Required | Notes |
|---|---|---|---|
| `company_name` | string ≤255 | yes | Becomes the user's name and company |
| `email` | email | yes | Login email; `409 email_exists` if taken |
| `password` | string 8–200 | yes | The customer's dashboard password (bcrypt) — the partner's screen collects it |
| `website_url` | URL | yes | Public http(s) site the agent learns from (SSRF-guarded, validated **before** anything is created) |
| `language` | string | yes | One of the **17 agent languages** — `en` `he` `es` `fr` `de` `it` `pt` `ru` `ar` `zh` `ja` `ko` `hi` `tr` `nl` `pl` `sv` (see [Enums](enums.md#agent-language)). A region suffix such as `pt-BR` or `zh-TW` is accepted and reduced to its language. |
| `voice.gender` | `female` \| `male` | yes | Picks the wizard's default voice for the language |
| `voice.name` | string | no | A specific voice id from `GET /voices` |
| `provider` | `openai` \| `gemini` \| `gpt_live` | no | **Members default to `gemini`**, like the wizard and `POST /accounts/{acc}/agents/build` (Nir, 2026-09-27; until then `openai`). `gpt_live` (GPT-Live-1, 13 Sept 2026) is accepted only where the platform has it switched on; elsewhere it is a `400 validation_error`. |
| `wallet_credit_usd` | number ≥ 0 | no | Overrides the configured partner credit (default $89); `0` = none |
| `widget_enabled` | boolean | no | Default `true`. `false` leaves the widget switched off; the domain and token are still set |
| `external_ids` | object | no | e.g. `{ "equalweb_customer_id": "55231" }` |
| `partner` | string | no | Which partner this member belongs to; stamped on `external_ids.partner`. Default `PUBLIC_API_DEFAULT_PARTNER` |
| `timezone`, `currency_code`, `max_pages` | | no | As on `POST /users` / the agent build |

```bash
curl -s -X POST "$B/partners/members" -H "Authorization: Bearer $MK" -H "Content-Type: application/json" -d '{
  "company_name": "Customer Ltd", "email": "owner@customer.com", "password": "S3cure-Passw0rd!",
  "website_url": "https://www.customer.com", "language": "he", "voice": { "gender": "female" },
  "external_ids": { "equalweb_customer_id": "55231" }
}'
```

`202`:

```json
{
  "user": { "user_id": "usr_1310", "account_id": "acc_1310", "email": "owner@customer.com", "plan": "payg", "status": "active", "…": "…" },
  "agent_id": "agt_3702",
  "build": { "status": "building", "step": "crawl", "steps": { "create": { "status": "done" }, "crawl": { "status": "running" }, "…": "…" } },
  "agent": { "…": "the agent row as GET /agents/{agt}/build shows it" },
  "widget": {
    "public_token": "pk_9f2c…", "enabled": true, "allowed_domains": ["customer.com"],
    "embed_snippet": "<script src=\"https://cdn.uirix.com/uirixwidget.js\" data-token=\"pk_9f2c…\" async></script>"
  },
  "wallet_balance_usd": 89,
  "login_url": "https://dashboard.uirix.com/admin-login?otp=<64 hex>&next=%2Fagent%2F3702%2FWebsiteCode",
  "login_url_expires_at": "2026-09-06T19:52:00.000Z",
  "login_lands_on": "/agent/3702/WebsiteCode",
  "demo_url": "https://dashboard.uirix.com/demo/3702-9f2c81a0e4d15b7c3a6f0d2e8b9c1a47",
  "meta": { "request_id": "req_…", "environment": "production" }
}
```

**The widget is ready to paste.** `allowed_domains` is set to the **apex host** of `website_url` (`https://www.customer.com/pricing` → `customer.com`), which the widget origin check accepts for the apex and every subdomain, so `www` and bare both work; the `public_token` is minted and the widget is enabled. The login link therefore lands on a site-code page that is already complete — the customer only copies `widget.embed_snippet`. Send `widget_enabled: false` to leave it switched off.

What happened: the user exists (bcrypt password, PAYG subscription `active`, `payment_gateway` null so the
monthly card job never touches it, `ext_account_settings` with defaults, the credit in `wallet_history`);
the agent exists and the V4 build runs in the background (crawl → knowledge → greeting → instructions →
ready, the same steps as `POST /accounts/{acc}/agents/build`); a single-use login link was issued.

The member's agents are built with the API's defaults (29 Sept 2026): **Product Descriptions** (`products_catalog`) in the agent's focus, **no Send-SMS task** (members have no phone number), and the **Contact-me action addressed to the member's e-mail** (`completions["#lead_email"]`), so a lead never points nowhere.

**Errors:** `400 validation_error` (a bad `website_url`, voice, language — nothing is created) ·
`409 email_exists` · `429 quota_exceeded` / `rate_limited` · `403 insufficient_scope` (any personal key).

## `GET /partners/members/{usr}` — progress + a fresh login link — master key

The same object, re-read: `build.status` moves `building → done | error` with per-step detail, and
**every read issues a new `login_url`** — the link from the create call expires in minutes (default 10,
`PUBLIC_API_LOGIN_LINK_TTL_MINUTES`) while a build can take longer, so poll this until `done`, then hand
the customer the link from the last read. Widget code needs the agent's `allowed_domains` to be set for the
snippet to render — already set from `website_url`, so nothing is missing.

Poll every 3–5 s. **A build lives in the backend process**: if it is restarted while one runs, that build dies. The status then reads `interrupted`, with `resume_with` naming the exact call to re-run (`POST /agents/{agt}/build` with `{"from_step":"crawl"}`); `done` with a note means it finished but the step detail is no longer in memory. The `agent` object is the durable record — `config_status: "complete"` and `onboarding_step: 7` mean the customer sees a finished agent, anything else shows as a draft in their dashboard.

## `POST /users/{usr}/login-links` — a login link on demand — master key

Body `{ "agent_id": "agt_3702" }` lands on that agent's widget code page (must be the user's agent,
else `404 agent_not_found`); `{ "next": "/dashboard" }` for any in-app path; `{}` for the dashboard home.
Absolute URLs are ignored (open-redirect guard). `201 { user_id, login_url, expires_at, lands_on }`.

**How the link works.** It is the dashboard's existing one-time login (the admin panel's impersonation
flow): a 64-hex token in `refresh_tokens` with `device_info` `ext_api_login_link:…`, single use, short TTL,
exchanged by the dashboard page for a 15-minute access token (no refresh cookie). The page scrubs the token
from the URL before the browser records it. The customer's phone-verification step is skipped for these
links — the partner already authenticated the customer on its own login. Treat the URL as a secret: send it
over your authenticated session only, never by email.

## "Sign in with EqualWeb" — OAuth sign-in from the EqualWeb portal (NLS-3574)

The UiriX login page shows a **Sign in with EqualWeb** button (28 Sept 2026) — a plain link opened in the same window, never a popup or an iframe. UiriX is an OAuth 2.0 client (authorization code + PKCE S256) of the EqualWeb portal; the customer signs in on EqualWeb and comes back signed in to UiriX.

| | Value |
|---|---|
| Client id | `uirix` |
| Authorize | `https://<portal>/manage/business-assistant/oauth/authorize` |
| Token | `https://<portal>/manage/business-assistant/oauth/token` (Basic client auth) |
| Userinfo | `https://<portal>/manage/business-assistant/oauth/userinfo` (only when the token answer carries no `user`) |
| **Callback (redirect_uri) to register** | dev: `https://dev-dashboard.uirix.com/api/auth/equalweb/callback` · production: `https://dashboard.uirix.com/api/auth/equalweb/callback` |
| Portal | dev `https://dev-oram.equalweb.com`; production: the production portal host with its own client secret |

**The hops.** The button → `GET /api/auth/equalweb[?to=<page>]` on the dashboard host, which stores `state` + the PKCE verifier + the page in a signed, ten-minute cookie and forwards the browser to the authorize URL (`response_type=code&client_id=uirix&redirect_uri=…&state=…&code_challenge=…&code_challenge_method=S256`). The portal sends the browser to the callback with `code` and `state` (or `error`). The callback checks the state, exchanges the one-time code at the token endpoint and reads the `user` object; the UiriX account is the partner member whose `external_ids.equalweb_customer_id` equals the user's `equalweb_customer_id` (the value sent at member creation — always send it). The member gets an ordinary dashboard session; the phone-verification step is skipped, as for login links. Then the browser lands on the **account home**, or on the page the customer had asked for before being sent to the login:

| `to` | Lands on |
|---|---|
| `setup` | Agent settings (`/agent/{id}/SettingsTab`) |
| `knowledge` | Knowledge — documents and pages (`/agent/{id}/FileUploadTab`) |
| `usage` | Usage dashboard — conversations, calls, leads (`/agent/{id}/UsageTab`) |
| `number` | Phone number (`/agent/{id}/VoiceTab`) |
| `code` | Website embed code (`/agent/{id}/WebsiteCode`) |

`{id}` is the member's newest agent. A customer with no UiriX account is sent back to the login page with "This EqualWeb account has no uiriX agent yet" — create the member first (`POST /partners/members`). A cancelled sign-in (`error=access_denied`) and any other failure return to the login page with a message; nothing is created on either path.

The platform shows the button only where the portal and the client secret are configured (`EQUALWEB_PORTAL_URL`, `EQUALWEB_OAUTH_CLIENT_SECRET`); production gets it with the production portal's host and secret. The partner login links (`POST /users/{usr}/login-links`) keep working independently.

## One membership per customer

**Nir, 2026-09-06: the gift is per customer, never per website.** A second `POST /partners/members` (or convert) carrying an `external_ids` value that already belongs to a member is refused with `409 member_exists`, naming the field, the matching `user_id` and nothing created. Always send `external_ids.equalweb_customer_id` — it is the key the rule matches on; without it only the email is unique. A customer with several websites keeps **one account** and adds agents to it.

## What the member starts with — $99

| Part | Amount | Where it lives |
|---|---|---|
| Plan allowance | $10 | `plans.price` of the pay-as-you-go row. Not wallet money, **not** a `wallet_history` entry, and nobody is charged for it — the customer is put on the plan and it is granted |
| Wallet credit | $89 | `users.wallet_balance`, with one `wallet_history` row `Partner credit - key_…` |

The balance gate spends the allowance first: `remaining = max(plan_price − monthly_spending, 0) + wallet_balance`, so a member starts at $99 of usage. The account is paused while the remaining amount is at or below the **$5 floor**, so about $94 is usable and the last $5 is the safety margin. There is no monthly charge and no renewal: when it is spent the customer tops up the wallet (which also saves a card) or moves to a paid plan.

## What marks an account as a partner's member

Data on the account, not a separate table. `ext_account_settings.external_ids` carries `partner` (from `PUBLIC_API_DEFAULT_PARTNER`, or `"partner"` in the request) and `partner_member_since`, next to whatever ids you send such as `equalweb_customer_id`. Both are visible on every `GET /users/{usr}` under `external_ids`. The other two signals are the plan (`payg`, status `active`) and the `wallet_history` row `Partner credit - key_…`, which records the credential that created the member.

## `POST /partners/members/{usr}/convert` — make an existing account a member — master key

For an account created before the partner door existed (or through `POST /users`). Body, all optional: `wallet_credit_usd`, `partner`, `external_ids`, `website_url` (provisions the widget of the account's agent from it), `agent_id` (which agent, default the newest), `widget_enabled`.

It moves the subscription to pay-as-you-go with `payment_gateway` and `next_charge_date` cleared — so the monthly card job never charges it — sets `plan_expires_at` to 2036, stamps the partner marker, credits the wallet when asked, and answers the same object as create, login link included. Converting twice does not credit twice unless the credit is asked for again. `404` when the user does not exist, `404 agent_not_found` when `agent_id` is not that user's.

```bash
curl -s -X POST "$B/partners/members/usr_3828/convert" \
  -H "Authorization: Bearer $MK" -H 'Content-Type: application/json' \
  -d '{"wallet_credit_usd": 89, "website_url": "https://www.customer.com"}'
```

## `POST /users` with `plan: "payg"`

The same plan without the agent: `{ "email", "name", "plan": "payg", "wallet_credit_usd"? }` — the credit
defaults to the configured partner credit; `wallet_credit_usd` on any other plan is `400 validation_error`.

## Show the customer the agent on their site

`demo_url` (on create, on `GET /partners/members/{usr}` and on convert) is a page on the UiriX dashboard host that needs **no login**: the customer sees a screenshot of their own home page with the agent's widget open as a full-height side panel on the left, pushing the site to the right — the real widget, the real token, chat and voice working. Put it behind a "See your agent" button; it is ready when `build.status` is `done` (before that the page says the agent is still being built and refreshes itself). The link stays valid until the widget token is regenerated. Details and the standalone route: [Agents → `GET /agents/{agt}/demo`](agents.md#get-agentsagtdemo--a-page-that-shows-the-agent-on-the-customers-site--scope-agentsread).

## More agents for a member

**Nir, 2026-09-27: the pay-as-you-go plan allows ten agents** (it allowed one until then). The membership, the wallet and the plan stay one per customer; the agents are added to the **same account** with the ordinary build call:

```bash
curl -s -X POST "$B/accounts/usr_1310/agents/build" -H "Authorization: Bearer $MK" -H "Content-Type: application/json" -d '{
  "website_url": "https://shop.customer.com", "language": "he", "voice": { "gender": "male" },
  "name": "Shop assistant"
}'
```

`202` with the new `agent_id` and the build state — the same steps as the member's first agent (crawl → knowledge → greeting → instructions → ready), polled with `GET /accounts/{acc}/agents/{agt}/build`. Scope `agents:admin` (the master key has it). Each agent gets its own widget token (`GET /agents/{agt}/widget`) and its own knowledge base; the wallet is shared.

| Limit | Value |
|---|---|
| Agents per member | **10** (`plans.max_agents` of the pay-as-you-go plan); the eleventh is `403 plan_limit_reached` with `current` / `limit` |
| Agent creates per day | 10 per account (`max_agent_creates_per_day`) |
| Builds at the same time | 2 per account (`409 build_concurrency_exceeded`) |
| Rate class | `build`, 5 / min per key |

No second membership and no second credit: `POST /partners/members` with the customer's id still answers `409 member_exists`; the extra agents come through the account. `GET /partners/members/{usr}` keeps reporting the **newest** agent; `GET /accounts/{acc}/agents` lists them all. The customer can also add agents from the dashboard (the V4 wizard) under the same limit.

## The customer's life after this

| Event | What happens |
|---|---|
| Chat / widget voice | Runs; the $10 start allowance first, then the wallet; paused when the remaining balance drops under $5. No monthly charge ever |
| Wants a phone number | `402 payment_method_required` in the dashboard until a Cardcom card is saved; then numbers, inbound and outbound calls open |
| Wallet spent | Everything pauses; the customer tops up from the dashboard wallet page (Cardcom) — that also saves the card |
| Wants an eleventh agent, or a bigger plan | Upgrades from the pricing page; the pay-as-you-go tab shows as the current plan until then |

Related: [Accounts & Users](accounts-and-users.md) (`POST /users`, quotas), [Agents](agents.md) (the build steps), [Scopes](scopes.md).


<!-- ===== production-schema.md ===== -->

# Public API v1 — database objects to create in production

Everything the Public API added to `agent_db`, migration by migration, so production can be built from
this list. **The SQL files are the source of truth**; this page is the index. Every file is idempotent
(each object is guarded with `IF … IS NULL` / `IF NOT EXISTS`), so re-running is safe.

| | |
|---|---|
| Migration files | `dashboard/backend/src/ext-api/migrations/NNN_*.sql` (run in filename order) |
| Runner | `cd dashboard/backend && npx tsx src/scripts/migrate-ext-api.ts` (splits on `GO`, records each file in the ledger, skips files already recorded) |
| Ledger | `dbo.ext_api_migrations` — created by `001`; one row per applied file |
| Connection | the root `.env` (`DB_*`), the same one 3000 uses |
| Status on dev | all ten applied (`010` on 2026-09-06) |

Run order matters only for `008` (recreates a view that `002` created and `007` extended). Nothing here
adds a trigger: the balance triggers on `usage_logs` / `public_sessions` are untouched.

## 001_ext_api_core — keys, audit, account settings

| Object | Kind | Purpose |
|---|---|---|
| `dbo.api_keys` | table | Personal API keys: `key_prefix`, `key_hash` (SHA-256, unique), `scopes` JSON, `account_ids` JSON, `environment` live/sandbox, `ip_allowlist`, `status`, `expires_at`, `rotated_from_id`, `revoked_at` |
| `dbo.api_audit_log` | table | One row per API request: `request_id`, credential type/id, `account_ids`, `cross_account`, method, path, status, error code, ip, duration |
| `dbo.ext_account_settings` | table | One row per account (`user_id` PK): `external_ids` JSON, `default_redaction`, retention days ×3, `max_concurrent_calls`, `max_calls_per_day`, `is_sandbox` (+ the quota columns from `010`) |
| `dbo.ext_api_migrations` | table | The runner's ledger |

## 002_conversation_columns_and_indexes — the unified conversation read model

| Object | Kind | Purpose |
|---|---|---|
| `usage_logs`, `public_sessions`, `conversations` + columns | columns | `agent_version`, `prompt_version`, `model`, `summary_json`, `summary_status`, `summary_model`, `summary_attempted_at`, `deleted_at`, `deleted_reason`, `ext_started_emitted_at`, `ext_ended_emitted_at` (`conversations` also `ended_at`) |
| `public_conversations.channel` | column | `widget` / `api` / `dashboard_preview` |
| `ix_usage_logs_user_started`, `ix_usage_logs_user_updated`, `ix_usage_logs_agent_started`, `ix_public_sessions_created`, `ix_public_sessions_updated`, `ix_public_conversations_agent_created`, `ix_conversations_user_created`, `ix_messages_conv_created` | indexes | Keyset paging on `GET /conversations` |
| `dbo.vw_ext_conversations` | view | `UNION ALL` of the three conversation stores into one normalised row (recreated by `008`) |

## 003_identity_versions — identity and prompt versions

| Object | Kind | Purpose |
|---|---|---|
| `dbo.conversation_identity` | table | Who the conversation was with: `store` + `ref_id` (unique), email, phone, name, company, page/utm context, `custom_json` |
| `agents` + columns | columns | `agent_version`, `agent_version_seq`, `agent_version_at`, `current_prompt_version` |
| `dbo.agent_prompt_versions` | table | Every deployed prompt (`incoming` / `chat` / `voice`), label unique per agent+type, `is_active`, `retired_at` |

## 004_webhooks — outgoing webhooks

| Object | Kind | Purpose |
|---|---|---|
| `dbo.webhook_endpoints` | table | Customer endpoints: url, events JSON, encrypted current/previous secret, `enabled`, `consecutive_failures` |
| `dbo.webhook_outbox` | table | One row per emitted event (`dedupe_key` unique) |
| `dbo.webhook_deliveries` | table | One row per endpoint per event: frozen `payload`, `status`, `attempt`, `next_attempt_at`, `dead_at` |
| `dbo.ext_watermarks` | table | Poller watermarks (name → value) |

## 005_calls_dnc — outbound calls through the API

| Object | Kind | Purpose |
|---|---|---|
| `dbo.dnc_numbers` | table | Do-not-call list per account (`user_id` + `phone_e164` unique), `source`, `source_call_id` |
| `dbo.api_calls` | table | Every `POST /calls`: status, schedule, policy, `context_json`, `matter_key`, links to `outbound_queued_calls` / `usage_logs`, `answered_by`, `disclosure_played` |
| `outbound_jobs.is_api_hidden` | column | The one hidden campaign per account that API calls run under (excluded from the campaign dialer) |

## 006_tombstones_sandbox

| Object | Kind | Purpose |
|---|---|---|
| `dbo.conversation_tombstones` | table | Record of an erased conversation (`conversation_ref` unique, reason, requested_by) — written by dashboard deletes and the retention sweep (not by the API since 2026-09-06) |

## 007_call_profile_record · 008_view_call_profile · 009_call_inline_profile

| Object | Kind | Purpose |
|---|---|---|
| `usage_logs.profile_id`, `usage_logs.profile_resolution` + `ix_usage_logs_profile_resolution` | columns, index | Which outbound profile a call actually ran under (`007`) |
| `dbo.vw_ext_conversations` | view | Recreated with both columns (`008`) |
| `api_calls.call_profile_json` | column | The inline profile of a `POST /calls` (`009`) |

## 010_api_quotas — daily quotas and build concurrency (2026-09-06)

| Object | Kind | Purpose |
|---|---|---|
| `ext_account_settings.max_agent_creates_per_day` | column, default 10 | Agents created through the API per UTC day (0 = no quota) |
| `ext_account_settings.max_builds_per_day` | column, default 30 | Agent builds + re-runs, profile builds, KB reindexes per day |
| `ext_account_settings.max_kb_documents_per_day` | column, default 100 | KB documents (upload, text, URL) per day |
| `ext_account_settings.max_chat_messages_per_day` | column, default 2000 | Conversation turns per day (a create counts as one) |
| `ext_account_settings.max_webhook_tests_per_day` | column, default 100 | Webhook pings and delivery replays per day |
| `ext_account_settings.max_concurrent_builds` | column, default 2 | Builds allowed to run at the same time per account |
| `dbo.ext_account_usage_daily` | table (NEW) | `(user_id, day)` PK; counters `agent_creates`, `builds`, `kb_documents`, `chat_messages`, `webhook_tests`; `updated_at`. FK to `users` with cascade. One atomic `UPDATE … OUTPUT` per consumption |

Why a counter table rather than counting the data tables (as calls do with `api_calls`): chat turns live
inside the transcript JSON and webhook pings leave no row, so there is nothing to count. The per-minute
rate classes stay in memory on purpose; a day must survive a restart, so it lives here.

## 011_view_activity_model — activity time, runtime verdict, chat model on the view (2026-09-11)

| Object | Kind | Purpose |
|---|---|---|
| `dbo.vw_ext_conversations` | view (recreated) | Adds `last_activity_at` (UTC, every store), `answered_by` (from `usage_logs.metadata.$.answered_by`, the 3003 runtime's ivr / voicemail verdict; NULL on chats) and makes the chat `model` fall back to `public_sessions.metadata.$.model`. Lets `conversationReadService` drop its two per-request LEFT JOINs |

Runner: `cd dashboard/backend && npx tsx src/scripts/migrate-ext-api.ts` (reports 001–010 as already applied, applies 011). Read-only for data.

## 012_admin_actions — the admin journal (2026-09-30)

| Object | Kind | Purpose |
|---|---|---|
| `dbo.ext_admin_actions` | table | One row per write made through `/admin/*` (master key): `request_id` (joins `api_audit_log`), `action`, `target_type` / `target_id`, `before_json` / `after_json`, `reason`, `idempotency_key`, `created_at` (UTC). No foreign keys on purpose — the history outlives the rows it describes |
| `ux_ext_admin_actions_idempotency` | unique filtered index | `idempotency_key` where not NULL — a retried wallet credit answers the original result instead of crediting twice |
| `ix_ext_admin_actions_created`, `ix_ext_admin_actions_target` | indexes | `GET /admin/actions` newest-first paging; "everything done to this customer / agent / key" |

Runner: `cd dashboard/backend && npx tsx src/scripts/migrate-ext-api.ts` (reports 001–011 as already applied, applies 012). Creates an empty table; no data is touched.

## 013_oauth_connectors — the OAuth 2.1 server of the MCP connector (2026-09-30)

| Object | Kind | Purpose |
|---|---|---|
| `dbo.oauth_clients` | table | Clients registered through `POST /api/ext/v1/oauth/register` (RFC 7591): `client_id` (UUID), `client_secret_hash` (SHA-256; NULL for public clients), `token_endpoint_auth_method` (`none` / `client_secret_post` / `client_secret_basic`), `client_name`, `redirect_uris` (JSON; `https://…` or `http://localhost` / `127.0.0.1` only), `grant_types`, `response_types`, `scope`, the RFC 7591 URIs, `software_id`, `registered_ip`, `created_at`, `last_used_at` |
| `dbo.oauth_authorization_requests` | table | One row per `GET /oauth/authorize` (10 min): the client, `redirect_uri`, `state`, PKCE `code_challenge` (S256 only), the offered `scopes`, `resource`; the consent page fills `decided_at` / `decision` / `user_id`. Cascades from `oauth_clients` |
| `dbo.oauth_authorization_codes` | table | SHA-256 of the single-use codes (5 min) the consent mints, bound to the api key it created (`api_key_id` → `api_keys`, cascade) and to the request; `used_at` makes them single-use |
| `dbo.oauth_tokens` | table | SHA-256 of every access (`uirix_at_`, 1 h) and refresh (`uirix_rt_`, 90 d) token: `kind`, `client_id`, `user_id`, `api_key_id` (→ `api_keys`, cascade), `scopes`, `resource`, `family_id` (the code the grant descends from), `expires_at`, `last_used_at`, `revoked_at`, `parent_id` (access → its refresh token), `replaced_by_id` (refresh rotation). Reusing a rotated refresh token after the 60-second grace, or reusing a code, revokes the whole family |
| `ux_oauth_tokens_hash` | unique index | Bearer lookup |
| `ix_oauth_tokens_api_key`, `ix_oauth_tokens_user_kind`, `ix_oauth_tokens_family`, `ix_oauth_tokens_expires` | indexes | Disconnect, the connections card, family revocation, the sweep |
| `ix_oauth_authorization_codes_expires`, `ix_oauth_authorization_requests_expires` | indexes | The sweep (expired requests/codes deleted at once, tokens 30 days after expiry; at startup and every 6 h) |

Why an api key behind every token: the consent creates (or reuses) a personal key with `created_by = 'oauth:<client_id>'` and the
scopes the user ticked; the access token authenticates as that key on every Public API route (`ext-api/middleware/auth.ts`, audit
`credential_type = key`, `credential_id` = the key). Revoking the key in the profile page, or `DELETE /api/oauth/connections/{client_id}`,
disconnects the app. Only hashes are stored — never a code, a token or a client secret.

Runner: `cd dashboard/backend && npx tsx src/scripts/migrate-ext-api.ts` (reports 001–012 as already applied, applies 013). Creates
four empty tables; no data is touched. Env (root `.env`, optional): `OAUTH_ACCESS_TOKEN_TTL_SECONDS` (3600), `OAUTH_REFRESH_TOKEN_TTL_DAYS` (90).
The consent page is `https://<DASHBOARD_DOMAIN>/connect/authorize?request=<id>`; the discovery documents are served by 3000 at
`/.well-known/oauth-authorization-server[/api/ext/v1/oauth]` and `/.well-known/oauth-protected-resource[/api/ext/v1/mcp]`, so the IIS
rule `ReverseProxyDashboardWellKnownOAuth` in `dashboard/web.config` must be deployed with it (before the frontend rule).

## 014_outbound_contact_timezone_iana — room for an IANA zone on every campaign queue row (2026-09-30)

| Object | Kind | Purpose |
|---|---|---|
| `outbound_queued_calls.contact_timezone` | column widened, `VARCHAR(20)` -> `VARCHAR(50)` | Batch outbound ([campaigns](campaigns.md)) stores an IANA zone per contact (`America/Argentina/Buenos_Aires` is 30 characters). The 20-character column would make `trg_sync_list_items_to_campaign_queue` fail the whole INSERT of a list item whenever a running / paused / completed campaign uses the list. `outbound_list_items.timezone` and `outbound_jobs.timezone` were already `VARCHAR(50)` |

Runner: `cd dashboard/backend && npx tsx src/scripts/migrate-ext-api.ts` (reports 001–013 as already applied, applies 014). Guarded on the current width, idempotent, metadata-only; no data is touched. **Run it before the first API campaign is started.** The 3003 dialer must be restarted with the same release: its `timezoneHelper` now understands IANA names, and a 3003 that does not would read `Asia/Jerusalem` as UTC.

## Legacy-schema migration that the API depends on (runner: `npm run migrate:legacy`)

| Object | Kind | Purpose |
|---|---|---|
| `plans.is_payg` | column, BIT default 0 | Flags the pay-as-you-go plan (`src/migrations/004_payg_plan.sql`, 2026-09-06). Code keys on the flag/id, never the name |
| `widget_email_log` | table | Every email the public widget attempted to send a visitor: agent, session, IP, recipient, template, status, refusal reason. It is both the audit trail and the source the rate limits read (`005_widget_email.sql`, 2026-09-07) |
| `widget_email_blocklist` | table | Addresses that bounced or complained; never mailed again |
| `plans` row `PAYG` | row | price 10, one agent, `sort_order` 0 (outside the upgrade ladder), `is_payg` 1 — the plan a partner-created (EqualWeb) account is put on, with a wallet credit instead of a card |
| `function_call_logs` | table + index `ix_function_call_logs_session` | One row per tool call an agent runs on chat, on the voice widget and on an inbound phone call — 3002 writes it too since 2026-09-11 (function, arguments with secrets masked, result, success, duration, `origin` = `chat:widget` / `chat:api` / `voice:widget:<provider>` / `phone:inbound:<provider>`), keyed by `(agent_id, session_key)` = the conversation's own session id (`usage_logs.session_key` for calls, the signed key's `rid` = `public_conversations.session_id` for the voice widget). Read back as the `speaker: "tool"` turns of the Public API transcript (`007_function_call_logs.sql`, 2026-09-10). Until the file runs, the API simply shows no tool turns — the reader tolerates a missing table |

Run `cd dashboard/backend && npm run migrate:legacy` before the API migrations on a fresh production box; the ledger is `dbo.schema_migrations`.

Summary worker settings (root `.env`, all optional, since 2026-09-10): `EXT_SUMMARY_ELIGIBLE_DAYS` (90 — accounts with a conversation in this window are summarised), `EXT_SUMMARY_FRESH_HOURS` (72 — conversations younger than this take the uncapped fresh lane), `EXT_SUMMARY_BACKFILL_PER_TICK` (20), `EXT_SUMMARY_BACKFILL_PER_DAY` (300 model calls per account per UTC day; the daily counter is process-local), `EXT_SUMMARY_ELIGIBLE_CACHE_MS` (300000), `EXT_SUMMARY_WORKER=off` to stop the worker.

## Production checklist

1. Back up `agent_db`.
2. Confirm the root `.env` on the production box points at the production database.
3. `cd dashboard/backend && npx tsx src/scripts/migrate-ext-api.ts` — expect ten `applied` lines the first time, `already applied` afterwards.
4. Verify: `SELECT name, applied_at FROM dbo.ext_api_migrations ORDER BY name` lists `001`…`010`; `SELECT OBJECT_ID('dbo.ext_account_usage_daily')` is not null.
5. Set the production env: `PUBLIC_API_MASTER_KEY` (≥ 48 chars, `uirix_mk_live_…`), `PUBLIC_API_BASE_URL`, `PUBLIC_API_RECORDING_SECRET`; `PUBLIC_API_SANDBOX_ACCOUNT_ID` stays **empty** — the sandbox is withheld from the documentation until it is tested (Nir, 2026-09-06), and an empty value keeps the daily generator off.
6. Restart 3000. `GET /api/ext/v1/health` answers 200 with `db: ok`.
7. Partner door env (optional): `PUBLIC_DASHBOARD_URL` = the dashboard origin the login links point at (default `FRONTEND_URL`, then `https://dev-dashboard.uirix.com`), `PUBLIC_API_LOGIN_LINK_TTL_MINUTES` (default 10). Leave `WALLET_MOCK_TOPUP` unset in production (the mock top-up stays off).
8. `shared/config/model-pricing.json → payg` holds the amounts (first deposit 100, wallet floor 5, partner credit 1000); the plan's own price is `plans.price` of the `is_payg` row.

Related: [DATABASE_MIGRATIONS.md](../DATABASE_MIGRATIONS.md) (runner design), [IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md) (what each migration serves).

## Test tenants in production — permanent, do not delete (Nir, 2026-09-10)

Two accounts on `dashboard.uirix.com` exist for development testing of the Public API against
production. Nir's ruling: **they stay**. Reuse them for every production check instead of creating
new tenants; never delete them, their agents or their keys.

| | usr_1304 · acc_1304 | usr_1305 · acc_1305 |
|---|---|---|
| Purpose | Direct API tenant (created with `POST /users`, later converted to a PAYG member) | EqualWeb member created with `POST /partners/members` |
| Email | qa-api-20260910@uirix.com | qa-member-20260910@uirix.com |
| Plan | payg, $5 wallet credit | payg, $2 wallet credit |
| Agent | agt_344 — interdeal.co.il, 5 pages, complete | agt_345 — interdeal.co.il, complete |
| Keys | key_2 read-only (rotated, its successor key_4 revoked), key_3 write — expires 2026-09-17, re-issue with the master key | none |
| Other rows | profiles prf_49 (sales) + prf_50 (clone), webhook whk_1 → https://httpbin.org/post, dnc_1 (+972500000001), conversations conv_p4162 / conv_p4163 | — |
| Limits | max_calls_per_day 5, max_builds_per_day 10 (patched during the test) | defaults |

Rules for using them: send `{}` on every body-less POST (the edge answers `411` otherwise); do not
build from `https://www.uirix.com` (refused as a private address from inside production); a build
costs real model usage — the daily build quota on acc_1304 is the guard. The test run itself is
documented in [changelog.md → 2026-09-10](changelog.md).

## Operational note — re-generate the structured summaries written before 2026-09-11

Until 2026-09-11 the structured summariser sent the model an EMPTY transcript (a `{{TRANSCRIPT}}` marker was never
substituted), so every row with `summary_model LIKE 'uirix-summary-v1@%'` written before that date describes nothing.
After deploying the fix, reset those rows once and the backfill lane re-summarises them (newest first, capped by
`EXT_SUMMARY_BACKFILL_PER_DAY`, ~/usr/bin/bash.001 each):

```sql
UPDATE dbo.usage_logs       SET summary_status = NULL, summary_json = NULL WHERE summary_model LIKE 'uirix-summary-v1@%' AND summary_attempted_at < '2026-09-11';
UPDATE dbo.public_sessions  SET summary_status = NULL, summary_json = NULL WHERE summary_model LIKE 'uirix-summary-v1@%' AND summary_attempted_at < '2026-09-11';
UPDATE dbo.conversations    SET summary_status = NULL, summary_json = NULL WHERE summary_model LIKE 'uirix-summary-v1@%' AND summary_attempted_at < '2026-09-11';
```

Rule-based summaries (`uirix-summary-v1@rules`) and the dashboard free-text summaries are unaffected.


<!-- ===== profiles.md ===== -->

# Outbound Call Profiles

> **Outbound only.** A profile is the script an **outbound** call runs under. It has no effect on inbound phone, widget chat or widget voice — those run the agent's own configuration ([Agents](agents.md)).
>
> Scopes `agents:read` (list, get, build state) and `agents:write` (create, update, delete, clone, build).

An outbound call needs three things a chat agent does not: a **role** for this campaign, a **task** it is trying to complete, and a **limit** on what it is allowed to do while trying. A profile is where UiriX keeps all three, and it is the same object the dashboard shows as a "profile card". Everything on that card is readable and writable here, and the card's **Rebuild with AI** button is `POST /profiles/{prf}/build`.

Without profiles, the only way to brief a campaign through the API was to rewrite the agent's own prompt with `PATCH /agents/{agt}` — which changes the agent on every channel it serves, for every caller, permanently. A profile changes one campaign. And if you do not want a stored profile at all, `POST /calls` accepts the same fields inline ([Calls → Everything in the request](calls.md#everything-in-the-request-the-inline-profile)).

## The one thing to understand first

**A profile replaces the agent's stored persona. It does not layer on top of it.**

The outbound runtime takes the profile's `system_instruction_template` **or** the agent's stored prompt — never both. If a call names a profile, the agent's own instructions are not in the prompt at all.

From the agent, a profile-driven call inherits only:

- the **voice** it speaks with (unless the profile or the call overrides it),
- the **language**,
- the **knowledge base** it can draw on.

Everything else — who the agent claims to be, what it is calling about, how it opens, what it may do — comes from the profile.

This matters because it is the opposite of what most people assume. If you write a profile that says *"you are calling about the renewal"* and expect the agent's stored *"you are Kim from EqualWeb, always be polite, never quote prices"* to still apply underneath, it will not. Write the whole persona into the profile — or let the builder write it for you from the card's inputs.

## Precedence on a call

```
1. Safety, policy and capability rules      (platform — always wins)
2. context.briefing on POST /calls          (the facts for THIS call)
3. The inline `profile` on POST /calls      (fields sent on the call itself)
4. The stored profile                       (the job: role, task, gating, tone)
   — or the agent's stored prompt, if the call names neither
5. Agent voice / language / knowledge base  (always, either way)
```

Rungs 2 and 4 are the pair people get wrong. **The profile is the job; the briefing is today's data.** A profile says *"you are collecting on an overdue invoice, you may offer a payment plan, you may not discount"*. A briefing says *"invoice 4417, €820, 34 days late"*. They layer; they do not compete. Put anything that changes per recipient in `context.briefing`, not in a profile — otherwise you will be creating a profile per customer. Rung 3 is field-by-field: an inline `greeting` replaces the profile's greeting and leaves its instructions alone.

## The three profile types

`category` is the profile's **type**, and it decides which capabilities the profile may grant, which focuses the builder writes the script around, and which prompt template the builder uses. It is one of:

| Type | For | Tools it unlocks | Focuses (examples) |
|---|---|---|---|
| `collections` | overdue invoices, payment reminders | payment tools (`send_payment_link`, `create_payment_promise`, `mark_dispute`, `send_bank_transfer_details`) + communication | `collect_payment_immediately`, `secure_payment_promise`, `update_payment_method` |
| `sales` | lead qualification, upgrades, win-back, renewals | sales tools (`send_zoom_link`, `send_product_info`, `send_coupon_code`, `send_proposal`, `schedule_calendar_meeting`, …) + communication | `build_rapport`, `qualify_lead`, `promote_product_service`, `schedule_demo`, `close_deal` |
| `events` | RSVPs, registrations, reminders | event tools (`update_rsvp`, `update_guest_count`, `send_event_ticket`, `send_directions_waze`, …) + communication | `secure_rsvp`, `build_event_excitement`, `share_event_details` |
| `appointments` | reminders, confirmations | **hidden in the product** until calendar integration lands; only the communication tools remain | — |

"Communication" is `send_sms`, `send_email`, `end_call`, available to every type. The full lists, with the `completions` key each tool needs, are published by [`GET /outbound-capabilities`](capabilities.md#outbound-capabilities--get-outbound-capabilities).

**This is enforced.** A `sales` profile that lists `send_payment_link` is `400 validation_error` naming the id and listing what a sales profile may grant; a focus from another type is refused the same way. Changing `category` re-validates the capability list against the new type in the same request. The dashboard wizard applies the same rule at step 4 of the card.

A profile written before this rule existed may hold a free-text category (`team_update` is a live one). It reads back as stored; it cannot be built until `category` is set to a type, and its `focuses` cannot be validated until then.

## A profile belongs to exactly one agent

`POST /calls` refuses a profile written against a different agent — `400 validation_error` on `profile_id`, with a message naming the agent the profile does actually belong to.

This is deliberate and it will not be relaxed. A profile is written against one agent's persona and one agent's capability set. Pointing it at a different agent hands the model a role that agent cannot perform: the profile grants `send_payment_link`, the agent has no payment integration, and the model — which has no way to know that — confidently promises the customer a payment link that will never arrive. The refusal is cheap; the promise is not.

If two agents need the same script, create the profile twice, once per agent. `POST /profiles/{prf}/clone` gives you the copy, but note it clones **within the same agent**; for a different agent, create it there.

## The profile object

```json
{
  "profile_id": "prf_42",
  "account_id": "acc_1001",
  "agent_id": "agt_308",
  "profile_code": "sales_lead_qualify_1770040063267",
  "label": "Lead Qualify",
  "category": "sales",
  "description": "Qualifies inbound signups with BANT.",
  "system_instruction_template": "# System Instructions\n## Identity\nYou are Kim DiCaprio…",
  "greeting_message": "Hi, this is Kim DiCaprio from EqualWeb. Is this [Customer Name]?",
  "focuses": ["qualify_lead", "promote_product_service"],
  "allowed_capabilities": ["send_email", "end_call"],
  "tone": { "formality": "neutral", "empathy": "medium", "persistence": "high" },
  "behavior": { "max_attempts": 3, "recall_after_hours": 24, "optimal_call_duration": 180 },
  "completions": { "zoom_meeting_link": "https://calendly.com/kim/30min" },
  "refinement_rules": ["Never quote a price on the call"],
  "override_voice": null,
  "override_realtime_params": null,
  "is_default_for_category": false,
  "is_active": true,
  "created_at": "2026-02-02T13:46:20.577Z",
  "updated_at": "2026-04-06T11:38:40.827Z"
}
```

### The two outputs — what the call actually runs

| Field | What it does |
|---|---|
| **`system_instruction_template`** | **The role.** The complete system prompt for the call — identity, goal, rules, script. It replaces the agent's stored prompt outright (see above). Write it yourself, or let `POST /profiles/{prf}/build` write it from the inputs below. No length limit beyond the 2 MB body cap; live templates run to about 11,000 characters and are delivered whole. |
| **`greeting_message`** | The opening line. First in the outbound greeting chain, ahead of the agent's `outbound_greeting` and `greeting`. `[Customer Name]` is replaced with the contact's name; `{{businessName}}` and `{{agentName}}` with the agent's. Written by the builder too. |

### The gating and the facts — read directly on every call

| Field | What it does |
|---|---|
| **`allowed_capabilities`** | **The gating.** The tools the model may call on this call, validated against the type's list. `null` = no restriction; `[]` = nothing but talking; a list = exactly those. `send_sms` is never implied and must be listed. See [What the gating actually gates](capabilities.md#what-the-gating-actually-gates). |
| **`completions`** | An object of facts injected into the prompt as authoritative data (`[IMPORTANT DATA TO USE IN CONVERSATION]`), and the values the `send_*` tools send: `send_zoom_link` sends `completions.zoom_meeting_link`, `send_payment_link` sends `completions.payment_link_base_url`. Campaign-level constants — a booking link, a venue — not per-recipient values; those belong in `context.briefing`. Every value is stored as a string. |
| **`tone`** | `{ formality: formal \| neutral \| casual, empathy: high \| medium \| low, persistence: high \| medium \| low }`. Two effects: the builder writes the script in that register, **and** on a call placed through `POST /calls` it is rendered as a prompt block ("TONE FOR THIS CALL") with concrete behaviour per level — how to address the customer, how to handle a concern, how many times to make the ask. Campaign calls dialled from the dashboard do not get the block; their prompt is unchanged (product ruling, 2026-09-04: API-placed calls only). |

### The builder's inputs — read by `POST /profiles/{prf}/build`

These are the rest of the card. They are stored and returned on every read, and they change a call **only through a build**: writing them and not building leaves the template as it was.

| Field | What the builder does with it |
|---|---|
| `focuses` | The goals of the call, from the type's list ([`GET /outbound-capabilities`](capabilities.md)). The script is written around them and they become the rubric the end-of-call rating scores against. |
| `behavior` | `{ max_attempts, recall_after_hours, optimal_call_duration }` (integers; seconds for the duration). Pacing guidance in the script — how long to aim for, when to offer a callback. Retry pacing for an API call is `policy.max_attempts` / `retry_after_minutes` on `POST /calls`, not this. |
| `refinement_rules` | An array of plain-language rules ("Never quote a price", "Always confirm the email before sending"). Appended to the script as highest-priority instructions. This is the profile's "Improve" step, the same idea as the agent's [refinement rules](agents.md#improve-agent-refinement-rules). Up to 50 rules of up to 1,000 characters. |
| `label`, `description` | The profile name and a description of the audience. Both reach the builder (`description` is not otherwise sent to the model). |
| `category` | Picks the prompt template (`generate-sales-instructions`, `generate-collections-instructions`, …). |

### Identity and organisation

| Field | What it does |
|---|---|
| `profile_id` | Opaque id, `prf_{n}`. Do not parse it. |
| `agent_id` | The one agent this profile may be used with. Set at creation from the path; not updatable. |
| `account_id` | The owning account. Always the agent's owner. |
| `profile_code` | **Server-generated and opaque.** `{category}_{slug}_{epoch_ms}`, e.g. `sales_lead_qualify_1770040063267`. You cannot set it and should not parse it — it exists for internal lookups and dashboard compatibility. It is **not unique** in the database, so never treat it as a key. Use `profile_id`. |
| `label` | **Your text.** What a human calls this profile; what the dashboard shows. Not unique, not validated beyond length and control characters, and it is what `?q=` searches. Any Unicode is fine — Hebrew labels are the norm on live rows. |
| `is_active` | `false` makes the profile unusable on `POST /calls` (`400 validation_error`). Deactivate rather than delete when a campaign still refers to it. |
| `is_default_for_category` | Marks this profile as the default within its `(agent, category)` pair. Setting it clears the flag on the account's other profiles in that pair. The dashboard uses it to preselect; the API never applies it implicitly — `POST /calls` uses the `profile_id` you name and nothing else. |
| `created_at`, `updated_at` | ISO-8601 UTC. `updated_at` moves on every write, including a finished build. |

### Voice: `override_voice`

`override_voice` sets the voice for a call placed through **`POST /calls`** with this profile, taking precedence over the agent's own voice. Campaign calls dialled from the dashboard keep the agent's voice (API-placed calls only, 2026-09-04). On the Gemini outbound path it is not read at all.

Valid values are the same `voice_id`s as [`GET /voices`](voices.md) for the agent's provider. If the voice matters for campaigns too, set it on the agent (`PATCH /agents/{agt}` → `voice`).

### Read-only: `override_realtime_params`

Returned on every read, refused on write (`400 validation_error`): no builder and no runtime reads it. It is the only field of the object that is inert.

## `GET /agents/{agt}/profiles`

Lists the agent's profiles, newest first. Standard list envelope and keyset cursor.

| Parameter | Values |
|---|---|
| `q` | Case-insensitive substring match on `label`. |
| `category` | Exact match. |
| `is_active` | `true` / `false`. |
| `limit` | 1–200, default 50. |
| `cursor` | From `pagination.next_cursor`. |

```bash
curl -s "$B/agents/agt_308/profiles?q=lead" -H "Authorization: Bearer $SK"
```

```json
{
  "data": [ { "profile_id": "prf_54", "label": "Lead Qualify", "…": "…" } ],
  "pagination": { "limit": 50, "has_more": false, "next_cursor": null },
  "meta": { "request_id": "req_…", "environment": "production", "agent_id": "agt_308" }
}
```

Every field of the full object is present on each list row, including the whole `system_instruction_template` — profiles are few and the template is the thing you came for, so there is no summary form.

An agent the credential does not own is `404 agent_not_found`, not an empty list.

## `GET /profiles/{prf}`

The full object, including `system_instruction_template` and `allowed_capabilities` in full — never truncated, never summarised.

A profile belonging to another account is `404 profile_not_found`, never `403`. A `prf_` id is not a way to find out what exists elsewhere.

## `POST /agents/{agt}/profiles`

Creates a profile against the agent in the path. `label` and `category` are required; everything else is optional. A profile created with only those two plus a capability list is buildable as it is — the builder fills the wizard's defaults (`neutral` / `medium` / `medium` tone, 3 attempts, 24-hour recall, 180-second target).

```bash
curl -s -X POST "$B/agents/agt_308/profiles" \
  -H "Authorization: Bearer $SK" -H 'Content-Type: application/json' \
  -d '{
    "label": "PCI euro",
    "category": "collections",
    "description": "EU customers with a declined card, 30-60 days overdue.",
    "focuses": ["secure_payment_promise", "update_payment_method"],
    "allowed_capabilities": ["send_payment_link", "create_payment_promise", "send_sms", "end_call"],
    "completions": { "payment_link_base_url": "https://example.com/pay" },
    "tone": { "formality": "formal", "empathy": "high", "persistence": "medium" },
    "refinement_rules": ["Never threaten collection action", "Offer the payment plan before the link"]
  }'
```

Answers `201` with the created profile, including the generated `profile_code`. The template is still empty at this point — call `POST /profiles/{prf}/build` to have it written, or `PATCH` it in yourself.

`profile_code` is not accepted in the body — sending it is `400 unsupported_field`. Any unknown key is `400 unsupported_field` naming the key. There is no partial acceptance: a rejected create writes nothing.

If `allowed_capabilities` grants a tool that needs a connector the agent has not connected (`schedule_calendar_meeting` without a calendar), the create still succeeds and the response carries a [`warnings`](#warnings) array.

## `PATCH /profiles/{prf}`

Partial update. Send only the keys you want to change; everything else is untouched, including the instruction template.

```bash
curl -s -X PATCH "$B/profiles/prf_42" \
  -H "Authorization: Bearer $SK" -H 'Content-Type: application/json' \
  -d '{"allowed_capabilities": ["send_email", "end_call", "send_sms"], "tone": {"persistence": "low"}}'
```

Every field in [the profile object](#the-profile-object) is writable except `profile_id`, `account_id`, `agent_id`, `profile_code`, `override_realtime_params`, `created_at` and `updated_at`. A profile cannot be moved to another agent — create it there instead.

`null` clears a nullable field (`description`, `system_instruction_template`, `greeting_message`, `override_voice`, and every JSON field). An empty body is `400 empty_update`; an unknown key is `400 unsupported_field`. Capabilities and focuses are validated against the type — the stored one, or the one in the same request.

Remember which fields a PATCH changes on the next call by itself (`allowed_capabilities`, `completions`, `tone`, the two outputs) and which change nothing until a build (`focuses`, `behavior`, `refinement_rules`, `description`).

Answers `200` with the whole updated profile, plus [`warnings`](#warnings) when the saved profile grants a tool whose connector is missing.

## Warnings

`POST /agents/{agt}/profiles`, `PATCH /profiles/{prf}` and `POST /profiles/{prf}/clone` (and the `202` of [`POST /calls`](calls.md#everything-in-the-request-the-inline-profile), for the inline or stored profile the call runs on) add a top-level `warnings` array **only when there is something to say** — the field is absent on a clean response, never `[]`. Nothing is refused because of a warning; the write has happened.

Today there is one code:

```json
{
  "profile_id": "prf_42",
  "allowed_capabilities": ["schedule_calendar_meeting", "end_call"],
  "warnings": [
    {
      "code": "connector_not_connected",
      "capability": "schedule_calendar_meeting",
      "message": "Capability 'schedule_calendar_meeting' needs a connected google-calendar or microsoft-365-calendar connector on agent agt_308, and none is connected. Nothing is blocked, but during a call the tool answers \"No calendar connected\" and cannot book. Connect one in the dashboard (agent → Connectors); GET /agents/agt_308/connectors shows what is connected."
    }
  ],
  "meta": { "…": "…" }
}
```

| Field | Meaning |
|---|---|
| `code` | `connector_not_connected` — the profile grants a capability whose [`required_connector`](capabilities.md#required-connectors) is not connected on the agent (no `active` connector of any listed type) |
| `capability` | The capability id that needs it |
| `message` | Human-readable: which connector types would satisfy it and where to look |

The check reads the profile **as saved**, so a `PATCH` that changes only the `label` of a profile that already grants the calendar tool warns again until a calendar is connected or the capability is removed. Connect a calendar in the dashboard, confirm with `GET /agents/{agt}/connectors` (`calendars_connected: true`), and the warning stops; there is nothing to acknowledge.

**What the warning is about.** Without a connected calendar the tool is still offered to the model on the call; the model is told to check the calendar, the tool answers `No calendar connected`, nothing is booked and the call carries on. See [Capabilities → Required connectors](capabilities.md#required-connectors).

## `POST /profiles/{prf}/build` — the card's "Rebuild with AI"

Runs the same builder the dashboard runs: two model calls turn the card's inputs (type, label, description, focuses, capabilities, completions, tone, behavior, refinement rules, and the agent's knowledge base) into a new `system_instruction_template` and `greeting_message`, in the agent's language. The two model calls are billed to the account as `build` usage.

| Body field | Type | Default | Notes |
|---|---|---|---|
| `use_knowledge_base` | boolean | `true` | Fold the agent's knowledge summary into the script. Off for a script that should not know the products. |

```bash
curl -s -X POST "$B/profiles/prf_42/build" -H "Authorization: Bearer $SK" -H 'Content-Type: application/json' -d '{}'
```

```json
{
  "profile_id": "prf_42",
  "status": "building",
  "build": { "status": "building", "started_at": "2026-09-05T10:12:03Z", "finished_at": null, "requested_by": "key_42", "use_knowledge_base": true, "warnings": [], "error": null, "result": null },
  "meta": { "…": "…" }
}
```

`202`. **The build runs after the response**, for 30–90 seconds (a few minutes with retries): the public hosts sit behind a proxy with a 100-second limit, so it cannot be answered synchronously. Confirm it, do not assume it:

1. Poll `GET /profiles/{prf}/build` until `status` is `done` or `error`.
2. Then read the profile: `updated_at` has moved and `system_instruction_template` is the new script. **The profile row is the record**; the build state is process-local and forgets after an hour (or a restart), so `status: "none"` after a while does not mean no build happened.

A build **overwrites** the template and the greeting, including anything you wrote into them by hand. The inputs are untouched.

**Errors:** `400 validation_error` on `category` (not a type) or `allowed_capabilities` (empty — the builder needs at least one tool to write for; the message lists the type's tools) · `409 profile_build_in_progress` (one is running; poll) · `404 profile_not_found` · `403 insufficient_scope`.

## `GET /profiles/{prf}/build`

The state of the last build started through this API.

| Field | Meaning |
|---|---|
| `status` | `building` \| `done` \| `error` \| `none` (nothing started here, or it was more than an hour ago) |
| `build.warnings` | Non-fatal notes from the builder — a language retry, a greeting shortened to fit, and (since 2026-09-10) the refinement-rule report: `refinement rules: refinement rules conflict — "…" vs "…": why` for a pair the agent cannot both follow, `refinement rules: refinement rule unclear — "…": why` for a rule the normaliser could not read. The rules are normalised before they reach the template (one imperative sentence each, duplicates merged; the stored `refinement_rules` stay as written) — worth reading once |
| `build.error` | The failure, when `status` is `error`. The profile is left as it was |
| `build.result` | `{ instruction_characters, greeting_characters }` of what was written |
| `updated_at` | The profile's own `updated_at` — the durable confirmation |

## No `DELETE /profiles/{prf}` — removed 2026-09-06

> **No deletion through the API (Nir, 2026-09-06).** Nothing is deleted through the Public API: there is no `DELETE` route for conversations, subjects, outbound profiles, knowledge documents, do-not-call entries or webhook endpoints. Those paths answer `404 not_found` for every key, the master key included. Deleting is a dashboard action by the account owner. The two `DELETE` verbs that remain — `DELETE /calls/{call}` (cancel a call that has not been dialled) and `DELETE /credentials/{key}` (revoke a key) — are cancellations, not deletions of data.

To retire a profile through the API set `is_active: false` with `PATCH /profiles/{prf}`; a campaign that references it keeps working, and the profile stays available for audit. Removing it for good is a dashboard action.

## `POST /profiles/{prf}/clone`

Copies the profile under a new `label`, on the same agent, with a fresh `profile_code`. `201` with the new profile. Every field is copied, including the built template and the builder's inputs.

The clone is never the category default, whatever the original was: promoting it is a separate, explicit `PATCH`, so cloning cannot silently redirect a live campaign. It carries the same [`warnings`](#warnings) as the profile it copies.

```bash
curl -s -X POST "$B/profiles/prf_42/clone" \
  -H "Authorization: Bearer $SK" -H 'Content-Type: application/json' \
  -d '{"label": "Lead Qualify — Q4 script"}'
```

## Using a profile on a call

```bash
curl -s -X POST "$B/calls" \
  -H "Authorization: Bearer $SK" -H 'Content-Type: application/json' \
  -d '{
    "agent_id": "agt_308",
    "to_e164": "+972521234567",
    "profile_id": "prf_42",
    "context": { "briefing": "Invoice 4417, EUR 820, 34 days overdue. Last contact 12 Aug." }
  }'
```

`profile_id` is echoed on the call object, and `profile_source` says `profile`. Add an inline `profile` object to override individual fields for that one call, or send it without `profile_id` to place a call with no stored profile at all — see [Calls](calls.md#everything-in-the-request-the-inline-profile).

## Errors

| Status | `code` | When |
|---|---|---|
| 400 | `invalid_id` | `prf` / `agt` has the wrong prefix or shape |
| 400 | `unsupported_field` | unknown key in the body, including `profile_code` |
| 400 | `validation_error` | `category` not a type; a capability or focus the type may not hold (carries `unknown_capability` / `unknown_focus` and the allowed list); `label` or `category` empty, too long, or containing control characters; a `tone` value outside its enum; a `behavior` value out of range; wrong JSON type; a write to `override_realtime_params`; a build on a profile that cannot be built |
| 400 | `empty_update` | `PATCH` with an empty body |
| 400 | `unsupported_filter` | unknown query parameter, or a non-boolean `is_active` |
| 401 | `missing_credentials` | no `Authorization` header |
| 403 | `insufficient_scope` | the key lacks `agents:read` / `agents:write` |
| 404 | `agent_not_found` | the agent does not exist, or belongs to another account |
| 404 | `profile_not_found` | the profile does not exist, or belongs to another account |
| 409 | `profile_build_in_progress` | `POST /build` while a build is running |

## Notes on data you may already have

Profiles predate this API, so an account's existing profiles were written by the dashboard wizard and by its AI builder. Two consequences:

- **A stored JSON column may be malformed.** Where it is, the field reads back as `null` rather than failing the request. `null` on `focuses` therefore means either "not set" or "unreadable" — the API does not distinguish. Writing the field replaces it.
- **`category` on an old row may not be a type.** It reads back as stored; it must be set to a type before the profile can be built or given focuses.

---

**UiriX Public API v1** · [Overview](README.md) · [Quickstart](quickstart.md) · [Authentication](authentication.md) · [Scopes](scopes.md) · [Conventions](conventions.md) · [Accounts & Users](accounts-and-users.md) · [Agents](agents.md) · [Capabilities](capabilities.md) · [Profiles](profiles.md) · [Conversations](conversations.md) · [Chat via API](chat-via-api.md) · [Widget Events](widget-events.md) · [Webhooks](webhooks.md) · [Calls](calls.md) · [Metrics](metrics.md) · [Voices](voices.md) · [Enums](enums.md) · [Partners](partners.md) · [Admin](admin.md) · [MCP](mcp.md) · [Changelog](changelog.md) · [OpenAPI](openapi.yaml)


<!-- ===== README.he.md ===== -->

# UiriX Public API v1 — סקירה בעברית

> **Contract v1.0.** Every endpoint documented on these pages is implemented and live.
> Live docs: `GET /api/ext/v1/docs` (Redoc) and `GET /api/ext/v1/openapi.json`.

> **חדשים כאן? קראו קודם את [Start here](START_HERE.md)** — איך סוכן בנוי (חשבון ← סוכן ← ידע ← סיכום ← הוראות ← שיפור ← קול ← בדיקה ← הפעלה ← מעקב), איזה endpoint עושה כל שלב, ומה דורש בנייה מחדש.

> **הורדה:** כל התיעוד מוגש מה-API עצמו — `GET /docs/download` (zip), `GET /docs/all.md` (קובץ אחד), `GET /docs/pages/{name}.md` (עמוד), `GET /openapi.yaml`. בפיתוח: `https://dev-dashboard.uirix.com/api/ext/v1/docs/download`. דף הרפרנס: `GET /docs`.

## מה זה

ה-API מאפשר למערכת חיצונית (למשל EqualWeb) לגשת לחשבון UiriX בצורה תכנותית: סוכנים, כל השיחות בכל הערוצים (widget, טלפון, dashboard) כ-turns מובנים, סיכומים מובנים, בלוק identity לחיבור ל-CRM, מטריקות מצטברות, שיחות יוצאות ו-webhooks חתומים.

## כתובות בסיס

| סביבה | Base URL | הערות |
|---|---|---|
| **Production** | `https://dashboard.uirix.com/api/ext/v1` | חי (אומת 30.9.2026: `GET /health` → 200). מפתחות ונתוני לקוחות של פרודקשן; מפתח המאסטר שם שונה מזה של dev. |
| Development | `https://dev-dashboard.uirix.com/api/ext/v1` | נתוני dev ומפתחות dev — כאן כל עמוד נבדק קודם. |
| Production, host ייעודי (מתוכנן) | `https://api.uirix.com/v1` | **לא פרוס — 404.** דורש site משלו ב-IIS. |

## עקרונות מרכזיים

- **אימות:** `Authorization: Bearer <key>`. מפתח master (`uirix_mk_live_`) לניהול tenants ומפתחות; מפתחות אישיים (`uirix_sk_live_`) עם scopes מפורשים.
- **מזהים אטומים** עם prefix: `acc_`, `agt_`, `conv_p|u|d`, `call_`, `key_`, `whk_`, `dlv_`, `doc_f|c`, `pv_`, `dnc_`.
- **timestamps** תמיד ISO-8601 ב-UTC עם `Z`.
- **מפתח שלא ידוע = `null`**, לעולם לא חסר. אובייקטים מקוננים (`utm`, `escalated_to_human`) תמיד קיימים.
- **מעטפת שגיאה אחידה:** `{ "error": { "code", "message", "field", "request_id" }, "meta": {...} }` ו-`X-Request-Id` על כל תגובה.
- **Rate limits** לפי credential ולפי מחלקת endpoint; `429` עם `Retry-After` אינו צורך מכסה.
- **Pagination** עם cursor אטום הכפוף לסט הפילטרים; ללא `from/to/updated_since` — חלון ברירת מחדל של 30 יום.
- **ללא streaming.** chat-via-API הוא request/response.
- **נכנס מול יוצא.** הסוכן (`/agents/{agt}`) מגדיר את הטלפון הנכנס, הצ'אט והצ'אט הקולי; שיחה יוצאת רצה על פרופיל (`/profiles/{prf}`) או על הפרופיל ה-inline ב-`POST /calls`, ומהסוכן היא יורשת רק קול, שפה ובסיס ידע.
- **כתיבה נכנסת לתוקף.** מ-5.9.2026 כתיבת הנחיות דרך ה-API מסונכרנת גם לפרומפט המקומפל שהווידג'ט מריץ, וחוקי שיפור (`*_refinement_rules`) מוחלים בפועל — לא רק נשמרים.

## החלטות מחייבות שכדאי להכיר

| נושא | החלטה |
|---|---|
| OAuth2 | client-credentials לאינטגרציות — נדחה, מפתחות סטטיים בלבד. מ-30.9.2026 יש שרת OAuth 2.1 (authorization code + PKCE, אישור של משתמש מחובר) לקונקטור ה-MCP בלבד ([mcp.md](mcp.md)) — לא credential לשרת |
| נתיבי §11.2 המקוננים | aliases לנתיבים השטוחים (`/agents/{agt}`, `/agents/{agt}/prompts`, `/agents/{agt}/kb/documents`) |
| chat-via-API | תמיד OpenAI, ללא קשר ל-`ai_provider` של הסוכן; מחויב כמו widget |
| `policy.voicemail: "leave_message"` | לא נתמך ב-v1 → `400 unsupported_policy`; רק `hang_up` |
| סיכומים מובנים | רק לחשבונות עם credential/webhook endpoint פעיל |

## מפת העמודים (אנגלית)

| עמוד | תוכן |
|---|---|
| [README.md](README.md) | סקירה, base URLs, מוסכמות, מפת endpoints, הבדלים מה-spec המקורי |
| [quickstart.md](quickstart.md) | הליכה של 10 דקות: health → agents → conversations → chat → webhook |
| [authentication.md](authentication.md) | מפתחות, הנפקה, rotation (שני מפתחות חיים), `X-Uirix-Account`, IP allow-list |
| [scopes.md](scopes.md) | טבלת scopes מלאה ומה כל scope פותח |
| [conventions.md](conventions.md) | request-id, קטלוג שגיאות מלא, rate limits, cursor, timestamps, `meta` |
| [accounts-and-users.md](accounts-and-users.md) | `/users`, `/credentials`, `/audit` (master) ו-`/accounts*` |
| [agents.md](agents.md) | **הצד הנכנס**: יצירת סוכן מאתר (`POST /accounts/{acc}/agents/build` — אשף V4 בצד השרת: כתובת, שפה, קול; סריקה → ידע → ברכה → הוראות → מוכן, עם פולינג על כל שלב), הסוכן על טלפון נכנס, צ'אט וצ'אט קולי — איזה שדה מניע איזה משטח, 45 שדות PATCH, Improve Agent (חוקי שיפור), גרסאות prompt, KB כולל קריאה ועדכון של הסיכום, widget, voice |
| [capabilities.md](capabilities.md) | שני קטלוגי היכולות: יכולות סוכן (ידע/פעולה) ויכולות outbound לפי סוג פרופיל; מה כל אחת עושה בפועל ואיזה `completions` היא צריכה |
| [profiles.md](profiles.md) | **הצד היוצא**: שלושת סוגי הפרופיל (collections / sales / events) והיכולות של כל אחד, כל שדות הכרטיס, `POST /profiles/{prf}/build` ("Rebuild with AI"), tone שמגיע לשיחה |
| [voices.md](voices.md) | אילו מזהי voice תקפים לכל provider, ושמות השיווק שהלקוח רואה |
| [conversations.md](conversations.md) | פילטרים, אובייקט השיחה, turns, summary, identity, metrics, הקלטות, redaction, מחיקה, retention |
| [chat-via-api.md](chat-via-api.md) | יצירת שיחה, שליחת הודעה, identify, end, חיוב |
| [webhooks.md](webhooks.md) | endpoints, אירועים, אימות HMAC (Node / PHP / Python), retries, replay |
| [calls.md](calls.md) | `POST /calls` — עם פרופיל שמור, או **הכל בבקשה** (`profile` inline: הנחיות, פתיח, קול, טון, יכולות, נתונים), סולם העדיפויות, סטטוסים, חלונות חיוג, DNC, quotas, ביטול |
| [campaigns.md](campaigns.md) | **חיוג המוני**: רשימות אנשי קשר (`/agents/{agt}/lists`, `/lists/{lst}/import`, `/lists/{lst}/items`) וקמפיינים (`/campaigns`, start / pause / stop, התקדמות, שורה לכל איש קשר); נרמול טלפונים, חוקי אזורי זמן (חלון החיוג לפי השעה המקומית של הנמען), DNC, מגבלות, walkthrough ב-curl |
| [metrics.md](metrics.md) | `/metrics/conversations` והגדרות המדדים הקבועות |
| [admin.md](admin.md) | **מפתח מאסטר בלבד.** שכבת הניהול שמפעיל (אנושי או AI) מריץ דרכה את הפלטפורמה: `/admin/overview`, חיפוש לקוחות ותיק לקוח, רשימת סוכנים חוצת-חשבונות, סטטוס ושגיאות של ארבעת השירותים, קונפיגורציית מודלים, מיגרציות; כתיבות (זיכוי ארנק, שינוי תוכנית, השעיה, קישור כניסה, מודל, פעולות על סוכן) — כולן עם `confirm: true` + סיבה, נרשמות ביומן `GET /admin/actions`. אין DELETE. כולל cookbook למפעיל (2026-09-30) |
| [mcp.md](mcp.md) | **קונקטור MCP.** ניהול הסוכנים של החשבון מ-Claude (claude.ai, Desktop, Code) או מ-ChatGPT בשפה חופשית: איך מתחברים, שרת ה-OAuth 2.1 (discovery, PKCE, רישום דינמי, טוקנים), קבוצות ההרשאה במסך האישור, קטלוג הכלים, resources ו-prompts, איך העוזר מתנהג, פתרון תקלות — ותפריט הצ'אט uirix |
| [enums.md](enums.md) | כל ה-enums המגורסים |
| [changelog.md](changelog.md) | v1.0 draft + פריטים מתוכננים |
| [openapi.yaml](openapi.yaml) | OpenAPI 3.1 לכל ה-surface |

---

**UiriX Public API v1** · [Overview](README.md) · [Quickstart](quickstart.md) · [Authentication](authentication.md) · [Scopes](scopes.md) · [Conventions](conventions.md) · [Accounts & Users](accounts-and-users.md) · [Agents](agents.md) · [Capabilities](capabilities.md) · [Profiles](profiles.md) · [Conversations](conversations.md) · [Chat via API](chat-via-api.md) · [Widget Events](widget-events.md) · [Webhooks](webhooks.md) · [Calls](calls.md) · [Campaigns](campaigns.md) · [Metrics](metrics.md) · [Voices](voices.md) · [Enums](enums.md) · [Partners](partners.md) · [Admin](admin.md) · [MCP](mcp.md) · [Changelog](changelog.md) · [OpenAPI](openapi.yaml)


<!-- ===== voices.md ===== -->

# Voices

> Scope `agents:read`.

The set of voices an agent may use, per provider.

You need this because the valid set **changes with the agent's provider**. A UiriX agent runs on either OpenAI or Gemini (`ai_provider`), and the two catalogues do not overlap at all. `PATCH /agents/{agt}` refuses a voice that does not belong to the agent's provider, so a mismatch is a `400` rather than an agent that silently cannot start a session — but the refusal only tells you the value was wrong. This endpoint is how you find a right one.

## `GET /voices`

| Parameter | Values | Default |
|---|---|---|
| `provider` | `openai`, `gemini`, `gpt_live` | every provider offered on this platform (`meta.providers`) |
| `include_inactive` | `true`, `false` | `false` |

Inactive voices are ones the product has retired. They are hidden by default because offering one as a choice is how an integration ends up with a broken agent; ask for them only when you need to explain a voice an existing agent already has.

This is reference data, identical for every account, so it takes no account parameter.

```bash
curl -s "$UIRIX_BASE_URL/voices?provider=openai" \
  -H "Authorization: Bearer $UIRIX_API_KEY"
```

```json
{
  "data": [
    {
      "voice_id": "alloy",
      "provider": "openai",
      "name": "Alloy",
      "gender": "female",
      "personality": "Versatile & Balanced",
      "description": "Professional all-rounder for any business need",
      "active": true,
      "requires_large_model": false,
      "localized_names": { "en": "Ava", "he": "נועה", "es": "Lucía", "…": "… 17 languages in all" }
    }
  ],
  "meta": {
    "request_id": "req_01M1NYVT7B9Q90Q17CH1HJ3PZK",
    "environment": "production",
    "providers": ["openai"],
    "include_inactive": false,
    "counts": { "openai": 10 }
  }
}
```

## Faces

Every voice carries its picture (2026-09-07). The files live on the UiriX CDN and are named after the voice's display `name`, not its `voice_id` — `alloy` → `Alloy.webp`. Build your picker straight from the response and you get the same faces as our own "create your voice agent" page:

```json
{
  "voice_id": "alloy", "name": "Alloy",
  "image_url": "https://cdn.uirix.com/shared/WEBP/Alloy.webp",
  "image_jpg_url": "https://cdn.uirix.com/shared/WEBP/Alloy.jpg",
  "video_url": "https://cdn.uirix.com/shared/MP4/Alloy.mp4",
  "video_talking_url": "https://cdn.uirix.com/avatars/voices/Alloy/avatar-talking.mp4?v=1758920000",
  "video_quiet_url": "https://cdn.uirix.com/avatars/voices/Alloy/avatar-quiet.mp4?v=1758920000",
  "sample_url": "https://cdn.uirix.com/shared/samples/Alloy.en.mp3?v=1"
}
```

The URLs are public and cacheable; no key is needed to fetch them. `image_url`, `video_talking_url` and `video_quiet_url` are present for **every** voice of both providers (29 Sept 2026); `image_jpg_url` is not — fall back to the image when a field is `null`. The two clips are what the UiriX widget itself plays: loop `video_quiet_url` while the agent listens and switch to `video_talking_url` while it speaks, muted, `playsinline`; keep each URL exactly as returned (the `?v=` is a cache version).

| Field | Meaning |
|---|---|
| `voice_id` | The value to send as `voice` in `PATCH /agents/{agt}`. |
| `provider` | Which `ai_provider` this voice belongs to. |
| `name` | The provider's own label — in practice the `voice_id` capitalised (`alloy` → `Alloy`). It is **not** the name a customer sees; that is in `localized_names`. |
| `gender`, `personality`, `description` | Descriptive, for building a picker. |
| `active` | `false` means retired. Do not offer it as a new choice. |
| `image_url` | The voice's face on the CDN, WebP. The same art the dashboard shows, so your wizard can display identical faces. |
| `image_jpg_url` | The same picture as JPEG, for a client that cannot take WebP. `null` when only WebP exists. |
| `video_url` | A short talking loop — kept for existing integrations. The older single clip where one exists (the OpenAI voices), otherwise the same file as `video_talking_url`; present for every voice since 29 Sept 2026. |
| `video_talking_url` | The face talking: the clip the widget plays while the agent speaks (`avatars/voices/<Name>/avatar-talking.mp4`, versioned). Every OpenAI and Gemini voice. |
| `video_quiet_url` | The face at rest: the clip the widget loops while the agent listens (`avatar-quiet.mp4`, versioned). Every OpenAI and Gemini voice. |
| `sample_url` | An MP3 of the voice **saying its marketing line in English** — see [Hear the voice](#hear-the-voice). Present for every OpenAI and Gemini voice; `null` only when the file has not been rendered yet (then `GET /voices/{voice_id}/sample` renders it). |
| `requires_large_model` | The voice is only available when the agent has `use_large_model` set. |
| `localized_names` | Marketing name per locale, keyed by language code. What the UiriX dashboard shows a customer. |

`requires_large_model` is `false` for every voice in both catalogues today. Read it rather than assuming, but do not build a flow around it.

## Hear the voice

Every OpenAI and Gemini voice has a **sample**: a 5–8 second MP3 of the voice saying its own marketing line ("Choose me to be your virtual assistant! I'm Ava, versatile and balanced…"), in **English**, with the voice's English marketing name. It is what the UiriX dashboard's "listen" button plays, rendered once and served from the CDN like the pictures — public, cacheable, no key needed to play it.

**Build the picker from `GET /voices`** — every voice comes with `image_url` and `sample_url`, so a play button next to each face is one `<audio>` element:

```html
<img src="https://cdn.uirix.com/shared/WEBP/Alloy.webp" alt="Ava">
<button onclick="new Audio('https://cdn.uirix.com/shared/samples/Alloy.en.mp3?v=1').play()">▶ Listen</button>
```

```bash
curl -s -H "Authorization: Bearer $SK" "$B/voices?provider=gemini" | jq '.data[] | {voice_id, name: .localized_names.en, sample_url}'
```

### `GET /voices/{voice_id}/sample` — scope `agents:read`

The same file by voice id, for a client that does not want to read the catalogue first, or when `sample_url` is `null`:

| Param | Notes |
|---|---|
| `voice_id` | Path — `alloy`, `kore`, … (case-insensitive) |
| `provider` | Query, optional — `openai` \| `gemini`; only needed if two catalogues ever share an id |
| `download` | Query, optional — `true` streams the MP3 bytes (`audio/mpeg`) instead of the JSON |

Answers **`200`** with the CDN URL. When the file does not exist yet it is rendered first (a few seconds, one render at a time per voice), then the answer follows with `rendered_now: true`:

```json
{ "voice_id": "kore", "provider": "gemini", "name": "Kore", "language": "en",
  "sample_url": "https://cdn.uirix.com/shared/samples/Kore.en.mp3?v=1", "rendered_now": false, "bytes": 80832 }
```

In a browser, play `sample_url` from the CDN directly — an `<audio>` element cannot send the bearer header, and the CDN file needs none. From a server or a shell, `download=true` gives the bytes:

```bash
curl -s -H "Authorization: Bearer $SK" "$B/voices/kore/sample?download=true" -o kore.mp3
```

**Errors:** `404 not_found` (`field: "voice_id"`) for an unknown voice · `401` · `403 insufficient_scope` · `429 rate_limited` (read class).

Notes: the samples are **English only** — the sentence the voice says does not change with the customer's language; the agent itself speaks the agent's language. The `?v=` on the URL is a cache version: keep the whole URL as returned rather than building it yourself. Samples exist for the `openai` and `gemini` catalogues; a provider without samples answers `404` on the sample route and `null` in `sample_url`.

## The name your customer sees is not the `voice_id`

The UiriX dashboard never shows `voice_id`. It shows a **marketing name**, and that name is per language — the same voice is "Chloe" to an English customer and "מיכל" to a Hebrew one. So a support ticket saying *"our agent should use Chloe"* is not asking for a voice called `chloe`; it is asking for `shimmer`.

`localized_names` is the mapping, and it is the only reliable one: resolve a name your customer typed by searching `localized_names` across the locales you care about, then send the `voice_id` you land on.

The common OpenAI voices, by their English marketing name:

| Customer sees (`en`) | Send as `voice` | Hebrew (`he`) |
|---|---|---|
| Ava | `alloy` | נועה |
| Noah | `ash` | יואב |
| Ethan | `echo` | איתי |
| Grace | `sage` | הילה |
| Zoe | `marin` | תמר |
| Liam | `cedar` | עומר |
| Sophie | `coral` | שירה |
| Chloe | `shimmer` | מיכל |
| Mia | `ballad` | מאיה |
| Oliver | `verse` | רועי |

Gemini has its own thirty, mapped the same way — `zephyr` is "Aria", `kore` is "Victoria", `aoede` is "Hannah". Every voice in both catalogues carries all **17 supported languages** in `localized_names`.

Two things to hold on to:

- **Do not hard-code this table.** It is a snapshot of `GET /voices` on the day it was written, and the product edits these names. Read the endpoint.
- **Do not treat the name as an identifier.** Marketing names are not unique across providers or guaranteed stable across locales; only `voice_id` is the identity. The name is for showing a human and for recognising what one told you.

Note that agent *names* in a UiriX account are frequently marketing names too, because the onboarding wizard suggests the voice's name as the agent's. An agent called "Chloe" tells you nothing about which voice it uses — read `voice.voice_id` off `GET /agents/{agt}`.

## The third provider: `gpt_live`

GPT-Live-1 (OpenAI's full-duplex voice model, 13 Sept 2026). 22 voices — 12 new (`quartz`, `ripple`, `vesper`, `willow`, `gleam`, `bossa`, `stone`, `meridian`, `tempo`, `beacon`, `delta`, `cinder`) and 10 shared with `openai`. On a phone call the voice model listens while it speaks (no turn-detection settings apply) and delegates facts and tools to a text backend. Offered only where the platform has it enabled; until then `ai_provider: "gpt_live"` is a `400 validation_error` and `GET /voices?provider=gpt_live` too.

## Changing an agent's provider

Do these in one edit, not two:

```bash
# 1. What is valid on the provider you are moving to
curl -s "$UIRIX_BASE_URL/voices?provider=openai" -H "Authorization: Bearer $UIRIX_API_KEY"

# 2. Change provider and voice together
curl -s -X PATCH "$UIRIX_BASE_URL/agents/agt_308" \
  -H "Authorization: Bearer $UIRIX_API_KEY" -H 'Content-Type: application/json' \
  -d '{"ai_provider":"openai","voice":"alloy"}'
```

Changing `ai_provider` alone would leave the agent holding a voice the new provider has never heard of, so the API refuses it:

```json
{
  "error": {
    "code": "validation_error",
    "field": "voice",
    "message": "The agent's current voice 'ballad' does not exist for provider 'gemini', so it must be changed in the same request. That voice belongs to 'openai'. Valid choices include: zephyr, kore, aoede, leda, callirrhoe. See GET /voices?provider=gemini."
  }
}
```

The same check rejects a `voice` that exists in neither catalogue, and an `ai_provider` that is not `openai`, `gemini` or `gpt_live`. `gpt_live` (GPT-Live-1, 13 Sept 2026) is accepted only where the platform has it switched on; elsewhere it is a `400 validation_error`. It runs before anything is written, so a rejected `PATCH` changes nothing at all — including the other fields in the same body. An agent that has no voice configured yet is left alone; the check has nothing to compare against and the session mint applies its own default.

## Errors

| Status | `code` | When |
|---|---|---|
| 400 | `validation_error` | `provider` is not `openai`, `gemini` or `gpt_live` (or `gpt_live` is not enabled here), or `include_inactive` is not `true` / `false` |
| 401 | `missing_credentials` | no `Authorization` header |
| 403 | `insufficient_scope` | the key lacks `agents:read` |

Related: [agents.md](agents.md) for `PATCH /agents/{agt}`, [enums.md](enums.md) for the other closed value sets.

---

**UiriX Public API v1** · [Overview](README.md) · [Quickstart](quickstart.md) · [Authentication](authentication.md) · [Scopes](scopes.md) · [Conventions](conventions.md) · [Accounts & Users](accounts-and-users.md) · [Agents](agents.md) · [Capabilities](capabilities.md) · [Profiles](profiles.md) · [Conversations](conversations.md) · [Chat via API](chat-via-api.md) · [Widget Events](widget-events.md) · [Webhooks](webhooks.md) · [Calls](calls.md) · [Metrics](metrics.md) · [Voices](voices.md) · [Enums](enums.md) · [Partners](partners.md) · [Admin](admin.md) · [MCP](mcp.md) · [Changelog](changelog.md) · [OpenAPI](openapi.yaml)


<!-- ===== webhooks.md ===== -->

# Webhooks

Pull is not enough for CRM enrichment and conversion signals — they need to fire within a minute of a conversation ending. Webhooks push signed JSON to your HTTPS endpoint with at-least-once delivery, a 24-hour retry ladder, a dead-letter queue and replay.

All endpoints on this page require scope `webhooks:manage`. Variables: `$B` base URL, `$SK` personal key, `$J` = `Content-Type: application/json`.

## Endpoint management

### Endpoint object

| Field | Type | Notes |
|---|---|---|
| `endpoint_id` | string | `whk_7` |
| `account_id` | string | |
| `url` | string | `https://` only |
| `description` | string \| null | |
| `events` | string[] | Subscribed event names (see [Events](#events)) |
| `enabled` | boolean | |
| `api_version` | `"v1"` | Payload version this endpoint receives |
| `secret` | string | **Only on create and rotate** — `whsec_` + 43 chars |
| `previous_secret_expires_at` | ISO-8601 \| null | Set during rotation |
| `consecutive_failures` | int | Dead deliveries in a row; auto-disable at 50 |
| `disabled_reason` | string \| null | `consecutive_failures` \| `user` |
| `created_at` / `updated_at` | ISO-8601 | |

### `POST /webhooks/endpoints`

| Body field | Type | Required | Notes |
|---|---|---|---|
| `url` | string | yes | Public `https://` URL. Private / loopback / link-local hosts, UiriX hosts and plain `http://` are `400 invalid_url` (dev environment allows `localhost`) |
| `events` | string[] | yes | At least one known event; unknown → `400 unsupported_event` |
| `description` | string | no | |
| `account_id` | string | no | Which account the endpoint belongs to. An endpoint belongs to exactly one account, so a key covering more than one — and the master key — **must** name it here or in `X-Uirix-Account`, otherwise the call is `403 forbidden_account` |

```bash
curl -s -H "Authorization: Bearer $SK" -H "$J" -X POST "$B/webhooks/endpoints" -d '{
  "url": "https://hooks.example.com/uirix",
  "events": ["conversation.started","conversation.ended","conversation.escalated","summary.ready","conversation.deleted","call.completed","call.failed","call.no_answer"],
  "description": "warehouse + HubSpot sync"
}' | jq .
```

```json
{
  "endpoint_id": "whk_7", "account_id": "acc_17",
  "url": "https://hooks.example.com/uirix", "description": "warehouse + HubSpot sync",
  "events": ["conversation.started","conversation.ended","conversation.escalated","summary.ready","conversation.deleted","call.completed","call.failed","call.no_answer"],
  "enabled": true, "environment": "live", "api_version": "v1",
  "secret": "whsec_k9Q2mX7pL4vT1bN8rJ3cW6yF0hA5dZ2sE9uG4iK1oM8",
  "previous_secret_expires_at": null, "consecutive_failures": 0, "disabled_reason": null,
  "created_at": "2026-09-03T14:40:00Z", "updated_at": "2026-09-03T14:40:00Z",
  "meta": { "request_id": "req_01J8ZD1A2B", "environment": "production" }
}
```

`201`. The `secret` is shown once and stored encrypted; there is no retrieval endpoint.

**Errors:** `400 invalid_url` · `400 unsupported_event` (`field: "events"`) · `400 validation_error` · `403 insufficient_scope`.

### `GET /webhooks/endpoints` · `GET /webhooks/endpoints/{whk}`

List envelope / single object, never with `secret`.

The list is **not paged**: it takes no `limit` or `cursor`, returns every endpoint for the account in one response, and always reports `pagination.has_more: false`. Do not write a cursor walk against it. **Errors:** `404 not_found`.

### `PATCH /webhooks/endpoints/{whk}`

Body: any of `url`, `events`, `description`, `enabled`. Setting `enabled: true` on an auto-disabled endpoint resets `consecutive_failures`. Same URL validation as create. **Errors:** `400 invalid_url` / `unsupported_event` / `unsupported_field` · `404`.

### No `DELETE /webhooks/endpoints/{whk}` — removed 2026-09-06

> **No deletion through the API (Nir, 2026-09-06).** Nothing is deleted through the Public API: there is no `DELETE` route for conversations, subjects, outbound profiles, knowledge documents, do-not-call entries or webhook endpoints. Those paths answer `404 not_found` for every key, the master key included. Deleting is a dashboard action by the account owner. The two `DELETE` verbs that remain — `DELETE /calls/{call}` (cancel a call that has not been dialled) and `DELETE /credentials/{key}` (revoke a key) — are cancellations, not deletions of data.

To stop deliveries, `PATCH /webhooks/endpoints/{whk}` with `enabled: false`; the endpoint, its secret and its delivery history stay in place. Removing it is a dashboard action.

### `POST /webhooks/endpoints/{whk}/rotate-secret`

Mints a new current secret; the old one becomes `previous` for **7 days**. During the overlap every delivery carries both `X-Uirix-Signature` (current) and `X-Uirix-Signature-Previous` (previous).

```json
{ "endpoint_id": "whk_7", "secret": "whsec_pZ8wQ1nB…", "previous_secret_expires_at": "2026-09-10T14:45:00Z", "meta": { "…": "…" } }
```

### `POST /webhooks/endpoints/{whk}/test`

> Rate class `build` (5 / min) and quota `max_webhook_tests_per_day` (default 100, shared with replays) — a ping is an outbound HTTP call on request (2026-09-06). Over it: `429 quota_exceeded`.

Sends a synchronous `ping` event and reports the result (no retries).

```json
{ "delivered": true, "response_code": 200, "duration_ms": 212, "delivery_id": "dlv_01J8ZD2C3D4E5F", "error": null, "meta": { "…": "…" } }
```

A timeout (10 s) or non-2xx gives `delivered: false` with `error`.

## Events

| Event | Fires when | `data` contains |
|---|---|---|
| `conversation.started` | The **first user turn** (not widget open, not greeting) | `conversation_id`, `agent_type`, `uirix_agent_id`, `agent_version`, `prompt_version`, `started_at`, `updated_at`, full `identity`, `links` |
| `conversation.ended` | Status becomes `completed`, `abandoned` or `failed` — within 60 s | Full conversation object **with transcript** (≤ 500 turns, `tool` turns included since 2026-09-10 — see [Conversations › Tool turns](conversations.md#tool-turns)), `identity`, `metrics`, `summary` (pending if not ready), `links` |
| `conversation.escalated` | A handoff to a human is triggered (handoff / transfer tool, or `escalated_to_human.value` in the summary) | `conversation_id`, `handoff_reason`, `escalated_to: { at, to }`, `identity`, `transcript` up to that point (`tool` turns included) |
| `summary.ready` | The async summary finished — **including failures** | `conversation_id`, full `summary` object (`summary_status: "ready"` or `"failed"`, with `answered_by`, `human_requested`, `identity_extracted` since 2026-09-10), `identity`, `updated_at` |
| `conversation.deleted` | Erasure on request, retention, or account closure | `conversation_id`, `account_id`, `deleted_at`, `reason` |
| `call.completed` | An outbound call ended with a connected conversation | Full conversation object + call fields (`call_id`, `answered_by`, `disclosure_played`, `attempts`, `matter_key`, `dnc_requested`…) |
| `call.failed` | Outbound call failed permanently | Call object with `error` |
| `call.no_answer` | Outbound call not answered / busy / voicemail after the last attempt | Call object |
| `ping` | `POST …/test` | `{ "endpoint_id", "sent_at" }` |

Exactly one event of each type is emitted per conversation (de-duplicated at the source), except `call.*` which is one per call.

### Payload envelope

```json
{
  "event": "conversation.ended",
  "delivery_id": "dlv_01J8Z9M4T7QX3R",
  "emitted_at": "2026-08-20T09:22:07Z",
  "api_version": "v1",
  "account_id": "acc_17",
  "data": { "…": "…" }
}
```

`data` always carries `updated_at` so a late-arriving older event can be discarded. Every payload carries the **full identity block** (all 23 keys), even `conversation.started`.

### Example — `conversation.ended`

```json
{
  "event": "conversation.ended",
  "delivery_id": "dlv_01J8Z9M4T7QX3R",
  "emitted_at": "2026-08-20T09:22:07Z",
  "api_version": "v1",
  "account_id": "acc_17",
  "data": {
    "conversation_id": "conv_p9876",
    "account_id": "acc_17",
    "agent_type": "chat",
    "uirix_agent_id": "agt_245",
    "agent_version": "agt245-v3",
    "prompt_version": "chat-v3",
    "model": "gpt-5-nano",
    "status": "completed",
    "started_at": "2026-08-20T09:14:02Z",
    "ended_at": "2026-08-20T09:21:47Z",
    "updated_at": "2026-08-20T09:21:47Z",
    "duration_seconds": 465,
    "turn_count": 14,
    "language": "en-US",
    "channel_detail": { "channel": "widget", "widget_version": "3.7.0", "device": "desktop" },
    "identity": {
      "account_id": "acc_17", "equalweb_agent_id": "1619", "visitor_id": "vis_9f2c81a0", "session_id": "ses_7723ab19",
      "email": "procurement@example-shop.de", "email_source": "in_conversation", "name": null, "company": null,
      "phone_e164": null, "caller_id_name": null, "called_number_e164": null, "auth_user_id": null, "auth_user_email": null,
      "page_url": "https://www.equalweb.com/pricing/", "page_title": "Pricing | EqualWeb", "referrer": "https://www.google.com/",
      "landing_page": "https://www.equalweb.com/european-accessibility-act/",
      "utm": { "utm_source": "google", "utm_medium": "cpc", "utm_campaign": "eaa-eu-enterprise", "utm_term": "european accessibility act compliance", "utm_content": "rsa_v3", "gclid": "Cj0KCQjw_example", "fbclid": null, "li_fat_id": null, "msclkid": null },
      "country": "DE", "user_agent": "Mozilla/5.0 …", "custom": { "plan": "free", "site_id": "55231" }, "ip_country_only": true, "do_not_sell": false
    },
    "summary": { "summary": null, "intent": null, "topics": [], "sentiment": null, "sentiment_score": null, "outcome": null, "action_items": [], "escalated_to_human": { "value": false, "at": null, "to": null }, "resolved": null, "handoff_reason": null, "summary_status": "pending", "summary_model": null },
    "metrics": { "containment": true, "escalation": false, "avg_agent_latency_ms": 1840, "p95_agent_latency_ms": 3120, "low_confidence_turns": null, "unanswered_turns": 0, "rephrase_count": null, "abandoned_at_turn_index": null, "abandoned_after_ms": null, "csat": null, "csat_comment": null },
    "transcript": [
      { "turn_index": 0, "speaker": "agent", "text": "Hi! I'm the EqualWeb assistant…", "timestamp": "2026-08-20T09:14:03Z", "confidence": null, "audio_offset_ms": null, "audio_duration_ms": null, "latency_ms": null, "turn_type": "greeting", "detected_intent": null, "detected_language": null, "sources": null, "unanswered": false, "human_agent_id": null },
      { "turn_index": 1, "speaker": "user", "text": "Does your widget alone make us compliant with the EAA?", "timestamp": "2026-08-20T09:14:41Z", "confidence": null, "audio_offset_ms": null, "audio_duration_ms": null, "latency_ms": null, "turn_type": "question", "detected_intent": null, "detected_language": null, "sources": null, "unanswered": false, "human_agent_id": null }
    ],
    "transcript_truncated": false,
    "has_recording": false,
    "retention_expires_at": "2028-08-20T09:14:02Z",
    "links": { "self": "https://api.uirix.com/v1/conversations/conv_p9876", "dashboard": "https://dashboard.uirix.com/conversations/conv_p9876" }
  }
}
```

### Example — `conversation.started`

```json
{
  "event": "conversation.started", "delivery_id": "dlv_01J8Z9K5B2C7D1", "emitted_at": "2026-08-20T09:14:42Z", "api_version": "v1", "account_id": "acc_17",
  "data": { "conversation_id": "conv_p9876", "agent_type": "chat", "uirix_agent_id": "agt_245", "agent_version": "agt245-v3", "prompt_version": "chat-v3", "started_at": "2026-08-20T09:14:02Z", "updated_at": "2026-08-20T09:14:41Z", "identity": { "…all 23 keys…": "…" }, "links": { "self": "…", "dashboard": "…" } }
}
```

### Example — `conversation.escalated`

```json
{
  "event": "conversation.escalated", "delivery_id": "dlv_01J8Z9L0P3Q8R2", "emitted_at": "2026-08-20T09:19:03Z", "api_version": "v1", "account_id": "acc_17",
  "data": { "conversation_id": "conv_p9876", "handoff_reason": "user_requested_human", "escalated_to": { "at": "2026-08-20T09:19:02Z", "to": "sales" }, "updated_at": "2026-08-20T09:19:02Z", "identity": { "…": "…" }, "transcript": [ { "turn_index": 0, "…": "…" } ], "links": { "…": "…" } }
}
```

### Example — `summary.ready`

```json
{
  "event": "summary.ready", "delivery_id": "dlv_01J8Z9N1V4W9X3", "emitted_at": "2026-08-20T09:23:40Z", "api_version": "v1", "account_id": "acc_17",
  "data": { "conversation_id": "conv_p9876", "updated_at": "2026-08-20T09:23:39Z", "identity": { "…": "…" },
    "summary": { "summary": "Visitor asked whether the widget alone satisfies the EAA…", "intent": "pricing_enterprise", "topics": ["EAA","e-commerce","pricing"], "sentiment": "positive", "sentiment_score": 0.6, "outcome": "lead_captured", "action_items": [ { "text": "Send enterprise pricing", "assignee": "sales", "due": null } ], "escalated_to_human": { "value": false, "at": null, "to": null }, "resolved": true, "handoff_reason": null, "summary_status": "ready", "summary_model": "uirix-summary-v1@gpt-5-nano" } }
}
```

### Example — `conversation.deleted`

```json
{ "event": "conversation.deleted", "delivery_id": "dlv_01J8ZE0A1B2C3D", "emitted_at": "2026-09-02T08:00:01Z", "api_version": "v1", "account_id": "acc_17",
  "data": { "conversation_id": "conv_p9876", "account_id": "acc_17", "deleted_at": "2026-09-02T08:00:00Z", "reason": "user_request", "updated_at": "2026-09-02T08:00:00Z" } }
```

### Example — `call.completed`

```json
{ "event": "call.completed", "delivery_id": "dlv_01J8ZF4G5H6J7K", "emitted_at": "2026-09-03T16:12:40Z", "api_version": "v1", "account_id": "acc_17",
  "data": { "call_id": "call_88", "conversation_id": "conv_u601", "agent_type": "phone_outbound", "status": "completed", "answered_by": "human", "disclosure_played": true, "attempts": 1, "matter_key": "dunning:example.com:2026-09", "to_e164": "+12125550147", "from_e164": "+16467131717", "started_at": "2026-09-03T16:10:02Z", "ended_at": "2026-09-03T16:12:31Z", "duration_seconds": 149, "dnc_requested": false, "updated_at": "2026-09-03T16:12:31Z",
    "identity": { "…": "…" }, "summary": { "…": "…" }, "metrics": { "…": "…" }, "transcript": [ "…" ], "transcript_truncated": false, "has_recording": true, "links": { "…": "…" } } }
```

## Delivery headers

```http
POST /uirix HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: uirix-webhooks/1.0
X-Uirix-Event: conversation.ended
X-Uirix-Delivery: dlv_01J8Z9M4T7QX3R          # stable across retries — deduplicate on it
X-Uirix-Timestamp: 1787412127                 # unix seconds, refreshed on every attempt
X-Uirix-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
X-Uirix-Signature-Previous: sha256=…          # only during secret rotation
X-Request-Id: req_01J8Z9M4T8
```

The body bytes are frozen when the delivery is created; every retry (and every replay) sends **exactly the same bytes** with a fresh timestamp and a fresh signature.

## Verifying signatures

Signature base string: `"{X-Uirix-Timestamp}.{raw_request_body}"` → HMAC-SHA256 with the endpoint secret → lowercase hex → prefixed `sha256=`.

Rules:

1. Read the **raw body bytes** before any JSON parsing or re-serialisation.
2. Reject if `|now − X-Uirix-Timestamp| > 300 s` (replay window).
3. Compare with a constant-time function.
4. During rotation accept the current secret against `X-Uirix-Signature`, or the previous secret against `X-Uirix-Signature-Previous` (or against `X-Uirix-Signature` if you only rotated on your side).
5. Respond `2xx` within **10 s**; do heavy work asynchronously. Deduplicate on `delivery_id`.

### Node

```js
const crypto = require('crypto');

function verifyUirix(rawBody, headers, secrets /* [current, previous?] */) {
  const ts = headers['x-uirix-timestamp'], sig = headers['x-uirix-signature'], prev = headers['x-uirix-signature-previous'];
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;           // replay window
  const ok = (sec, s) => {
    const e = 'sha256=' + crypto.createHmac('sha256', sec).update(`${ts}.${rawBody}`).digest('hex');
    return !!s && s.length === e.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(e));
  };
  return ok(secrets[0], sig) || (secrets[1] ? (ok(secrets[1], prev) || ok(secrets[1], sig)) : false);
}

// Express — keep the raw body:
app.post('/webhooks/uirix', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyUirix(req.body.toString('utf8'), req.headers, [process.env.UIRIX_WHSEC, process.env.UIRIX_WHSEC_PREV])) return res.sendStatus(401);
  const evt = JSON.parse(req.body);   // dedupe on evt.delivery_id, then enqueue
  res.sendStatus(200);
});
```

### PHP

```php
<?php
function verifyUirix(string $rawBody, array $headers, array $secrets): bool {
    $ts = $headers['X-Uirix-Timestamp'] ?? ''; $sig = $headers['X-Uirix-Signature'] ?? ''; $prev = $headers['X-Uirix-Signature-Previous'] ?? '';
    if (abs(time() - (int)$ts) > 300) return false;                       // replay window
    $ok = function (string $sec, string $s) use ($ts, $rawBody): bool {
        return $s !== '' && hash_equals('sha256=' . hash_hmac('sha256', "{$ts}.{$rawBody}", $sec), $s);
    };
    if ($ok($secrets[0], $sig)) return true;
    return isset($secrets[1]) && ($ok($secrets[1], $prev) || $ok($secrets[1], $sig));
}

$raw = file_get_contents('php://input');
$h = getallheaders();
if (!verifyUirix($raw, $h, [getenv('UIRIX_WHSEC'), getenv('UIRIX_WHSEC_PREV') ?: null])) { http_response_code(401); exit; }
$evt = json_decode($raw, true);   // dedupe on $evt['delivery_id']
http_response_code(200);
```

### Python

```python
import hmac, hashlib, time

def verify_uirix(raw_body: bytes, headers: dict, secrets: list[str | None]) -> bool:
    ts = headers.get("X-Uirix-Timestamp", "")
    sig = headers.get("X-Uirix-Signature", "")
    prev = headers.get("X-Uirix-Signature-Previous", "")
    try:
        if abs(time.time() - int(ts)) > 300:          # replay window
            return False
    except ValueError:
        return False

    def ok(secret: str | None, candidate: str) -> bool:
        if not secret or not candidate:
            return False
        expected = "sha256=" + hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
        return hmac.compare_digest(expected, candidate)

    return ok(secrets[0], sig) or ok(secrets[1] if len(secrets) > 1 else None, prev) or ok(secrets[1] if len(secrets) > 1 else None, sig)

# Flask:
# @app.post("/webhooks/uirix")
# def hook():
#     if not verify_uirix(request.get_data(), request.headers, [os.environ["UIRIX_WHSEC"], os.environ.get("UIRIX_WHSEC_PREV")]):
#         abort(401)
#     evt = request.get_json(force=True)   # dedupe on evt["delivery_id"]
#     return "", 200
```

## Secret rotation

1. `POST /webhooks/endpoints/{whk}/rotate-secret` → new `secret`; previous stays valid 7 days.
2. Deploy the new secret as *current* on your receiver, keep the old one as *previous*.
3. During the window deliveries carry `X-Uirix-Signature` (new) **and** `X-Uirix-Signature-Previous` (old), so either receiver version verifies.
4. After `previous_secret_expires_at` only the new secret signs.

## Retries, dead letter, replay

| Aspect | Behaviour |
|---|---|
| Success | Any `2xx` within 10 s |
| Failure | Non-2xx, timeout, connection error, or a redirect (redirects are **not** followed) |
| Schedule | After the first attempt: **1 min, 5 min, 15 min, 1 h, 6 h, 24 h** (±20 % jitter) — **7 attempts over ~31 h** |
| Delivery id | `X-Uirix-Delivery` / `delivery_id` is identical on every attempt; deduplicate on it |
| Dead letter | After attempt 7 the delivery is `failed` with `dead_at`, listed at `GET /webhooks/deliveries?status=failed` |
| Auto-disable | 50 consecutive dead deliveries → endpoint `enabled: false`, `disabled_reason: "consecutive_failures"`; re-enable with `PATCH { "enabled": true }` |
| Ordering | **Not guaranteed.** Use `emitted_at` and `data.updated_at` to discard stale events |
| Transcript cap | Inline `transcript` is capped at **500 turns**; then `transcript_truncated: true` and the full record is at `links.self` — never silently truncated |
| Timeouts | Connect + response 10 s; body ≤ 2 MB |

### Delivery object

| Field | Type | Notes |
|---|---|---|
| `delivery_id` | string | `dlv_…` |
| `endpoint_id` | string | |
| `event` | string | |
| `account_id` | string | |
| `status` | `pending` \| `delivered` \| `failed` \| `replaying` | |
| `attempt` | int | Attempts so far (1–7) |
| `next_attempt_at` | ISO-8601 \| null | |
| `last_attempt_at` | ISO-8601 \| null | |
| `last_response_code` | int \| null | |
| `last_error` | string \| null | `timeout`, `connection_refused`, `redirect`, `http_500`… |
| `delivered_at` / `dead_at` | ISO-8601 \| null | |
| `created_at` | ISO-8601 | |

### `GET /webhooks/deliveries`

| Param | Notes |
|---|---|
| `status` | `pending` \| `delivered` \| `failed` \| `replaying` |
| `event` | Event name |
| `endpoint_id` | |
| `from`, `to` | On `created_at`; default last 7 days |
| `conversation_id` | |
| `limit`, `cursor` | |

```bash
curl -s -H "Authorization: Bearer $SK" "$B/webhooks/deliveries?status=failed" | jq '.data[] | {delivery_id, event, attempt, last_response_code, dead_at}'
```

```json
{ "delivery_id": "dlv_01J8Z9M4T7QX3R", "event": "conversation.ended", "attempt": 7, "last_response_code": 500, "dead_at": "2026-08-21T16:30:12Z" }
```

### `POST /webhooks/deliveries/{dlv}/replay`

Re-queues the delivery (`status: "replaying"` → `pending`) with the **same `delivery_id` and the same bytes**; only the timestamp and signature are new. `202`. Works for `failed` and `delivered` deliveries. **Errors:** `404 not_found` · `400 validation_error` (already pending).

## URL and network rules

- `https://` only; TLS certificate must be valid (no self-signed).
- Public hosts only — private ranges, loopback, link-local (`169.254.…`), `*.uirix.com` are rejected at create time and re-checked at send time (DNS rebinding is blocked).
- Source IPs of the dispatcher are published on request; prefer signature verification over IP allow-listing.

## Errors on this page

| HTTP | `code` | When |
|---|---|---|
| 400 | `invalid_url` | Non-https / private / UiriX host |
| 400 | `unsupported_event` | Unknown event name |
| 400 | `unsupported_field` / `validation_error` | Bad body |
| 403 | `insufficient_scope` | Missing `webhooks:manage` |
| 404 | `not_found` | Unknown endpoint / delivery, or belongs to another account |

---

**UiriX Public API v1** · [Overview](README.md) · [Quickstart](quickstart.md) · [Authentication](authentication.md) · [Scopes](scopes.md) · [Conventions](conventions.md) · [Accounts & Users](accounts-and-users.md) · [Agents](agents.md) · [Capabilities](capabilities.md) · [Profiles](profiles.md) · [Conversations](conversations.md) · [Chat via API](chat-via-api.md) · [Widget Events](widget-events.md) · [Webhooks](webhooks.md) · [Calls](calls.md) · [Metrics](metrics.md) · [Voices](voices.md) · [Enums](enums.md) · [Partners](partners.md) · [Admin](admin.md) · [MCP](mcp.md) · [Changelog](changelog.md) · [OpenAPI](openapi.yaml)


<!-- ===== widget-events.md ===== -->

# Widget Browser Events — `window.uirix`

> **Browser-side companion to the Public API.** This page is not part of `api.uirix.com/v1`; it documents
> the JavaScript surface of the UiriX chat/voice widget that runs on your own website. Its purpose is to
> close the loop between what a visitor does in the widget (browser) and the conversation record the API
> returns (server) — every event carries the same `conversation_id` you will find on
> [`GET /conversations/{conv}`](conversations.md).
>
> **Requires a CDN redeploy** of `uirixwidget.js`, `voice-landing.html` and `uirix-voice-widget-v3.js`.

## What you get

| Capability | Call |
|---|---|
| Subscribe to widget events | `window.uirix.on('chat_opened', fn)` / `off` / `on('*', fn)` |
| The same events as DOM events | `window.addEventListener('uirix:chat_opened', e => e.detail)` |
| Pass your own identity into the conversation | `window.uirix.identify({ equalweb_agent_id: '1619', … })` |
| Read the current conversation id | `window.uirix.getConversationId()` |
| Honour a consent manager | `window.uirix.setConsent({ analytics: false })` |
| Consume the raw iframe messages | `window.addEventListener('message', …)` — see [postMessage contract](#postmessage-contract) |

Nothing here requires an API key. The events are emitted in the visitor's browser; the matching
server-side record is read later with a server-to-server key.

## Install

The widget script loads asynchronously, so calls made before it arrives must survive. Use the **queue
stub**: define `window.uirix` with a `q` array plus thin stubs, then load the script. Everything queued is
replayed, in order, the moment the widget initialises.

```html
<!-- 1. config + queue stub — inline, before the loader -->
<script>
  window.uirix = window.uirix || { q: [] };
  ['on', 'off', 'identify', 'setConsent'].forEach(function (m) {
    window.uirix[m] = window.uirix[m] || function () {
      window.uirix.q.push([m].concat([].slice.call(arguments)));
    };
  });

  window.uirix.token   = 'pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
  window.uirix.domains = { js: 'https://cdn.uirix.com/', public: 'https://dashboard.uirix.com/' };

  // Safe to call immediately — queued until the widget is ready.
  window.uirix.on('chat_ended', function (e) {
    window.dataLayer = window.dataLayer || [];
    window.dataLayer.push(Object.assign({ event: 'uirix_' + e.event }, e));
  });
  window.uirix.identify({ equalweb_agent_id: '1619' });
</script>

<!-- 2. the widget -->
<script async src="https://cdn.uirix.com/uirixwidget.js"></script>
```

`window.uirix` is the **same object** that holds `token`, `domains` and `extraData`; the loader augments it
in place, so existing installations keep working unchanged. After initialisation `window.uirix.q` stays
usable — anything pushed later executes immediately, so a tag manager can keep using one code path.

Both delivery mechanisms are always active: registered handlers **and** a `CustomEvent` on `window`.

```js
window.addEventListener('uirix:lead_captured', function (ev) {
  console.log(ev.detail.field, ev.detail.has_email);
});
```

## Events

Every event object carries these keys, always present:

| Key | Type | Notes |
|---|---|---|
| `event` | string | The event name, e.g. `chat_ended` |
| `conversation_id` | string \| null | `conv_p…` — the id used by [`GET /conversations/{conv}`](conversations.md). `null` only before the server has created the conversation (i.e. before the first message of a brand-new chat session) |
| `account_id` | string | `acc_<id>` — the same account id the API uses |
| `agent_type` | `chat` \| `voice` | The surface that fired the event, not the API's conversation type (widget conversations are `chat` on the API record) |
| `page_url` | string | The **embedding page** URL, not the iframe's |
| `timestamp` | string | ISO-8601 UTC |
| `consent` | object | `{ analytics: boolean }` — see [Consent](#consent) |

Plus the event-specific properties:

| Event | Fires when | Extra properties |
|---|---|---|
| `chat_opened` | The chat panel is open and ready, or a voice session has connected | — |
| `chat_message_sent` | The visitor sends a message | `turn_index` (0-based), `message_length` (characters, **not** the text) |
| `chat_agent_replied` | The agent's reply is rendered | `turn_index` (the turn it answers), `latency_ms` |
| `chat_ended` | The panel is closed, the chat is cleared, the tab is closed, or the voice session stops | `duration_seconds`, `turn_count`, `outcome` (`user_closed` \| `user_cleared` \| `window_closed` \| `voice_session_closed`) |
| `chat_escalated` | A handoff to a human is triggered | `handoff_reason` |
| `lead_captured` | The visitor supplies an email / phone / lead form | `field` (`email` \| `phone` \| `form`), `has_email` (boolean), `source` (the tool that captured it) |

> **The value is never in the browser event.** `lead_captured` reports *that* an email was captured and in
> which field — never the address. The value travels server-side into the conversation's identity block and
> is read back through [`GET /conversations/{conv}?include=identity`](conversations.md#identity-block).

### Example payloads

```json
{ "event": "chat_opened", "conversation_id": "conv_p1946", "account_id": "acc_1001",
  "agent_type": "chat", "page_url": "https://www.equalweb.com/pricing/?utm_source=google",
  "timestamp": "2026-09-03T09:15:12.004Z", "consent": { "analytics": true } }
```

```json
{ "event": "chat_message_sent", "conversation_id": "conv_p1946", "account_id": "acc_1001",
  "agent_type": "chat", "page_url": "https://www.equalweb.com/pricing/", "turn_index": 0,
  "message_length": 34, "timestamp": "2026-09-03T09:15:26.118Z", "consent": { "analytics": true } }
```

```json
{ "event": "chat_agent_replied", "conversation_id": "conv_p1946", "account_id": "acc_1001",
  "agent_type": "chat", "page_url": "https://www.equalweb.com/pricing/", "turn_index": 0,
  "latency_ms": 2317, "timestamp": "2026-09-03T09:15:28.435Z", "consent": { "analytics": true } }
```

```json
{ "event": "lead_captured", "conversation_id": "conv_p1946", "account_id": "acc_1001",
  "agent_type": "chat", "page_url": "https://www.equalweb.com/pricing/", "field": "email",
  "has_email": true, "source": "create_lead",
  "timestamp": "2026-09-03T09:17:02.900Z", "consent": { "analytics": true } }
```

```json
{ "event": "chat_ended", "conversation_id": "conv_p1946", "account_id": "acc_1001",
  "agent_type": "voice", "page_url": "https://www.equalweb.com/pricing/", "duration_seconds": 96,
  "turn_count": 5, "outcome": "voice_session_closed",
  "timestamp": "2026-09-03T09:18:41.502Z", "consent": { "analytics": true } }
```

## `identify()`

Pass your own identity into the conversation so it comes back on the API record and can be joined to your
CRM. Merged and idempotent — call it as often as you like, from any page, before or after the widget opens.

```js
window.uirix.identify({
  equalweb_agent_id: '1619',                   // STRING, always
  email: 'customer@example.com',
  email_source: 'crm_prefill',                 // in_conversation | lead_form | authenticated | crm_prefill
  name: 'Jane Doe',
  company: 'Example Ltd',
  phone_e164: '+12125550147',                  // E.164 only
  auth_user_id: 'usr_8812',
  auth_user_email: 'customer@example.com',
  custom: { plan: 'free', site_id: '55231' }
});
```

| Field | Rule |
|---|---|
| `equalweb_agent_id` | **Must be a JSON string.** Round-trips verbatim: `"1619"` → `"1619"`, `"-1"` → `"-1"`, `"007"` → `"007"`. A number is rejected (it would destroy leading zeros) and the stored value is left untouched |
| `phone_e164` | Validated with libphonenumber. `+12125550147` ✔, `0501234567` ✘, `+972 50-123-4567` ✘ — an invalid number is dropped, the rest of the payload is still stored |
| `email`, `auth_user_email` | Rejected if not a valid address |
| `email_source` | One of `in_conversation`, `lead_form`, `authenticated`, `crm_prefill` |
| `custom` | Flat key/value bag (string / number / boolean), at most 50 keys |
| Everything else | Truncated to the column width rather than rejected |

Accepted keys are exactly the identity block of [spec §5.1](conversations.md#identity-block):
`equalweb_agent_id`, `visitor_id`, `session_id`, `email`, `email_source`, `name`, `company`, `phone_e164`,
`auth_user_id`, `auth_user_email`, `page_url`, `page_title`, `referrer`, `landing_page`, `utm`, `country`,
`user_agent`, `custom`, `do_not_sell`.

A field you never send is never overwritten: a later `identify()` fills gaps, it does not erase.

### Automatic page context

On the first open the widget captures — **unless analytics consent is off** — and sends with the first
message:

`page_url` · `page_title` · `referrer` · `landing_page` (the first page of the browsing session, kept in
`sessionStorage`) · `utm_source` · `utm_medium` · `utm_campaign` · `utm_term` · `utm_content` · `gclid` ·
`fbclid` · `li_fat_id` · `msclkid`

You do not have to do anything for this; `identify()` values always win over the captured ones.

## `getConversationId()`

```js
var id = window.uirix.getConversationId();   // "conv_p1946" | null
```

`null` until the server has created the conversation. The reliable moment to read it is inside any event
handler — the id is on the event object.

## Consent

```js
window.uirix.setConsent({ analytics: false });   // e.g. from your CMP callback
```

| Effect | Behaviour |
|---|---|
| Events | **Still delivered** to your handlers and as `CustomEvent`s, flagged `consent: { analytics: false }` |
| Server | `page_url`, `page_title`, `referrer`, `landing_page` and all utm / click ids stop being sent — the widget stops sending them and the server drops them if they arrive anyway |
| Chat | Unaffected. The conversation keeps working |
| Persistence | Stored per widget token in `localStorage`; call it again with `true` to re-enable |

Call it before the visitor opens the widget when you can — the flag is baked into the iframe URL at open
time and updated live afterwards.

For CCPA/CPRA, `identify({ do_not_sell: true })` sets the per-conversation flag the API exposes.

## postMessage contract

If you prefer to consume the raw messages, the widget iframes post to the embedding page's **specific
origin** (never `"*"`), and every message carries `source: "uirix"`:

```js
window.addEventListener('message', function (ev) {
  if (ev.origin !== 'https://dashboard.uirix.com') return;   // your `domains.public` origin
  if (!ev.data || ev.data.source !== 'uirix') return;
  if (!ev.data.event) return;                                 // control message, not an event
  // ev.data = { source: 'uirix', event: 'chat_ended', conversation_id: 'conv_p1946', … }
});
```

| Direction | Message | Meaning |
|---|---|---|
| iframe → page | `{ source:'uirix', event, … }` | One widget event (the six above) |
| iframe → page | `{ source:'uirix', type:'conversation', conversation_id }` | The conversation id became known |
| page → iframe | `{ source:'uirix', type:'identify', identity }` | Forwarded `identify()` payload |
| page → iframe | `{ source:'uirix', type:'consent', analytics }` | Forwarded `setConsent()` |

The pre-existing control messages (`widgetReady`, `closeWidget`, `switchToChat`, `switchToVoice`,
`updateSession`) are unchanged.

## Analytics recipes

### GA4 (gtag)

```html
<script>
  window.uirix = window.uirix || { q: [] };
  window.uirix.on = window.uirix.on || function () { window.uirix.q.push(['on'].concat([].slice.call(arguments))); };

  window.uirix.on('*', function (e) {
    if (e.consent && e.consent.analytics === false) return;   // respect the CMP
    gtag('event', 'uirix_' + e.event, {
      conversation_id: e.conversation_id,
      uirix_account: e.account_id,
      agent_type: e.agent_type,
      turn_index: e.turn_index,
      latency_ms: e.latency_ms,
      duration_seconds: e.duration_seconds,
      turn_count: e.turn_count,
      lead_field: e.field,
      has_email: e.has_email
    });
  });

  // Conversions
  window.uirix.on('lead_captured', function (e) {
    gtag('event', 'generate_lead', { conversation_id: e.conversation_id, method: 'uirix_' + e.agent_type });
  });
</script>
```

### Cloudflare Zaraz

Zaraz consumes `dataLayer` pushes and `zaraz.track` calls. Push once, trigger many:

```html
<script>
  window.uirix = window.uirix || { q: [] };
  window.uirix.on = window.uirix.on || function () { window.uirix.q.push(['on'].concat([].slice.call(arguments))); };

  window.uirix.on('*', function (e) {
    if (e.consent && e.consent.analytics === false) return;
    if (window.zaraz && zaraz.track) zaraz.track('uirix_' + e.event, e);
    (window.dataLayer = window.dataLayer || []).push(Object.assign({ event: 'uirix_' + e.event }, e));
  });
</script>
```

Then add a Zaraz trigger on the `uirix_lead_captured` / `uirix_chat_ended` events and map
`conversation_id` to a field on the destination tool — that id is what lets you join the browser hit to the
transcript later.

## Supporting endpoints

These are widget endpoints (public token, no API key), listed because the events depend on them.

### `GET /api/public/widget/{public_token}/config`

Adds two keys used by every event:

```json
{ "agentId": 400, "agentName": "Aria", "accountId": "acc_1001", "agentType": "chat" }
```

`accountId` is the stable `acc_<id>` the read API uses. `agentType` is the API-side conversation type of
this widget's records (`chat`); a browser event overrides it with the surface that fired it.

### `POST /api/public/widget/{public_token}/identify`

Called by the widget itself — documented so the behaviour is predictable.

```jsonc
// request (chat: sessionId · voice: the signed sessionKey the mint returned)
{ "sessionId": "sess_…", "identity": { "equalweb_agent_id": "1619" },
  "pageContext": { "page_url": "…", "utm": { "utm_source": "google" } },
  "consent": { "analytics": true } }

// response — before the first message there is no conversation row yet
{ "success": true, "pending": true,  "conversationId": null,        "accountId": "acc_1001" }
{ "success": true, "pending": false, "conversationId": "conv_p1946", "accountId": "acc_1001" }
```

When the answer is `pending: true` the widget keeps the payload and replays it with the next chat message,
so nothing is lost. Rate limit: 30 requests per minute per token + IP.

## Notes and limits

- **A CDN redeploy is required.** The three CDN files must be republished before any of this is live on a
  customer page: `uirixwidget.js` (loader + API), `voice-landing.html` (voice iframe),
  `uirix-voice-widget-v3.js` (inline voice widget, which installs the same API when the loader is absent).
- Handler exceptions are caught and logged; one broken handler cannot break the chat.
- Unknown event names arriving from an iframe are ignored — the loader only re-emits the six documented
  events.
- On the standalone `/public` chat page (not embedded), events are dispatched as `uirix:<event>`
  `CustomEvent`s on that page's own `window`; there is no parent to post to.
- `turn_index` counts visitor turns within one loaded panel, starting at 0. Clearing the chat starts a new
  conversation and resets it.

---

**UiriX Public API v1** · [Overview](README.md) · [Quickstart](quickstart.md) · [Authentication](authentication.md) · [Scopes](scopes.md) · [Conventions](conventions.md) · [Accounts & Users](accounts-and-users.md) · [Agents](agents.md) · [Capabilities](capabilities.md) · [Profiles](profiles.md) · [Conversations](conversations.md) · [Chat via API](chat-via-api.md) · [Widget Events](widget-events.md) · [Webhooks](webhooks.md) · [Calls](calls.md) · [Metrics](metrics.md) · [Voices](voices.md) · [Enums](enums.md) · [Partners](partners.md) · [Admin](admin.md) · [MCP](mcp.md) · [Changelog](changelog.md) · [OpenAPI](openapi.yaml)
