{"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  * 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  * 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.","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\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: if the client supplies the standard ``Last-Event-ID`` HTTP\nheader, the stream backfills events with ``event_id > last`` from\nthe durable ``call_events`` table BEFORE attaching to the live\ntail. Avoids gaps after a reconnect.\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":"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 mp3 recording (proxied through our Twilio creds)","description":"Admin-only proxy for the Twilio recording (internal QA tool).\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\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 an\nadmin API key.\n\n**Admin-only.** This endpoint exists for internal testing /\nbot-quality review — only API keys flagged\n``metadata['is_admin']='true'`` (or the ``BOOTSTRAP_ADMIN_API_KEY``\nenv-var escape hatch) can fetch a recording. Non-admin keys —\nincluding the call owner — see a stealth 404. They do not learn\nwhether a recording exists at all (same posture as PR #93's\nstealth-admin pattern on operator endpoints). Customers do NOT\nhave a documented way to download their own mp3 via this endpoint\n— by design.\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}.mp3``.\n    Without it the disposition is ``inline`` (HTML5 ``<audio>``\n    plays in-page).\n\nStatus codes:\n  * 200 — streaming the mp3 (audio/mpeg) via S3 or the Twilio proxy fallback.\n  * 202 — recording is still processing; retry shortly.\n  * 401 — missing / invalid API key.\n  * 404 — non-admin key (stealth), call doesn't exist, or the\n          call has no ``call_sid`` (never dialed).\n  * 500 — Twilio credentials are not configured server-side.","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 is still being processed by Twilio. 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:`services.transcript_merger` after the call ended.\n\nThe merged transcript fuses two sources into a single timeline:\n\n* **Hostess (inbound)** — already captured live during the call\n  via our parallel Deepgram WS (``transcript_full`` rows tagged\n  ``supervisor_stt``). No audio of the hostess is persisted —\n  consistent with US 2-party-consent posture.\n* **Bot (outbound)** — bot-only mp3 from Twilio (the call was\n  dialed with ``RecordingTrack=\"outbound\"``) → sent to Deepgram\n  batch for word-level timing → calibrated against the bot's\n  known send timestamps so both sides share one timeline.\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:`services.transcript_merger`: in short\n          ``{\"version\", \"duration_ms\", \"calibration_delta_ms\",\n          \"turns\": [{\"speaker\", \"start_ms\", \"end_ms\", \"text\",\n          \"source\", \"words\"?, \"overlap_with_next\"?}]}``.\n  * 202 — merger pending. Retry once SSE emits ``transcript_ready``.\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}/cancel":{"post":{"tags":["calls"],"summary":"Cancel a call that hasn't completed","description":"Mark the call as CANCELLED and release credit reservation.\n\nPhase 3 limitation: if the call is already in DIALING / IN_PROGRESS,\nwe update DB state and release credits but do NOT terminate the\nTwilio leg from this path (the orchestrator's hangup logic owns\nTwilio termination today). Phase 4 wires both together.\n\nReturns ``cancelled=False`` for unknown / not-yours / already-final\ncalls — never 404, so an idempotent retry is safe.","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":"Run a skill — translates structured input into a voice call","description":"Stripe-style idempotent wrapper around :func:`_execute_skill_run`.\n\nWithout ``Idempotency-Key`` header: behaves exactly like a single\ninvocation of ``_execute_skill_run`` — no replay tracking.\n\nWith ``Idempotency-Key`` header (Phase A.1): claims the slot\nBEFORE running so two concurrent retries cannot both dial Twilio.\nSee :mod:`services.skill_idempotency` for the dispatch table.\n\nIdempotency error codes:\n  * ``409 idempotency_conflict``      — same key, different body.\n  * ``409 idempotency_in_progress``   — still running, lock fresh.\n  * ``409 idempotency_state_unknown`` — running with stale lock\n    (worker may have crashed after dialing — agent must verify\n    via ``GET /calls?since=...`` before retrying with a NEW key).\n  * ``400 idempotency_key_too_long``  — key > 255 chars.","operationId":"run_skill_skills__skill_id__run_post","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 two lightweight\nfeeds the webapp uses: the admin role flag and the current\nmaintenance state. Both are cheap (admin = metadata read; maintenance\n= 5-second cached Postgres lookup).","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; we cap at\n:data:`MAX_CONCURRENT_CALLS_CEILING` to keep a misconfigured account\nfrom saturating our pipeline.\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":{"type":"string","maxLength":4000,"minLength":1,"title":"Answer","description":"Free text the bot will dictate to the operator."},"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","required":["answer"],"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."}},"type":"object","required":["delivered"],"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"},"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"}},"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"},"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"}},"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"},"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":{"type":"string","maxLength":32,"minLength":4,"title":"Target Phone","description":"E.164 phone number to dial (e.g. +37441467478)."},"brief":{"type":"string","maxLength":4000,"minLength":1,"title":"Brief","description":"Natural-language task for the voice agent. Used as the Gemini system prompt's TASK section."},"language":{"type":"string","title":"Language","description":"ru / en / auto. Drives Gemini speech language.","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"}},"type":"object","required":["target_phone","brief"],"title":"StartCallBody"},"StartCallResponse":{"properties":{"call":{"$ref":"#/components/schemas/CallDTO"},"task_id":{"type":"string","title":"Task Id"},"credits_reserved":{"type":"integer","title":"Credits Reserved"},"owner_pod":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Owner Pod"}},"type":"object","required":["call","task_id","credits_reserved"],"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"}}}}