> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stateset.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Put an agent on the phone

> Create a front-desk voice agent for acme-outdoors, hear it in your browser before it owns a phone number, route a number to it, place a DNC- and quiet-hours-gated outbound call, and read back the transcript and latency.

This guide takes one agent — a front desk for a store called `acme-outdoors` — from a JSON
config to a real phone call. The order is deliberate: you **talk to the agent in your browser
first**, with no phone number, no Twilio and no per-minute PSTN charges, and only then attach
a number and dial out. The [Build Your First Voice Agent](/stateset-voice/quickstart) quickstart
covers the wider setup (tenant config, webhooks, live supervision); this guide is the shortest
honest path from key to conversation.

## Before you start

|           |                                                                                                         |
| --------- | ------------------------------------------------------------------------------------------------------- |
| Base URL  | `https://api.voice.stateset.com/api/v1`                                                                 |
| Auth      | `Authorization: Bearer stsk_<tenant>_<key-id>_<secret>` — a tenant API key on every request             |
| Steps 1–2 | Need nothing but the key — the browser call runs without any telephony configured                       |
| Steps 3–4 | Need tenant Twilio credentials and an owned number (see [Configuration](/stateset-voice/configuration)) |

<Note>
  Keys are minted with `POST /api-keys` (`{ "label": "front-desk-prod" }`) and the response's
  `token` is returned **once** — only a hash is stored, and `GET /api-keys` never shows it again.
  A lost key is deleted and re-minted, not recovered. The examples assume yours is exported as
  `$STATESET_VOICE_API_KEY`.
</Note>

<Steps>
  <Step title="Create the front-desk agent">
    `POST /voice/agents` takes an `agent_key` (your stable handle), `name`, an optional
    `description`, a `config`, and `publish` — with `publish: true` version 1 goes live in the
    same call. Inside `config`: `agent_name`, `greeting`, `instructions`, `realtime_model`,
    `chat_model`, `voice`, and optional `tools[]` — JSON-schema functions fulfilled by **your**
    HTTPS backend, invoked mid-call.

    ```bash theme={null}
    curl --request POST "https://api.voice.stateset.com/api/v1/voice/agents" \
      --header "Authorization: Bearer $STATESET_VOICE_API_KEY" \
      --header "Content-Type: application/json" \
      --data '{
        "agent_key": "front-desk",
        "name": "Front Desk",
        "description": "Answers acme-outdoors calls: stock, hours, order status",
        "publish": true,
        "config": {
          "agent_name": "Front Desk",
          "greeting": "Thanks for calling Acme Outdoors! How can I help?",
          "instructions": "You answer for Acme Outdoors, an outdoor-gear store. Look orders up before promising anything, and offer a callback for warranty claims.",
          "realtime_model": "gpt-4o-realtime-preview",
          "chat_model": "gpt-4o-mini",
          "voice": "alloy",
          "tools": [
            {
              "name": "look_up_order",
              "description": "Fetch the status of an Acme Outdoors order.",
              "parameters": {
                "type": "object",
                "required": ["order_id"],
                "properties": { "order_id": { "type": "string" } }
              },
              "endpoint": {
                "method": "POST",
                "url": "https://api.acme-outdoors.example/voice/orders",
                "auth_header": "Bearer $ACME_INTERNAL_TOKEN",
                "timeout_ms": 3000
              }
            }
          ]
        }
      }'
    ```

    ```json Response theme={null}
    { "ok": true,
      "agent":   { "id": "0b7e6c2d-1f44-4a8e-9c3b-5d2e7f8a9b10", "agent_key": "front-desk", "name": "Front Desk" },
      "version": { "id": "6f1c2a3e-9b7d-4e21-a5c8-0d2f4b6e8a10", "status": "published", "version_number": 1 } }
    ```

    Keep `agent.id` — it is `$AGENT_ID` below. Validation is eager and strict: an unknown key
    anywhere in `config` is a `400`, never silently dropped, and each tool must pass the same
    gate (name charset, object JSON schema, `POST`-only public-HTTPS endpoint, 250–10000 ms
    timeout, at most 16 tools). Mid-call the server POSTs `{tool, arguments, call}` to your
    endpoint with an `X-Webhook-Signature` HMAC — verify it exactly like a
    [webhook](/stateset-voice/webhooks#verifying-signatures); the JSON reply becomes the tool
    result the model sees.

    <Note>
      Agents are **versioned**. `PATCH /voice/agents/{agent_id}` mints a *new* version, and that
      version is not live unless you send `publish: true` (or publish it later). If an edit seems
      to have no effect on calls, you shipped a draft — check which version is published before
      rewriting the prompt.
    </Note>
  </Step>

  <Step title="Talk to it in your browser — no phone number yet">
    This is the magic moment, and it needs nothing you don't already have. Browsers speak the
    identical media-stream protocol Twilio does, so `POST /voice/web-calls` gives a web page the
    entire pipeline — this agent, its tools, transfers, evals, billing — with `call_type: "web"`.
    All body fields are optional: `agent_key`, `agent_version_id` (pin an exact version), `greeting`.

    ```bash theme={null}
    curl --request POST "https://api.voice.stateset.com/api/v1/voice/web-calls" \
      --header "Authorization: Bearer $STATESET_VOICE_API_KEY" \
      --header "Content-Type: application/json" \
      --data '{ "agent_key": "front-desk" }'
    ```

    ```json Response (201) theme={null}
    { "call_id": "web-9f2c1a7e0d5b4c88a1e3f6b2c4d8e0aa",
      "websocket_url": "wss://api.voice.stateset.com/media-stream?stream_token=…&agent_key=front-desk",
      "start_message": { "event": "start", "start": {
          "streamSid": "web-9f2c…", "callSid": "web-9f2c…",
          "customParameters": { "stream_token": "…", "call_type": "web", "agent_key": "front-desk" } } },
      "audio": { "input_format": "ulaw_8000", "output_format": "ulaw_8000", "frame_ms": 20,
                 "message_shape": { "event": "media", "media": { "payload": "<base64 of 160 ulaw bytes>" } } },
      "server_events": { "clear": { "event": "clear", "streamSid": "web-9f2c…",
                 "on_receive": "stop playback and discard buffered output audio (barge-in)" } },
      "stop_message": { "event": "stop", "stop": {} },
      "expires_at": "2026-08-31T14:05:00Z" }
    ```

    The response is the whole contract: open `websocket_url`, send `start_message` verbatim (the
    signed `stream_token` inside it is the entire auth story — browsers cannot set WebSocket
    headers), then stream 20 ms base64 `ulaw_8000` mic frames in the `message_shape` and play the
    ones you receive. Honor the `clear` event by flushing playback, or the agent talks over the
    caller after barge-in. Send `stop_message` to hang up. The
    [Web SDK page](/stateset-voice/sdks) has a complete dependency-free client — mic capture,
    µ-law codec, paced playback, barge-in — you can paste into a page and click **Talk**.

    <Warning>
      The token expires in **\~5 minutes**: mint per call, never cache a `websocket_url`. And your
      tenant key must never reach the browser — mint the web call from your backend and forward the
      response body to the page. A `503` here means the server has no `STREAM_AUTH_SECRET` to sign
      tokens with.
    </Warning>

    Talk to it. Ask for order `A-1207` and watch your tool endpoint get hit. Iterate on the
    prompt with `PATCH` + `publish` until it sounds right — each browser call is the same
    pipeline the phone will use, so what you hear is what callers get.
  </Step>

  <Step title="Attach a phone number">
    A phone-number route maps a number to an agent, and inbound Twilio webhooks use it to
    resolve the owning tenant and agent deterministically. `direction` is one of `inbound`,
    `outbound`, `sip_inbound`, `sip_outbound`; `agent_version_id` optionally pins the route to an
    exact version, and `greeting` overrides the agent's own for this number.

    ```bash theme={null}
    curl --request POST "https://api.voice.stateset.com/api/v1/voice/phone-numbers" \
      --header "Authorization: Bearer $STATESET_VOICE_API_KEY" \
      --header "Content-Type: application/json" \
      --data '{
        "phone_number": "+15555550142",
        "direction": "inbound",
        "agent_id": "'$AGENT_ID'",
        "greeting": "Thanks for calling Acme Outdoors!"
      }'
    ```

    Call `+1 555 555 0142` from your own phone: the front desk answers with that greeting.
    `GET /voice/phone-numbers` lists routes, `PATCH`/`DELETE /voice/phone-numbers/{route_id}`
    re-point or remove one — re-pointing a route is how you roll a new agent version out to a
    number, or back.
  </Step>

  <Step title="Place a real outbound call, behind the gates">
    `POST /voice/calls` dials a real phone through the hardened outbound flow. Only `to` is
    required; `from` defaults to the tenant's number. `agent_key` resolves the currently
    published version, `agent_name` and `greeting` override presentation for this call,
    `machine_detection` is `off` (default), `enable`, or `detect_message_end`, and
    `max_attempts` (1–5) with `retry_on` (`no_answer`, `busy`, `failed`, `voicemail`) and
    `retry_backoff_seconds` (0–3600) make the platform redial on your terms. `metadata` (a JSON
    object ≤ 4 KB) is persisted on the attempt and inherited by retry children, so campaign
    attribution survives the whole chain; `org_id` tags multi-org tenants. Two fields are
    mutually exclusive with each other's plane: `agent_version_id` pins a version, while
    `config` carries a transient inline agent (same shape and validation as step 1, custom
    `tools` included) for this call only.

    ```bash theme={null}
    curl --request POST "https://api.voice.stateset.com/api/v1/voice/calls" \
      --header "Authorization: Bearer $STATESET_VOICE_API_KEY" \
      --header "Content-Type: application/json" \
      --header "Idempotency-Key: restock-ORD-10042-attempt-1" \
      --data '{
        "to": "+15555550187",
        "from": "+15555550142",
        "agent_key": "front-desk",
        "greeting": "Hi, this is the Acme Outdoors front desk — the tent poles you asked about are back in stock.",
        "machine_detection": "detect_message_end",
        "max_attempts": 2,
        "retry_on": ["no_answer", "busy"],
        "retry_backoff_seconds": 300,
        "metadata": { "campaign": "restock-notify", "order_id": "ORD-10042" }
      }'
    ```

    ```json Response theme={null}
    { "ok": true, "call_sid": "CA1234567890abcdef1234567890abcdef",
      "status": "queued", "to": "+15555550187", "from": "+15555550142" }
    ```

    <Warning>
      This rings a real phone, so two safety gates run **before** Twilio is touched and a blocked
      call answers `403` instead of dialling: the tenant's **do-not-call list**, and **TCPA
      quiet hours** — evaluated in the *called party's* timezone (derived from the NANP number),
      not yours, so a compliant-looking 2 pm call from your office can still be blocked at a 7 am
      destination. The `Idempotency-Key` is the third gate: the same key within 24 hours replays
      the stored outcome instead of dialling again, a concurrent duplicate gets `409` (retry
      shortly), and a deployment without a database answers `503` rather than risk a silent
      duplicate call. Derive the key from the thing you're calling about, not from a timestamp.
    </Warning>

    `ok: true, status: "queued"` means the call was placed, not answered — the transcript in the
    next step is where the outcome lands, and `answered_by` records what machine detection
    concluded.
  </Step>

  <Step title="Read back the transcript and the latency">
    Call logs are the durable record. Find the call by its `call_sid` (other filters:
    `status`, `outcome`, `direction`, `from_number`/`to_number`, `escalated`,
    `from_date`/`to_date`, `min_duration`/`max_duration`, `transcript_search`, plus
    `limit`/`offset` or keyset `cursor` pagination):

    ```bash theme={null}
    curl "https://api.voice.stateset.com/api/v1/call-logs?call_sid=CA1234567890abcdef1234567890abcdef" \
      --header "Authorization: Bearer $STATESET_VOICE_API_KEY"
    ```

    then fetch it whole — `GET /call-logs/{id}` returns status, direction, duration, the full
    turn-by-turn `transcript`, the `summary`, the normalized `outcome` (`answered`, `voicemail`,
    `no_answer`, `busy`, `failed`), `answered_by`, every `function_calls` entry your tool
    endpoint served, escalation fields and the `recording_url` when recording is on.

    ```bash theme={null}
    curl "https://api.voice.stateset.com/api/v1/call-logs/550e8400-e29b-41d4-a716-446655440000" \
      --header "Authorization: Bearer $STATESET_VOICE_API_KEY"
    ```

    Then ask the question every voice deployment eventually asks — *how fast does it feel?*

    ```bash theme={null}
    curl "https://api.voice.stateset.com/api/v1/call-logs/latency?window=24h&samples=5" \
      --header "Authorization: Bearer $STATESET_VOICE_API_KEY"
    ```

    ```json Response (stats excerpt) theme={null}
    { "ok": true,
      "stats": { "window": "24h", "call_sample_count": 38, "turn_sample_count": 412,
                 "p50_llm_first_token_ms": 480.0, "p95_llm_first_token_ms": 910.0,
                 "p50_e2e_first_audio_ms": 820.0, "p95_e2e_first_audio_ms": 1400.0,
                 "p99_e2e_first_audio_ms": 2100.0 },
      "slowest": [ { "call_sid": "CA12…", "llm_p95_ms": 2300.0, "e2e_p95_ms": 3100.0,
                     "turn_count": 14, "model": "gpt-4o-realtime-preview", "voice": "alloy" } ] }
    ```

    Two numbers matter: **LLM first token** (model think time) and **end-to-end first audio**
    (what the caller actually waits through). `window` accepts `24h`, `7d`, `30d` or `all`, and
    `slowest` hands you the exact calls to open when a percentile drifts —
    [Operations](/stateset-voice/operations) covers what to do about each cause.
  </Step>
</Steps>

## What you built

| Piece                                   | What it is                                                                                              | Where to go deeper                                                                                                                                           |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `front-desk` agent, version 1 published | Prompt, models, voice and a custom `look_up_order` tool, strictly validated                             | [Voice API map](/stateset-voice/api)                                                                                                                         |
| A browser call                          | `POST /voice/web-calls` — signed 5-minute `websocket_url` + `start_message`, same pipeline as the phone | [Web SDK](/stateset-voice/sdks)                                                                                                                              |
| A number route                          | `+1 555 555 0142` → `front-desk`, resolving inbound webhooks deterministically                          | [Quickstart](/stateset-voice/quickstart)                                                                                                                     |
| One gated outbound call                 | `POST /voice/calls` behind DNC, quiet hours and an idempotency key                                      | [`POST /make-call`](/api-reference/voice/calls/make-call-create) — same body, playground included                                                            |
| The record and the feel                 | Transcript, summary, outcome, and p50/p95/p99 latency                                                   | [`GET /call-logs/{id}`](/api-reference/voice/call_logs/call-logs-by-get) · [`GET /call-logs/latency`](/api-reference/voice/call_logs/call-logs-latency-list) |

## Troubleshooting

<AccordionGroup>
  <Accordion title="400 creating the agent, and the message names a field I didn't think I sent">
    `config` is validated strictly: an unknown or misspelled key is rejected, never dropped, so
    a typo like `instrutions` is a `400` instead of an agent that ignores half its prompt. Tools
    fail the same way for a non-object JSON schema, a non-HTTPS or non-public endpoint URL, a
    method other than POST, a `timeout_ms` outside 250–10000, or a seventeenth tool. A tool
    whose name collides with a built-in platform function is accepted but ignored in favor of
    the built-in — rename yours.
  </Accordion>

  <Accordion title="The web call worked, then the same websocket_url stopped connecting">
    That is the design: the signed token in the URL and `start_message` expires after about
    five minutes (`expires_at` says exactly when). Mint a fresh call per click. If the mint
    itself returns `503`, the server has no `STREAM_AUTH_SECRET`. If audio connects but the
    agent constantly interrupts itself, enable browser echo cancellation — without it the
    agent's own voice loops back in as caller speech — and remember browsers only start audio
    after a user gesture.
  </Accordion>

  <Accordion title="403 or 409 on POST /voice/calls">
    `403` is a policy block doing its job — the destination is on your do-not-call list, or it
    is outside TCPA quiet hours *in the called party's timezone*. Don't retry a `403` on a
    timer with the same intent; queue it for permitted hours. `409` means the same
    `Idempotency-Key` is in flight concurrently: wait briefly and retry, and the replayed
    outcome comes back instead of a second dial. A `503` on placement means the deployment has
    no database to record idempotency in — it refuses rather than risk a duplicate call.
  </Accordion>

  <Accordion title="I patched the agent but calls still use the old prompt">
    `PATCH /voice/agents/{agent_id}` created a **draft** version; calls resolve the *published*
    one (or whatever `agent_version_id` a route or call pinned). Publish the new version, and
    check your phone-number route isn't pinned to the old id. Inline `config` on a single call
    is different again — it is recorded as a draft under the reserved `transient-inline` agent
    and touches nothing else.
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Web SDK" icon="browser" href="/stateset-voice/sdks">
    The complete dependency-free browser client for step 2 — µ-law codec, paced playback, barge-in.
  </Card>

  <Card title="Webhooks" icon="webhook" href="/stateset-voice/webhooks">
    Get `voice.call.ended` and `voice.session.completed` pushed to you instead of polling call logs.
  </Card>

  <Card title="Voice API reference" icon="book" href="/api-reference/voice/overview">
    The 65 generated tenant endpoints with live playgrounds — calls, sessions, outcomes, automations.
  </Card>

  <Card title="Live supervision" icon="headset" href="/api-reference/voice/sessions/sessions-by-actions-by-create">
    Monitor, whisper, barge, escalate or end the call you just placed, while it is still running.
  </Card>
</CardGroup>
