Chat API
Two routes, one auth/scoping model: /api/chat (one-shot) and /api/chat-stream (streaming,
with memory and a real tool loop).
POST /api/chat
{ "question": "What did Lindsey promise about launch?", "as": "optional-viewer-id" }
Scoped Q&A over the live spine. Identity comes from the verified Bearer JWT; the model is shown
only the projects that viewer may see, resolved server-side through the same _spine.js boundary
/api/spine uses — the client cannot widen its own scope by passing a different as. A lead or
admin may pass as to ask as someone else, for oversight; anyone else's as is ignored. This
route is read-only and never writes. With no Supabase configured, it falls back to the token-gated
bundled demo dataset rather than erroring.
Model: chat role (claude-haiku-4-5 by default) — see
AI Providers Reference.
POST /api/chat-stream
{ "question": "...", "session_id": "uuid", "turn_id": "uuid", "as": "optional-viewer-id" }
Server-Sent Events out: {"type": "delta" | "tool" | "done" | "stopped" | "error", ...}. Auth and scoping
mirror /api/chat exactly, but this route adds two things the one-shot route doesn't have:
persisted multi-turn memory (chat_sessions/chat_messages) and a real tool-calling loop instead
of a single injected context block. Read-only tools run over the scoped spine; list_skills,
read_skill, and propose_skill_change are dispatched at the route boundary rather than inside
the generic tool executor, specifically so a skill proposal always passes through the same
prompt-governance path a prompt rule proposal does.
The answer outlives the connection (feedback #76). Closing the page, switching conversation or
starting a new one no longer stops an answer. The turn runs with background: true:
- The question is saved to
chat_messages, andchat_sessions.turn_id(the page's id for this answer) andturn_started_atare set. - The answer is saved when it is written. A disconnect only stops writing to that connection.
- If the answer is stopped or fails, a reply is still saved: the partial text plus
[Stopped.], or a failure note. turn_idandturn_started_atare cleared when the turn ends, but only if they still name this turn.
One answer at a time per conversation: a second question while an answer is still being written is
refused with 409 TURN_IN_PROGRESS (a marker older than 305 seconds, just past the 300-second limit, is treated as dead). Home retries a
question refused this way for a few seconds, which covers asking again right after Stop. Question and
answer are saved with meta.turn_id, which /api/chat-history?id= returns, so Home can match the
answer it is waiting for.
Only the Stop button stops an answer. It sends POST /api/chat-history { action: "stop", turn_id },
which records turn_stop_turn_id. The turn stops only when that names its own turn_id, so a Stop
can never land on a later question. The turn checks once as it starts (for a Stop pressed before it
was marked) and then every 3 seconds, mid-answer too. Home resends Stop for a few seconds until the
answer is running. This relies on the route not enabling
Vercel supportsCancellation. Slack turns are unchanged: they save the question and answer together
at the end. Background answers run only in your own conversations. A lead continuing a teammate's
conversation under View as keeps the old behaviour, where closing the page stops the answer.
Model: chatStream role by default (claude-sonnet-5), upgraded to chatStreamPro
(claude-opus-5) only for callers on the SABLE_CHAT_OPUS_EMAILS allowlist.
GET / POST /api/chat-history
The Home side panel's list of your conversations (SABLE-000072). It used to be the browser tab's own
list, so closing the tab or switching device lost it. Every conversation is saved in
chat_sessions / chat_messages, and the panel now reads them from there:
GET /api/chat-historylists the signed-in person's own conversations active in the last 30 days, newest first:{ conversations: [{ id, title, kind, created_at, updated_at, message_count, first_prompt }], retention_days: 30 }.kindisgo_sableorchat. Hidden ones are left out.GET /api/chat-history?id=<uuid>returns one conversation's turns, oldest first, to reopen it. It continues under the same session id, so SABLE keeps the earlier turns. Replies come back as text; cards such as email drafts are not rebuilt. Attached files show as chips, not their extracted text.POST /api/chat-historywith{ "action": "hide", "id": "<uuid>" }setshidden_at. Nothing is deleted.POST /api/chat-historywith{ "action": "stop", "id": "<uuid>", "turn_id": "<uuid>" }asks that answer to stop (your own conversation only). The reply'sstateis that answer's state (below);unknownmeans it has not started yet, so Home sends Stop again for up to ~20 s.GET /api/chat-history?id=<uuid>&status=1returns onlyrunning_turn_id, the answer being written in that conversation, or null. The full?id=response also returns it, and each message carries itsturn_id.
A conversation is marked for a check when a question is sent, and the mark is cleared only when the answer finishes on screen. A conversation opened from the list is also marked when the server says an answer is running there. When Home shows a marked conversation, it asks the server whether an answer is being written there. If one is, Home shows "SABLE is still working on this answer" and checks every 4 seconds. When the answer ends, or straight away for a conversation whose live answer the page stopped following, Home reloads the conversation from the saved turns. The server is the record. Stop for a running answer is on the live screen only.
A turn stops itself 280 seconds after its request arrived, before the 300-second function limit. A
partial answer is kept and marked "[Timed out.]". Stopping early leaves time to save a reply and
clear the marker. A marker older than 305 seconds counts as dead (_chat_turn_state.js, shared by
both routes). If a turn is killed before it can save anything, its question is left without an
answer. The next background turn first saves "[This answer didn't finish.]" under it, so neither the
history nor the model ever has two questions in a row.
The list comes from the chat_history_list SQL function (service role only), which returns only
what a row shows, with a first question of at most 400 characters and never attachment text.
It is always your own list, also under View as, because chat turns are saved under the person
signed in. Anyone else's conversation answers 404.
The panel groups the list by day in New York time: Today, Yesterday, Previous 7 days and Older.
Rate limiting
Both routes are rate-limited independently: SABLE_CHAT_RATE_LIMIT for /api/chat,
SABLE_CHAT_STREAM_RATE_LIMIT for /api/chat-stream. SABLE_CHAT_CTX_MAX_CHARS caps the scoped
spine context injected into either route so it can't exceed the model's context window (default
120,000 characters).
Public-web research stays separate
Neither chat route reaches the open web. /api/research (Exa-backed, lead/admin-only) is a
separate, explicitly invoked surface — see AI Providers Reference.
Private SABLE knowledge is the only source either chat route draws from.
Where the code lives
web/api/chat.js,chat-stream.js,_chat_context.js,_chat_stream_core.jsweb/api/_spine.js,_scope.js— the scope boundary both routes share with/api/spineweb/api/_propose_rule.js,_propose_skill.js