Chat API¶
Build a client for a published chat with HTTP for sessions, history, files and ratings, and WebSocket for streaming messages. The chat runs the saved workflow using its owner's resources. A caller can access only their own sessions.
Contract and access¶
| Contract | Running server | Source |
|---|---|---|
| HTTP v1 | /openapi.public.json, /docs/public, tag Chat |
Public API reference, OpenAPI JSON |
| Socket v1 | /asyncapi.public.yaml |
AsyncAPI YAML |
| Frame schemas | Included in AsyncAPI | Client and server JSON Schema |
HTTP uses /api/public/v1/chat/{slug}; streaming uses
/ws/public/v1/chat/{slug} (wss:// over HTTPS). Use the versioned paths for
new clients. See Compatibility for the old
paths and version policy. There is one message or Human answer per socket
connection; SSE is not available.
| Access mode | HTTP identity | Socket identity |
|---|---|---|
anonymous |
Chat cookie, or X-Chat-Visitor |
Chat cookie, auth.visitor, or ?visitor= |
signed_in |
Studio session/Bearer token | Studio session, or studio ?token= on the same origin |
app |
Authorization: Bearer <user JWT> |
First auth.token frame; ?token= is a fallback |
Anonymous and studio clients use the same origin as Superstep (an embedded
page runs inside its Superstep iframe). A direct cross-origin client needs
an app token and an allowed origin: see Third-party auth.
access can be a legacy single string or a list of modes; a valid app token
takes priority over an anonymous cookie. App, anonymous and studio histories
stay separate.
Start a session¶
Obtain a short-lived user token from your own authenticated backend. Here
userToken is that JWT; baseUrl is your Superstep origin, with no trailing
slash, and slug is the chat's page address.
const baseUrl = 'https://studio.example.com'
const slug = 'support'
async function chatHttp(path, options = {}) {
const response = await fetch(`${baseUrl}/api/public/v1/chat/${slug}${path}`, {
...options,
credentials: 'omit',
headers: { ...options.headers, Authorization: `Bearer ${userToken}` },
})
const body = await response.json()
if (!body.success) throw body.error
return body.data
}
const page = await chatHttp('')
const session = await chatHttp('/sessions', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
})
const sessionId = session.id
REST JSON uses {"success":true,"data":...,"error":null}; an error has
success:false and error:{code,message,details}. Logo and attachment
downloads return image/file bytes. All routes are listed in the
HTTP reference.
For same-origin anonymous clients, start with X-Chat-Visitor: new when not
using cookies. Keep data.visitor_token returned by session creation/listing
and send it as X-Chat-Visitor thereafter, and as auth.visitor on sockets.
It is a credential for that visitor's history; do not expose it to other users.
Send and stream¶
This example continues with baseUrl, slug, sessionId and userToken
above. The token is sent in a frame, keeping it out of the URL.
const socketUrl = new URL(`/ws/public/v1/chat/${slug}`, baseUrl)
socketUrl.protocol = socketUrl.protocol === 'https:' ? 'wss:' : 'ws:'
const socket = new WebSocket(socketUrl)
let lastSeq = -1
socket.addEventListener('message', ({ data }) => {
const frame = JSON.parse(data)
if (frame.v !== 1 || frame.seq <= lastSeq) return
lastSeq = frame.seq
switch (frame.type) {
case 'ready':
socket.send(JSON.stringify({ type: 'auth', token: userToken }))
socket.send(JSON.stringify({ type: 'message', chat_session_id: sessionId,
text: 'Where is my order?', attachments: [], locale: 'en' }))
break
case 'token':
// Append frame.delta to the draft answer.
break
case 'question':
// Keep frame.node and show frame.prompt to the person chatting.
break
case 'turn':
// Keep frame.turn_id to rate the saved assistant answer.
break
case 'done':
// Check frame.status, then reload the saved turns over HTTP.
break
case 'error':
// Handle frame.error.code; do not display raw errors as HTML.
break
default:
// Ignore unknown frame types; optional step/run_started frames need no action here.
break
}
})
socket.addEventListener('close', () => {
// A close without done/error is an interruption: recover history before retrying.
})
socket.addEventListener('error', () => {
// Show a connection error without logging the token.
})
Frames¶
Client frames have type and the fields below. Do not add v, seq or
arbitrary extra fields to a client frame; invalid frames return
CHAT_FRAME_INVALID. Send at most one auth, before the operation.
| Client type | Fields |
|---|---|
auth |
token for an app user, or visitor for an anonymous visitor |
message |
chat_session_id, text; optional attachments (uploaded ids), locale (en/vi) |
answer |
chat_session_id, response; optional node from the question and locale |
cancel |
No other fields; send while the run is active |
Every server frame has v:1 and a seq starting at zero and increasing
within that connection. ts, when present, is Unix seconds. Ignore unknown
types and fields. seq restarts on a new socket; it is not a history cursor.
| Server type | Meaning |
|---|---|
ready |
Socket accepted, protocol:"superstep-chat"; authentication/permission is still to be checked |
run_started |
The run's run_id |
token |
Append delta; optional node, field, iteration |
step |
event = started, finished or error, plus node; details depend on the configured steps mode |
question |
A Human node is waiting: node, prompt, kind/input_type, with optional options/fields |
turn |
The stored assistant turn_id, role, optional status and run_id; emitted before done when persistence finishes in time |
done |
status = ok, error, waiting, cancelled or timeout; server closes afterwards |
error |
Stable error.code, English message, optional details; no done is guaranteed after an error |
An illustrative exchange (ids are placeholders):
{"type":"ready","protocol":"superstep-chat","v":1,"seq":0}
{"type":"run_started","run_id":"r1","v":1,"seq":1}
{"type":"token","delta":"Hello","v":1,"seq":2}
{"type":"turn","turn_id":"t2","role":"assistant","status":"ok","run_id":"r1","v":1,"seq":3}
{"type":"done","status":"ok","run_id":"r1","v":1,"seq":4}
Human answers and cancellation¶
After question and done with status:"waiting", open a new socket,
authenticate again and send an answer for the same session:
{"type":"answer","chat_session_id":"session-id","node":"human-node-id","response":"yes","locale":"en"}
Use the question's node and the response type the Human node accepts
(for example text or a boolean approval). The transport schema also permits
form objects; that alone does not add a form runtime. Questions assigned to a
reviewer are not answerable by the visitor (CHAT_ANSWER_BY_REVIEWER).
Do not send attachments with an answer. To cancel an active run, send
{"type":"cancel"} on its socket. Disconnecting also requests cancellation;
fetch history to find the final outcome.
History and reconnection¶
GET .../sessions lists your sessions; GET .../sessions/{sid} includes
saved turns. After a stream or interruption, fetch
GET .../sessions/{sid}/turns?after=<last-saved-turn-id> for newer turns in
order; omit after for all turns. Responses contain data.turns.
An unknown or another session's cursor returns 404 CHAT_TURN_NOT_FOUND.
Use stored turn ids to reconcile the draft stream with persisted history.
Persistence can lag: poll with backoff if the last turn is not saved yet.
There is no socket replay or message idempotency key. Resending text after
a disconnect can start another run, so inspect history first. An interrupted
connection may have no terminal done; seq cannot resume it.
Files and ratings¶
- Read
page.attachments(enabled,kinds,max_files,max_mb). - Upload one file per
POST .../sessions/{sid}/attachments, multipart fieldfile; keepdata.id. The browser sets the multipart Content-Type boundary. - Put those ids in
message.attachments. Files must belong to that session and be unsent. A file already sent cannot be reused or deleted through the unsent-file endpoint. - Download with authenticated
GET .../attachments/{aid}(bytes). Remove an unsent file withDELETE .../attachments/{aid}. JSON deletes returndata.ok; they do not take a confirmation field.
Documents are PDF or UTF-8 .txt, .md, .csv; images are PNG, JPEG or
WebP. Chat settings and operator ceilings apply. An upload consumes the
message-rate quota too. See Attachments.
Rate an assistant turn with status ok or error using
PUT .../sessions/{sid}/turns/{tid}/rating, body {"value":"up"} or
{"value":"down","comment":"Missing the delivery date"}. A down comment
is optional and at most 1,000 characters; an up rating has no comment.
DELETE on the same path removes the rating. Use the saved turn_id,
fetching history if the stream did not include a turn frame.
Errors and limits¶
Handle error.code, not message text. CHAT_SESSION_NOT_FOUND also hides
another user's session. Token errors and origin policy are in the
auth guide; all codes are in
Error codes.
429 RATE_LIMITED carries HTTP Retry-After in seconds (socket errors carry
error.details.retry_after). Back off before opening another socket or
retrying a write. Per-IP read/message limits, per-visitor or external-user
message limits, and the chat's UTC daily cap all apply. Operators configure
STUDIO_PUBLIC_CHAT_RATE_*; see Configuration.
Prepare and execute an edit¶
Check GET .../sessions/{sid} for capabilities.edit and the target turn's editable flag. After the user confirms a new workflow execution, call:
POST /api/public/v1/chat/{slug}/sessions/{sid}/edits
Content-Type: application/json
{"turn_id":"saved-user-turn","message":"Corrected request","operation_id":"client-generated-unique-id","expected_last_turn_id":"latest-saved-turn"}
The envelope's data contains session (the independent branch and its retained prefix), message, attachment_ids, operation_id, the reserved run_id, and state (prepared, started, finished). Preparing does not execute the workflow. For a prepared intent, open the normal v1 socket and send a message using the returned branch ID, normalised message, copied attachment_ids, and edit_operation_id: operation_id. The response stream and all subsequent history/files/Human answers/ratings belong to that branch. Keep the same operation ID and payload when recovering one intent; using it with different content is CHAT_EDIT_CONFLICT. A deliberate new edit needs a new ID.
An already started/finished intent must recover its reserved run through branch history, without sending another execution. CHAT_EDIT_STARTED also means read that branch/run instead of replaying. A lost run-start frame can be recovered with the reserved run ID. A removed branch returns CHAT_EDIT_GONE and is never recreated by retry. A busy source returns CHAT_EDIT_BUSY; changed history returns CHAT_EDIT_STALE. No arbitrary checkpoint, state, owner or workflow ID is accepted. Public identity/origin/revocation rules and normal quotas apply. Old v1 message/answer requests remain unchanged.
Editing retains the original session; inherited prefix turns have fresh IDs and inherited: true, cannot be edited/rated/resumed, and expose only the chat's allowed steps. The branch has optional source_session_id and source_turn_id; a deleted source does not prevent branch history/file access. See chat usage for the rerun warning and workflow-version semantics.
Every initiating message in the source must have a saved terminal outcome; a later finished answer does not make an earlier pending run safe to fork. Agent routing is reconstructed from the latest retained run, including resets after target removal. If required historical agent metadata has been pruned, CHAT_EDIT_CONTEXT_UNAVAILABLE refuses to guess: start a new conversation. A claimed intent whose process stops before saving its run remains non-replayable; bounded history recovery may report interruption. Preparing or reloading a branch never runs it automatically.
Prepare regeneration¶
Check capabilities.regenerate and the latest answer's regeneratable flag. After confirming that external actions may repeat, submit the answer ID (not a user/Human response):
POST /api/public/v1/chat/{slug}/sessions/{sid}/regenerations
Content-Type: application/json
{"turn_id":"latest-terminal-answer","operation_id":"a-new-unique-intent-id","expected_last_turn_id":"latest-terminal-answer"}
The server chooses the initiating root message and its retained prefix; caller-supplied input/state/checkpoints are refused. The response and socket admission reuse the edit pipeline above, including the existing edit_operation_id wire field and ChatEdit response schema name. The SDKs expose this shared result as ChatRevision in TypeScript and use regenerate/prepare_regenerate in Python. Preserve one operation ID across recovery, read admitted runs without executing again, and use a fresh ID for a deliberate new attempt. Action is part of intent identity; reusing an ID across edit/regenerate conflicts.
Branch lineage adds source_action: "edit" | "regenerate". For regenerate, source_turn_id is the original assistant answer; old branches without a stored action imply edit. Source deletion does not erase the branch's action/files. Busy, stale, file-policy, ownership, origin, revocation and quota checks are shared. A waiting Human/reviewer question is not terminal, and its old response/approval is never replayed. Unsupported servers fail before POST; only the latest terminal answer can be selected.
When reading an old admitted intent after its branch has continued, SDK recovery still finds that intent's reserved run rather than starting after newer messages. TypeScript store state preserves a later Human wait while returning the requested run's result.