Everything streams over Server-Sent Events. One endpoint serves both runs and chat sessions, because a session is an instance like any run:
GET /api/v1/runs/{runId}/stream HTTP/1.1
Host: api.flowkoi.com
Authorization: Bearer flo_...
Accept: text/event-stream
Browser EventSource can’t set headers — use the query fallback:
new EventSource(`https://api.flowkoi.com/api/v1/runs/${runId}/stream?api_key=${key}`);
SSE is not a separate protocol. It’s an ordinary HTTP response with
Content-Type: text/event-stream, delivered in pieces as they’re produced. Any
client that can read a response body incrementally can read it — no library
required.
There is no WebSocket. Runs accept no input, and conversations happen through
POST /flows/{flowId}/chat.
Event catalogue
Lifecycle
| Event | Payload |
|---|---|
status | { status, claudeStatus } — sent once on connect |
claude_status | { status: "working" | "idle" | "waiting" | "error" } |
filetree | [{ name, path, type, children? }] |
finished | { exitCode?, sessionId, reason? } — the stream then closes |
reason is no_upstream if the container never started, or already_ended if
the run was over before you connected.
Content — published twice, deliberately
Every run publishes its content in two independent forms. Read whichever suits you and ignore the other.
Rendered — always a string:
| Event | Payload |
|---|---|
output | { data: string } — one readable line. On connect, the first output replays the buffered log so a late subscriber sees the whole run. |
Structured — the agent’s own events, forwarded verbatim. These carry the tool
names and their arguments, tool results, token usage and cost — none of which
survives into output:
| Event | Payload |
|---|---|
system | { subtype: "init", model, tools, … } |
assistant | { message: { content: [...] } } — blocks are text, tool_use (with name and input), or tool_result |
user | same shape; carries tool results fed back to the model |
result | { is_error, result, total_cost_usd, usage } |
stream | a structured event whose own type collided with a name above; payload unchanged |
Unknown event names are forward-compatible additions — skip them rather than failing. The agent emits types this API was not designed around, and they arrive named by their own type rather than being dropped.
Chat sessions
Two further events apply when the instance is a session:
| Event | Payload |
|---|---|
chat_output | { text: string } — reply prose as it is produced |
turn_end | { exitCode, sessionId } — end of one turn |
turn_end does not close the connection. A session outlives its turns: keep
reading and the next message’s output arrives on the same stream. Only finished
means the session itself is over.
One stream, many turns
POST /flows/{id}/sessions → { sessionId, runId, streamUrl }
GET {streamUrl} → open it, leave it open
POST /flows/{id}/chat → { message, sessionId, stream: true } → 202
◄══ turn_start … chat_output … turn_end
POST /flows/{id}/chat → next turn, same stream
streamUrl stays valid for the whole session — the session’s runId doesn’t
change between turns.
Holding the stream open is worth doing for a second reason: an open subscriber counts as a present client, which moves the container’s idle timeout from five minutes to sixty. A user who takes a while to reply then doesn’t pay a cold start.
Keepalive and reconnection
A : ping comment every 15 seconds keeps proxies (Cloud Run, nginx) from
closing an idle connection. SSE comments are ignored by every conformant client.
If your connection drops, reconnect and read again — the server replays the
persisted logs and current status on join, so you don’t lose context. There
is no Last-Event-Id cursor yet; replay is unconditional.
Blocking instead
If you’d rather not stream, POST /flows/{flowId}/chat without stream: true
blocks and returns the whole reply. It gives up after 120 seconds and returns
whatever arrived, with truncated: true — check that field, because a truncated
reply reads exactly like a finished one and the flow keeps running behind it.
For long work with no connection held at all, pass callbackUrl on the run
trigger and take a webhook instead.