FlowKoi exposes two real-time surfaces over the same underlying agent session. Both are authenticated with the same API key and stay open until the run reaches a terminal state.
| Use it when… | SSE (/stream) | WebSocket (/ws/v1/runs/:id) |
|---|---|---|
| You just want to tail output | ✅ | ✅ |
| You want to send messages back into the run | ❌ (pair with POST /messages) | ✅ |
| You want the lowest setup cost | ✅ (curl -N) | Needs a WS client |
| You’re on a browser without WebSocket | ✅ | ❌ |
For mid-run interactions over the SSE path, see the API reference — POST /runs/{runId}/messages and POST /runs/{runId}/files.
Server-Sent Events
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 the Authorization header — use the query fallback:
new EventSource(`https://api.flowkoi.com/api/v1/runs/${runId}/stream?api_key=${apiKey}`);
Event types
| Event | Payload | Notes |
|---|---|---|
output | { "data": string } | Raw terminal output chunk. UTF-8. May be partial mid-byte across chunks; decode incrementally. |
status | { "status": "running" | "sleep", "claudeStatus": string | null } | Sent once on join so late subscribers learn the current state. |
claude_status | { "status": "working" | "idle" | "waiting" | "error" } | Fired by the in-container detector based on spinners, output rate, and prompt patterns. |
filetree | [{ name, path, type, children? }] | Snapshot of the workdir tree. |
finished | { "sessionId": string, "reason"?: string, "exitCode"?: number } | Terminal — stream closes immediately after. |
A : ping comment is sent every 15 s so proxies (nginx, Cloud Run frontends) don’t drop the connection on idle.
Reconnect behaviour
The stream auto-closes on finished. If your TCP connection drops mid-run, reconnect and replay — the server resends the persisted logs and current status on join so you don’t lose context.
There’s no Last-Event-Id cursor yet; replay is unconditional.
WebSocket
wss://api.flowkoi.com/ws/v1/runs/{runId}?api_key=flo_...
Frame format mirrors the SSE events but as JSON envelopes: { type, payload }.
Client → server frames
type | payload | Effect |
|---|---|---|
terminal:input | string | Forwarded into the PTY as keystrokes. SDK adds a trailing newline; raw clients must too. |
file:inject | { files: [{ path, content }] } | Same as POST /runs/{runId}/files but in-band. |
Server → client frames
Same set as SSE: terminal:output, claude:status, session:ended, filetree:update, plus the connection-lifecycle frames session:connected.
SDK helper
import { connect } from "@flowkoi/sdk/ws";
const sock = connect(flowkoi, runId);
sock.on<string>("terminal:output", (data) => process.stdout.write(data));
sock.sendMessage("now run the tests");
sock.injectFiles([{ path: "input.txt", content: "hi" }]);
Node 22+ has a global WebSocket. On older Node, pass a polyfill:
import WebSocket from "ws";
connect(flowkoi, runId, { WebSocket });
Output buffering and replay
Both surfaces are room-backed by the same wsProxy:
- The container’s raw PTY output is broadcast to every live subscriber (WS + SSE).
- The room also persists the full output as
instance.logsin Firestore — that’s whatGET /runs/{runId}/logsreads from. - New subscribers joining mid-run get the full replay first, then live frames.
You will see duplicate output if you both stream AND read logs — pick one.