{
  "openapi": "3.1.0",
  "info": {
    "title": "Ognate Chat API",
    "description": "Chat protocol + integration endpoints for Ognate tenants. All paths are relative to `/chatapiv1` on your Ognate host.\n\nAuth differs per route — see each route's description for the exact header.",
    "version": "0.1.170"
  },
  "paths": {
    "/sessions": {
      "get": {
        "tags": [
          "Chat protocol"
        ],
        "summary": "List Sessions",
        "description": "Page through the calling user's sessions for this tenant.\n\nAuth: ``X-Identity-Token`` (same precedence as ``/session/init``). Tenant\nresolved from ``X-Tenant-Key``.\n\nQuery params: ``cursor`` (opaque, from a prior page), ``limit`` (default\n20, hard-capped at 50), ``include_hidden`` (default ``false``; set to\n``true`` to include soft-deleted/hidden sessions). Returns\n``{\"sessions\": [...], \"next_cursor\": \"...\" | null}``.",
        "operationId": "list_my_sessions",
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SessionListResponse"
                }
              }
            }
          }
        }
      }
    },
    "/session/{session_id}/messages": {
      "get": {
        "tags": [
          "Chat protocol"
        ],
        "summary": "Get Session Messages",
        "description": "Return the caller's message history for a session.\n\nAuth: ``X-Identity-Token`` — same precedence as ``/session/init`` and\n``/sessions``. Tenant via ``X-Tenant-Key``. Ownership enforced via\n``get_user_identity``;\nmismatch returns 404 (no signal on session existence).\n\nResponse shape: ``{\"messages\": [{message_id, role, content,\ncreated_at}], \"next_cursor\": str | null}``. Tool-call frames,\nfeedback state, and other internal annotations are excluded by\nprojection at the ``session_store`` boundary.",
        "operationId": "get_session_messages",
        "parameters": [
          {
            "name": "session_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Session Id"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SessionMessagesResponse"
                }
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/session/{session_id}/hide": {
      "post": {
        "tags": [
          "Chat protocol"
        ],
        "summary": "Hide Session",
        "description": "Soft-delete a session for the calling identity.\n\nAuth: ``X-Identity-Token`` plus ``X-Tenant-Key``. The tombstone write\nitself is the auth boundary — ``set_deleted_at_if_identity`` conditions\natomically on ``identity_pk`` (ownership), ``attribute_not_exists(\ndeleted_at)`` (first-write-wins) and NOT rep-attention status. No\npre-read peek: the common path is exactly one DDB call, and there is no\npeek→write window for an escalation or identity relink to race through.\nMismatch or missing session returns 404 (no signal on session existence).\n\nOrdering is deliberate: when the conditional write is rejected because\nthe row is ESCALATED/REOPENED, ``close_escalation`` runs and the write is\nretried ONCE. The close flips status to RESOLVED and appends a\nCLOSE_USER_ENDED marker so the rep portal naturally drops the session\nfrom the active list and the transcript records why — otherwise the rep\nwould keep typing into a ghost session until nightly purge. A close\nfailure (Bedrock/DDB throttle, concurrent rep close → 409) propagates\nBEFORE any tombstone lands, leaving the session fully visible and the\nhide cleanly retryable. If the retried write is rejected on attention\nstatus AGAIN, a re-escalation raced in between the close and the retry —\n409, caller retries.\n\nSuccessful hide writes a ``deleted_at`` tombstone; the nightly cleanup\nbranch hard-deletes after a 24h grace window. Idempotent — second call on\na deleted session keeps the original tombstone (and never regresses\n``updated_at``) and still returns 204.",
        "operationId": "delete_session",
        "parameters": [
          {
            "name": "session_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Session Id"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Successful Response"
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/chat": {
      "post": {
        "tags": [
          "Chat protocol"
        ],
        "summary": "Chat",
        "description": "Send a user message and stream the assistant reply.\n\nAuth: session token from ``/session/init`` (``X-Session-Token: <sid_jwt>``).\n\nBody: ``{message: str}`` — 1 char minimum (canned \"too short\" reply\nbelow that) up to ``MAX_MESSAGE_LEN``. Vague anaphoric follow-ups on an\nempty history also short-circuit to a canned reply so RAG noise can't\nseed the session label.\n\nBehavior: persists the user turn, runs RAG retrieval, streams the\nassistant reply via Bedrock Converse, persists the assistant turn,\nauto-escalates if the model decided to. May call the tenant's customer-\ndata tool mid-stream when configured.\n\nResponse: ``text/plain`` NDJSON stream. Each line is one of\n``{\"chunk\": str}`` (partial assistant text), ``{\"tool_call\": {...}}``\n(started/cached/failed), or a final ``{\"reply\": {message_id, text,\nescalate}}``. Stream closes on completion or upstream error.",
        "operationId": "send_chat_message",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ChatRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {}
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/escalate": {
      "post": {
        "tags": [
          "Chat protocol"
        ],
        "summary": "Escalate",
        "description": "Flip a session into the human-rep escalation state.\n\nAuth: session token (``X-Session-Token: <sid_jwt>``).\n\nBehavior: marks the session ESCALATED, fans out a rep notification\n(email + dispatcher hooks), increments the tenant escalation counter.\nIdempotent — calling twice on an already-escalated session no-ops the\nnotification fan-out and still returns success.\n\nSubsequent ``/chat`` calls on the session route through the rep path;\npoll for rep replies via ``GET /escalate/poll``.\n\nResponse: ``{ok: true, session_id}``. Returns ``403 chat_suspended`` when the\ntenant's master kill switch is on, ``409 escalation_not_configured`` when no\nenabled rep target (email/webhook) exists, or ``404 session_not_found`` when\nthe session is soft-deleted (hidden) — a tombstoned row must never re-enter\na rep-attention state.",
        "operationId": "escalate_to_human",
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EscalateResponse"
                }
              }
            }
          }
        }
      }
    },
    "/escalate/poll": {
      "get": {
        "tags": [
          "Chat protocol"
        ],
        "summary": "Escalate Poll",
        "description": "Long-poll for new messages in an escalated session.\n\nAuth: session token OR rep token. Rep tokens may poll any session under\ntheir tenant; session tokens may only poll their own ``sid``.\n\nQuery: ``session_id`` (required), ``after`` (last seen ``message_id``;\n\"0\" for fresh). Returns messages strictly newer than ``after``.\n\nResponse: ``{messages: [{id, role, content}], closed: bool, status: str}``.\n``closed=true`` signals the session terminal — stop polling. Caller\ndetermines terminal state per audience: widget sees CLOSED/EXPIRED;\nrep sees a broader rep-terminal set.",
        "operationId": "poll_escalation_messages",
        "parameters": [
          {
            "name": "session_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Session Id"
            }
          },
          {
            "name": "after",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "default": "0",
              "title": "After"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EscalatePollResponse"
                }
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/escalate/poll-all": {
      "get": {
        "tags": [
          "Chat protocol"
        ],
        "summary": "Escalate Poll All",
        "description": "Multiplexed rep-reply poll across every session the calling identity owns.\n\nAuth: ``X-Identity-Token`` plus ``X-Tenant-Key``. Same precedence as\n``/sessions``. Collapses the per-session ``/escalate/poll``\nfan-out from N requests/tick to one when the widget owns >1 session.\n\n``since`` is required (422 when absent) and must parse as ISO-8601\n(400 otherwise). Strict ``created_at > since`` so passing back the prior\nresponse's ``newest_at`` as the next cursor cannot replay rows.\n\nReturns rep messages only — user/assistant frames are filtered server-side\nso the widget cannot accidentally render history twice.",
        "operationId": "poll_escalation_messages_all_sessions",
        "parameters": [
          {
            "name": "since",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Since"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EscalatePollAllResponse"
                }
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/feedback": {
      "post": {
        "tags": [
          "Chat protocol"
        ],
        "summary": "Feedback",
        "description": "Submit thumbs feedback on a specific assistant message.\n\nAuth: session token. ``message_id`` must belong to the caller's\nsession (404 otherwise).\n\nBody: ``{message_id, session_id, rating: \"good\" | \"bad\", note?: str}``.\nNote is free text up to ``feedback_service.MAX_NOTE_LENGTH``.\n\nBehavior: idempotent overwrite — re-submitting on the same message\nreplaces the prior rating + note. Bumps the per-tenant feedback\ncounter for analytics.\n\nResponse: ``{ok: true}``.",
        "operationId": "submit_feedback",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/FeedbackRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FeedbackResponse"
                }
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/session/init": {
      "post": {
        "tags": [
          "Chat protocol"
        ],
        "summary": "Session Init",
        "description": "Mint or resume a chat session and return a session JWT.\n\nThe bootstrap call: every subsequent ``/chat``, ``/escalate``,\n``/feedback``, ``/sessions``, ``/session/{sid}/...`` request needs the\n``token`` returned here as ``X-Session-Token``.\n\nAuth: ``X-Tenant-Key`` (required, channel-bound). Identity follows\nthree branches via ``X-Identity-Token``:\n  1. Token present + valid → validate (kind, tid, exp, kid) → use\n     ``uid:<sub>`` (user) or ``anon:<sub>`` (anon) identity. On a\n     ``kind=user`` token, a verified ``X-Relink-Token`` (the prior anon\n     token) merges that anon history iff ``relink_anon`` is enabled.\n  2. Token absent → mint a fresh anon token (365d), returned as\n     ``identity_token`` in the response for the widget to persist.\n  3. Token present but invalid/expired → 401\n     (``identity_token_expired`` is refresh-eligible for user-kind;\n     anon expiry triggers a silent re-init on the widget side).\n\nAll identity is carried by ``X-Identity-Token``.\n\nBody (optional): ``{session_id?: str}``. Pass ``session_id`` to resume\na prior session owned by this identity; any mismatch (unknown sid,\nforeign owner, terminal status, deleted) falls through to mint-new\nsilently.\n\nResponse: ``{session_id, token, widget_config, escalation_enabled,\nauto_escalate}``. On branch 2 (first anon visit) also includes\n``{identity_token}``.",
        "operationId": "init_session",
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SessionInitResponse"
                }
              }
            }
          }
        }
      }
    },
    "/widget/config": {
      "get": {
        "tags": [
          "Chat protocol"
        ],
        "summary": "Widget Config",
        "description": "Return the tenant's widget branding, before any identity exists.\n\nAuth: ``X-Tenant-Key`` only (channel-bound) — the same tenant resolution\n``/session/init`` performs, minus every identity branch.\n\nExists because ``widget_config`` used to ship only in the ``/session/init``\nresponse, and init is lazy: it fires on the visitor's first send. So a fresh\npage load painted the hardcoded defaults (\"Support\") and only adopted the\ntenant's title, subtitle, accent colour and welcome text after a message had\nbeen sent — reverting on every reload. Eagerly calling init instead would\nmint a session and an anonymous identity for every page view, including\nbounces.\n\nReturns 401 ``tenant_key_invalid`` for a missing/unknown/inactive key, and\n429 once ``_CONFIG_RPM`` is exhausted for the tenant.",
        "operationId": "get_widget_config",
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WidgetConfigResponse"
                }
              }
            }
          }
        }
      }
    },
    "/embed/v1/token": {
      "post": {
        "tags": [
          "Embed"
        ],
        "summary": "Mint User Token",
        "description": "Exchange a tenant API key for a 12-hour widget ``identity_token`` (kind=user).\n\nServer-to-server only — rejects any request that carries a browser\n``Origin`` header. Run this from your backend, then pass the returned\n``identity_token`` to the widget (``data-identity-token`` attr) or use it\ndirectly as ``X-Identity-Token`` against ``/session/init``.\n\nAuth: tenant API key (``Authorization: Bearer og_...``).\n\nBody: ``{user_id: str, claims?: dict}``.\n``user_id`` is your stable user identifier (reserved prefixes\nrejected). ``claims`` is freeform per-user metadata carried inside the JWT,\nvalidated + capped at 2KB encoded, but **not currently surfaced to the\nassistant** (kept for forward-compat). Anon→identified history merge needs\nno field here — the widget presents the prior anon token as\n``X-Relink-Token`` to ``/session/init`` and the server verifies it.\n\nResponse: ``{identity_token: str, expires_in: 43200, token_type:\n\"Bearer\"}``. TTL is fixed at 12h.",
        "operationId": "mint_user_token",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TokenExchangeRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmbedTokenResponse"
                }
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/integrations/messages": {
      "post": {
        "tags": [
          "Integrations"
        ],
        "summary": "Integrations Messages",
        "description": "Receive a customer-rep message, verify HMAC, persist + audit.\n\nAuth flow per design § Inbound endpoint:\n  1. Header presence (``X-Ognate-Tenant-Id``, ``X-Ognate-Signature``,\n     ``X-Ognate-Timestamp``) — 401 on any missing.\n  2. Timestamp skew check (5-minute window) — 401 on stale.\n  3. Tenant lookup + ``webhook_secret_arn`` presence check — 401.\n  4. Constant-time HMAC compare — 401 on mismatch.\n  5. Replay check on the verified signature itself (``(tenant_id,\n     signature)``, in-process TTL cache) — 401 (same shape as a bad\n     signature) on an exact resend within the skew window.\n  6. Tenant↔session ownership via\n     ``sessions_table.get_item(Key={tenant_id, session_id})`` — 404\n     if missing.\n  7. Session status must be ESCALATED — 409 otherwise.\n  8. Idempotency on ``client_message_id`` if supplied: a duplicate\n     returns ``{\"status\": \"duplicate\"}`` without writing.\n  9. Persist via ``session_store.save_message(source=\"integration\",\n     ...)`` and audit via ``audit.log_action``.\n\nBody bytes are read raw (not via ``request.json()``) so the customer's\nHMAC signature — which signs the literal request body — verifies\ncorrectly. Re-serializing JSON would change whitespace and break\nevery signature.",
        "operationId": "receive_integration_message",
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/IntegrationMessageResponse"
                }
              }
            }
          }
        }
      }
    },
    "/integrations/draft": {
      "post": {
        "tags": [
          "Integrations"
        ],
        "summary": "Integrations Draft",
        "description": "Fetch the AI-suggested reply for a session's latest pending customer turn\n(``passthrough_level = l3_review`` only).\n\nAuth: identical to ``POST /integrations/messages`` — ``X-Ognate-Tenant-Id``,\n``X-Ognate-Timestamp``, ``X-Ognate-Signature = hex(HMAC_SHA256(secret, f\"{ts}.{body}\"))``,\n5-minute skew, one-step rotation grace, replay rejection.\n\nBody: ``{\"session_id\": \"<id>\"}``.\n\nThe draft is generated lazily on the first request (portal poll or this endpoint —\nwhichever comes first) and cached, so repeat calls are cheap and return the same text.\n``draft`` is ``null`` with ``reason``:\n\n* ``no_pending_l3_turn`` — tenant not on ``l3_review`` or no pending customer turn.\n  Do not retry until the customer writes again.\n* ``not_ready`` — still being prepared (or withheld: a draft the assistant would not\n  have shipped live is never surfaced). Retry in a few seconds.\n\n``message_id`` identifies the customer message the draft answers — the same id as the\ncorresponding ``message.created`` webhook. Fetching marks the draft as delivered to\nyour integration, which enables adoption scoring of the rep's eventual reply.",
        "operationId": "fetch_integration_draft",
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/IntegrationDraftResponse"
                }
              }
            }
          }
        }
      }
    },
    "/integrations/feedback": {
      "post": {
        "tags": [
          "Integrations"
        ],
        "summary": "Integrations Feedback",
        "description": "Attach rep feedback to the customer turn it is about (design 2026-08-21) —\naddressed directly via ``message_id`` rather than riding along on a\n``POST /integrations/messages`` send (the design this replaces).\n\nAuth: identical chain to ``POST /integrations/messages`` (see that route's\ndocstring for the 5-step breakdown).\n\nBody: ``{\"session_id\": \"<id>\", \"message_id\": \"<customer message_id>\", \"feedback\": {...}}``\n— ``feedback`` is the same ``RepFeedbackIn`` shape as the rep-portal send.\nStrict, unlike the messages route's lenient feedback wrap: a malformed shape, or\na feedback object with every field left at its default (nothing truthy after\n``to_storage()``), 400s ``invalid_feedback_body``.\n\nTenant↔session ownership is enforced (404 ``session_not_found`` on a missing or\nunowned session) but there is NO escalation-status gate — feedback is accepted\nafter the session resolves or closes.\n\n404 ``feedback_target_not_found`` when no l2/l3 TrainingRecords row's\n``context_up_to_message_id`` matches ``message_id`` in this session — covers an\nL1 turn (L1 rows are never feedback-addressable), a tenant on ``on`` passthrough\n(never opens an l2/l3 row), an unknown/typo'd id, or a purged/expired row.",
        "operationId": "receive_integration_feedback",
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/IntegrationFeedbackResponse"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "ChatRequest": {
        "properties": {
          "message": {
            "type": "string",
            "title": "Message",
            "description": "User's chat message. Trimmed server-side; replies below ~3 chars short-circuit to a canned 'please elaborate' message; over 4000 chars rejects with 413.",
            "examples": [
              "How do I reset my password?"
            ]
          }
        },
        "type": "object",
        "required": [
          "message"
        ],
        "title": "ChatRequest"
      },
      "EmbedTokenResponse": {
        "properties": {
          "identity_token": {
            "type": "string",
            "title": "Identity Token",
            "description": "Signed identity token (kind=user). Pass to the widget via `data-identity-token` or directly as `X-Identity-Token` on /session/init."
          },
          "expires_in": {
            "type": "integer",
            "title": "Expires In",
            "description": "Token lifetime in seconds. Fixed at 12h (43200)."
          },
          "token_type": {
            "type": "string",
            "const": "Bearer",
            "title": "Token Type",
            "description": "RFC 6750 token type — always 'Bearer'.",
            "default": "Bearer"
          }
        },
        "type": "object",
        "required": [
          "identity_token",
          "expires_in"
        ],
        "title": "EmbedTokenResponse"
      },
      "EscalatePollAllMessage": {
        "properties": {
          "session_id": {
            "type": "string",
            "title": "Session Id",
            "description": "Session this message belongs to."
          },
          "message_id": {
            "type": "string",
            "title": "Message Id",
            "description": "Per-session message id; sort key in MessagesTable."
          },
          "role": {
            "type": "string",
            "const": "rep",
            "title": "Role",
            "description": "Author role; always 'rep' for this endpoint."
          },
          "content": {
            "type": "string",
            "title": "Content",
            "description": "Message text."
          },
          "created_at": {
            "type": "string",
            "title": "Created At",
            "description": "ISO 8601 UTC timestamp the message was persisted."
          }
        },
        "type": "object",
        "required": [
          "session_id",
          "message_id",
          "role",
          "content",
          "created_at"
        ],
        "title": "EscalatePollAllMessage"
      },
      "EscalatePollAllResponse": {
        "properties": {
          "messages": {
            "items": {
              "$ref": "#/components/schemas/EscalatePollAllMessage"
            },
            "type": "array",
            "title": "Messages",
            "description": "Rep messages persisted after the supplied `since` cursor across every session the calling widget identity owns, oldest first. Empty when no new messages."
          },
          "newest_at": {
            "type": "string",
            "title": "Newest At",
            "description": "Max `created_at` observed in this response. Use as the next call's `since`. Echoes the input `since` when the response is empty so cursors do not regress."
          }
        },
        "type": "object",
        "required": [
          "messages",
          "newest_at"
        ],
        "title": "EscalatePollAllResponse"
      },
      "EscalatePollMessage": {
        "properties": {
          "id": {
            "type": "string",
            "title": "Id",
            "description": "Message id; use as the next `after` cursor."
          },
          "role": {
            "type": "string",
            "enum": [
              "user",
              "assistant",
              "rep"
            ],
            "title": "Role",
            "description": "Author role."
          },
          "content": {
            "type": "string",
            "title": "Content",
            "description": "Message text."
          }
        },
        "type": "object",
        "required": [
          "id",
          "role",
          "content"
        ],
        "title": "EscalatePollMessage"
      },
      "EscalatePollResponse": {
        "properties": {
          "messages": {
            "items": {
              "$ref": "#/components/schemas/EscalatePollMessage"
            },
            "type": "array",
            "title": "Messages",
            "description": "New messages since the supplied `after` id, oldest first. Empty when no new messages."
          },
          "closed": {
            "type": "boolean",
            "title": "Closed",
            "description": "True when the session has reached a terminal state for this caller's audience. Stop polling once true."
          },
          "status": {
            "type": "string",
            "title": "Status",
            "description": "Current session status (e.g. ESCALATED, RESOLVED, CLOSED)."
          },
          "kb_context": {
            "anyOf": [
              {
                "items": {},
                "type": "array"
              },
              {
                "type": "null"
              }
            ],
            "title": "Kb Context",
            "description": "l2_assisted only: suggested KB chunks for the pending user turn [{source_uri, text, score, doc_type, timestamp_iso}]. Null unless this rep poll surfaces a new user turn."
          },
          "ai_draft": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Ai Draft",
            "description": "l3_review only: the live AI-suggested reply for the pending user turn, for the rep to edit/approve. Null unless this rep poll surfaces a generated answer-outcome draft."
          },
          "pending_turn": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Pending Turn",
            "description": "Rep callers only: the pending user turn's message id — an explicit turn-boundary signal the portal uses to reset per-turn feedback state (fires even for snapshot-less turns, unlike kb_context). Null when there is no pending turn or for widget callers."
          }
        },
        "type": "object",
        "required": [
          "messages",
          "closed",
          "status"
        ],
        "title": "EscalatePollResponse"
      },
      "EscalateResponse": {
        "properties": {
          "ok": {
            "type": "boolean",
            "const": true,
            "title": "Ok",
            "description": "Always true. Idempotent — repeated calls on an escalated session still return ok."
          },
          "session_id": {
            "type": "string",
            "title": "Session Id",
            "description": "The escalated session_id, echoed for client confirmation."
          }
        },
        "type": "object",
        "required": [
          "ok",
          "session_id"
        ],
        "title": "EscalateResponse"
      },
      "FeedbackRequest": {
        "properties": {
          "message_id": {
            "type": "string",
            "title": "Message Id",
            "description": "The assistant message_id the user is rating. Must belong to session_id; mismatch returns 404."
          },
          "session_id": {
            "type": "string",
            "title": "Session Id",
            "description": "Session containing the rated message."
          },
          "rating": {
            "type": "string",
            "enum": [
              "good",
              "bad"
            ],
            "title": "Rating",
            "description": "Thumbs-up = 'good', thumbs-down = 'bad'."
          },
          "note": {
            "type": "string",
            "title": "Note",
            "description": "Optional free-text comment from the user. Length cap enforced server-side.",
            "default": ""
          }
        },
        "type": "object",
        "required": [
          "message_id",
          "session_id",
          "rating"
        ],
        "title": "FeedbackRequest"
      },
      "FeedbackResponse": {
        "properties": {
          "ok": {
            "type": "boolean",
            "const": true,
            "title": "Ok",
            "description": "Always true on success. Idempotent — re-submitting overwrites the prior rating + note."
          }
        },
        "type": "object",
        "required": [
          "ok"
        ],
        "title": "FeedbackResponse"
      },
      "HTTPValidationError": {
        "properties": {
          "detail": {
            "items": {
              "$ref": "#/components/schemas/ValidationError"
            },
            "type": "array",
            "title": "Detail"
          }
        },
        "type": "object",
        "title": "HTTPValidationError"
      },
      "IntegrationDraft": {
        "properties": {
          "text": {
            "type": "string",
            "title": "Text",
            "description": "AI-suggested reply for the pending customer turn. Show it to your rep to edit or approve; send the final text via POST /integrations/messages."
          },
          "model_id": {
            "type": "string",
            "title": "Model Id",
            "description": "Model that produced the draft."
          }
        },
        "type": "object",
        "required": [
          "text",
          "model_id"
        ],
        "title": "IntegrationDraft"
      },
      "IntegrationDraftResponse": {
        "properties": {
          "draft": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/IntegrationDraft"
              },
              {
                "type": "null"
              }
            ],
            "description": "Null when no draft is available — see `reason`."
          },
          "message_id": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Message Id",
            "description": "The customer message this draft answers (matches `message_id` of the corresponding `message.created` webhook)."
          },
          "reason": {
            "anyOf": [
              {
                "type": "string",
                "enum": [
                  "no_pending_l3_turn",
                  "not_ready"
                ]
              },
              {
                "type": "null"
              }
            ],
            "title": "Reason",
            "description": "Why `draft` is null. `no_pending_l3_turn`: the tenant is not on l3_review or this session has no pending customer turn — do not retry until the customer writes again. `not_ready`: the draft is still being prepared — retry in a few seconds."
          }
        },
        "type": "object",
        "title": "IntegrationDraftResponse"
      },
      "IntegrationFeedbackResponse": {
        "properties": {
          "record_id": {
            "type": "string",
            "title": "Record Id",
            "description": "The TrainingRecords row the feedback landed on."
          }
        },
        "type": "object",
        "required": [
          "record_id"
        ],
        "title": "IntegrationFeedbackResponse"
      },
      "IntegrationMessageResponse": {
        "properties": {
          "message_id": {
            "type": "string",
            "title": "Message Id",
            "description": "Identifier of the persisted rep message; reuse on duplicate detection."
          },
          "status": {
            "anyOf": [
              {
                "type": "string",
                "const": "duplicate"
              },
              {
                "type": "null"
              }
            ],
            "title": "Status",
            "description": "Present only when `client_message_id` matched a prior message. Omitted on first persistence."
          }
        },
        "type": "object",
        "required": [
          "message_id"
        ],
        "title": "IntegrationMessageResponse"
      },
      "SessionInitResponse": {
        "properties": {
          "session_id": {
            "type": "string",
            "title": "Session Id",
            "description": "Session UUID — pass as the bearer subject for subsequent chat calls."
          },
          "token": {
            "type": "string",
            "title": "Token",
            "description": "Short-lived session JWT. Send as `Authorization: Bearer <token>` to /chat, /escalate, /feedback."
          },
          "widget_config": {
            "additionalProperties": true,
            "type": "object",
            "title": "Widget Config",
            "description": "Per-tenant widget settings (theme, copy, feature flags). Opaque to the API contract."
          },
          "escalation_enabled": {
            "type": "boolean",
            "title": "Escalation Enabled",
            "description": "When false, /escalate will return 409 escalation_not_configured (no enabled rep targets); the widget should hide the human-handoff affordance. (A suspended tenant instead returns 403 chat_suspended — see chat_enabled.)"
          },
          "chat_enabled": {
            "type": "boolean",
            "title": "Chat Enabled",
            "description": "When false the tenant's chat is suspended (master kill switch): the widget should disable the composer and show an offline notice. /chat and /escalate return 403.",
            "default": true
          },
          "auto_escalate": {
            "type": "boolean",
            "title": "Auto Escalate",
            "description": "When true, the widget should escalate automatically on the first user turn instead of waiting for an explicit click."
          },
          "identity_token": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Identity Token",
            "description": "Freshly minted anonymous identity token. Present ONLY on the first-ever session for an anonymous visitor (no X-Identity-Token sent). Persist in localStorage and send as `X-Identity-Token` on subsequent /session/init calls."
          },
          "status": {
            "anyOf": [
              {
                "type": "string",
                "enum": [
                  "ACTIVE",
                  "ESCALATED",
                  "REOPENED",
                  "RESOLVED"
                ]
              },
              {
                "type": "null"
              }
            ],
            "title": "Status",
            "description": "Session status when resuming a prior session. Only populated on the resume path so the widget can rehydrate its escalation state and route subsequent sends through /escalate/message instead of /chat. Terminal statuses (CLOSED, EXPIRED) never appear here because the resume path mints a fresh session for them. RESOLVED is resumable because a customer message reopens it. Null for freshly-minted sessions."
          }
        },
        "type": "object",
        "required": [
          "session_id",
          "token",
          "widget_config",
          "escalation_enabled",
          "auto_escalate"
        ],
        "title": "SessionInitResponse"
      },
      "SessionListResponse": {
        "properties": {
          "sessions": {
            "items": {
              "$ref": "#/components/schemas/SessionRecord"
            },
            "type": "array",
            "title": "Sessions",
            "description": "One page of the caller's sessions, newest activity first."
          },
          "next_cursor": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Next Cursor",
            "description": "Opaque cursor to pass back as `?cursor=…` for the next page; null when the page is the last one."
          }
        },
        "type": "object",
        "required": [
          "sessions",
          "next_cursor"
        ],
        "title": "SessionListResponse"
      },
      "SessionMessage": {
        "properties": {
          "message_id": {
            "type": "string",
            "title": "Message Id",
            "description": "Monotonic per-session message identifier."
          },
          "role": {
            "type": "string",
            "enum": [
              "user",
              "assistant",
              "rep"
            ],
            "title": "Role",
            "description": "Author role."
          },
          "content": {
            "type": "string",
            "title": "Content",
            "description": "Message text."
          },
          "created_at": {
            "type": "string",
            "title": "Created At",
            "description": "ISO 8601 timestamp of message persistence."
          }
        },
        "type": "object",
        "required": [
          "message_id",
          "role",
          "content",
          "created_at"
        ],
        "title": "SessionMessage"
      },
      "SessionMessagesResponse": {
        "properties": {
          "messages": {
            "items": {
              "$ref": "#/components/schemas/SessionMessage"
            },
            "type": "array",
            "title": "Messages",
            "description": "One page of the session's user-facing messages, oldest first. Internal annotations (tool_call frames, feedback state) are excluded."
          },
          "next_cursor": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Next Cursor",
            "description": "Opaque cursor for the next page; null when the page is the last one."
          }
        },
        "type": "object",
        "required": [
          "messages",
          "next_cursor"
        ],
        "title": "SessionMessagesResponse"
      },
      "SessionRecord": {
        "properties": {
          "session_id": {
            "type": "string",
            "title": "Session Id",
            "description": "Stable UUID identifier for the session."
          },
          "status": {
            "type": "string",
            "title": "Status",
            "description": "Session status — e.g. ACTIVE, ESCALATED, RESOLVED, CLOSED."
          },
          "created_at": {
            "type": "string",
            "title": "Created At",
            "description": "ISO 8601 timestamp of session creation."
          },
          "updated_at": {
            "type": "string",
            "title": "Updated At",
            "description": "ISO 8601 timestamp of the most recent activity."
          },
          "first_message": {
            "type": "string",
            "title": "First Message",
            "description": "The first user-turn text, used as a derived label preview."
          },
          "label": {
            "type": "string",
            "title": "Label",
            "description": "Human-readable label combining the first message and a relative-time suffix."
          },
          "title": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Title",
            "description": "Server-derived session title (truncated first user message, ~60 chars). Null when no user turn has landed yet. Clients should prefer this over `label` for display, falling back to `label` and then a localized 'Untitled chat' string."
          }
        },
        "type": "object",
        "required": [
          "session_id",
          "status",
          "created_at",
          "updated_at",
          "first_message",
          "label"
        ],
        "title": "SessionRecord"
      },
      "TokenExchangeRequest": {
        "properties": {
          "user_id": {
            "type": "string",
            "minLength": 1,
            "title": "User Id"
          },
          "claims": {
            "anyOf": [
              {
                "additionalProperties": true,
                "type": "object"
              },
              {
                "type": "null"
              }
            ],
            "title": "Claims"
          }
        },
        "type": "object",
        "required": [
          "user_id"
        ],
        "title": "TokenExchangeRequest",
        "description": "Body schema for ``POST /token``.\n\n``claims`` is freeform per-tenant data (plan tier, locale, role flags, etc.)\ncarried inside the signed JWT and validated + size-capped at mint, but **not\ncurrently surfaced to the assistant** — the model does not read these values\ntoday. The field is kept (inert, zero runtime cost) for forward-compat with\na future contained-injection path. Anon→identified history merge is no longer\na token claim: the widget presents the prior anon token as ``X-Relink-Token``\non ``/session/init``, where the server verifies its signature before merging."
      },
      "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"
      },
      "WidgetConfigResponse": {
        "properties": {
          "widget_config": {
            "additionalProperties": true,
            "type": "object",
            "title": "Widget Config",
            "description": "Per-tenant widget settings (theme, copy, feature flags). Opaque to the API contract."
          },
          "chat_enabled": {
            "type": "boolean",
            "title": "Chat Enabled",
            "description": "When false the tenant's chat is suspended (master kill switch): the widget should disable the composer and show an offline notice. Mirrors the field of the same name on /session/init, available here before the first send.",
            "default": true
          }
        },
        "type": "object",
        "required": [
          "widget_config"
        ],
        "title": "WidgetConfigResponse",
        "description": "Branding-only bootstrap, readable before any identity exists.\n\nDeliberately a strict subset of ``SessionInitResponse``: no session is\nminted, no JWT issued, nothing identity-scoped is returned. Only the\nper-tenant presentation the widget needs to paint its first frame."
      }
    }
  },
  "tags": [
    {
      "name": "Integrations",
      "description": "Server-to-server: receive escalated conversations by webhook, post rep replies, fetch AI drafts and send feedback. HMAC-signed with your tenant webhook secret (see each route)."
    },
    {
      "name": "Embed",
      "description": "Mint identity tokens for the embedded widget, server-side, with your tenant API key (`Authorization: Bearer og_…`)."
    },
    {
      "name": "Chat protocol",
      "description": "The widget's own wire protocol. Only needed when you build a custom client (native app, server-side bot) — the embedded widget does all of this for you."
    }
  ],
  "servers": [
    {
      "url": "https://{host}/chatapiv1",
      "variables": {
        "host": {
          "default": "d2s9u525oh4y8r.cloudfront.net",
          "description": "Your Ognate host"
        }
      }
    }
  ]
}
