Streaming

Server-Sent Events for one-way tails, WebSockets for bidirectional chat.

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 referencePOST /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

EventPayloadNotes
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

typepayloadEffect
terminal:inputstringForwarded 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.logs in Firestore — that’s what GET /runs/{runId}/logs reads 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.