# Agents Source: https://docs.sempera.org/agents The Agent model, draft -> preview -> publish, immutable versions, rollback. An Agent is Sempera's core product object: an AI employee that answers a business's phone or Web calls. ## Listing agents `GET /v1/agents` (`agents:read`) lists the Agents the credential can see, with the cursor pagination in [Errors and limits](/errors-and-limits). `status` is `active` (the default), `archived` or `all`; any other value is `400 validation_failed` with `field: status`. An Agent that cannot be read at the moment is not dropped: it appears as `{"id", "object": "agent", "status": "needs_repair", "reason"}`, so a list never silently shrinks. ## Shape `GET /v1/agents/{id}` returns the Agent with its stable `id`, `name`, `workspace_id`, `status` (`draft`, `published`, `archived`), the published version pointer, the verification gates, the channels it can speak on, and, on the detail read, its `draft`, `published`, `voice`, `language`, `instructions`, `tools` and `knowledge`. `channels` says where the Agent can be reached: `web_voice`, `web_chat`, and `phone`, which is `true` once a phone number is set up for the Agent (a number bound to it, or a line released through its deployments). It is the same reading as the phone step of the App's launch checklist, and a numbers read that fails never reports a phone. Provider identifiers (the voice platform's pathway or persona ids) are internal and never the Agent's identity. The Agent's `voice` is `{id, name}` (plus `unresolved: true` when Sempera cannot say which engine the stored voice is on), where `id` is the Sempera voice id `GET /v1/voices` lists. A draft takes a voice the same way: `PATCH /v1/agents/{id}/draft` with `{"fields": {"voice": {"id": "voice_...", "name": "..."}}}`. The draft and every version store the underlying voice that id stands for, so a published version keeps speaking with exactly the voice it was built with. ## Draft, preview, publish Every Agent has one editable **draft** and a ledger of immutable **published versions**. | Step | Route | Notes | | - | - | - | | Read the draft | `GET /v1/agents/{id}/draft` | `editable_fields` lists the keys a PATCH may carry; their current values and choices are the named sections of `GET /v1/agents/{id}` (`instructions`, `voice`, `guardrails`, ...) | | Edit the draft | `PATCH /v1/agents/{id}/draft` | `{"fields": {...}, "expect_revision": "..."}`; a stale revision is refused with `409 conflict` and the current revision | | Discard the draft | `DELETE /v1/agents/{id}/draft` | | | Typed preview | `POST /v1/agents/{id}/preview` | One caller turn against the draft (default) or the published version; carry `session_id` to continue | | Web voice preview | `POST /v1/sessions` | Always the published version | | Publish | `POST /v1/agents/{id}/publish` | Decides, applies and compiles: a new immutable version. Accepts `Idempotency-Key` | | Versions | `GET /v1/agents/{id}/versions`, `GET /v1/agents/{id}/versions/{version_id}` | Never mutated. `{version_id}` is a version's id or its number; a version this agent does not have is `404 not_found` | | Deployments | `GET /v1/agents/{id}/deployments` | The release ledger on the voice platform | | Releases across agents | `GET /v1/release-deployments` | `deploy:read`. Every version put on an environment, for every Agent the credential can see, newest first, with cursor pagination. Each row is a `release_deployment` with `agent_id`, `agent_name`, `workspace_id`, `version_id`, `version_number`, `environment` (`staging`, `production` or `practice`), `action` (`draft`, `promotion` or the ledger's own kind), `status`, `by` and `at` | | Rollback | `POST /v1/agents/{id}/rollback` | `{"to_deployment_id": "..."}` creates a NEW rollback record; history is never rewritten | Production calls and sessions always run a published version. Secrets never appear in a version read; tool authentication is reported as a secret reference, configured or not. ## Lifecycle `POST /v1/agents/{id}/archive` hides an agent, and `POST /v1/agents/{id}/unarchive` brings it back (`agents:write` for both). Each answers the Agent with an `archive` result, and a refusal is `409 conflict` with a sentence saying why. `DELETE /v1/agents/{id}` with `{"confirm": ""}` removes the record, its conversations and its number bindings; it is refused while a version is live. ## Publishing roles Publishing needs `agents:publish` on the key and the `owner` role in the workspace. An operator can edit and preview the draft; only an owner can make it live. ## Contact card and business identity An agent has a contact card and owner-provided business details: `display_name`, `legal_name`, `phone`, `email`, `website`, `address`, `country` and `registration_number`. Reads need `agents:read`; writes need `agents:write` and the `owner` role. | Route | Does | | - | - | | `GET /v1/agents/{agent_id}/identity` | Reads `{profile, verification, revision}`. | | `PUT /v1/agents/{agent_id}/identity` | Saves the whole `profile` with the `revision` you read; a stale revision is `409 conflict`. Changing the profile resets verification to `unverified`. | | `POST /v1/agents/{agent_id}/identity/verification` | Records a review request for the `revision` you read. It needs legal name, address, country and registration number. | | `GET /v1/agents/{agent_id}/identity/vcard` | Downloads the card as `text/vcard`. | | `POST /v1/agents/{agent_id}/identity/send-test` | Checks the delivery boundary for a `to` number. It always answers `503 owner_setup_required` with `sent: false`: nothing is sent. | `verification.status` is `unverified` or `pending_review`. A review request is not a verified badge, `verified_at` stays `null`, and no client can mark a business verified. # Metric alerts Source: https://docs.sempera.org/alerts Rules that fire when a share of recent calls crosses a threshold, and whom they notify. A metric alert rule says "tell me when X% of calls ...": when a share of an agent's recent calls crosses a threshold, Sempera records a trigger and notifies the people and systems the rule names. Every metric is computed from records Sempera already keeps; a metric Sempera does not record (cost, sentiment, audio quality) is not offered. ## Routes | Route | Scope | Notes | | - | - | - | | `GET /v1/alerts` | `agents:read` | This workspace's rules. `limit` (1 to 200, default 50), `page` (default 1), `sort` (`name`, `status`, `last_saved`, `last_triggered`; default `last_saved`), `direction` (`asc`, `desc`; default `desc`), `agent_id` or `agentId` (rules that cover one agent, every-agent rules included). A `limit` or `page` out of range (`page` is at most 100000) is refused with `400 validation_failed`, not clamped | | `GET /v1/alerts/metrics` | `agents:read` | What a rule may measure and where it may notify | | `POST /v1/alerts/preview` | `agents:write` | Try a rule without saving it or notifying anyone. `hours` is 24 or 168 | | `POST /v1/alerts` | `agents:write` | Create a rule. `201` | | `GET /v1/alerts/{id}` | `agents:read` | One rule and its trigger log, newest first | | `PATCH /v1/alerts/{id}` | `agents:write` | Change any field; the fields you leave out keep their values. Optional `expected_updated_at` makes the save conditional (see Editing together) | | `DELETE /v1/alerts/{id}` | `agents:write` | Delete a rule and clear any alert it raised | | `POST /v1/alerts/{id}/evaluate` | `agents:write` | Run one evaluation of this rule now, the same pass the scheduler runs | | `POST /v1/alerts/preview` | `agents:write` | Replay an unsaved rule against past calls. `hours` is `24` (default) or `168`. Nothing is saved and nobody is notified | Writes need the `owner` role. A rule may only name agents the credential can reach, and a rule that names an agent the credential cannot read is left out of its list. Responses use the compatibility envelope `{"data": ..., "errors": null}`; the list is `{"data": {"alerts": [...], "pagination": {"limit", "page", "total", "total_pages"}}}`. Refusals use the standard error envelope ([Errors and limits](/errors-and-limits)). ## Editing together To avoid overwriting another owner's edit, send the rule's `updated_at` back unchanged as `expected_updated_at` (a time with a zone): ```http theme={null} PATCH /v1/alerts/{id} {"name": "Too many hand-offs", "expected_updated_at": "2026-10-31T15:00:00+00:00"} ``` The rule is saved only if it still has that `updated_at`. The check and save are one step, so of two saves carrying the same value exactly one is applied. Enabling or disabling a rule (`enabled`) takes the same guard. If another owner saved first, the answer is `409 conflict` with `reason: "stale_alert"` and `updated_at`, the value now on file. Nothing is written or audited, and no firing state is cleared. Show the owner what changed, then either discard the edit or explicitly retry with `expected_updated_at` set to that newer `updated_at`. A value that is not a time with a zone (including null) is `400 validation_failed` with `field: "expected_updated_at"`. A rule of another workspace, or one deleted meanwhile, is `404 not_found` and names no `updated_at`. Without the field a save behaves as before: the last save wins. ## A rule ```http theme={null} POST /v1/alerts {"name": "Too many hand-offs", "metric": "handoffs", "thresholds": [{"percentage": 20, "severity": "warning"}, {"percentage": 40, "severity": "critical"}], "min_call_volume": 10, "lookback_minutes": 60, "agent_ids": "all", "notifications": [{"channel": "email", "to": "owner@example.com"}]} ``` | Field | Rule | | - | - | | `name` | Required, up to 200 characters | | `metric` | One of the keys `GET /v1/alerts/metrics` lists: `failed_calls`, `slow_replies`, `handoffs`, `unfinished_requests`, `judge_failures`, `long_calls` | | `cutoff` | Seconds, only for `slow_replies` (0.5 to 30, default 2) and `long_calls` (30 to 7200, default 300) | | `thresholds` | 1 to 3 of `{"percentage", "severity"}`. Percentage is more than 0 and at most 100; severity is `info`, `warning` or `critical`. No two share a percentage or a severity, and a higher percentage carries a higher severity | | `min_call_volume` | 1 to 100000. Below it nothing fires: one failure in one call is not a 100% failure rate | | `lookback_minutes` | `10`, `30`, `60`, `180` or `1440` | | `agent_ids` | `"all"` (default) or a non-empty list of agent ids | | `notifications` | Up to 10 of `{"channel": "email", "to": "
"}` or `{"channel": "webhook", "to": ""}`. A webhook target is one of this workspace's own enabled endpoints ([Webhooks](/webhooks)), never a bare URL | | `tools` | At most one of `{"id", "input"}`: `id` is an `api_request` tool's `tool_id` as `GET /v1/tools` shows it; `input` is up to 20 named values, each up to 2000 characters, which may reference the alert's fields as `{{name}}` (`GET /v1/alerts/metrics` lists them as `tool_variables`) | | `enabled` | `true` (default) or `false` | A rule read adds `id`, `object: "alert"`, `org_id`, `metric_label`, `category`, `severity` (the highest threshold's), `lookback_window` (the same number as `lookback_minutes`), `status` (`ok`, `triggered` or `disabled`), `firing_severity`, `last_saved` (also `updated_at`), `last_triggered`, `last_evaluated` and `created_at`. A detail read adds `triggers`, newest first and at most 100: each has `id`, `kind` (`fired`, `worsened` or `recovered`), `severity`, `value_pct`, `threshold_pct`, `call_volume`, `matched`, the window, `agent_ids`, `notified`, `notify_failed`, `notify_error` and `at`. ## How a rule fires Every evaluation measures the metric over the lookback window for the agents in scope. When the call volume reaches the minimum and the share is at or above a threshold, the rule fires once, records a trigger, raises an alert in the Sempera App and notifies each target. It records a second trigger only when it worsens to a higher severity, and a recovery when an evaluation finds it clear; a recovery notifies the same targets with a "Cleared" message. Changing a rule's definition, or disabling it, clears its firing state. Webhook targets receive the signed `metric_alert.triggered` event ([Webhooks](/webhooks)); this event is delivered only to the endpoints a rule names, never by subscription. A rule's tool runs through the same road, test mode and write rules as a tool step inside a call. A trigger's `notified` counts the targets that accepted delivery. A firing is also the `alert.fired` event your automation triggers can listen for (`GET /v1/automation/events`). The App's Add notification menu offers Email and Webhook from the channel catalogue, plus the separate Tool slot. A webhook selects an existing enabled workspace endpoint, never a new URL. Ten email/webhook destinations do not consume the tool slot. Phone-call notifications are not available. Fixture email records a local test message but does not count as delivery: `notified` stays zero for that target, with `notify_failed: true` and `notify_error: email_environment_fixture`. Provider acceptance counts as delivery, not proof that a person read it. Tool fixtures likewise do not count as delivery. `POST /v1/alerts/{id}/evaluate` answers `{"evaluation", "alert", "triggers"}`. `evaluation.action` is `fired`, `worsened`, `recovered` or `none`, beside `value_pct`, `matched`, `call_volume`, `enough_volume`, `severity`, `threshold_pct`, `agent_ids` and the window. `DELETE` answers `{"id", "deleted": true}`. `POST /v1/alerts/preview` takes the same body as `POST /v1/alerts`, checks it the same way, and replays it once a minute over the last `hours`. It saves no rule, records no trigger and sends nothing, so `notifications_sent` is always `0`. It reads only the agents both the rule and the credential can reach, and any other `hours` is `400 validation_failed`. It answers `{"hours", "from", "to", "triggered", "evaluations", "interval_minutes", "windows", "call_volume", "notifications_sent"}`: `triggered` counts each rise into a firing or higher severity, and `windows` holds the hourly measurements plus every change of severity. The call metrics read preview and simulator conversation records, the same records Attention reads in the App; phone calls kept only as call-log rows are not counted. `judge_failures` counts the Fail verdicts of this workspace's own judges, and its volume is the number of verdicts in the window. ## Refusals | Status | When | | - | - | | `400 validation_failed` | A field breaks a rule above, including a webhook target or tool that is not this workspace's own | | `404 not_found` | No such rule in this workspace, a rule naming an agent the credential cannot read, or a body naming an agent it cannot reach | | `409 conflict` | Evaluating a disabled rule | | `409 conflict` | A conditional edit is stale: `reason: stale_alert` and the current `updated_at`; nothing is saved | | `503 service_unavailable` | The deployment has no customer database to keep rules in. Only writes and `GET /v1/alerts/{id}` refuse this way: `GET /v1/alerts` answers an empty list and `GET /v1/alerts/metrics` still answers | # API chat Source: https://docs.sempera.org/api-chat Authenticated text conversations with a published agent, channel enablement, workspace-bound continuations and preview tool policy. API chat sends a text message to an agent's published configuration. It is authenticated server-to-server access, not a public widget. It uses the existing typed runtime and its workspace privacy, model admission and daily message caps. Integration tools retain preview policy; enabling the channel is not approval for live integration writes. Apply migration 0218\_agent\_api\_chat\_channels before serving these routes. No provider is contacted by that migration or by channel setup. Missing storage fails closed. ## Channel configuration GET /v1/agents/\{agent\_id}/channels/api-chat requires agents:read and a workspace-bound credential able to read the agent. PATCH /v1/agents/\{agent\_id}/channels/api-chat requires agents:write and the configure\_integration role capability. Send exactly \{"enabled":true} or \{"enabled":false}. The response is read back from the same workspace's durable channel row. Writes are audited. A session write also requires the usual CSRF proof. ## Messages POST /v1/agents/\{agent\_id}/chat requires sessions:write and talk\_to\_agent. The channel must be enabled. Send \{"message":"Hello"}. The message limit is 4,000 characters. Optional session\_id continues the returned opaque chat handle. The workspace, agent and credential must all match the opening request. A wrong or expired handle returns 404, a simultaneous turn returns 409, and capacity or daily-message limits return 429. Disabled channels return 403 before inference. Provider or published-configuration unavailability is reported, never replaced with a simulated answer. A successful response includes id, object (chat), agent\_id, channel (api\_chat), reply, ended, tool\_policy (preview), expires\_in\_seconds and note. Responses are no-store. Conversation evidence remains in the existing typed conversation store, with its preview classification intact. API chat does not relabel these records as live phone calls. In Conversations, these records carry the API chat channel and their last recorded activity time. Ordinary typed previews remain Web chat. The session read returns channel `api_chat` for API chat records ([Sessions and calls](/sessions-and-calls)). Sessions expire after 30 minutes idle. At most 20 are held per workspace and 200 per process, including openings in flight. A server restart ends open handles. Continue with the same credential; do not share a handle between users. API keys belong on your server and must never be embedded in browser JavaScript. No additional public ingress or provider activation is introduced. # API overview Source: https://docs.sempera.org/api-overview Hostnames, versioning, IDs, JSON, timestamps, request ids, tracing. ## Hostnames | Hostname | What it is | | - | - | | `sempera.org` | The company | | `app.sempera.org` | The Sempera App, the product a customer signs in to | | `api.sempera.org` | The Sempera API, the one developer surface | There is exactly one API hostname. The App's own browser traffic goes to `app.sempera.org/api/*`, a same-origin routing layer for its session cookie; it is not a second API and nothing outside the App should call it. ## Versioning The API major version is in the path: `/v1` for Sempera-native resources. It is not the Agent's version. An Agent's published configuration versions are `agent_version` resources under `/v1/agents/{id}/versions`. The `/v2` paths that exist today are compatibility facades for observed console traffic. They are intentionally thin, keep the same credential and workspace boundary as `/v1`, and do not introduce a second source of truth. A future Sempera-native `/v2` would ship with a deprecation notice, a migration guide and a sunset date for `/v1`, published in the changelog. Nothing is removed from `/v1` silently. ## Routes that need no credential These routes answer anyone, at the anonymous limit of 60 requests a minute per address ([Errors and limits](/errors-and-limits)). They carry no workspace data. | Route | What it answers | | - | - | | `GET /` and `GET /v1` | The API's front door, the same small JSON index at both: its name, version, `status` (`ok`), `documentation` (the docs site) and links to the contract and the guide pages. | | `GET /v1/health` | Liveness: `ok`, the API version, the build and the environment. | | `GET /openapi.json` | The OpenAPI 3.1 contract, generated from the running route table. | | `GET /llms.txt` | The map of these pages for coding agents. | | `GET /robots.txt` | The search policy of the API host. | | `GET /docs/{page}` | A guide page as Markdown. The `.md` suffix is optional. | | `GET /v2/models` | The speech models Sempera publishes, as the model list many client libraries expect ([Sessions and calls](/sessions-and-calls)). | | `GET /v1/workspaces/{workspace_id}/.well-known/jwks.json` | A workspace's public signing keys, for a receiver that verifies a signed request ([Secrets and request signing](/secrets-and-signing)). | | `POST /v1/phone-onboarding/start` | Starts a Sempera Phone sign-up link an assistant shows its owner. It grants nothing ([Personal phone plan](/phone-plan)). | | `POST /v1/phone-onboarding/poll` | Whether the owner approved that link. Only after the owner approves does it hand over one limited credential, once ([Personal phone plan](/phone-plan)). | `GET /v1/me` needs a credential, but any class of credential may call it; without one it answers `401 authentication_required`. The routes that Sempera's service providers call are not in this table; the next section says why. ## Supported routes and compatibility routes `openapi.json` groups every operation by tag. Two kinds are worth telling apart. * **Supported routes** are everything outside the `Compatibility` tag. These pages name each one, the contract covers it, and it changes only under the deprecation policy in [Migration and changelog](/changelog). * **Compatibility routes** carry the `Compatibility` tag. They are thin facades, at `/v1/compat/...`, under `/v2` and elsewhere, that answer in the shapes other consoles use so that a client built for those shapes keeps working. They use the same credential and the same workspace boundary as every other route and read the same records, so they add no second source of truth. These pages do not name each one: `openapi.json` is their contract. Provider ingress, a few routes under `/v1/webhooks` that a telephony carrier or the payment provider calls, is a third group. It needs no Sempera credential because each route carries its provider's own proof. It is not part of the integration contract, these pages do not list its routes, and you never call it ([Webhooks](/webhooks)). ## IDs IDs are opaque strings. Agent ids are Sempera's own stable slugs (never a voice or telephony provider's identifier). Keys, workspaces, calls and webhook endpoints are UUIDs. Sessions carry the id the runtime recorded them under. Do not parse ids. ## JSON, timestamps, objects Requests and responses are `application/json` (uploads are `multipart/form-data`). Timestamps are UTC ISO-8601. Most Sempera-native resources carry an `object` field naming their type, and most Sempera-native lists are `{"object": "list", "data": [...], "has_more": bool, "next_cursor": string|null}`. Compatibility facade endpoints keep their observed external envelopes instead; the OpenAPI `Compatibility` tag and the route-specific schemas are the source of truth for those shapes. ## Request ids and tracing Every response carries `X-Request-Id`, minted by Sempera. A value you send in `X-Request-Id` is echoed back as `X-Client-Request-Id`. A W3C `traceparent` header is echoed back unchanged. ## Idempotency Writes where a duplicate would be dangerous accept `Idempotency-Key`. See [Errors and limits](/errors-and-limits). ## Tenancy Every credential belongs to one workspace. Everything it can read or write is inside that workspace; an id from another workspace answers `not_found`, the same as an id that never existed. # Archive (reversible) Source: https://docs.sempera.org/api-reference/agents/archive-reversible https://api.sempera.org/openapi.json post /v1/agents/{agent_id}/archive # Check the contact card delivery boundary Source: https://docs.sempera.org/api-reference/agents/check-the-contact-card-delivery-boundary https://api.sempera.org/openapi.json post /v1/agents/{agent_id}/identity/send-test # Create an agent Source: https://docs.sempera.org/api-reference/agents/create-an-agent https://api.sempera.org/openapi.json post /v1/agents # Delete an agent (irreversible) Source: https://docs.sempera.org/api-reference/agents/delete-an-agent-irreversible https://api.sempera.org/openapi.json delete /v1/agents/{agent_id} # Deployment ledger Source: https://docs.sempera.org/api-reference/agents/deployment-ledger https://api.sempera.org/openapi.json get /v1/agents/{agent_id}/deployments # Discard the draft Source: https://docs.sempera.org/api-reference/agents/discard-the-draft https://api.sempera.org/openapi.json delete /v1/agents/{agent_id}/draft # Download agent contact card Source: https://docs.sempera.org/api-reference/agents/download-agent-contact-card https://api.sempera.org/openapi.json get /v1/agents/{agent_id}/identity/vcard # Edit the draft Source: https://docs.sempera.org/api-reference/agents/edit-the-draft https://api.sempera.org/openapi.json patch /v1/agents/{agent_id}/draft # Get a version Source: https://docs.sempera.org/api-reference/agents/get-a-version https://api.sempera.org/openapi.json get /v1/agents/{agent_id}/versions/{version_id} # Get an agent Source: https://docs.sempera.org/api-reference/agents/get-an-agent https://api.sempera.org/openapi.json get /v1/agents/{agent_id} # Inferred caller-memory schema Source: https://docs.sempera.org/api-reference/agents/inferred-caller-memory-schema https://api.sempera.org/openapi.json get /v1/agents/{agent_id}/memory/schema # List agents Source: https://docs.sempera.org/api-reference/agents/list-agents https://api.sempera.org/openapi.json get /v1/agents # List published versions Source: https://docs.sempera.org/api-reference/agents/list-published-versions https://api.sempera.org/openapi.json get /v1/agents/{agent_id}/versions # Publish a new immutable version Source: https://docs.sempera.org/api-reference/agents/publish-a-new-immutable-version https://api.sempera.org/openapi.json post /v1/agents/{agent_id}/publish # Read agent business identity Source: https://docs.sempera.org/api-reference/agents/read-agent-business-identity https://api.sempera.org/openapi.json get /v1/agents/{agent_id}/identity # Read the draft Source: https://docs.sempera.org/api-reference/agents/read-the-draft https://api.sempera.org/openapi.json get /v1/agents/{agent_id}/draft # Record a business identity review request Source: https://docs.sempera.org/api-reference/agents/record-a-business-identity-review-request https://api.sempera.org/openapi.json post /v1/agents/{agent_id}/identity/verification # Restore an archived agent Source: https://docs.sempera.org/api-reference/agents/restore-an-archived-agent https://api.sempera.org/openapi.json post /v1/agents/{agent_id}/unarchive # Roll back to a previous deployment Source: https://docs.sempera.org/api-reference/agents/roll-back-to-a-previous-deployment https://api.sempera.org/openapi.json post /v1/agents/{agent_id}/rollback # Save a version of the approved agent specification Source: https://docs.sempera.org/api-reference/agents/save-a-version-of-the-approved-agent-specification https://api.sempera.org/openapi.json post /v1/agents/{agent_id}/versions Run the same local compiler as the App. Does not apply draft edits or promote a live environment. # Save agent contact card and business details Source: https://docs.sempera.org/api-reference/agents/save-agent-contact-card-and-business-details https://api.sempera.org/openapi.json put /v1/agents/{agent_id}/identity # Typed preview turn Source: https://docs.sempera.org/api-reference/agents/typed-preview-turn https://api.sempera.org/openapi.json post /v1/agents/{agent_id}/preview # Create a metric alert rule Source: https://docs.sempera.org/api-reference/alerts/create-a-metric-alert-rule https://api.sempera.org/openapi.json post /v1/alerts # Delete a metric alert rule Source: https://docs.sempera.org/api-reference/alerts/delete-a-metric-alert-rule https://api.sempera.org/openapi.json delete /v1/alerts/{id} # Evaluate a metric alert rule now Source: https://docs.sempera.org/api-reference/alerts/evaluate-a-metric-alert-rule-now https://api.sempera.org/openapi.json post /v1/alerts/{id}/evaluate # Get a metric alert rule and its trigger log Source: https://docs.sempera.org/api-reference/alerts/get-a-metric-alert-rule-and-its-trigger-log https://api.sempera.org/openapi.json get /v1/alerts/{id} # List this organization's metric alert rules Source: https://docs.sempera.org/api-reference/alerts/list-this-organizations-metric-alert-rules https://api.sempera.org/openapi.json get /v1/alerts # Metrics and channels an alert rule may use Source: https://docs.sempera.org/api-reference/alerts/metrics-and-channels-an-alert-rule-may-use https://api.sempera.org/openapi.json get /v1/alerts/metrics # Preview an alert without saving or notifying Source: https://docs.sempera.org/api-reference/alerts/preview-an-alert-without-saving-or-notifying https://api.sempera.org/openapi.json post /v1/alerts/preview # Update a metric alert rule Source: https://docs.sempera.org/api-reference/alerts/update-a-metric-alert-rule https://api.sempera.org/openapi.json patch /v1/alerts/{id} # Create an API key (secret shown once) Source: https://docs.sempera.org/api-reference/api-keys/create-an-api-key-secret-shown-once https://api.sempera.org/openapi.json post /v1/api-keys # Get an API key Source: https://docs.sempera.org/api-reference/api-keys/get-an-api-key https://api.sempera.org/openapi.json get /v1/api-keys/{key_id} # List API keys Source: https://docs.sempera.org/api-reference/api-keys/list-api-keys https://api.sempera.org/openapi.json get /v1/api-keys # Rename an API key Source: https://docs.sempera.org/api-reference/api-keys/rename-an-api-key https://api.sempera.org/openapi.json patch /v1/api-keys/{key_id} # Revoke an API key Source: https://docs.sempera.org/api-reference/api-keys/revoke-an-api-key https://api.sempera.org/openapi.json delete /v1/api-keys/{key_id} # Confirm your mobile linking code Source: https://docs.sempera.org/api-reference/assistant/confirm-your-mobile-linking-code https://api.sempera.org/openapi.json post /v1/text-assistant/link/confirm Requires the current workspace owner browser session, not an API key, and an entitled workspace. Writes require CSRF proof. # Enable or disable the text Assistant Source: https://docs.sempera.org/api-reference/assistant/enable-or-disable-the-text-assistant https://api.sempera.org/openapi.json patch /v1/text-assistant/settings Requires the current workspace owner browser session, not an API key, and an entitled workspace. Writes require CSRF proof. Disabling remains available without entitlement. # Read text Assistant settings Source: https://docs.sempera.org/api-reference/assistant/read-text-assistant-settings https://api.sempera.org/openapi.json get /v1/text-assistant/settings Requires the current workspace owner browser session, not an API key, and an entitled workspace. Writes require CSRF proof. # Read your text Assistant thread Source: https://docs.sempera.org/api-reference/assistant/read-your-text-assistant-thread https://api.sempera.org/openapi.json get /v1/text-assistant/thread Requires the current workspace owner browser session, not an API key, and an entitled workspace. Writes require CSRF proof. # Send a mobile linking code Source: https://docs.sempera.org/api-reference/assistant/send-a-mobile-linking-code https://api.sempera.org/openapi.json post /v1/text-assistant/link Requires the current workspace owner browser session, not an API key, and an entitled workspace. Writes require CSRF proof. # Unlink your mobile number Source: https://docs.sempera.org/api-reference/assistant/unlink-your-mobile-number https://api.sempera.org/openapi.json delete /v1/text-assistant/link Requires the current workspace owner browser session, not an API key, and an entitled workspace. Writes require CSRF proof. Unlinking remains available without entitlement. # Describe the calling credential Source: https://docs.sempera.org/api-reference/authentication/describe-the-calling-credential https://api.sempera.org/openapi.json get /v1/me Public in the sense that every credential class may call it; anonymous callers get 401. # A trigger's executions Source: https://docs.sempera.org/api-reference/automations/a-triggers-executions https://api.sempera.org/openapi.json get /v1/automation/triggers/{id}/executions # Create a trigger Source: https://docs.sempera.org/api-reference/automations/create-a-trigger https://api.sempera.org/openapi.json post /v1/automation/triggers # Create a trigger (one record) Source: https://docs.sempera.org/api-reference/automations/create-a-trigger-one-record https://api.sempera.org/openapi.json post /v1/automation/pipelines # Delete a trigger Source: https://docs.sempera.org/api-reference/automations/delete-a-trigger https://api.sempera.org/openapi.json delete /v1/automation/triggers/{id} # Dry-run a trigger against a sample event (nothing is delivered) Source: https://docs.sempera.org/api-reference/automations/dry-run-a-trigger-against-a-sample-event-nothing-is-delivered https://api.sempera.org/openapi.json post /v1/automation/triggers/{id}/test # Events a trigger can listen for Source: https://docs.sempera.org/api-reference/automations/events-a-trigger-can-listen-for https://api.sempera.org/openapi.json get /v1/automation/events # Get a trigger Source: https://docs.sempera.org/api-reference/automations/get-a-trigger https://api.sempera.org/openapi.json get /v1/automation/triggers/{id} # Get a trigger execution Source: https://docs.sempera.org/api-reference/automations/get-a-trigger-execution https://api.sempera.org/openapi.json get /v1/automation/executions/{id} # List this organization's triggers Source: https://docs.sempera.org/api-reference/automations/list-this-organizations-triggers https://api.sempera.org/openapi.json get /v1/automation/triggers # List trigger executions Source: https://docs.sempera.org/api-reference/automations/list-trigger-executions https://api.sempera.org/openapi.json get /v1/automation/executions # Send a failed or stalled webhook or tool run again, once Source: https://docs.sempera.org/api-reference/automations/send-a-failed-or-stalled-webhook-or-tool-run-again-once https://api.sempera.org/openapi.json post /v1/automation/executions/{id}/retry # Update, pause or resume a trigger Source: https://docs.sempera.org/api-reference/automations/update-pause-or-resume-a-trigger https://api.sempera.org/openapi.json patch /v1/automation/triggers/{id} # Advance due batch recipients Source: https://docs.sempera.org/api-reference/batches/advance-due-batch-recipients https://api.sempera.org/openapi.json post /v2/batches/{batch_id}/run # Create and start a call batch Source: https://docs.sempera.org/api-reference/batches/create-and-start-a-call-batch https://api.sempera.org/openapi.json post /v2/batches/create # Get batch Source: https://docs.sempera.org/api-reference/batches/get-batch https://api.sempera.org/openapi.json get /v2/batches/{batch_id} # Get batch attempts Source: https://docs.sempera.org/api-reference/batches/get-batch-attempts https://api.sempera.org/openapi.json get /v2/batches/{batch_id}/logs # Stop queued batch recipients Source: https://docs.sempera.org/api-reference/batches/stop-queued-batch-recipients https://api.sempera.org/openapi.json post /v2/batches/{batch_id}/stop # Ask questions about a finished phone call Source: https://docs.sempera.org/api-reference/calls/ask-questions-about-a-finished-phone-call https://api.sempera.org/openapi.json post /v1/calls/{call_id}/analyze Uses evaluation credits. Answers require transcript evidence; missing information is unknown. Results are returned, not stored. # Create an outbound call Source: https://docs.sempera.org/api-reference/calls/create-an-outbound-call https://api.sempera.org/openapi.json post /v1/calls Places one outbound call on a named line. mode prompt runs only the prompt; the line still supplies the number, consent, do-not-contact, calling hours, caps and billing. The prompt-only mark is not a caller field and skips no check. # Get a phone call Source: https://docs.sempera.org/api-reference/calls/get-a-phone-call https://api.sempera.org/openapi.json get /v1/calls/{call_id} # Import a corrected transcript; it reads as an unverified owner import Source: https://docs.sempera.org/api-reference/calls/import-a-corrected-transcript;-it-reads-as-an-unverified-owner-import https://api.sempera.org/openapi.json post /v1/calls/{call_id}/corrected-transcript # List phone calls for an agent Source: https://docs.sempera.org/api-reference/calls/list-phone-calls-for-an-agent https://api.sempera.org/openapi.json get /v1/calls # Read a corrected transcript with speaker labels and turn confidence Source: https://docs.sempera.org/api-reference/calls/read-a-corrected-transcript-with-speaker-labels-and-turn-confidence https://api.sempera.org/openapi.json get /v1/calls/{call_id}/corrected-transcript # Signed download URL for a call recording Source: https://docs.sempera.org/api-reference/calls/signed-download-url-for-a-call-recording https://api.sempera.org/openapi.json get /v1/recordings/{recording_id} # SSE snapshot of a live transcript Source: https://docs.sempera.org/api-reference/calls/sse-snapshot-of-a-live-transcript https://api.sempera.org/openapi.json get /v1/live-calls/transcript/stream # SSE snapshot of live calls Source: https://docs.sempera.org/api-reference/calls/sse-snapshot-of-live-calls https://api.sempera.org/openapi.json get /v1/live-calls/stream # Stop all active phone calls in this credential's scope Source: https://docs.sempera.org/api-reference/calls/stop-all-active-phone-calls-in-this-credentials-scope https://api.sempera.org/openapi.json post /v1/calls/active/stop Stops a snapshot, not future calls. Each result reports its own outcome. Owner role required. # Stop one active phone call Source: https://docs.sempera.org/api-reference/calls/stop-one-active-phone-call https://api.sempera.org/openapi.json post /v1/calls/{call_id}/stop # Transfer an active phone call Source: https://docs.sempera.org/api-reference/calls/transfer-an-active-phone-call https://api.sempera.org/openapi.json post /v1/calls/active/transfer # List citation schemas Source: https://docs.sempera.org/api-reference/citation-schemas/list-citation-schemas https://api.sempera.org/openapi.json get /v1/citation_schemas # List citation schemas (the /list path) Source: https://docs.sempera.org/api-reference/citation-schemas/list-citation-schemas-the-list-path https://api.sempera.org/openapi.json get /v1/citation_schemas/list # Score extraction quality over call history Source: https://docs.sempera.org/api-reference/citation-schemas/score-extraction-quality-over-call-history https://api.sempera.org/openapi.json post /v1/citation_schemas/backfill # Create a scoped REST credential (secret shown once) Source: https://docs.sempera.org/api-reference/connected-clients/create-a-scoped-rest-credential-secret-shown-once https://api.sempera.org/openapi.json post /v1/client-grants/rest One credential for one client that cannot use OAuth, usable on the phone routes only. # Disconnect one client Source: https://docs.sempera.org/api-reference/connected-clients/disconnect-one-client https://api.sempera.org/openapi.json delete /v1/client-grants/{grant_id} End one client's access. Every other client keeps working. # List connected clients Source: https://docs.sempera.org/api-reference/connected-clients/list-connected-clients https://api.sempera.org/openapi.json get /v1/client-grants Every outside client connected to this workspace: OAuth grants, MCP keys and REST credentials, with scopes and last use. # Merge console contact profiles Source: https://docs.sempera.org/api-reference/contacts/merge-console-contact-profiles https://api.sempera.org/openapi.json post /v1/contacts/merge # Read organisation contact schema Source: https://docs.sempera.org/api-reference/contacts/read-organisation-contact-schema https://api.sempera.org/openapi.json get /v1/contacts/schema # Resolve a workspace contact without guessing Source: https://docs.sempera.org/api-reference/contacts/resolve-a-workspace-contact-without-guessing https://api.sempera.org/openapi.json post /v1/contacts/resolve # Save organisation contact schema Source: https://docs.sempera.org/api-reference/contacts/save-organisation-contact-schema https://api.sempera.org/openapi.json put /v1/contacts/schema # List the workspace's credit history Source: https://docs.sempera.org/api-reference/credits/list-the-workspaces-credit-history https://api.sempera.org/openapi.json get /v1/credits/history # Read the workspace's credit balance Source: https://docs.sempera.org/api-reference/credits/read-the-workspaces-credit-balance https://api.sempera.org/openapi.json get /v1/credits Credits pay for building: Assistant turns, simulations and evals, test voice calls and knowledge processing. Live agents and phone calls never spend them. # A workspace's public signing keys (JWKS) Source: https://docs.sempera.org/api-reference/developer-settings/a-workspaces-public-signing-keys-jwks https://api.sempera.org/openapi.json get /v1/workspaces/{workspace_id}/.well-known/jwks.json Public by design, no credential: verify the RS256 JWT a signed request carries. # Add a workspace secret (the value is never returned) Source: https://docs.sempera.org/api-reference/developer-settings/add-a-workspace-secret-the-value-is-never-returned https://api.sempera.org/openapi.json post /v1/secrets # Delete a workspace secret Source: https://docs.sempera.org/api-reference/developer-settings/delete-a-workspace-secret https://api.sempera.org/openapi.json delete /v1/secrets/{id} # Every agent version put on an environment in this workspace Source: https://docs.sempera.org/api-reference/developer-settings/every-agent-version-put-on-an-environment-in-this-workspace https://api.sempera.org/openapi.json get /v1/release-deployments # Fetch a Refresh secret's value now (the value is never returned) Source: https://docs.sempera.org/api-reference/developer-settings/fetch-a-refresh-secrets-value-now-the-value-is-never-returned https://api.sempera.org/openapi.json post /v1/secrets/{id}/refresh # Generate or rotate the signing key Source: https://docs.sempera.org/api-reference/developer-settings/generate-or-rotate-the-signing-key https://api.sempera.org/openapi.json post /v1/signing/keys # JWT request signing settings and keys Source: https://docs.sempera.org/api-reference/developer-settings/jwt-request-signing-settings-and-keys https://api.sempera.org/openapi.json get /v1/signing # Rename a workspace secret, replace a Static value, or pause and resume it Source: https://docs.sempera.org/api-reference/developer-settings/rename-a-workspace-secret-replace-a-static-value-or-pause-and-resume-it https://api.sempera.org/openapi.json patch /v1/secrets/{id} # Update JWT request signing Source: https://docs.sempera.org/api-reference/developer-settings/update-jwt-request-signing https://api.sempera.org/openapi.json patch /v1/signing # Withdraw every signing key but the active one Source: https://docs.sempera.org/api-reference/developer-settings/withdraw-every-signing-key-but-the-active-one https://api.sempera.org/openapi.json post /v1/signing/keys/revoke-previous # Create an owned number pool Source: https://docs.sempera.org/api-reference/dialing-pools/create-an-owned-number-pool https://api.sempera.org/openapi.json post /v1/agents/{agent_id}/dialing-pools # List dialing pools and owned number readiness Source: https://docs.sempera.org/api-reference/dialing-pools/list-dialing-pools-and-owned-number-readiness https://api.sempera.org/openapi.json get /v1/agents/{agent_id}/dialing-pools # Preview caller ID without dialing or reserving Source: https://docs.sempera.org/api-reference/dialing-pools/preview-caller-id-without-dialing-or-reserving https://api.sempera.org/openapi.json post /v1/agents/{agent_id}/dialing-pools/{pool_id}/preview # Read a dialing pool Source: https://docs.sempera.org/api-reference/dialing-pools/read-a-dialing-pool https://api.sempera.org/openapi.json get /v1/agents/{agent_id}/dialing-pools/{pool_id} # Replace dialing pool settings Source: https://docs.sempera.org/api-reference/dialing-pools/replace-dialing-pool-settings https://api.sempera.org/openapi.json post /v1/agents/{agent_id}/dialing-pools/{pool_id}/update # Archive an eval criterion Source: https://docs.sempera.org/api-reference/evals/archive-an-eval-criterion https://api.sempera.org/openapi.json delete /v1/evals/{eval_id} # Calls a judge can score, with their verdicts Source: https://docs.sempera.org/api-reference/evals/calls-a-judge-can-score-with-their-verdicts https://api.sempera.org/openapi.json get /v1/evals/test-cases # Create an eval criterion Source: https://docs.sempera.org/api-reference/evals/create-an-eval-criterion https://api.sempera.org/openapi.json post /v1/evals # Eval results for an agent Source: https://docs.sempera.org/api-reference/evals/eval-results-for-an-agent https://api.sempera.org/openapi.json get /v1/evals/results # Evaluate selected stored production calls without saving a second copy Source: https://docs.sempera.org/api-reference/evals/evaluate-selected-stored-production-calls-without-saving-a-second-copy https://api.sempera.org/openapi.json post /v1/evals/{eval_id}/production-run # Evaluation runs of this workspace's agents Source: https://docs.sempera.org/api-reference/evals/evaluation-runs-of-this-workspaces-agents https://api.sempera.org/openapi.json get /v1/evals/runs Runs recorded by POST /v1/evals/run, newest first. A run from before runs were recorded is not listed. # Get an eval criterion Source: https://docs.sempera.org/api-reference/evals/get-an-eval-criterion https://api.sempera.org/openapi.json get /v1/evals/{eval_id} # Judge an agent's completed conversations Source: https://docs.sempera.org/api-reference/evals/judge-an-agents-completed-conversations https://api.sempera.org/openapi.json post /v1/evals/run Scores the agent's completed conversations that are not yet scored. The answer carries run_id, which GET /v1/evals/runs/{run_id} reads back. # Judge templates Sempera ships Source: https://docs.sempera.org/api-reference/evals/judge-templates-sempera-ships https://api.sempera.org/openapi.json get /v1/evals/agent-templates # List eval criteria Source: https://docs.sempera.org/api-reference/evals/list-eval-criteria https://api.sempera.org/openapi.json get /v1/evals # One evaluation run with its results Source: https://docs.sempera.org/api-reference/evals/one-evaluation-run-with-its-results https://api.sempera.org/openapi.json get /v1/evals/runs/{run_id} One run with a result per call and judge, how each differs from the previous run of the same suite, and the body to start it again. A run of another workspace is not_found. # Propose judge tuning from corrected production-call verdicts; save nothing Source: https://docs.sempera.org/api-reference/evals/propose-judge-tuning-from-corrected-production-call-verdicts;-save-nothing https://api.sempera.org/openapi.json post /v1/evals/{eval_id}/align # Recent completed production calls available to this judge Source: https://docs.sempera.org/api-reference/evals/recent-completed-production-calls-available-to-this-judge https://api.sempera.org/openapi.json get /v1/evals/{eval_id}/production-calls # Save a judge suite (a named bundle of templates for one agent) Source: https://docs.sempera.org/api-reference/evals/save-a-judge-suite-a-named-bundle-of-templates-for-one-agent https://api.sempera.org/openapi.json post /v1/evals/user-templates # Saved judge suites for this workspace Source: https://docs.sempera.org/api-reference/evals/saved-judge-suites-for-this-workspace https://api.sempera.org/openapi.json get /v1/evals/user-templates # Score one judge template against an agent's recent calls (not saved) Source: https://docs.sempera.org/api-reference/evals/score-one-judge-template-against-an-agents-recent-calls-not-saved https://api.sempera.org/openapi.json post /v1/evals/quick-run # This workspace's judges with their latest run Source: https://docs.sempera.org/api-reference/evals/this-workspaces-judges-with-their-latest-run https://api.sempera.org/openapi.json get /v1/evals/agents # Update an eval criterion Source: https://docs.sempera.org/api-reference/evals/update-an-eval-criterion https://api.sempera.org/openapi.json patch /v1/evals/{eval_id} # Create and start experiment Source: https://docs.sempera.org/api-reference/experiments/create-and-start-experiment https://api.sempera.org/openapi.json post /v2/agents/{uuid}/experiments Splits new conversations only on the channels in served_on. It does not enable a channel, place calls, change consent or bypass admission caps. Only verified published versions of this agent can be compared. Republishing the baseline suspends assignment; stopping affects new conversations, not conversations already open. Schedules and call quotas are not supported and are rejected. See /docs/tests-and-evals.md#experiments. # Get experiment Source: https://docs.sempera.org/api-reference/experiments/get-experiment https://api.sempera.org/openapi.json get /v2/agents/{uuid}/experiments/{experiment_id} # Get observed experiment results Source: https://docs.sempera.org/api-reference/experiments/get-observed-experiment-results https://api.sempera.org/openapi.json get /v2/agents/{uuid}/experiments/{experiment_id}/results Observed outcomes from experiment-tagged conversation records, not projected traffic. Empty arms have null rates. No automatic winner or disposition scores. # List experiments Source: https://docs.sempera.org/api-reference/experiments/list-experiments https://api.sempera.org/openapi.json get /v2/agents/{uuid}/experiments Splits new conversations only on the channels in served_on. It does not enable a channel, place calls, change consent or bypass admission caps. Only verified published versions of this agent can be compared. Republishing the baseline suspends assignment; stopping affects new conversations, not conversations already open. Schedules and call quotas are not supported and are rejected. See /docs/tests-and-evals.md#experiments. # Stop experiment Source: https://docs.sempera.org/api-reference/experiments/stop-experiment https://api.sempera.org/openapi.json post /v2/agents/{uuid}/experiments/{experiment_id}/stop No options. Repeated stops return the same stopped experiment. Existing conversations keep their version. # Update experiment Source: https://docs.sempera.org/api-reference/experiments/update-experiment https://api.sempera.org/openapi.json patch /v2/agents/{uuid}/experiments/{experiment_id} # Compatibility integration actions Source: https://docs.sempera.org/api-reference/integrations/compatibility-integration-actions https://api.sempera.org/openapi.json get /v1/integrations/{integration_id}/actions # Compatibility integration detail Source: https://docs.sempera.org/api-reference/integrations/compatibility-integration-detail https://api.sempera.org/openapi.json get /v1/integrations/{integration_id} # Create an integration Source: https://docs.sempera.org/api-reference/integrations/create-an-integration https://api.sempera.org/openapi.json post /v1/integrations # Disable an integration Source: https://docs.sempera.org/api-reference/integrations/disable-an-integration https://api.sempera.org/openapi.json delete /v1/integrations/{integration_id} # List integrations Source: https://docs.sempera.org/api-reference/integrations/list-integrations https://api.sempera.org/openapi.json get /v1/integrations # Update an integration Source: https://docs.sempera.org/api-reference/integrations/update-an-integration https://api.sempera.org/openapi.json patch /v1/integrations/{integration_id} # Verify an integration's connection Source: https://docs.sempera.org/api-reference/integrations/verify-an-integrations-connection https://api.sempera.org/openapi.json post /v1/integrations/{integration_id}/verify # Ask knowledge using local retrieval, with cited passages Source: https://docs.sempera.org/api-reference/knowledge/ask-knowledge-using-local-retrieval-with-cited-passages https://api.sempera.org/openapi.json post /v1/knowledge/chat # Create a plain-text knowledge source Source: https://docs.sempera.org/api-reference/knowledge/create-a-plain-text-knowledge-source https://api.sempera.org/openapi.json post /v1/knowledge-sources/text # Get a knowledge source Source: https://docs.sempera.org/api-reference/knowledge/get-a-knowledge-source https://api.sempera.org/openapi.json get /v1/knowledge-sources/{source_id} # Ingest an answer and resolve a recorded question Source: https://docs.sempera.org/api-reference/knowledge/ingest-an-answer-and-resolve-a-recorded-question https://api.sempera.org/openapi.json put /v1/knowledge/questions/{question_id}/answer # Link, delete or dismiss a recorded knowledge question Source: https://docs.sempera.org/api-reference/knowledge/link-delete-or-dismiss-a-recorded-knowledge-question https://api.sempera.org/openapi.json patch /v1/knowledge/questions/{question_id} # List an agent's knowledge sources Source: https://docs.sempera.org/api-reference/knowledge/list-an-agents-knowledge-sources https://api.sempera.org/openapi.json get /v1/knowledge-sources # List source testing choices and pathway nodes Source: https://docs.sempera.org/api-reference/knowledge/list-source-testing-choices-and-pathway-nodes https://api.sempera.org/openapi.json get /v1/knowledge/testing-context # Re-process a source Source: https://docs.sempera.org/api-reference/knowledge/re-process-a-source https://api.sempera.org/openapi.json post /v1/knowledge-sources/{source_id}/reparse # Read one public page or refresh a previously read page Source: https://docs.sempera.org/api-reference/knowledge/read-one-public-page-or-refresh-a-previously-read-page https://api.sempera.org/openapi.json post /v1/knowledge/crawl # Read version-bound knowledge retrieval settings Source: https://docs.sempera.org/api-reference/knowledge/read-version-bound-knowledge-retrieval-settings https://api.sempera.org/openapi.json get /v1/agents/{agent_id}/knowledge/retrieval-settings # Remove a knowledge source Source: https://docs.sempera.org/api-reference/knowledge/remove-a-knowledge-source https://api.sempera.org/openapi.json delete /v1/knowledge-sources/{source_id} # Stage knowledge retrieval settings in the agent draft Source: https://docs.sempera.org/api-reference/knowledge/stage-knowledge-retrieval-settings-in-the-agent-draft https://api.sempera.org/openapi.json put /v1/agents/{agent_id}/knowledge/retrieval-settings # Test local retrieval against selected or published knowledge Source: https://docs.sempera.org/api-reference/knowledge/test-local-retrieval-against-selected-or-published-knowledge https://api.sempera.org/openapi.json post /v1/knowledge/test # Update a text source without publishing the agent Source: https://docs.sempera.org/api-reference/knowledge/update-a-text-source-without-publishing-the-agent https://api.sempera.org/openapi.json patch /v1/knowledge-sources/{source_id} # Upload a document (multipart) Source: https://docs.sempera.org/api-reference/knowledge/upload-a-document-multipart https://api.sempera.org/openapi.json post /v1/knowledge-sources # Connect an AI client (secret shown once) Source: https://docs.sempera.org/api-reference/mcp/connect-an-ai-client-secret-shown-once https://api.sempera.org/openapi.json post /v1/mcp/connections Mint one credential for one outside AI client. read: inspect. draft: also create and edit drafts. test: also run the offline test battery. build: both draft and test. write: also publish, manage knowledge and bind owned numbers. phone: also the phone plan's tools, which call and text only within its budgets and an owner's approval. # Disconnect an AI client Source: https://docs.sempera.org/api-reference/mcp/disconnect-an-ai-client https://api.sempera.org/openapi.json delete /v1/mcp/connections/{connection_id} Revoke one connection. The deployed agents are not touched. # List connected AI clients Source: https://docs.sempera.org/api-reference/mcp/list-connected-ai-clients https://api.sempera.org/openapi.json get /v1/mcp/connections The workspace's connected AI clients: one revocable, expiring credential each, usable only on POST /v1/mcp. # MCP JSON-RPC over HTTP Source: https://docs.sempera.org/api-reference/mcp/mcp-json-rpc-over-http https://api.sempera.org/openapi.json post /v1/mcp JSON-RPC 2.0 MCP. initialize, tools/list and tools/call: three original tools, read-only projections of /v1 reads, and three draft-and-test tools that run the existing create-agent, edit-draft and run-tests routes, and the eight phone tools of a personal phone plan. Needs agents:read to connect; each tool also names the scope it needs. Nothing on this route publishes or deletes, and a call or text leaves only through a phone tool, with the signed approval its preview issued. # Setup guidance for connecting one coding-agent client Source: https://docs.sempera.org/api-reference/mcp/setup-guidance-for-connecting-one-coding-agent-client https://api.sempera.org/openapi.json get /v1/mcp/clients/{client} The same answer as the mcp_client_setup tool, as a read. # Correct a caller fact or summary Source: https://docs.sempera.org/api-reference/memory/correct-a-caller-fact-or-summary https://api.sempera.org/openapi.json patch /v1/memory/{memory_id} # Create a caller fact or summary Source: https://docs.sempera.org/api-reference/memory/create-a-caller-fact-or-summary https://api.sempera.org/openapi.json post /v1/memory # Enable or disable memory in the agent draft Source: https://docs.sempera.org/api-reference/memory/enable-or-disable-memory-in-the-agent-draft https://api.sempera.org/openapi.json patch /v1/agents/{agent_id}/memory # Forget one caller line for one agent Source: https://docs.sempera.org/api-reference/memory/forget-one-caller-line-for-one-agent https://api.sempera.org/openapi.json post /v1/memory/reset # Check channel send admission Source: https://docs.sempera.org/api-reference/messaging/check-channel-send-admission https://api.sempera.org/openapi.json post /v1/agents/{agent_id}/messaging/{channel}/messages Currently refuses every send. No message is queued or delivered. # List channel conversations Source: https://docs.sempera.org/api-reference/messaging/list-channel-conversations https://api.sempera.org/openapi.json get /v1/agents/{agent_id}/messaging/{channel}/conversations # Messaging channel setup Source: https://docs.sempera.org/api-reference/messaging/messaging-channel-setup https://api.sempera.org/openapi.json get /v1/agents/{agent_id}/messaging # Read channel conversation Source: https://docs.sempera.org/api-reference/messaging/read-channel-conversation https://api.sempera.org/openapi.json get /v1/agents/{agent_id}/messaging/{channel}/conversations/{conversation_id} # A developer guide page (Markdown) Source: https://docs.sempera.org/api-reference/meta/a-developer-guide-page-markdown https://api.sempera.org/openapi.json get /docs/{page} # API front door Source: https://docs.sempera.org/api-reference/meta/api-front-door https://api.sempera.org/openapi.json get /v1 # Liveness Source: https://docs.sempera.org/api-reference/meta/liveness https://api.sempera.org/openapi.json get /v1/health # OpenAPI 3.1 document Source: https://docs.sempera.org/api-reference/meta/openapi-31-document https://api.sempera.org/openapi.json get /openapi.json # Retrieval map for coding agents Source: https://docs.sempera.org/api-reference/meta/retrieval-map-for-coding-agents https://api.sempera.org/openapi.json get /llms.txt # Robots policy for the API host Source: https://docs.sempera.org/api-reference/meta/robots-policy-for-the-api-host https://api.sempera.org/openapi.json get /robots.txt # List Sempera's published TTS models and their capabilities Source: https://docs.sempera.org/api-reference/models/list-semperas-published-tts-models-and-their-capabilities https://api.sempera.org/openapi.json get /v1/models # List what each Sempera TTS model supports Source: https://docs.sempera.org/api-reference/models/list-what-each-sempera-tts-model-supports https://api.sempera.org/openapi.json get /v2/tts/models # Public, unauthenticated model discovery Source: https://docs.sempera.org/api-reference/models/public-unauthenticated-model-discovery https://api.sempera.org/openapi.json get /v2/models Sempera's text-to-speech models in the list shape many client libraries expect. # What one Sempera TTS model supports Source: https://docs.sempera.org/api-reference/models/what-one-sempera-tts-model-supports https://api.sempera.org/openapi.json get /v2/tts/models/{model_id} # Authorization server metadata Source: https://docs.sempera.org/api-reference/oauth/authorization-server-metadata https://api.sempera.org/openapi.json get /.well-known/oauth-authorization-server # Protected resource metadata Source: https://docs.sempera.org/api-reference/oauth/protected-resource-metadata https://api.sempera.org/openapi.json get /.well-known/oauth-protected-resource # Protected resource metadata for the MCP endpoint Source: https://docs.sempera.org/api-reference/oauth/protected-resource-metadata-for-the-mcp-endpoint https://api.sempera.org/openapi.json get /.well-known/oauth-protected-resource/v1/mcp # Register an OAuth client Source: https://docs.sempera.org/api-reference/oauth/register-an-oauth-client https://api.sempera.org/openapi.json post /v1/oauth/register # Start an authorization Source: https://docs.sempera.org/api-reference/oauth/start-an-authorization https://api.sempera.org/openapi.json get /v1/oauth/authorize Authorization code flow with S256 PKCE. Redirects rather than returning JSON on success. Revoke approved grants with DELETE /v1/client-grants/{grant_id}. # Swap a code or refresh token Source: https://docs.sempera.org/api-reference/oauth/swap-a-code-or-refresh-token https://api.sempera.org/openapi.json post /v1/oauth/token # Read operational evidence reviews Source: https://docs.sempera.org/api-reference/operations/read-operational-evidence-reviews https://api.sempera.org/openapi.json get /v1/operational-reviews Restricted to Sempera platform operators in the explicitly designated internal operations workspace. A customer owner role is not sufficient. # Record an operational evidence review Source: https://docs.sempera.org/api-reference/operations/record-an-operational-evidence-review https://api.sempera.org/openapi.json post /v1/operational-reviews Platform operators in the designated internal operations workspace only. Records an attestation, never a live action or production verification. # Create a reseller-funded child organisation Source: https://docs.sempera.org/api-reference/organizations/create-a-reseller-funded-child-organisation https://api.sempera.org/openapi.json post /v1/organizations Requires a reseller owner or admin session and CSRF proof. The parent_organization_id is the reseller partner id. An approved entitlement and an existing verified customer owner are required. external_ref is required for retry-safe provisioning. The reseller funds the child through wholesale billing. This does not buy or transfer credits. Manage the returned customer through /v1/partners/{id}/customers; usage is at /v1/partners/{id}/report. Workspace API keys cannot administer a reseller. # Get an organization Source: https://docs.sempera.org/api-reference/organizations/get-an-organization https://api.sempera.org/openapi.json get /v1/organizations/{organization_id} # List organizations Source: https://docs.sempera.org/api-reference/organizations/list-organizations https://api.sempera.org/openapi.json get /v1/organizations # Get a compiled persona Source: https://docs.sempera.org/api-reference/personas/get-a-compiled-persona https://api.sempera.org/openapi.json get /v1/personas/{persona_id} # List compiled personas Source: https://docs.sempera.org/api-reference/personas/list-compiled-personas https://api.sempera.org/openapi.json get /v1/personas # Bind a phone number to an agent Source: https://docs.sempera.org/api-reference/phone-numbers/bind-a-phone-number-to-an-agent https://api.sempera.org/openapi.json post /v1/phone-numbers # List phone numbers bound to an agent Source: https://docs.sempera.org/api-reference/phone-numbers/list-phone-numbers-bound-to-an-agent https://api.sempera.org/openapi.json get /v1/phone-numbers # Cancel a phone task Source: https://docs.sempera.org/api-reference/phone/cancel-a-phone-task https://api.sempera.org/openapi.json post /v1/phone-tasks/{task_id}/cancel # Poll a phone signup Source: https://docs.sempera.org/api-reference/phone/poll-a-phone-signup https://api.sempera.org/openapi.json post /v1/phone-onboarding/poll # Preview a call and get its owner approval link Source: https://docs.sempera.org/api-reference/phone/preview-a-call-and-get-its-owner-approval-link https://api.sempera.org/openapi.json post /v1/phone-tasks/preview # Preview a text and get its owner approval link Source: https://docs.sempera.org/api-reference/phone/preview-a-text-and-get-its-owner-approval-link https://api.sempera.org/openapi.json post /v1/phone-messages/preview # Read a phone task and its result Source: https://docs.sempera.org/api-reference/phone/read-a-phone-task-and-its-result https://api.sempera.org/openapi.json get /v1/phone-tasks/{task_id} # Read a text thread on the plan's number Source: https://docs.sempera.org/api-reference/phone/read-a-text-thread-on-the-plans-number https://api.sempera.org/openapi.json get /v1/phone-threads/{thread_id} # Read the phone line, plan and allowance Source: https://docs.sempera.org/api-reference/phone/read-the-phone-line-plan-and-allowance https://api.sempera.org/openapi.json get /v1/phone-profile # Send an approved text Source: https://docs.sempera.org/api-reference/phone/send-an-approved-text https://api.sempera.org/openapi.json post /v1/phone-messages # Start an approved call Source: https://docs.sempera.org/api-reference/phone/start-an-approved-call https://api.sempera.org/openapi.json post /v1/phone-tasks # Start an owner-approved phone signup Source: https://docs.sempera.org/api-reference/phone/start-an-owner-approved-phone-signup https://api.sempera.org/openapi.json post /v1/phone-onboarding/start # Deployment identity and capacity (CI/certification) Source: https://docs.sempera.org/api-reference/platform/deployment-identity-and-capacity-cicertification https://api.sempera.org/openapi.json get /v1/platform # Get a saved prompt Source: https://docs.sempera.org/api-reference/prompts/get-a-saved-prompt https://api.sempera.org/openapi.json get /v1/prompts/{prompt_id} # List this workspace's saved prompts Source: https://docs.sempera.org/api-reference/prompts/list-this-workspaces-saved-prompts https://api.sempera.org/openapi.json get /v1/prompts # Save a prompt for future use Source: https://docs.sempera.org/api-reference/prompts/save-a-prompt-for-future-use https://api.sempera.org/openapi.json post /v1/prompts # Add a scenario to an agent's draft Source: https://docs.sempera.org/api-reference/scenarios/add-a-scenario-to-an-agents-draft https://api.sempera.org/openapi.json post /v2/agents/{uuid}/scenarios # Archive a scenario (out of the draft, restorable) Source: https://docs.sempera.org/api-reference/scenarios/archive-a-scenario-out-of-the-draft-restorable https://api.sempera.org/openapi.json post /v2/agents/{uuid}/scenarios/{scenario_uuid}/archive # Copy a scenario to another agent of this workspace Source: https://docs.sempera.org/api-reference/scenarios/copy-a-scenario-to-another-agent-of-this-workspace https://api.sempera.org/openapi.json post /v2/agents/{uuid}/scenarios/{scenario_uuid}/copy # Create pathway folders, move pathways and save their order Source: https://docs.sempera.org/api-reference/scenarios/create-pathway-folders-move-pathways-and-save-their-order https://api.sempera.org/openapi.json post /v2/agents/{uuid}/pathway-organization # Delete a scenario (from the draft, or from the archive) Source: https://docs.sempera.org/api-reference/scenarios/delete-a-scenario-from-the-draft-or-from-the-archive https://api.sempera.org/openapi.json delete /v2/agents/{uuid}/scenarios/{scenario_uuid} # Every agent's scenarios in this workspace Source: https://docs.sempera.org/api-reference/scenarios/every-agents-scenarios-in-this-workspace https://api.sempera.org/openapi.json get /v2/scenarios # List an agent's scenarios Source: https://docs.sempera.org/api-reference/scenarios/list-an-agents-scenarios https://api.sempera.org/openapi.json get /v2/agents/{uuid}/scenarios # One scenario Source: https://docs.sempera.org/api-reference/scenarios/one-scenario https://api.sempera.org/openapi.json get /v2/agents/{uuid}/scenarios/{scenario_uuid} # One scenario per published version Source: https://docs.sempera.org/api-reference/scenarios/one-scenario-per-published-version https://api.sempera.org/openapi.json get /v2/agents/{uuid}/scenarios/{scenario_uuid}/history # Restore an archived scenario into the draft Source: https://docs.sempera.org/api-reference/scenarios/restore-an-archived-scenario-into-the-draft https://api.sempera.org/openapi.json post /v2/agents/{uuid}/scenarios/{scenario_uuid}/restore # The agent's own words per published version Source: https://docs.sempera.org/api-reference/scenarios/the-agents-own-words-per-published-version https://api.sempera.org/openapi.json get /v2/agents/{uuid}/scenarios/agent/history # The judges that score a test scenario's calls Source: https://docs.sempera.org/api-reference/scenarios/the-judges-that-score-a-test-scenarios-calls https://api.sempera.org/openapi.json get /v1/agent-testing/scenarios/{uuid}/eval-agents # Update a pathway in the agent's draft Source: https://docs.sempera.org/api-reference/scenarios/update-a-pathway-in-the-agents-draft https://api.sempera.org/openapi.json post /v2/agents/{uuid}/scenarios/{scenario_uuid} # Claim, take over or reply to a widget chat, or return it to the agent Source: https://docs.sempera.org/api-reference/sessions/claim-take-over-or-reply-to-a-widget-chat-or-return-it-to-the-agent https://api.sempera.org/openapi.json post /v1/widget-live-chats/{chat_id} # Create a Web voice or simulator session Source: https://docs.sempera.org/api-reference/sessions/create-a-web-voice-or-simulator-session https://api.sempera.org/openapi.json post /v1/sessions # Enable or disable API chat Source: https://docs.sempera.org/api-reference/sessions/enable-or-disable-api-chat https://api.sempera.org/openapi.json patch /v1/agents/{agent_id}/channels/api-chat # Get a session Source: https://docs.sempera.org/api-reference/sessions/get-a-session https://api.sempera.org/openapi.json get /v1/sessions/{session_id} # List sessions for an agent Source: https://docs.sempera.org/api-reference/sessions/list-sessions-for-an-agent https://api.sempera.org/openapi.json get /v1/sessions # List the voices a session or draft may name Source: https://docs.sempera.org/api-reference/sessions/list-the-voices-a-session-or-draft-may-name https://api.sempera.org/openapi.json get /v1/voices # Read API chat channel Source: https://docs.sempera.org/api-reference/sessions/read-api-chat-channel https://api.sempera.org/openapi.json get /v1/agents/{agent_id}/channels/api-chat # Read live translation availability and recent sessions Source: https://docs.sempera.org/api-reference/sessions/read-live-translation-availability-and-recent-sessions https://api.sempera.org/openapi.json get /v1/translation-sessions # Read this agent's waiting and operator-held widget chats Source: https://docs.sempera.org/api-reference/sessions/read-this-agents-waiting-and-operator-held-widget-chats https://api.sempera.org/openapi.json get /v1/widget-live-chats # Send a message to the published agent Source: https://docs.sempera.org/api-reference/sessions/send-a-message-to-the-published-agent https://api.sempera.org/openapi.json post /v1/agents/{agent_id}/chat # Start a Sempera live translation session Source: https://docs.sempera.org/api-reference/sessions/start-a-sempera-live-translation-session https://api.sempera.org/openapi.json post /v1/translation-sessions Requires workspace activation. Two room-scoped join tokens are returned, one per person. Audio is recognized, translated and spoken. No phone call is placed. # end SMS conversation Source: https://docs.sempera.org/api-reference/sms/end-sms-conversation https://api.sempera.org/openapi.json post /v1/sms/conversations/{conversation_id}/end # Get SMS conversation Source: https://docs.sempera.org/api-reference/sms/get-sms-conversation https://api.sempera.org/openapi.json get /v1/sms/conversations/{conversation_id} # handoff SMS conversation Source: https://docs.sempera.org/api-reference/sms/handoff-sms-conversation https://api.sempera.org/openapi.json post /v1/sms/conversations/{conversation_id}/handoff # List SMS messages Source: https://docs.sempera.org/api-reference/sms/list-sms-messages https://api.sempera.org/openapi.json get /v1/sms/conversations/{conversation_id}/messages # Read SMS consent and sending policy Source: https://docs.sempera.org/api-reference/sms/read-sms-consent-and-sending-policy https://api.sempera.org/openapi.json get /v1/sms/conversations/{conversation_id}/policy # Record an SMS opt-out without sending Source: https://docs.sempera.org/api-reference/sms/record-an-sms-opt-out-without-sending https://api.sempera.org/openapi.json post /v1/sms/conversations/{conversation_id}/opt-out # resume SMS conversation Source: https://docs.sempera.org/api-reference/sms/resume-sms-conversation https://api.sempera.org/openapi.json post /v1/sms/conversations/{conversation_id}/resume # Send a bounded SMS batch Source: https://docs.sempera.org/api-reference/sms/send-a-bounded-sms-batch https://api.sempera.org/openapi.json post /v1/sms/batch # Send conversation message Source: https://docs.sempera.org/api-reference/sms/send-conversation-message https://api.sempera.org/openapi.json post /v1/sms/conversations/{conversation_id}/messages # Send SMS Source: https://docs.sempera.org/api-reference/sms/send-sms https://api.sempera.org/openapi.json post /v1/sms/send # Archive (or restore) a code snippet Source: https://docs.sempera.org/api-reference/snippets/archive-or-restore-a-code-snippet https://api.sempera.org/openapi.json delete /v1/snippets/{snippet_id} # Create a code snippet (its first draft) Source: https://docs.sempera.org/api-reference/snippets/create-a-code-snippet-its-first-draft https://api.sempera.org/openapi.json post /v1/snippets # Edit the draft (opens a new draft after the highest version when none is open) Source: https://docs.sempera.org/api-reference/snippets/edit-the-draft-opens-a-new-draft-after-the-highest-version-when-none-is-open https://api.sempera.org/openapi.json patch /v1/snippets/{snippet_id} # Get a code snippet and its versions Source: https://docs.sempera.org/api-reference/snippets/get-a-code-snippet-and-its-versions https://api.sempera.org/openapi.json get /v1/snippets/{snippet_id} # Get one version of a code snippet Source: https://docs.sempera.org/api-reference/snippets/get-one-version-of-a-code-snippet https://api.sempera.org/openapi.json get /v1/snippets/{snippet_id}/versions/{version} # List code snippets Source: https://docs.sempera.org/api-reference/snippets/list-code-snippets https://api.sempera.org/openapi.json get /v1/snippets # Publish the draft as an immutable version Source: https://docs.sempera.org/api-reference/snippets/publish-the-draft-as-an-immutable-version https://api.sempera.org/openapi.json post /v1/snippets/{snippet_id}/publish # Run a version (or the draft) on sample input in the sandbox Source: https://docs.sempera.org/api-reference/snippets/run-a-version-or-the-draft-on-sample-input-in-the-sandbox https://api.sempera.org/openapi.json post /v1/snippets/{snippet_id}/test # A Testbed run and its per-situation comparison Source: https://docs.sempera.org/api-reference/tests/a-testbed-run-and-its-per-situation-comparison https://api.sempera.org/openapi.json get /v1/testbed/runs/{run_id} # Change a standard Source: https://docs.sempera.org/api-reference/tests/change-a-standard https://api.sempera.org/openapi.json patch /v1/standards/{standard_id} # Delete a standard Source: https://docs.sempera.org/api-reference/tests/delete-a-standard https://api.sempera.org/openapi.json delete /v1/standards/{standard_id} # Failures per graph node over a window, and the weakest link Source: https://docs.sempera.org/api-reference/tests/failures-per-graph-node-over-a-window-and-the-weakest-link https://api.sempera.org/openapi.json get /v1/test-analytics # Get a test run Source: https://docs.sempera.org/api-reference/tests/get-a-test-run https://api.sempera.org/openapi.json get /v1/tests/{test_id} # List an agent's standards with their latest result Source: https://docs.sempera.org/api-reference/tests/list-an-agents-standards-with-their-latest-result https://api.sempera.org/openapi.json get /v1/standards # List test runs for an agent Source: https://docs.sempera.org/api-reference/tests/list-test-runs-for-an-agent https://api.sempera.org/openapi.json get /v1/tests # Read a synthetic graph run receipt Source: https://docs.sempera.org/api-reference/tests/read-a-synthetic-graph-run-receipt https://api.sempera.org/openapi.json get /v1/agent-testing/tornado/{run_id} # Read an offline simulation set Source: https://docs.sempera.org/api-reference/tests/read-an-offline-simulation-set https://api.sempera.org/openapi.json get /v1/agents/{agent_id}/simulations/{simulation_id} # Run a turn's situation against the step's current and a candidate instruction (nothing is saved) Source: https://docs.sempera.org/api-reference/tests/run-a-turns-situation-against-the-steps-current-and-a-candidate-instruction-nothing-is-saved https://api.sempera.org/openapi.json post /v1/testbed/runs # Run an offline simulation set Source: https://docs.sempera.org/api-reference/tests/run-an-offline-simulation-set https://api.sempera.org/openapi.json post /v1/agents/{agent_id}/simulations # Run bounded concurrent synthetic graph scenarios Source: https://docs.sempera.org/api-reference/tests/run-bounded-concurrent-synthetic-graph-scenarios https://api.sempera.org/openapi.json post /v1/agent-testing/tornado # Run standards against the agent's current version (background job) Source: https://docs.sempera.org/api-reference/tests/run-standards-against-the-agents-current-version-background-job https://api.sempera.org/openapi.json post /v1/standards/run # Run the test battery (background job) Source: https://docs.sempera.org/api-reference/tests/run-the-test-battery-background-job https://api.sempera.org/openapi.json post /v1/tests # Save a node-level standard (a step, a fixed situation, what the step must do) Source: https://docs.sempera.org/api-reference/tests/save-a-node-level-standard-a-step-a-fixed-situation-what-the-step-must-do https://api.sempera.org/openapi.json post /v1/standards # Save a Testbed situation and expectations as a standard Source: https://docs.sempera.org/api-reference/tests/save-a-testbed-situation-and-expectations-as-a-standard https://api.sempera.org/openapi.json post /v1/testbed/standards # Standards runs, newest first Source: https://docs.sempera.org/api-reference/tests/standards-runs-newest-first https://api.sempera.org/openapi.json get /v1/standards/runs # The situation at one turn of a stored conversation: the step, its instruction, the original reply Source: https://docs.sempera.org/api-reference/tests/the-situation-at-one-turn-of-a-stored-conversation:-the-step-its-instruction-the-original-reply https://api.sempera.org/openapi.json get /v1/testbed/situation # Create a tool Source: https://docs.sempera.org/api-reference/tools/create-a-tool https://api.sempera.org/openapi.json post /v1/tools # Disable (archive) a tool Source: https://docs.sempera.org/api-reference/tools/disable-archive-a-tool https://api.sempera.org/openapi.json delete /v1/tools/{tool_id} # Execute a tool in test mode Source: https://docs.sempera.org/api-reference/tools/execute-a-tool-in-test-mode https://api.sempera.org/openapi.json post /v1/tools/{tool_id}/execute # Get a tool Source: https://docs.sempera.org/api-reference/tools/get-a-tool https://api.sempera.org/openapi.json get /v1/tools/{tool_id} # List tools Source: https://docs.sempera.org/api-reference/tools/list-tools https://api.sempera.org/openapi.json get /v1/tools # Recent executions of a tool Source: https://docs.sempera.org/api-reference/tools/recent-executions-of-a-tool https://api.sempera.org/openapi.json get /v1/tools/{tool_id}/executions # Update a tool Source: https://docs.sempera.org/api-reference/tools/update-a-tool https://api.sempera.org/openapi.json patch /v1/tools/{tool_id} # Add a cleared voice to the workspace library Source: https://docs.sempera.org/api-reference/voices/add-a-cleared-voice-to-the-workspace-library https://api.sempera.org/openapi.json post /v1/voices/library/add # Enable or revoke authenticated team sharing Source: https://docs.sempera.org/api-reference/voices/enable-or-revoke-authenticated-team-sharing https://api.sempera.org/openapi.json post /v1/voices/library/{entry_id}/share # List professional clone preparations Source: https://docs.sempera.org/api-reference/voices/list-professional-clone-preparations https://api.sempera.org/openapi.json get /v1/voices/studio # List workspace voice collection Source: https://docs.sempera.org/api-reference/voices/list-workspace-voice-collection https://api.sempera.org/openapi.json get /v1/voices/library # Play a saved voice with the workspace sample text Source: https://docs.sempera.org/api-reference/voices/play-a-saved-voice-with-the-workspace-sample-text https://api.sempera.org/openapi.json get /v1/voices/library/{entry_id}/sample Returns audio/wav. Needs the voice shortlist write capability because synthesis spends capacity. Rechecks voice clearance and workspace admission before synthesis. # Prepare a professional clone without uploading or training Source: https://docs.sempera.org/api-reference/voices/prepare-a-professional-clone-without-uploading-or-training https://api.sempera.org/openapi.json post /v1/voices/studio # Read a voice shared with this workspace Source: https://docs.sempera.org/api-reference/voices/read-a-voice-shared-with-this-workspace https://api.sempera.org/openapi.json get /v1/voices/library/{entry_id} # Set the workspace rating for a saved voice Source: https://docs.sempera.org/api-reference/voices/set-the-workspace-rating-for-a-saved-voice https://api.sempera.org/openapi.json post /v1/voices/library/{entry_id}/rate # Carrier inbound-call webhook (media stream) Source: https://docs.sempera.org/api-reference/webhook-ingress/carrier-inbound-call-webhook-media-stream https://api.sempera.org/openapi.json post /v1/webhooks/telephony/voice Verified with the carrier's request signature, keyed by the account that holds the dialled number; an unbound number is refused. Answers with call-control markup that connects the call's audio to the media-stream route, or declines the call when the deployment or the workspace is at capacity. Off unless the deployment enables it. # Carrier media stream (websocket) Source: https://docs.sempera.org/api-reference/webhook-ingress/carrier-media-stream-websocket https://api.sempera.org/openapi.json get /v1/webhooks/telephony/media-stream A websocket carrying one call's audio. Locked by a single-use token, bound to the call and valid for seconds, minted by the inbound-call webhook; any stream without one is closed. Off unless the deployment enables it. # Carrier recorded-message webhook (media stream) Source: https://docs.sempera.org/api-reference/webhook-ingress/carrier-recorded-message-webhook-media-stream https://api.sempera.org/openapi.json post /v1/webhooks/telephony/message Verified with the carrier's request signature, keyed by the account that holds the dialled number. Records the message a caller left on the message path, one per call, for an employee that takes messages. Off unless the deployment enables it. # Legacy service handoff Source: https://docs.sempera.org/api-reference/webhook-ingress/legacy-service-handoff https://api.sempera.org/openapi.json post /v1/webhooks/legacy-layla Fixed service-to-service ingress, not a customer destination. Never retries an uncertain send. # Payment provider webhook ingress Source: https://docs.sempera.org/api-reference/webhook-ingress/payment-provider-webhook-ingress https://api.sempera.org/openapi.json post /v1/webhooks/stripe Verified over the raw body, 300 s tolerance, replay-guarded by event id. Server-to-server only. # Repository provider webhook Source: https://docs.sempera.org/api-reference/webhook-ingress/repository-provider-webhook https://api.sempera.org/openapi.json post /v1/webhooks/github Server-to-server only. Verifies the raw body before parsing; requires delivery id and event headers. # Telephony carrier post-call ingress Source: https://docs.sempera.org/api-reference/webhook-ingress/telephony-carrier-post-call-ingress https://api.sempera.org/openapi.json post /v1/webhooks/telephony Verified with the shared carrier token (constant-time compare); idempotent on the provider call id. # Verified text Assistant ingress Source: https://docs.sempera.org/api-reference/webhook-ingress/verified-text-assistant-ingress https://api.sempera.org/openapi.json post /v1/webhooks/text-assistant Server-to-server signed text ingress. Requires webhook-id, webhook-timestamp and webhook-signature over the raw body. Closed unless the channel is configured. # Create a webhook endpoint Source: https://docs.sempera.org/api-reference/webhooks/create-a-webhook-endpoint https://api.sempera.org/openapi.json post /v1/webhooks # Delete a webhook endpoint Source: https://docs.sempera.org/api-reference/webhooks/delete-a-webhook-endpoint https://api.sempera.org/openapi.json delete /v1/webhooks/{webhook_id} # Delivery attempts Source: https://docs.sempera.org/api-reference/webhooks/delivery-attempts https://api.sempera.org/openapi.json get /v1/webhooks/{webhook_id}/deliveries # Get a webhook endpoint Source: https://docs.sempera.org/api-reference/webhooks/get-a-webhook-endpoint https://api.sempera.org/openapi.json get /v1/webhooks/{webhook_id} # List webhook endpoints Source: https://docs.sempera.org/api-reference/webhooks/list-webhook-endpoints https://api.sempera.org/openapi.json get /v1/webhooks # Resend a recorded webhook delivery Source: https://docs.sempera.org/api-reference/webhooks/resend-a-recorded-webhook-delivery https://api.sempera.org/openapi.json post /v1/webhooks/{webhook_id}/deliveries/{delivery_id}/resend # Send a signed test event Source: https://docs.sempera.org/api-reference/webhooks/send-a-signed-test-event https://api.sempera.org/openapi.json post /v1/webhooks/{webhook_id}/test # Update a webhook endpoint Source: https://docs.sempera.org/api-reference/webhooks/update-a-webhook-endpoint https://api.sempera.org/openapi.json patch /v1/webhooks/{webhook_id} # Attach a custom component to a node of a widget's agent Source: https://docs.sempera.org/api-reference/widgets/attach-a-custom-component-to-a-node-of-a-widgets-agent https://api.sempera.org/openapi.json post /v1/widget/custom-components # Remove a widget custom component Source: https://docs.sempera.org/api-reference/widgets/remove-a-widget-custom-component https://api.sempera.org/openapi.json delete /v1/widget/custom-components/{id} # Update a widget custom component Source: https://docs.sempera.org/api-reference/widgets/update-a-widget-custom-component https://api.sempera.org/openapi.json patch /v1/widget/custom-components/{id} # Widget custom components on this workspace's agents Source: https://docs.sempera.org/api-reference/widgets/widget-custom-components-on-this-workspaces-agents https://api.sempera.org/openapi.json get /v1/widget/custom-components # Get a workspace Source: https://docs.sempera.org/api-reference/workspaces/get-a-workspace https://api.sempera.org/openapi.json get /v1/workspaces/{workspace_id} # List installed Sempera service versions Source: https://docs.sempera.org/api-reference/workspaces/list-installed-sempera-service-versions https://api.sempera.org/openapi.json get /v1/org/service-versions # List workspaces Source: https://docs.sempera.org/api-reference/workspaces/list-workspaces https://api.sempera.org/openapi.json get /v1/workspaces # Pin new sessions to an installed Sempera release Source: https://docs.sempera.org/api-reference/workspaces/pin-new-sessions-to-an-installed-sempera-release https://api.sempera.org/openapi.json put /v1/org/service-version # Read the organization's Sempera service pin Source: https://docs.sempera.org/api-reference/workspaces/read-the-organizations-sempera-service-pin https://api.sempera.org/openapi.json get /v1/org/service-version # Rename a workspace Source: https://docs.sempera.org/api-reference/workspaces/rename-a-workspace https://api.sempera.org/openapi.json patch /v1/workspaces/{workspace_id} # Owner features in the App Source: https://docs.sempera.org/app-features Call-data exports, outbound batch scheduling and CSV recipients, per-tool analytics, code snippets in the App; features with no API route, and the `/v1` reads that cover the same ground. Some of what a workspace owner does in the Sempera App has no route in this API. The routes behind these features are the App's own (`app.sempera.org/api/*`): they take a signed-in session, and a Sempera API key is honoured only on `/v1` ([Authentication and credentials](/authentication), [API overview](/api-overview)). They are described here so an integration knows what the App does around the API, what the customer sees, and which `/v1` reads cover the same ground. Nothing on this page is a route you can call with a key. ## Exporting call data Where: Conversations (Export) and Settings, Data and privacy. Who: workspace owners only. An owner exports one agent's phone calls, narrowed by the same filters the Conversations list has, as JSON or CSV. | Filter | Rule | | - | - | | Date window | An absolute start and end; either may be left open. A call with no start time is never left out by the window | | Length | `any`, under 1 minute, 1 to 5, 5 to 15, or over 15 minutes. A call with no measured length is in none of the length buckets | | Search | Up to 200 characters, matched the way the list matches it (the caller as shown, the call id and the outcome's own sentence) | The file is built in the background, so a large export never holds a request open: * One export at a time per agent; asking for a second while one is being built is refused with the export already underway named. * At most 10,000 calls and 64 MiB. Past either, the export fails with a sentence that says to narrow the date range. * When it is ready, the workspace's verified owners get an email with a link to Data and privacy in the App. The link opens a page; it is never a link to the file and carries no token, so a forwarded email opens nothing without an owner's own session. * The file leaves only through an owner-gated download in the App, with the same owner, agent-scope and workspace checks as the export itself. It is never a public URL, and it is served `no-store`. * A file can be downloaded for seven days, then it expires and is removed. Deleted content is never exported. A transcript that was deleted, or is past its retention window and waiting for the next sweep, is listed with its state and no words. If a transcript is deleted after the file was built, the file is withdrawn and removed, and its download answers that the file is gone. A JSON export is one document: a header naming what the export covers, then `calls`. A CSV export has one row per call, with the columns `id`, `started_at`, `duration_s`, `caller`, `outcome`, `summary`, `recording`, `transcript`, `turns`, `qc_status`, `qc_tags`, `deleted_at`, `delete_reason` and `conversation`, a byte order mark and CRLF line ends so a spreadsheet opens it as UTF-8. A cell that starts with `=`, `+`, `-`, `@`, a tab or a carriage return gets an apostrophe in front, so a spreadsheet shows it and never runs it. Requests, builds and downloads are recorded in the workspace's audit log as `call.export_requested`, `call.exported` and `call.export_downloaded`, with counts and filters and never a number, a search string or a word of a transcript. Reading calls from code is `GET /v1/calls` and `GET /v1/calls/{call_id}` ([Sessions and calls](/sessions-and-calls)). ## Outbound batches: start time, CSV recipients, scheduler Where: an agent's Outbound page. A batch is a list of people the business has permission to reach. It carries a name, a consent basis in words (at least 12 characters, kept on the record), a calling window read in each person's own time zone (default 09:00 to 18:00), and its limits: at most 500 recipients, 1 to 20 at once (default 3), 1 to 5 attempts (default 3), a wait of a minute to a week before a retry (default an hour). A call batch also needs an owner's approval before it can start. Before anyone is reached the workspace's do-not-contact list is checked, and a number on it is recorded as blocked and never called. **Start time.** A new batch takes an optional "Start sending at", a date and time read in the batch's time zone (or carrying its own offset). It may be at most five minutes in the past and at most a year ahead. Nothing is due before it, and after it each person is still reached only inside their own window. The batch says when it sends. **CSV recipients.** People can be added from a CSV file as well as typed. The file is at most 256 KiB, and it is only read: checking a file stores nothing and contacts nobody. * With a header row, the number column is the one named `number`, `phone`, `phone_number`, `phone number`, `mobile`, `cell` or `to`; a `timezone`, `time zone`, `time_zone` or `tz` column sets each person's zone; on a call batch a `precall_dtmf` or `keys` column sets the keys pressed when the call connects; every other named column becomes a `{field}` the message can use. Without a header, the columns are number, name and note. * Every row goes through the same check a typed person does. A row that fails, or that has a cell starting the way a spreadsheet formula does (`=`, `+`, `-`, `@`, a tab or a carriage return; a phone-shaped number is not one), is left out and reported with its row number and the reason, never the cell. A number listed twice is contacted once. Rows past the 500th are left out with a note. **Scheduler.** Whether a started batch sends on its own is a setting of the deployment. When it is on, a pass runs once a minute and advances every running batch whose start time has come and that has someone due. When it is off, a batch moves only when the owner presses "Send the next few now". The scheduler has no way of dialling of its own: it runs the same pass the button runs, with the same do-not-contact check, the same outbound call limits, the same per-person window and the same audit record (which notes whether the button or the scheduler ran the pass). It never touches a draft, and skips a paused or stopped batch. A pause or stop pressed while a pass is placing calls takes effect before the next person. A restart never dials anyone twice. A person marked as being dialled when a pass was interrupted is recorded as failed, with a note that the call may already have rung, and is not called again. The Outbound page shows whether started batches send on their own here and how often, why a running batch is waiting (an outbound call limit, or the workspace's limit on calls at once), and what the last pass sent and who ran it. **Outbound call limits.** An owner can cap the calls the workspace places per UTC hour and per UTC day (Settings, Usage and balance). No cap is set by default. The platform may set a ceiling for every workspace, and the stricter of the two applies. Every road that places a call spends one call from the cap after the do-not-contact check and before the dial: a batch pass, a single call from the Outbound page, and `POST /v1/calls`. When a cap is reached nobody is called and the answer says which limit and when it lifts: a batch pass leaves the rest pending, a single call is refused with `409`, and `POST /v1/calls` answers `429 rate_limited` with `retry_after_s` in the error. A cap that cannot be read stops calling: a batch pauses, a single call is refused, and `POST /v1/calls` answers `503 service_unavailable`. A call turned back before it was dialled gives its slot back. Texts are not calls and are not counted. **Calls at once.** A workspace's own limit on calls at once and on minutes a day, the one checked when a preview call starts, also applies to every call the Outbound page dials, a batch pass or a single call. It is the limit of the workspace the agent belongs to, whoever pressed the button. It is checked just before the dial, and each outbound call counts against it while it lasts, and in Calls started today on Settings, Usage and balance (below). At the limit nobody is called: a batch pass leaves the rest pending and the page says why the batch waits, and a single call is refused with `409` and the reason. The refusal is audited with the reason (a batch pass in its own pass record), and so is a single call refused because it is outside the person's calling hours. From the API: `POST /v1/calls` places one outbound call ([Sessions and calls](/sessions-and-calls)). The compatibility read `GET /v2/batches/list` lists an agent's batches with their recipients and progress; it does not carry the start time. ## Usage this billing period Where: Settings, Usage and balance (This billing period). Who: any member of the workspace. The card sets the workspace's inbound calls and minutes beside its outbound calls and minutes for the current billing period: the subscription's own current period when there is one, otherwise the UTC calendar month, and it says which. * **Inbound** counts every phone call the workspace's agents answered in the period, once each: the calls the carrier reported and the calls answered on Sempera's own voice service. A call seen both ways is counted once, by the same rule that sends one `call.completed` ([Webhooks](/webhooks)). A call answered on Sempera's own voice service is counted once it is announced, usually within a minute or two of ending; one not announced within a day of ending is not counted. Test, Try it live and simulator sessions are never counted. * **Outbound** counts the calls the workspace placed, how many were answered, and the minutes from answer to hang-up. * A call with no measured length is counted as a call and named, never as zero minutes. * Every figure is an estimate of use, not an invoice. The plan's published figures are shown beside them as published; no overage charge is worked out, and inbound and outbound minutes are not combined against the plan's included minutes. There is no `/v1` read for these figures. **Today's use**, on the same page, shows **Calls started today** (the UTC day): each call the workspace's limit on calls at once let through, whether a session started from the App or `POST /v1/sessions`, or an outbound call the Outbound page dials (a batch pass or a single call). An outbound call counts once it passes that check, even when the line then turns out busy or nobody answers, and each retry counts again; a call refused at the limit does not count. Calls answered on the workspace's phone numbers are not in this count. ## Buying a phone number Where: Channels, Phone numbers, Buy number. Workspace owners only. Opening the dialog buys nothing. It shows Country and verifies whether a payment method is saved for this workspace. A failed verification says the status is unknown, never that no method exists. Add payment method opens the existing hosted billing settings with a return link; returning to the App refreshes the status. If billing is not configured, the App reports that refusal instead of displaying a card form. Buying still requires an active paid plan, a verified saved payment method, the workspace's own agent, the carrier purchase gate, explicit cost confirmation and CSRF protection. Neither a saved method nor a paid plan alone authorizes a purchase. No card details or processor customer identifiers are returned to the dialog. These are session-only App controls, not developer-key API endpoints. ## Tool analytics Where: an agent's Tools page. For each tool the agent called, over the last 7, 30 or 90 days (default 30): | Figure | Meaning | | - | - | | Calls, succeeded, failed, refused | What happened to each call of the tool. A refusal is a call the tool's rules stopped before it ran | | Success share | Succeeded calls over all calls, in percent | | Time taken | The median and the 95th percentile, counting only calls that actually went out; a refusal, or a preview answer that nothing sent, is left out. The card says how many calls the two figures rest on | | Why the rest did not succeed | Each reason with its count, from one fixed vocabulary of plain sentences; an unknown cause reads as "something else went wrong" and is never echoed | | Last problem | The most recent reason, with when | | Trend | One bucket per day on the business's own calendar (its time zone, or UTC when none is set): calls and how many did not succeed | The page also totals every tool. Tools letting callers down come first, then the busiest. The figures are counted from the tool calls recorded in the agent's conversations (previews, simulations and phone calls): request and response bodies never reach the rollup, and a call with no time at all cannot be placed in a window, so it is left out and counted. Other pages: a tool's own test lives on its Test call tab, and the tool form can read a pasted `curl` command into its fields. The command is only read, never run: files, certificates, substitutions and pipes are refused, every credential in it becomes a named secret reference and its value is dropped, and nothing is saved until the owner reviews and saves. `GET /v1/tools` lists the tools ([Tools and integrations](/tools-and-integrations)); there is no API read for these analytics. ## Code snippets in the App The Tools page covers the whole owner road for the snippets `snippets.md` describes, on the same records: write a snippet in one editor (name, description, code, inputs, result shape, timeout) that says which draft a save writes and that published versions stay as published; test the draft or any version on sample input shaped from its inputs; publish behind a dialog that says the number is frozen; discard a draft; read the version history, newest first, with each version's code and hash, and which tools pin it. In the tool builder, a Code snippet tool has a pin picker: a snippet and one of its published versions, and a preview of what the model fills in. An existing tool shows what it runs today. Its version changes only after "Move the pin" and an explicit confirmation, and Save stays closed until then. Writing a snippet needs the operator role or above; pinning it in a tool needs the owner role, as `POST /v1/tools` does. ## Staging and analytics: App routes, not `/v1` Two App features are served by the App's own routes and have no public `/v1` counterpart yet. They take the signed-in owner's session, not an API key, carry no stability promise and are not in `openapi.json`. Build nothing against them; if you need the data over the API, say so at [hello@sempera.org](mailto:hello@sempera.org). * **Staging.** An agent's Environments page keeps one staged snapshot of the draft between development and production. Owners publish the draft (or an earlier version) to staging, try it live against it, restage an earlier snapshot, discard it, promote it, or roll production back. Promotion is the ordinary publish from the staged snapshot, and a snapshot is pinned to the production version it was staged over: once production moves it is stale and cannot be promoted. By default staging never reaches a caller: a phone number set to staging answers with production. Only when Sempera's call service has its pinned-version switches on (`SEMPERA_PINNED_VERSIONS` and the agent's own serve-staging-numbers setting) does such a number answer with the staged snapshot, once Sempera's servers have compiled and sealed it, and any problem falls back to production; the environments view's `numbers_note` says which applies. The routes live under `/api/agent/environments`. Read-only listings of what each environment holds are on `/v2/agents/{uuid}/environments` ([MCP for coding agents](/connect-mcp)). * **Analytics.** The workspace and agent Insights pages (Traffic, saved Reports with CSV download, and Boards of report tiles) read the App's routes under `/api/analytics` and `/api/insights`. An old `/analytics` address on an agent opens Insights. # Authentication and credentials Source: https://docs.sempera.org/authentication API keys, kinds, scopes, roles, the browser session token, service and CI identities, and what each can never do. One hostname does not mean one credential. Each class exists for one job and can never do another's. | Class | Used by | How it authenticates | What it can never do | | - | - | - | - | | Customer App session | `app.sempera.org` | Host-only, HttpOnly, Secure cookie plus a CSRF token on writes | Be presented to `api.sempera.org` | | Developer key (`kind: developer`) | A developer's server | `Authorization: Bearer sempera_sk_...` | Mint keys (`api_keys:write`) | | Runtime key (`kind: runtime`) | The agent runtime executing tools | Bearer | Publish, edit or create agents, manage keys, read other workspaces, touch billing | | Web session token | A browser in a Web voice conversation | The short-lived token `POST /v1/sessions` returns, bound to one room | Anything but join that one conversation | | Service key (`kind: service`) | An internal Sempera service | Bearer | Publish, manage keys | | CI key (`kind: ci`) | Deployment and smoke automation | Bearer, read-only scopes | Write anything | | Admin key (`kind: admin`) | A workspace owner's elevated automation | Bearer | Leave its workspace | | OAuth client | ChatGPT, Claude, Codex or Grok Bot, approved by a workspace owner | `Authorization: Bearer sempera_at_...`, one hour, refreshed by a rotating token | Reach anything but `POST /v1/mcp`, or hold a scope beyond `phone:read`, `phone:call` and `phone:text` | | REST client credential | A client that cannot do OAuth, such as Muse | `Authorization: Bearer sempera_sk_...` | Reach anything but the phone routes, or buy anything | | Third-party webhook | The payment provider, the telephony carrier | The provider's own signature or shared token | Reach any other route | ## API keys The `Authorization` header value is `Bearer `. Replace `` with your key; the angle brackets are placeholder notation, not part of it. Send the key as `Authorization: Bearer sempera_sk_...`. No other header or scheme is recognised; a key on any route outside `/v1` is ignored. A key has: * a **workspace**: the one customer account it lives in; * a **role**: `owner`, `operator` or `viewer`, the same ladder a person has in the workspace. A key is never minted above the role of whoever minted it, and each kind has a ceiling (a runtime or CI key is at most a viewer); * **scopes**: an explicit list. Each route names the scope it needs; a key without it gets `403 insufficient_scope` before anything runs. `kind` caps which scopes a key may hold at all. Scopes: `agents:read`, `agents:write`, `agents:publish`, `sessions:read`, `sessions:write`, `calls:read`, `calls:write`, `phone_numbers:read`, `phone_numbers:write`, `tools:read`, `tools:write`, `tools:execute`, `integrations:read`, `integrations:write`, `knowledge:read`, `knowledge:write`, `tests:read`, `tests:write`, `evals:read`, `evals:write`, `workspaces:read`, `workspaces:write`, `organizations:read`, `api_keys:read`, `api_keys:write`, `webhooks:read`, `webhooks:write`, `deploy:read`, `models:read`, `prompts:read`, `prompts:write`, `phone:read`, `phone:call`, `phone:text`. Authorization is two locks: the scope (what the credential was minted for) and the role (what that identity may do to the resource). Both must pass. Default is deny. ## Minting and revoking A workspace owner creates keys in the Sempera App under Settings, API keys, or with `POST /v1/api-keys` (owner only, `api_keys:write`); either way the secret is shown once. The first key of a workspace is created in the App, because creating a key needs an owner's signed-in session or an admin key, and a developer key can never mint keys. `GET /v1/api-keys` lists keys without secrets. `DELETE /v1/api-keys/{id}` revokes immediately. `GET /v1/me` describes the calling credential. ## Connected clients Each outside client gets its own grant, so one can be ended without touching another. An owner sees them all in Settings, Connected clients, with scopes and last use. **OAuth, for MCP hosts.** The host discovers the door from the `401` on `POST /v1/mcp`, registers itself, and sends the browser to a consent page on `app.sempera.org`. Only a signed-in workspace owner can approve, and no key or token can do it for them. The grant covers one client in one workspace with the scopes approved there, and approving the same client again replaces it. The flow is authorization code with PKCE (`S256`), exact redirects and audience binding to `https://api.sempera.org/v1/mcp`. Details are in [MCP](/connect-mcp#sign-in-with-oauth). **A scoped REST credential, for a client that cannot do OAuth.** An owner creates one in Settings, Connected clients, or with `POST /v1/client-grants/rest` (`client`, `scopes`, optional `expires_in_days`). It holds phone scopes only, works on the phone routes only, expires in 90 days by default (at most 365), and is shown once. `GET /v1/client-grants` lists every connected client (OAuth, MCP keys and REST credentials) and `DELETE /v1/client-grants/{grant_id}` ends one. An ordinary API key, another workspace's client and an unknown id all read as `not_found`. ## Browser clients For customer-facing voice, keep the workspace API key on your server. Your server calls `POST /v1/sessions` and hands the browser the returned `token` and `url`; the token is scoped to one room and expires with the conversation. Never embed a workspace key in a public bundle. Developer tools can call the public `/v1` API using an explicitly supplied `Authorization` bearer key and `credentials: "omit"`. `https://docs.sempera.org` may preflight the route's supported methods, including writes. Other origins may preflight read-only methods when `Access-Control-Request-Headers` includes `Authorization`; the actual read must carry the bearer key. A preflight announces the header name, not the key, and grants no access by itself: the actual request still needs valid authentication, scopes and workspace permissions. CORS does not enable credentials mode. Requests carrying cookies, session-only routes, OAuth protocol doors, provider/server-to-server ingress, legacy `/api` routes and routes outside `/v1` receive no permissive CORS headers. Allowed request headers are `Authorization`, `Content-Type`, `Idempotency-Key`, `X-Request-Id` and `traceparent`. Browser callers can read request and correlation ids, rate-limit and retry headers, `Idempotent-Replayed`, `Location`, `WWW-Authenticate` and `Content-Disposition` on permitted responses, including errors. ## Sign-in for people People sign in to the Sempera App with Google, Apple, email or phone. Those roads live on `app.sempera.org` and never on the API. # Automations (triggers) Source: https://docs.sempera.org/automations Triggers that listen for an event, test conditions and run one action (a signed webhook, a tool or an owner email), with runs, retries and dry runs. A trigger says "when this event happens, and these conditions hold, do one thing". It listens for one event Sempera emits, tests the event against zero or more conditions, and runs one action aimed at a destination the workspace has already approved. Every run is recorded, including the ones a condition skipped. A trigger covers the whole workspace or the agents it names; an event about an agent it does not cover leaves no run. Every trigger belongs to one workspace. A trigger or run of another workspace answers `404` exactly like one that never existed, and a trigger that names an agent the credential cannot read is not listed to it. Reads need `agents:read`. Writes need `agents:write` and the `owner` role, because a trigger sends the business's call data to another system. A dry run needs `agents:write` only. Success answers use the compatibility envelope `{"data": ..., "errors": null}`; refusals use the standard error envelope ([Errors and limits](/errors-and-limits)). Without a customer database, the two list routes answer empty lists, the events route still answers, and every other route answers `503 service_unavailable`. ## Routes | Route | Notes | | - | - | | `GET /v1/automation/events` | The events, their fields and the operators each field type offers, the actions offered and the actions refused | | `GET /v1/automation/triggers` | `{"triggers", "total", "limit", "offset"}`. `limit` 1 to 200 (default 50), `offset` from 0, `sort` `recent` (default), `runs`, `active` or `name`, and the filters `agent_id` (triggers covering that agent) and `event` | | `POST /v1/automation/triggers` | Create. `201`. `POST /v1/automation/pipelines` creates the same record; call one of them, not both | | `GET /v1/automation/triggers/{id}` | One trigger | | `PATCH /v1/automation/triggers/{id}` | A partial edit: fields you leave out keep their values. `status` (`active` or `paused`) or `enabled` pauses and resumes. Optional `expected_updated_at` makes the save conditional (see Editing together) | | `DELETE /v1/automation/triggers/{id}` | `{"id", "deleted": true}` | | `POST /v1/automation/triggers/{id}/test` | A dry run. Nothing is sent and nothing is recorded, and a paused trigger can be tested | | `GET /v1/automation/triggers/{id}/executions` | One trigger's runs, newest first. `limit`, `offset` and `status` | | `GET /v1/automation/executions` | Every run the credential may read, newest first. `limit`, `offset`, `status` and `trigger_id` | | `GET /v1/automation/executions/{id}` | One run | | `POST /v1/automation/executions/{id}/retry` | Send a failed or stalled webhook or tool run again, once | A `limit`, `offset`, `sort`, `event` or `status` outside the values above is refused with `400 validation_failed`, not clamped. ## Editing together Two owners can open the same trigger and both save. Without a guard the later save wins and the earlier one is lost without a word. To avoid that, send the `updated_at` you read back with the edit: ```http theme={null} PATCH /v1/automation/triggers/{id} {"name": "Booked calls", "expected_updated_at": "2026-10-23T15:00:00+00:00"} ``` `expected_updated_at` is the trigger's `updated_at`, sent back unchanged (a time with a zone). The trigger is saved only if it still has that `updated_at`, and the check and the save are one step, so of two saves that carry the same value exactly one is applied. A pause or a resume (`enabled` or `status`) is an edit and takes the same field. If another owner saved first, the answer is `409 conflict` with `reason: "stale_trigger"` and `updated_at`, the value now on file. Nothing is written and nothing is recorded in the audit log. Show the owner what changed, then either discard the edit or send it again with `expected_updated_at` set to that `updated_at` to save it over the newer version. A value that is not a time with a zone is `400 validation_failed` with `field: "expected_updated_at"`. A trigger of another workspace, or one deleted meanwhile, is `404 not_found` and names no `updated_at`. Without the field a save behaves as it always did: the last save wins. ## A trigger ```http theme={null} POST /v1/automation/triggers {"name": "Long calls to the ops channel", "event": "call.completed", "conditions": [{"field": "duration_s", "operator": "greater_than", "value": 600}], "action": {"kind": "webhook", "endpoint_id": ""}, "agent_ids": ["northwind-barbers"], "enabled": true} ``` | Field | Rule | | - | - | | `name` | Required, up to 200 characters | | `event` | Required: one key of the table below | | `conditions` | Up to 20 of `{"field", "operator", "value"}`; every one must hold. `field` is one of the event's fields, `operator` one that field's type offers, and `value` matches the field's type (text up to 500 characters). `is_empty` and `is_not_empty` take no value | | `action` | Required, one of the actions below | | `agent_ids` | `"all"` (default) or 1 to 50 agent ids | | `enabled` | `true` or `false`. A trigger created without it starts paused | A trigger reads `id`, `object: "automation_trigger"`, `org_id`, `name`, `enabled`, `status`, `event`, `event_name`, `conditions`, `action`, `agent_ids`, `run_count`, `last_run_at`, `created_at` and `updated_at`. | Type | Operators | | - | - | | Text | `equals`, `not_equals`, `contains`, `not_contains`, `is_empty`, `is_not_empty`; text compares without regard to case | | Number | `equals`, `not_equals`, `greater_than`, `less_than`, `is_empty`, `is_not_empty` | | Yes or no | `equals`, `not_equals` | | List of text | `contains`, `not_contains`, `is_empty`, `is_not_empty` | | Object, or a list of objects | `is_empty`, `is_not_empty` | ## Events | Event | Fired when | Fields | | - | - | - | | `call.completed` | A phone call ended and its record was stored | `call_id`, `agent_id`, `direction`, `duration_s` (number), `outcome`, `summary`, `recording_stored` (yes or no), `turns` (number) | | `call.failed` | An outbound call was not placed. A number on the do-not-contact list is a deliberate skip, not a failure | `agent_id`, `batch_id`, `batch_name`, `attempt` (number), `reason`, `will_retry` (yes or no), `number_last4` | | `knowledge.gap_detected` | The agent searched its knowledge and nothing answered the caller | `agent_id`, `call_id`, `question`, `search_query`, `coverage` (number, 0 to 1), `documents` (list of text) | | `alert.fired` | A metric alert rule fired or worsened ([Metric alerts](/alerts)) | `alert_id`, `alert_name`, `metric`, `severity`, `kind`, `value_pct`, `threshold_pct`, `call_volume`, `matched`, `lookback_minutes` (numbers), `agent_ids` (list of text) | | `issue.created` | A triage issue was opened ([Triage issues](/triage)) | `issue_id`, `number` (number), `title`, `severity`, `category`, `status`, `source`, `agent_id` | | `agent.published` | A version of an agent was published | `agent_id`, `version` (object) | | `call.outcomes` | Dispositions were read from a finished call ([Dispositions](/dispositions)) | `agent_id`, `call_id`, `version_number` (number), `outcomes` (list of objects) | \| `tool.success`, `tool.failure` | An attached conversation tool finished or was refused on a live phone call. A Test panel run, a simulation, a typed preview and API chat never fire them. Fixture results carry `environment: FIXTURE`; they are not live system success | `agent_id`, `call_id`, `tool`, `status` (number or null), `environment` | | `call.post_webhook` | A completed call was recorded and its post-call event is ready, not proof of webhook delivery | `agent_id`, `call_id`, `direction` | | `call.started` | A phone runtime session reached its opening | `agent_id`, `call_id`, `direction` | | `eval.completed` | An existing evaluation run announced completion or failure | `agent_id`, `test_id`, `status`, `outcome` | | `sms.received`, `sms.post_webhook` | An authenticated, non-replayed inbound SMS webhook was stored | `agent_id`, `thread_id`, `message_id` | | `call.voicemail` | The outbound runtime classified an answering machine | `agent_id`, `call_id` | | `call.inbound_received` | An authenticated inbound phone request passed admission | `agent_id`, `call_id` | | `integration.webhook_received` | An authenticated incoming integration delivery was stored for the first time | `agent_id`, `endpoint_id`, `event_id`, `event_type`, `record_type`, `record_id` | `call.completed` never fires for a test, and on a call Sempera's own call service answered its `summary` is empty ([Webhooks](/webhooks)). `agent.published` and `call.outcomes` are the webhook events of the same name, reduced to the fields listed here. Integration events contain identifiers, never arbitrary incoming payloads or secrets. There are no invented CRM subscriptions. Tool actions triggered by automations do not recursively emit conversation tool events. ### Integration and CRM changes Choose Integration webhook received in the trigger builder. The contact and booking templates add an event-type condition; they do not subscribe to another system. Configure your sender to deliver a JSON object to the agent's existing incoming webhook using the Sempera signature and delivery-ID headers. For example: ```json theme={null} {"event_type":"crm.contact.updated","record_type":"contact","record_id":"contact-123"} ``` The three optional values must be identifiers of 1 to 200 ASCII characters, starting with a letter or digit and containing only letters, digits, underscores, periods, colons, slashes or hyphens. Missing or invalid values become empty strings, not coerced or truncated identifiers. All other payload fields are excluded from trigger data. Workspace and agent identity always come from the authenticated endpoint, never the submitted body. A duplicate or rejected delivery does not announce another event. Use all-condition matching with `endpoint_id` and `event_type` to limit a trigger to one connection and one change. Tool inputs can reference `{{record_id}}` to look up or update the record through a workspace-approved API tool. Do not send credentials or whole customer records in these identifier fields. The existing incoming-event storage still retains the original accepted body under its existing retention policy. An owner must configure the external sender or a signing adapter. Native provider webhook signatures and automatic CRM subscriptions are not implemented by this path. Receipt-to-trigger dispatch remains best effort, not a durable subscription queue: a process interruption after storage can leave an event unannounced, and redelivery is deduplicated. No live delivery proof is implied by a template or dry run. ## Actions | `action.kind` | Fields | What it does | | - | - | - | | `webhook` | `endpoint_id` | POSTs the event to one of the workspace's own enabled webhook endpoints ([Webhooks](/webhooks)), signed with that endpoint's secret | | `tool` | `tool_id`, `input` | Calls one of the workspace's own API request tools once ([Tools and integrations](/tools-and-integrations)). `input` is up to 20 named text values, each up to 2000 characters, which may use `{{field}}` for a field of the event | | `email` | `to` | Emails every owner of the workspace with a verified address. `to` is `workspace_owners`, the only value | | `call` | `request` | A full `POST /v1/calls` request with an explicit agent, recipient, production/version pin and consent. Uses that route's runtime dispatch, calling windows, caps and do-not-contact checks | | `sms` | `agent_id`, `user_number`, `agent_message`, `consent_basis`, `timezone`, `window` | Sends through the agent's existing SMS handler, with texting enablement, sender ownership, limits, write policy, opt-out and do-not-contact checks. `timezone` and `window` are the recipient's sending hours, checked when saved and again before the send. Outside them, or on a trigger saved without them, nothing is sent and the run fails with `outside_sending_hours` or `sms_hours_required` | | `slack` | `tool_id`, `input` | Uses a registered workspace HTTPS API request tool aimed at `hooks.slack.com` or `slack.com`. Credentials remain in the existing tool registry, not the trigger | | `custom_code` | `snippet_id`, `version`, `input` | Runs an active published workspace snippet in the existing bounded, no-network sandbox. Inputs may use the same event field substitutions as tool inputs | A bare URL is not a destination: register the address as a webhook endpoint first. An `endpoint_id` or `tool_id` that is not this workspace's own, or a tool that is not an API request tool, is a `400`. Call and SMS actions additionally require `calls:write` and outbound permission when created or edited. Their target agent must also be in the credential's scope. Choosing a `custom_code` action additionally requires `tools:write` and snippet permission. A `call` action's `timezone`, `window` and `webhook` are checked when saved, by the rules `POST /v1/calls` uses. A call or SMS action on `call.failed` or `call.outcomes` is a `400`: only inbound call events may start contact, so it could never run. The four specialized actions are stored under the durable `tool` business-action class, so existing publication crash, retention and owner-review protections still apply. Their public action preserves its specialized kind. No schema migration is needed. Each execution has a stable one-shot dispatch key, with stale reservation recovery disabled. These actions cannot be manually retried; inspect an uncertain result before authoring new work. Tests and dry runs never send. A fixture-mode SMS or Slack action records that nothing was delivered rather than reporting a real delivery. Custom code records execution status, not arbitrary code output, in the delivery log. A webhook action posts this body with `X-Sempera-Signature`, `X-Sempera-Event-Id` (the body's `id`, the same on every attempt), `X-Sempera-Event` and, when the workspace has turned on JWT request signing, the signing header ([Secrets and request signing](/secrets-and-signing)): ```json theme={null} {"id": "evt_...", "object": "event", "type": "call.completed", "created_at": "2026-09-28T12:00:00+00:00", "workspace_id": "...", "trigger": {"id": "...", "name": "Long calls to the ops channel"}, "data": {"call_id": "...", "duration_s": 640, "...": "every field the event lists"}} ``` It makes up to three attempts (at once, 30 seconds later, then 5 minutes later) until the first `2xx`, and follows the delivery rules in [Webhooks](/webhooks). A tool is called once, because a tool that changes something is not safe to repeat unasked, through the same road and write rules as a tool step inside a call. Where the deployment answers tools from fixtures, the run ends `failed` with an error saying nothing was sent. ## Runs A run reads `id`, `object: "automation_execution"`, `trigger_id`, `trigger_name`, `event`, `event_id`, `event_summary`, `event_data`, `agent_ids`, `status`, `matched`, `failed_condition`, `action_kind`, `action`, `stalled`, `retryable`, `started_at`, `completed_at` and `duration_ms`. `status` is `skipped` (a condition did not hold; `failed_condition` names the first, with the value it saw), `running`, `succeeded` or `failed`. `action` is the result: for a webhook the endpoint and every attempt with its `response_code` or `error`, for a tool the tool and the outcome, for an email `recipients` and `sent`. A run still `running` well after its attempts could have finished reads `stalled: true`. `POST .../retry` sends a failed or stalled webhook or tool run once more, now: the same event id and time, to the trigger's current destination, numbered after the attempts it already has. It answers the run. A skipped or succeeded run, an email run (its owners would get it twice), a run whose call content was deleted, a run still delivering, and a run whose trigger's action has changed since, are refused with `409 conflict` and a sentence saying why. ## Dry run ```http theme={null} POST /v1/automation/triggers/{id}/test {"event": {"duration_s": 700}} ``` `event` sets some of the event's fields; the event's own sample fills the rest (and a trigger that names agents uses its first). It answers `object: "automation_test"`, `dry_run: true`, `delivered: false`, the resolved `event_data`, `in_scope`, each condition with the value it saw (`actual`) and whether it `passed`, `failed_condition`, `would_run` and, when it would run, the `action` that would go out with its destination. A field the event does not have, or one of the wrong type, is a `400`. ## Refusals | Status | When | | - | - | | `400 validation_failed` | A field breaks a rule above; `field` names it | | `404 not_found` | No such trigger or run in this workspace, or one naming an agent the credential cannot read | | `409 conflict` | Retrying a run that cannot be sent again; or an edit whose `expected_updated_at` is no longer the trigger's (`reason: "stale_trigger"`, with the `updated_at` on file) | | `503 service_unavailable` | The deployment has no customer database to keep triggers in | # Billing boundaries Source: https://docs.sempera.org/billing-boundaries What the API never does with money, which usage and cost reads exist, and where plans and invoices are managed. This page says what the Sempera API does and does not do with money, so an integration never has to guess. It repeats no price on purpose: the published plans are on the [pricing page](https://sempera.org/pricing/), and a number copied here could only drift out of date. ## The short version * **The API never moves money.** No route charges a card, buys something, changes a plan, adds a credit or issues a refund. * **Plans, payment methods, invoices and credit packs live in the Sempera App.** A workspace owner manages them there, through the payment provider the App uses. * **A running agent is billed through its plan.** A fixed monthly fee and its phone number, with included minutes and overage. Credits never pay for a live call. * **Building spends credits.** Assistant turns, simulations and evals, test voice calls and knowledge processing draw on the workspace's credit balance, which the API can read (below). * **The gates that exist in the API protect spending; they are not payments.** They are listed below. ## What the API tells you Four routes are as close to billing state as the API gets. The two organization reads are native and describe the organization only. The two `/v1/compat` reads belong to the Compatibility tag, which exists so clients shaped for other consoles can read the fields they expect, and they answer in the `{"data": ..., "errors": null}` envelope. All four are reads: none charges, changes a plan or sends anything. The first time anything asks, Sempera creates the workspace's default billing record (no payment processor attached, status `none`), so a first read of either `/v1/compat` route can add that one row; a second read changes nothing. | Route | Scope | What it answers | | - | - | - | | `GET /v1/organizations` | `organizations:read` | The organizations you can see. Today every workspace is its own organization, the membership and billing umbrella ([Workspaces and organizations](/workspaces)). | | `GET /v1/organizations/{organization_id}` | `organizations:read` | One of them, or `404 not_found`. | | `GET /v1/compat/app/orgs/{uuid}/billing` | `organizations:read` | The workspace's `plan`, `status` and `billing_provider`. The balance and refill fields are `null`, because there is no prepaid balance. Another workspace's id answers `404 not_found`. | | `GET /v1/compat/app/billing/customer` | `organizations:read` | The billing customer Sempera holds for the workspace, or `"customer": null` with `"has_payment_method": false` when there is none. Fields the payment provider keeps are `null`, never copied. | `POST /v1/compat/app/pay/purchases` also exists for client compatibility. It creates no purchase and charges nothing: it answers an empty `purchases` list. ## Credits Every workspace starts with 100 free credits, once. Credit packs are one-off purchases in the App, cost US dollars, and never expire: the prices are on the Buy credits page and the [pricing page](https://sempera.org/pricing/). What each action costs is in `costs` on the read below, so an integration never hard-codes it. | Route | Scope | What it answers | | - | - | - | | `GET /v1/credits` | `organizations:read` | `balance`, `held`, `spent_total`, what each action costs (`costs`), the packs on sale (`packs`). The workspace is the credential's, never a parameter. | | `GET /v1/credits/history` | `organizations:read` | Every grant, purchase, spend, refund and reversal, newest first. `limit` is 1 to 200; pass `next_before` as `before` for the next page. | * **Reserve, then settle.** Credits are held when work starts, so a balance can dip by more than the work finally costs. What is not used comes back as a `refund` row. Work that fails is refunded in full. A hold whose work never reports refunds itself. * **Empty stops building, and only building.** A refused action answers `402 credits_exhausted` with `needed`, `balance`, `debt` and, where buying can work, `buy_path`, and starts nothing. Agents already live keep answering and placing calls, whatever the balance. * **A run does what its credits cover.** An eval holds up to 25 conversations and scores no more than it held; `left_unscored` says how many remain, and another run picks them up. A test voice call ends at the minutes held, with a spoken reason. Live calls have no such limit. * **Publishing never spends on its own.** Checks after a publish are opt-in: the answer says what they would cost, and they run when you start them. * \*\*`POST /v1/calls` spends no credits.\*\* Neither does `POST /v1/sessions`. Only a phone test (a call with `test`) and a spoken test in the App do. A test voice call also uses the plan's minutes and counts toward call limits, like any call, on top of its credits. * **Not charged:** typed tests, the Builder, explaining a call and Speech Studio. The Assistant's daily token allowance stays as an abuse limit, separate from credits. * **A refund takes its share of a pack back, and a dispute all of it.** What the balance cannot cover is recorded as `debt`, which stops building and buying until it is cleared. A won dispute clears it and restores the rest. The balance itself never goes below zero. * **A workspace a provider bills cannot buy packs.** `can_buy` is false and `buy_note` says why; Sempera adds its credits. * **TEST payments add no credits on a production service,** and TEST credits never carry into live. * **One free grant per workspace.** A creator who already had one on a workspace now deleted starts the next at zero. ## Usage and cost you can see * **In the App, not the API.** Settings, Usage shows this billing period's inbound and outbound calls and minutes. Every figure is an estimate of use, not an invoice. The plan's published figures sit beside it, no overage charge is worked out, and inbound and outbound minutes are not added together against the plan's included minutes ([Owner features in the App](/app-features)). Its daily chart reads the internal browser route `GET /api/workspaces/{workspace_id}/usage-series?days=30`, not a developer-key billing endpoint. Active workspace members may read 7, 14, 30 or 90 UTC calendar days, including the partial current day. The answer carries `available`, `days` (each with `day`, `calls`, `minutes`, `unmeasured_calls`), `as_of`, `timezone`, `is_invoice: false` and `basis_note`. Carrier/runtime duplicates count once; released outbound attempts, tests and previews do not count. A missing duration remains explicitly unmeasured, not a measured zero. Other workspaces and unauthenticated readers receive the same 404; invalid ranges receive 400; an unreadable meter receives 503, never a fabricated empty chart. All three internal usage reads (today, billing period and daily series) recheck the browser credential's workspace authorization and bind that target's database request context, independently of the session's last selected workspace. Developer API keys cannot use these browser routes. * **The cost of a disposition before you publish it.** `POST .../dispositions/{id}/estimates` quotes the cost per call in US dollars, with its assumptions, and stores nothing. Publishing a disposition whose expected cost is above zero requires `acknowledgeEstimate: true`, so a cost cannot start by accident ([Dispositions](/dispositions)). * **Not offered:** a cost metric. Metric alerts are computed only from records Sempera already keeps, and cost is not one of them ([Metric alerts](/alerts)). ## Limits that protect spending * **Outbound call limits.** A workspace owner can cap the calls the workspace places per UTC hour and per UTC day. No cap is set by default, the platform may set a ceiling for every workspace, and the stricter one applies. `POST /v1/calls` over a cap answers `429 rate_limited` with `retry_after_s` and places nothing. If the cap cannot be read, it answers `503 service_unavailable` and places nothing ([Sessions and calls](/sessions-and-calls), [Owner features in the App](/app-features)). * **Request rate limits** are 600 requests a minute per key and 60 a minute per address on routes that need no credential. They protect the service; they are not a quota tied to a plan ([Errors and limits](/errors-and-limits)). * **Drafts cannot place real calls.** `POST /v1/calls` needs a published version, so a half-edited agent cannot ring a customer's phone ([Sessions and calls](/sessions-and-calls)). ## What stays outside the API * **Choosing, changing or cancelling a plan**, adding or removing a payment method, and receiving an invoice. * **Buying a phone number.** `POST /v1/phone-numbers` points a number you supply at an agent. No route searches for or buys one. * **Paying anyone.** The payment provider delivers its own events to Sempera on a route you never call, under the provider's own signature ([Webhooks](/webhooks)). If your integration needs a billing fact that is not here, ask the workspace owner to read it from the App, or write to [hello@sempera.org](mailto:hello@sempera.org). Do not infer it from the compatibility routes above, which report only what Sempera records. # Agent branches Source: https://docs.sempera.org/branches A second draft of an agent, rebased and merged onto its main draft. A branch is a second working draft of an agent, cut from its main draft (what the App calls Development), so a change can be prepared apart and landed in one step. Merging is a fast-forward only: a branch lands while main has not moved since the branch was cut or last rebased, and otherwise it must be rebased first. Nothing here publishes; after a merge, main's draft is published the usual way ([Agents](/agents)). These are `/v2` compatibility routes ([API overview](/api-overview)). `{agent_id}` is the Sempera agent id, and a branch id is a UUID. | Route | Scope | Notes | | - | - | - | | `GET /v2/agents/{agent_id}/branches` | `agents:read` | The agent's open branches, oldest first | | `POST /v2/agents/{agent_id}/branches` | `agents:write` | `{"name"}`. Cuts a branch from main's draft as it stands. `201` | | `POST /v2/agents/{agent_id}/branches/{branch_id}/rebase-preview` | `agents:write` | What rebasing onto main's current head would do. Writes nothing | | `POST /v2/agents/{agent_id}/branches/{branch_id}/rebase` | `agents:write` | Rebase onto the head the preview named, settling each conflict | | `POST /v2/agents/{agent_id}/branches/{branch_id}/merge` | `agents:write` | Land the branch on main and close it | | `DELETE /v2/agents/{agent_id}/branches/{branch_id}` | `agents:write` | Close the branch without landing it: `{"deleted": true}` | Writes need the `operator` or `owner` role. A branch reads `id`, `agent_id`, `name`, `base_version_id`, `created_by`, `created_at` and `behind`, which is true once main has moved past the point the branch was cut from or last rebased onto. A branch `name` is 1 to 64 letters, digits, dots, underscores or dashes, unique among the agent's open branches. `dev`, `latest`, `staging`, `production` and version-like names such as `1.2.3` are reserved. ## Rebase and merge Branches are compared one draft setting at a time, with the instructions document counting as one setting. The preview answers `{"dev_head_version_id", "up_to_date", "auto_merged", "changes", "conflicts"}`: `changes` are settings only main changed, taken automatically; `conflicts` are settings both sides changed differently. Each item carries `id` (`setting:`), `key`, `label`, `base` and `theirs` (main's value); a conflict adds `ours` (the branch's value). ```http theme={null} POST /v2/agents/northwind-barbers/branches/{branch_id}/rebase {"dev_head_version_id": "", "resolutions": {"setting:greeting": "ours"}} ``` Every conflict needs a resolution: `"ours"`, `"theirs"` or `{"value": ...}`, a new value that passes the same checks a draft edit does. The merged result becomes the branch's draft and its base moves to main's head. The answer is `{"version", "auto_merged", "resolved"}`. If main moved since the preview, the rebase is refused and you preview again. A merge takes no body and answers `{"version", "merged_fields"}`, the settings the branch changed. ## Refusals Answers use the compatibility envelope `{"data": ..., "errors": null}`; a refusal these routes make is `{"data": null, "errors": [{"error", "message"}]}`. A missing credential, scope or role, and a request body that is not a JSON object, get the standard error envelope ([Errors and limits](/errors-and-limits)). | Status | `error` | When | | - | - | - | | `400` | `INVALID_NAME` | The name breaks the rule above | | `400` | `INVALID_RESOLUTIONS`, `UNRESOLVED_CONFLICTS` | A resolution is malformed or its value is refused, or a conflict has none | | `400` | `NO_CHANGES` | Merging a branch with no changes of its own | | `404` | `NOT_FOUND` | No such agent, or no such open branch of it in this workspace. Deleting a branch that is not there answers `BRANCH_NOT_FOUND` | | `409` | `BRANCH_EXISTS` | An open branch already has this name | | `409` | `REBASE_STALE` | Main changed since the preview, or `dev_head_version_id` is missing or does not match main's head | | `409` | `BRANCH_BEHIND` | Main moved past the branch's base; preview, rebase, then merge again. Nothing is written | | `503` | `UNAVAILABLE` | The deployment has no database to keep branches in | Editing a branch's own draft is done in the Sempera App today; `PATCH /v1/agents/{id}/draft` edits main. # Migration and changelog Source: https://docs.sempera.org/changelog The one-API cutover, retired hostnames, deprecation policy. ## Deprecation policy Breaking changes ship as a new major path (`/v2`) with a migration guide, a deprecation notice on the old path and a sunset date announced here at least 90 days ahead. Additive changes (new fields, new routes, new event types) ship on `/v1` without notice and are safe to ignore. **Unreleased** API front door * `GET https://api.sempera.org/` and `GET https://api.sempera.org/v1` answer the same small JSON index instead of an error or a redirect to the company site: `name`, `version`, `status`, `documentation` (the address of this documentation site), `openapi`, `llms_txt`, `docs` and `health`. `status` and `documentation` are new fields; nothing was removed ([API overview](/api-overview)). **Unreleased** AI disclosure setting * Each agent has an AI disclosure setting (`ai_disclosure` in the draft, `compliance.yaml.ai_disclosure.enabled`), off by default for new and existing agents. On: the agent says it is a virtual assistant right after the owner's greeting, as before. Off: the owner's greeting is said word for word, with no AI sentence added. Changing it is a HIGH-risk change, decided by a named person on Publish and kept in the agent's change and version history. The recording notice is unchanged: it follows the greeting whenever calls are recorded and is never said when they are not. The truthful answer to "are you a person?" does not depend on the setting. **Unreleased** saved links and client guidance * Legacy App links retain their query parameters and fragment at the final workspace redirect, including repeated filters. Parameters and anchors explicitly set by a migration take precedence. Sign-in still uses the same-origin return address. A visitor without a workspace still goes to onboarding, which now carries the saved link and resumes it when onboarding is finished or skipped (or offers it beside a new agent built from a website). Only a same-origin path outside sign-in, sign-up and onboarding is resumed. * [Supported clients](/supported-clients) now distinguishes the source-available TypeScript and Python SDKs from package publication. The MCP guide makes the complete bearer header value explicit. No API route, credential boundary or database schema changed. * `POST /v1/calls` accepts `mode: "prompt"`: the conversation is only `task` (or `settings.prompt`), with `first_sentence`, `voice` and `language` for this call. `agent_id` remains the line for number, consent, do-not-contact, calling hours, caps and billing. A prompt is required. `agent_version`, `production` and `environment` are refused. `prompt_only` is not a field and skips no check. [Sessions and calls](/sessions-and-calls) and [TypeScript and Python SDKs](/sdks). **2026-11-02** recovering a stale scenario save * `POST /v2/agents/{agent_id}/scenarios/{id}` keeps its existing revision check and `409 CONFLICT` envelope, adding `reason: stale_scenario` and the current draft `revision` only for a stale save, including a race at the atomic draft write. Other conflicts and missing or foreign pathways disclose no revision. The App asks Keep my changes or Discard changes before recovery. **2026-11-02** Sempera voice and engine identities * `GET /v1/voices` accepts Sempera engine aliases in `engine` or `provider`, rejects invalid aliases with `reason: invalid_voice_id`, and answers catalogue refusals in Sempera's words. * Spec exports and frozen environment settings seal both speech legs' engine and model identities, including retained staging snapshots created before this change. Stored agents, voices and version hashes are unchanged. No data migration is required. * Conversation details, call-log summaries, live-call engines and voice handovers use Sempera identities for locally served speech too. Advanced views are not an exception. Operators retain the original runtime record. **2026-11-01** credits ### Added * `GET /v1/credits` and `GET /v1/credits/history`: a workspace's credit balance, what each action costs, and every movement of it ([Billing boundaries](/billing-boundaries)). * `left_unscored` on eval runs and quick runs: what a run's credits did not reach. * `402 credits_exhausted`: building actions (`POST /v1/tests`, `POST /v1/evals/run`, `POST /v1/evals/quick-run`, simulation and Standards runs, knowledge uploads and re-reads, and a call with `test`) now spend credits, and refuse with `needed`, `balance`, `debt` and, where buying can work, `buy_path` when the balance cannot cover them. `POST /v1/calls` without `test` and `POST /v1/sessions` are unchanged and spend none. **2026-11-01** sign-in for AI assistants ### Added * OAuth 2.1 sign-in on `POST /v1/mcp`: discovery documents, client registration, authorization code with PKCE, exact redirects, audience binding, rotating refresh tokens and an owner consent page in the App. * `phone:read`, `phone:call` and `phone:text` scopes. A credential holding only these can connect to `POST /v1/mcp`. * `GET /v1/client-grants`, `POST /v1/client-grants/rest` and `DELETE /v1/client-grants/{grant_id}`: one list of connected clients, a scoped REST credential for clients that cannot do OAuth, and revocation of one client at a time. ### Changed * A `401` on `POST /v1/mcp` now carries `WWW-Authenticate` with the protected resource metadata address. Its body is unchanged, and bearer keys work as before. **2026-11-01** official SDKs ### Added * TypeScript (`@sempera/sdk`) and Python (`sempera`) client libraries, generated from `openapi.json` with one method per operation, typed errors, retries that honor `Retry-After`, automatic idempotency keys, cursor paging and webhook signature verification ([TypeScript and Python SDKs](/sdks)). They are not published to npm or PyPI yet. No route changed. **2026-10-31** metric alerts ### Added * `PATCH /v1/alerts/{id}` takes an optional `expected_updated_at`, the `updated_at` the caller read, including when enabling or disabling a rule. The check and save are one step; if another owner saved first, the answer is `409 conflict` with `reason: stale_alert` and the `updated_at` now on file, and nothing is written, audited or cleared. A value that is not a time with a zone is `400 validation_failed` on `expected_updated_at`. A rule of another workspace, or one deleted meanwhile, is `404 not_found` and names no `updated_at`. Without the field a save behaves as before ([Metric alerts](/alerts)). **2026-10-29** docs and contract alignment ### Added * `openapi.json` now describes what the docs already said: the `runtime_identity` block on a session, an `EvalRun` schema for `GET /v1/evals/runs` and `GET /v1/evals/runs/{run_id}`, the `eval_id` and `call_ids` fields of `POST /v1/evals/run`, and the seven tags (MCP, Models, Prompts, Scenarios, Widgets, Citation schemas, Developer settings) that routes used without a description. * [Errors and limits](/errors-and-limits) lists the stable `reason` values a refusal can carry, including `invalid_voice_id` (a value that looks like a Sempera voice id and is not one), and [Sessions and calls](/sessions-and-calls) documents the fields of `runtime_identity`. * [Owner features in the App](/app-features) says which staging and analytics features are App-only (served under `/api`, not `/v1`). **2026-10-29** what ran a session ### Added * Evals: `GET /v1/evals/runs` and `GET /v1/evals/runs/{run_id}` read the evaluation runs `POST /v1/evals/run` now records, and that call's answer carries `run_id` ([Tests and evals](/tests-and-evals)). * `GET /v1/sessions/{session_id}` returns `runtime_identity` for a session recorded from now on: the engine and model that heard, thought and spoke (named by Sempera's opaque engine names), whether any leg fell back, whether Sempera's own servers or a hosted call service ran it, the reply and voice timings, and whether the session was a test or a customer's conversation. `verified` is false when the stored block no longer matches its own integrity checksum. Older sessions return `null`. **2026-10-28** voices ### Changed * Every voice a customer reads is named by a Sempera voice id (`voice_` then lowercase letters and digits) and neutral fields, never by the speech engine or the engine's own id for the voice: `GET /v1/voices` (each row is `{id, voice_id, name, display_name, description, language, source, production_eligible, preview_available, available}`, plus `visibility` for a custom voice), the Agent's `voice`, a draft's `voice`, a session's `voice.voice_id`, a persona's and a call's voice, and the agent export's `agent.yaml`. The `provider` and `engine` fields of `GET /v1/voices` are removed. A voice of a published Sempera model keeps the name its model lists. * The ids are stable and safe to store, and one voice has one id whichever transport of an engine it is spoken through. A raw voice id from before this change is still accepted wherever a voice is written (a draft, a session, a favorite, an agent file) and keeps meaning the same voice; reads return only Sempera voice ids. * An id that looks like a Sempera voice id but is not one is refused the same way everywhere: `400` with `reason: invalid_voice_id` (`code: invalid_voice_id` on the App's own routes), on a draft, a session, a voice's detail and preview, and a favorite. * A voice refusal is in Sempera's words ("That voice is not in the voice list.") and never repeats the engine or the engine's id for the voice. * `POST /v1/sessions`: `voice` and `speech_recognition` no longer carry `provider`, `fallback_provider` or `model`, except a leg on a published Sempera model, which is still named by its Sempera model id. `voice.voice_id` is the Sempera voice id. * `GET` and `PATCH /v1/agents/{id}/draft`: the draft's `fields` name a speech engine (`runtime_tts_provider`, `runtime_tts_fallback`, `runtime_stt_provider` and a speech selection's `provider`) by a sealed Sempera engine name (`engine_` then letters and digits), and a voice by its Sempera voice id. A `PATCH` takes the sealed name or the engine id. The draft summary previews a speech engine by its plain label ("Studio voice", "Standard recognition"). * The runtime reading names a speech engine and its model by a sealed Sempera name too: the `runtime` block's hearing and speaking legs (`options[].value`, `identifier` and `model`, the `configured` and `effective` values, the speaking pace's engine) in the `result` of a draft `PATCH` and on `GET /v1/agents/{id}`. A `PATCH` takes the sealed name back, and the engine id still writes. An option's `reason_detail` is the same owner sentence as its `reason`. * A refused voice reads by the refusal's code, never its internal sentence: `voice_unknown`, `engine_unavailable`, `engine_not_allowed`, `catalog_unavailable`, `preview_unavailable`, and for a custom voice's consent `consent_no_workspace`, `consent_store_unavailable` and `consent_missing` (as `reason` beside `code: voice_consent_required`). * Agent files and GitHub sync: `agents//agent.yaml` names each voice by its Sempera voice id (`voice: {id, name}`) and each speech engine by its sealed name, so nothing the agent's repository holds names the engine. Importing reads both back to the same runtime values, an older file with raw ids still imports, and the content hash Sempera reports for a file tree is the same in either form. The first sync after this change commits the rewritten `agent.yaml` once. **2026-10-23** webhooks ### Added * `PATCH /v1/webhooks/{id}` takes an optional `expected_updated_at`, the `updated_at` the caller read. The endpoint is saved only if it still has that `updated_at`, in the same step as the check, whatever the edit changes (the address, the subscriptions, a pause or a new signing secret); if another owner saved first, the answer is `409 conflict` with `reason: stale_webhook` and the `updated_at` now on file, and nothing is written. A value that is not a time with a zone is `400 validation_failed` on `expected_updated_at`. An endpoint of another workspace, or one deleted meanwhile, is `404 not_found` and names no `updated_at`. Without the field a save behaves as before ([Webhooks](/webhooks)). **2026-10-23** widgets ### Added * Saving a website widget in the App takes an optional `expected_updated_at`, the `updated_at` the owner read. The widget is saved only if it still has that `updated_at`, in the same step as the check; if another owner saved first, the save is refused with `409` and `code: stale_widget`, the answer carries the widget now on file, and nothing is written or audited. A value that is not a time with a zone is `400` on `expected_updated_at`. Without the field a save behaves as before. No `/v1` route changes: `GET /v1/widgets` still leaves `updated_at` null ([Website widgets](/widgets)). **2026-10-23** triggers ### Added * `PATCH /v1/automation/triggers/{id}` takes an optional `expected_updated_at`, the `updated_at` the caller read. The trigger is saved only if it still has that `updated_at`, in the same step as the check; if another owner saved first, the answer is `409 conflict` with `reason: stale_trigger` and the `updated_at` now on file, and nothing is written or audited. A value that is not a time with a zone is `400 validation_failed` on `expected_updated_at`. Without the field a save behaves as before ([Automations](/automations)). **2026-10-09** documentation ### Added * Three pages: [Security and privacy](/security-and-privacy), [Billing boundaries](/billing-boundaries) and [Supported clients](/supported-clients). The quick start gains what to have before you start and what the API does not do yet. [API overview](/api-overview) lists the routes that need no credential and explains the difference between supported and compatibility routes. [Agents](/agents), [Workspaces and organizations](/workspaces) and [Tools and integrations](/tools-and-integrations) now name the five supported routes they had left out: `GET /v1/agents/{id}/versions/{version_id}`, `POST /v1/agents/{id}/unarchive`, `GET /v1/organizations/{organization_id}`, `GET /v1/integrations/{integration_id}` and `GET /v1/integrations/{integration_id}/actions`. No route changed. ### Changed * Examples use the demonstration agent `northwind-barbers` in place of a customer's agent id, and invented ids, digests and timestamps in place of any value from a customer's record. No route or field changed. * The quick start and [Authentication and credentials](/authentication) no longer say the App has no key screen: a workspace owner creates API keys in the App under Settings, API keys, and a workspace's first key is created there. No route or field changed. * [Webhooks](/webhooks) describes provider ingress by category and no longer lists its routes one by one: they are for Sempera's service providers, not for integrators. [Web SDK](/web-sdk) and [Sessions and calls](/sessions-and-calls) describe the realtime media client by category. No route or field changed. * [Billing boundaries](/billing-boundaries) says exactly what the two `/v1/compat` billing reads may write (the workspace's default billing record on a first read) and nothing more. [API overview](/api-overview)'s table of routes that need no credential lists all eight. **2026-09-28** shipped after that record Recorded from the canonical branch's history. Dates are the commits'. Owner features in the App with no API route are in [Owner features in the App](/app-features) and [Text messages](/sms). ### Changed * `POST /v1/calls` for a number on the workspace's do-not-contact list still answers `501 capability_unavailable`, now with `reason: do_not_contact` and the message "No call was placed: the number is on this workspace's do-not-contact list." The refusal is written to the workspace's audit log with the number's last four digits only, and the one-call batch is still recorded with that person marked as blocked. It used to answer "outbound call was refused" with no `reason` and write no audit row (2026-09-30, [Sessions and calls](/sessions-and-calls), [Errors and limits](/errors-and-limits)). * `channels.phone` on the Agent (`GET /v1/agents`, `GET /v1/agents/{id}`, the Agent returned by create, archive and unarchive, and the MCP `list_agents` and `get_agent` tools) is `true` once a phone number is set up for the agent: a number bound to it, or a line released through its deployments, the same reading as the App's launch checklist. It was always `false` (2026-09-29, [Agents](/agents)). * `DELETE /v1/integrations/{id}` (archive, or restore with `?restore=1`) takes an optional `revision` query parameter, the revision the caller read; one that is no longer on file is refused with `409 conflict`, `reason: stale_connection` and the revision on file, and nothing is archived or restored. Without it the call behaves as before (2026-09-29, [Tools and integrations](/tools-and-integrations)). * An agent's `greeting` (its opening line) no longer replaces the whole opening. The agent always follows the owner's words by saying it is a virtual assistant and, when calls are recorded, with the recording notice, in the language of the call; an opening line that already asks the caller a question is not followed by a second one. The compiled opening the compatibility reads return as `first_sentence` carries both. It used to be the owner's words alone (2026-09-29). * `identity_style` sets only how the agent introduces itself: `owned` speaks as part of the front desk, by name, and `virtual-assistant` leads with the title. Every agent says it is an AI virtual assistant at the start of every call, and says plainly that it is an AI when a caller asks whether it is a person, whatever this field holds; `owned` used to make it answer without saying so. The values are unchanged and an agent that holds `owned` keeps it (2026-09-29). * `POST /v1/agents/{id}/preview`: the first reply of a typed session starts with the agent's opening (the AI disclosure and, when calls are recorded, the recording notice) in the language the caller wrote in, as a call's first words do. It used to be a greeting the model composed (2026-09-29, [Agents](/agents)). * `PATCH /v1/integrations/{id}` refuses a save built on an older revision with `409 conflict`, `reason: stale_connection` and the revision on file. It answered `400 validation_failed` with a sentence, against the rule in [Errors and limits](/errors-and-limits) that a stale revision is a `409` (2026-09-29, [Tools and integrations](/tools-and-integrations)). * `DELETE /v1/tools/{id}` (archive, or restore with `?restore=1`) takes an optional `revision` query parameter, the revision the caller read; one that is no longer on file is refused with `409 conflict`, `reason: stale_tool` and the revision on file, and nothing is archived or restored. Without it the call behaves as before (2026-09-29, [Tools and integrations](/tools-and-integrations)). * `PATCH /v1/tools/{id}` refuses a save built on an older revision with `409 conflict`, `reason: stale_tool` and the revision on file. It answered `400 validation_failed` with a sentence, against the rule in [Errors and limits](/errors-and-limits) that a stale revision is a `409` (2026-09-29, [Tools and integrations](/tools-and-integrations)). * `/v1/knowledge-sources` fills the fields it documents: `content_type`, `added_at` and `attached` were always `null`, `collections` was `null` on an upload and on a source in no collection, and the list's `pdf_supported` was always `null`. An upload now answers what a read of the source answers, `raw` included: an upload's `raw` is the library row a read answers, where it was the store's own record (it gains the fields a read carries, such as `revision` and, for text documents, `text`, and loses `mime`, `extracted` and `removed_at`); if that read fails after the file is saved, the upload answers `201` from the saved record, with `collections` and `attached` `null`. `POST .../reparse` answers the state the re-read left, where it always said `reparsing`, and `DELETE` and `POST .../reparse` answer an id the agent's library does not hold `404 not_found`, as the read does, where they answered `409 conflict`; a source already removed, or a library that cannot be read, still answers `409 conflict` with the reason (2026-09-30, [Knowledge](/knowledge)). * `PATCH /v1/knowledge/{id}/content` takes an optional `expect_version_id`, the version the content read returned; a save built on an older version is refused with `409 conflict`, `reason: stale_content` and the current version, and nothing is written. Without the field a save behaves as before (2026-09-29). * `GET /v1/recordings/{recording_id}` answers a deleted recording `410 gone` (`reason: deleted`), a recording whose access ended under the workspace's recording security `410 gone` (`reason: access_ended`), and a caller outside a members-only workspace `403 forbidden` (`reason: members_only`), signing nothing; every link it issues is recorded in the workspace's audit log. The error vocabulary gains `gone` (410), which an App-road refusal forwarded through the API used to report as `internal_error` (2026-09-29, [Call recordings](/recordings), [Errors and limits](/errors-and-limits)). * `GET /v1/agents/{id}` returns the agent's own `voice` (`id`, `name`) and `language` (`code`, `label`); both were always `null`. One go-live check in its `readiness` block is now named `artifact:phone-build`, as the App's Deploy tab names it (2026-09-29, [Agents](/agents)). * `GET /v1/calls/{call_id}` and `GET /v1/sessions/{call_id}` (and the compatibility call-log reads) return the customer view of a call: each model leg is named by its Sempera model id, and the account id, credential source and upstream model the record held are no longer returned (2026-09-29). * A model leg that Sempera's own cloud model pool served, which has no published Sempera model id, is named `sempera-cloud-model` on the host `cloud` in those call and session reads and the compatibility call-log reads. They used to return the engine's own name, its model and its host (2026-09-29). * The `result` of `PATCH` and `DELETE /v1/agents/{id}/draft` names a cloud model pool engine in its `runtime` block as `sempera-cloud-model`, and lists every such engine as one option that cannot be chosen for a single agent; a draft that sets `runtime_llm_backend` to `sempera-cloud-model` is refused with a sentence saying Sempera chooses the cloud model for the whole organization. Engines on Sempera's own servers keep their names (2026-09-29). * `GET /v1/live-calls/stream` names each call's language model in `engines.llm` by its Sempera model id, or `null` (2026-09-29, [Live calls and live transcripts](/live-call-streams)). * A speech recognition or voice engine that a cloud provider hosts for Sempera is named `sempera-cloud-speech-recognition` or `sempera-cloud-voice` in `GET /v1/sessions/{call_id}`, `GET /v1/calls/{call_id}` and the compatibility call-log reads, and in `engines.stt` and `engines.tts.provider` on `GET /v1/live-calls/stream`. They used to return the engine's own name and model. Engines on Sempera's own servers keep their names (2026-09-29, [Live calls and live transcripts](/live-call-streams)). * The `result` of `PATCH` and `DELETE /v1/agents/{id}/draft` reads such an engine the same way in its hearing and speaking legs: the running engine (`effective`), its model and the speaking pace's `provider` use the neutral name, and each such option's `reason_detail` is the same sentence as its `reason`. An option's `value` and the draft's own values are unchanged, so a draft still names the engine by the value it read (2026-09-29). * Two refusal sentences name no engine a cloud provider hosts: a session, or a publish, that picks one where it is not cleared for real calls is refused with "The cloud voice engine ..." or "The cloud speech recognition engine ..." (the `409` of `POST /v1/sessions` still carries the engine's id in `engine`), and a recognizer that cannot choose the language per call is refused with "Auto-detect is not available with the speech recognition you chose." (2026-09-29). * Why a call's voice handed over to its backup voice is one fixed sentence in `GET /v1/sessions/{call_id}` and `GET /v1/calls/{call_id}`: each handover's `reason` in `runtime_actual.tts.fallbacks` and in `sibling.tts_fallbacks` reads "The voice engine stopped answering, so the backup voice took over." It used to be the error's own text, which could name the address the engine was reached on; the engines and voices of the handover are named as before (2026-09-29). * The draft's summary, which previews each changed value now (`before`) and in the draft (`after`), names a voice or speech recognition engine a cloud provider hosts as `sempera-cloud-voice` or `sempera-cloud-speech-recognition`, and a cloud model pool engine as `sempera-cloud-model`, with their models, in the `result` of `PATCH` and `DELETE /v1/agents/{id}/draft`, the `summary` of `GET /v1/agents/{id}/draft` and the `draft` of `GET /v1/agents/{id}`. It used to preview the engine's own name and model. The draft's own `fields` are unchanged, and engines on Sempera's own servers keep their names (2026-09-29). * `POST /v1/sessions` names the model of such an engine in `voice.model` and `speech_recognition.model` as `sempera-cloud-voice` or `sempera-cloud-speech-recognition`. It used to return the engine's own model. `voice.provider` and `speech_recognition.provider` are still the engine ids the session was asked for (2026-09-29, [Sessions and calls](/sessions-and-calls)). * `GET /v1/personas` and `GET /v1/personas/{persona_id}` report the agent's own language, time zone and voice in `call_config`, its role as `description`, and real dates: when its first and newest versions were built, and the number and build time of the version it runs now, each `null` when the agent has none. `orchestration_prompt` and `promoted_at` are `null`. They used to be fixed values (`en`, `America/Toronto`, an empty voice and description, version `1` and one placeholder date) whatever the agent was configured with (2026-09-29, [Personas](/personas)). * `GET /v1/recordings/{recording_id}` looks the recording up before it signs a link: an id that does not exist and another workspace's recording both answer `404 not_found`, and neither is signed. It used to sign a link for any id it was sent. A link a signed-in member asks for now plays for that member; it was bound to a form of their identity the audio path never matched, so it played for nobody. A link asked for with an API key still plays for nobody (2026-09-29, [Call recordings](/recordings)). * A trigger's webhook action survives a restart of the service between its attempts: the run keeps what its next attempt needs, and the next attempt is sent once the service is back, with the same event id and body, to the trigger's endpoint while it is still enabled, three attempts in all. An attempt a restart cut off counts, marked as not known to have arrived. Such a run used to stay running until it showed as stalled and the owner sent it again (2026-09-29, [Webhooks](/webhooks)). * `call.completed` is also sent for an interpreted call (Live translation), once per call, with `provider` `sempera` and `recording_stored` false, and the workspace's Call completed triggers fire for it with `direction` `interpreted`; `GET /v1/sessions/{call_id}` reads it. A session whose interpreter never started sends nothing. Interpreted calls used to send no `call.completed` and fire no trigger (2026-09-29, [Webhooks](/webhooks)). * `call.completed` is also sent for a phone call Sempera's own call service answered, with `provider` `sempera`, once per call whichever road sees it first, and never for a test session; `GET /v1/calls/{call_id}` reads such a call by the id the event carries. It used to be sent only for calls the carrier reported (2026-09-29, [Webhooks](/webhooks)). * A workspace's Call completed triggers (`/v1/automation`) also fire for a phone call Sempera's own call service answered, once per call under the same claim as `call.completed`, so a call both roads see fires them once; tests never fire them. They used to fire only for calls the carrier reported (2026-09-29, [Webhooks](/webhooks)). * An inbound text on an agent whose texting is off (Send text messages in its phone setup, which starts off) is stored and shown on its thread and not answered; the thread records why. It used to be answered on any bound number (2026-09-29, [Text messages](/sms)). * With texting off in an agent's phone setup (Send text messages, which starts off), the in-call text tool is refused and sends nothing, and the Send SMS voicemail action sends no text and records why. Both used to send whatever the setting said (2026-09-29, [Text messages](/sms)). * Why an agent did not answer a text is a fixed sentence for the kind of failure (texting off, the model unavailable, the text not sent, and so on), on the thread, in the conversation drawer and on Needs attention. It used to include the engine's or the carrier's own error text, which could name the service it called (2026-09-29, [Text messages](/sms)). * `agent.published` carries the same `agent_version` object (`id`, `object`, `agent_id`, `version_number`, `created_at`, `spec_hash`, `compiled_artifact_id`) whichever road published. A publish from the App used to send the version ledger's own row, with different key names and internal snapshot hashes ([Webhooks](/webhooks)). * A webhook endpoint whose stored signing secret cannot be read is never sent an unsigned request: the attempt fails in the delivery log with a sentence saying so. One such endpoint also no longer stops the workspace's other endpoints from hearing the same event. * Every webhook delivery goes over one guarded road (2026-09-28): the host is resolved and the connection pinned to the address that was checked, an address inside a private network is refused, and a redirect is a failed attempt. A saved endpoint URL with a private address literal, a user name or password, or a bad port is refused with `validation_failed`. * A trigger's webhook action names one of the workspace's registered webhook endpoints and is signed with that endpoint's own secret; a new `tool` action calls one of the workspace's API request tools. `POST /v1/automation/executions/{id}/retry` sends a failed or stalled webhook or tool run again once, with the same event id (2026-09-28). * A call's `call.completed` and `call.outcomes` deliveries lose the words read from a call once its transcript is deleted: the stored bodies are emptied, and a later retry of a `call.outcomes` delivery sends the emptied event (2026-09-26). * A version is named the same way everywhere (2026-09-28): the production pin on `POST /v1/calls` resolves to the version the agent's current build resolved to, not simply the newest ledger row, and a voice or phone call's record names the version it started on, not the one current when it ended. * Editing a tool or a connection checks its name only inside its own workspace. Another workspace's tool of the same name no longer refuses the edit or reveals that the name exists elsewhere (2026-09-28). * Evaluation criteria are read and scored from the caller's own workspace only (2026-09-27). * `openapi.json` names no provider: an operation id that carried a platform name is now `listCitationSchemasListPath`, and a compatibility path whose name carries that platform's name is still served but no longer listed. * `openapi.json` declares the `branch_id` path parameter (a UUID) on the four `/v2/agents/{uuid}/branches/{branch_id}` routes (rebase preview, rebase, merge and delete), which listed only the agent (2026-09-29). The routes themselves did not change. ### Added * In the App, a text conversation shows every message on its thread in order, who sent each and when, and a texter left without an answer is an urgent item on Needs attention that counts toward the attention alert (2026-09-29, [Text messages](/sms)). * Outbound call limits (2026-09-28): an owner can cap the calls a workspace places per UTC hour and per UTC day. `POST /v1/calls` over a cap answers `429 rate_limited` with `retry_after_s`, and `503 service_unavailable` when a cap cannot be read; nobody is called ([Errors and limits](/errors-and-limits), [Owner features in the App](/app-features)). * A real phone call answered by Sempera's own call service over the carrier's audio stream (2026-09-27): two provider-ingress routes carry it, public only on a deployment that turned live call audio on ([Webhooks](/webhooks)). * Owner features in the App, described in [Owner features in the App](/app-features): filtered call-data exports built in the background and downloaded by owners; outbound batches with a start time, CSV recipients and a background scheduler; per-tool analytics over 7, 30 or 90 days; an Import from cURL panel on the tool form; writing, testing, publishing and pinning code snippets from the Tools page. * Text messages (2026-09-28): the agent's published version answers inbound texts on a thread it holds, in place of a fixed reply ([Text messages](/sms)). * Developer guide pages for snippets, triage, dispositions, alerts, widgets, text messages and branches, verified against the route table, and `app-features.md`. The guide said `POST /v1/calls` was not offered and answered `501`; it records a one-call outbound batch and answers `201`, and [Sessions and calls](/sessions-and-calls) now says so. `webhooks.md` is rewritten as the stable outbound contract: the events, each payload, signing with a test vector, retries, idempotency, ordering and the delivery log. * Developer guide pages for automations, scenarios, secrets and request signing, live calls and live transcripts, recordings, personas, citation schemas and the caller memory schema (2026-09-29), each checked against the route table and its handlers. They record behaviour as it is, including that the live streams answer one snapshot frame and close, that `GET /v1/recordings/{recording_id}` signs a link an API key cannot fetch, and which persona fields are fixed values. **2026-09-28** changes since the cutover Recorded from the route table and its history between 2026-09-15 and 2026-09-27. `openapi.json` is the full contract. ### Changed * `GET /v1/tools`, `GET /v1/integrations` and `GET /v1/voices` (2026-09-21) answer compatibility envelopes instead of the standard list envelope: `{"status", "tools", "hasMore", "nextCursor", "data", "errors"}`, `{"data": {"count", "integrations"}, "errors"}` and `{"voices": [...]}`. The `kinds` and `engines` keys those lists carried are gone. This changed `/v1` without the notice the policy above promises; this entry is the record of it. * `session.completed` is retired. It was accepted as a subscription but never sent; asking for it is now `validation_failed`, and endpoints that stored it no longer list it ([Webhooks](/webhooks)). * `test.completed` is now sent when a test run finishes or crashes (2026-09-26). ### Added * Agent branches under `/v2/agents/{agent_id}/branches` ([Agent branches](/branches)). * Dispositions and their extractors under `/v2/agents/{agent_id}/dispositions`; published dispositions run on completed phone calls and their results ride on `call.outcomes` as `dispositions` ([Dispositions](/dispositions)). * Triage issues under `/v1/triage`: issues, flagged calls, files, comments, activity and links between issues ([Triage issues](/triage)). * Metric alert rules under `/v1/alerts`, and the `metric_alert.triggered` event, delivered only to the endpoints a rule names ([Metric alerts](/alerts)). * Code snippets under `/v1/snippets`, and the `code` tool kind that pins a published snippet version ([Code snippets](/snippets)). * Website widgets: `GET /v1/widgets`, `GET /v1/widget/threads` and `/v1/widget/custom-components` ([Website widgets](/widgets)). * Text messages: `GET /v1/sms/numbers` and `GET /v1/sms/conversations` ([Text messages](/sms)). * Automation triggers and their executions under `/v1/automation`. * Evals: `GET /v1/evals/agents`, `/v1/evals/agent-templates`, `/v1/evals/test-cases`, `/v1/evals/user-templates` (and `POST`), and `POST /v1/evals/quick-run` ([Tests and evals](/tests-and-evals)). * Speech models and saved prompts: `GET /v1/models`, `GET /v2/tts/models`, the credential-free `GET /v2/models`, and `/v1/prompts` ([Sessions and calls](/sessions-and-calls)). * Workspace secrets (`/v1/secrets`), JWT request signing (`/v1/signing`, `/v1/signing/keys` and `GET /v1/workspaces/{workspace_id}/.well-known/jwks.json`) and `GET /v1/release-deployments`. * Live calls and recordings: `GET /v1/live-calls/stream`, `GET /v1/live-calls/transcript/stream` and `GET /v1/recordings/{recording_id}`. * `GET /v1/personas`, `/v1/citation_schemas`, `GET /v1/agents/{agent_id}/memory/schema`, scenarios under `/v2/agents/{agent_id}/scenarios`, and environment variables under `/v2/agents/{agent_id}/environments/{env}/variables`. * `POST /v1/mcp` and `GET /v1/mcp/clients/{client}` for coding agents ([Getting started](/getting-started)). * Further compatibility facades, tagged `Compatibility` in `openapi.json`. **2026-09** the one-API cutover * Compatibility `/v2` routes for the console agent, disposition, deployment, batch and analytics reads were added behind the same credential and workspace boundary as `/v1`. They are facades over Sempera-owned stores, not a separate database. * `api.sempera.org` became the single canonical Sempera API, versioned under `/v1`, with `openapi.json`, `llms.txt` and these guide pages served from it. * The private server hostname (the retired legacy API hostname, retired 2026-09-14) was migrated behind the canonical API. No customer or developer integration ever targeted it. * The existing operational routes that the live salon agent's tools call (`/v1/caller/*`, `/v1/catalog/*`, `/v1/center/*`, `/v1/booking/*`, `/v1/messages`) remain in service unchanged as the runtime tool surface; they are internal to Sempera-built agents, are not part of the public contract, and will be folded into Tools when the salon agent is next re-certified. * Credential classes (developer, runtime, CI, service, admin keys; the browser session token) were introduced with explicit scopes. * Sempera-signed outbound webhooks were introduced (`X-Sempera-Signature`). # Citation schemas Source: https://docs.sempera.org/citation-schemas The outcome fields frozen with each published version and how well each is captured. A citation schema is the set of values an agent was published to capture from each call, frozen with that published version, together with how well each value has been captured. The values are the agent's **outcomes**: named facts such as "booked an appointment" or "service asked about", which Sempera reads from the transcript after each call together with the sentence that supports them (the `call.outcomes` webhook event carries them, [Webhooks](/webhooks)). Dispositions ([Dispositions](/dispositions)) are the per-call contract built on top of outcomes. Outcomes are defined on the agent's draft (the `outcomes` field of `PATCH /v1/agents/{id}/draft`, [Agents](/agents)) and published with it. The routes only read what already exists: they define nothing and write nothing. | Route | Scope | Notes | | - | - | - | | `GET /v1/citation_schemas` | `agents:read` | Schemas, newest published version first within each agent | | `GET /v1/citation_schemas/list` | `agents:read` | The same list at a second path | | `POST /v1/citation_schemas/backfill` | `agents:write` | `{"agent_id"}`. Answers one agent's schemas and quality scores in a single object | The list takes `agent_id` (also `client`) to narrow it to one agent, `limit` (1 to 200, default 50) and `cursor`, and answers `{"object": "list", "data": [...], "has_more", "next_cursor"}`. Without `agent_id` it covers every active agent the credential can read. A version that defined no outcomes has no schema and is not listed. An agent the credential cannot read is `404 not_found`. ## A schema ```json theme={null} {"id": "cit_northwind-barbers_4", "object": "citation_schema", "agent_id": "northwind-barbers", "version": 4, "source": "settings", "fields": [{"name": "booked", "type": "boolean", "description": "Booked an appointment"}], "quality": [{"version_number": 4, "key": "booked", "name": "Booked an appointment", "captured": 40, "found": 36, "corrected": 2, "accuracy": 95, "filled": 90}], "filled": 90, "created_at": "2026-09-28T09:15:42.386027Z"} ``` `version` is the agent's published version number and `created_at` is when that version was created. `source` is always `settings`. Each field's `name` is the outcome's key (the name a webhook receiver reads), `type` is `boolean`, `choice`, `text` or `number`, and `description` holds the outcome's display name, not its longer description. `quality` has one row per outcome captured under that version. `captured` counts the calls where the outcome could be read, `found` the ones where a value with supporting text was found, and `corrected` the ones the owner marked wrong in the App. `filled` is `found` as a whole percentage of `captured`. `accuracy` is the whole percentage of captured values not corrected, so a value nobody has corrected counts as right: read it beside `captured`, because 100 over two calls means two calls. The schema's own `filled` is the average of its rows' `filled` values, rounded, and `null` when nothing was captured under that version. ## Backfill `POST /v1/citation_schemas/backfill` needs `agents:write` and the `operator` or `owner` role, and answers `{"object": "citation_schema_backfill", "agent_id", "schemas", "quality"}`: the number of that agent's schemas and every quality row of them. Despite the name it changes nothing: it reads no new calls, scores nothing again and returns the figures the list route shows. An `agent_id` that is missing, unknown or not readable is `404 not_found`. # MCP: connect a coding agent or an assistant Source: https://docs.sempera.org/connect-mcp The MCP endpoint, the read-only tools and the scope each needs, protocol versions, answers, errors and paging, and what is not available over MCP. `POST /v1/mcp` is a JSON-RPC 2.0 [Model Context Protocol](https://modelcontextprotocol.io) endpoint. A coding agent or assistant can read its workspace and, with the matching permissions, create and edit agents, save versions, publish drafts, run offline tests and simulations, manage text knowledge and bind owned phone numbers. Every tool runs the matching API handler as the caller. The write registry cannot place a call, send an SMS, buy a number or delete a resource. The separately scoped phone tools require the approvals described below. Write access does not bypass roles, workspace boundaries, quotas or release checks. The original read registry contains 29 tools, with separate registries for write, draft-and-test and phone operations. ## Connect Use the same bearer key as every other route ([Authentication and credentials](/authentication)), or sign in with OAuth (below). `agents:read` is the door, and a phone scope opens it too for a credential that holds only those: anything else is refused at the endpoint (`403 insufficient_scope`). Each tool then names the one further scope it needs, and `tools/list` shows a key only the tools its scopes allow. A CI key is refused (`403 forbidden`), as it always was. The call below asks for the setup snippet of one client; the same answer is a plain read at `GET /v1/mcp/clients/{client}`. ```bash theme={null} curl -X POST https://api.sempera.org/v1/mcp \ -H "Authorization: Bearer sempera_sk_..." \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"mcp_client_setup","arguments":{"client":"claude-code"}}}' ``` The endpoint takes one JSON message per POST and answers one JSON body. It offers no event stream and no session: with a live credential, any method but `POST` (`GET`, `HEAD` and `DELETE` among them) answers `405` with `Allow: POST`, and it sends no `Mcp-Session-Id`. That is the plain JSON form of the Streamable HTTP transport, which every published MCP revision lets a server choose and requires a client to accept, so ChatGPT and Claude need no event stream to connect. No tool here sends progress, so none is offered. ## Sign in with OAuth A host that cannot hold a static key (ChatGPT, Claude) signs in with OAuth 2.1 instead. Give it the URL `https://api.sempera.org/v1/mcp`. Bearer keys keep working exactly as before. 1. The host posts without a token and gets `401` with `WWW-Authenticate: Bearer resource_metadata="https://api.sempera.org/.well-known/oauth-protected-resource/v1/mcp"`. 2. It reads that document, then `https://api.sempera.org/.well-known/oauth-authorization-server`. 3. It registers itself at `POST /v1/oauth/register` (RFC 7591) and sends the browser to `GET /v1/oauth/authorize` with PKCE (`S256`, required), the `resource` `https://api.sempera.org/v1/mcp`, and the scopes it wants. Redirects match exactly. A loopback address may differ by port alone, because a native client binds a free port each time. 4. The person signs in on `app.sempera.org` if they have not, and a workspace owner approves on the consent page: which client, which workspace, which scopes, and where the answer goes. A client Sempera cannot recognise by its published redirect is shown as unverified. The owner can approve fewer scopes than were asked for, never more. 5. The host swaps the code at `POST /v1/oauth/token`. An access token lasts one hour and a refresh token 30 days. Each refresh turns the refresh token over. Presenting a spent one ends every token from that sign-in, and so does presenting a spent code. | Scope | What it allows | | - | - | | `phone:read` | See the line, the allowance, and the results of calls and texts | | `phone:call` | Start and cancel approved calls | | `phone:text` | Send approved texts and read their threads | These three are all a client can ever hold. No scope buys a number, a plan or credits: a request naming any other scope is refused `invalid_scope`, and a client that asks for nothing gets `phone:read`. Scopes open only the tools that need them, so a phone-only grant does not see agent tools. A token works on `POST /v1/mcp` and nowhere else (any other route answers `401`), and only for the resource it was issued to. It resolves to the same principal a key does, so the same role, scope, workspace and audit checks apply. Only `SHA-256` hashes of tokens, codes and client secrets are stored. Client Metadata Documents are not offered. A host that prefers them falls back to registration, which ChatGPT, Claude and Codex all document. Tools do not yet ask for more scope mid-conversation: to change what a client holds, approve it again, which replaces its grant. Owners see every client in Settings, Connected clients (`GET /v1/client-grants`), with its scopes and last use, and end one with `DELETE /v1/client-grants/{grant_id}`. Its tokens stop at once and every other client keeps working. ## Protocol versions Two revisions are spoken. Which rules a request is held to depends on one thing: whether it carries the `MCP-Protocol-Version` header. | A request that... | What it gets | | - | - | | carries `MCP-Protocol-Version: 2025-06-18` or `MCP-Protocol-Version: 2024-11-05` | The Streamable HTTP rules for a server with no stream and no sessions. A notification, or a client's answer to a request, is accepted with `202` and no body. A body without `jsonrpc: "2.0"`, a request without an `id`, or a JSON array is refused with `400`. The two headers are served identically; what `initialize` answers depends only on the revision named in its own parameters. | | carries `MCP-Protocol-Version` naming any other revision, empty or not | `400 validation_failed`, with the revisions spoken listed in `supported`. Before the endpoint negotiated, the header was ignored. | | carries no `MCP-Protocol-Version` header | Served as this endpoint always served it: a notification gets `200` and an empty result, and a JSON array is a `400`. Only three of its answers change; they are listed under Which clients move. | `initialize` answers the revision the client asked for when it is `2025-06-18` or `2024-11-05`. For a newer date (`2025-11-25`, `2026-07-28` and later) it answers `2025-06-18`, the newest spoken; for `2025-03-26`, an older date, an unknown value or none it answers `2024-11-05`, which is all it answered before it negotiated. Resources, prompts, completions, logging, structured output, event streams and sessions are not offered; nothing here claims them. With either supported version header, an `id` must be a string or a finite integral number. Numeric spellings such as `1`, `1.0` and `1e0` are accepted and the parsed value is echoed without conversion. Null, booleans, objects, arrays, fractional numbers and non-finite values are refused before a tool is called, with HTTP `400`, JSON-RPC `-32600` and a null response id. Notifications still omit the id and receive `202` with no body; valid client replies receive the same empty acknowledgement. Requests without the version header retain their legacy behavior. ### Which clients move * **A client that sends the header.** The revisions from `2025-06-18` on require a client to send `MCP-Protocol-Version` on every request after `initialize`, so a client built on a current SDK sends it. Such a client now negotiates `2025-06-18` (it asks for a newer revision and is answered with the newest spoken), then gets `202` for its `notifications/initialized`, and may try to open a stream with a `GET` on the endpoint and be answered `405`, which that revision allows for a server that offers none. This is a change from the endpoint before this release, which answered such a client `2024-11-05` and `200`. A client that sends a header naming any other revision is refused with `400`. * **A client that sends no header on any request.** Three of the answers it already got change: `initialize` asking for `2025-06-18` or newer is answered `2025-06-18` (it was `2024-11-05`); `tools/list` with a `cursor` is a `-32602` error (it was ignored, and the list never paged); and each of the three original tools gains `title`, `annotations` and `_meta` in `tools/list`. Its `tools/list` also now carries the 26 added tools. Every other answer to it is unchanged, including the original tools' results and their `-32000` errors. * **A browser page on another site.** See the `Origin` rule below. The API answers a browser's preflight for this endpoint with `401` and no CORS headers, so such a page could not send it a JSON request with a key in the first place; the `Origin` rule is a second lock. ## Origin Every request that carries an `Origin` header is checked, with or without `MCP-Protocol-Version` (the only method served is `POST`; any other is refused `405`, or `401` with no live credential, before it gets that far). The `Origin` value must be this deployment's own host, a host the deployment is configured to allow, or an origin the App is served from (the origins the deployment lists for its web App); otherwise the answer is `403 forbidden`, and the request's scope, version header and body are never looked at. The App's own pages therefore reach this endpoint through the App's proxy even when that proxy runs on another host or port than the API, as it does in development and in the browser tests. The API's front door looks at the credential first: a request with no key, an unknown key or a revoked key is refused `401` whatever its `Origin`, as is a dead token, and that `401` says where to sign in, so the `403` is what a request with a live key and a foreign `Origin` gets. A request with no `Origin` header (a command-line tool, an SDK, a server-side client) is not a browser's and is not checked. `Origin: null`, an empty `Origin` and a value that is not a URL are foreign, and so is a request with several `Origin` headers when any one of them is. The deployment's own host means the host and port the request arrived with, so that half relies on the deployment's host policy (which answers `404` to a host it does not know) and does not compare the scheme; an App origin is compared whole, scheme, host and port. This rule is new with the release that added these tools: a client that sent an `Origin` of another site and was served before is refused now. ## Tools Every tool projects an existing route, run as the caller: the route's own scope, role, workspace and ownership checks apply. The table below lists the original read tools; the write workflow and its follow-up reads are below. An id from another workspace is refused. The `list_agents` tool keeps its original shape; a list that its route pages is paged here the same way. | Tool | Reads | Scope | Arguments | | - | - | - | - | | `list_agents` | `GET /v1/agents` | `agents:read` | none | | `get_agent` | `GET /v1/agents/{agent_id}` | `agents:read` | agent\_id (required) | | `mcp_client_setup` | `GET /v1/mcp/clients/{client}` | `agents:read` | client (required) | | `get_agent_draft` | `GET /v1/agents/{agent_id}/draft` | `agents:read` | agent\_id (required) | | `list_agent_versions` | `GET /v1/agents/{agent_id}/versions` | `agents:read` | agent\_id (required), limit, cursor | | `get_agent_version` | `GET /v1/agents/{agent_id}/versions/{version_id}` | `agents:read` | agent\_id (required), version\_id (required) | | `get_latest_agent_version` | `GET /v2/agents/{uuid}/versions/latest` | `deploy:read` | agent\_id (required) | | `list_agent_environments` | `GET /v2/agents/{uuid}/environments` | `deploy:read` | agent\_id (required) | | `list_agent_variables` | `GET /v2/agents/{uuid}/environments/{env}/variables` | `deploy:read` | agent\_id (required), environment (required) | | `list_agent_branches` | `GET /v2/agents/{uuid}/branches` | `agents:read` | agent\_id (required) | | `list_agent_deployments` | `GET /v2/agents/{uuid}/deployments` | `deploy:read` | agent\_id (required) | | `list_agent_check_runs` | `GET /v2/agents/{uuid}/check-runs` | `evals:read` | agent\_id (required), limit | | `list_calls` | `GET /v1/calls` | `calls:read` | agent\_id (required), limit, cursor | | `get_call_log` | `GET /v1/calls/{call_id}` | `calls:read` | call\_id (required), agent\_id | | `list_sessions` | `GET /v1/sessions` | `sessions:read` | agent\_id (required), channel, limit, cursor | | `get_session` | `GET /v1/sessions/{session_id}` | `sessions:read` | session\_id (required), agent\_id | | `list_knowledge_sources` | `GET /v1/knowledge-sources` | `knowledge:read` | agent\_id (required), limit, cursor | | `get_knowledge_source` | `GET /v1/knowledge-sources/{source_id}` | `knowledge:read` | source\_id (required), agent\_id (required) | | `list_tools` | `GET /v1/tools` | `tools:read` | status, limit, cursor | | `get_tool` | `GET /v1/tools/{tool_id}` | `tools:read` | tool\_id (required) | | `list_integrations` | `GET /v1/integrations` | `integrations:read` | none | | `list_tests` | `GET /v1/tests` | `tests:read` | agent\_id (required), limit, cursor | | `get_test_run` | `GET /v1/tests/{test_id}` | `tests:read` | test\_id (required) | | `list_eval_agents` | `GET /v1/evals/agents` | `evals:read` | limit, cursor | | `get_eval_agent` | `GET /v1/evals/{eval_id}` | `evals:read` | eval\_id (required) | | `get_eval_results` | `GET /v1/evals/results` | `evals:read` | agent\_id (required) | | `list_voices` | `GET /v1/voices` | `agents:read` | none | | `get_docs_page` | `GET /docs/{page}` | none | page (required) | | `get_docs_index` | `GET /llms.txt` | none | none | Each tool in `tools/list` carries MCP's own hints (`readOnlyHint` true, `destructiveHint` false, `idempotentHint` true, `openWorldHint` false) and Sempera's explicit metadata under `_meta`: `org.sempera/effect` (`read`), `org.sempera/side-effects` (`false`), `org.sempera/scope`, `org.sempera/route`, `org.sempera/operation-id` and `org.sempera/paginated`. A host should treat hints as claims by the server, which is why the server also enforces them: a tool can only project a `GET` route. `openWorldHint` is false because no tool sends a value you gave it to a third party; a read may still do what its route does inside Sempera, such as the cached health check of Sempera's own speech engines behind `list_voices`. ## Arguments, answers and errors * An agent is named `agent_id` in every tool, whichever path variable the route calls it. Ids are letters, digits, `.`, `_`, `:` and `-`, up to 200 characters. An unknown argument, a missing required one, a wrong type or an out-of-range number is refused before anything is read, with the argument named. * An answer is the route's JSON as text in one `text` content item (narrowed as a later section says for nine tools), or, for the two guide tools, its Markdown. A failure is a tool result with `isError: true` whose text is the route's own error envelope, for example `{"error":{"code":"not_found","message":"no such agent","request_id":"req_..."}}`. The codes are those in [Errors and limits](/errors-and-limits), and `request_id` is the one the call returned in `X-Request-Id`. An answer about your workspace never carries an internal address, the name of a setting, a file path, a stack trace or the name of an engine or supplier behind Sempera; the tests scan every tool's answer for them. * Arguments that are not a JSON object, an unknown method and an unknown cursor on `tools/list` are protocol errors (`-32602`, `-32601`). The three original tools keep reporting a refusal as JSON-RPC error `-32000`, as they always did; every newer tool reports it as a tool result. * Lists page with `limit` (1 to 200, default 50; `list_agent_check_runs` takes 1 to 100) and `cursor`. Pass `next_cursor` from the last page back unchanged to read the next one (`nextCursor` on `list_tools`, `data.next_cursor` on `list_eval_agents`). A cursor is an opaque position: the same cursor gives the same page while the rows are unchanged, and one issued to another key reads only that key's own rows. `list_integrations`, `list_voices` and the environment, branch and deployment reads answer in one piece. * An answer larger than 1 MiB is refused with `payload_too_large`; ask for a smaller page or a more specific id. A read that runs longer than 30 seconds is refused with `service_unavailable`. A call counts once against the key's request budget ([Errors and limits](/errors-and-limits)), like any other request. * A secret environment variable always reads `null` in `list_agent_variables`; its value never leaves the server. ### What nine tools leave out Nine tools answer a short, written-out list of fields rather than the route's whole answer. A field the route gains later is not passed on until this page and the tool both name it. The REST routes are unchanged. * **Calls and conversation records** (`list_calls`, `get_call_log`, `list_sessions`, `get_session`) return metadata only: the id, the agent, the channel, the status, the start and end times, the duration, the language, the outcome as a code and a label, the version and the number of turns. There is no caller number (not even a masked one), no recording, no transcript, no summary and no title (a phone call's title is its summary), no extracted values and no provider log. The outcome, the language and the version come from the voice platform's record of the call, so each is shown only when it has the shape of a short code (letters, digits and `.` `_` `:` `-`, no spaces); anything else reads `null`, and an outcome's label is shown only when it is one of Sempera's own outcome labels or the plain wording of its code. No argument switches any of these on. Reading what was said needs a transcript permission that does not exist yet. * \*\*`get_eval_results`\*\* returns counts, pass rates, the weekly and per-version trend, and one row per scored conversation with its verdict and score. It leaves out the judge's reasoning (which quotes the conversation), the failure clusters built from it, the judge's rubric and the evaluator's own configuration. * \*\*`list_voices`\*\* takes no arguments. It lists the built-in voices an agent may name, with each one's language and whether it may carry a live call, followed by your workspace's own ready custom voices. It asks no outside voice library and takes no search, language or provider filter. No other workspace's voice is ever listed. * \*\*`list_tests` and `get_test_run`\*\* read legacy battery jobs in memory (at most 20 newest), plus candidate checks retained in the App's canonical Checks ledger on Sempera's servers. A retained candidate is resolved by id only among the caller's authorized agents after restart. Legacy jobs are lost at restart. A run shows its state and outcome, not the failure text of a run that crashed nor who started it. Another workspace's run is answered `not found` with the same sentence as an unknown id. * \*\*`list_agent_check_runs`\*\* returns each check run's id, agent, version, status and times. A run that ended in an error keeps the status `ERROR`, and its `error_message` is always the one sentence "The run ended in an error. Its detail is not returned over MCP." in place of the system's own words for what went wrong, which can name a file on a server, a setting or an address. ## Phone tools A credential with `phone:read`, `phone:call` or `phone:text` also lists `phone_get_profile`, `phone_preview_call`, `phone_start_call`, `phone_get_task`, `phone_cancel_task`, `phone_preview_text`, `phone_send_text` and `phone_get_thread`. They run the same operations as `/v1/phone-*` and refuse with the same codes. Each tool needs its own scope. A preview returns a link for the signed-in owner, and a start or a send runs only once the owner approved it or a standing approval covers it. No tool approves, buys, subscribes or tops up. See [Personal phone plan](/phone-plan). ## Not available over MCP * Rolling back, archiving or deleting an agent, discarding a draft, buying or releasing a number, running a paid evaluation, changing a variable or a tool. Calls are available only through the separately scoped and approved phone tools described above. The write registry is an explicit list, not arbitrary HTTP access. * The text of a call, the caller's number and a call's summary, until a separate permission for them exists. * Integration detail by key. `list_integrations` answers; there is no tool for one integration. * Post-call event subscriptions, resources and prompts. None of these exist yet, and no page here documents them as live. ## Draft and test over MCP Three tools run the same write routes the App and REST clients use, as the caller, with the route's own scope, role, workspace and agent checks. A draft edited here is the one canonical draft the App opens; there is no second copy. They are listed in `tools/list` only to a credential that holds their scope. | Tool | Runs | Scope | Arguments | | - | - | - | - | | `create_agent` | `POST /v1/agents` (owner role) | `agents:write` | name (required), template, idempotency\_key (required) | | `update_agent_draft` | `PATCH /v1/agents/{agent_id}/draft` | `agents:write` | agent\_id (required), fields (required), expect\_revision (required), idempotency\_key (required) | | `run_agent_tests` | `POST /v1/tests` | `tests:write` | agent\_id (required), scenarios, candidate\_revision, runtime\_policy, idempotency\_key (required) | * `idempotency_key` is the request's identity: a retry with the same value gets the first answer back and changes nothing again; the same value with a different change is refused `idempotency_key_reused`. * `update_agent_draft` needs `expect_revision`, the `revision` that `get_agent_draft` returned. An edit made from an older read is refused `conflict` with `reason: stale_draft`, so a newer change by someone else is never overwritten. * `run_agent_tests` with `candidate_revision` and `runtime_policy` runs the reviewed draft through the App's canonical Checks runner, never a real call, booking, SMS or email. Without both it retains the legacy battery behavior, but a paid or unknown configured runtime is refused. Read the run with `get_test_run`. * Every call writes one row to the workspace's audit log (`mcp.tool_call`: the tool, the agent, the outcome, the credential id; never the text you sent) and each completed change one builder usage row under `mcp:` (a refused call or a replayed retry is not counted), apart from the call minutes the call service records. ## Connected AI clients An owner connects one outside client at a time in Settings, API keys, Connected AI clients (or `POST /v1/mcp/connections` with `client`, `access` and optional `call_records` and `expires_in_days`). Each connection is its own key, shown once, that: * works on `POST /v1/mcp` and nothing else (any other route answers `403 forbidden`); * holds a fixed scope list: `read` gives the read tools' scopes (call and conversation metadata only with `call_records`), `draft` adds only `agents:write`, `test` adds only `tests:write`, and `build` adds both. Owners can authorize drafting without test execution, or testing without draft edits. Explicit `write` access also adds `agents:publish`, `knowledge:write`, `phone_numbers:read` and `phone_numbers:write`. It never holds `calls:write` or a key scope. Existing keys are not upgraded; * expires (90 days by default, at most 365) and is listed with when it was last used. `GET /v1/mcp/connections` lists them and `DELETE /v1/mcp/connections/{connection_id}` disconnects one. Disconnecting revokes that key only: the deployed agents keep answering calls exactly as before. Another workspace's connection, and an ordinary API key, read as `not_found` on these routes. ## Write tools and a safe agent recipe Choose Write in Settings, API keys, Connected AI clients and confirm the permission change. This creates a new expiring key, not an upgrade of an existing connection. Ordinary API keys can use these tools when their scopes and roles allow them. A client should ask its operator to approve publishing and number binding before invoking those tools. Every write requires `idempotency_key`. Reuse it only for an identical retry. A timeout is not proof that nothing changed: retry with the same key and read the resource before attempting another change. | Tool | Method and path | Scope | Arguments besides idempotency\_key | | - | - | - | - | | `save_agent_version` | `POST /v1/agents/{agent_id}/versions` | `agents:write` | agent\_id | | `publish_agent_version` | `POST /v1/agents/{agent_id}/publish` | `agents:publish` | agent\_id, reason | | `run_agent_simulations` | `POST /v1/agents/{agent_id}/simulations` | `tests:write` | agent\_id, scenario\_keys, optional repeats | | `bind_phone_number` | `POST /v1/phone-numbers` | `phone_numbers:write` | agent\_id, number | | `create_knowledge_source` | `POST /v1/knowledge-sources/text` | `knowledge:write` | agent\_id, name ending in .txt, content | | `update_knowledge_content` | `PATCH /v1/knowledge/{uuid}/content` | `knowledge:write` | source\_id, content, expect\_version\_id | | `reparse_knowledge_source` | `POST /v1/knowledge-sources/{source_id}/reparse` | `knowledge:write` | agent\_id, source\_id | Follow-up reads are `get_knowledge_content` (`source_id`, `knowledge:read`, `GET /v1/knowledge/{uuid}/content`) and `get_agent_simulation` (`agent_id`, `simulation_id`, `tests:read`, `GET /v1/agents/{agent_id}/simulations/{simulation_id}`). A created knowledge source returns both its library `id` for reprocessing and its `content_id` for reading and editing text. Read `data.version_id` before editing and supply it as `expect_version_id`; stale edits are refused. 1. Connect with the narrowest access needed and call `tools/list` to discover available tools. 2. Use `create_agent` with a synthetic business name, then `get_agent_draft` and `update_agent_draft` with the returned revision. The App reads the same draft. 3. Add a text source with `create_knowledge_source`. Use the draft's editable attachment fields to attach its collection. Uploading alone does not attach it or change the live agent. 4. Run `run_agent_tests` on the draft and read `get_test_run`. A failed or unavailable runner is not a passed test. 5. With explicit operator approval, use `publish_agent_version` to apply the draft and create an immutable version. `save_agent_version` instead compiles the already approved specification without applying unpublished edits. Neither tool promotes a production environment. 6. Run `run_agent_simulations` with chosen scenario keys and read `get_agent_simulation` until it finishes. The normal offline runner and its required local dependencies must be installed. Simulation results do not establish live-call proof. 7. Only after the normal telephony ownership and entitlement requirements are met, use `bind_phone_number` for an already owned number. Purchasing and releasing numbers are not MCP tools. The new writes carry `readOnlyHint: false`, explicit effect metadata, and the same audit and usage accounting as the draft tools. Publishing is marked `org.sempera/publishes: true`. Live client interoperability, production database behavior and carrier ownership checks require separate authorized proof; local tests do not claim those. ## Build and test an agent from an outside AI client over REST A client that does not speak MCP can do the same over the REST API, with a key an owner of the workspace creates in Settings, API keys. The steps below need `agents:write` to create and edit a draft and to try it with a typed preview, and `tests:write` to run the test battery. The key reads results back with the matching `read` scopes. A key made in the App holds every scope a developer key may have, so it can also publish (`agents:publish`) and place a real outbound phone call (`calls:write`). Keep it private and do not paste it into a shared chat. | Step | Method and path | Scope | | - | - | - | | Create a draft agent | `POST /v1/agents` with `{"name": "..."}` (optional `template`, `answers`) | `agents:write` | | Read the draft and its revision | `GET /v1/agents/{agent_id}/draft` | `agents:read` | | Edit the draft | `PATCH /v1/agents/{agent_id}/draft` with `{"fields": {...}, "expect_revision": "..."}` | `agents:write` | | Add a test scenario to the draft | `POST /v2/agents/{agent_id}/scenarios` | `agents:write` | | Try the draft with one typed message | `POST /v1/agents/{agent_id}/preview` with `{"message": "..."}`; it never places a call | `agents:write` | | Run the test battery | `POST /v1/tests` with `{"agent_id": "...", "scenarios": ["..."]}`; it answers `202` and runs in the background | `tests:write` | | Read a test run | `GET /v1/tests/{test_id}` (or the `get_test_run` MCP tool) | `tests:read` | Send an `Authorization` header with the value `Bearer `, replacing the placeholder with your key ([Authentication and credentials](/authentication)). A draft save that carries an old `expect_revision` is refused with `409 conflict`, so read the draft first and send its `revision`. What a key cannot do: * **Make another key.** Only an owner signed in to the App can, so a key cannot mint a narrower one. * **Use the Builder.** The Builder's jobs (start, answer, review, apply) run inside the signed-in app. They are not on the API, and a key does not open them. A connector that signs in with OAuth, such as a ChatGPT connector, uses the sign-in above. No real account has proven it yet ([Supported clients](/supported-clients)). ## Clients `mcp_client_setup` returns the URL, the header and the config snippet for each client below. The protocol behavior on this page is covered by automated tests that play a client; no client build below has yet been run against the live endpoint, and the table says so. | Client | Setup snippet | Run against the live endpoint | | - | - | - | | Claude Code | `mcp_client_setup` with `claude-code` | not yet tested (2026-10-09) | | Claude Desktop | `mcp_client_setup` with `claude-desktop` | not yet tested (2026-10-09) | | Cursor | `mcp_client_setup` with `cursor` | not yet tested (2026-10-09) | | VS Code | `mcp_client_setup` with `vs-code` | not yet tested (2026-10-09) | | Windsurf | `mcp_client_setup` with `windsurf` | not yet tested (2026-10-09) | | Gemini CLI | `mcp_client_setup` with `gemini-cli` | not yet tested (2026-10-09) | | Codex | `mcp_client_setup` with `codex` | not yet tested (2026-10-09) | | Any other client | `mcp_client_setup` with `other` | a plain `curl` call, shown above | ## Retained candidate checks Read `get_agent_draft`, review its canonical fields and diff, then pass its revision as `candidate_revision` to `run_agent_tests`. Also pass exactly `runtime_policy: {"evidence":"local-model","tools":"fixture","max_cost":0}`. The two arguments are a pair: a missing policy or revision, a live tool policy, a hosted-model request, a nonzero cost limit or unknown policy fields is refused. The configured hosted fallback is not inherited. If the local model is unavailable, the run fails honestly rather than switching to a paid model. The candidate response identifies the reviewed draft revision, the SHA-256 of the actual compiled candidate and its parent AgentVersion. Its runtime policy is explicit and `release_evidence` is false. Check results are written to the existing App Checks ledger, so `list_tests` and `get_test_run` can review them after a process restart. This does not create a new builder, agent store, compiler or publication road. These retained runs need Sempera's persistent evidence store; a deployment without it is refused before a battery starts. The actual served model build still needs independent runtime evidence; the policy label is not proof of a model version or a quality pass. Fixture batteries in automated tests prove the route, compiler, private tool facade, candidate binding, record retention and refusal behavior. They are not local-model quality evidence, live-model evidence, private host receipt or a production approval. ChatGPT authorization remains unavailable until the separately approved external grant and release gates are satisfied. # SDKs and developer tools Source: https://docs.sempera.org/developer-tools The seven open-source projects on the API: the TypeScript and Python SDKs, the command line, the MCP server, agent skills, n8n nodes and runnable examples, and which to use. Seven open-source projects sit on top of the API. Each is MIT licensed, public in the `Sempera-AI` organisation on GitHub, uses your workspace API key, and calls only routes that `https://api.sempera.org/openapi.json` lists. None is on npm or PyPI yet, so today you install each one from its repository ([Migration and changelog](/changelog)). Create the key in the Sempera App under Settings, API keys, and give it only the scopes the tool needs ([Authentication and credentials](/authentication)). ## Client libraries ### TypeScript SDK [Sempera-AI/sempera-node](https://github.com/Sempera-AI/sempera-node) is the official TypeScript client: one typed method per operation in the contract, plus retries, idempotency keys, paging and webhook signature verification. Node 22.18 or newer. Usage is in [TypeScript and Python SDKs](/sdks). ```bash theme={null} npm install github:Sempera-AI/sempera-node ``` ### Python SDK [Sempera-AI/sempera-python](https://github.com/Sempera-AI/sempera-python) is the official Python client, standard library only, with the same retries, idempotency keys, paging and webhook verification. Python 3.9 or newer. Usage is in [TypeScript and Python SDKs](/sdks). ```bash theme={null} pip install git+https://github.com/Sempera-AI/sempera-python ``` ## Command line [Sempera-AI/sempera-cli](https://github.com/Sempera-AI/sempera-cli) is the `sempera` command: list agents, read calls and conversations, check numbers, place a call or send a text, and verify webhook signatures from a terminal or a script. Add `--json` to any command for the API's own answer. Node 20 or newer. ```bash theme={null} git clone https://github.com/Sempera-AI/sempera-cli.git cd sempera-cli && npm install && npm link sempera login sempera agents list ``` ## AI and coding agents ### MCP server [Sempera-AI/sempera-mcp](https://github.com/Sempera-AI/sempera-mcp) is a Model Context Protocol server that runs on your machine over stdio. It lets an assistant read your agents, calls, conversations and numbers, and place a call or send a text only when you turn writes on. Sempera also hosts an MCP endpoint over HTTP ([MCP for coding agents](/connect-mcp)); this package is the local alternative. ### Agent skills and editor rules [Sempera-AI/sempera-ai](https://github.com/Sempera-AI/sempera-ai) holds skills, plugin manifests and editor rules that teach a coding agent to build and publish an agent, place a call or send a text with consent, read conversations, set up a number and debug a call. [Supported clients](/supported-clients) lists the clients Sempera has checked. ## Automation [Sempera-AI/n8n-nodes-sempera](https://github.com/Sempera-AI/n8n-nodes-sempera) adds n8n community nodes: place a call, send a text, read calls, list agents, and start a workflow when a signed webhook arrives. The trigger verifies every delivery as [Webhooks](/webhooks) describes. ## Examples [Sempera-AI/sempera-examples](https://github.com/Sempera-AI/sempera-examples) has small runnable examples in Node, Python and curl, each doing one job: place a call, receive and verify webhooks, text without breaking opt-outs, book through a connected calendar, and start a batch from a CSV. They need no packages. ## Which one to use | You want to | Use | | - | - | | Call the API from a server | The TypeScript or Python SDK, or plain HTTPS ([Getting started](/getting-started)) | | Script or inspect a workspace by hand | The command line | | Let an assistant or coding agent work with Sempera | The MCP server and the agent skills, or the hosted MCP endpoint | | Start a no-code workflow on a call | The n8n nodes | | Copy a working starting point | The examples | # Dispositions Source: https://docs.sempera.org/dispositions Values produced from every finished phone call, priced, published and tested, with versioned extractors. A disposition is a set of values you want Sempera to produce from every finished phone call of an agent: "did the caller book", "which service", "the callback number". You define the values, price the definition, publish it, and test it against real calls on your own schedule. From then on every new completed phone call runs the published version, and the result arrives on the `call.outcomes` webhook event ([Webhooks](/webhooks)). ## Conventions These are `/v2` compatibility routes ([API overview](/api-overview)) under `/v2/agents/{agent_id}/dispositions`, where `{agent_id}` is the Sempera agent id. An agent the credential cannot read answers `404` like one that never existed. | Work | Scope | Role | | - | - | - | | Reading | `agents:read` | any | | Creating and editing drafts, estimating, extractors, corrections, align | `agents:write` | `operator` or `owner` | | Starting a test run | `agents:write` | `operator` or `owner` | | Publishing, disabling, deleting; publishing an extractor | `agents:write` | `owner` | Answers use the compatibility envelope `{"data": ..., "errors": null}`. A refusal these routes make is `{"data": null, "errors": [{"error", "message"}]}` with `error` one of `BAD_REQUEST` (400), `NOT_FOUND` (404), `CONFLICT` (409), `TOO_MANY_REQUESTS` (429) or `SERVICE_UNAVAILABLE` (503). A missing credential, scope or role gets the standard error envelope ([Errors and limits](/errors-and-limits)). Every body field is checked; an unknown field is refused. `GET /v1/dispositions/` is a placeholder that always answers `403`; use the per-agent routes. ## Routes | Route | Notes | | - | - | | `GET .../dispositions` | `{"dispositions": [...]}`, the live ones, oldest first | | `POST .../dispositions` | `{"key", "definition"}`. Starts `disabled` with its draft at revision 1. `201` | | `GET .../dispositions/{id}` | One disposition (a deleted one still reads) | | `DELETE .../dispositions/{id}` | Retire it. Idempotent; reads still answer, writes do not | | `GET .../dispositions/{id}/draft` | `{"dispositionId", "revision", "baseVersionId", "definition"}` | | `PATCH .../dispositions/{id}/draft` | `{"expectedDraftRevision", "definition"}` replaces the draft whole. `409` when someone saved first | | `POST .../dispositions/{id}/estimates` | Price the current draft per call. Stores nothing | | `POST .../dispositions/{id}/publish` | `{"expectedDraftRevision", "estimateId", "acknowledgeEstimate"}`. Freezes the draft into a numbered version and enables it | | `POST .../dispositions/{id}/disable` | Stop it. Draft, versions and runs stay. No body | | `GET .../dispositions/{id}/versions` | `{"versions", "nextCursor"}`, newest first, 25 a page; pass `beforeVersion` to page back | | `GET .../dispositions/{id}/versions/{version_id}` | One published version | | `GET .../dispositions/variable-catalog` | The agent's variables and published Outcomes fields a value may read | A disposition reads `id`, `agentId`, `key`, `state` (`enabled` or `disabled`), `publishedVersionId`, `publishedVersionNumber`, `draftRevision`, `draftValueCount`, `hasUnpublishedChanges`, `name`, `description`, `createdAt`, `updatedAt` and `deletedAt`. `key` is 1 to 64 lowercase letters, digits and underscores, starting with a letter, and unique among the agent's live dispositions. ## The definition ```json theme={null} {"name": "Appointment outcome", "description": "Did the caller book?", "timing": "immediate", "values": [{"id": "44444444-4444-4444-8444-444444444444", "key": "booked", "label": "Booked", "schema": {"kind": "boolean"}, "source": {"kind": "judge", "judgeId": ""}}], "transformation": {"version": 1, "root": {"kind": "object", "fields": { "booked": {"kind": "value", "valueId": "44444444-4444-4444-8444-444444444444"}}}}, "postCallWebhook": {"mode": "emit_followup"}} ``` | Field | Rule | | - | - | | `name`, `description` | Up to 120 and 2000 characters | | `timing` | `immediate` or `within_24h` | | `values` | Up to 50. Each is `{"id", "key", "label", "schema", "source"}`: `id` is a UUID you choose and keep, `key` follows the disposition key rule and is unique in the definition, `label` is up to 120 characters | | `transformation` | `{"version": 1, "root": }` assembles the delivered payload. A node is `{"kind": "value", "valueId"}`, `{"kind": "literal", "value"}`, `{"kind": "object", "fields": {...}}` or `{"kind": "array", "items": [...]}` (up to 50 items) | | `postCallWebhook` | `{"mode": "emit_followup"}`: the result is sent as a follow-up event. `hold` is refused; the call's own webhook is never held back | A value `schema` has a `kind`: `boolean`, `string` (optional `format: "date-time"`), `number`, `enum` (`options`: 1 to 24 of `{"key", "label"}`), `array` (`items`, optional `minItems` and `maxItems` up to 1000) or `object` (`properties`, up to 64, and `required`). Any schema may carry a `description`. At most 8 levels, 200 nodes and 64 KB. A value `source` says how the value is produced: | `kind` | Shape | Rule | | - | - | - | | `judge` | `{"judgeId"}` | An eval criterion of this workspace (`GET /v1/evals`) that applies to this agent. The value's schema is `boolean`, or an `enum` whose keys are exactly `pass` and `fail` | | `variable_extraction` | `{"variableKey"}` | One of the agent's call variables, or a published Outcomes field named `outcomes.`, whose value on a live call is the call's own captured outcome. The schema must match what the variable holds; the variable catalog lists each one's `outputSchema` | | `structured_extraction` | `{"extractorId", "extractorVersionId"}` | Reads through a versioned extractor (below). The schema must equal the pinned version's `outputSchema` exactly. A draft may leave the version out; a test run needs one; publishing needs a published one | | `custom_code` | | Refused: customer code does not run here yet | A source may add `"execution": {"reasoningEffort": "auto", "inputSourceIds": ["transcript"]}`. Sempera reads the call transcript with one setting, so any other effort or input source is refused. ## Estimate and publish `POST .../estimates` answers a quote: `id`, `draftRevision`, `perValue` (each value's `executionPath`, token volumes and `usdPerCall`), `totalUsdPerCall` (`min`, `expected`, `max`) and the `assumptions` behind it. The `id` is a digest of exactly what would run and how it is priced. Publish with that `id` and the draft revision you estimated; if anything changed since, publish answers `409` and you estimate again. `acknowledgeEstimate: true` is required when the expected cost per call is above zero. Publishing needs at least one value, freezes a copy of every pinned extractor version and judge wording into the version (a later edit never changes a published version), enables the disposition, and answers the version: `id`, `versionNumber`, `definition`, `publishedAt`. ## On live calls Every enabled disposition with a published version runs on every new completed phone call of the agent, after the call's outcomes are captured. Previews, typed tests and simulations never run it, and a version published after a call ended never runs on that call. The `call.outcomes` event then carries `dispositions`: one entry per disposition with its `status`, `values` and the `payload` the transformation assembled. When no model is reachable a value is stored `not_evaluated` rather than guessed; Sempera reads it again when a model is back, within a day of the call, and sends the event again. ## Test runs and corrections | Route | Notes | | - | - | | `POST .../dispositions/{id}/runs` | Start a run in the background. `201`; poll the run | | `GET .../dispositions/{id}/runs` | `{"runs"}`, newest first; `limit` 1 to 100 (default 25) | | `GET .../dispositions/{id}/runs/{run_id}` | Status, sizes, summary counts and the frozen definition | | `GET .../dispositions/{id}/runs/{run_id}/rows` | One row per call: `status`, `results`, the latest `annotations` and, when complete, `payload`. `limit` 1 to 250 (default 50) and `offset` | | `GET .../dispositions/{id}/runs/{run_id}/export-csv` | The whole run as CSV | | `PUT .../runs/{run_id}/cases/{call_id}/annotations/{value_id}` | Correct one value on one call. `201` | | `GET .../runs/{run_id}/cases/{call_id}/annotations/{value_id}` | That value's corrections, newest first (up to 50, `truncated` says if there are more) | | `POST .../dispositions/{id}/runs/{run_id}/align` | Proposed rewrites of each corrected value's instructions. Writes nothing | A run body is `{"target": "draft" | "published", "population": {...}, "executionSla": "realtime" | "queued"}`; `executionSla` defaults to `realtime` for a disposition whose timing is `immediate` and `queued` for `within_24h`. `population` may carry `environment`, which can only be `production`, and is `{"mode": "recent", "requestedSize": 1 to 250}` (default 20 of the agent's most recent finished calls with a transcript) or `{"mode": "calls", "callIds": [...]}` (1 to 250 ids). `{"replayRunId"}` instead replays a finished run over the same frozen definition and calls. At most 3 runs of one agent may be queued or running (`429`). A run's `status` is `queued`, `running`, `complete`, `partial` or `failed`; with no model reachable it fails and says so. Paging numbers on runs and rows are pulled into range rather than refused. A correction is `{"state": "produced", "value": ..., "explanation": "..."}` or `{"state": "no_value", "explanation": "..."}`. The explanation is 40 to 4000 characters, the value must fit the value's frozen schema, and the run must be finished (`409` otherwise). Corrections are append-only revisions; the engine's own result is never changed. Align takes an optional `{"valueIds": [...], "instructions": "..."}` (1 to 50 ids, up to 2000 characters) and answers `proposals` (each value's `changes`: `field`, `before`, `after`, `rationale`), `skipped` with a reason, and `truncatedCorrections`. It needs a finished run with at least one correction (`409 DISPOSITION_ALIGN_NOT_READY`), a reachable model (`503`), and allows 30 requests per workspace per hour (`429`). You apply a proposal yourself, or not. ## Extractors An extractor is a reusable, versioned reader a `structured_extraction` value pins: a task prompt, an optional system prompt, the output schema and how it is read. It belongs to the workspace and is either workspace-wide or scoped to one agent; its `key` is unique in the workspace. | Route | Notes | | - | - | | `GET .../dispositions/extractors` | Every extractor this agent can use, most recently updated first | | `POST .../dispositions/extractors` | `{"key", "name", "promptMd", "outputSchema", "inferenceConfig"}`, optional `description`, `systemPromptMd` and `scopedToAgent`. `201` with `{"extractor", "currentVersion"}` | | `GET .../dispositions/extractors/{extractor_id}` | `{"extractor", "currentVersion", "activeVersion"}` | | `GET .../dispositions/extractors/{extractor_id}/versions/{version_id}` | One version | | `PATCH .../dispositions/extractors/{extractor_id}/draft` | `{"expectedVersionId", "expectedDraftRevision", ...}` with any of `name`, `description`, `systemPromptMd`, `promptMd`, `outputSchema` and `inferenceConfig`; the fields you leave out keep their values. `key` and `scopedToAgent` are set at create and refused here as unknown fields. `409` when the draft moved | | `POST .../dispositions/extractors/{extractor_id}/publish` | `{"expectedVersionId", "expectedDraftRevision"}`. Answers `publishedVersion` and a fresh editable `currentVersion` | | `GET .../dispositions/extractor-catalog` | Extractors with a published version, with the `extractorVersionId` a value pins | | `GET .../dispositions/model-profile-catalog` | The reader profiles: one, `balanced` | `promptMd` is required and up to 8000 characters and 8000 UTF-8 bytes, so text with many multi-byte characters is refused sooner; `systemPromptMd` has the same cap, and the pair is at most 16000 bytes. `outputSchema` follows the value schema rules. `inferenceConfig` is `{"contextSources": ["transcript"], "modelProfileKey": "balanced", "temperature": 0 to 2, "maxOutputTokens": 1 to 64000}`; only `contextSources` is required, and any other source, profile or `thinking` setting is refused. An extractor always has one editable version; publishing freezes it as the active version (its `state` reads `archived`, meaning frozen) and opens the next number for editing. A disposition that pinned an earlier version keeps it. # Errors, pagination, idempotency and limits Source: https://docs.sempera.org/errors-and-limits The error envelope and codes, pagination, idempotency, rate limits. ## Error envelope Every refusal has one shape: ```json theme={null} {"error": {"code": "insufficient_scope", "message": "this credential cannot agents publish", "request_id": "req_...", "scope": "agents:publish"}} ``` | Code | Status | Meaning | | - | - | - | | `authentication_required` | 401 | No credential | | `invalid_credential` | 401 | A key that is unknown, expired or revoked | | `insufficient_scope` | 403 | The key lacks the route's scope | | `forbidden` | 403 | The role cannot do this | | `workspace_suspended` | 403 | The workspace is paused, so its agents take no new actions. Reads and exports still work | | `csrf_failed` | 403 | An App-session write without its CSRF proof | | `validation_failed` | 400 | A field is missing or wrong (`field` names it) | | `AGENT_ENV_UNPINNED` | 400 | The call asked for the production version of an agent that has no version pinned to production | | `credits_exhausted` | 402 | Not enough credits for this building action, so nothing started. Carries `needed`, `balance`, `debt` and, where buying can work, `buy_path`. Live agents are not affected ([Billing boundaries](/billing-boundaries)) | | `not_found` | 404 | No such resource in your scope | | `method_not_allowed` | 405 | | | `conflict` | 409 | A rule refused it (a stale draft, a live version, a taken number); `reason` may carry a finer code | | `idempotency_key_reused` | 409 | Same key, different request | | `gone` | 410 | It existed and is no longer served (a deleted recording, or one whose access ended); `reason` says which | | `payload_too_large` | 413 | | | `rate_limited` | 429 | Wait `Retry-After` seconds | | `CALL_RL_PLAN_CONCURRENCY` | 429 | A personal phone plan allows one call at a time ([phone plan](/phone-plan)) | | `CALL_RL_PLAN_HOURLY` | 429 | The plan's calls per hour are used | | `CALL_RL_PLAN_DAILY` | 429 | The plan's calls for today are used | | `CALL_RL_PLAN_DAILY_MINUTES` | 429 | The plan's call minutes for today are used | | `CALL_RL_PLAN_ALLOWANCE` | 429 | The plan's minutes are used (limited mode) | | `SMS_RL_PLAN_HOURLY` | 429 | The plan's texts per hour are used | | `SMS_RL_PLAN_DAILY` | 429 | The plan's texts for today are used | | `SMS_RL_PLAN_ALLOWANCE` | 429 | The plan's text segments are used (limited mode) | | `PHONE_PLAN_INACTIVE` | 403 | No active phone plan | | `PHONE_DESTINATION_BLOCKED` | 403 | The plan cannot reach that number | | `PHONE_COST_UNKNOWN` | 503 | That destination or route has no approved cost, so nothing started | | `PHONE_APPROVAL_REQUIRED` | 403 | The account owner has not approved this yet | | `PHONE_APPROVAL_INVALID` | 403 | The request does not match what was previewed | | `PHONE_APPROVAL_EXPIRED` | 410 | The approval expired. Preview again | | `PHONE_FUNDING_NOT_LIVE` | 403 | A test payment cannot fund a real call or text | | `PHONE_AGENT_UNPINNED` | 409 | The owner has not pinned an agent to the plan | | `PHONE_NUMBER_MISSING` | 409 | The plan has no number ready | | `PHONE_RECIPIENT_OPTED_OUT` | 403 | The recipient texted STOP | | `PHONE_OUTSIDE_HOURS` | 409 | Outside calling hours | | `capability_unavailable` | 501 | Not offered on this platform. `POST /v1/calls` also answers it with `reason: do_not_contact` when the number is on the workspace's do-not-contact list ([Sessions and calls](/sessions-and-calls)) | | `provider_failed` | 502 | A provider behind the API failed | | `provider_timeout` | 504 | | | `owner_setup_required` | 503 | A feature the workspace owner has not configured yet | | `credits_unavailable` | 503 | The credit balance could not be read, so nothing started. Live agents are not affected | | `service_unavailable` | 503 | | | `internal_error` | 500 | Quote the `request_id` to support | Errors never carry stack traces, secrets or internal hostnames. ### Reasons A refusal can carry a finer `reason` beside its `code`. A reason is stable and safe to branch on; the `message` is for people and may change. | Reason | Code and status | Where | Meaning | | - | - | - | - | | `invalid_voice_id` | `validation_failed`, 400 | A draft, a session, a voice's detail and preview, a favorite | The value looks like a Sempera voice id (`voice_` then lowercase letters and digits) and is not one Sempera issued. Choose an id from `GET /v1/voices`. The App's own routes answer it as `code: invalid_voice_id` | | `voice_unknown`, `engine_unavailable`, `engine_not_allowed`, `catalog_unavailable`, `preview_unavailable` | `validation_failed` or `conflict` | Voice reads, previews and drafts | Why a voice or its engine cannot be used right now, in Sempera's words. None names a speech supplier or its id for the voice | | `consent_missing`, `consent_no_workspace`, `consent_store_unavailable` | `forbidden`, with `code: voice_consent_required` on the App's routes | A custom voice | The consent record a custom voice needs is absent or unreadable | | `stale_draft` | `conflict`, 409 | `PATCH /v1/agents/{id}/draft`, MCP `update_agent_draft` | Your `expect_revision` is older than the draft. Read it again and merge | | `stale_webhook` | `conflict`, 409 | `PATCH /v1/webhooks/{id}` | Your `expected_updated_at` is older than the endpoint's. The answer carries the `updated_at` now on file | | `do_not_contact` | `capability_unavailable`, 501 | `POST /v1/calls` | The number is on the workspace's do-not-contact list | | `judge_unavailable`, `judge_not_independent` | `service_unavailable`, 503 | `POST /v1/evals/run`, `POST /v1/evals/quick-run` | No judging model is reachable, or the judge is not independent of the builder. No calls were scored | An engine in a draft, a session or a runtime reading is named by a sealed Sempera name (`engine_` then letters and digits) and a voice by its Sempera voice id; a refusal never repeats the engine or the engine's own id. ### A paused workspace While a workspace is paused, a signed-in member gets `403 workspace_suspended` on every request that would take a new action, and still reads and exports. Its API keys behave differently: a key of a paused workspace is refused as an unknown key would be, with `401 invalid_credential`, on reads as well as writes, and works again unchanged when the workspace is restored. Do not rotate or reissue keys in response to that 401 without first checking the workspace's status in the App. A workspace that only ever used API keys still has a road to its data while paused. The workspace owner signs in to the Sempera App, where the agent export, the call export and the call list keep working, and where the data deletion and workspace closure paths stay open. Nothing is deleted by a pause. ## Pagination Most Sempera-native list routes take `limit` (1 to 200, default 50) and `cursor`. Responses are `{"object": "list", "data": [...], "has_more": true, "next_cursor": "..."}`. Pass `next_cursor` back unchanged; cursors are opaque. Compatibility facade routes keep their observed external envelopes, including `tools`/`hasMore`/`nextCursor`, `data.count`/`data.integrations`, `data` plus `errors`, and `voices`-only shapes. Check `openapi.json` for the exact response shape on each route. ## Idempotency Send `Idempotency-Key` (up to 255 characters) on `POST /v1/agents`, `/publish`, `/rollback`, `/v1/sessions`, `/v1/calls`, `/v1/phone-numbers`, `/v1/tools`, `/v1/tools/{id}/execute`, `/v1/snippets`, `/v1/snippets/{id}/publish`, `/v1/integrations`, `/v1/tests`, `/v1/evals`, `/v1/evals/user-templates`, `/v1/evals/run` and `/v1/webhooks`. A retry with the same key and the same request replays the stored answer with `Idempotent-Replayed: true`; the same key with a different request is refused with `409 idempotency_key_reused`. Keys live 24 hours and are scoped to your workspace. ## Rate limits | Subject | Limit | | - | - | | Per API key | 600 requests per minute | | Anonymous (public routes), per IP | 60 requests per minute | | Carrier media-stream ingress, per IP (its own budget, never the anonymous one) | 600 requests per minute | Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (Unix seconds). A `429` carries `Retry-After` in seconds. Back off exponentially. Separately from these request limits, a workspace owner may cap the outbound calls the workspace places per UTC hour and per UTC day. `POST /v1/calls` over a cap answers `429 rate_limited` with `retry_after_s` in the error, and places nothing; if the cap cannot be read it answers `503 service_unavailable` and places nothing ([Owner features in the App](/app-features)). ## Body size JSON bodies are limited to 1 MiB; knowledge uploads to 25 MiB. # Getting started with the Sempera API Source: https://docs.sempera.org/getting-started The twelve-step developer journey from key to published agent to a real conversation. Base URL: `https://api.sempera.org/v1`. Every request is JSON over HTTPS with an API key in the `Authorization` header. ## Before you start * **You need a workspace and an API key.** New registrations are temporarily unavailable; existing customers can log in to the Sempera App at `https://app.sempera.org`. The workspace owner creates API keys there under Settings, API keys, or with `POST /v1/api-keys` ([Authentication and credentials](/authentication)). If you do not have a workspace yet, write to [hello@sempera.org](mailto:hello@sempera.org) to ask for access. * **There is one API hostname and no separate test host.** Everything below runs against `https://api.sempera.org`. Experiment in a workspace you can afford to change, and use `Idempotency-Key` on writes you may retry ([Errors and limits](/errors-and-limits)). * **A key is a secret.** Keep it on your server, never in a browser or a repository ([Security and privacy](/security-and-privacy)). * **Examples here are synthetic.** They use a demonstration agent, `northwind-barbers`. Substitute your own agent's id. ## The developer journey 1. **Get an API key.** The workspace owner creates one in the App (Settings, API keys, New API key) or with `POST /v1/api-keys`. The secret starts with `sempera_sk_` and is shown once. 2. **Create an Agent.** `POST /v1/agents` with `{"name": "Northwind Barbers"}`. The response is the Agent, including its stable `id`. 3. **Configure instructions and voice.** `PATCH /v1/agents/{id}/draft` with `{"fields": {...}}`. Read `GET /v1/agents/{id}/draft` first: `editable_fields` lists the keys a PATCH may carry, and `GET /v1/agents/{id}` shows their current values. 4. **Attach tools.** Create them once with `POST /v1/tools`, then reference their ids in the draft's attachment fields. 5. **Attach knowledge.** Upload documents with `POST /v1/knowledge-sources?agent_id=...` (multipart), then attach the collection in the draft. 6. **Preview.** `POST /v1/agents/{id}/preview` with `{"message": "Hi, can I book a haircut?"}` runs one typed turn against the draft. 7. **Run tests.** `POST /v1/tests` with `{"agent_id": "..."}` starts the scripted battery; poll `GET /v1/tests/{test_id}`. 8. **Publish.** `POST /v1/agents/{id}/publish` creates a new immutable version. Production only ever uses a published version. 9. **Open a conversation.** `POST /v1/sessions` with `{"agent_id": "...", "channel": "web_voice"}` returns a short-lived realtime token for a browser, or bind a number with `POST /v1/phone-numbers`. 10. **Inspect it.** `GET /v1/sessions?agent_id=...` lists every conversation; `GET /v1/sessions/{id}` has the transcript, tool timeline and outcome. 11. **Run evals.** Define criteria with `POST /v1/evals`, judge conversations with `POST /v1/evals/run`. 12. **Iterate.** Edit the draft, preview, publish again. Versions are never rewritten; roll back with `POST /v1/agents/{id}/rollback`. ## First request ```bash cURL theme={null} curl https://api.sempera.org/v1/me \ -H "Authorization: Bearer sempera_sk_..." ``` ```js Node theme={null} // Node 18 or newer: fetch is built in const res = await fetch("https://api.sempera.org/v1/me", { headers: { Authorization: `Bearer ${process.env.SEMPERA_API_KEY}` }, }); console.log(res.headers.get("x-request-id"), await res.json()); ``` ```python Python theme={null} import json, os, urllib.request req = urllib.request.Request( "https://api.sempera.org/v1/me", headers={"Authorization": f"Bearer {os.environ['SEMPERA_API_KEY']}"}, ) with urllib.request.urlopen(req) as res: print(res.headers["X-Request-Id"], json.load(res)) ``` The answer says what the credential is: its kind, workspace, role, scopes and the agents it can see. Every response carries `X-Request-Id`; quote it when you write to support. ## Create and publish an agent ```bash cURL theme={null} curl -X POST https://api.sempera.org/v1/agents \ -H "Authorization: Bearer sempera_sk_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: create-northwind-1" \ -d '{"name": "Northwind Barbers"}' curl -X PATCH https://api.sempera.org/v1/agents/northwind-barbers/draft \ -H "Authorization: Bearer sempera_sk_..." \ -H "Content-Type: application/json" \ -d '{"fields": {"greeting": "Thanks for calling Northwind Barbers, this is Nova."}}' curl -X POST https://api.sempera.org/v1/agents/northwind-barbers/publish \ -H "Authorization: Bearer sempera_sk_..." \ -H "Idempotency-Key: publish-northwind-1" ``` ```js Node theme={null} const base = "https://api.sempera.org/v1"; const headers = { Authorization: `Bearer ${process.env.SEMPERA_API_KEY}`, "Content-Type": "application/json", }; const agent = await fetch(`${base}/agents`, { method: "POST", headers: { ...headers, "Idempotency-Key": "create-northwind-1" }, body: JSON.stringify({ name: "Northwind Barbers" }), }).then((r) => r.json()); await fetch(`${base}/agents/${agent.id}/draft`, { method: "PATCH", headers, body: JSON.stringify({ fields: { greeting: "Thanks for calling Northwind Barbers, this is Nova." } }), }); await fetch(`${base}/agents/${agent.id}/publish`, { method: "POST", headers: { ...headers, "Idempotency-Key": "publish-northwind-1" }, }); ``` ```python Python theme={null} import json, os, urllib.request BASE = "https://api.sempera.org/v1" KEY = os.environ["SEMPERA_API_KEY"] def call(method, path, body=None, idempotency_key=None): headers = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"} if idempotency_key: headers["Idempotency-Key"] = idempotency_key data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method) with urllib.request.urlopen(req) as res: return json.load(res) agent = call("POST", "/agents", {"name": "Northwind Barbers"}, "create-northwind-1") call("PATCH", f"/agents/{agent['id']}/draft", {"fields": {"greeting": "Thanks for calling Northwind Barbers, this is Nova."}}) call("POST", f"/agents/{agent['id']}/publish", idempotency_key="publish-northwind-1") ``` ## Connecting a coding agent (MCP) `POST /v1/mcp` is a JSON-RPC 2.0 [Model Context Protocol](https://modelcontextprotocol.io) endpoint (`initialize`, `tools/list`, `tools/call`), for driving Sempera from a coding agent instead of a browser. It needs `agents:read`, the same bearer key as every other route. Three tools: `list_agents` and `get_agent` wrap the Agents API; `mcp_client_setup` (arguments `{"client": "claude-code" | "claude-desktop" | "cursor" | "vs-code" | "windsurf" | "gemini-cli" | "codex" | "other"}`) returns that client's own URL, header and config snippet, so a coding agent (or you) can connect any of them without leaving the session: ```bash theme={null} curl -X POST https://api.sempera.org/v1/mcp \ -H "Authorization: Bearer sempera_sk_..." \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"mcp_client_setup","arguments":{"client":"claude-code"}}}' ``` The same answer is a plain read at `GET /v1/mcp/clients/{client}` (same key, same scope), which is what the App's Settings > API keys page uses. Writes stay on the REST routes above; nothing here mutates a workspace yet. ## What the API does not do yet Plan for these before you design around the API: * **Create a workspace.** A workspace is created in the App when a person signs up. No API route creates one, and the first API key is created in the App too ([Authentication and credentials](/authentication)). * **Buy or search for a phone number.** `POST /v1/phone-numbers` points a number you supply at an agent. * **Manage plans, payment methods or invoices.** Those live in the Sempera App ([Billing boundaries](/billing-boundaries)). * **Change recording, retention or deletion settings, or hand you call audio.** Those are workspace owner settings in the App, and an API key is never given audio ([Call recordings](/recordings)). * **Replace the App's owner features.** Call-data exports, outbound scheduling and tool analytics have no API route ([Owner features in the App](/app-features)). * **Install a Sempera SDK.** No package is published to npm or PyPI yet; the OpenAPI document is the contract ([TypeScript and Python SDKs](/sdks), [Web SDK](/web-sdk)). ## Where the rest is The full contract is `https://api.sempera.org/openapi.json`. The guide pages are listed in `https://api.sempera.org/llms.txt`. Three pages answer the questions teams ask before they commit: [security and privacy](/security-and-privacy), [billing boundaries](/billing-boundaries) and [supported clients](/supported-clients). # Sempera developer documentation Source: https://docs.sempera.org/index Build, test and run Sempera phone agents from your own code. One hostname, one credential model, one contract. Developer documentation # Sempera developer documentation Build, test and run Sempera phone agents from your own code. One hostname, one credential model, one contract. [Start building](/getting-started) [Open the Sempera App](https://app.sempera.org) Sempera builds and operates AI phone agents ("AI employees") for independent businesses. The [Sempera API](/api-overview) at `https://api.sempera.org/v1` lets a developer create an Agent, configure and publish it, attach tools, knowledge and a phone number, open Web voice sessions, and read every conversation. One hostname, one credential model, path-versioned under /v1. ## Explore the documentation > [card] [Get started](/getting-started) > > Get an API key, create an agent, publish it and hold its first conversation. > [card] [Build an agent](/agents) > > Drafts, previews, immutable versions, scenarios, caller memory and knowledge. > [card] [Phone numbers and calls](/sessions-and-calls) > > Bind a number, place outbound calls, open Web voice sessions and follow calls live. > [card] [Test and improve](/tests-and-evals) > > Scripted tests before you publish, evals and dispositions on every finished call. > [card] [AI, MCP and SDKs](/developer-tools) > > Reach Sempera from your code or your coding agent: SDKs, a command line and MCP. > [card] [Workspace and security](/security-and-privacy) > > How workspaces stay apart, how keys and secrets work, and what is kept about calls. ## Make your first request ```bash cURL theme={null} curl https://api.sempera.org/v1/me \ -H "Authorization: Bearer sempera_sk_..." ``` ```js Node theme={null} // Node 18 or newer: fetch is built in const res = await fetch("https://api.sempera.org/v1/me", { headers: { Authorization: `Bearer ${process.env.SEMPERA_API_KEY}` }, }); console.log(res.headers.get("x-request-id"), await res.json()); ``` ```python Python theme={null} import json, os, urllib.request req = urllib.request.Request( "https://api.sempera.org/v1/me", headers={"Authorization": f"Bearer {os.environ['SEMPERA_API_KEY']}"}, ) with urllib.request.urlopen(req) as res: print(res.headers["X-Request-Id"], json.load(res)) ``` The answer names the credential: its kind, workspace, role and scopes. The full journey is in [Getting started](/getting-started). ## Popular guides > [card] [Place a call](/sessions-and-calls#calls) > > One outbound call from a published agent, with consent, calling hours and caps checked. > [card] [Webhooks](/webhooks) > > Signed events for calls, outcomes, publishes and tests, with retries and a delivery log. > [card] [Connect a coding agent](/connect-mcp) > > Drive Sempera from a coding agent over the Model Context Protocol. > [card] [API reference](/api-overview) > > Base URL, credentials, errors and every endpoint, with a playground. **Docs for agents:** [llms.txt](https://docs.sempera.org/llms.txt) lists every page, [llms-full.txt](https://docs.sempera.org/llms-full.txt) holds them all in one file, and the contract is [openapi.json](https://api.sempera.org/openapi.json). Questions: [hello@sempera.org](mailto:hello@sempera.org). # Knowledge Source: https://docs.sempera.org/knowledge Knowledge sources and processing state. A Knowledge Source is a document an Agent can answer from. Sources belong to an agent's library and are grouped into collections; the Agent's draft names which collections are attached. * `GET /v1/knowledge-sources?agent_id=...` lists sources, one page at a time, beside the agent's `collections` (each with whether it is attached and how it stands against the last build) and `pdf_supported` (whether a PDF upload can be read on this deployment). * `POST /v1/knowledge-sources?agent_id=...` uploads one document as `multipart/form-data` (`file`, optional `collection`). Plain text, Markdown, CSV, saved web pages, Word documents and PDF are accepted (a PDF is read only where `pdf_supported` is true); the file is read at once and the response (`201`) is the source exactly as a read of it answers. If reading the library back fails after the file is saved, the upload still answers `201`, from the saved record, with `collections` and `attached` `null`; a read of the source a moment later fills them. * `GET /v1/knowledge-sources/{id}?agent_id=...` reads one source. * `POST /v1/knowledge-sources/{id}/reparse?agent_id=...` reads the stored file again and answers the `processing` state that re-read left (`parsed`, or `failed` with the reason). * `DELETE /v1/knowledge-sources/{id}?agent_id=...` removes it from the library; the versions already built with it keep naming it. A source carries `id`, `name`, `content_type` (the media type of what Sempera read the file as, which can differ from what the uploader claimed), `size_bytes`, `processing` (`state` is `queued`, `parsed` or `failed`, with `error`), `collections` (the ids of this agent's collections that hold it), `attached` (whether one of those collections is attached in the agent's draft) and `added_at` (when the file now on record was uploaded; an in-place edit through `PATCH /v1/knowledge/{id}/content` moves it). An id this agent's library does not hold, another agent's included, is `404 not_found` on every route. A source already removed reads as `404`, but `DELETE` and reparse answer it `409 conflict` with the reason ("that document was already removed"), and a library that cannot be read answers `DELETE` and reparse `409 conflict` with the reason, never `404`. Processing state is tracked apart from the Agent's configuration: uploading never changes what the Agent says until the collection is attached in the draft and a version is published. ## Source testing `PATCH /v1/knowledge-sources/{id}?agent_id=...` updates a text source with `content` and optional `expect_revision` from the library reading. Stale revisions return 409 and do not overwrite the newer reading. This uses library document ids, not the workspace UUIDs returned by the source testing context. `POST /v1/knowledge/crawl` takes `agent_id`, `url` and optional `collection` to read one public HTTPS page (201). To refresh, send `agent_id` and `source_id` instead (200). This is a bounded single-page read, not a recursive site crawler. The existing private-address, redirect, size, timeout and quota checks apply. Both operations require `knowledge:write` and the configure-integration role. Neither attaches a source or publishes an agent. `POST /v1/knowledge/chat` exposes the source-testing contract below to API clients and the console composer. It returns cited retrieved passages, not a generated conversational answer. Each request is independent; it has no hidden conversation history. Model-generated knowledge chat remains outside this contract. `GET /v1/knowledge/testing-context?agent_id=...` requires `knowledge:read` and the read role capability in the selected workspace. It lists only sources visible to this credential (`sources`: id, name, parse status and owning agent), plus the requested agent's actual pathway nodes (`nodes`: id, label, prompt) and any `node_note`. Nodes are agent configuration: a credential without `agents:read` gets an empty `nodes` list and a note saying why. A foreign agent is not found. Loading a node in the console copies its recorded prompt into the composer; it does not simulate node execution, infer unrecorded per-node source bindings, or change source selection. `POST /v1/knowledge/test` takes `agent_id`, `question`, and optional `source_ids` (at most 50 canonical source UUIDs from the context response). It requires `knowledge:write`, because a published test adds to the ask log, plus the read role and the same workspace/agent isolation. Omitting `source_ids` searches the built version; an explicit empty array searches nothing. An explicit selection searches those sources' latest parsed readings, without attaching or publishing them. Foreign or missing sources return 404, malformed ids or invalid questions return 400, and sources that cannot be read or are not ready return 409. Paused workspaces retain the context read but cannot run the POST, following the neighboring action guard and partner entitlement policy. The result is a deterministic local retrieval diagnostic: question, verdict, reason, scored passages with source attribution, and `mode` (`published` or `selected_sources`). The `published` mode records an ask in the existing knowledge log; a `selected_sources` test records nothing, so it cannot close a caller's open question or change coverage. It is not a model-generated answer or a live call, contacts no provider, and leaves the agent's draft and published artifacts unchanged. ## Console source inventory and query filters `GET /v1/knowledge/sources` and `/v1/knowledge/sources/picker` accept `agent_id` to narrow the credential's workspace-visible sources. Parsed source inventory rows normally return `COMPLETED`. `STALE` means a verified published snapshot binds an older reading of that document; an unbuilt or unattached source alone is not evidence of staleness. Failed and processing rows retain `FAILED` and `PROCESSING`. `GET /v1/knowledge/queries` supports `source_id`, case-insensitive question search `q`, `status` (`all`, `answered`, `question_answered`, `question_open`), inclusive ISO 8601 `start_date` and `end_date`, `agent_id`, `take` (1 to 100), and `cursor`. These filters apply before pagination. Each row includes workspace-visible canonical `source_ids`; legacy asks without document attribution do not match a source filter. Foreign or missing source filters return 404. A question resolved by a later answer keeps that status even when the answer is outside the requested date window. The console sends local calendar-day boundaries as UTC instants. The console's Export CSV downloads the currently filtered source inventory, with quoted cells and spreadsheet-formula escaping. Add sources at workspace level chooses the agent that owns the library, then uses the same upload, collection, web-page and Drive workflows as the agent page. Drive authorization and web fetching still require their configured owner permissions and network access; the console never claims a connection or import succeeded without the actual outcome. ## Recorded question lifecycle `GET /v1/knowledge/queries` includes `question_id`, `question_revision`, `question_state`, `answer` and `answer_source_id`. Question identity is bound to the workspace, agent and normalized question text. Owner annotations are separate from the retrieval diagnostic's `status`; the original query history is retained. Both mutation routes require `knowledge:write` and the configure-integration role capability. Read-only CI keys and runtime keys cannot use them. Send the recorded `agent_id`, `question` text and current integer `revision` (`question_revision` from the query row, initially 0). A stale revision returns `409 conflict`; an invented question or a question outside the credential's workspace is not found. * `PATCH /v1/knowledge/questions/{question_id}` takes `action`: `link`, `dismiss` or `delete`. Linking also requires a `source_id` naming a ready source in the workspace and marks the question resolved. Dismiss and delete update only the annotation, not the query history or source library. The response includes `id`, `state`, the new `revision` and `note`. * `PUT /v1/knowledge/questions/{question_id}/answer` also takes `answer`, a nonblank string of at most 20,000 characters. It ingests the answer as a text source through the ordinary quota and credit checks, then marks the question resolved. The response also includes the canonical `source_id`. A failed ingestion does not resolve the question; retrying the same content reuses its source rather than creating another one. Neither route changes published knowledge. Attach the source's collection and publish a version before callers can use the answer. Question changes require the workspace database; an unavailable database is not a successful save. ## Retrieval settings `GET /v1/agents/{agent_id}/knowledge/retrieval-settings` needs `knowledge:read` and answers `{settings, revision, applies_to, note}`. `PUT /v1/agents/{agent_id}/knowledge/retrieval-settings` needs `knowledge:write` and the `owner` role. It takes `settings` and `expect_revision`, the draft revision you read. `settings` is `{boost_columns, exclude_fields}`: up to 50 boost columns with weights from 1 to 20, and up to 50 excluded fields, none of which may also be boosted. Field names are labels in the extracted rows (1 to 64 letters, digits, spaces, underscores or hyphens). A stale draft is `409 conflict`. Save stages the settings in the agent draft, and they apply from the next published version. Exclusions drop labelled fields only, not unlabelled prose, and never delete source data. # Live calls and live transcripts Source: https://docs.sempera.org/live-call-streams The roster of calls in progress, one call's transcript so far and the workspace's active calls, each as a snapshot frame you read again to see a call move. Three routes show what is happening on an agent's phone line and in its browser conversations right now: the roster of calls in progress, the turns spoken so far on one of them, and a workspace-wide list of active calls. Each answers a **snapshot**. The `stream` routes answer one Server-Sent Events frame and close the connection; there is no push, so read again (every few seconds is the pace the App uses) to see a call move. All three need `calls:read`, any role. Listening in, stopping and taking over a live call are supervisor actions in the Sempera App; there is no `/v1` route for them. | Route | Answers | | - | - | | `GET /v1/live-calls/stream?agent_id=` | One `roster` frame: the agent's calls in progress | | `GET /v1/live-calls/transcript/stream?agent_id=&room=` | One `transcript` frame: the turns so far of one call | | `GET /v1/calls/active` | The workspace's active calls as JSON, in the compatibility envelope | | `GET /v1/calls/active/stream` | The same read as one `snapshot` frame | ## The roster `agent_id` is required (`400 validation_failed` without it) and must be an agent the credential can read (`404 not_found` otherwise). The answer is `200 text/event-stream` with one frame: ```text theme={null} event: roster data: {"client": "northwind-barbers", "live": [ ... ], "media_server": true, "supervisor_modes": { ... }} ``` Each item of `live` is a call in progress, oldest first: `room` (its id, the value to pass as `room` below), `client` (the agent id), `mode`, `workspace_id`, `agent_version_id`, `language`, `caller` and `line` (numbers masked to their last four digits, `null` when unknown), `channel` (`phone` on a real phone line), `started_at` (Unix seconds), `elapsed_s`, `participants`, `recording_indicator` (always `true`), `sempera_call_id`, `engines` and the internal correlation identifiers a support request may quote. A room with nobody in it is not a call and is not listed. `supervisor_modes` says which supervisor actions the App offers and why any is not offered. `engines` says what the call runs on: `llm` is the Sempera model id of the call's language model, or `null` when that model has none; `stt` and `tts.provider` are the speech recognition and voice engines, each named by its Sempera model id when the call uses a published Sempera model, `sempera-cloud-speech-recognition` or `sempera-cloud-voice` for an engine a cloud provider hosts for Sempera, and `null` when there is no Sempera name to give; `tts.voice` is the voice. When Sempera's live call service cannot be reached the answer is still `200`, with `"live": []`, `"media_server": false` and `"error": "media server unreachable: "`. An empty roster with `media_server: true` means no call is in progress. ## One call's transcript `room` is a `room` from the roster; it is required, as is `agent_id`. The frame is: ```text theme={null} event: transcript data: {"room": "", "live": true, "turns": [{"role": "caller", "text": "..."}, {"role": "agent", "text": "...", "interrupted": true}]} ``` `turns` are the turns spoken so far, in order, each with a `role` (`caller` or `agent`) and its `text`; an agent turn the caller cut off carries `"interrupted": true`. There is nothing else on a turn, and the list is empty until the first turn is written. The route answers a frame for every outcome, not the standard error envelope ([Errors and limits](/errors-and-limits)): the frame's data is `{"error": "..."}` with status `400` when `room` is missing, `409` when the room is not a call in progress for this agent (it ended, or belongs to another agent) and `200` when Sempera's live call service cannot be reached (`media server unreachable: `). A missing `agent_id`, or one the credential cannot read, is refused with the standard envelope as for the roster. A finished call is read with `GET /v1/sessions/{id}` and `GET /v1/calls/{call_id}` instead ([Sessions and calls](/sessions-and-calls)). ## Active calls across the workspace `GET /v1/calls/active` lists the calls in progress on every agent the credential can read; `agent_id` narrows it to one. It answers `{"data": [...], "errors": null}`, each item `call_id` (the room), `agent_id`, `user_id` (the workspace id), `from` (masked), `to` (the masked line), `start_time`, `status` (always `IN_PROGRESS`) and `timestamp` (milliseconds since the epoch, as text). `objective`, `pathway_id` and `international` are always `null`: Sempera keeps none of them for a live call. When Sempera's live call service cannot be reached the list is empty and `errors` carries `{"error": "media_server_unreachable", "message": "..."}`. `GET /v1/calls/active/stream` sends exactly that answer as one `event: snapshot` frame. # Caller memory schema Source: https://docs.sempera.org/memory-schema Which caller facts an agent may remember, what the schema filters, and the memory changes read. An agent can remember facts about the people who call it, so a returning caller is not asked the same questions twice. The memory schema says which facts the agent may keep. It is read from the agent's configuration; there is no route to write it. | Route | Scope | Notes | | - | - | - | | `GET /v1/agents/{agent_id}/memory/schema` | `agents:read` | The agent's memory schema | | `GET /v1/memory/changes` | `agents:read`, `owner` role | What the agents learned or corrected, newest first | ## Owner write API All writes require `agents:write`, the owner-only `manage_contact_memory` capability, an active entitled workspace, and an agent visible to the credential. Existing workspace-bound storage and its database access rules apply; no new store is created. * `POST /v1/memory`: `agent_id`, international `number` (for example `+12025550123`), `contact`, lowercase `key`, `value` (up to 300 characters), and `kind` (`fact` or `summary`, default `fact`). Returns 201 with the memory id. Facts use the existing replacement history. Summaries append entries. * `PATCH /v1/memory/{memory_id}`: `agent_id` and `value`. Corrects a fact or summary by id, preserving correction history. Foreign rows return 404. * `POST /v1/memory/reset`: `agent_id`, `number`, `reason`, `confirm: true`. Forgets all contacts on that one line for that agent, including verification credentials, and returns the existing deletion receipt. Legal hold returns 409. * `PATCH /v1/agents/{agent_id}/memory`: boolean `enabled` and `expect_revision` from the agent draft read. Saves only the draft. Publish separately to make it live. A stale revision returns 409. Writes use owner provenance, not caller-controlled verification claims. Missing workspace storage returns 503, never an invented successful memory write. The console offers fact and summary entry, correction, and confirmed line reset. ## The schema ```json theme={null} {"object": "memory_schema", "enabled": true, "source": "settings", "entity_schemas": [{"entity_type": "contact", "description": "Facts this agent may remember about a caller", "identifier_field": "name", "fields": [{"name": "preferred_time", "type": "string", "description": "Usual time of day", "required": false}]}]} ``` | `enabled` | `source` | `entity_schemas` | | - | - | - | | `false` | `null` | Empty. The agent remembers nothing | | `true`, with fields named on the agent | `settings` | One `contact` entity holding those fields | | `true`, with no fields named | `cached_version` | One `contact` entity holding four default fields: `preferred_time`, `preferred_language`, `name` and `first_name`, each text | A field is `name`, `type` (`string` unless the agent says otherwise), `description` and `required`. An agent that names fields by name alone gets `string` fields with an empty description that are not required. Memory is turned on with the `contact_memory` field of the agent's draft (`"on"`), and the fields are the `memory_schema` field, a list of names (`PATCH /v1/agents/{id}/draft`, [Agents](/agents)). The route reads the agent's configuration as last published, so a draft edit shows here only after the agent is published. ## What the schema decides When a call ends, Sempera writes what it learned to the caller's memory. While memory is on, a fact it captured from the caller whose key is not a field of the schema is dropped. A caller's name is stored as `first_name`, so a schema that names its own fields should include `first_name` if names are to be remembered. A one-line summary of the call and the call's open tasks are not facts and are not filtered by the schema. Memory is kept for the retention period the workspace owner set. The schema is a filter on writes only. It never removes what was stored before it changed, and a workspace owner can view, correct and forget a caller's memory in the App. ## Memory changes `GET /v1/memory/changes` answers `{"data": {"changes", "initial_memory", "memory_history"}, "errors": null}`. It covers every agent the credential can read, or one agent with `agent_id`. `memory_history` is up to 100 entries, newest first, each with `id`, `timestamp`, `type` (`fact_update`, `summary_update` or `task_update`), `key`, `value`, `agent_id`, `source`, `verified`, `call_id`, `corrected_from` (the id of the entry this one replaced) and `superseded` (true once it was corrected; forgotten entries never appear). Add `call_id` (or `conversation_id`) to see one call's entries: `changes` is then what that call left in memory, as `facts` (key to value), `summary` and `open_items`, and a call with no entries is `404` with `{"data": null, "errors": [{"error": "NOT_FOUND", "message": "..."}]}`. Without `call_id`, `changes` is `null`. `initial_memory` is always `null`: Sempera keeps no snapshot of memory from before a call. The route needs the `owner` role as well as the scope; other roles are refused with `403 forbidden`. ## Contacts Contacts are the people your agents have remembered, grouped across the workspace. These routes need the `owner` role as well as the scope. | Route | Scope | Does | | - | - | - | | `GET /v1/contacts/schema` | `agents:read` | Reads the shared contact schema: `{fields, revision}`. | | `PUT /v1/contacts/schema` | `agents:write` | Replaces the fields (up to 50, each `name`, `type` of `string`, `number`, `boolean` or `date`, `description`, `required`). Send the `revision` you read; a stale revision is `409 conflict`. | | `POST /v1/contacts/merge` | `agents:write` | Groups two console profiles with `source_id` and `target_id`; answers `{id, merged, note}`. | | `POST /v1/contacts/resolve` | `agents:read` | Finds a contact by `contact_id`, or by `phone` (international, starting with `+`) with an optional `name`. Answers `status` (`resolved`, `ambiguous` or `not_found`), the `contact` when exactly one matches, and the `candidates` as `{id, name}`. It never guesses between candidates. | A merge groups profiles for the owner only. Each memory entry keeps its own agent, line, provenance, expiry and verification, nothing about how a caller is recognised on a call changes, and a caller never gets another person's history. # Personas Source: https://docs.sempera.org/personas The read-only persona view of each agent, and which of its fields are fixed values. A persona is the identity an agent speaks with: the name callers hear, and the call settings that go with it. Sempera keeps one persona per agent, so the same persona answers on every number and channel the agent has. The personas routes are a **read-only view** of each agent, in a fixed shape; there is nothing to create, edit or delete here. Change the name, voice or call settings on the agent's draft and publish ([Agents](/agents)). | Route | Scope | Notes | | - | - | - | | `GET /v1/personas` | `agents:read` | One persona per active agent the credential can read | | `GET /v1/personas/{persona_id}` | `agents:read` | One persona. `{persona_id}` is `per_` followed by the agent id | `GET /v1/personas` takes `page` (from 1, default 1) and `limit` (1 to 100, default 20) and answers `{"data": [...], "errors": null}` with no total and no cursor; a page past the end is an empty list, and a `page` or `limit` outside its range is `400 validation_failed`. `GET /v1/personas/{persona_id}` answers the persona itself, unwrapped. An id that does not start with `per_` is `404 not_found`, and so is an agent the credential cannot read. ## The persona A persona reads `id` (`per_`), `name`, `description`, `image_url`, `created_at`, `updated_at`, `deleted_at`, `current_draft_version_id`, `current_production_version_id`, `current_draft_version`, `current_production_version`, `inbound_numbers` and `workspace_id`. `name` is the agent's persona name, or its name when it has none. Both version ids are the same id, and both versions carry the same content: the draft copy differs only in `version_type` (`draft`). A version reads `id` (`perver__production`), `persona_id`, `version_type`, `version_number`, `created_at`, `updated_at`, `promoted_at`, `promoted_by`, `promoted_from_version_id`, `personality_prompt`, `orchestration_prompt`, `call_config`, `default_tools`, `entity_schemas`, `kb_ids`, `memory_enabled`, `pathway_conditions`, `skills` and `auth_config`. `call_config` reads `language`, `timezone`, `voice`, `record`, `wait_for_greeting`, `noise_cancellation`, `max_duration`, `transfer_phone_number`, `keywords`, `pronunciation_guide` and `background`. What follows the agent, read from its current specs, call settings and version history: | Field | Value | | - | - | | `id`, `name`, `workspace_id` | The agent's id, persona name and workspace | | `description` | The agent's role, as its specs name it (for example `front-desk coordinator`); `null` when its specs cannot be read | | `created_at`, `updated_at` | When the agent's first version and its newest version were built; `null` for an agent that has never been built | | `version_number`, and a version's `created_at` and `updated_at` | The number of the version the agent runs now, and when that version was built; `null` for an agent that has never been built | | `personality_prompt` | The persona name again, not the agent's instructions (read those from `GET /v1/agents/{id}`) | | `call_config.language` | The agent's language code, as its specs store it; `null` when its specs cannot be read | | `call_config.timezone` | The business's time zone, the one the agent answers hours questions in; `null` when its specs cannot be read | | `call_config.voice` | The id of the voice the agent's calls use; `null` when no voice has been chosen or its specs cannot be read | | `call_config.record`, `wait_for_greeting`, `noise_cancellation`, `background` | The agent's call settings; `null` for the first three when its specs cannot be read | | `call_config.max_duration` | The longest a call may run, in seconds; `null` when the agent sets no limit of its own | | `call_config.keywords`, `pronunciation_guide` | The agent's speech keywords and pronunciation entries | | `memory_enabled` | Whether the agent remembers callers ([Caller memory schema](/memory-schema)); `null` when its specs cannot be read | What is a fixed value, whatever the agent is configured with: `image_url`, `deleted_at` and `auth_config` are `null`; `promoted_at`, `promoted_by` and `promoted_from_version_id` are `null`, because Sempera records when a version was built, not when it began reaching callers; `orchestration_prompt` is `null`, because an agent runs one conversation and has no routing prompt; `transfer_phone_number` is `""`; and `inbound_numbers`, `default_tools`, `entity_schemas`, `kb_ids`, `pathway_conditions` and `skills` are empty lists. Read the agent's tools and knowledge from its detail read (`GET /v1/agents/{id}`) and its numbers from `GET /v1/phone-numbers?agent_id=` ([Sessions and calls](/sessions-and-calls)). # Personal phone plan Source: https://docs.sempera.org/phone-plan One number your AI assistants can use: limits, calls, texts, approvals, clients and signup. Sempera Phone gives one workspace one number that its AI assistants can use: Claude, ChatGPT, Muse, Grok Bot or anything else that reaches `/v1` or MCP with a scoped credential. It is separate from business plans. It carries no business onboarding, no managed support and no entitlement on them. Nothing on this page publishes a price. The API and the MCP tools can use a plan. They cannot buy one. Signup, checkout and cancel happen on the owner's Billing > Phone plan page. The plan is dark until it is opened: while `published` is false in its configuration, no checkout starts and the page offers none. ## Limits The default is unlimited US and Canada voice and text, inside fair-use caps. An owner-editable config can switch the plan to a limited allowance of 100 connected minutes and 100 text segments a month. | Limit | Default | Refusal | | - | - | - | | Calls at once, inbound and outbound, whole account | 1 | `CALL_RL_PLAN_CONCURRENCY` | | Calls per hour, outbound | 20 | `CALL_RL_PLAN_HOURLY` | | Calls per day, outbound | 50 | `CALL_RL_PLAN_DAILY` | | Call minutes per day, inbound and outbound | 1,000 | `CALL_RL_PLAN_DAILY_MINUTES` | | Longest outbound call | 60 minutes, clamped at dispatch | none | | Texts per hour, agent replies and API sends | 20 | `SMS_RL_PLAN_HOURLY` | | Texts per day, agent replies and API sends | 100 | `SMS_RL_PLAN_DAILY` | | Monthly allowance, limited mode only | 100 minutes, 100 segments | `CALL_RL_PLAN_ALLOWANCE`, `SMS_RL_PLAN_ALLOWANCE` | | Destinations | US and Canada | `PHONE_DESTINATION_BLOCKED` | `+1` alone is not enough. A number must be a real North American number on a priced list: US and Canadian geographic area codes and toll-free calls. Premium and special exchanges (N11, 555, 900, 976), non-geographic and Caribbean codes, high-cost northern Canada, and anything unknown are refused. Toll-free numbers can be called, not texted. Hourly caps reset on the hour and daily caps at midnight UTC, and a 429 carries `retry_after_s`. A call counts at its worst case from the moment it is reserved until real minutes replace that, in whole started minutes. A call is clamped to what is left rather than refused, unless under a minute is left. Inbound while a call is live, or with nothing left, hears a busy message. An inbound text is always stored on its thread, even when replies have stopped. Transfers are off on this plan. Other refusals: `PHONE_PLAN_INACTIVE` (no plan, cancelled, payment overdue, refunded or period ended), `PHONE_COST_UNKNOWN` (a destination or route has no approved cost), `PHONE_FUNDING_NOT_LIVE`, `PHONE_AGENT_UNPINNED`, `PHONE_NUMBER_MISSING`, `PHONE_OUTSIDE_HOURS`, `PHONE_RECIPIENT_OPTED_OUT`, `PHONE_APPROVAL_REQUIRED`, `PHONE_APPROVAL_INVALID` and `PHONE_APPROVAL_EXPIRED`. A test payment activates a plan but cannot fund a real call or text on any road, agent replies and HELP included. Calling and texting hours must hold in every zone the plan reaches. A plan is for one person. It is refused on a workspace with a business subscription or live customer lines, and a business checkout is refused while a plan is usable. Its gates key on a usable plan and its own number, so a cancelled plan changes nothing else on the workspace. A workspace cannot be closed while a plan is still billing. An owner pilot is bound by its own budget at worst case: US\$20, 120 minutes and 40 segments. ## A call There is one set of scopes: `phone:read` reads the line and tasks, `phone:call` previews, starts and cancels calls, and `phone:text` previews and sends texts and reads threads. Every phone credential is a connected client: an OAuth grant, an MCP connection or a REST credential. An ordinary API key is refused. * `GET /v1/phone-profile` returns the number, plan state and what is left, with recent thread ids for `phone:text`. Other clients share the same balance. It is factual and never offers an upgrade. * `POST /v1/phone-tasks/preview` takes `to`, `task`, `permitted_actions` and optional `max_minutes`. It places nothing and reserves nothing. It answers the task `id`, the worst-case `spend_bound_cents` and `approval`: a `url` for the owner, or `standing: true`. * `POST /v1/phone-tasks` takes the same fields with `id` and `spend_bound_cents`. Before the owner approves it answers `PHONE_APPROVAL_REQUIRED`. After, 202 with the same id in state `reserved`. `reserved` is queued, not dialing. * `GET /v1/phone-tasks/{task_id}` is how to follow it. Polling is the portable road: a finished call does not reopen a chat. * `POST /v1/phone-tasks/{task_id}/cancel` is safe to repeat. Before the dial nothing happens and everything is given back. A live call is hung up. `permitted_actions` come from `ask_questions`, `share_callback_number`, `leave_message`, `request_appointment_change` and `make_booking`. | State | Meaning | | - | - | | `awaiting_approval` | Previewed. Nothing reserved | | `reserved` | Approved, worst case held, not dialing | | `dispatching` | Setting up. Nothing has reached a carrier | | `ringing` | The dial has been committed | | `connected` | Answered | | `completed` | Ended, real minutes recorded | | `failed` | Did not happen; `error` says why and nothing is charged for a call that never connected | | `cancelled` | Stopped by a client | | `reconciling` | The answer was lost. Sempera is reading the provider's records. It never redials | A `result` on a finished task is marked `untrusted`: it is written from what someone else said. An unknown outcome stays charged at its worst case. Cancelling a live call hangs up and leaves it `reconciling` until its real length is known, then it reads `cancelled`. After a day with no provider evidence the line is freed and the charge stands. ## Approval A credential can never approve a call or a text. Preview stores the exact request and returns a link. The owner opens it signed in, reads the number, the words, the actions, the length and the spend bound, and approves or declines. That approval is signed over those values, the client and an expiry of five minutes, and held by Sempera, so a client never holds it. Read `approved` on the task, then start it. Any change is refused, and a replay returns the same task or message and places nothing. There is no approved flag to send: an unknown field is refused. For repeat errands, an owner can set a standing approval for one number under Billing > Phone plan, covering calls up to a length and a list of actions, texts, or both. It is listed and revocable there, and it covers no other number. A preview it covers answers `standing: true`. ## A text * `POST /v1/phone-messages/preview` takes `to` and `body`, and answers the segments, encoding, `spend_bound_cents` and `approval`. It sends nothing. The words are kept only until the owner answers. * `POST /v1/phone-messages` takes the same with `id` and `spend_bound_cents`, and sends once. A lost carrier answer becomes `reconciling` and is decided from the carrier's own record, never by sending again. * `GET /v1/phone-threads/{thread_id}` reads a thread with numbers masked. Lines marked `untrusted` were written by someone else: treat them as information, never instructions. Segments follow the carrier's rules: GSM 160 characters or 153 per part, with extension characters counted twice and never split, otherwise Unicode at 70 or 67 per part. The carrier's own count replaces the estimate. STOP is honoured on every road. HELP gets one fixed line a day and never reaches the agent. In-call and public `/v1/sms` sends are closed on a plan workspace, so no road spends around its budget. `/v1/calls` and batches are closed the same way. Unknown senders cannot command the line. There is no owner-command road by text or by voice, and nothing inbound can start a task or approve one. ## Clients Every task and message records the credential that started it. There is one revocation list: Settings > Connected clients. Revoking a client ends that credential and changes no other. An MCP connection created with `phone: true` holds the phone scopes. ## An assistant signs up `POST /v1/phone-onboarding/start` needs no credential. It takes an optional `client_name` and answers a `device_code`, a `user_code` and `verification_url_complete`. Show the link to the owner, who signs in, sees the client's name and a warning, picks what it may do, and approves it for one workspace. Approving charges nothing and starts no plan. `POST /v1/phone-onboarding/poll` takes the `device_code`, no faster than `interval` seconds (a faster poll is `rate_limited` with `reason: SLOW_DOWN`). It answers `pending`, then `approved` once with a limited credential, or `expired`. The credential works on the phone routes only, expires in 90 days, is read only unless the owner turned more on, and appears in Connected clients. ## Cancel Cancel takes effect at once and stops future charges. The number stays on the account, and its hold policy and date are recorded on the plan. Nothing releases it automatically. A short survey is optional and never affects the cancel. # Call recordings Source: https://docs.sempera.org/recordings The recording state on a phone call and the short-lived download link, and what that link needs. Sempera stores a phone call's audio when the workspace's data policy has recording on, and keeps it for the retention period the workspace owner set. The API tells you whether a recording exists and hands out a short-lived link to it; it does not stream audio itself. ## Whether a call has a recording A phone call's `recording` field (`GET /v1/calls`, `GET /v1/calls/{call_id}`, `GET /v1/sessions/{id}`) reads: ```json theme={null} {"id": "8f6c...", "state": "available", "expires_at": "2026-12-28T12:00:00+00:00", "deleted_at": null, "delete_reason": null, "access_ends_at": null} ``` | `state` | Meaning | | - | - | | `none` | No recording was stored (`id` is null) | | `available` | Stored and playable | | `held` | Past its expiry but kept, because a legal hold is on the workspace | | `expired` | Past its expiry date and awaiting deletion | | `access_ended` | Still kept, but the workspace's recording security setting has ended access to it | | `deleted` | Deleted; `deleted_at` and `delete_reason` say when and why | `id` is the value for `{recording_id}` below. `recording_stored` on the `call.completed` webhook ([Webhooks](/webhooks)) says whether one was stored. ## The download link | Route | Scope | Notes | | - | - | - | | `GET /v1/recordings/{recording_id}` | `calls:read` | A signed link that is valid for 5 minutes, for a recording your workspace owns | ```json theme={null} {"object": "recording", "id": "8f6c...", "download": true, "url": "/api/recordings/audio?id=8f6c...&exp=1790688433&sig=...&download=1", "expires_at": "2026-09-29T13:27:13+00:00"} ``` The route looks the recording up before it signs anything. `recording_id` must be a recording of a phone call on an agent the credential can read, in the workspace that agent belongs to; take ids from the `recording` field of a call you can read. An id that does not exist and an id that belongs to another workspace both answer `404 not_found`, identically, and no link is made for either. A recording that was deleted answers `410 gone` with `reason: deleted`, one whose access ended under the workspace's recording security answers `410 gone` with `reason: access_ended`, and a workspace that plays recordings only to its own signed-in members answers any other caller `403 forbidden` with `reason: members_only`; none of them is signed. Every link this route issues is recorded in the workspace's audit log, as the App's are. `url` is a path on the Sempera App (`https://app.sempera.org`), not on this API ([API overview](/api-overview)). The App checks the link's signature, checks its expiry, and requires a signed-in member of the workspace that owns the recording before it sends any audio. The link carries no access by itself. The audio path answers `410` for a recording that was deleted or whose access ended, and every play is recorded in the workspace's audit log. The signature is bound to the identity that asked for the link, and only that identity can use it: | Who asked | Who can play the link | | - | - | | A signed-in member, with their App session (the App serves `/v1` on its own origin) | That member, while signed in and still a member of the owning workspace | | An API key | Nobody. The audio path does not accept an API key (`403`), and a signed-in member presenting a key's link is a different identity (`403`) | So with an API key this route confirms that a recording exists and is yours, but it does not deliver audio: downloading call audio server to server is not offered. To listen, open the call in the App. Recording on or off, the retention period, legal holds, how long access lasts and deletion are the workspace owner's settings in the App; no `/v1` route changes them. # Scenarios Source: https://docs.sempera.org/scenarios An agent's situations in its draft (simple, complex, authentication): create, archive, restore, copy, version history; and the test scenarios list. A scenario is one situation an agent knows how to handle: when it applies, what to do, and when the agent may move on. "The caller wants to move an appointment; find the booking, offer two new times; keep going until the new time is confirmed." A scenario is a block inside the agent's own words (the "Anything else" part of its instructions), so a scenario is part of the draft, compiles into the agent when it is published and has no store of its own. These are `/v2` compatibility routes ([API overview](/api-overview)). `{agent_id}` is the Sempera agent id and `{id}` a scenario id. A scenario keeps its id across versions; a copy in another agent has its own. Reads need `agents:read`. Writes change the agent's working draft, so they need `agents:write` and the `operator` or `owner` role, and nothing reaches callers until the draft is published ([Agents](/agents)). | Route | Scope | Notes | | - | - | - | | `GET /v2/agents/{agent_id}/scenarios` | `agents:read` | `{"agent_id", "revision", "current", "archived"}`: the scenarios in the draft, and the ones taken out of it | | `POST /v2/agents/{agent_id}/scenarios` | `agents:write` | Add one to the draft. `201` | | `GET /v2/agents/{agent_id}/scenarios/{id}` | `agents:read` | One scenario, current or archived | | `POST /v2/agents/{agent_id}/scenarios/{id}` | `agents:write` | Replace one current scenario with the fields below plus `expect_revision`, the draft `revision` as last read. A renamed scenario answers with its new id. `409` when the draft changed since that read or the new name is taken | | `POST /v2/agents/{agent_id}/scenarios/{id}/archive` | `agents:write` | Take it out of the draft and keep it, restorable. It is not part of the agent once published | | `POST /v2/agents/{agent_id}/scenarios/{id}/restore` | `agents:write` | Put an archived scenario back into the draft | | `DELETE /v2/agents/{agent_id}/scenarios/{id}` | `agents:write` | Remove it for good, from the draft or from the archive. Answers the scenario with `"deleted": true` | | `POST /v2/agents/{agent_id}/scenarios/{id}/copy` | `agents:write` | `{"agent_id": ""}`. Adds a copy to that agent's draft. `201` | | `GET /v2/scenarios` | `agents:read` | Every readable agent's current scenarios: `{"scenarios", "agents"}`, each scenario with its `agent_name`, and `agents` every agent the credential can read (the choices for a copy). `agent_id` narrows `scenarios` to one agent | | `GET /v2/agents/{agent_id}/scenarios/{id}/history` | `agents:read` | One scenario per published version | | `GET /v2/agents/{agent_id}/scenarios/agent/history` | `agents:read` | The agent's own words as a whole per published version | ## Pathway folders `GET /v2/scenarios` also returns `organization`, keyed by readable agent id. Each value has `revision`, `folders` (`id`, `name`), `placements` (pathway id to folder id or null), and `order` (saved pathway ids). `POST /v2/agents/{agent_id}/pathway-organization` needs `agents:write` and the same operator/owner capability, workspace and agent checks as pathway edits. Send `expect_revision` from that agent's organization value and one action: * `{"action":"create_folder","name":"Bookings","expect_revision":"..."}` * `{"action":"move","scenario_id":"...","folder_id":"...","expect_revision":"..."}`. A null folder moves to the root. Both the pathway and folder must belong to this agent. * `{"action":"reorder","order":["..."],"expect_revision":"..."}`. List every current pathway exactly once. The response is the saved organization value. Stale revisions and duplicate names are `409`; foreign or missing resources are `404`; invalid actions are `400`. Folder changes persist immediately and are audited, but do not change runtime content or publish an agent. Folder names are single lines, 1 to 120 characters, unique per agent ignoring case, with at most 100 folders. No provider request is made. ### Create a pathway ```http theme={null} POST /v2/agents/northwind-barbers/scenarios {"name": "Reschedule a booking", "type": "complex", "when": "the caller wants to move an appointment", "until": "the new time is confirmed", "steps": ["find the booking", "offer two new times"]} ``` | Field | Rule | | - | - | | `name` | Required, up to 120 characters | | `when` | Required: when the scenario applies | | `until` | Required: the point at which the agent may move on | | `type` | `simple` (default), `complex` or `authentication` | | `steps` | 1 to 12 lines. Needed by `complex`, allowed by `authentication`, refused by `simple` | | `verification` | The check that must pass before the agent goes on. Needed by `authentication`, refused by `simple` and `complex` | `when`, `until`, each step and `verification` are single lines of up to 500 characters and may not begin with `Situation:`, `When:`, `Keep going until:`, `Only after:` or `Step:`. Any other field is refused. The type is read back from the shape, never stored: a scenario with a `verification` is `authentication`, one with steps is `complex`, and one with neither is `simple`. A scenario reads `id`, `agent_id`, `name`, `named`, `when`, `until`, `verification`, `steps`, `text` (the block as it sits in the instructions), `type`, `type_label`, `state` (`current` or `archived`), `archived_at` and `archived_by`. An agent has at most 50 current scenarios, and a name is unique among its current and archived ones, ignoring case. ## History `.../history` answers `{"versions", "next_cursor", "environments"}`. `versions` is newest first; each entry has `id`, `name` (`Version 4`), `version_number`, `created_at`, `author`, `added` and `removed` (lines changed against the version before) and `node`, the scenario as that version had it, or null once it was taken out. `environments` names the version the staging and production environments run, with the same `node`. A version whose specs were not kept cannot be read and is left out. `next_cursor` is always null. ## Refusals Answers use the compatibility envelope `{"data": ..., "errors": null}`; a refusal about the thing named is `{"data": null, "errors": [{"error", "message"}]}`. A missing credential, scope or role gets the standard error envelope ([Errors and limits](/errors-and-limits)). A stale pathway save adds `reason: stale_scenario` and `revision` to the first `CONFLICT` error. Nothing is written or audited as a successful save. Ask the owner before retrying their held fields with that revision; a further save by someone else is refused again. To discard local edits, read the scenario and revision together from the agent's scenario list, without writing. A duplicate name or other conflict does not carry this reason or revision and must not be treated as permission to overwrite. A missing or foreign pathway remains a `404` and discloses no revision. | Status | `error` | When | | - | - | - | | `400` | `BAD_REQUEST` | A field breaks a rule above, or the copy names the agent itself | | `404` | `NOT_FOUND` | No such agent (or one the credential cannot reach), or no such scenario | | `409` | `CONFLICT` | The name is taken, the agent is at its 50, the scenario is already archived (or, for a restore, not archived), or the draft changed while the write was made and it should be repeated | ## Test scenarios Test scenarios are a separate thing: scripted caller cases the test battery plays against an agent ([Tests and evals](/tests-and-evals)). `GET /v1/agent-testing/scenarios` (`agents:read`) lists them with `agent_id` (one agent, default every readable agent), `category` and `enabled` (`true` or `false`, anything else is `400 validation_failed`) as filters, in the compatibility envelope. Each row carries `id`, `name`, `tester_persona_prompt`, `max_turns`, `start_node_id`, `request_data`, `tags`, `created_at`, `updated_at` and `agent_id`; the remaining fields are fixed. `GET /v1/agent-testing/scenarios/{id}/eval-agents` (`agents:read`) lists the judges that score a test scenario's calls: this workspace's enabled evals that apply to the scenario's agent, in name order, each weighted `1` and aimed at the pass level (`target_level_keys: ["P"]`). # TypeScript and Python SDKs Source: https://docs.sempera.org/sdks Official clients generated from the OpenAPI document: install, authentication, errors, pagination, idempotency keys and webhook signature verification. Two official client libraries wrap the API: `@sempera/sdk` for TypeScript and `sempera` for Python. Both are generated from the live OpenAPI document (`https://api.sempera.org/openapi.json`), so each has one method for every operation the document lists, plus a small hand-written core for authentication, errors, retries, idempotency keys, paging and webhook verification. They are MIT licensed. The source is public at `Sempera-AI/sempera-node` and `Sempera-AI/sempera-python`; [Web SDK](/web-sdk) still applies for the browser. The SDKs call only routes that `openapi.json` lists. ## Install The packages are on npm and PyPI. ```bash theme={null} # TypeScript (Node 22.18 or newer) npm i @sempera/sdk # Python (3.9 or newer, no dependencies) pip install sempera ``` ## Authentication Send your API key as a bearer token ([Authentication and credentials](/authentication)). Pass it to the constructor, or set `SEMPERA_API_KEY` and omit it. Keep the key on your server. ```python Python theme={null} from sempera import Sempera client = Sempera("sempera_sk_...") # or Sempera() with SEMPERA_API_KEY set me = client.get_me() print(me) ``` ```ts TypeScript theme={null} import { Sempera } from "@sempera/sdk"; const client = new Sempera({ apiKey: process.env.SEMPERA_API_KEY }); const me = await client.getMe(); ``` Methods are named after the operation id in the contract: `createAgent` in TypeScript, `create_agent` in Python. Path and query parameters are named exactly as in `openapi.json`. A request body is passed as a plain object or dict. ## Create and publish an agent ```python Python theme={null} agent = client.create_agent({"name": "Northwind Barbers"}) published = client.publish_agent(agent["id"], {}) ``` ```ts TypeScript theme={null} const agent = await client.createAgent({ name: "Northwind Barbers" }); const published = await client.publishAgent({ agent_id: agent.id }, {}); ``` ## Place a prompt call `createCall` / `create_call` places one outbound call. `mode: "prompt"` uses only the prompt. `agent_id` is still the line (number, consent, do-not-contact, calling hours, caps and billing). Send `task`, and optionally `first_sentence`, `voice` and `language`. Do not send `agent_version` or `production` with `mode: "prompt"`. The call still requires `Idempotency-Key` and `consent_basis`. Passing your own key makes a retry safe across process restarts. ```python Python theme={null} call = client.create_call({ "agent_id": "northwind-barbers", "mode": "prompt", "phone_number": "+141****0123", "task": "Confirm Friday at 3pm. Ask whether that time still suits.", "first_sentence": "Hello, this is a quick call about Friday.", "voice": "voice_example", "language": "en", "consent_basis": "This person asked Northwind Barbers to call them about Friday.", }, idempotency_key="friday-3pm-ada") ``` ```ts TypeScript theme={null} const call = await client.createCall({ agent_id: "northwind-barbers", mode: "prompt", phone_number: "+141****0123", task: "Confirm Friday at 3pm. Ask whether that time still suits.", first_sentence: "Hello, this is a quick call about Friday.", voice: "voice_example", language: "en", consent_basis: "This person asked Northwind Barbers to call them about Friday.", }, { idempotencyKey: "friday-3pm-ada" }); ``` ## Errors Every refusal is the one error envelope ([Errors and limits](/errors-and-limits)). The SDK raises a typed error that carries the stable `code`, the optional `reason` and `field`, the `request_id` to quote to support and the HTTP `status`. Branch on `code` or `reason`, never on the message. | Error | Status | Meaning | | - | - | - | | `ValidationError` | 400 | A field is missing or wrong (`field` names it) | | `AuthenticationError` | 401 | No credential, or an unknown, expired or revoked key | | `PermissionDeniedError` | 403 | The key lacks the scope, or the role cannot do this | | `NotFoundError` | 404 | No such resource in your scope | | `ConflictError` | 409 | A rule refused it; `reason` may be finer | | `IdempotencyKeyReusedError` | 409 | The same idempotency key was sent with a different request | | `RateLimitError` | 429 | Wait `retry_after` seconds (`retryAfterSeconds` in TypeScript) | | `ServerError` | 5xx | The API or a provider behind it failed | | `ConnectionFailure` | none | No answer: DNS, TLS, or a timeout | ```python Python theme={null} from sempera import NotFoundError, SemperaError try: client.get_agent("agt_missing") except NotFoundError as err: print(err.code, err.request_id) except SemperaError as err: print(err.status, err.code, err.reason) ``` ```ts TypeScript theme={null} import { NotFoundError, SemperaError } from "@sempera/sdk"; try { await client.getAgent({ agent_id: "agt_missing" }); } catch (err) { if (err instanceof NotFoundError) console.log(err.code, err.requestId); else if (err instanceof SemperaError) console.log(err.status, err.code, err.reason); else throw err; } ``` The SDK retries what is safe to retry, two times by default, with exponential backoff and jitter: a `429` (waiting the `Retry-After` it sends), a `502`, a `504`, a `503` with code `service_unavailable`, and a dropped connection. A `503 owner_setup_required` is never retried because waiting does not fix it. A write that has no idempotency key is retried only after a `429`, which means it was not processed. Change the count with `max_retries` / `maxRetries` on the client or on one call. ## Pagination List routes take `limit` and `cursor` and answer `{"object": "list", "data": [...], "has_more": true, "next_cursor": "..."}` ([Errors and limits](/errors-and-limits)). `paginate` follows `next_cursor` until `has_more` is false and yields every item. ```python Python theme={null} from sempera import paginate for agent in paginate(lambda cursor: client.list_agents(limit=100, cursor=cursor)): print(agent["id"]) ``` ```ts TypeScript theme={null} import { paginate } from "@sempera/sdk"; for await (const agent of paginate((cursor) => client.listAgents({ limit: 100, cursor }))) { console.log(agent.id); } ``` `paginate` follows the native envelope only. A compatibility route keeps its own envelope ([Errors and limits](/errors-and-limits)), so `paginate` raises on one instead of silently yielding nothing; page those by hand with the `cursor` it returns. ## Idempotency keys Operations that accept an `Idempotency-Key` (listed in [Errors and limits](/errors-and-limits)) get one automatically: the SDK generates a key for the call and sends the same key on every retry, so a retried `POST` cannot act twice. Pass your own to make a retry safe across process restarts, for instance one derived from your order id. A key lives 24 hours; the same key with a different request is a `409 idempotency_key_reused`. ```python Python theme={null} session = client.create_session({"agent_id": "agt_northwind", "end_user_id": "cust_1042"}, idempotency_key="booking-1042") ``` ```ts TypeScript theme={null} const session = await client.createSession( { agent_id: "agt_northwind", end_user_id: "cust_1042" }, { idempotencyKey: "booking-1042" }, ); ``` ## Webhook signature verification Verify every delivery against the raw request body before you parse it ([Webhooks](/webhooks)). The helper checks the `X-Sempera-Signature` header (`t=,v1=." + body>`) in constant time and rejects a timestamp more than 300 seconds from your clock. It raises `WebhookSignatureError`; answer `400` and process nothing. Then drop an event id you have already handled. ```python Python theme={null} from sempera import WebhookSignatureError, construct_event def handle(raw_body: bytes, signature_header: str): try: event = construct_event("whsec_...", raw_body, signature_header) except WebhookSignatureError: return 400 print(event["type"], event["id"]) return 200 ``` ```ts TypeScript theme={null} import { constructEvent, WebhookSignatureError } from "@sempera/sdk"; export function handle(rawBody: string, signatureHeader: string): number { try { const event = constructEvent("whsec_...", rawBody, signatureHeader); console.log(event.type, event.id); return 200; } catch (err) { if (err instanceof WebhookSignatureError) return 400; throw err; } } ``` The webhooks guide publishes a known answer (secret, timestamp, body and signature). Both SDKs are tested against it, and you can use it to test your own handler. ## Staying in step with the API The operation layers are regenerated from the contract with one command, `python3 06-tools/build-sdks.py`, and a test in the Sempera repository fails when a route is added, renamed or retyped without regenerating. `GET /v1/me` and the `API_VERSION` constant tell you which contract version your copy was built from. ## Other tools and source Every tool below is MIT licensed and public in the `Sempera-AI` organisation on GitHub, and calls only routes the OpenAPI document lists. | Repository | What it is | | - | - | | `Sempera-AI/sempera-node` | This TypeScript client | | `Sempera-AI/sempera-python` | This Python client | | `Sempera-AI/sempera-cli` | The `sempera` command line: agents, calls, conversations, numbers, texts and webhook signature checks | | `Sempera-AI/sempera-mcp` | A stdio MCP server for coding agents and assistants; read tools by default, writes only with `--allow-writes` | | `Sempera-AI/sempera-ai` | Agent skills and plugin setup for AI coding agents that build and operate Sempera agents | | `Sempera-AI/n8n-nodes-sempera` | n8n community nodes: place calls, send texts, read calls, and a trigger for signed webhooks | | `Sempera-AI/sempera-examples` | Small runnable examples in Node, Python and curl | # Secrets and request signing Source: https://docs.sempera.org/secrets-and-signing Workspace secrets tools reference by name, and JWT request signing with a public key set receivers verify against. Two owner settings keep credentials out of your tool definitions and let a receiver check where a request came from. **Secrets** are values you store once for the workspace, such as the token a tool sends to your own system; Sempera never returns a value. **Request signing** adds a short-lived signed token, verifiable with public keys, to the requests Sempera sends in your name. Both are workspace settings. Writes need the `owner` role and the scope shown; a secret or key of another workspace answers `404` exactly like one that never existed. A write from the Sempera App also passes the CSRF check ([Errors and limits](/errors-and-limits)). ## Secrets | Route | Scope | Notes | | - | - | - | | `GET /v1/secrets` | `tools:read` | The workspace's secrets, unpaged, in the compatibility envelope `{"data": {...}, "errors": null}`: `secrets` beside `types`, `statuses`, `sorts`, `reference_formats` and `configured` | | `POST /v1/secrets` | `tools:write` | Add one. `201` with the secret | | `PATCH /v1/secrets/{id}` | `tools:write` | Rename it, replace a Static value, or pause and resume it | | `POST /v1/secrets/{id}/refresh` | `tools:write` | Fetch a Refresh secret's value now | | `DELETE /v1/secrets/{id}` | `tools:write` | Delete it: the stored value goes with it. Answers the secret with `"deleted": true` | `GET /v1/secrets` takes `q` (a name search, up to 80 characters), `type` (`all`, `static`, `refresh`), `status` (`all`, `active`, `paused`) and `sort` (`created_desc` by default, `created_asc`, `updated_desc`, `updated_asc`); any other value is refused with `400 validation_failed`. Other routes answer the secret itself. A secret has a name in `CAPITALS_WITH_UNDERSCORES`, 2 to 80 characters, starting with a letter, unique in the workspace. There are two types. ```http theme={null} POST /v1/secrets {"name": "ORDERS_TOKEN", "value": ""} ``` ```http theme={null} POST /v1/secrets {"name": "ORDERS_ACCESS", "type": "refresh", "refresh": {"url": "https://orders.example.com/oauth/token", "method": "POST", "headers": {"X-Client": "sempera"}, "body": "{\"grant_type\": \"client_credentials\"}", "interval_minutes": 55, "json_path": "$.access_token"}} ``` | Field | Rule | | - | - | | `name` | Required | | `type` | `static` (default) or `refresh` | | `value` | Static only: up to 8192 characters, no line breaks or control characters. A Refresh secret takes none | | `refresh.url` | Refresh only, required: `https`, no username, password, fragment or template, and not a local or private address | | `refresh.method` | `GET` (default) or `POST` | | `refresh.headers` | Up to 20, each value up to 2000 characters. Not `Host`, `Content-Length`, `Transfer-Encoding` or `Connection` | | `refresh.body` | POST only, up to 8192 characters | | `refresh.interval_minutes` | Required, 1 to 1440 | | `refresh.json_path` | Required, up to 200 characters: `$.access_token`, `$.data.items[0].token` or `$['access-token']`. It must reach one text, number or true or false value | A Refresh secret is fetched when you create it, on `.../refresh`, and again, when a tool is about to use it, if its last attempt is older than the interval. The request goes out with a 10 second timeout, and the answer must be a `2xx` JSON document. A failed fetch is recorded in `refresh.last_error` (a sentence that never contains the value or the answer) and keeps the last good value; creating a secret whose first fetch fails still succeeds, and the secret says why it has no value. A refresh needs the deployment's encryption key, like every stored secret; without it writes answer `503 owner_setup_required`. A secret reads `id`, `object: "secret"`, `name`, `key`, `type` (`Static` or `Refresh`), `status` (`Active` or `Paused`), `reference` (`{{secret.NAME}}`), `id_reference` (`{{secret.id.}}`), `has_value`, `created_by`, `updated_by`, `created_at`, `updated_at` and `refresh`, null for a Static secret, otherwise `url` (without its query string), `method`, `header_names` (never the values), `interval_minutes`, `json_path`, `last_refreshed_at`, `last_attempt_at` and `last_error`. `PATCH` takes `name`, `value` and `status` (`active` or `paused`), at least one. A value on a Refresh secret is refused, and a Refresh secret's settings cannot be edited, only its name and status. `.../refresh` on a Static secret is `400`, and on a paused one `409 conflict`. ### Using a secret in a tool A tool names a secret by its name in `auth` (`{"type": "bearer_reference", "secret": "ORDERS_TOKEN"}`, or `api_key_reference` with a `header`), or by a template in its request URL path or query, a static header or a static body: `{{secret.ORDERS_TOKEN}}` or `{{secret.id.}}` (never in the host). A tool your workspace owns resolves them from your workspace's secrets only. A reference to an unknown secret, a paused secret or one with no value refuses the call before anything is sent, and the value is masked wherever it would appear in a result, including an answer that echoes it ([Tools and integrations](/tools-and-integrations)). ## Request signing When signing is on, the requests Sempera sends to your systems in your workspace's name carry one more header holding a JWT: an owner-built tool call that reaches your system (a sandbox test or a live conversation), every outbound webhook delivery, every webhook action of an automation trigger ([Automations](/automations)) and a widget's chat webhooks ([Website widgets](/widgets)). It adds to `X-Sempera-Signature` ([Webhooks](/webhooks)); it never replaces it. | Route | Scope | Notes | | - | - | - | | `GET /v1/signing` | `webhooks:read` | The settings, the keys and where to verify | | `PATCH /v1/signing` | `webhooks:write` | Change settings; fields you leave out keep their values | | `POST /v1/signing/keys` | `webhooks:write` | Generate the first key, or rotate. `201`, with the settings view and the new key as `generated` | | `POST /v1/signing/keys/revoke-previous` | `webhooks:write` | Withdraw every key but the active one, now | | `GET /v1/workspaces/{workspace_id}/.well-known/jwks.json` | none | The published public keys | These answer plain objects, not the compatibility envelope. `GET /v1/signing` answers `object: "request_signing"`, `workspace_id`, `algorithm` (`RS256`), `settings`, `keys`, `issuer`, `jwks_url`, `jwks_path`, `key_storage_configured` and `signed_requests` (`["tool_calls", "webhooks"]`). A key reads `id`, `object: "signing_key"`, `kid`, `algorithm`, `status` (`active`, `previous` or `revoked`), `public_key_pem`, `created_at`, `retired_at`, `revoked_at` and, in the list, `published`. A private key is never returned. | Setting | Rule | | - | - | | `enabled` | `false` until you turn it on, which needs an active key: without one, `409 conflict` | | `header_name` | Default `X-Sempera-JWT`. Letters, digits and hyphens, up to 64 characters; not `Authorization`, `Content-Type`, `Content-Length`, `Host`, `Cookie` or a header Sempera itself sets for events | | `header_prefix` | Default `Bearer`. Up to 32 printable characters | | `audience` | Blank by default, which means the target's origin. Up to 300 characters | | `include_protocol` | `true` by default. When `false` and no `audience` is set, the audience is the host alone | | `ttl_seconds` | Token lifetime, 60 to 3600 (default 300) | | `custom_claims` | Up to 20 string values (names up to 64 characters, values up to 500) added to every token. `iss`, `sub`, `aud`, `iat`, `nbf`, `exp`, `jti` and `body_sha256` are Sempera's own and refused | The header value is the prefix followed by an RS256 token. Its header carries `kid`; its claims are `iss` (`https://api.sempera.org/v1/workspaces/`), `sub` (the workspace), `aud`, `iat`, `nbf`, `exp`, a fresh `jti`, `body_sha256` (the hex SHA-256 of the exact body bytes sent, of the empty string for no body) and your custom claims. To verify, read `kid` from the token, take that key from the JWKS, check the RS256 signature, `iss`, `aud` and `exp`, and compare `body_sha256` with the SHA-256 of the raw body you received. A request without the header should fail that check: if the key cannot be read when a request is sent, the request goes out without the header rather than not at all. Rotating makes the new key the one that signs and keeps the previous key in the JWKS for 24 hours, so tokens already in flight still verify; at most three keys are published, and older ones are revoked. Revoking previous keys removes every earlier key from the JWKS at once, for a suspected compromise, and answers the settings view with `revoked`, the number withdrawn. A key is 2048-bit RSA and its private half is stored encrypted under the deployment's key; without that key no signing key can be made. The JWKS needs no credential, answers `{"keys": []}` for a workspace without keys (or one that does not exist) and may be cached for 5 minutes. ## Refusals | Status | When | | - | - | | `400 validation_failed` | A field breaks a rule above | | `403 forbidden` | The credential's role is not `owner`; `403 csrf_failed` for an App-session write without its proof | | `404 not_found` | No such secret in this workspace | | `409 conflict` | A name is taken, a paused secret is refreshed, or signing is turned on without a key | | `503 owner_setup_required` | The deployment has no encryption key for stored credentials | | `503 service_unavailable` | The deployment has no customer database | # Security and privacy Source: https://docs.sempera.org/security-and-privacy How workspaces are kept apart, how credentials, secrets and signed links work, what is kept about calls, and how to report a concern. This page describes how the Sempera API keeps a workspace, and the people who call it, apart from everyone else, as far as the API's documented behaviour shows. It is a developer's view. It claims no certification, and it does not replace the [privacy policy](https://sempera.org/legal/privacy/), which lists what Sempera keeps, for how long, and which categories of provider handle it. ## What you can rely on | Guarantee | What it means for an integration | | - | - | | One workspace per credential | A key belongs to one workspace and can read and write only inside it. A browser token belongs to one conversation. | | Two locks, default deny | A request passes only when the key holds the route's scope and the identity behind it holds the role the action needs. | | Another workspace's id looks like a missing id | An id from another workspace answers `404 not_found`, word for word the same as an id that never existed. A response cannot be used to find out what other customers have. | | Secret values are not read back | Tool credentials, workspace secrets and signing keys are accepted once and never returned. | | Errors carry no internals | An error never holds a stack trace, a secret or an internal hostname. | ## Workspace isolation Every credential belongs to exactly one workspace, and everything it can read or write is inside that workspace ([API overview](/api-overview)). `GET /v1/workspaces` shows a key exactly one. Lookups check ownership before they answer: a recording, a secret or a key that belongs to another workspace is reported as not found, and nothing is signed or revealed for it. A tool or connection name is checked only inside its own workspace, so a name used elsewhere is never disclosed ([Call recordings](/recordings), [Secrets and request signing](/secrets-and-signing), [Migration and changelog](/changelog)). When you build an integration, treat `not_found` on an id you were given as "not yours or not there", and do not retry it with a different credential in the hope of a different answer. ## Credentials For API keys, the `Authorization` header value is `Bearer `. Replace `` with your key and never put that value in a URL or log. * **API keys.** Send `Authorization: Bearer sempera_sk_...`. The secret is shown once, when the key is created. Sempera stores only a one-way hash of it, as the privacy policy describes. `DELETE /v1/api-keys/{id}` revokes a key immediately. No other header or scheme is recognised, and a key presented anywhere outside `/v1` is ignored ([Authentication and credentials](/authentication)). * **Kinds cap what a key may hold.** A runtime or CI key is never more than a viewer, whoever minted it, and a CI key is read-only. A key is never minted above the role of the person who minted it. * **Browsers never hold an API key.** Your server calls `POST /v1/sessions` and hands the browser a token bound to one room. It expires in 30 minutes and lets the browser do nothing but join that conversation ([Authentication and credentials](/authentication), [Sessions and calls](/sessions-and-calls)). * **People sign in to the App, not the API.** The App's session cookie is host-only, `HttpOnly` and `Secure`, carries a CSRF check on writes, and is not a credential for the API host ([Authentication and credentials](/authentication)). * **Rate limits** are 600 requests a minute per key and 60 a minute per address for routes that need no credential ([Errors and limits](/errors-and-limits)). If a key may have leaked, revoke it first. An owner can do that with `DELETE /v1/api-keys/{id}` or in the Sempera App, then mint a replacement. ## Secrets in tools and connections A tool's credential is a reference to a named workspace secret, never a value. A connection's provider credentials are never returned by any read, a published agent version never shows a secret, and the App does not display a secret again after it is saved ([Tools and integrations](/tools-and-integrations), [Secrets and request signing](/secrets-and-signing)). A secret you delete takes its stored value with it. ## Signed requests and signed events * **Outbound events** carry `X-Sempera-Signature`, an HMAC-SHA256 over `{t}.{body}` with a five-minute tolerance. Verify it with a constant-time comparison before you trust the body. The recipe and a known-answer vector are in [Webhooks](/webhooks). * **Delivery is guarded.** Before an event is sent, the destination host is resolved and the connection is pinned to the address that was checked. An address inside a private network is refused and a redirect counts as a failed attempt. An endpoint URL with a private address literal, embedded credentials or a bad port is refused when you save it ([Webhooks](/webhooks), [Migration and changelog](/changelog)). * **Requests Sempera makes in your name** can also carry a short-lived signed token that you verify against a published key set. It is an owner setting ([Secrets and request signing](/secrets-and-signing)). ## Calls, recordings and consent * **Disclosure.** Every agent says plainly that it is an AI when a caller asks whether it is a person, and never claims to be one. Whether it also says it is an AI virtual assistant at the start of every call is the agent's AI disclosure setting, off by default and chosen by the workspace owner, who is responsible for the disclosure laws that apply to their calls. When calls are recorded, the opening always carries the recording notice, whatever that setting says ([Migration and changelog](/changelog)). * **Recording is the workspace owner's choice.** Whether calls are recorded, how long a recording is kept, legal holds and deletion are owner settings in the App. The API reports the state of each recording ([Call recordings](/recordings)). * **Playback is gated.** `GET /v1/recordings/{recording_id}` signs a link valid for 5 minutes, only for a recording your workspace owns. The link works for the signed-in member it was issued to and nobody else. An API key never receives audio. Every link issued and every play is recorded in the workspace's audit log. * **Outbound calls need a stated basis.** `POST /v1/calls` records a `consent_basis`, refuses a number on the workspace's do-not-contact list, and respects the call limits the owner set ([Sessions and calls](/sessions-and-calls), [Owner features in the App](/app-features)). * **Deleted means gone from the reads.** An export never contains a deleted transcript, and the stored bodies of `call.completed` and `call.outcomes` deliveries lose the words read from a call once its transcript is deleted ([Owner features in the App](/app-features), [Migration and changelog](/changelog)). ## Public pages hold no workspace data These pages are public and need no key. Every example in them is invented: a demonstration agent, made-up ids and digests, numbers in the reserved fictional range, and placeholders where a secret would go. If you find a real customer name, number or key in any page, report it as described below. ## Reporting a problem Write to [hello@sempera.org](mailto:hello@sempera.org). That mailbox is the contact named in the privacy policy and in the OpenAPI document. Include the `X-Request-Id` of the response you are asking about, which every response carries, and describe what you saw. Do not send secrets, call audio or personal information in the first message. This page promises no response time and no reward programme. ## What this page does not claim It does not claim any compliance certification or audit report. It does not state where stored data physically sits: the privacy policy names the categories of provider involved and says what is and is not guaranteed about location. It does not describe internal systems, and it is not a substitute for the terms you accepted. # Sessions, calls and phone numbers Source: https://docs.sempera.org/sessions-and-calls Channel-neutral sessions, minting a Web voice session, phone calls, phone numbers. ## Sessions A Session is one conversation with an Agent on any channel: | `channel` | Where it came from | | - | - | | `web_voice` | A browser voice conversation opened with `POST /v1/sessions` | | `web_chat` | A typed preview turn | | `api_chat` | An authenticated API chat turn with the published agent | | `simulator` | A simulated caller with a synthetic caller id | | `phone` | A real phone call (also a Call) | | `test` | A scripted test scenario | `GET /v1/sessions?agent_id=...` lists them newest first; `GET /v1/sessions/{id}` returns the transcript (`turns`), the tool timeline and captured variables where the runtime recorded them, latency, outcome and the version that spoke. ## Live translation `GET /v1/translation-sessions?agent_id=...` reads availability and recent sessions with `sessions:read`. `POST /v1/translation-sessions` takes `agent_id`, `caller_language` and `staff_language`, requires `sessions:write`, and returns two room-scoped join tokens. Both routes require workspace activation and access to the agent. No phone call is placed. Tokens must not be logged or shared outside the intended participants. ## Dialing pools These routes require access to the agent and the workspace's dialing-pool entitlement. Reads need `calls:read`; configuration writes need `calls:write` and permission to send outbound calls. | Route | Behavior | | - | - | | `GET /v1/agents/{agent_id}/dialing-pools` | Lists pools and owned-number readiness. | | `GET /v1/agents/{agent_id}/dialing-pools/{pool_id}` | Reads one pool in this workspace and agent. | | `POST /v1/agents/{agent_id}/dialing-pools` | Creates a pool from `name`, `phone_numbers`, `daily_cap` and `local_only`; supports an idempotency key. | | `POST /v1/agents/{agent_id}/dialing-pools/{pool_id}/update` | Replaces the same settings; supports an idempotency key. | | `POST /v1/agents/{agent_id}/dialing-pools/{pool_id}/preview` | Takes only `to` and previews selection without dialing or reserving capacity; needs `calls:read`. | ## Rich messaging These routes require an active workspace and access to the agent. Reads need `calls:read`. | Route | Behavior | | - | - | | `GET /v1/agents/{agent_id}/messaging` | Reports channel setup and remaining activation requirements. | | `GET /v1/agents/{agent_id}/messaging/{channel}/conversations` | Lists stored conversations with `page` and `page_size`. | | `GET /v1/agents/{agent_id}/messaging/{channel}/conversations/{conversation_id}` | Reads a conversation belonging to this channel, agent and workspace. | | `POST /v1/agents/{agent_id}/messaging/{channel}/messages` | Requires `calls:write` and send admission, then refuses with `409 owner_setup_required`. No message is queued or delivered. | ## Voices `GET /v1/voices` lists every voice a session or a draft may name, with `production_eligible` (may carry a live call) and `available` (it can speak on this deployment right now). Each voice is named by a Sempera voice id (`id`, `voice_`, then letters and digits) and described by `name`, `description`, `language`, `source` (`built_in`, `library`, or the kind of a custom voice) and, for your workspace's own custom voices, `visibility: "private"`. The id is stable: the same voice has the same id on every read and every deployment, so it is safe to store. No voice payload names the speech engine or the engine's own id for the voice. Pass the id as `voice_id` on `POST /v1/sessions` to override the agent's published voice for one conversation, or set it as the draft's `voice.id` and publish. A raw voice id from before 2026-10-28 is still accepted wherever a voice is written; reads return only Sempera voice ids. The optional `engine` filter (also accepted as `provider`) takes a Sempera engine alias returned by a runtime reading. Treat it as opaque. Older engine values remain accepted for compatibility. An invalid alias returns `400 validation_failed` with `reason: invalid_voice_id`; catalogue refusals use Sempera's wording and a stable reason, never the engine's own error text. The MCP `list_voices` tool lists the same Sempera voice identities without an engine argument. Spec exports and frozen environment settings name speech engines and models by the same stable Sempera aliases. Existing saved agents, published versions and retained snapshots are not rewritten. ### Voice library and studio A workspace keeps a shortlist of cleared Sempera voices. Every workspace member sees the whole library; sharing only controls a direct link to one entry. Private clones are never added or shared. Writes need the `agents:write` scope and an Operator role or above; the studio routes need an Owner. | Route | Scope | What it does | | - | - | - | | `GET /v1/voices/library` | `agents:read` | The workspace's saved voices: `{voices: [{id, voice_id, display_name, rating, shared, share_scope, share_path}]}` | | `POST /v1/voices/library/add` | `agents:write` | Save a cleared voice by its Sempera `voice_id`. Saving the same voice again returns the same entry. `201` | | `GET /v1/voices/library/{entry_id}` | `agents:read` | One entry, only while it is shared. Otherwise `404` | | `POST /v1/voices/library/{entry_id}/rate` | `agents:write` | Set the workspace rating, a whole number from 1 to 5 | | `POST /v1/voices/library/{entry_id}/share` | `agents:write` | `{"enabled": true}` or `false` turns the direct entry link on or off | | `GET /v1/voices/library/{entry_id}/sample` | `agents:write` | Plays the voice with the workspace sample text as `audio/wav`. A sample can spend capacity, so it needs the write scope and the same Operator role as the writes and a workspace that is active | | `GET /v1/voices/studio` | `agents:read` | Saved professional clone preparations, with a notice that training is not enabled | | `POST /v1/voices/studio` | `agents:write` | Save preparation details: `display_name`, an active `consent_id` and `declared_audio_seconds` (600 to 7200). No recording is accepted, nothing is trained and the request stays `usable: false`. `201` | ## Models `GET /v1/models` and `GET /v2/tts/models` (`GET /v2/tts/models/{id}` for one) list Sempera's own published, customer-eligible speech models: `scopes: models:read`. Values are Sempera's own model and voice names; no route ever names a supply account or an underlying engine. `GET /v2/models` is the same speech catalog, reshaped as the model list many client libraries expect (`{id, object: "model", created: 0, owned_by: "sempera", name, modality: "text-to-speech", capabilities: {encodings, sample_rates, containers, controls}}`, `Cache-Control: public, max-age=300`), and needs no credential at all. ## Stored prompts `GET /v1/prompts` lists a workspace's saved prompts (`{"status": "success", "prompts": [...]}`); `GET /v1/prompts/{id}` reads one; `POST /v1/prompts` with `{"prompt": "...", "name": "..."}` (`name` optional) saves a new one, both wrapped the same way (`{"status": "success", "prompt": {"id", "prompt", "name", "last_updated"}}`). Reads need `prompts:read`; writes need `prompts:write`. A saved prompt is reused by hand, most often filling the `prompt` field on a call's own settings; nothing here runs at call time. ## Minting a Web voice session ```http HTTP theme={null} POST /v1/sessions {"agent_id": "northwind-barbers", "channel": "web_voice", "end_user_id": "visitor-42", "language": "en"} ``` ```bash cURL theme={null} curl -X POST https://api.sempera.org/v1/sessions \ -H "Authorization: Bearer sempera_sk_..." \ -H "Content-Type: application/json" \ -d '{"agent_id": "northwind-barbers", "channel": "web_voice", "end_user_id": "visitor-42", "language": "en"}' ``` ```js Node theme={null} // On your server: the API key never reaches the browser. const session = await fetch("https://api.sempera.org/v1/sessions", { method: "POST", headers: { Authorization: `Bearer ${process.env.SEMPERA_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ agent_id: "northwind-barbers", channel: "web_voice", end_user_id: "visitor-42", language: "en" }), }).then((r) => r.json()); // Hand session.url and session.token to the browser. ``` ```python Python theme={null} import json, os, urllib.request req = urllib.request.Request( "https://api.sempera.org/v1/sessions", data=json.dumps({"agent_id": "northwind-barbers", "channel": "web_voice", "end_user_id": "visitor-42", "language": "en"}).encode(), method="POST", headers={"Authorization": f"Bearer {os.environ['SEMPERA_API_KEY']}", "Content-Type": "application/json"}, ) with urllib.request.urlopen(req) as res: session = json.load(res) # hand session["url"] and session["token"] to the browser ``` The response is `{"id", "transport", "url", "token", "expires_at", "voice", "speech_recognition"}`, where `transport` names the realtime media protocol the `url` and `token` are for ([Web SDK](/web-sdk)). Hand `url` and `token` to the browser; the token is bound to that one room and expires in 30 minutes. Your API key never leaves your server. The request is refused with `409` when the agent has no published version, when the requested voice is not in the catalogue, or when the platform is at capacity (`503`). When the agent hears or speaks through a published Sempera model, `speech_recognition` or `voice` names it by its Sempera model id (`model_id`, repeated as `provider` and `model`), and the session runs that model; if the model cannot carry a call on this deployment, the request is refused with `409` and a sentence saying so, and no other model is used in its place. Otherwise the two legs name no engine and no model: `voice` carries the voice's Sempera id (`voice_id`) and name, and `speech_recognition` its language. A voice that cannot be used is refused in Sempera's words, and a `voice_id` that looks like a Sempera voice id but is not one is refused with `400` and `reason: invalid_voice_id`. ## What ran a session: `runtime_identity` `GET /v1/sessions/{session_id}` carries a `runtime_identity` block for a session recorded from 2026-10-29 on: one answer to "what heard, thought and spoke, did anything fall back, and was it a customer's conversation". Older sessions return `null`. | Field | Meaning | | - | - | | `legs.stt`, `legs.llm`, `legs.tts` | The hearing, thinking and speaking legs. Each has `engine`, `model` and `machine` (a category: `cloud` or `sempera`); `tts` also has `ended_on` (the engine the voice really ended on), `voice` (the Sempera voice id) and `language` when known | | `fallbacks` | Handovers that happened: `llm` rows (`kind`, `from`, `to`) and `tts` rows (`from`, `to`, `reason` in Sempera's words). Empty lists mean nothing fell back | | `worker` | `runtime` (`origin` when Sempera's own servers ran the call, `hosted` when a hosted call service ran it) and `transport` | | `latency` | Timings the runtime measured: `greeting_at_s`, `first_reply_s`, `reply_turns`, and reply and voice percentiles | | `traffic` | `class` (`customer`, `demo`, `test` or `unrecorded`) and `company_overhead`, true for anything that is not a real caller on a real line | | `verified` | False when the stored block no longer matches its own integrity checksum. The checksum catches accidental corruption; it is not tamper evidence | Engines and models carry Sempera's sealed names (`engine_...`), or the Sempera model id when a leg ran a published Sempera model. The block never names a supplier, a host, a device or a failure's own text, and it prices nothing. ## Calls A phone call is a Session with `channel: phone` and also a Call: `GET /v1/calls?agent_id=...`, `GET /v1/calls/{call_id}`. A Call adds the carrier's lifecycle: caller and called numbers, direction, duration, recording state and provider correlation ids (internal). `POST /v1/calls` (`calls:write`, owner role, requires `Idempotency-Key`) places one outbound call through the App's guarded dispatcher. A missing gateway or unreadable outbound policy refuses the request; it never reports a ledger-only call as queued. ```http HTTP theme={null} POST /v1/calls Idempotency-Key: appointment-reminder-unique-intent {"agent_id": "northwind-barbers", "to": "+14165550123", "production": true, "message": "Your appointment reminder is ready.", "consent_basis": "This person asked Northwind Barbers to call them with appointment reminders.", "metadata": {"name": "Ada"}} ``` ```bash cURL theme={null} curl -X POST https://api.sempera.org/v1/calls \ -H "Authorization: Bearer sempera_sk_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: appointment-reminder-unique-intent" \ -d '{"agent_id": "northwind-barbers", "to": "+14165550123", "production": true, "message": "Your appointment reminder is ready.", "consent_basis": "This person asked Northwind Barbers to call them with appointment reminders.", "metadata": {"name": "Ada"}}' ``` ```js Node theme={null} const res = await fetch("https://api.sempera.org/v1/calls", { method: "POST", headers: { Authorization: `Bearer ${process.env.SEMPERA_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": "appointment-reminder-unique-intent", }, body: JSON.stringify({ agent_id: "northwind-barbers", to: "+14165550123", production: true, message: "Your appointment reminder is ready.", consent_basis: "This person asked Northwind Barbers to call them with appointment reminders.", metadata: { name: "Ada" }, }), }); const call = await res.json(); ``` ```python Python theme={null} import json, os, urllib.request body = { "agent_id": "northwind-barbers", "to": "+14165550123", "production": True, "message": "Your appointment reminder is ready.", "consent_basis": "This person asked Northwind Barbers to call them with appointment reminders.", "metadata": {"name": "Ada"}, } req = urllib.request.Request( "https://api.sempera.org/v1/calls", data=json.dumps(body).encode(), method="POST", headers={ "Authorization": f"Bearer {os.environ['SEMPERA_API_KEY']}", "Content-Type": "application/json", "Idempotency-Key": "appointment-reminder-unique-intent", }, ) with urllib.request.urlopen(req) as res: call = json.load(res) ``` | Field | Rule | | - | - | | `agent_id`, `to` (alias `phone_number`) | Required. `to` is E.164, or national digits with `country` (an ISO code the App's Send a call page lists) | | `production` (alias `use_production`) or `agent_version` (alias `agent_version_id`) | By default, resolve the production deployment pin. An explicit version names a published id or number. That version's exact artifact must be installed; an older version is `400` on `agent_version`, not a silent fallback. No production pin is `400 AGENT_ENV_UNPINNED`. A publish that lands while the call is being placed or the call service is loading the version refuses rather than running different bytes | | `message` (aliases `opening`, `first_sentence`) | The opening line after the required automated-call disclosure | | `consent_basis` | Required sentence recording why this person opted in. Never invented by the API | | `from` (alias `from_number`) | An owned caller id | | `metadata` | Existing opening-template context: text values substituted into `{name}` and other placeholders. Not a per-call webhook payload. Values that the batch normalizer would truncate are refused | | `timezone`, `window` | Recipient calling hours. Defaults match the App: `America/Toronto`, `09:00-18:00`. Outside the window no call is placed | | `precall_dtmf` | Keys pressed after connection and before the employee speaks | | `name` | Label in the outbound record, at most 80 characters | | `settings` | `voice_id`, `language`, `max_call_minutes`, `interruptions`, `interruptibility`, `interruption_threshold`, `acknowledgements`, `background_noise`, `noise_cancellation`, `voicemail`, `prompt`, `keywords`, `call_data`. JSON types are checked; unsupported keys and conflicting settings are `400` | | `voice`, `language`, `task`, `max_duration`, `keywords` | Aliases for `settings.voice_id`, `.language`, `.prompt`, `.max_call_minutes`, `.keywords`. Duration is a whole number of minutes, 2 through 60, subject to the platform's stricter safety ceiling. With `mode` `prompt`, `task` is the whole conversation and is required. Otherwise the task supplements published rules. Keywords require a recognizer with per-call word-list support; otherwise `400` names `settings.keywords` | | `mode` | `agent` (default) or `prompt`. See Prompt mode below. `prompt_only` is not a field | | `block_interruptions` | Boolean: true blocks interruption, false allows it. Cannot be combined with interruption settings | | `background_track` | Alias for `settings.background_noise`: `none`/`off`, `office`, `coffee_shop`, `restaurant` | | `request_data` | Alias for `settings.call_data`: a nonempty object of named text values. Never combine both spellings. Available throughout the call, separate from opening-template metadata | `call_data` is an object of up to 20 named text values for this conversation only. A key starts with a letter and contains only letters, digits and underscores, at most 40 characters; each value is at most 200 characters. The runtime binds these as call facts without changing the published agent. Do not send credentials in call data. The App's version picker lists the currently available published phone version and refuses an unavailable selection rather than silently replacing it. Scheduling in the App creates a deferred batch only when the scheduler is enabled; the public single-call API still places calls now. Send only one spelling of an option, including across `settings` and top-level aliases. `voicemail` and `precall_dtmf_sequence` alias the existing voicemail and keypad settings. `webhook` selects a registered signed endpoint for this call; see [Webhooks](/webhooks). Any other option returns `400 validation_failed` naming its field, even when null or false. Recording overrides, transfers, per-call scheduling, retries, model selection and analysis overrides are refused until their runtime paths support them. ### Prompt mode `mode: "prompt"` places one call from a prompt instead of the agent's conversation. `agent_id` is still required. It is the line the call goes out on (number, consent, do-not-contact, calling hours, caps and billing), not the conversation. A prompt is required: `task` or `settings.prompt`. `first_sentence` (alias of `message`) is the opening line after the automated-call disclosure. `voice` and `language` set the voice and language for this call. Omit `agent_version`, `production` and `environment`; sending any of them is `400`. `prompt_only` is not a field. Sending it is `400` and skips no check. ```http theme={null} POST /v1/calls Idempotency-Key: friday-3pm-ada {"agent_id": "northwind-barbers", "mode": "prompt", "phone_number": "+141****0123", "task": "Confirm Friday at 3pm. Ask whether that time still suits.", "first_sentence": "Hello, this is a quick call about Friday.", "voice": "voice_example", "language": "en", "consent_basis": "This person asked Northwind Barbers to call them about Friday."} ``` The same checks as an agent call still apply, and `mode` does not turn any of them off: the consent sentence, the idempotency key, calling hours, the do-not-contact list, outbound caps, an owned caller id, workspace screening, and billing. A missing prompt places nothing. The answer is `201` with `mode: "prompt"` and no `agent_version_id`. The line's instructions, tools, connections and knowledge are not used. ### Corrected transcripts `POST /v1/calls/{call_id}/corrected-transcript` (`calls:write` and `calls:read`, owner role) imports corrected turns for a completed phone call: `source` (`human_review` or `recording_analysis`) and 1 to 5000 `segments`, each with ordered `start` and `end` seconds, a numeric `speaker`, a `speaker_label` (`caller`, `assistant` or `unknown`), `text` and a 0 to 1 `confidence`. `GET /v1/calls/{call_id}/corrected-transcript` (`calls:read`) reads it back. Every import reads as `provenance: owner_import`, `verified: false` and `confidence_kind: importer_reported`: `source` is what the importer said, and Sempera does not verify it or re-transcribe the call. The original transcript is unchanged, and the correction shares its retention and deletion. No correction yet is `409 correction_unavailable`; a deleted or expired transcript is `410`. A call recorded under Exclude from history, a call on an agent this key cannot see and a call that does not exist all answer the same `404`. Each import is audited with ids and counts, never the words. ## Active call controls These routes require `calls:write`, the owner supervision permission and a workspace-bound credential. An optional `agent_id` narrows the credential's agent scope; it cannot widen it. Every request requires a nonempty `reason` of at most 200 characters, recorded by the existing supervision audit trail. * `POST /v1/calls/{call_id}/stop` ends one active phone call by its Sempera id. * `POST /v1/calls/active/transfer` takes `call_id`, `to` (E.164) and `reason`. It uses the existing phone transfer path and preserves launch restrictions. * `POST /v1/calls/active/stop` ends a snapshot of active phone calls within the authorized scope. Browser sessions and calls arriving afterward are not affected. The response has `data` (one outcome per call), `total` and `stopped`. Failures are reported individually, not hidden by aggregate success. A legacy room without a public call id can still be stopped in bulk; its result has `id: null` rather than exposing an internal room identifier. One-call controls return `404` for missing, ended or out-of-scope calls. An unreadable live roster returns `503`, not an empty success. A transport failure can leave the outcome unknown: check active calls before retrying. The console's Live now section offers owner-confirmed bulk stop for the current agent, alongside its existing single-call stop and transfer controls. ## Questions about a finished call `POST /v1/calls/{call_id}/analyze` takes `{"questions": ["What did the caller request?"]}`. It requires `calls:read`, evaluation permission and a workspace-bound credential. An optional `agent_id` query parameter narrows lookup. Each request reserves one conversation-evaluation credit unit through the existing credit ledger. Send 1 to 10 nonempty questions, each at most 500 characters. The call must have a finished phone transcript. Missing or deleted transcripts are refused. Transcripts over 60,000 characters are refused, never silently truncated. Public outbound ids resolve to the call service's finished record of the call when that record contains the public dispatch correlation; a dispatch checkpoint alone is not a finished transcript. The response includes `id`, `agent_id`, `object: call_analysis` and `answers`. Each answer has `question`, `answer`, `evidence` and `status`. An answered question must include a verbatim transcript quote. Missing information returns `answer: null`, empty evidence and `status: unknown`. An unavailable evaluator or invalid evidence returns a service error, never an invented answer. Answers are returned without creating a new stored analysis record. The conversation drawer offers the same questions form to members with evaluation permission and explains the credit use before submission. ## Call batches `POST /v2/batches/create` requires `calls:write`, owner outbound permission and `Idempotency-Key`. Send `global` with the call's agent, production/version, consent basis and supported call options, and `call_objects` containing `phone_number`, optional `metadata` template context, `timezone` and `precall_dtmf_sequence`. Alternatively supply `csv`, using the existing recipient CSV columns. Invalid rows reject the request rather than disappearing. Unknown per-recipient options are refused. Optional top-level fields are `description`, `concurrency`, `max_attempts`, `retry_after_s`, `start_at`, and `status_webhook` (an enabled signed endpoint registered in the workspace). Creation persists and starts the batch, then advances one due pass through the existing consent, do-not-contact, quota and call-cap gates. Future windows and retries remain queued. The response includes the scheduler's actual state: automatic continuation requires the existing deployment switch `SEMPERA_OUTBOUND_SCHEDULER=1`; no API changes that switch or raises caps. `POST /v2/batches/{batch_id}/run`, with an idempotency key, also advances due recipients using that same checkpointed path. `GET /v2/batches/list`, `GET /v2/batches/{batch_id}` and `GET /v2/batches/{batch_id}/logs` return real persisted records and attempts. Lists and logs support `take` and `skip`; logs also accept `direction=asc|desc`. `POST /v2/batches/{batch_id}/stop` stops future recipients and retries, not an already connected call. Repeating stop is safe. Every endpoint subscribed to `batch.updated` receives signed events with `batch_id`, `state` and `summary` after passes and lifecycle changes. A `status_webhook` must name an endpoint subscribed to `batch.updated` (otherwise `400`), and receives each event once. A failed notification never turns a placed batch into an error. All resources are workspace and agent scoped. The workspace Outbound list offers recorded attempts and owner-only Start, Resume, Pause and Stop actions using the same guarded lifecycle as the agent's Outbound page. Each action asks for confirmation; Stop requires a reason. Starting or resuming makes the batch eligible for the scheduler, not proof that a recipient was contacted. The list's `single_call` flag identifies one-call checkpoints, which do not offer batch lifecycle actions. Attempts show status and time without recipient numbers or provider messages. The answer is `201` with a `call` object: `id` (`call-...`), `agent_id`, `channel` (`phone`), the dispatcher's real `status` (`answered` on connection), `direction` (`outbound`), `caller` and `to`, `agent_version_id`, `agent_version_number`, `batch_id`, `attempt` and `settings`. The request is checkpointed as a one-call batch before dialing. `GET /v1/calls/{id}` reads that dispatch record in the same workspace; this checkpoint is not a completed-call transcript or duration. Internal provider correlation ids never appear in the creation response. Before dialing, the number is checked against the workspace's do-not-contact list and outbound limits, and nobody is called when either stops it: * A number on the do-not-contact list is refused with `501 capability_unavailable`, `reason` `do_not_contact` in the error and a message that says the number is on the list. The one-call batch is still recorded, with that person marked as blocked, and the refusal is audited. * A reached limit answers `429 rate_limited` with `retry_after_s` in the error, and a limit that cannot be read answers `503 service_unavailable`. Nothing is recorded but the audit row ([Owner features in the App](/app-features)). The same request and key replay the successful response for 24 hours; a changed request is `409 idempotency_key_reused`. Concurrent requests and uncertain dispatches are `409 request_in_progress`. A timeout or process loss after dispatch begins does not release the reservation or reclaim it after two minutes. Reconcile the saved outbound attempt before making a new intent; do not use a new key to blindly retry an uncertain call. ## Phone numbers `GET /v1/phone-numbers?agent_id=...` lists the numbers bound to an agent, with the carrier readback that proves where the number really points. `POST /v1/phone-numbers` with `{"agent_id", "number"}` binds a number to an agent (owner role, `phone_numbers:write`). Binding is what makes a real call storable at all: an unbound number's calls are never stored. A developer does not need to know which telephony or voice platform Sempera runs to bind a number. Those are internal and may change. # Text messages (SMS) Source: https://docs.sempera.org/sms The numbers an agent texts from, its two-way text threads, and how the agent answers an inbound text. A text sent to a number bound to an agent is stored on a two-way thread and answered by the agent, and a workspace owner can take a thread over in the App. Texting is turned on per agent in the Sempera App, as part of its phone setup (Send text messages), and it starts off. That one setting decides which numbers `GET /v1/sms/numbers` lists, whether the agent answers a text, and whether it sends one during or after a call: a text that reaches any bound number is always stored on its thread, and with texting off the agent never answers it and never texts anyone (below). Public sends use this same SMS path and its limits. Routes answer in the compatibility envelope `{"data": ..., "errors": null}`. Phone numbers in a thread are masked to their last four digits, exactly as the owner sees them; full numbers never leave the thread. ## Public sends and handoff Every write below requires `calls:write`, owner outbound permission and an `Idempotency-Key`. Reads require `calls:read`. All routes check workspace and agent scope, including cached idempotent responses. A customer database is required for writes and individual conversation reads. * `POST /v1/sms/send`: `agent_id`, `agent_number`, `user_number`, `agent_message` and `consent_basis` are required. The sender must be the agent's existing enabled SMS sender. Unsupported fields get a field-named 400, not a silently ignored setting. Optional `timezone` (default `America/Toronto`) and `window` (`HH:MM-HH:MM`, default `09:00-18:00`) are the recipient's sending hours, as for calls. Outside them nothing is stored or sent and the answer is 409 with `reason: outside_sending_hours`. Whatever the window, no text is sent between 9 PM and 8 AM in the recipient's local time (the zone of the number's area code, else the workspace's own time zone): the send answers `status: refused` with `error: quiet_hours`, and nothing reaches the carrier. * `POST /v1/sms/batch`: `global` defaults and `messages`, a list of 1 to 100 objects overriding those defaults. This is bounded synchronous processing, not an uploaded-file workflow. Every row is validated before sending; the result contains a stable `batch_id` and each message's actual outcome. Each row is held to its own `timezone` and `window`; a row outside its hours is refused and not sent. * `GET /v1/sms/conversations/{conversation_id}` returns the real thread and messages. `GET /v1/sms/conversations/{conversation_id}/messages` supports `take` and `skip`. * `GET /v1/sms/conversations/{conversation_id}/policy` returns `thread`, `state`, `consent` (`not_opted_out`, `opted_out`, or `unreadable`), `do_not_contact`, `can_send`, a refusal `reason`, `max_body_chars`, and `sending_hours` (the default recipient timezone and window, and `human_reply_exempt: true`). Not opted out is not proof of opt-in. The send still requires a consent basis and rechecks the current guards. * `POST /v1/sms/conversations/{conversation_id}/opt-out` with an empty object records a recipient's opt-out request against this thread's sender and returns the same policy shape. It is owner-only, scoped, audited and idempotent. It sends no confirmation text and stays available when launch eligibility disables sends. There is no owner restore action: only an inbound START, UNSTOP or YES restores texting. Suppression covers all sending numbers in that workspace; other recipients and workspaces are unchanged. * `POST /v1/sms/conversations/{conversation_id}/handoff`, `POST /v1/sms/conversations/{conversation_id}/resume` and `POST /v1/sms/conversations/{conversation_id}/end` use the existing human/AI/ended state transitions. An ended thread cannot send or resume. * `POST /v1/sms/conversations/{conversation_id}/messages`: `message`, `consent_basis`, optional `sender` (`human`, the default, or `agent`). A human must take over first; an agent cannot send while a human holds the thread. An `agent` reply obeys the same optional `timezone` and `window` as a send, and outside them nothing is stored or sent (409, `reason: outside_sending_hours`). A `human` reply in a live thread is exempt, and those two fields do not apply to it. Sends obey launch eligibility, texting enablement, sender ownership, limits, write policy, do-not-contact and durable opt-outs. No public send, batch row or conversation reply goes to a verified, unreleased line on Sempera in any workspace (403), which prevents two agents from answering each other without end. An agent also answers at most 20 texts automatically per thread in 24 hours; past that, the thread is flagged for a person. A pending message is persisted before entering the send path; its outcome is recorded afterwards. An uncertain send retains its idempotency reservation without stale takeover. `sent` is provider acceptance, not proof of final delivery. Fixture mode says `simulated` and `sent: false`. A refused or withheld send is recorded honestly instead of reported as delivered. No credentials, full numbers or provider error bodies appear in the results or audit events. No migration is required. The App's inbox and reply composer use these same public writes with browser session CSRF proof and an idempotency key. A legacy `/api/sms-handoff` reply against the customer database is refused, so it cannot bypass the consent basis, do-not-contact and send-policy checks. Its read/state controls and the offline fixture contract remain available. Successful sends clear a thread's unanswered marker only when no newer inbound message arrived during the send; failed or withheld sends do not dismiss attention. ## Opt-out and help keywords An inbound message containing only STOP, STOPALL, UNSUBSCRIBE, CANCEL, END or QUIT, ignoring case and surrounding whitespace, opts the sender out of texts from the workspace. Sempera confirms once per inbound message: `You're unsubscribed from texts. Reply START to resubscribe.` Every later agent, campaign, tool or manual send is blocked until the person texts START, UNSTOP or YES. These restore texting and send a confirmation. YES restores only a person who is opted out; from anyone else it is an ordinary message for the agent. Replayed inbound messages do not send another confirmation or undo a newer consent decision. Punctuation and extra words are not stripped when matching. HELP and INFO return the business name and contact email or phone configured in Settings > Business, without changing consent. If no contact is configured, the business's receiving number is used. These keyword responses do not depend on whether an agent or a person holds the conversation. When the carrier reports that it already handled a keyword, Sempera does not double-reply. ## Numbers `GET /v1/sms/numbers` (`phone_numbers:read`) lists the numbers the agents this credential can read text from: numbers bound to an agent whose phone setup turns texting on, less any the carrier reports cannot send texts. Each row is `{"phone_number", "agent_id", "sms_config", "conversation_count"}`. `phone_number` is the bound number in E.164, `conversation_count` counts this workspace's text threads on that number, and `sms_config` is always null: Sempera keeps no per-number texting settings. `channel` defaults to `sms`; any other channel lists nothing, because Sempera offers no other messaging channel. ## Threads `GET /v1/sms/conversations` (`calls:read`) lists text threads on the agents this credential can read. | Parameter | Rule | | - | - | | `agent_id` | One agent (`agentId` is accepted too); omit for every agent in scope | | `page` | From 1 to 100000 (default 1) | | `pageSize` | 1 to 100 (default 50); `page_size` is accepted too | | `sortDir` | `desc` (default) or `asc`. Threads are ordered by when each thread was created, not by its latest message | The answer carries `pagination` beside `data`: `{"currentPage", "pageSize", "totalItems", "totalPages"}`. Each thread reads: | Field | Meaning | | - | - | | `id`, `agent_id` | The thread and the agent answering it | | `user_number`, `agent_number` | The texter's and the agent's numbers, last four digits only | | `handoff` | `ai` (the agent is answering), `human` (a person took it over) or `ended` | | `is_active` | False once `handoff` is `ended` | | `message_count`, `last_message`, `last_message_at` | The thread so far | | `created_at`, `updated_at` | When the thread began; and the time of its last message, or `created_at` when it has none | | `effective_channel` | Always `sms` | `variables`, `current_node_id`, `curr_pathway_id`, `pathway_tags`, `summary`, `citation_variables` and `disposition_tag` are always null: Sempera does not keep them for a text thread. An invalid `page`, `pageSize` or `sortDir` answers `400` with `{"data": null, "errors": [{"error": "bad_request", "message"}]}`. An agent the credential cannot read answers `404`. A missing credential or scope gets the standard error envelope ([Errors and limits](/errors-and-limits)). ### Delivery states When Sempera's servers have `SEMPERA_SMS_STATUS_CALLBACK_URL` set (the carrier-facing `/api/telephony/sms-status` address with its telephony token), every text the agent sends asks the carrier to report delivery there. The conversation drawer then shows each outbound text as `accepted` (the carrier took it), `sent` (handed to the phone network), `delivered` (the handset got it) or `not_delivered`. States only move forward, and a callback is matched to a message only inside the workspace that owns the sending number. An `undelivered` or `failed` state marks the thread not answered with a fixed sentence, never the carrier's own error text. Without the setting, texts stay `accepted`. ## Texts an agent sends on a call Two texts leave during or after a call, both from the agent's own bound number: the in-call text tool (Send a text message, among the agent's tools), which texts the caller a confirmation, an address or a link they asked for, and the Send SMS voicemail action, which texts the message when an answering machine picks up an outbound call. Both obey the same Send text messages setting as an answer to an inbound text. With texting off, or with no phone setup yet: * **The in-call text is refused and nothing is sent.** The agent is told that texting is off, so it can offer the details on the call instead. This holds in Test and Try it live as well as on a phone call. * **The voicemail text is not sent.** The call's record says the text was not sent because texting is off, where it records every other reason a voicemail text did not go (no number of the agent's own, a limit reached, sending not allowed on this server). The outbound batch then treats the call as a machine that got no message. ## Who answers a text An inbound text on a thread the agent holds (`handoff` is `ai`), on an agent whose texting is on, is answered by the agent itself: the same published version, with its own tools, knowledge and model, that answers the typed Test and the website chat. There is no separate texting prompt or model. The carrier is acknowledged at once and the answer is produced afterwards. * **One conversation per thread.** The first text opens a conversation on the version published at that moment, and every later text on the thread is answered by that same conversation, so a publish in the middle changes nothing for it. The conversation ends after 30 minutes without a text, when the agent ends it, or when the service restarts; the next text opens a new one on whatever is published then, and it does not repeat the opening line. A service holds at most 200 conversations at once, and the least recently texted one ends first. * **Texting off means the agent does not answer.** With texting off in the agent's phone setup, or with no phone setup yet, an inbound text is stored and shown on its thread like any other, the agent is not asked, and nothing is sent; the thread is flagged as not answered, with that reason, until a person replies or the agent answers a later text. Turned on, texting applies from the next text; a text stored while it was off is never answered later. * **A person owns a thread they took over.** While `handoff` is `human` or `ended` nothing is asked of the agent. If a person takes a thread over while the agent is composing an answer, that answer is withheld and never sent. * **One text.** An answer is the agent's own words with whitespace collapsed, cut to 480 characters at the end of a sentence, or at a word with an ellipsis. * **Tools are write-protected.** An answer's tool calls run under the same protection the typed Test uses, so they do not write to a real system. * **When the agent cannot answer, nothing is sent.** That covers an agent whose texting is off, an agent with no published version, a model that does not respond or cannot be bound, a turn that fails, a text with no words, and a number whose workspace does not own the agent. The reason is recorded on the caller's text, in one set sentence per kind of failure and never an engine's or a carrier's own error text, and the thread is flagged as not answered in the App until a person replies. There is no canned fallback reply: a fixed sentence sent from the business's own number would read like the agent answering. The API shows the thread's `handoff`; the reasons and the flag are in the App. * **A texter left waiting needs attention.** An answer the carrier did not deliver leaves the texter waiting too. Each texter left waiting is one urgent item on the agent's Needs attention list, with the reason, however many texts they send before someone answers, and it counts toward the owner's attention alert when that is turned on. Its link opens the thread. * **The thread in the App.** Opening a text conversation shows every message on the thread, oldest first: who sent it (the texter, the agent, or a person on the team), when, an answer that was not delivered, and why a text was not answered. # Code snippets Source: https://docs.sempera.org/snippets Versioned workspace logic a code tool pins; published versions never change. A snippet is a small piece of logic a workspace writes once and runs from any number of `code` tools: "the deposit is 20% of the quote", "a Toronto postal code starts with M". Snippets are versioned. A published version never changes, and a tool pins one published version, so what an agent runs stays fixed until someone changes the tool. ## What a snippet holds | Field | Rule | | - | - | | `name` | 2 to 64 lowercase letters, digits and underscores, starting with a letter (`deposit_amount`). Surrounding spaces are trimmed and capitals lowercased before the rule is checked. Unique in the workspace and fixed at create | | `description` | Optional. Whitespace is collapsed and text past 500 characters is cut, not refused. Changing it with `PATCH` takes effect at once and opens no draft | | `source` | One expression in Sempera's restricted transform language, the one Calculation tools use. It has no import, file or network primitive; source it cannot compile is refused with `400` | | `inputs` | Up to 20 named values the source reads: `{"name", "type", "description", "required"}`, or just a name as a string. An input's description is cut at 500 characters. Names are 1 to 40 lowercase letters, digits and underscores, starting with a letter; `now` is reserved. `type` is `string` (default), `number` or `boolean`; `required` defaults to `true` | | `output_schema` | Required. A subset of JSON Schema: `type`, `properties`, `required`, `additionalProperties`, `items`, `enum`, `minimum`, `maximum`, `minLength`, `maxLength`, `minItems`, `maxItems`, `description`, `title`, at most 8 levels deep and 16 KB. Any other keyword is refused rather than ignored | | `timeout_ms` | 250 to 5000, default 1000 | Every run happens in an isolated sandbox with the version's timeout. A result that does not match `output_schema` is an error, and the value is never returned to anyone, the agent included. ## Routes | Route | Scope | Notes | | - | - | - | | `GET /v1/snippets` | `tools:read` | Active snippets by name; `?status=all` adds archived ones. A standard list page (`limit`, `cursor`) | | `POST /v1/snippets` | `tools:write` | Creates the snippet and its first draft (version 1). `201`. Accepts `Idempotency-Key` | | `GET /v1/snippets/{snippet_id}` | `tools:read` | The snippet, every version newest first, and `used_by` (the tools that pin it) | | `PATCH /v1/snippets/{snippet_id}` | `tools:write` | Edits the open draft, or opens a new draft numbered after the highest version. A field you leave out keeps the draft's value, or else the latest published version's | | `POST /v1/snippets/{snippet_id}/publish` | `tools:write` | Freezes the draft as an immutable version. Accepts `Idempotency-Key` | | `GET /v1/snippets/{snippet_id}/versions/{version}` | `tools:read` | One version by number | | `POST /v1/snippets/{snippet_id}/test` | `tools:write` | Runs the draft or a version on sample input | | `DELETE /v1/snippets/{snippet_id}` | `tools:write` | Archives it; `?restore=1` restores it | Reading is open to every role. Creating, editing, publishing, archiving and testing need the `operator` or `owner` role. Ids look like `snip_0123456789abcdef`; a snippet of another workspace answers `404` exactly like one that never existed. A snippet reads `id`, `object` (`snippet`), `name`, `description`, `status` (`active` or `archived`), `archived_at`, `created_at`, `updated_at`, `created_by`, `latest_published` and `draft_version` (version numbers, or null). `GET /v1/snippets/{snippet_id}` adds `versions` and `used_by`, whose items are `{"id", "name", "label", "version"}` for each tool that pins it. A version reads `object` (`snippet_version`), `snippet_id`, `version`, `state` (`draft` or `published`), the body fields above, `content_hash`, `created_by`, `created_at`, `updated_at`, `published_by` and `published_at`; reading one version by number also carries `snippet_name` and `snippet_archived`. ## Create, publish, test ```http theme={null} POST /v1/snippets {"name": "deposit_amount", "source": "{'deposit': round(pct(quote, 20))}", "inputs": [{"name": "quote", "type": "number"}], "output_schema": {"type": "object", "properties": {"deposit": {"type": "number"}}, "required": ["deposit"]}} ``` `POST /v1/snippets/{id}/publish` takes no body. `POST /v1/snippets/{id}/test` takes `{"version": 1, "arguments": {"quote": 100}}`; `version` is a number or `"draft"`, and omitting it tests the draft. The answer is a `snippet_test` record: `object`, `snippet_id`, `version`, `state`, `status` (`succeeded` or `failed`), `input`, `output`, `error`, `error_code`, and on success `latency_ms` and `audit`. A failed run answers `409` with that same record, not the error envelope. Run-time `error_code` values are `arguments`, `timeout`, `sandbox`, `output_too_large`, `refused`, `schema` and `busy` (every sandbox slot stayed taken through a short wait, and nothing ran). ## Refusals | Status | When | | - | - | | `400` | A body field breaks a rule above, a version number is not a whole number, or a test's `arguments` is not an object | | `404` | No such snippet or version in this workspace, or a test of the draft when there is no draft | | `409` | The name is taken; the workspace already keeps 200 snippets or the snippet 500 versions; the snippet is archived; there is no draft to publish; the draft is identical to the latest version; someone else published or opened the draft at the same moment; an archive while a tool still pins it | | `503` | The deployment has no database to keep snippets in | ## Pinning a snippet from a tool A `code` tool names a snippet and one published version: ```http theme={null} POST /v1/tools {"definition": {"kind": "code", "name": "deposit", "label": "Deposit", "description": "Works out the deposit for a quote.", "when_to_use": "When the caller asks how much the deposit is.", "config": {"snippet_id": "snip_0123456789abcdef", "snippet_version": 1, "result_label": "deposit"}}} ``` Sempera fills in a frozen copy of that version (its source, inputs, schema, timeout and hash) and ignores any body the request carried, so the tool and every agent version that uses it run exactly what was published. The version must be published and the snippet not archived, and a `code` tool is always a tool that reads (`access: read`). Creating or changing a tool needs the `owner` role. # Supported clients Source: https://docs.sempera.org/supported-clients How you can connect today (any HTTP client, browsers, coding-agent clients over MCP), what Sempera has verified, what it has not, and what is not available. How you can connect to Sempera today, and how much of each road Sempera has actually proven. Read the status column before you plan around a client: "supported" and "guidance only" are different promises, and this page keeps them apart. ## What the statuses mean | Status | What it means | | - | - | | Supported | The road is part of the documented contract and works with any compliant client. | | Guidance only | Sempera serves a ready-made setup snippet for this client. Sempera has not recorded a connection made from the real client, so a snippet that fails is a bug to report, not a known-good path. | | Unverified | The server side exists and is tested against the published standards, but no real account has completed it. A failure is a bug to report, not a known-good path. | | Not available | It does not exist yet. Nothing on this page promises a date. | ## Ways to connect | Road | What you use | How you sign in | Status | | - | - | - | - | | Any HTTP client | The REST API under `/v1`, described by `https://api.sempera.org/openapi.json`. Curl, server code in any language, or a coding agent's own HTTP tool. | API key, sent as a bearer token | Supported | | A browser voice conversation | Your server calls `POST /v1/sessions`, then the page connects with the open-source realtime web client ([Web SDK](/web-sdk)). | A short-lived token for one room | Supported. No Sempera SDK package is published. | | A webhook receiver | Any HTTPS server that verifies `X-Sempera-Signature` ([Webhooks](/webhooks)). | The signature, with your endpoint's secret | Supported | | An AI assistant over MCP | `POST /v1/mcp`, approved on a consent page. See [AI assistants](#ai-assistants). | OAuth | Unverified | | A coding-agent client over MCP | `POST /v1/mcp`, with a setup snippet from `GET /v1/mcp/clients/{client}` or the `mcp_client_setup` tool. See the MCP section of [getting started](/getting-started#connecting-a-coding-agent-mcp). | API key, sent as a bearer token | Guidance only, per client below | ## Coding-agent clients over MCP Every client below receives the same endpoint and the same header; only the file or command that holds them differs. Pass the client's id to `GET /v1/mcp/clients/{client}` to get the exact snippet for it. | Client | Id | Setup snippet served | Connection verified from the real client | Status | | - | - | - | - | - | | Claude Code | `claude-code` | Yes, a shell command | Not recorded | Guidance only | | Claude Desktop | `claude-desktop` | Yes, a JSON config using a remote-server bridge | Not recorded | Guidance only | | Cursor | `cursor` | Yes, a JSON config | Not recorded | Guidance only | | VS Code | `vs-code` | Yes, a JSON config that prompts for the key | Not recorded | Guidance only | | Windsurf | `windsurf` | Yes, a JSON config | Not recorded | Guidance only | | Gemini CLI | `gemini-cli` | Yes, a JSON config | Not recorded | Guidance only | | Codex | `codex` | Yes, a TOML config | Not recorded | Guidance only | | Any other client | `other` | Yes, a plain HTTP request | Not recorded | Guidance only | The key you connect with needs the `agents:read` scope, and a CI key is refused ([Authentication and credentials](/authentication)). Treat the key like any other: put it in the client's secret store or an environment variable, never in a file you commit. ## AI assistants Each assistant gets its own grant, so ending one leaves the others working. None of these has been proven from a real account. A client moves out of Unverified only when a real account has signed in, listed tools, run an action, read a result and been revoked, with the date and app version recorded here. | Client | How it connects | Sign-in | Status | | - | - | - | - | | ChatGPT | Custom MCP server | OAuth | Unverified | | Claude | Custom connector | OAuth | Unverified | | Codex | MCP server in `config.toml` | OAuth, or a bearer key | Unverified | | Grok Bot | MCP server by URL | OAuth, if the app offers it: its docs do not say | Unverified | | Muse | Custom connector over REST | Scoped REST credential | Unverified | The phone tools themselves are listed in [MCP](/connect-mcp). Connecting a client does not give the phone agent that client's memory, chats or subscription. ### ChatGPT 1. At chatgpt.com/plugins, choose the plus button, then **Add custom MCP server**. 2. Enter `https://api.sempera.org/v1/mcp` and choose **OAuth**. Leave the client ID empty so it registers itself. 3. Create the plugin, install it, and approve the workspace and scopes on Sempera's consent page. The expected redirect is `https://chatgpt.com/connector_platform_oauth_redirect`, which the consent page shows as ChatGPT. To check on the first run: that ChatGPT uses registration and that this redirect is the one it sends. ### Claude 1. In Customize, Connectors, choose **Add custom connector** (on Team and Enterprise, an Owner adds it in Organization settings). 2. Enter `https://api.sempera.org/v1/mcp`. Under OAuth client choose **Register automatically**, because **Use Claude's published identity** needs Client Metadata Documents, which Sempera does not offer. 3. Connect, then approve on Sempera's consent page. Claude connects from its own cloud, not your device. The expected redirect is `https://claude.ai/api/mcp/auth_callback`. Claude Code uses the same server with a loopback redirect, which the consent page marks as an app on your own computer. ### Codex ```toml theme={null} [mcp_servers.sempera] url = "https://api.sempera.org/v1/mcp" ``` Then run `codex mcp login sempera --oauth-client-registration dcr`. Codex opens a browser and listens on a loopback port. To use a key instead, add `bearer_token_env_var = "SEMPERA_API_KEY"`, which Codex tries first. ### Grok Bot Add `https://api.sempera.org/v1/mcp` as a custom server by URL. Grok Bot's documentation does not say how a custom server signs in, so the first real connection decides it. If the app offers OAuth, it follows the same steps and appears on the consent page as an unverified app with its real redirect host. If it only takes a header, use a key from Connected AI clients on Settings, API keys. ### Muse Muse has no form for adding a server. Ask it to build a custom connector from `https://api.sempera.org/openapi.json`. 1. In Sempera, open Settings, Connected clients, and create a Muse credential with the scopes you want. 2. When Muse asks for the key, enter it in Muse's secure credential entry, never in the chat. 3. Muse sends it as `Authorization: Bearer sempera_sk_...` on the phone routes. Meta publishes no developer reference for this that Sempera could check, so these steps come from third-party guides and need proof in a real account. ## Not available today * **Published SDK packages.** The official TypeScript and Python libraries exist in source and are checked against the API contract, but are not published to package registries. See [SDKs](/sdks) for source installation and supported behaviour. Sempera does not verify clients produced by third-party generators. * **Directory listings and marketplace plugins.** Sempera does not offer a directory listing or a marketplace plugin for connecting. A setup snippet is the supported way to connect a coding agent. * **A separate test host.** There is one API hostname ([API overview](/api-overview)). Use a workspace you can afford to experiment in, and read the idempotency rules before you automate writes ([Errors and limits](/errors-and-limits)). ## Keeping this page honest When a client's connection has been checked from the real client, its row says so with the date and the client version. Until then the cell says "Not recorded", and a snippet is a starting point rather than a promise. # Tests and evals Source: https://docs.sempera.org/tests-and-evals Scripted test runs before deployment, evals over completed conversations. ## Tests Tests are deterministic, scripted scenarios run before deployment on Sempera's own battery (the same one the Sempera App's Test tab runs). * `POST /v1/tests` with `{"agent_id": "...", "scenarios": ["..."]}` (scenarios optional) starts a background run and answers `202` with a `test_run`. * `GET /v1/tests/{id}` reports status and, when finished, the per-scenario verdicts and the evidence they wrote. * `GET /v1/tests?agent_id=...` lists runs. A published version's verification gates are on the Agent (`gates`). Tests never weaken those gates; they are the same battery under a simpler surface. ## Testbed The Testbed takes one turn of a stored conversation and replays it against the step's current instruction and, if you give one, a candidate instruction, so you can see how a prompt change would have answered real callers before you make it. It is operator work under the same gate as standards: `run_local_test` on top of `tests:read` or `tests:write`, reads included. Nothing in the Testbed edits the agent; a candidate is returned as a proposal to apply as a reviewed change. * `GET /v1/testbed/situation?agent_id=...&conversation_id=...&turn=...` answers the `testbed_situation` at that turn: the step the conversation stood on, the step's current instruction, the original reply and whether the step has an instruction to edit. Add `step` to name the step yourself. A text-message thread has no steps and answers `400`. * `POST /v1/testbed/runs` starts a background run and answers `202` with a `testbed_run`. Body: `agent_id`, `conversation_id`, `turn`, optional `step`, optional `candidate_prompt` (up to 8000 characters), optional `expectations` (the same shape as a standard's) and optional `across_recent` (`true` for 5 situations, or a number from 1 to 20) to replay other recent conversations that stood on the same step. At most 20 expectations across all situations. One run per agent at a time: a simulation set or test battery already running answers `409`. Accepts `Idempotency-Key`. * `GET /v1/testbed/runs/{run_id}?agent_id=...` reports the run: its `state`, the version it ran on, each situation with the original reply beside the current and candidate results, a `summary` and, when a candidate was given, the `proposal`. * `POST /v1/testbed/standards` saves the situation at a turn as a standard and answers `201`. Body: `agent_id`, `conversation_id`, `turn`, optional `step`, `expectations` (1 to 8, as for any standard) and optional `name`. The new standard gates promotion like any other. Accepts `Idempotency-Key`. Another workspace's agent or run is refused as not found. The turn must be a nonnegative integer. Invalid situations or candidate instructions answer `validation_failed`; a conflicting simulation or test run answers `conflict`. Saved standards retain the promotion gates described below. ## Synthetic concurrent runs A synthetic concurrent run walks the built agent's own conversation graph with scripted routing decisions, several sessions at once, to show how the graph behaves under load. No model, voice, tool, webhook or business system is reached (`provider_calls` is always `0`), so a receipt proves graph behavior, not voice quality or model capacity. Same gate as the Testbed: `run_local_test` on top of `tests:write` to start and `tests:read` to read. * `POST /v1/agent-testing/tornado` runs one and answers `201` with the finished `run`. Body: `agent_id`, `synthetic` (must be `true`), optional `concurrency` (1 to 4, default 1), optional `repeats` (1 to 100, default 1) and `scenarios`, each a `name` and 1 to 20 `turns` of `next_node`, `expected_node`, `condition_met` and `caller_said`. At most 100 sessions in total. * `GET /v1/agent-testing/tornado/{run_id}?agent_id=...` reads a receipt again: `total`, `passed`, `failed`, the `service_version` it ran on, the `agent_sha256` of the build it walked and each session's `trace`. Runs spend credits like simulations: one simulation attempt per session is held before any session starts, the sessions that completed are charged (`credited_sessions`) and a run that fails or is cancelled gives the hold back. Too few credits answers `402 credits_exhausted` with nothing started. A script naming a step the build does not have answers `validation_failed`, a workspace already running one answers `validation_failed` too, and an unreadable service version answers `503 service_unavailable`. ## Standards A standard is a saved regression test for ONE step of an agent's conversation. It pins that step to a fixed situation (the caller's lines, and optionally the caller's number) and says what the step must do. It belongs to the agent and is the same standard on every version, so a change that breaks the step turns it red. Standards are operator work: every route below needs the `run_local_test` capability (operator or owner) on top of the `tests:read` or `tests:write` scope, reads included, because step names and per-attempt conversations are internals an owner surface never shows. A standard or a run in another workspace answers exactly like one that does not exist. * `GET /v1/standards?agent_id=...` lists the agent's standards. Each carries its `revision` (a short hash of what it asserts) and `last_result`, its newest result on the agent's CURRENT version, with `current: false` when the standard was edited after that result. The list also carries `version_number`, `blocks_promotion` and, when it is true, `gate`: the sentence that says which standards are red, stale or not yet run. * `POST /v1/standards` saves one and answers `201`. Body: `agent_id`, `name`, `step` (the step it is bound to), `situation` (`script`: 1 to 12 caller lines of up to 400 characters; optional `caller_phone`), `expectations` (1 to 8), optional `repeats` (1 to 5, default 1) and `enabled` (default true). An agent holds at most 50 standards. Accepts `Idempotency-Key`. * `PATCH /v1/standards/{standard_id}` changes one. The body is the whole standard again, with `agent_id`. * `DELETE /v1/standards/{standard_id}?agent_id=...` deletes one. * `POST /v1/standards/run` with `{"agent_id": "..."}` runs every enabled standard against the agent's current version as one background job and answers `202` with a `standards_run`. Add `standard_ids` to run only those (an unknown id answers `404`), and `timeout_s` to bound the run. A `version` other than the current one answers `409`: standards run on the current version only. One run holds at most 20 standards and 20 expectations; a larger agent names which to run. Accepts `Idempotency-Key`. * `GET /v1/standards/runs?agent_id=...` lists the stored runs, newest first (the 30 most recent are kept), each with the version it ran on, its `state`, the standards it was asked for and a result per standard. An expectation has a `kind`: | `kind` | Needs | Passes when | | - | - | - | | `judged` (default) | `intent`: the rule, in words | a wording judge decides the step followed the rule | | `says` | `pattern`: a regular expression, case-insensitive | what the agent said at the step matches | | `never_says` | `pattern` | what the agent said at the step does not match | | `routes_to` | `target`: a step name | the conversation goes on to that step | | `captures` | `target`: a variable name | the step captures that variable | `threshold` (above 0, at most 1, default 1) is the share of attempts that must meet the expectation; an attempt nobody could decide never counts as met. A standard's verdict is `passed`, `failed`, `unknown` (a rule nobody could decide), `error` (the attempt itself broke) or `incomplete` (the run stopped early). Only `passed` is green. **The gate.** An agent with enabled standards cannot be approved or promoted on a version where any of them has not been run and passed. A result counts only for the version it ran on and the revision it ran as: edit a standard, or publish a new version, and it must run again. An agent with no enabled standards is not affected. ## Test analytics * `GET /v1/test-analytics?agent_id=...` reads the stored runs and answers, per step of the conversation, how many attempts reached it (`visits`), how many failed there (`failures`), the `rate`, and whether there were enough attempts to trust it (`sufficient`). `weakest` is the step with the highest failure rate among those with enough attempts and at least one failure, or `null`. * `days` (1 to 365, default 30) sets the window and `min_samples` (1 to 1000, default 5) the attempts a step needs. A value outside the range answers `400`; it is never clamped. * `sources` says which runs were counted and which are not (evaluation runs and whole-scenario battery runs do not score individual steps). Same capability and scope as the standards reads. ## Evals Evals judge completed conversations against owner-defined criteria with a language model. They are analytics, never a release authority. * `POST /v1/evals` with `{"definition": {"name", "description", "kind": "binary"|"numeric", "instructions", "applies_to"}}` * `GET /v1/evals`, `GET /v1/evals/{id}`, `PATCH /v1/evals/{id}` (with `revision`), `DELETE /v1/evals/{id}` (archive) * `POST /v1/evals` and `PATCH` also take optional `pass_when` and `fail_when` (up to 1000 characters each), which the judge reads beside the instructions. Every agent named in `applies_to` must be one the caller can reach. * `POST /v1/evals/run` with `{"agent_id": "..."}` scores the agent's completed conversations that are not yet scored. Add `eval_id` to run one criterion of this workspace, and/or `call_ids` (1 to 200) to score only those calls. A run with `eval_id` answers `503` when no judging model is reachable, rather than storing `unknown` verdicts. * `GET /v1/evals/results?agent_id=...` reads the verdicts. * `GET /v1/evals/agents` lists this workspace's criteria as judges, each with its pass/fail/unknown counts and latest run. * `GET /v1/evals/test-cases` (optional `agent_id`) lists every call a judge can score (preview, simulator and scripted calls) with the verdicts this workspace's criteria gave it. * `GET /v1/evals/runs` lists the evaluation runs `POST /v1/evals/run` recorded for this workspace's agents, newest first: status (`completed`, or `partial` when a judge returned unknown), pass rate (unknown excluded), the judges and agent versions covered, who started it and how long it took. It takes `agent_id`, `status`, `since` and `until` (days, `2026-10-29`) and the standard `limit` and `cursor`. A run before this existed has no record and is not listed. * `GET /v1/evals/runs/{run_id}` returns one run with a result per call and judge (verdict, the judge's reasoning, whether it was scored in this run or carried over from an earlier verdict), how each result differs from the previous run of the same suite (the same agent and judges), and the body to start it again with `POST /v1/evals/run`. A run of another workspace answers `404`. * `GET /v1/evals/agent-templates` lists the judge templates Sempera ships (`sempera/judge-templates.json`). * `POST /v1/evals/quick-run` with `{"agent_id", "template_id", "limit"}` (limit 1 to 20, default 5) scores one template against the agent's most recent calls and saves nothing; `503` when no judging model is reachable. * `GET /v1/evals/user-templates` lists this workspace's saved suites; `POST /v1/evals/user-templates` with `{"name", "agent_id", "template_ids"}` saves one. These runs read the call transcript. Audio judges score only production calls, below. The console's Runs page offers Create run to owners and operators. Choose a workspace judge, then an agent it covers and the recorded calls to score. This uses `POST /v1/evals/run?workspace=` with explicit `agent_id`, `eval_id` and `call_ids`, then opens the returned `run_id`. It never places a phone call. If judging, admission or permissions refuse the request, the dialog retains the refusal and no successful result is invented. Recorded versions lists the selected agent's saved versions. These are not replays: each evaluation result retains the version of the call it scored. ### Production-call judging A judge can also be checked against stored production calls without saving a second copy of any call: * `GET /v1/evals/{eval_id}/production-calls?agent_id=...` (`evals:read` and `calls:read`) lists up to 200 recent completed calls of an agent the judge covers, with `truncated` when more exist. * `POST /v1/evals/{eval_id}/production-run` (`evals:write`, operator or owner) with `{"call_ids": [...]}` (1 to 10 completed calls) returns a verdict, score, rationale and reason per call. Nothing is saved and the result never satisfies a release gate. * `POST /v1/evals/{eval_id}/align` (`evals:write`, owner) with `{"revision", "corrections": [{"call_id", "verdict": "pass"|"fail", "explanation"}]}` proposes new wording (transcript judges) or a new threshold (audio judges) at the revision you read. Nothing is saved; apply a proposal with `PATCH /v1/evals/{id}`. Transcript judges read an imported corrected transcript when one exists, otherwise the original, and answer `503` when no independent judge is reachable. Audio judges (`mode: audio`) measure signal levels from the stored recording (16-bit PCM WAV, at most 30 minutes and 32 MiB): duration, energy, low-energy frames and clipping. They never infer meaning, and an unreadable, deleted, expired or altered recording is `unknown` with a reason. Each recording read is audited with ids only. A call recorded under Exclude from history is `404` and is never listed. Both runs and alignment spend Eval credits: one per selected call is held before any work, a run is charged only for calls that received a pass or fail verdict, and a failed alignment is refunded. Too few credits is `402 credits_exhausted` and nothing is scored. ## Experiments Experiments compare the published version of one agent with another verified, already-published version of that same agent. They use the existing conversation router and count the records it writes, not simulated traffic or projected results. Reads require `agents:read`. Creating, updating and stopping require `agents:write` and the owner-only `manage_experiments` capability. Every route is workspace and agent scoped; an unknown or out-of-scope agent or experiment returns `404`. * `GET /v2/agents/{uuid}/experiments` returns `data.experiments`, newest first. * `POST /v2/agents/{uuid}/experiments` starts an experiment and returns `201` with `data.experiment`. Send `variant_version_id` and `traffic_percentage` (an integer from 1 through 99). Optional `name` is 1 to 80 characters, defaulting to `API experiment`. `variant_percent` is an alias for `traffic_percentage`; do not send both. The current published version, a draft, another agent's version, or a version without its verified saved artifact is refused. * `GET /v2/agents/{uuid}/experiments/{experiment_id}` returns `data.experiment`, including its status, version identities, share and lifecycle timestamps. * `PATCH /v2/agents/{uuid}/experiments/{experiment_id}` changes `name` and/or `traffic_percentage` (or its alias). A changed share applies to new conversations. The compared versions cannot be changed. A stopped experiment is immutable. * `POST /v2/agents/{uuid}/experiments/{experiment_id}/stop` accepts no options and returns the stopped experiment. Repeating it is safe. New conversations return to baseline; conversations already open keep the version they started with. * `GET /v2/agents/{uuid}/experiments/{experiment_id}/results` returns `data` containing `experiment`, `arms`, `reading`, `measure`, `dispositions`, `served_on` and `not_served`. Each arm has conversation and outcome counts, `reached_result`, `result_rate` (percent), `average_turns` and `flagged`. Empty arms have null rates and averages, not made-up scores. `reading` explains the sample size and uncertainty; it does not pick a winner. Disposition scores are not counted. Only one experiment can run per agent (`409` for a second). A workspace can retain up to 100 experiments. Creation starts immediately. Scheduling (`run_hours`, `starts_at`, `ends_at`) and variant call quotas are not supported; these options and other unknown fields are rejected with `400`, never silently ignored. Without the customer database the routes return `503`, not an empty successful list. The `served_on` and `not_served` fields describe the server's current channel configuration. By default only new typed conversations in the App's Try it live participate. Phone routing participates only where Sempera's existing pinned-version call service is explicitly enabled; creating an experiment does not enable it. Calls on a hosted call service, SMS and website chat do not participate. The public agent preview endpoint does not opt into experiment assignment. Publishing a new baseline suspends assignment until the experiment is stopped. Assignment failures fall back to the published version. Experiments do not place calls, send messages, promote versions, change consent, or bypass safety checks, call caps or pinned-engine limits. The variant pins its prompt, model and settings; tools and knowledge remain current. Results remain readable after stopping and continue to reflect conversations that were already open. ## Production guardrails Guardrails compiled into a published version are read on the Agent detail (`guardrails` in the App; the same rules the compiler enforces). Continuous monitoring of live behaviour is the evals road, run on a schedule from your side. # Tools and integrations Source: https://docs.sempera.org/tools-and-integrations Tool definitions, test-mode execution, integrations and their connection lifecycle. **Tools** describe capabilities: what an Agent can do mid-call (look up a caller, quote a service, find openings, request a booking, take a message). **Integrations** describe connections: the provider account a tool reaches (a booking system, a calendar, a CRM, an MCP server, a custom HTTP service). Both are workspace-level resources. An Agent attaches them by id through its draft. ## Tools `POST /v1/tools` with `{"definition": {...}}`. A definition has a stable id (minted), `name`, `description`, `kind`, `parameters` (the input schema), `request` (method, URL, headers, body template), `auth` (a secret **reference**, `SEMPERA_SECRET_` held on Sempera's servers, never a value), timeouts and retry policy. `GET /v1/tools`, `GET /v1/tools/{id}`, `PATCH /v1/tools/{id}` (with the `revision` you read; a save built on an older revision is refused with `409 conflict`, `reason: stale_tool` and the revision on file), `DELETE /v1/tools/{id}` (archive; `?restore=1` to restore; an optional `?revision=` names the revision you read, and one that is no longer on file is refused with `409 conflict`, `reason: stale_tool` and the revision on file, with nothing archived or restored; without it the call behaves as before). ### Executing a tool `POST /v1/tools/{id}/execute` with `{"arguments": {...}, "environment": "FIXTURE"|"MOCK"|"SANDBOX"}` runs the tool in test mode and returns a `tool_execution` record: `id`, `input`, `output`, `status`, `error`, `latency_ms`, `started_at`, `request_id`. A tool that writes is never sent from here without `confirm_write: true`. `GET /v1/tools/{id}/executions` lists recent executions. Production executions happen inside conversations and appear on the Session's tool timeline, not here. ## Integrations `POST /v1/integrations` with `{"definition": {...}}`, `GET /v1/integrations`, `PATCH /v1/integrations/{id}` (with the `revision` you read; a save built on an older revision is refused with `409 conflict`, `reason: stale_connection` and the revision on file), `DELETE /v1/integrations/{id}` (archive; `?restore=1` to restore; an optional `?revision=` names the revision you read, and one that is no longer on file is refused with `409 conflict`, `reason: stale_connection` and the revision on file, with nothing archived or restored; without it the call behaves as before), and `POST /v1/integrations/{id}/verify`, which probes the connection with its real credentials and reports the result. `GET /v1/integrations/{integration_id}` reads one connection and `GET /v1/integrations/{integration_id}/actions` lists the actions it offers a tool (`integrations:read`); both answer in the `{"data": ..., "errors": null}` envelope. Connection lifecycle: `configured` -> `verification_required` -> `connected` -> (`degraded` | `error`) -> `disabled`. Provider credentials live as secret references on Sempera's servers and are never returned. Provider-specific APIs do not leak into this surface: a salon's booking system and a restaurant's POS are both "an integration" with capabilities behind tools. ## Tools home and native connections `/workspace/{workspace}/tools` has one top row: Tools, Analytics, Secrets, Connections (`?section=tools|analytics|secrets|connections`). Code snippets stay inside Tools. The former `/integrations` address redirects to Connections; the GitHub installation return continues to use its existing handoff. Create tool opens `/workspace/{workspace}/tools/create`, reuses the existing organization tool form and registry create route, then reads the saved tool back and opens its workspace detail. Creating never attaches or executes it. The same workspace membership and `configure_integration` capability as the agent-level form are required. Tool and integration listbox filters narrow the catalogue and analytics without changing the stored execution counts; integration identity uses the saved connector or OAuth connection reference. Create connection opens an anchored provider popover. Selecting an unconfigured provider explains the missing OAuth application credentials without creating a connection. Salesforce still exposes Production and Sandbox at that boundary. Once configured, the existing consent and token-exchange flow remains the only way to establish a connected account. Analytics reads recorded tool executions for the selected agent, including counts, success and latency. No executions is an empty state, not a sample chart. Secrets reuses `/v1/secrets`: workspace-encrypted values are write-only after save and resolved only when the owning workspace's tool runs. Create connection offers Google Calendar, Slack, Salesforce, HubSpot, Calendly, Cal.com and Notion. Each creation has its own registry ID and grant, so multiple accounts of the same provider are supported. A saved definition or declared action catalogue is not evidence that a provider was contacted. OAuth consent and server-side token exchange establish the connection; the row shows the account label, standing, granted scopes and last credential use. Choose the connection and individual actions in an agent's Apps section, then publish that draft. Native actions use real provider APIs through Sempera's existing connection service: | Provider | Actions | | - | - | | Google Calendar | Selected calendar availability/events; owner-enabled appointment changes | | Slack | List channels, post message | | Salesforce | Find/create lead, find/create contact | | HubSpot | Find/create contact, create note | | Calendly | Available times, single-use booking link | | Cal.com | Available slots, create booking | | Notion | Search, create page | A Slack or Notion connection names one destination, the channel or database id in `selected_resource` (`{"channel_id": "C0123456789"}` or `{"database_id": "..."}`). On a call, the agent posts only there: the model is not offered the choice, a different destination is refused and nothing is sent. A connection with no destination offers no post or page tool on calls until its owner chooses one in Connections. Triggers and alert rules keep the destination their owner configured. Writes follow the existing environment/write-permission policy and the agent's published tool selections. The execution evidence records status, timing and argument names, not credential values or customer record bodies. Native OAuth destinations are pinned; Salesforce uses the provider-issued instance URL, never a mutable registry address. Consent state is signed, single-use, bound to workspace, user and starting browser, and expires. Tokens are exchanged and refreshed server-side and encrypted using workspace/connection-bound associated data. No native access or refresh token is returned to the browser. ## Owner setup checklist This is the single checklist for these seven apps. Nothing is activated or deployed by adding the code. Without both client values and the existing OAuth encryption key, a row says **Not configured** and cannot claim success. * Set `SEMPERA_APP_ORIGINS` with the intended console origin first. For the production console, every row below uses the exact redirect URI `https://app.sempera.org/api/connectors/oauth/callback`. A separately deployed test console must register its exact origin plus `/api/connectors/oauth/callback` in a separate app or as an additional allowed redirect. No wildcards or workspace-specific redirect paths. Start and exchange use the same stored URI. * Provision `SEMPERA_SECRET_OAUTH_TOKEN_KEY` using the existing 32-byte key format described in `sempera/oauth_crypto.py`, through the deployment secret store, not the browser or repository. Keep it stable across instances and restarts. This is separate from each provider's OAuth client secret. * Create the apps below, register the exact redirect, grant the listed scopes or capabilities, and set both environment variables on Sempera's servers. | Provider and app registration | Exact production redirect URI | Requested scopes/capabilities | Server environment variables | | - | - | - | - | | Google Calendar: Google Cloud OAuth web application | `https://app.sempera.org/api/connectors/oauth/callback` | `openid`, `email`, `calendar.calendarlist.readonly`, `calendar.events.readonly`, `calendar.events.freebusy` under `https://www.googleapis.com/auth/` for calendar scopes; appointment changes request `calendar.events.owned` separately | `SEMPERA_OAUTH_GOOGLE_CLIENT_ID`, `SEMPERA_OAUTH_GOOGLE_CLIENT_SECRET` | | Slack: `https://api.slack.com/apps`, create app, OAuth & Permissions | `https://app.sempera.org/api/connectors/oauth/callback` | Slack's own bot scopes channels:read, groups:read and chat:write (not Sempera API scopes); invite the bot to channels it should use | `SEMPERA_OAUTH_SLACK_CLIENT_ID`, `SEMPERA_OAUTH_SLACK_CLIENT_SECRET` | | Salesforce: Setup, App Manager / External Client App Manager, OAuth web-server app | `https://app.sempera.org/api/connectors/oauth/callback` | `api`, `refresh_token`; enable authorization code and PKCE, allow the connecting user's CRM object permissions | `SEMPERA_OAUTH_SALESFORCE_CLIENT_ID`, `SEMPERA_OAUTH_SALESFORCE_CLIENT_SECRET` | | HubSpot: `https://developers.hubspot.com/`, developer account, public OAuth app | `https://app.sempera.org/api/connectors/oauth/callback` | `crm.objects.contacts.read`, `crm.objects.contacts.write` (contacts and associated notes) | `SEMPERA_OAUTH_HUBSPOT_CLIENT_ID`, `SEMPERA_OAUTH_HUBSPOT_CLIENT_SECRET` | | Calendly: `https://developer.calendly.com/`, register OAuth application | `https://app.sempera.org/api/connectors/oauth/callback` | `default`; authorize an account with event types and scheduling access | `SEMPERA_OAUTH_CALENDLY_CLIENT_ID`, `SEMPERA_OAUTH_CALENDLY_CLIENT_SECRET` | | Cal.com: Settings, Developer, OAuth, create OAuth client | `https://app.sempera.org/api/connectors/oauth/callback` | `READ_PROFILE`, `READ_BOOKING`; slots and booking creation use the public event-type endpoints | `SEMPERA_OAUTH_CALCOM_CLIENT_ID`, `SEMPERA_OAUTH_CALCOM_CLIENT_SECRET` | | Notion: `https://www.notion.so/profile/integrations`, public integration | `https://app.sempera.org/api/connectors/oauth/callback` | No scope parameter; enable Read content and Insert content, then share the intended pages/databases during consent | `SEMPERA_OAUTH_NOTION_CLIENT_ID`, `SEMPERA_OAUTH_NOTION_CLIENT_SECRET` | * Salesforce's modal selects Production (`login.salesforce.com`) or Sandbox (`test.salesforce.com`). Register/allow the client in the target organization. The saved token endpoint preserves that choice during refresh and revoke. * PKCE S256 is used for Salesforce, Calendly and Cal.com. Slack, HubSpot and Notion use their configured confidential-client authorization-code flow. If Slack token rotation is enabled, refresh follows the returned expiry. * Disconnect erases local token ciphertext and attempts provider revocation. Slack, Salesforce, HubSpot, Calendly and Notion have revocation handlers. Cal.com's OAuth implementation has no configured revocation endpoint: the UI explicitly asks the owner to remove access at Cal.com. A failed revocation is never represented as provider-confirmed. Salesforce, HubSpot, Calendly and Notion revoke this grant's own token even when another connection names the same account. Slack connections on the same Slack team can share one installation token, so Slack keeps the account-wide sibling safeguard keyed on the team id: its token is revoked only when no other live connection uses that team. Cal.com has no revocation and is never compared with other connections. * Salesforce grants refresh from `issued_at` plus a conservative ten-minute lifetime, and a refresh updates the saved instance URL. Legacy grants without an instance show a reconnect action, not Connected. Refreshes are serialized by grant id in Sempera's database, including callers in separate server processes. * Validate with disposable accounts: consent, account/scopes readback, an agent read, an approved write, automatic refresh, disconnect, and refusal on the next tool use. Automated fake-provider tests are not vendor certification; real consent remains unverified until these owner steps are completed. ## Hosted console connection routes These authenticated console routes use the workspace's one connection registry in Sempera's database when that database is the canonical client store. They do not create another registry or automatically import a file kept on Sempera's servers. Configuration needs the `configure_integration` capability and workspace-wide scope; checks need `run_local_test`. The database independently isolates the stored rows. Workspace status and billing entitlement are checked before use; an explicit `connectors.enabled=false` disables connections. Disconnect remains available for credential cleanup. | Method and literal path | Purpose | | - | - | | `GET /api/connectors/registry` | List this workspace's connections and usage. With `?cleanup=1`, a workspace whose connections are switched off can still read the list (`cleanup_only: true`), so a signed-in account can be disconnected | | `POST /api/connectors/registry/create` | Create a reference-only connection | | `POST /api/connectors/registry/update` | Save against the revision read | | `POST /api/connectors/registry/archive` | Reversible archive with attachment guard, not credential revocation | | `POST /api/connectors/probe` | MOCK definition check, or SANDBOX authorized native-provider read | | `GET /api/connectors` | Load an agent's reviewed attachments with its client scope | | `POST /api/connectors/calendar/resources` | Read subscribed Google calendars | | `POST /api/connectors/resources` | Read eligible Slack channels or Notion databases; `{id, cursor?}` with `next_cursor` pagination | | `GET /api/connectors/oauth/providers` | Provider configuration standing | | `POST /api/connectors/oauth/start` | Start browser-bound consent | | `GET /api/connectors/oauth/callback` | Consume consent once and save encrypted credentials | | `GET /api/connectors/oauth/status` | Read grant standing without tokens | | `POST /api/connectors/oauth/disconnect` | Forget local tokens and attempt supported remote revocation | SANDBOX here means a read-only authorization check, not a claim that the connected account is a vendor sandbox. A successful check records VERIFIED; a refused check records FAILED. Neither proves that every action is permitted by that account. MOCK never reads a token or records provider success. Slack's channel and Notion's database/title column are selected by the owner and read back from the authorized provider before saving `selected_resource`. They are content-bound into the published version. Missing destinations refuse runtime use. The model cannot pass a channel, database or title-column override. Slack link/media unfurling is disabled. Changing a destination requires a new publication; the hosted dispatch refuses a connection changed since publication. Hosted calls dispatch native actions through the existing signed `POST /worker/data` operation `connector.call`. Sempera's servers accept it only while this exact call is admitted and in progress, recheck the version hashes, workspace, entitlement and current connection, and run the normal connection service. Provider credentials stay on Sempera's servers, never on the call service that carries the audio. Unknown write responses are not retried. No new public `/v1` route or separate integration store is introduced here. # Triage issues Source: https://docs.sempera.org/triage Tracking a problem with the calls that show it, files, comments and links between issues. An issue is one problem your team is tracking with an agent: "callers asking for a colour correction are told we are closed". It carries a title, a severity and a category, a status on a small board, the calls that show the problem, attached files, links to other issues and an activity log. Every issue belongs to one workspace. An issue id of another workspace answers `404` exactly like one that never existed. Reads need `evals:read`. Writes need `evals:write` and the `operator` or `owner` role; marking an issue viewed needs `evals:write` only. These routes answer in the compatibility envelope: `{"data": ..., "errors": null}` on success and `{"data": null, "errors": [{"error", "message"}]}` on a refusal the route itself makes (`bad_request` 400, `not_found` 404, `conflict` 409). A missing credential, scope or role, a body that cannot be read as a JSON object, and an `agent_id` the credential cannot read (on create, and as the issues list filter) are refused with the standard error envelope ([Errors and limits](/errors-and-limits)). Lists page with `limit` and an opaque `next_cursor`; pass it back as `cursor` unchanged. The list routes refuse a query parameter they do not take; `GET /v1/triage/categories`, `GET /v1/triage/issues/{id}` and `GET .../affected` read none and ignore any you send. An issues list's cursor belongs to the filters and sort it was made with: change one between pages and the cursor is refused as invalid. ## Issues | Route | Notes | | - | - | | `GET /v1/triage/issues` | `{"items", "next_cursor"}`. `limit` 1 to 100 (default 50); `sort` `last_activity_at` (default), `created_at`, `severity` or `status`; `order` `desc` (default) or `asc`; filters `search` (title and description), `status`, `severity`, `category` (each may repeat), `owner_id`, `assignee_id`, `agent_id`, and `date_range` (`24h`, `7d`, `30d`, `90d`) over `date_field` (`last_activity_at` or `created_at`) | | `POST /v1/triage/issues` | Create. `201` | | `GET /v1/triage/issues/{id}` | One issue | | `PATCH /v1/triage/issues/{id}` | Change `title`, `description`, `status`, `severity`, `category`, `owner_id` or `assignee_id`. A status change is written to the activity log | | `PUT /v1/triage/issues/{id}/view` | Mark the issue viewed by this credential: `{"issue_id", "last_viewed_at"}` | | `GET /v1/triage/categories` | Every category this workspace's issues use, as `{"items", "next_cursor"}` (one unpaged list, `next_cursor` always null). An item is `id`, `name`, `org_id` and `created_at`, the date it was first used | ```http theme={null} POST /v1/triage/issues {"title": "Beard trim callers told we are closed", "severity": "high", "category": "booking", "agent_id": "northwind-barbers"} ``` | Field | Rule | | - | - | | `title` | Required, up to 200 characters | | `severity` | Required: `critical`, `high`, `medium` or `low` | | `category` | Required free text, up to 64 characters. There is no fixed list | | `description` | Up to 10000 characters | | `status` | `backlog`, `todo` (default), `in_progress`, `in_review`, `done` or `closed` | | `source` | Create only: `manual` (default), `automated` or `client` | | `agent_id` | Create only: an agent the credential can read | | `owner_id`, `assignee_id` | An active member of the workspace, or null | | `resource_links` | Create only: files to attach, each `{"resource_type": "file", "resource_id": ""}` | An issue reads `id`, `triage_id` (`T-1001`, numbered per workspace), `number`, `org_id`, the fields above except `resource_links` (which is never returned), `author_id`, `created_at`, `updated_at`, `last_activity_at`, `resource_count`, `flag_count`, `relation_count` and `has_unread_activity` (true until this credential marks the issue viewed, and again after any later activity). `external_link` and `latest_agent_session` are always null and `is_processing` is always false. Creating an issue is also the `issue.created` event your automation triggers can listen for (`GET /v1/automation/events`). ## Evidence: calls | Route | Notes | | - | - | | `GET /v1/triage/issues/{id}/flags` | The calls flagged onto the issue, newest first. `limit` 1 to 100 (default 50) and `cursor` | | `POST /v1/triage/issues/{id}/flags` | `{"call_id", "type", "note"}`; `type` is `call` (default), `quality`, `routing` or `other`; `note` up to 2000 characters. `201`, or `409` when the call is already flagged on this issue | | `PUT /v1/triage/issues/{id}/calls/{call_id}` | Link a call whether or not it is already linked (the idempotent form) | | `GET /v1/triage/issues/{id}/affected` | What the issue touches: `issue_id`, its `calls` and the `personas` of every agent involved. `pathways` is always empty and `skill_context` names only the issue's `agent_id`, its other fields being null: Sempera has no pathways, skills or dispatch routing | | `GET /v1/triage/flag-types` | The flag types this workspace has used, with `usage_count`, `last_used_at` and the latest non-empty note as `description`. `limit` 1 to 100 and `cursor` | `call_id` names a conversation of an agent the credential can read; one it cannot read, or that does not exist, answers `404`. A flag reads `issue_id`, `call_id`, `agent_id`, `type`, `note`, `attached_by_id`, `org_id` and `created_at`. ## Files, comments, activity and links | Route | Notes | | - | - | | `GET /v1/triage/issues/{id}/resources` | Attached files, newest first; `limit` 1 to 100. `status` is `unavailable` once the credential can no longer see the file. No `cursor` yet | | `POST /v1/triage/issues/{id}/resources` | `{"resource_type": "file", "resource_id": ""}`. `201`, or `200` with the existing attachment; `404` for a file id that is not one the credential can see | | `POST /v1/triage/issues/{id}/comments` | `{"detail": "..."}`, up to 10000 characters. `201` with the activity entry | | `GET /v1/triage/issues/{id}/activity` | Comments, status changes, attached calls and link changes, newest first. `limit` 1 to 100 (default 100) and `cursor` | | `GET /v1/triage/issues/{id}/relations` | Links to and from the issue. Each item names the other issue (`related_issue_id`, `related_triage_id`, `related_title`), the `relation_type` and `direction` (`outgoing` when this issue stored it). `limit` 1 to 100 and `cursor` | | `POST /v1/triage/issues/{id}/relations` | `{"related_issue_id", "relation_type"}` with `related_to`, `blocks` or `duplicate_of`. `201`; `404` when the related issue does not exist or belongs to another workspace; `409` when the same link exists (the reverse of a `related_to` is the same link); an issue cannot link to itself | | `DELETE /v1/triage/issues/{id}/relations/{relation_id}` | Remove a link from either side. `204` | File ids are the `id` values `GET /v1/files/list` returns (`knowledge:read`), the knowledge documents of the agents the credential can read. Only files can be attached today. # Web SDK Source: https://docs.sempera.org/web-sdk Connecting a browser to a session with a realtime media web client; no Sempera SDK package is published yet; the OpenAPI document is the machine-readable contract. There is no published Sempera SDK package yet: the TypeScript and Python clients in [TypeScript and Python SDKs](/sdks) are generated from the contract but are not on npm or PyPI. The OpenAPI document at `https://api.sempera.org/openapi.json` is the machine-readable contract, generated from the running route table. ## Connecting a browser to a Web voice session today 1. Your server calls `POST /v1/sessions` with your API key and receives `{"url", "token", "expires_at"}`, plus a `transport` field that names the realtime media protocol the `url` and `token` are for. 2. Your page connects with an open-source realtime-media web client for that protocol. This page does not name the client library or version Sempera has tried; write to [hello@sempera.org](mailto:hello@sempera.org) if you need them. The sketch below shows the shape any such client has. It is not a package to install: ```ts theme={null} // `connect` stands for your realtime-media client's connect call. `session` is the JSON your server returned. const room = await connect(session.url, session.token); await room.enableMicrophone(); ``` 3. The agent joins the room and speaks; audio tracks arrive as remote participants. When the conversation ends, disconnect and read `GET /v1/sessions/{id}` for the transcript. The token is scoped to one room and never lets the browser call the API. For `web_voice`, the join token expires after five minutes. `expires_at` is the token's actual expiry, not the maximum length of an already connected conversation. Keep your API key on your server and issue a session only after checking your own customer's identity and authorization. Compatibility note: `POST /v1/sessions` web voice join tokens now use a five-minute window instead of the media default. Request a fresh session if the visitor has not joined by `expires_at`. App Talk and simulator tokens are unchanged. This join window is separate from a connected call's duration. # Webhooks Source: https://docs.sempera.org/webhooks The outbound event contract (call.completed, call.outcomes, agent.published, test.completed, metric_alert.triggered, webhook.test), the payload of each, X-Sempera-Signature (HMAC-SHA256 over "{t}.{body}", 5-minute tolerance) with a verification recipe, retries, idempotency and ordering, the delivery log, and provider ingress. Two directions live under `/v1/webhooks`. * **Outbound events** are what Sempera sends to an HTTPS endpoint you register: signed JSON about calls, agents, test runs and alerts. This page is their contract: the events, the shape of each, how to verify a delivery, and what is and is not guaranteed. * **Provider ingress** is where the telephony carrier and the payment provider deliver to Sempera, each under its own proof. You never call these (see the end of this page). ## Outbound events ### The events | Category | Event | Sent when | Reaches | | - | - | - | - | | Call | `call.completed` | A phone call ended and was stored: the carrier's post-call report was ingested, or Sempera's own call service finished the call. An interpreted call is announced too | Endpoints subscribed to it | | Call | `call.outcomes` | The values you asked to capture were read from a finished call, or a published disposition ran on it | Endpoints subscribed to it | | Agent | `agent.published` | A new version of an agent was published, by the API or the App | Endpoints subscribed to it | | Test | `test.completed` | A test run (`POST /v1/tests`, or the App's Test bench) finished or crashed | Endpoints subscribed to it | | Alert | `metric_alert.triggered` | A metric alert rule fired, worsened or recovered ([Metric alerts](/alerts)) | Only the endpoint the rule names | | Endpoint | `webhook.test` | You called `POST /v1/webhooks/{id}/test` | Only that endpoint | | Batch | `batch.updated` | An outbound batch pass completed | Endpoints explicitly subscribed to it | `GET /v1/webhooks` lists your endpoints and, beside them, `events`: every event name you can subscribe to, in the order above. Every event in that list is one Sempera sends. Every event goes only to endpoints of the workspace that owns the call, agent or test run, and only to endpoints that are enabled, not deleted and subscribed to it. An endpoint never receives another workspace's event; the envelope's `workspace_id` names the workspace it came from. `metric_alert.triggered` is never fanned out by subscription: an endpoint that lists it hears it only when an alert rule of the same workspace names that endpoint as a notification target. `session.completed` is retired and was never sent: a preview session has no single end Sempera can announce. Asking for it is a `validation_failed` error that says so. Endpoints that stored it no longer list it; one that listed nothing else now lists no events and keeps its URL and secret. Use `call.completed` for phone calls. ### Registering an endpoint ```http theme={null} POST /v1/webhooks {"url": "https://example.com/sempera", "events": ["call.completed", "call.outcomes"], "secret": "<16 to 256 characters>"} ``` | Route | What it does | Needs | | - | - | - | | `GET /v1/webhooks` | Lists the workspace's endpoints (`limit` 1 to 200, default 50, and `cursor`), with `events` | `webhooks:read` | | `POST /v1/webhooks` | Creates one; `201`. Accepts `Idempotency-Key` | `webhooks:write`, owner role | | `GET /v1/webhooks/{id}` | Reads one | `webhooks:read` | | `PATCH /v1/webhooks/{id}` | Changes `url`, `events`, `enabled`, `secret` or `description`. Optional `expected_updated_at` makes the save conditional (see Editing together) | `webhooks:write`, owner role | | `DELETE /v1/webhooks/{id}` | Deletes it; it stops receiving at once | `webhooks:write`, owner role | | `GET /v1/webhooks/{id}/deliveries` | The delivery log (below) | `webhooks:read` | | `POST /v1/webhooks/{id}/test` | Sends one signed `webhook.test` now and answers with what your endpoint did | `webhooks:write`, owner role | An endpoint reads `id`, `object` (`webhook_endpoint`), `url`, `description`, `events`, `enabled`, `has_secret`, `workspace_id`, `created_at`, `updated_at` and `post_call_filter`. Leave `events` out on create and the endpoint subscribes to every event except `metric_alert.triggered` and `batch.updated`. * `url` is `https`, at most 2000 characters, with no user name or password and a valid port. A host inside a private network is refused when you save it, and again every time Sempera sends: the name is resolved and the connection is pinned to the address that was checked. Sempera never follows a redirect; a `3xx` answer is a failed delivery. * `secret` is required on create, 16 to 256 characters. It is write-only: stored encrypted, never returned; `has_secret` says whether one is set. Change it with `PATCH`. Never rely on the URL being secret. * A member below the owner role, or a key without the scope, cannot write; another workspace's endpoint answers `404` on every route above. ### Post-call targeting and resend Endpoints without a selector remain account-wide. On create, set `post_call_filter` to `{"phone_number":""}` or `{"call_id":""}` and `events` to `["call.completed"]` to narrow delivery. Selectors are immutable; replace the endpoint to change one. Another workspace's number or call is not found. `POST /v1/calls` accepts `webhook`, the URL of an enabled signed endpoint already registered in the workspace, with an empty `post_call_filter`. Only a confirmed dial creates a child subscription referencing that endpoint; it copies neither the URL nor the secret and is not another endpoint. Each delivery attempt checks the parent is enabled and not deleted and uses its current URL and signing secret. Pausing, deleting or rotating the parent therefore applies to its child subscriptions too. A workspace can have at most 1000 open child subscriptions. A child is removed once its call's `call.completed` is handed to the delivery log, when its parent is deleted, or two days after confirmation if the call was never announced. A busy, unanswered or declined line releases its hold at once, and repeating that request with the same `Idempotency-Key` returns the same answer without dialing again. A dial whose outcome is unknown keeps its hold. Overlapping subscriptions to the same parent produce one delivery, not duplicate deliveries. `webhook_events`, when provided, must be `["call.completed"]`. Other event options fail with a field-specific 400. Account and number subscriptions remain independent, so overlapping subscriptions can receive the same event. `POST /v1/webhooks/{id}/deliveries/{delivery_id}/resend` requires owner permission, `webhooks:write`, and `Idempotency-Key`. It reuses the signed, SSRF-guarded delivery road for a single attempt, with no retry sleeps, and records the result in the normal delivery log. Content availability is checked again immediately before sending. Resend receipts omit caller content, so a late receipt cannot restore content deleted while the request was in flight. The resend has a new event id and `resend_of` naming the original event. A paused endpoint or a delivery whose call content was deleted cannot resend. Retrying the same request key returns its saved result without sending again. ### Editing together Two owners can open the same endpoint and both save. Without a guard the later save wins and the earlier one is lost without a word. To avoid that, send the `updated_at` you read back with the edit: ```http theme={null} PATCH /v1/webhooks/{id} {"url": "https://example.com/sempera", "expected_updated_at": "2026-10-23T15:00:00+00:00"} ``` `expected_updated_at` is the endpoint's `updated_at`, sent back unchanged (a time with a zone). The endpoint is saved only if it still has that `updated_at`, and the check and the save are one step, so of two saves that carry the same value exactly one is applied. Every change `PATCH` takes is an edit and takes the same field: a new `url`, `events`, a pause or a resume (`enabled`), a new `description`, and a new `secret`. The field is a guard and is never stored on the endpoint. If another owner saved first, the answer is `409 conflict` with `reason: "stale_webhook"` and `updated_at`, the value now on file. Nothing is written: a new `secret` in that request is not stored either, and the error repeats no secret. Show the owner what changed, then either discard the edit or send it again with `expected_updated_at` set to that `updated_at` to save it over the newer version. A value that is not a time with a zone is `400 validation_failed` with `field: "expected_updated_at"`. An endpoint of another workspace, or one deleted meanwhile, is `404 not_found` and names no `updated_at`. Without the field a save behaves as it always did: the last save wins. ### The envelope Every delivery is one JSON object: | Field | Type | Meaning | | - | - | - | | `id` | string | `evt_` and 32 hex characters. Unique per event, and the same on every attempt to deliver it | | `object` | string | Always `event` | | `type` | string | The event name | | `created_at` | string | When the event was created, ISO 8601 with a `+00:00` offset. The same on every attempt | | `workspace_id` | string | The workspace the event belongs to | | `data` | object | The event's own fields (below) | The body is UTF-8 JSON, compact, with its keys sorted, and it is the same bytes on every attempt of one event. Parse it as JSON and never depend on key order or on a field you do not know: new optional fields ship on `/v1` without notice ([Migration and changelog](/changelog)). The examples below are indented for reading. ### Event data A field marked `always` is in every event of its type. Any other field is absent (never `null`) when it does not apply. `number or null` and `string or null` are the only nullable fields. #### `call.completed` Sent once for each finished phone call, after its record is stored. Two roads send it: * **A call the carrier handled**, when its post-call report is ingested and the call, its transcript and, when one was fetched, its recording are stored. * **A call Sempera's own call service answered**, when the call service's record of the call is stored. A pass announces these once a minute, so the event usually arrives within a minute or two of the call ending; a call not announced within a day of ending is not announced. `provider` is `sempera`. A call is announced once, whichever road sees it first. Both roads share one record of what was announced, keyed on every identity a call has (its `call_id`, the call service's session, the carrier's own call id), so a report the carrier repeats, a record read again, or one call seen by both roads sends nothing more. A test is never announced: Try it live in the browser, the typed Test and the simulator send no `call.completed`. `call.outcomes` for the same call carries the same `call_id`. The workspace's own Call completed triggers (`/v1/automation`) fire at the same moment, from the same announcement: once per call on either road, and never for a test. On a call Sempera's own call service answered, a trigger's `outcome` is the outcome the App's Conversations list shows for the call and its `summary` is empty. **An interpreted call** (Live translation, where it is switched on for a workspace: Sempera interprets between two people who do not share a language, such as your staff and a customer) is a real conversation, not a test, and is announced the same way, once, by the same pass. `provider` is `sempera`, `duration_s` is how long the session lasted, `recording_stored` is false because no audio of it is kept, and in `logs` each turn's `caller` is what the caller said (null on your staff's turns) and `say` is what Sempera said. It is a Session, not a phone Call: `GET /v1/sessions/{call_id}` reads it, on the `interpreted` channel, and `GET /v1/calls/{call_id}` answers `404`. A trigger's `direction` for it is `interpreted`, never `inbound` or `outbound`, and its `outcome` is the one the Conversations list shows. A session whose interpreter never started (nothing was interpreted) was never a call and is not announced. | Field | Type | Present | Meaning | | - | - | - | - | | `call_id` | string | always | The call. `GET /v1/calls/{call_id}` reads it (an interpreted call: `GET /v1/sessions/{call_id}`) | | `agent_id` | string | always | The agent that took the call | | `provider` | string | always | The provider label the carrier's post-call report named (`voice_provider` when it named none), or `sempera` when Sempera's own call service answered the call | | `duration_s` | number or null | always | Call length in seconds; null when it is not known | | `recording_stored` | boolean | always | Whether the call's recording was stored | | `logs` | array | only when the workspace turned on "Include logs in webhook payload" (Settings, Organization) | The per-turn log; each turn is `{"index": number, "caller": string or null, "say": string or null}` | | `metadata` | object | only when the agent's published version has metadata | The agent's own metadata (Settings, Metadata), such as the case ID your system matches on | ```json theme={null} { "id": "evt_0f3a9b7c1d2e4f5a8b6c7d8e9f001122", "object": "event", "type": "call.completed", "created_at": "2026-09-28T14:05:31.204118+00:00", "workspace_id": "11111111-1111-4111-8111-111111111111", "data": { "call_id": "call-0a1b2c3d-0000-4000-8000-000000000001", "agent_id": "northwind-barbers", "provider": "voice_provider", "duration_s": 150.0, "recording_stored": true, "metadata": {"case_id": "A-17"} } } ``` #### `call.outcomes` The post-call follow-up for one call, sent to the workspace's subscribed endpoints. It is sent when the agent's published outcomes are routed to the webhook, and whenever a published disposition ran on the call ([Dispositions](/dispositions)); a call with neither sends nothing. If no endpoint listens for it, the App shows the delivery as skipped, with the reason. The event arrives after the call is stored and read, normally after `call.completed`, and the two do not wait for each other: join them on `call_id`. | Field | Type | Present | Meaning | | - | - | - | - | | `agent_id` | string | always | The agent | | `call_id` | string | always | The call | | `version_number` | number or null | always | The version of the outcome definitions that read the call; null when the agent defines no outcomes | | `outcomes` | array | always | One row per outcome, each `{"key", "name", "type", "status", "value", "quote"}`: `type` is `boolean`, `number`, `choice` or `text`; `status` is `found`, `not_found` or `unavailable`; `quote` is the sentence from the call that supports a found `value`, otherwise null. Empty when only a disposition asked for the event, or after the call's transcript was deleted | | `metadata` | object | only when the agent's published version has metadata | The agent's own metadata | | `dispositions` | array | only when a published disposition ran on the call | One entry per disposition: `disposition_id`, `key`, `name`, `version_id`, `version_number`, `status` (`complete`, `partial`, `failed` or `not_evaluated`), `values` (each `key`, `label`, `state`, `value`, `rationale`, and `error` when it failed), `payload` and `completed_at` | ```json theme={null} { "id": "evt_5e6f7a8b9c0d4e1f8a2b3c4d5e6f7081", "object": "event", "type": "call.outcomes", "created_at": "2026-09-28T14:06:02.518774+00:00", "workspace_id": "11111111-1111-4111-8111-111111111111", "data": { "agent_id": "northwind-barbers", "call_id": "call-0a1b2c3d-0000-4000-8000-000000000001", "version_number": 2, "outcomes": [ {"key": "booked", "name": "Booked an appointment", "type": "boolean", "status": "found", "value": true, "quote": "Done, you are booked."}, {"key": "party", "name": "Party size", "type": "number", "status": "not_found", "value": null, "quote": null} ] } } ``` A disposition an outage left unfinished is finished by a later pass, and the event is then sent again for the same `call_id` with a new `id`: treat the later event as the fuller answer. #### `batch.updated` Sent after an outbound batch pass to endpoints explicitly subscribed to this event. | Field | Type | Present | Meaning | | - | - | - | - | | `batch_id` | string | always | The batch | | `state` | string | always | The batch's current state | | `summary` | object | always | Recipient counts by state | ```json theme={null} { "id": "evt_00000000000000000000000000000002", "object": "event", "type": "batch.updated", "created_at": "2026-09-28T14:06:02.518774+00:00", "workspace_id": "11111111-1111-4111-8111-111111111111", "data": {"batch_id": "batch-review", "state": "done", "summary": {"done": 1}} } ``` #### `agent.published` | Field | Type | Present | Meaning | | - | - | - | - | | `agent_id` | string | always | The agent | | `version` | object | always | The version just published, the same `agent_version` object `GET /v1/agents/{id}/versions` lists: `id`, `object` (`agent_version`), `agent_id`, `version_number` (integer), `created_at`, `spec_hash` and `compiled_artifact_id` | ```json theme={null} { "id": "evt_a1b2c3d4e5f60718293a4b5c6d7e8f90", "object": "event", "type": "agent.published", "created_at": "2026-09-28T13:40:12.003211+00:00", "workspace_id": "11111111-1111-4111-8111-111111111111", "data": { "agent_id": "northwind-barbers", "version": { "id": "av-000000000000000000000001", "object": "agent_version", "agent_id": "northwind-barbers", "version_number": 3, "created_at": "2026-09-28T13:40:11.912345Z", "spec_hash": "0000000000000000000000000000000000000000000000000000000000000000", "compiled_artifact_id": "ca-000000000000000000000001" } } } ``` #### `test.completed` Sent when a test run ends, including one that crashed. Read the run with `GET /v1/tests/{test_id}`; its reason text stays there and never rides a webhook. | Field | Type | Present | Meaning | | - | - | - | - | | `test_id` | string | always | The run | | `agent_id` | string | always | The agent under test | | `status` | string | always | `completed`, or `failed` when the run itself crashed | | `scope` | string | always | `full` or `scoped`; only a full run writes release evidence | | `outcome` | string or null | always | The verdict; null on a failed run | | `scenarios_scored` | number or null | always | Scenarios scored; null on a failed run | | `violations` | number or null | always | Violations found; null on a failed run | | `evidence` | boolean | always | Whether the run wrote release evidence | | `started_at` | string | always | When the run started | | `finished_at` | string | always | When it ended | ```json theme={null} { "id": "evt_3c4d5e6f708192a3b4c5d6e7f8091a2b", "object": "event", "type": "test.completed", "created_at": "2026-09-28T10:02:00.410902+00:00", "workspace_id": "11111111-1111-4111-8111-111111111111", "data": { "test_id": "job-0123456789ab", "agent_id": "northwind-barbers", "status": "completed", "scope": "full", "outcome": "pass", "scenarios_scored": 12, "violations": 0, "evidence": true, "started_at": "2026-09-28T10:00:00Z", "finished_at": "2026-09-28T10:02:00Z" } } ``` #### `metric_alert.triggered` Sent to the endpoint an alert rule names, when the rule fires, worsens or recovers ([Metric alerts](/alerts)). The rule's notification target must be an enabled endpoint of the same workspace. The first attempt is made while the rule is evaluated; a failed first attempt is retried in the background on the schedule below. | Field | Type | Present | Meaning | | - | - | - | - | | `rule_id` | string | always | The alert rule | | `rule_name` | string | always | Its name | | `metric` | string | always | The metric key the rule measures | | `kind` | string | always | `fired`, `worsened` or `recovered` | | `subject` | string | always | A one-line summary: `Alert: `, or `Cleared: ` on a recovery | | `message` | string | always | The message body, in plain sentences; its lines are joined with a newline | | `severity` | string | in practice on every alert; treat as optional | `info`, `warning` or `critical`; on a recovery, the severity the rule was at before it cleared | | `value_pct` | number | in practice on every alert; treat as optional | The share of calls measured, in percent | | `threshold_pct` | number | on `fired` and `worsened`; absent on `recovered` | The threshold crossed, in percent | ```json theme={null} { "id": "evt_9a8b7c6d5e4f30211a2b3c4d5e6f7a8b", "object": "event", "type": "metric_alert.triggered", "created_at": "2026-09-28T15:00:00.120004+00:00", "workspace_id": "11111111-1111-4111-8111-111111111111", "data": { "rule_id": "33333333-3333-4333-8333-333333333333", "rule_name": "Failed calls", "metric": "failed_calls", "kind": "fired", "subject": "Alert: Failed calls", "message": "Failed calls (Warning): Failed calls is 100% (3 of 3) over the last 60 minutes, at or above 50%.\nOpen Alerts in the Sempera App to see the rule and its trigger log.", "severity": "warning", "value_pct": 100.0, "threshold_pct": 50.0 } } ``` #### `webhook.test` Sent by `POST /v1/webhooks/{id}/test`, to that endpoint only, with one attempt and no retries. The route answers `{"object": "webhook_test", "endpoint_id", "event_id", "status", "attempts", "response_code", "error"}`, where `status` is `delivered` or `failed`, so you see at once what your endpoint did. | Field | Type | Present | Meaning | | - | - | - | - | | `endpoint_id` | string | always | The endpoint tested | | `actor` | string | always | Who asked for the test | ```json theme={null} { "id": "evt_0b1c2d3e4f5061728394a5b6c7d8e9f0", "object": "event", "type": "webhook.test", "created_at": "2026-09-28T12:00:00.000000+00:00", "workspace_id": "11111111-1111-4111-8111-111111111111", "data": { "endpoint_id": "22222222-2222-4222-8222-222222222222", "actor": "key:k-owner-a" } } ``` ## Signing and verifying Every delivery carries these headers: | Header | Meaning | | - | - | | `X-Sempera-Signature` | `t=,v1=` | | `X-Sempera-Event-Id` | The event's `id`. Unique per event and the same on every attempt | | `X-Sempera-Event` | The event type | | `Content-Type` | `application/json` | | `User-Agent` | `Sempera-Webhooks/1` | The signature is the lowercase hex HMAC-SHA256, keyed with your endpoint secret, of `"." + raw_body`, where `t` is the same number that appears in the header and `raw_body` is the exact bytes of the request body. Each attempt is signed at the moment it is sent, so `t` and `v1` differ between attempts of one event while the body and `X-Sempera-Event-Id` do not. An endpoint saved without a secret (`has_secret` is false; none can be created through the API today) is sent no signature header. An endpoint whose stored secret cannot be read is never sent an unsigned request: the attempt fails in the delivery log with a sentence saying so, and nothing reaches your URL. ### Verifying 1. Take the raw request body bytes, exactly as received, before parsing. 2. Parse `t` and `v1` from the header. 3. Reject if `t` is more than 300 seconds from your clock, in either direction. 4. Compute `HMAC_SHA256(secret, "." + raw_body)` as lowercase hex. 5. Compare with `v1` in constant time. 6. Drop an `X-Sempera-Event-Id` you have already processed. ```python Python theme={null} import hmac, hashlib, time def verify(secret: str, body: bytes, header: str, tolerance: int = 300) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) t = int(parts["t"]) if abs(time.time() - t) > tolerance: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts["v1"]) ``` ```js Node theme={null} import { createHmac, timingSafeEqual } from "node:crypto"; // body is the raw request body as a Buffer, exactly as received export function verify(secret, body, header, tolerance = 300) { const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2))); const t = Number(parts.t); if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > tolerance) return false; const expected = createHmac("sha256", secret).update(`${t}.`).update(body).digest("hex"); const given = Buffer.from(parts.v1 ?? "", "utf8"); return given.length === expected.length && timingSafeEqual(Buffer.from(expected, "utf8"), given); } ``` A known answer, so you can check your own implementation in any language: ```text theme={null} secret = whsec_vector_0123456789 t = 1790000000 body = {"created_at":"2026-09-28T12:00:00+00:00","data":{"actor":"vector","endpoint_id":"22222222-2222-4222-8222-222222222222"},"id":"evt_00000000000000000000000000000001","object":"event","type":"webhook.test","workspace_id":"11111111-1111-4111-8111-111111111111"} X-Sempera-Signature = t=1790000000,v1=1f4226699ca9fa61b71fbb57dbd2f57efa6788488437ee2e411ea4e3002be3c1 ``` ### Signed requests without a shared secret A workspace may also turn on JWT request signing (`GET` and `PATCH /v1/signing`, keys under `/v1/signing/keys`). Every delivery attempt then carries one more header, `X-Sempera-JWT` by default (the name and a prefix are configurable; the default value is `Bearer `): a short-lived RS256 token over the same request. Its claims are `iss`, `sub` (the workspace), `aud` (the endpoint's origin, or the audience you set), `iat`, `nbf`, `exp`, a fresh `jti` and `body_sha256`, the hex SHA-256 of the exact body bytes, plus any custom claims you set. Verify it against the public keys at `GET /v1/workspaces/{workspace_id}/.well-known/jwks.json`, which needs no credential. It adds to `X-Sempera-Signature`; it never replaces it. A trigger's webhook action (which names one of your endpoints and is signed with that endpoint's own secret) and a widget's chat webhooks ([Website widgets](/widgets), signed with the widget's own secret) use this same `X-Sempera-Signature` scheme with the `X-Sempera-Event-Id` and `X-Sempera-Event` headers. They are not events of this catalogue and carry their own bodies. A trigger's webhook action is tried on the same three-attempt schedule, and a restart of the service between its attempts does not end it: once the service is back, the next attempt is sent within about a minute and a half of falling due, with the same event id and body, to the endpoint the trigger names while it is still enabled. An attempt a restart cut off counts as one of the three, marked as not known to have arrived. If the service was down for more than 15 minutes after the event, the run shows as stalled and the owner can send it again (`POST /v1/automation/executions/{id}/retry`). ## Delivery, retries and idempotency Your endpoint answers `2xx` within 10 seconds to accept a delivery. Any other answer, a timeout, a refused connection or an address that is not allowed is a failed attempt, including `4xx` and a `3xx` (a redirect is never followed). Sempera reads at most 64 KiB of your response and ignores it. **Schedule.** An event is delivered in three attempts until the first `2xx`: the first at once, the second 30 seconds after the first attempt ends, the third 5 minutes after the second ends. After the third failure Sempera stops. `metric_alert.triggered` follows the same schedule (its first attempt is made while the rule is evaluated). `webhook.test` is one attempt. \*\*`call.outcomes` has its own ladder.\*\* Its deliveries are kept on disk as a ledger the App shows, and are made in up to three attempts: the second no sooner than 60 seconds after the first fails, the third no sooner than 120 seconds after the second, checked once a minute. If the third fails, the delivery stays failed until the workspace owner retries it from the App, which sends the same event again as one more attempt. **Retries are in memory.** The other events' retries are held by the process that sends them. If that process restarts between attempts, the remaining attempts are not sent, and nothing sends the event again by itself. Do not treat a webhook as the only record: reconcile against the API (`GET /v1/calls`, `GET /v1/agents/{id}/versions`, `GET /v1/tests/{id}`) when you need to be sure. **Idempotency.** Delivery is at least once. A receiver can see the same event twice, for example when your `2xx` arrives after the 10 second limit. `X-Sempera-Event-Id` (the envelope's `id`) is the idempotency key: it is unique per event and identical on every attempt and every retry of that event. Record the ids you have processed and drop repeats; keeping them for at least a day covers every retry Sempera makes on its own. The delivery log keeps one row per (endpoint, event, attempt). **Ordering.** No order is guaranteed, between different events or between an event and the retries of an older one. Each event is delivered on its own and every subscribed endpoint is sent independently, so a retried older event can arrive after a newer one. Order by `created_at` and join related events on `call_id` or `agent_id` instead of by arrival. ## The delivery log `GET /v1/webhooks/{id}/deliveries` lists the endpoint's most recent 500 attempts, newest first, as `{"object": "list", "data": [...], "has_more", "next_cursor"}` (`limit` 1 to 200, default 50, and `cursor`). Each row: | Field | Meaning | | - | - | | `id` | The log row | | `object` | `webhook_delivery` | | `event_id` | The event's `id`; all attempts of one event share it | | `event_type` | The event name | | `attempt` | 1 for the first attempt, then 2 and 3 | | `status` | `delivered` or `failed` | | `response_code` | Your endpoint's HTTP status; null when there was no answer | | `error` | A plain sentence for a failed attempt; empty when delivered | | `sent_at`, `created_at` | When the attempt was made and recorded | The log carries no request bodies. ## Inbound: provider ingress A few routes under `/v1/webhooks` exist so that Sempera's own service providers, the telephony carrier and the payment processor, can deliver events and call audio to Sempera. They are for those providers and not for you: you never call them, they are not part of the integration contract, and this page does not list them. Each one accepts only its provider's own proof: a signature over the request, a shared token compared in constant time, or a single-use token that expires within seconds. Repeating a payment event or a post-call report does not repeat its effect, and a phone number that is not bound to a workspace stores nothing. A Sempera API key is never accepted on an ingress route. The two that connect live call audio are public only on a deployment that has turned live call audio on; elsewhere they are closed like any other route. No other inbound routes exist. # Website widgets Source: https://docs.sempera.org/widgets Widgets, their web chat threads, and custom components shown mid-conversation. A widget puts an agent on a business's own website: a public voice demo any visitor can start, and a web chat that runs through it (the chat can be switched on only while the demo is). A widget always runs its own agent's published conversation. Widgets are created and configured in the Sempera App; the API reads them, reads their chat threads, and manages the custom components a widget shows. Every route here answers in the compatibility envelope `{"data": ..., "errors": null}`, except that an agent the credential cannot read, named by `agent_id` (or `agentId`) on `GET /v1/widgets` and `GET /v1/widget/threads`, answers `404` in the standard error envelope ([Errors and limits](/errors-and-limits)), like one that never existed. ## Widgets and threads | Route | Scope | Notes | | - | - | - | | `GET /v1/widgets` | `sessions:read` | The widgets on the agents this credential can read, oldest first; `agent_id` narrows to one agent | | `GET /v1/widget/threads` | `sessions:read` | Typed web chat conversations, newest first. `agent_id`, `limit` 1 to 100 (default 20), `sort` `desc` (default) or `asc`, and `cursor` (a numeric offset). Answers `{"threads", "next_cursor"}` | A widget reads `id`, `agent_id`, `label`, `allowed_domains`, `config` (its brand settings and the public demo's `demo_enabled`, `demo_daily_sessions` and `demo_max_seconds`), `sessions_last_24h` and `created_at`. `pathway_id`, `agent_prompt`, `messages_per_minute` and `updated_at` are always null on this read: a widget speaks for an agent, and its per-minute message limit (10 by default, 1 to 120, set in the App) is not returned here. A visitor over it is answered `429` with `retry_after_s`. A thread reads `id`, `widget_id`, `agent_id`, `created_at`, `visitor_id` and `origin`. This historical projection does not expose the bounded live conversation: `messages`, `ended_at` and `live_agent_handoff_at` remain null. An invalid `limit`, `cursor` or `sort` answers `400`. ## Editing together Widgets are saved in the Sempera App, and no route in this API saves one. Two owners can open the same widget and both save. Without a guard the later save would win and the earlier one would be lost without a word, so the App's save takes an optional `expected_updated_at`, the `updated_at` the screen read. The widget is saved only if it still has that `updated_at`, and the check and the save are one step, so of two saves that carry the same value exactly one is applied. If another owner saved first, the save is refused with `409` and `code: "stale_widget"`. Nothing is written and nothing is recorded in the audit log. The answer carries the widget now on file, with its `updated_at`, so the App can show what changed and the owner can discard the edit or save it over the newer version. A value that is not a time with a zone is `400` with `field: "expected_updated_at"`. A widget of another workspace, or one deleted meanwhile, is answered as a widget that does not exist and names no `updated_at`. A save without the field behaves as it always did: the last save wins. `GET /v1/widgets` leaves `updated_at` null, so a script that only reads widgets here has no token to send and nothing to trip over. ## Custom components A custom component is a frame from your own website that the web chat shows when the conversation reaches one step of the agent's published conversation: a booking calendar, a product card. The frame's URL receives values the conversation has collected, as query parameters. | Route | Scope | Notes | | - | - | - | | `GET /v1/widget/custom-components` | `sessions:read` | Every component on the widgets of the agents this credential can read, oldest first | | `POST /v1/widget/custom-components` | `agents:write` | Attach a component. `200` with the component | | `PATCH /v1/widget/custom-components/{id}` | `agents:write` | Change `widget_id` (moves the component to another of this workspace's widgets, where it counts against that widget's 20), `pathway_id`, `pathway_node`, `width`, `height`, `variables` or `iframe_url`. The whole component is checked again, so a change to `width` alone is refused with `400` when a later publish has removed the step or a variable it names | | `DELETE /v1/widget/custom-components/{id}` | `agents:write` | Remove it. `204` | Writes need the `owner` role: a widget speaks in the business's name to visitors nobody has met. ```http theme={null} POST /v1/widget/custom-components {"widget_id": "", "pathway_id": "northwind-barbers", "pathway_node": "", "width": "100%", "height": "480px", "variables": ["service"], "iframe_url": "https://example.com/booking"} ``` | Field | Rule | | - | - | | `widget_id` | One of this workspace's widgets | | `pathway_id` | Must be the widget's own agent id. The field name is kept for compatibility; a widget has no other conversation to run | | `pathway_node` | A step id in the agent's published conversation. A step a later publish removes stays stored and simply stops showing | | `width`, `height` | Whole pixels (`1px` to `9999px`) or a percentage (`1%` to `100%`) | | `variables` | Up to 20 names of values the agent's published conversation collects. Agent environment values (`env.*`) are refused. When a name is refused, the message lists up to ten of the names this agent collects | | `iframe_url` | An `https` address on a public host, up to 2000 characters, with no user name or password, and not the Sempera App itself | All seven fields are required on create. A component reads those fields plus `id`, `org_id`, `created_at` and `updated_at`. Each step shows at most one component per widget, and a widget holds at most 20. When the chat reaches the step, the visitor's browser loads `iframe_url` with each listed variable the conversation has a value for appended as a query parameter, replacing a same-named parameter already in the URL. Text is cut at 500 characters, `true` and `false` are written as words, and a value that is missing, a list or an object is left out. ## Live chats An owner can let a typed visitor ask for a person: the widget's "Let visitors ask for a person" setting in the App, off by default. While it is off the visitor sees no button and an ask is refused. A visitor who asks waits with the agent paused; they can go back to the agent at any time, and after 5 minutes with nobody claiming the chat it returns to the agent by itself. These two routes are the desk. They answer in the standard envelope ([Errors and limits](/errors-and-limits)), need the `work_callback` capability, and see only chats on the agent named by `agent_id`. * `GET /v1/widget-live-chats?agent_id=...` (`sessions:write`, operator role: the desk shows live visitors' words) answers `{"chats": [...], "lifetime": "chat"}`: each waiting or person-held chat with its `id`, `widget_id`, `agent_id`, `handoff` (`waiting` or `operator`), `revision`, `held_by_you`, `messages` and `expires_at`. * `POST /v1/widget-live-chats/{chat_id}?agent_id=...` (`sessions:write`) with `action` and `expected_revision` (the `revision` you read): `claim` a waiting chat, `reply` with `text` (up to 500 characters) to the chat you hold, `reclaim` a chat another member holds (the owner, or any member of the desk, so a chat is never stranded) or `resume` to return it to the agent. Answers the chat. A changed `revision`, or an action the chat's state does not allow, answers `409 conflict`; another agent's chat, or one that has ended, answers `404 not_found`. Live chats keep the chat's own lifetime and message limits. Their state is stored with the admitted session, isolated by workspace, and shared across servers. A server restart does not reset the five-minute wait or lose the person holding a chat. Expiry clears the conversation content; the bounded end reason remains briefly for visitor polling. What a person said is handed to the agent as conversation, never as instructions. The widget's "Every message" webhook still fires for each visitor message while a person holds the chat, with an empty `agent_reply` and `handoff` set to `waiting` or `operator`; every other message event carries `handoff: "agent"`. The "End of conversation" webhook's `messages` list each line with `role` `visitor`, `agent` or `operator`. Webhooks remain best-effort background delivery, not a durable delivery queue. ## Refusals A refusal these routes make is `{"data": null, "errors": [{"error", "message"}]}`: `bad_request` (400) for a field that breaks a rule above, `not_found` (404) for a widget or component that is not this workspace's, and `conflict` (409) for a second component on the same step or a 21st on one widget. A missing credential, scope or role gets the standard error envelope ([Errors and limits](/errors-and-limits)). # Workspaces, organizations and API keys Source: https://docs.sempera.org/workspaces How workspaces keep customers apart, and API key management. ## Workspaces A workspace is the boundary between one customer and another. Agents, tools, integrations, knowledge, calls, keys and webhooks belong to exactly one. `GET /v1/workspaces` lists the workspaces the credential can see (an API key sees exactly one). `GET /v1/workspaces/{id}` adds `your_role` and the agents in scope. `PATCH /v1/workspaces/{id}` renames (owner). ## Organizations An organization is the membership and billing umbrella over workspaces. `GET /v1/organizations` lists the caller's workspace organizations. `GET /v1/organizations/{organization_id}` reads one (`organizations:read`); an id the credential cannot see answers `404 not_found`, the same as an id that never existed. Membership and invitations are managed in the Sempera App. ### Reseller-funded child organizations `POST /v1/organizations` creates a child workspace under an existing reseller. It requires a signed-in reseller owner or admin, the session's CSRF token and an allowed Sempera App origin. Workspace API keys do not carry reseller authority and are refused, even with `organizations:write`. The JSON body contains `parent_organization_id` (the reseller partner id), `name`, `owner_email`, `external_ref` and optional `support_responsibility` (`partner` or `sempera`, default `partner`). Other fields are refused. The customer's owner must already have an active Sempera account with a verified email and cannot be reseller staff. An established customer receives an ownership invitation through the existing invitation flow. The parent needs an approved entitlement and an available customer slot. The child is always wholesale-funded: the reseller, not the child, is responsible for funding. Workspace creation and its parent link commit together. A new child returns 201. Reusing an `external_ref` under the same parent returns the existing child with 200, including when the parent's customer slots are now full. Keep the reference stable after a lost response; use a different reference for a different customer. The App generates a reference for retries within the open form when none is supplied. The response's `id` is the partner customer id; `workspace_id` is the child's workspace id. Manage existing children in the signed-in partner console: list and read customers, suspend or resume service, and read attributed usage for a date range. Those session-authenticated management operations are not part of this canonical API's published route contract. Recorded amounts are not credit balances. Each action checks the caller's current reseller role and the parent-child relationship. Creating a child gives reseller staff no membership in it and no access to its calls or recordings. Credit purchases, credit transfers and reseller price configuration are not enabled by this endpoint. The published credit offers remain catalogue-only. A usage allowance is not a purchased balance, and credits are not converted into minutes or cash. Credit settlement, reservation/consumption, reversals and price-floor enforcement must be implemented before these operations can be offered. ## Doug text Assistant These routes manage the workspace owner's text channel from their signed-in Sempera App session. API keys, including owner and CI keys, cannot use them. Reads require `workspaces:read`; writes require `workspaces:write`, owner authority and CSRF proof. New work requires an active workspace and Assistant entitlement. The channel is default off and is available only on a deployment configured to deliver messages; these routes do not activate a provider. | Route | Contract | | - | - | | `GET /v1/text-assistant/settings` | Returns `name`, `available`, `enabled`, `linked`, masked `mobile` or null, and `pending`. | | `PATCH /v1/text-assistant/settings` | Takes a boolean `enabled` and returns the settings. Disabling remains available without entitlement. | | `POST /v1/text-assistant/link` | Takes `mobile` in international E.164 format and sends a linking code. Returns `sent: true`, not a verified link. | | `POST /v1/text-assistant/link/confirm` | Takes the received `code` in the authenticated App session and returns settings after successful verification. | | `DELETE /v1/text-assistant/link` | Removes the owner's link and its messages, then returns settings. Unlinking remains available without entitlement. | | `GET /v1/text-assistant/thread` | Returns only this owner's messages in the current workspace, never another owner's thread. | The thread response contains `name`, `messages`, `has_more` and `next_before`. Each message contains `id`, `body`, nullable `answer`, `state` and timestamp `at`. A page contains up to 50 messages, oldest first within that page. Pass `before=` to fetch the next older page. The cursor must be a canonical UUID in this owner's workspace thread; an unknown or foreign cursor returns no messages. An invalid cursor answers `400 validation_failed`. Linking codes expire after ten minutes and allow at most five verification attempts. Requests are throttled, and an already linked number must be unlinked before changing it. Settings expose only the mobile number's last four digits. A disabled or unavailable channel does not imply a successful send. ## Service version A workspace pins the Sempera runtime release its new sessions start on, independent of any agent's published version. Only releases installed on this deployment are listed; today that is one. * `GET /v1/org/service-versions` (`workspaces:read`) answers `{"versions": [...]}`, each with `id`, `name` and `available`. * `GET /v1/org/service-version` (`workspaces:read`) answers the pin: `{"version": "...", "applies_to": "new_sessions"}`. A pin that cannot be read answers `503 service_unavailable`. * `PUT /v1/org/service-version` (`workspaces:write`, owner only) with `{"version": "...", "expected_version": "..."}` changes it. A stale `expected_version` or a release that is not installed answers `409 conflict`; a paused workspace answers `403 forbidden` on every route here. Sessions already running keep the release they started on. A call, preview or test whose workspace pin cannot be read, or names a release that is not installed, is refused before it starts rather than run on another release. ## Platform `GET /v1/platform` (scope `deploy:read`, the CI and certification credential) reports the deployment's build identity, its production posture and the current call-capacity snapshot. It never names a machine or a path. ## API keys `GET /v1/api-keys`, `POST /v1/api-keys`, `GET /v1/api-keys/{id}`, `PATCH /v1/api-keys/{id}`, `DELETE /v1/api-keys/{id}`. See [Authentication and credentials](/authentication) for kinds, roles and scopes. Creating, renaming and revoking need the owner role and `api_keys:write`; from the App they also carry the session's CSRF token. `PATCH` takes `{"name": "..."}` (1 to 120 characters) and changes only the label: the secret, scopes and role stay. A revoked key and a connection key (named `mcp:`, `oauth:` or `rest:`) answer `409 conflict`, and a new name cannot start with one of those prefixes.