Streaming

Server-Sent Events — the event catalogue, and how a session's stream spans many turns.

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

EventPayload
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:

EventPayload
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:

EventPayload
system{ subtype: "init", model, tools, … }
assistant{ message: { content: [...] } } — blocks are text, tool_use (with name and input), or tool_result
usersame shape; carries tool results fed back to the model
result{ is_error, result, total_cost_usd, usage }
streama 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:

EventPayload
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.