A small client you drop into your own site to embed a live agent conversation — open a connection, run a turn, and stream the reply as it forms.
The one client your app talks to.
The Chat SDK is a small, self-contained client that turns a user's prompt into a live agent conversation and streams the reply back as it is produced. You embed it in your own web app; it opens a secure WebSocket to KeenAgents, runs an agent, and hands you the answer token by token through a callback. You work with a single class — ChatAPI: you construct it, register a few callbacks, connect, and call runFlow for each turn.
The connection carries no credentials in its URL — the consumer's short-lived session token rides inside the run, and the platform re-verifies it on every turn. Everything on the connection is tenant-scoped: a client opened for one tenant can only ever reach that tenant's agents and data.
Construct ChatAPI once, with the identity of the signed-in consumer.
You create one client per open conversation, passing the space it targets, the agent to run, and the signed-in consumer's identity and session token. Build it once (not on every render) and keep the instance around — the socket only opens when you call connect().
new ChatAPI({ … })
import { ChatAPI } from '@your-org/keen-chat-sdk'; const chat = new ChatAPI({ spaceID, // the space the run targets (also the project id) agentID, // the agent SLUG — not the admin row id userId, // the signed-in consumer's id email, // the signed-in consumer's email accessToken, // the consumer's short-lived session token url // the platform chat endpoint (wss:// in production) });
projectID is optional and defaults to this value.wss:// URL in production; a plain ws:// URL only for local http development. Point it at the platform — not at your own dev server — or the connection closes on its own.{ name: value } map. Covered under Passing Cookies to Your Flow.agentID must be the agent's slug. Passing the admin row id is refused as agent-not-found, and renaming a slug immediately changes what a run resolves to — keep your client in sync when a slug changes.Register callbacks, connect, then send one turn at a time.
Register your callbacks before connecting so no early frame is missed, then open the socket. Each user message is one call to runFlow, keyed by a chatId that groups the turns of one conversation.
connect → runFlow
// 1) Wire callbacks BEFORE connect() — see "The Callbacks You Wire". chat.onStatus(status => setStatus(status)); // 'disconnected' | 'connecting' | 'connected' chat.onRunning(running => setRunning(running)); // true while a turn is in flight chat.onStream((event, data) => { if (event === 'token') appendToReply(data.text); // the live answer else showStatusRow(event, data); // thinking / processing / error }); // 2) Open the connection. chat.connect(); // 3) Send a turn. chatId is required; it groups this conversation's turns. await chat.runFlow({ chatId, prompt });
The awaited value is not the answer. runFlow resolves to a snapshot of the run for inspection; the text a user reads arrives through onStream as token events that you accumulate into one reply. This is the single most important thing to internalize about the SDK.
Only one turn runs at a time — a second runFlow while one is in flight is rejected, which is why you gate your Send control on the running state. A turn can be stopped with cancel():
send + cancel
async function send(prompt) { try { await chat.runFlow({ chatId, prompt }); } catch (err) { // A canceled turn settles as a cancellation, not an error — show a quiet // "Canceled." row; anything else is a real failure row. } } // Stop a response in progress; the conversation stays open for the next turn. chat.cancel();
disconnect() when the conversation view unmounts so route changes don't leak connections.Four subscriptions carry everything you render.
event and its data: token events accumulate into one reply bubble, while thinking, processing and progress render as status rows so a long, multi-step run reads as progress rather than a frozen screen, and error renders as an error row.disconnected, connecting, connected. Drive your connection indicator off it and gate Send on connected.true the instant a turn starts and false when it settles — success, cancel, or failure. This is the canonical Send/Cancel gate; pair it with onStatus.onStream.The split worth memorizing is onStream for application output versus onSystem for transport diagnostics. Two further callbacks exist for advanced cases — one that fires once when the connection is fully established, and one that carries every reply for a log or stats panel — but most apps never need them.
Here is exactly what each callback hands you and in what shape, with an example.
onStream — the answer and progress
// onStream(event, data, requestId?) // event: 'thinking' | 'processing' | 'progress' | 'token' | 'error' // data: the frame body for that event (envelope already stripped) chat.onStream((event, data) => { if (event === 'token') { // data = { text: 'Hello', requestId: 'r-8f2a…' } — the next slice of the answer appendToReply(data.text); } else if (event === 'error') { // data = { text: 'Script node failed: timeout' } — a run-level failure to render as an error row showErrorRow(data.text); } else { // 'thinking' | 'processing' | 'progress' — data.text is a human-readable status line showStatusRow(event, data.text); } });
onStatus and onRunning — plain values for UI state
// onStatus(status) — status is one string // 'disconnected' | 'connecting' | 'connected' | 'unauthorized' chat.onStatus((status) => setConnected(status === 'connected')); // onRunning(running) — a boolean: true the instant a turn starts, false when it settles chat.onRunning((running) => setSendDisabled(running));
onSystem — a diagnostic string (never the answer)
// onSystem(text) — text is a plain string describing a transport event chat.onSystem((text) => { // examples of what actually arrives here: // 'open wss://…' the socket opened // 'close (code 1006)' dropped — Origin not allow-listed, wrong URL, or network blip // 'close (code 1009 …)' a frame exceeded the 8 MB cap // 'socket error' a low-level connection error console.debug('[transport]', text); });
onWelcome and onResponse — advanced
// onWelcome(payload) — fires once, right after the connection is established chat.onWelcome((payload) => { // payload = { correlationID: 'c-4a1b…' } — the id the platform tags this connection with console.debug('connected as', payload.correlationID); }); // onResponse(envelope, requestId) — every completed reply; for a log/stats panel, not the chat bubble chat.onResponse((envelope, requestId) => { // envelope = { success: true, code: '…', message: 'OK', data: { … } } // success — did the turn complete; message — a short human-readable note logPanel.push({ requestId, ok: envelope.success, note: envelope.message }); });
Refresh the token on a live client — never reconnect for it.
A conversation can stay open longer than a single session token lives, and the platform verifies the token on every turn. So a long-lived chat would eventually start refusing sends. The client is built for this: the access token is mutable through setAccessToken(), so a refreshed token lands on the next turn without dropping the socket.
setAccessToken
// Push a freshly minted token onto the live client — no reconnect.
chat.setAccessToken(freshAccessToken);A practical pattern is to refresh on an interval while the chat is open and hand the new token to setAccessToken(). If the token has already expired mid-conversation the socket stays open and the send is refused — refresh and retry on the same connection. Only when the session is genuinely over (no new token can be minted) do you sign the user out; leave the client alone until then.
What the client does between your send and the settled reply — and how it treats each outcome.
A single runFlow call hands the turn to the client's internal run queue, which drives the whole exchange for you: it sends the opening frame, works through the flow's nodes as the platform reports them, streams tokens to onStream as they arrive, and settles when the run reaches its end. You await one call; the queue does all the step-by-step bookkeeping. On every step it reads a status and decides what to do — and that decision is the behavior worth understanding.
onRunning to false, and delivers the reason (agent errors on onStream, transport on onSystem). See the catalog below for each case.Canceled — no error, no dangling work.contextBatchSize.onRunning is the canonical Send/Cancel gate. Gate your Send button on onRunning and onStatus so a new turn can't start on top of one still in flight.What can fail, what it means, and whether the platform fixes it, you fix it, or you retry.
Failures reach your app through the callbacks you already wire — never as a thrown exception you have to catch around a send. The rule of thumb: an agent- or flow-level failure lands on onStream as an error event you render as an error row, while a transport failure lands on onSystem. In both cases onRunning flips back to false, so your Send button re-enables on its own. Each failure below is tagged with how the system treats it.
1009 (message too big). Fix: keep large data out of the prompt and pass a reference your flow resolves.setAccessToken(), and retry on the same connection — no reconnect. Sign the user out only when no new token can be minted.onStream as an error event carrying a human-readable reason. Render it as an error row. The conversation and socket stay usable for the next turn; nothing is torn down.Canceled, and onRunning flips to false. Send the next turn whenever you like.onSystem (never as the agent's answer). The one you'll actually see is close 1006 (abnormal): usually the page's Origin isn't allow-listed, the WebSocket URL is wrong, or the network blinked. Fix: confirm the URL points at the platform and the calling Origin is registered for your API key, then reconnect.onStream, log transport events from onSystem, and drive your UI state from onRunning and onStatus. The client already distinguishes "retry this", "refresh the token", and "this one is terminal" for you.onStream, not from the runFlow return value. Wire the stream first; the awaited value is for inspecting the run.connect(), construct the client once (not per render), and disconnect() when the view unmounts — that keeps sockets from leaking across navigations.Keen Agents 2026
Documentation
Release 15