{"openapi":"3.1.0","info":{"title":"AI Call Agent","version":"0.1.0"},"paths":{"/calls":{"post":{"tags":["calls"],"summary":"Start an AI voice call","description":"Reserve credits, persist task+call, then dial OR queue.\n\nTwo modes, gated by ``settings.call_queue_enabled``:\n\n* **Flag off (default)** — synchronous direct dial. Returns ``201``\n  with :class:`StartCallResponse`: the freshly-created call in status\n  ``dialing`` + the Twilio call_sid.\n* **Flag on** — the call is enqueued (status ``queued``, credits\n  reserved) and the single drainer dials it later at the global CPS\n  rate. Returns ``202`` with :class:`QueuedCallResponse`; clients poll\n  ``GET /calls/{call_id}`` until the status leaves ``queued``. There is\n  NO 409 in this path — the queue is unlimited; the drainer enforces\n  the per-customer cap at dial time.\n\nThe call's transcript / outcome is fetched later via ``GET /calls/{id}``\nin both modes.\n\nErrors (shared by both modes unless noted):\n  * 403 — freeform-brief path disabled for this key (freeform mode\n          only, i.e. no ``body.slots``); ``error_code=freeform_disabled``.\n          Gated by per-key metadata ``freeform_allowed`` or, when\n          absent, ``settings.freeform_calls_default``. The structured\n          ``slots`` branch is never subject to this gate.\n  * 402 — out of credits (poi-billing QuotaExceededError).\n  * 409 — per-customer concurrency cap reached (synchronous mode ONLY;\n          cancel an active call or raise the cap via PUT /users/me/limits).\n  * 400 — bad phone / empty brief.\n  * 422 — destination refused by policy; ``error_code=\n          emergency_code_blocked`` (911) or ``service_code_blocked``\n          (211, 311, 411, 511, 611, 711, 811). Never dialed, never\n          billed, holds for every account regardless of any permission\n          (#343).\n  * 422 — ``invalid_target_phone``: not 8-15 significant digits\n          (freeform path). Previously reached Twilio and came back as a\n          502 after a wasted credit reserve/release.\n  * 502 — Twilio dial failed (synchronous mode; after we released the\n          reservation).\n  * 503 — service in admin-set maintenance window. Active calls keep\n          running; only NEW calls are gated. Body carries the message\n          + optional resume_at so clients can show a useful banner.\n\nStructured-slots branch: when ``body.slots`` is present this delegates\nentirely to :func:`_start_call_structured` (deterministic slot gate +\nthe SAME ``_execute_skill_run`` helper ``POST /skills/{id}/run`` uses)\ninstead of the freeform path below. The freeform contract (no\n``slots``) is completely unchanged.","operationId":"start_call_calls_post","parameters":[{"name":"X-Gateway-Context","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Gateway-Context"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StartCallBody"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StartCallResponse"}}}},"202":{"description":"Call queued (call_queue_enabled). The drainer dials it later; poll GET /calls/{call_id}.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueuedCallResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["calls"],"summary":"List the customer's calls (most recent first)","operationId":"list_calls_calls_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":100,"title":"Limit"}},{"name":"X-Gateway-Context","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Gateway-Context"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CallDTO"},"title":"Response List Calls Calls Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/calls/{call_id}":{"get":{"tags":["calls"],"summary":"Get a single call by id","operationId":"get_call_calls__call_id__get","parameters":[{"name":"call_id","in":"path","required":true,"schema":{"type":"string","title":"Call Id"}},{"name":"X-Gateway-Context","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Gateway-Context"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CallDTO"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/calls/{call_id}/answer":{"post":{"tags":["calls"],"summary":"Async webhook callback for an ask_user pending on this call","description":"Customer's webhook responded asynchronously — fulfil the pending Future.\n\nAuth: must be the same customer that owns the call (via X-API-Key).\nCross-customer answer attempts are rejected with 404 to avoid leaking\nexistence.\n\nIdempotency: if the ask_user already timed out / call ended / no\nask_user was pending, we return ``delivered=false`` (200) so a\ncustomer retry doesn't 5xx them. The answer is silently dropped.\n\nBroker failure: a DB/transport error while claiming the pending row\nis classified by exception type (never by message string):\n\n* Transient (connection dropped, pool-checkout timeout, network\n  blip) → ``503`` with ``detail.error=\"transport_error\"`` — kept\n  distinct from the benign ``no_pending_request`` (200) so a caller\n  told to retry-on-transient doesn't mistake a blip for a closed\n  question (issue #216). Safe to retry with the same ``request_id``.\n* Non-retryable (schema mismatch, constraint/integrity, SQL-dialect\n  or programming error) → ``500`` with ``detail.error=\"server_error\"``\n  — retrying with the same request can't fix it, so we don't invite a\n  retry storm that would only fail until the ask_user times out.\n\nSee :func:`_is_non_retryable_db_error` for the split.\n\nRace: the customer might call us BEFORE we set up the pending\nFuture (their webhook responded synchronously already, OR they\nraced our outbound POST). The webhook transport sets the Future\nBEFORE issuing the HTTP POST so this race is closed in practice.","operationId":"submit_answer_calls__call_id__answer_post","parameters":[{"name":"call_id","in":"path","required":true,"schema":{"type":"string","title":"Call Id"}},{"name":"X-Gateway-Context","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Gateway-Context"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnswerBody"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnswerResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/calls/{call_id}/events":{"get":{"tags":["calls"],"summary":"Realtime call event stream (Server-Sent Events)","description":"SSE stream of realtime events for a call.\n\nAuth: must own the call (via X-API-Key). Cross-customer attempts\nreturn 404 to avoid leaking call existence.\n\nResume: supply a cursor and the stream backfills only events with\n``event_id > cursor`` from the durable ``call_events`` table BEFORE\nattaching to the live tail. Avoids gaps after a reconnect.\n\nTwo ways to pass it, because one of them does not always arrive:\n\n* ``Last-Event-ID`` — the standard SSE header.\n* ``?after_event_id=N`` — same meaning, in the URL.\n\nThe query parameter wins when both are present.\n\nWhy both. Behind ``voygr-api-gateway`` the **header cannot arrive**:\nthe gateway synthesizes its own header set and forwards only what a\nroute explicitly allowlists — the events route allowlists nothing.\nThe query string does arrive, but only on a gateway carrying its\n``_forward_path`` fix. Both the production gateway and (since\n2026-07-09) the test one do; the test image before that dropped the\nquery as well, which silently voided ``?limit`` and ``?status`` too.\n\nConsequence for clients: a resume cursor is an optimisation, never a\nguarantee — an old or misconfigured proxy can eat either spelling.\nCallers MUST stay correct when the backfill repeats. For ``ask_user``\nthat means de-duplicating on ``request_id`` locally.\n\nPhase B caveat: ``ask_user`` events delivered here are\nfire-and-forget — production agents should keep their webhook\nconfigured as a backup. Phase C lifts this caveat.\n\nStream lifecycle:\n  1. Auth + ownership check.\n  2. Per-pod stream cap check.\n  3. Backfill events (full history, or from ``Last-Event-ID``).\n  4. Attach broker subscriber.\n  5. Live-tail with 15s heartbeats; close 5s after ``outcome`` or\n     at the 30-min cap, whichever first.","operationId":"stream_call_events_calls__call_id__events_get","parameters":[{"name":"call_id","in":"path","required":true,"schema":{"type":"string","title":"Call Id"}},{"name":"after_event_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","maximum":9223372036854775807,"minimum":0},{"type":"null"}],"description":"Resume cursor: return only events with a greater event_id. Query-parameter twin of the Last-Event-ID header, for clients behind a proxy that drops it. Wins over the header when both are supplied.","title":"After Event Id"},"description":"Resume cursor: return only events with a greater event_id. Query-parameter twin of the Last-Event-ID header, for clients behind a proxy that drops it. Wins over the header when both are supplied."},{"name":"Last-Event-ID","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Last-Event-Id"}},{"name":"X-Gateway-Context","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Gateway-Context"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/calls/{call_id}/recording":{"get":{"tags":["calls"],"summary":"Stream the call's recording — Twilio mp3, or LiveKit egress OGG","description":"Download a call's recording — owner-scoped, with an admin superset.\n\nPrefers S3 (Twilio External Storage): when the call has a\n``RecordingSid`` and the object exists, we fetch it and **stream the\nbytes THROUGH the pod** as a normal 200. (A 302 redirect to a presigned\nURL is unusable here — the gateway strips the ``Location`` header, so\nclients can't follow it.) Falls back to the Twilio proxy stream below\nfor calls recorded before External Storage (or when the object hasn't\nlanded yet).\n\nLiveKit-transport calls (``call.recording_s3_key`` set by the egress\nstart/webhook path) never go through any of the above — they serve a\ndual-channel OGG straight from the egress S3 key instead, since a\nLiveKit call has no Twilio ``RecordingSid``/``CallSid`` to look up.\n\nTwilio stores the mp3 behind Basic auth (Account SID + Auth Token).\nThe browser cannot supply those creds without exposing them in JS,\nso we stream the file through this endpoint authenticated by the\ncaller's API key.\n\n**Access: owner-scoped, admin superset.** The call's OWNER may\ndownload the recording of their own call. An ADMIN key\n(``metadata['is_admin']='true'`` or the ``BOOTSTRAP_ADMIN_API_KEY``\nenv-var escape hatch) may fetch ANY call's recording as a superset —\nthe internal testing / bot-quality-review path. Any other requester —\na key asking for a call it does not own — sees a stealth 404 and does\nnot learn whether the call or its recording exists (same posture as\nPR #93's stealth pattern on operator endpoints). ``recording_declined``\ncalls 404 for everyone, owner included (2-party-consent, issue #205).\n\nAuth: see :func:`_resolve_recording_auth` — accepts\n``?api_key=...`` query (HTML5 ``<audio>`` tag), ``X-API-Key``\nheader (direct API), or ``X-Gateway-Context`` header\n(gateway-fronted path). The previous implementation only accepted\nthe first two and 401'd under ``GATEWAY_FRONTED=1`` because the\ngateway strips the raw ``X-API-Key`` before forwarding.\n\nTwilio fallback: if our DB doesn't yet have ``recording_url``\npersisted — the ``/recording-status`` webhook race lost, the\nPostgres row hasn't been read by this pod yet, or the call row\nlanded late — we look up the recording in Twilio's Recordings\nAPI by ``CallSid``. If Twilio has it, we stream it back AND\nlazy-persist for next time. If Twilio also has nothing (still\nprocessing), we return ``202 {\"status\":\"recording_pending\"}``\nso callers poll instead of giving up with 404.\n\nQuery params:\n  * ``?api_key=...`` — alternative to the ``X-API-Key`` header\n    (needed for ``<audio>`` tag in browsers).\n  * ``?download=true`` — emit ``Content-Disposition: attachment``\n    so browsers / curl save the file as ``call_{id}.{ext}``, where\n    the extension matches the served ``Content-Type`` (see below).\n    Without it the disposition is ``inline`` (HTML5 ``<audio>``\n    plays in-page).\n\nStatus codes:\n  * 200 — streaming the recording. The container is identified from\n          the bytes, so the ``Content-Type`` and the download\n          extension always agree: dual-channel WAV (``audio/wav``)\n          for Twilio External S3 Storage, OGG (``audio/ogg``) for a\n          livekit-transport egress object, MP3 (``audio/mpeg``) for\n          the legacy Twilio Recordings proxy. Unrecognised bytes\n          keep each branch's historical type rather than degrading\n          to ``octet-stream``.\n  * 202 — recording still processing (Twilio) or the egress upload\n          still finalizing (LiveKit); retry shortly.\n  * 401 — missing / invalid API key.\n  * 404 — requester is neither the call's owner nor an admin\n          (stealth), the call doesn't exist, the recording was\n          declined, the call has no ``call_sid`` (never dialed), or\n          Twilio no longer has the media.\n  * 500 — Twilio credentials are not configured server-side; doesn't\n          apply to the livekit-transport/egress branch above, which\n          never touches Twilio.\n  * 502 — Twilio answered the media fetch with an unexpected error;\n          retrying later may succeed.","operationId":"get_recording_calls__call_id__recording_get","parameters":[{"name":"call_id","in":"path","required":true,"schema":{"type":"string","title":"Call Id"}},{"name":"download","in":"query","required":false,"schema":{"type":"boolean","description":"When true, emit Content-Disposition: attachment so browsers/curl save the file as call_{id}.mp3.","default":false,"title":"Download"},"description":"When true, emit Content-Disposition: attachment so browsers/curl save the file as call_{id}.mp3."},{"name":"api_key","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Api Key"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}},{"name":"X-Gateway-Context","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Gateway-Context"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"202":{"description":"Recording still processing — Twilio finalizing the mp3, or the LiveKit egress upload finalizing the OGG. Retry in a few seconds.","content":{"application/json":{"example":{"status":"recording_pending"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/calls/{call_id}/transcript-merged":{"get":{"tags":["calls"],"summary":"Get the merged time-aligned transcript built post-call","description":"Return the merged transcript built by\n:mod:`callwright.engine.cascade.transcript_merger` after the call\nended.\n\nNormally it is built from the **dual-channel call recording**: both\nsides transcribed in one Deepgram batch pass with\n``multichannel=True``, channel 0 → ``hostess`` (the callee) and\nchannel 1 → ``bot``. Because both come off one file on one clock,\n``calibration_delta_ms`` is 0 and the timeline is exact. This is\nalso why the merged transcript is fuller than the live\n``transcript_full``: the callee channel carries audio the live\npipeline never transcribed — IVR phone trees, hold announcements,\nspeech from before the STT gate opened.\n\nTwo fallbacks exist for calls the recording can't answer for:\nper-channel live STT rows when the call has them, and — for\nhistorical single-channel recordings — bot utterances from the\nbatch pass calibrated against the live hostess transcript. Each\nturn's ``source`` field says which produced it.\n\nAuth: owner of the call only. Same posture as\n``GET /calls/{call_id}`` (which already returns\n``transcript_full`` to owners). Cross-customer requests return 404.\n\nStatus codes:\n  * 200 — merged transcript JSON. Shape documented in\n          :mod:`callwright.engine.cascade.transcript_merger`: in\n          short ``{\"version\", \"duration_ms\",\n          \"calibration_delta_ms\", \"turns\": [{\"speaker\", \"start_ms\",\n          \"end_ms\", \"text\", \"source\", \"words\"?,\n          \"overlap_with_next\"?, \"timing_estimated\"?}]}``, where\n          ``speaker`` is ``\"bot\"`` or ``\"hostess\"``.\n  * 202 — merger pending. Retry once SSE emits ``transcript_ready``,\n          or ask for it directly with\n          ``POST /calls/{call_id}/transcript-merged/rebuild``.\n  * 401 — missing / invalid API key (raised by ``verify_request``).\n  * 404 — call not found / not owned.","operationId":"get_transcript_merged_calls__call_id__transcript_merged_get","parameters":[{"name":"call_id","in":"path","required":true,"schema":{"type":"string","title":"Call Id"}},{"name":"X-Gateway-Context","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Gateway-Context"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"202":{"description":"Merger has not run yet — the /recording-status webhook either hasn't fired or the async merge task is still in flight (Deepgram batch ~3-5s).","content":{"application/json":{"example":{"status":"merger_pending"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/calls/{call_id}/transcript-merged/rebuild":{"post":{"tags":["calls"],"summary":"Rebuild the merged transcript for a finished call","description":"Rebuild this call's merged transcript now, and return it.\n\nThe automatic trigger is Twilio's ``/recording-status`` webhook,\nwhich fires once per call and cannot be replayed. So a call whose\nmerge failed at that moment — Deepgram unavailable, the S3 object\nnot yet written, or a bug since fixed in the merger — had no way\nback: its ``merged_transcript`` stayed null or stale forever. This\nis that way back, and it is what makes a fix worth anything to\ncalls that already happened.\n\nIdempotent: the transcript is rebuilt from the recording and\noverwrites whatever was stored. Callers may repeat the request\nfreely; each one costs a Deepgram batch pass over the recording, so\ndo not put it in a poll loop — use the ``transcript_ready`` SSE\nevent or ``GET /calls/{call_id}/transcript-merged`` for that.\n\nAuth and consent are the same gates as the GET: the call's owner\nonly, and never for a call whose callee declined recording.\n\nStatus codes:\n  * 200 — rebuilt. Body is the merged transcript, identical in\n          shape to ``GET /calls/{call_id}/transcript-merged``.\n  * 401 — missing / invalid API key (raised by ``verify_request``).\n  * 404 — call not found, not owned, or recording declined.\n  * 425 — the recording isn't available yet; retry shortly.\n  * 503 — the merge failed on a dependency; safe to retry.","operationId":"rebuild_transcript_merged_calls__call_id__transcript_merged_rebuild_post","parameters":[{"name":"call_id","in":"path","required":true,"schema":{"type":"string","title":"Call Id"}},{"name":"X-Gateway-Context","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Gateway-Context"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"425":{"description":"The recording isn't fetchable yet — the call may still be finalizing. Retry shortly.","content":{"application/json":{"example":{"status":"recording_pending"}}}},"503":{"description":"The merge could not be completed (a dependency was unavailable). Safe to retry.","content":{"application/json":{"example":{"status":"merge_failed"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/calls/{call_id}/cancel":{"post":{"tags":["calls"],"summary":"Cancel a call that hasn't completed","description":"Cancel a call: terminal outcome, credits settled, leg hung up.\n\nSince #508 this writes a terminal ``failed_cancelled`` outcome,\nreleases the local reservation, settles the gateway hold, and tears\ndown the telephony leg if one is up (best-effort — a carrier refusal\nis logged, and does not take away the cancel).\n\nBilling: free while nobody has been reached; a call already\nIN_PROGRESS is charged the normal per-call rate, because the agent has\nbeen talking on the customer's behalf and cancelling does not undo\nwhat it did.\n\nReturns ``cancelled=False`` for unknown / not-yours / already-final\ncalls — never 404, so an idempotent retry is safe. Also ``False``\nwhen the call reached its own outcome first: that verdict stands.","operationId":"cancel_call_calls__call_id__cancel_post","parameters":[{"name":"call_id","in":"path","required":true,"schema":{"type":"string","title":"Call Id"}},{"name":"X-Gateway-Context","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Gateway-Context"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CancelResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/skills":{"get":{"tags":["skills"],"summary":"List registered AI-agent skills","description":"Public — no auth.\n\nThe list is short and deterministic; agents fetch it once at boot\nto discover what they can call. Per the plan, no per-IP rate limit\nin Phase A v1; CDN/Cloudflare can be added if abuse appears.","operationId":"list_registered_skills_skills_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/SkillSummary"},"type":"array","title":"Response List Registered Skills Skills Get"}}}}}}},"/skills/{skill_id}/manifest":{"get":{"tags":["skills"],"summary":"Get the input schema + capability manifest for a skill","description":"Public — describes the skill's input contract and phase-level capabilities.\n\nManifest is **fully public**: it cannot consult the API key store,\nso it cannot return per-customer flags. Customer-specific channel\nvalidation (e.g. webhook configured?) happens only on\n``POST /skills/{skill_id}/run``.","operationId":"get_skill_manifest_skills__skill_id__manifest_get","parameters":[{"name":"skill_id","in":"path","required":true,"schema":{"type":"string","title":"Skill Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Get Skill Manifest Skills  Skill Id  Manifest Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/skills/{skill_id}/run":{"post":{"tags":["skills"],"summary":"[DEPRECATED] Run a skill — alias of POST /calls' structured slots path","description":"DEPRECATED — officially-deprecated alias of ``POST /calls``'\nstructured ``slots`` path (both eventually call ``_execute_skill_run``;\nsee ``routes.calls._start_call_structured``). Every response this\nendpoint produces — success AND every error path below, including the\nidempotency dispatch table — MUST carry ``Deprecation: true`` and\n``Link: </calls>; rel=\"successor-version\"``.\n\nImplementation note on HOW headers reach every path: setting headers\non the FastAPI-injected ``http_response`` only works for the\nsuccessful-return path — FastAPI copies that object's headers onto the\noutgoing response when the handler returns normally. Once an\n``HTTPException`` is raised, FastAPI's exception middleware builds a\n*fresh* response from ``exc.headers`` and never looks at the injected\n``Response`` again, so headers set on it before the raise are silently\ndropped. ``_execute_skill_run`` (plus the idempotency dispatch below)\nhas ~15 raise sites — threading header-setting through each individually\nwould be a large, error-prone diff. Instead this thin wrapper does both\nin one place: set the headers on ``http_response`` up front (covers\nevery success — sync 201, queued 202, replay), then catch\n``HTTPException`` at the outermost scope and re-raise a copy with the\ntwo headers merged into ``exc.headers`` (preserving any headers the\noriginal raise already set, e.g. ``Retry-After`` on\n``idempotency_in_progress``). This is the smallest change that\nguarantees full coverage without touching ``_execute_skill_run`` or\nany individual raise site.","operationId":"run_skill_skills__skill_id__run_post","deprecated":true,"parameters":[{"name":"skill_id","in":"path","required":true,"schema":{"type":"string","title":"Skill Id"}},{"name":"Idempotency-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Idempotency-Key"}},{"name":"X-Gateway-Context","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Gateway-Context"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Body"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillRunResponse"}}}},"202":{"description":"Call queued (call_queue_enabled). The drainer dials it later; poll status_url.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillRunResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/booking/bookings":{"get":{"tags":["bookings"],"summary":"List the customer's bookings (most recent first)","description":"Return bookings owned by the caller (matched by ``X-API-Key``).\n\n* ``status`` (optional) — narrow to one ``BookingStatus`` value.\n  Invalid values return ``400`` with the allow-list in the message\n  so callers can recover programmatically.\n* ``limit`` — bound between 1 and 100. Defaults to 20 to keep the\n  MCP ``list_bookings`` tool response small enough to fit in a\n  single LLM context window.\n\nTelegram-bot bookings are filtered out by the repository's\n``customer_id`` predicate — they cannot leak through this surface.","operationId":"list_bookings_v1_booking_bookings_get","parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by booking status. One of: active, pending_user_confirm, past, cancelled. Omit to return bookings regardless of status.","title":"Status"},"description":"Filter by booking status. One of: active, pending_user_confirm, past, cancelled. Omit to return bookings regardless of status."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":20,"title":"Limit"}},{"name":"X-Gateway-Context","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Gateway-Context"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/BookingDTO"},"title":"Response List Bookings V1 Booking Bookings Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/booking/bookings/{booking_id}":{"get":{"tags":["bookings"],"summary":"Get a single booking by id","description":"Single-booking lookup, scoped to caller's customer_id.\n\nReturns 404 for both \"no such booking\" and \"booking belongs to a\ndifferent customer\" — the response shape is intentionally identical\nso existence cannot be inferred from the status code or body.","operationId":"get_booking_v1_booking_bookings__booking_id__get","parameters":[{"name":"booking_id","in":"path","required":true,"schema":{"type":"string","title":"Booking Id"}},{"name":"X-Gateway-Context","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Gateway-Context"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingDTO"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/users/me":{"get":{"tags":["users"],"summary":"Current user profile + quota","description":"Self-view. Backed by the cached ``APIKeyData`` plus three\nlightweight feeds the webapp uses: the admin role flag, the current\nmaintenance state, and the customer's outstanding credit hold. All\nare cheap (admin = metadata read; maintenance = 5-second cached\nPostgres lookup; hold = one indexed SUM over the customer's\nnon-terminal calls).","operationId":"me_users_me_get","parameters":[{"name":"X-Gateway-Context","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Gateway-Context"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeDTO"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/users/me/webhook":{"put":{"tags":["users"],"summary":"Set the ask_user webhook URL (optionally rotate the HMAC secret)","description":"Configure the endpoint our platform POSTs to when Gemini calls\n``ask_user`` mid-call. Only HTTPS is accepted — in-flight webhook\npayloads include the booking context + HMAC header, which we refuse\nto transmit over plaintext.\n\nSecrets rotate by default. Keeping an older secret alive across a\nrotation would let an attacker who captured the HMAC key earlier\ncontinue signing; rotating as-you-configure is the safer default.","operationId":"set_webhook_users_me_webhook_put","parameters":[{"name":"X-Gateway-Context","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Gateway-Context"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetWebhookRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetWebhookResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["users"],"summary":"Remove the ask_user webhook URL and secret","description":"Remove the webhook configuration.\n\nWe set the two metadata fields to ``None`` rather than mutating the\ndict directly — the store's ``update_metadata`` is a merge, and the\nlibrary treats a ``None`` value as \"explicitly cleared\". Callers\nthat read ``ask_user_webhook_url`` get ``None`` back either way.","operationId":"clear_webhook_users_me_webhook_delete","parameters":[{"name":"X-Gateway-Context","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Gateway-Context"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClearWebhookResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/users/me/limits":{"put":{"tags":["users"],"summary":"Set the max number of concurrent calls allowed for this customer","description":"Update the per-customer concurrent-call cap.\n\nDefault is 1 (per manager spec — \"one call at a time per customer\").\nCustomers that need parallel dialing raise it themselves, up to the\n**admin-set ceiling** on their key; we additionally cap everyone at\n:data:`MAX_CONCURRENT_CALLS_CEILING` to keep a misconfigured account\nfrom saturating our pipeline.\n\nThe ceiling is a SECOND metadata field (#346). Keeping the endpoint\nis deliberate — \"customers that need parallel dialing raise it\nthemselves\" is a product decision. The defect it fixes is that the\nadmin-set value and the customer-settable value used to be one\nfield, which made the admin's number a default: a key minted with\n``--max-concurrent 2`` could ``PUT`` itself to 100. Now the write is\nrefused (403) above the ceiling, and\n:meth:`services.call_service.CallService._read_max_concurrent`\nclamps at enforcement time as well, so a value written before the\nceiling was lowered — or straight into DynamoDB — still binds.\n\nStored as ``metadata[\"max_concurrent_calls\"]`` on the API key — read\nback on every ``POST /calls`` to enforce the gate (see\n:class:`services.call_service.CallService.start_call`).","operationId":"set_limits_users_me_limits_put","parameters":[{"name":"X-Gateway-Context","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Gateway-Context"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetLimitsRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LimitsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"AnswerBody":{"properties":{"answer":{"anyOf":[{"type":"string","maxLength":4000,"minLength":1},{"type":"null"}],"title":"Answer","description":"Free text the bot will dictate to the operator."},"outcome":{"anyOf":[{"type":"string","enum":["timeout","transport_failure"]},{"type":"null"}],"title":"Outcome","description":"Closed client-side outcome when the observed ask_user request cannot receive an answer."},"request_id":{"anyOf":[{"type":"string","maxLength":64},{"type":"null"}],"title":"Request Id","description":"Phase C: optional request_id (from the ask_user event payload) to disambiguate when multiple ask_user are in flight on the same call. When omitted, the server falls back to FIFO (oldest pending) — preserves backwards compatibility with webhook agents that don't yet pass request_id."},"mode":{"type":"string","enum":["voice","context"],"title":"Mode","description":"How the bot should treat this answer. 'voice' (default): speak the answer out loud to the operator. 'context': silent guidance — bot acts on the info without voicing it. Use 'context' for concierge IVR navigation hints.","default":"voice"}},"type":"object","title":"AnswerBody","description":"Async webhook callback — customer's reply to a prior ask_user."},"AnswerResponse":{"properties":{"delivered":{"type":"boolean","title":"Delivered","description":"True if a transport was actively waiting on this call_id when the answer arrived. False means the ask_user already timed out, the call ended, or no ask_user was pending — answer is silently dropped."},"reason":{"type":"string","enum":["delivered","no_pending_request"],"title":"Reason","description":"'delivered' — a transport took the answer. 'no_pending_request' — nothing was pending under this request_id: already answered, expired (~60s), or unknown. The answer was dropped; do NOT treat this as success."}},"type":"object","required":["delivered","reason"],"title":"AnswerResponse"},"BookingDTO":{"properties":{"booking_id":{"type":"string","title":"Booking Id","description":"UUID assigned at booking creation."},"customer_id":{"type":"string","title":"Customer Id","description":"poi-billing customer that owns this booking."},"call_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Call Id","description":"The call that produced this booking. Empty/legacy rows surface as null."},"type":{"$ref":"#/components/schemas/BookingType","description":"Booking category."},"target_phone":{"type":"string","title":"Target Phone","description":"Phone the bot called."},"venue_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Venue Name"},"booking_date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Booking Date","description":"Combined date + time of the reservation (UTC, naive)."},"guests":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Guests"},"booking_holder_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Booking Holder Name"},"contact_phone":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Contact Phone"},"details":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Details"},"status":{"$ref":"#/components/schemas/BookingStatus","description":"Booking lifecycle state."},"language":{"type":"string","title":"Language"},"refusal_reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Refusal Reason","description":"When status indicates refusal, the reason returned by the venue (e.g. 'closed today', 'fully booked')."},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"}},"type":"object","required":["booking_id","customer_id","type","target_phone","status","language","created_at","updated_at"],"title":"BookingDTO","description":"Public read view of a customer-owned booking.\n\n1:1 with :class:`domain.booking.Booking` except:\n\n* Enum fields use the ``BookingType`` / ``BookingStatus`` types\n  directly; because both extend ``str``, Pydantic serialises them\n  as their ``.value`` string AND the OpenAPI schema documents the\n  allowed values automatically.\n* ``call_id`` is normalised to ``None`` when the underlying ORM\n  row carried an empty string (legacy data).\n* ``haiku_decision_reason`` (internal AI classification reasoning)\n  is intentionally NOT exposed — it reveals model implementation\n  detail and may contain transcript fragments. Use the call's\n  transcript endpoint for raw conversation data."},"BookingStatus":{"type":"string","enum":["active","pending_user_confirm","past","cancelled"],"title":"BookingStatus"},"BookingType":{"type":"string","enum":["restaurant","doctor","hotel","other"],"title":"BookingType"},"CallDTO":{"properties":{"call_id":{"type":"string","title":"Call Id"},"customer_id":{"type":"string","title":"Customer Id"},"task_id":{"type":"string","title":"Task Id"},"target_phone":{"type":"string","title":"Target Phone"},"language":{"type":"string","title":"Language"},"status":{"type":"string","title":"Status"},"call_sid":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Call Sid"},"started_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Started At"},"ended_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Ended At"},"duration_sec":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Duration Sec"},"outcome_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Outcome Type"},"outcome_summary":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Outcome Summary"},"outcome_charge_cents":{"type":"integer","title":"Outcome Charge Cents"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"has_recording":{"type":"boolean","title":"Has Recording","default":false},"recording_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recording Url"},"disclosure_delivered":{"type":"boolean","title":"Disclosure Delivered","default":false},"disclosure_turn_index":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Disclosure Turn Index"},"reservation_signals":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Reservation Signals"},"transcript_full":{"anyOf":[{"items":{"additionalProperties":true,"type":"object"},"type":"array"},{"type":"null"}],"title":"Transcript Full"},"supervisor_decisions":{"anyOf":[{"items":{"additionalProperties":true,"type":"object"},"type":"array"},{"type":"null"}],"title":"Supervisor Decisions"}},"type":"object","required":["call_id","customer_id","task_id","target_phone","language","status","call_sid","started_at","ended_at","duration_sec","outcome_type","outcome_summary","outcome_charge_cents","created_at"],"title":"CallDTO","description":"Public read view of a Call.\n\n`transcript_full` / `supervisor_decisions` are heavy (can be 10-50 KB\neach) so they're returned ONLY by `from_domain(..., include_details=True)`\n— callers use False for list endpoints, True for the single-call GET.\n`reservation_signals` is short (<10 short strings) so always included.\n\n``recording_url`` is a RELATIVE path to the proxy endpoint on this\nservice (``/calls/{call_id}/recording``) when a recording exists.\nNever the raw Twilio mp3 URL — those carry our Account SID and must\nnot leak to the client. Callers prepend their gateway base URL\n(e.g. ``https://gw.vox-bot.live``) when fetching."},"CancelResponse":{"properties":{"cancelled":{"type":"boolean","title":"Cancelled"}},"type":"object","required":["cancelled"],"title":"CancelResponse"},"ClearWebhookResponse":{"properties":{"cleared":{"type":"boolean","title":"Cleared","default":true}},"type":"object","title":"ClearWebhookResponse"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"LimitsResponse":{"properties":{"max_concurrent_calls":{"type":"integer","title":"Max Concurrent Calls"},"max_concurrent_calls_ceiling":{"type":"integer","title":"Max Concurrent Calls Ceiling","description":"Admin-set upper bound for this key. The customer-settable max_concurrent_calls can never exceed it.","default":100}},"type":"object","required":["max_concurrent_calls"],"title":"LimitsResponse"},"MeDTO":{"properties":{"api_key_id":{"type":"string","title":"Api Key Id"},"customer_id":{"type":"string","title":"Customer Id"},"customer_name":{"type":"string","title":"Customer Name"},"api_key_last_4":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Api Key Last 4"},"status":{"type":"string","title":"Status"},"quota_limit":{"type":"integer","title":"Quota Limit"},"current_usage":{"type":"integer","title":"Current Usage"},"quota_period":{"type":"string","title":"Quota Period"},"ask_user_webhook_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ask User Webhook Url"},"ask_user_webhook_configured":{"type":"boolean","title":"Ask User Webhook Configured"},"max_concurrent_calls":{"type":"integer","title":"Max Concurrent Calls"},"max_concurrent_calls_ceiling":{"type":"integer","title":"Max Concurrent Calls Ceiling","default":100},"credits_held":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Credits Held"},"credits_available":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Credits Available"},"credits_hold_per_call":{"type":"integer","title":"Credits Hold Per Call","default":0},"credits_charge_per_successful_call":{"type":"integer","title":"Credits Charge Per Successful Call","default":0},"is_admin":{"type":"boolean","title":"Is Admin","default":false},"maintenance":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Maintenance"}},"type":"object","required":["api_key_id","customer_id","customer_name","api_key_last_4","status","quota_limit","current_usage","quota_period","ask_user_webhook_url","ask_user_webhook_configured","max_concurrent_calls"],"title":"MeDTO","description":"Self-view derived from :class:`APIKeyData`.\n\nFields are intentionally minimal — we surface identity and quota\nplus whether a webhook is configured. The HMAC secret is NEVER\nincluded; it's only shown once at creation/rotation time."},"QueuedCallResponse":{"properties":{"call_id":{"type":"string","title":"Call Id"},"queue_id":{"type":"string","title":"Queue Id"},"position":{"type":"integer","title":"Position"},"status":{"type":"string","title":"Status","default":"queued"},"credits_reserved":{"type":"integer","title":"Credits Reserved","description":"Refundable hold already placed for this queued call. Released on settlement, minus credits_charge_on_success if billable.","default":0},"credits_charge_on_success":{"type":"integer","title":"Credits Charge On Success","description":"What this call costs if it reaches a billable outcome.","default":0}},"type":"object","required":["call_id","queue_id","position"],"title":"QueuedCallResponse","description":"``202 Accepted`` body when ``call_queue_enabled`` is on.\n\nThe call was persisted + credits reserved but NOT dialed — the\ndrainer dials it later at the global CPS rate. Clients poll\n``GET /calls/{call_id}`` until the status leaves ``queued``.\n``position`` is the FIFO place in the queue (0 == front). It counts\nentries still ``pending`` OR currently being dialed (``warming``), so\n``position`` can read 0 while the entry just ahead is mid-dial."},"SetLimitsRequest":{"properties":{"max_concurrent_calls":{"type":"integer","maximum":100.0,"minimum":1.0,"title":"Max Concurrent Calls","description":"Maximum number of calls the customer can have in flight simultaneously. Defaults to 1. Calls counted as in-flight: scheduled / dialing / in_progress."}},"type":"object","required":["max_concurrent_calls"],"title":"SetLimitsRequest"},"SetWebhookRequest":{"properties":{"url":{"type":"string","maxLength":2083,"minLength":1,"format":"uri","title":"Url","description":"HTTPS endpoint to POST ask_user questions to."},"regenerate_secret":{"type":"boolean","title":"Regenerate Secret","description":"If true, issue a new HMAC secret (invalidates the previous one).","default":true}},"type":"object","required":["url"],"title":"SetWebhookRequest"},"SetWebhookResponse":{"properties":{"url":{"type":"string","title":"Url"},"secret":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Secret","description":"HMAC signing key — returned only on regeneration, shown ONCE."}},"type":"object","required":["url"],"title":"SetWebhookResponse"},"SkillRunResponse":{"properties":{"skill_run_id":{"type":"string","title":"Skill Run Id","description":"Stable id of this run. Format: ``srun_{call_id}``. On Phase A.1 idempotent replay this value is byte-identical to the first response."},"call_id":{"type":"string","title":"Call Id"},"call_sid":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Call Sid"},"owner_pod":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Owner Pod"},"status":{"type":"string","title":"Status"},"credits_reserved":{"type":"integer","title":"Credits Reserved","description":"Refundable hold placed on the customer's balance for the duration of this run. Released on settlement, minus credits_charge_on_success if the outcome is billable. NOT the price of the run. 0 on the gateway-fronted path, where the gateway holds the credits at its own boundary."},"credits_charge_on_success":{"type":"integer","title":"Credits Charge On Success","description":"What this run actually costs if the call reaches a billable outcome. Non-billable outcomes cost 0 and the entire hold is released.","default":0},"status_url":{"type":"string","title":"Status Url"},"answer_url":{"type":"string","title":"Answer Url"},"recording_url":{"type":"string","title":"Recording Url"},"replayed":{"type":"boolean","title":"Replayed","default":false},"expected_next_steps":{"items":{"type":"string"},"type":"array","title":"Expected Next Steps"}},"type":"object","required":["skill_run_id","call_id","call_sid","owner_pod","status","credits_reserved","status_url","answer_url","recording_url","expected_next_steps"],"title":"SkillRunResponse","description":"Response body for ``POST /skills/{skill_id}/run`` (success path).\n\n``replayed`` is always ``False`` in Phase A — the field is reserved\nso Phase A.1 idempotency can flip it without changing the schema."},"SkillSummary":{"properties":{"skill_id":{"type":"string","title":"Skill Id"},"title":{"type":"string","title":"Title"},"description":{"type":"string","title":"Description"},"manifest_url":{"type":"string","title":"Manifest Url"},"run_url":{"type":"string","title":"Run Url"}},"type":"object","required":["skill_id","title","description","manifest_url","run_url"],"title":"SkillSummary","description":"One row in ``GET /skills``."},"StartCallBody":{"properties":{"target_phone":{"anyOf":[{"type":"string","maxLength":32,"minLength":3},{"type":"null"}],"title":"Target Phone","description":"E.164 phone number to dial (e.g. +37441467478). REQUIRED on the freeform path (no `slots`) — 422 `missing_target_phone` if omitted there. On the structured `slots` path it is NOT required at the top level: booking carries the phone in `slots`, and cancellation resolves it server-side from the stored booking."},"brief":{"anyOf":[{"type":"string","maxLength":4000},{"type":"null"}],"title":"Brief","description":"Natural-language task for the voice agent. Used as the Gemini system prompt's TASK section. REQUIRED on the freeform path (no `slots`) — 422 `missing_brief` if omitted there. Ignored (and therefore optional) on the structured `slots` path, which builds its own deterministic brief."},"language":{"type":"string","title":"Language","description":"Call language (ISO 639-1) or `auto`. Drives the agent's speech language and the STT/TTS routing. Supported: en, es, fr, de, hi, ru, pt, ja, it, nl (Deepgram Flux) plus sr, tr, pl (nova-3 fallback). Unsupported codes fast-fail with 422 — the vendor APIs accept them silently and produce garbage, so the allow-list lives here.","default":"auto"},"kind":{"$ref":"#/components/schemas/TaskKind","description":"Coarse task classifier — drives no behavior in Phase 3, used for analytics / future routing.","default":"other"},"parsed_slots":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Parsed Slots","description":"Optional structured fields (date, time, guests, ...). Free-form JSON; the voice agent reads from `brief` for now and `parsed_slots` is forward-compat."},"ask_user_mode":{"type":"string","pattern":"^(any|stream)$","title":"Ask User Mode","description":"Routing for the bot's ask_user tool. `any` (default) keeps the legacy chain (Telegram > Webhook > Terminal > TELEGRAM_CHAT_ID_FALLBACK). `stream` is the right choice for CLI / gateway agents that subscribe to GET /calls/{id}/events and respond via POST /calls/{id}/answer — it disables Telegram + TELEGRAM_CHAT_ID_FALLBACK rules so questions never reach a developer TG inbox by accident.","default":"any"},"skill":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Skill","description":"Deprecated / ignored. Routing is derived from `intent` (booking -> restaurant-reservation, cancellation -> cancel-booking, the conversational intents -> concierge). Kept for backward compatibility; setting it has no effect."},"intent":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Intent","description":"Structured-slots path only. One of `inquiry`, `info_gathering`, `issue_resolution`, `booking`, `cancellation`. Falls back to `slots['intent']` when omitted; missing / unknown -> 422 unknown_intent."},"slots":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Slots","description":"Structured per-intent slots (see GET /skills for each intent's schema). When present, POST /calls validates + normalises via the deterministic slot gate and routes to the intent's skill instead of the freeform `brief` path. `None` (default) keeps the legacy freeform contract unchanged."}},"type":"object","title":"StartCallBody"},"StartCallResponse":{"properties":{"call":{"$ref":"#/components/schemas/CallDTO"},"task_id":{"type":"string","title":"Task Id"},"credits_reserved":{"type":"integer","title":"Credits Reserved","description":"Refundable hold placed on the customer's balance for the duration of this call. Released on settlement, minus credits_charge_on_success if the outcome is billable. NOT the price of the call."},"credits_charge_on_success":{"type":"integer","title":"Credits Charge On Success","description":"What this call actually costs if it reaches a billable outcome. Non-billable outcomes cost 0 and the entire hold is released."},"owner_pod":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Owner Pod"}},"type":"object","required":["call","task_id","credits_reserved","credits_charge_on_success"],"title":"StartCallResponse"},"TaskKind":{"type":"string","enum":["restaurant_booking","restaurant_cancel","doctor_appointment","hotel_booking","concierge","other"],"title":"TaskKind"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"}}}}