The quickstart gets one run working. This page is about choosing the right shape for what you’re building, and the handful of things that are easy to get wrong.
Pick a shape first
Everything else follows from this.
| You want | Use | Because |
|---|---|---|
| One task, watch it work | Run | Streams live, finishes, done |
| Back-and-forth, simple | Chat | One call, one reply, remembers context |
| Back-and-forth, watch it work | Session | Streams and remembers |
Run POST /flows/{id}/runs → { runId, streamUrl }
GET {streamUrl} → events until `finished`
Chat POST /flows/{id}/chat → { reply, sessionId }
POST /flows/{id}/chat → same sessionId, remembers
Session POST /flows/{id}/sessions → { sessionId, runId, streamUrl }
GET {streamUrl} → open it, leave it open
POST /flows/{id}/chat → stream:true, 202, output on the stream
A run is disposable: it executes once and its container exits. A session persists — the same working directory and conversation history across every message, until it goes idle.
There is no WebSocket. Runs take no input; conversations happen through
/chat. Anything you read elsewhere about /ws/v1/runs/:id, terminal:input
frames or @flowkoi/sdk/ws describes a surface that no longer exists.
Authentication
Authorization: Bearer flo_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Browser EventSource can’t set headers, so the stream endpoint also accepts
?api_key=. Query keys land in access logs — prefer the header wherever you
can set one.
An API key grants everything in its project: listing flows, triggering any of them, reading and writing run workdirs. Do not ship one to a browser or any untrusted client.
Runs
curl -X POST https://api.flowkoi.com/api/v1/flows/$FLOW/runs \
-H "Authorization: Bearer $FLOWKOI_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"prompt":"Summarise yesterday'\''s error logs."}'
Returns immediately with a runId and a streamUrl. That’s two requests —
the POST does not itself stream. Open the streamUrl to watch:
curl -N "$STREAM_URL" -H "Authorization: Bearer $FLOWKOI_API_KEY"
event: system
data: {"type":"system","subtype":"init","model":"claude-opus-5"}
event: assistant
data: {"type":"assistant","message":{"content":[
{"type":"tool_use","name":"Bash","input":{"command":"grep ERROR app.log"}}]}}
event: output
data: {"data":"[tool: Bash]\r\n"}
event: result
data: {"type":"result","is_error":false,"total_cost_usd":0.0412}
event: finished
data: {"exitCode":0,"sessionId":"3f2b…"}
Two kinds of event, pick one
Every run publishes its content twice, in two independent forms:
output— one rendered line, always a string. Simple; good for a log view.system/assistant/user/result— the agent’s own structured events. These carry the tool names and their arguments, tool results, token usage and cost. None of that survives intooutput.
Read whichever suits you and ignore the other. If you need to intercept a specific tool call and its arguments — to render a diff for approval, say — you want the structured ones:
for await (const ev of flowkoi.runs.stream(runId)) {
if (ev.type === "assistant") {
for (const block of ev.message.content) {
if (block.type === "tool_use" && block.name === "propose_edit") {
renderDiff(block.input); // the arguments, intact
}
}
}
if (ev.type === "finished") break;
}
Treat unknown event names as forward-compatible additions and skip them. The agent emits event types we didn’t design for — they arrive named by their own type rather than being dropped.
How much do you see while it works?
By default, message granularity. You learn what is happening the moment it
happens — the tool_use event above arrives before the tool runs, carrying its
arguments — but the prose of a reply lands in one piece when the message
finishes, and a long thinking pass is silent until it completes:
t=2.3 assistant tool_use: Bash { command: "pnpm test" }
⋮ 45 seconds of nothing but a `: ping` every 15s
t=47.1 user tool_result
t=47.2 assistant "Three tests fail…" ← the whole sentence, at once
That’s enough for a ”🔧 Running tests…” indicator, and it’s the right default for
anything unattended. For something a human watches type, add
"partialMessages": true to the run or session:
POST /flows/{id}/runs
{ "prompt": "…", "partialMessages": true }
Deltas then arrive as stream_event — Anthropic’s standard shapes, with
content_block_delta carrying a text_delta or thinking_delta. Thinking
streams too, so the silent reasoning pass becomes visible.
if (ev.type === "stream_event" && ev.event.type === "content_block_delta") {
const d = ev.event.delta;
if (d.type === "text_delta") appendReply(d.text);
if (d.type === "thinking_delta") appendThinking(d.thinking);
}
It’s off by default because it’s token-rate traffic to every subscriber — worth
it for an editor, wasted on a nightly job. The output event is unaffected
either way; deltas don’t appear in it.
Chat
Choosing a sessionId
You pick it, or omit it and one is generated and returned. Reuse the same string to continue a conversation; a new string starts fresh — same flow, new working directory, no memory of the last one.
Make it unique per end-user conversation. It is the lookup key: two of your
users sharing a sessionId share a container, a working directory and each
other’s history. Key it off your own conversation or thread id — never off
something coarse like a flow name, a tenant name or a date.
It is scoped per flow, so conv_abc on flow A and flow B are two independent
sessions.
Don’t confuse it with runId:
sessionId | you set it; identifies the conversation |
runId | we set it; the session’s instance — use it for streamUrl and logs |
runId is stable for the life of the session, which is why one stream covers
every turn. Lost it? POST /flows/{id}/sessions with the same sessionId
returns it again.
One call, one reply, context preserved by sessionId:
const a = await flowkoi.flows.chat(flowId, { message: "What broke last night?" });
const b = await flowkoi.flows.chat(flowId, {
message: "Show me the stack trace",
sessionId: a.sessionId, // ← same conversation
});
Check truncated. This path blocks, and gives up after 120 seconds:
if (a.truncated) {
// `reply` is only what arrived by the deadline — and the flow is still
// running. Send another message on the same sessionId to collect the rest,
// or switch to a session and stream.
}
A truncated reply is not marked in the text and frequently reads as a finished, if unhelpful, answer. If your flows do real work, assume you will hit this.
Sessions — streaming and memory
Open the stream first, then send messages. Output arrives on the stream that’s already open, and stays open across every turn.
const s = await flowkoi.flows.session(flowId);
const stream = flowkoi.runs.stream(s.runId); // open BEFORE the first message
await flowkoi.flows.chat(flowId, {
message: "What broke last night?",
sessionId: s.sessionId,
stream: true, // 202 immediately
});
for await (const ev of stream) {
if (ev.type === "session_ready") markReady(); // setup finished
if (ev.type === "chat_output") process.stdout.write(ev.text);
if (ev.type === "turn_end") break; // this turn is done
}
The bootstrap call exists precisely so the stream can be open before the first message produces output — otherwise turn one is the only turn you can’t watch live.
session_ready arrives once, when the container has finished setting up. You
don’t have to wait for it — a message sent during setup queues for up to 45
seconds rather than failing — but it’s the signal to show a ready state instead
of a spinner.
If you get disconnected
Reopen the stream and you’re caught up. Conversational frames are buffered per
session and replayed in order, so you get the reply text produced while you were
away and any turn_end that fired during the gap. The turn never stopped — it
isn’t tied to your connection.
If you lost the runId too, POST /flows/{id}/sessions with the same
sessionId returns it again; the session’s instance doesn’t change.
The buffer is bounded, so a very long answer can outrun it. You’ll receive
replay_truncated before the partial replay if so — treat it as “backfill
from the log”, not as a warning you can ignore:
GET /runs/{runId}/logs?format=structured&channel=chat
turn_end is not finished
| Event | Means |
|---|---|
turn_end | This message’s reply is complete. Keep reading — the next one arrives here. |
finished | The session itself is over. The stream closes. |
Closing your reader on turn_end and reopening per message works, but throws
away the reason to use a session: an open stream also keeps the container warm
(60 minutes instead of 5), so the next message doesn’t pay a cold start.
There is no deadline on this path. Silence is just silence; a : ping comment
every 15 seconds keeps proxies from closing the connection.
Scoping a session to one tenant
Sessions take the same configuration as runs, and keep it:
POST /flows/{id}/sessions
{
"mcpServers": {
"acme": { "type": "http", "url": "https://mcp.acme.com/mcp",
"headers": { "Authorization": "Bearer <tenant-token>" } }
},
"mcpMode": "replace",
"disallowedTools": ["mcp__acme__update_topic"],
"appendSystemPrompt": "You are editing topic 42. Propose, never write."
}
The lifetime differs from a run. A run consumes its configuration on first
boot — one container, one credential. A session keeps it, because a session
outlives its container: idle after 5 minutes unattended, 60 with the stream held
open, then respawned by the next message. A consumed config would bring that
respawn back with no MCP servers and no disallowedTools, silently — your
tenant scoping and your “never writes directly” guarantee would both evaporate
mid-conversation, with nothing to indicate it.
disallowedTools is enforced on every turn, not just the first: the chat engine
spawns a fresh agent per message and rebuilds its restrictions each time.
Rotating a credential mid-session
A warm session can hold a container for an hour. A tenant-scoped token is often good for less. Rotate without restarting:
PATCH /flows/{id}/sessions/{sessionId}/config
{ "mcpServers": { … new token … }, "mcpMode": "replace",
"disallowedTools": ["mcp__acme__update_topic"] }
Replaces rather than merges — send the whole configuration. A partial update of a security boundary is a way to leave half a policy in force.
Applies to the next turn; one already running finishes as it started.
delivered: false is not an error — it means the session was asleep, and the
new configuration applies when the next message wakes it.
Per-run configuration
Four fields on the run trigger, all scoped to that run’s container alone.
POST /flows/{id}/runs
{
"prompt": "Rewrite the installation section.",
"mcpServers": { // this run's tools, this run's token
"acme": { "type": "http", "url": "https://mcp.acme.com/mcp",
"headers": { "Authorization": "Bearer <per-run-token>" } }
},
"mcpMode": "replace", // or "merge" (default)
"appendSystemPrompt": "You are editing topic 42. Propose, never write.",
"disallowedTools": ["mcp__acme__update_topic"],
"env": { "ACME_WORKSPACE": "ws_9f2c" }
}
mcpServers is how one flow serves many tenants. Mint a scoped token per
run and pass it as a header — each run gets its own container, so the token is
visible to that run and nothing else. The alternative, rewriting a flow’s shared
MCP config before each trigger, races between concurrent runs and hands one
tenant’s token to another’s run.
disallowedTools is enforced, not advisory. A named tool is absent from the
session entirely. Use it when “the model must not do X” has to be a guarantee
rather than a request — a prompt is only ever a request.
Some constraints worth knowing up front:
- Remote MCP transports only (
http,sse). TLS required off localhost. - None of these can be combined with an idempotency key — a replayed key returns the original run, still carrying the first call’s configuration, and silently ignoring new configuration is exactly the failure they exist to prevent.
- Platform-owned environment names (
CLAUDE_*,ANTHROPIC_*,FLOW_*,PATH, …) are rejected rather than ignored, so a typo fails loudly.
Errors and retries
Every error is { "error": { "code", "message" } }.
| Code | What to do |
|---|---|
RATE_LIMITED | Back off; Retry-After tells you how long |
CONCURRENT_LIMIT | The project is at its concurrency ceiling — see below |
MONTHLY_QUOTA | Raise the project’s monthly cap, or wait for the 1st UTC |
VALIDATION_ERROR | Check the message; it names the offending field |
NOT_FOUND | Flow, run or session isn’t in this key’s project |
ENDPOINT_RETIRED | You’re calling /runs/{id}/messages; use /chat |
CONCURRENT_LIMIT may not be your fault. The ceiling counts every running
instance in the project — Portal visitors, Slack conversations, scheduled runs,
and anyone with the IDE open — but only API calls are rejected by it. So you can
be refused capacity that non-API activity is holding, and retrying won’t help
until it frees up. If you’re sharing a project with other surfaces, size the cap
for the total, not for your share.
For triggering, use an Idempotency-Key so a network retry doesn’t start
two runs. For streaming, just reconnect — the server replays buffered output
on join.
Webhooks
Pass callbackUrl on the trigger and you get a signed run.finished POST
instead of holding a connection:
X-FlowKoi-Signature: t=<unix>,v1=<hmac-sha256>
X-FlowKoi-Event: run.finished
Verify it with verifyWebhook from @flowkoi/sdk/webhook. Three attempts, at
2s, 10s and 30s. Useful when runs are long and you’d rather not keep a stream
open — see Webhooks.
The five things that catch people out
truncatedon/chat. A partial reply looks like a complete one.turn_end≠finished. Stop reading on the wrong one and you lose the conversation.- Triggering is two requests. The POST returns a URL; it does not stream.
- No WebSocket. Runs accept no input at all.
- API keys are project-wide. Never put one in a browser.