Skip to main content

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:

  1. The question is saved to chat_messages, and chat_sessions.turn_id (the page's id for this answer) and turn_started_at are set.
  2. The answer is saved when it is written. A disconnect only stops writing to that connection.
  3. If the answer is stopped or fails, a reply is still saved: the partial text plus [Stopped.], or a failure note.
  4. turn_id and turn_started_at are 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-history lists 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 }. kind is go_sable or chat. 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-history with { "action": "hide", "id": "<uuid>" } sets hidden_at. Nothing is deleted.
  • POST /api/chat-history with { "action": "stop", "id": "<uuid>", "turn_id": "<uuid>" } asks that answer to stop (your own conversation only). The reply's state is that answer's state (below); unknown means it has not started yet, so Home sends Stop again for up to ~20 s.
  • GET /api/chat-history?id=<uuid>&status=1 returns only running_turn_id, the answer being written in that conversation, or null. The full ?id= response also returns it, and each message carries its turn_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.js
  • web/api/_spine.js, _scope.js — the scope boundary both routes share with /api/spine
  • web/api/_propose_rule.js, _propose_skill.js