API guide

Choosing between runs, chat and sessions — and using each one well.

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 wantUseBecause
One task, watch it workRunStreams live, finishes, done
Back-and-forth, simpleChatOne call, one reply, remembers context
Back-and-forth, watch it workSessionStreams 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 into output.

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:

sessionIdyou set it; identifies the conversation
runIdwe 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

EventMeans
turn_endThis message’s reply is complete. Keep reading — the next one arrives here.
finishedThe 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" } }.

CodeWhat to do
RATE_LIMITEDBack off; Retry-After tells you how long
CONCURRENT_LIMITThe project is at its concurrency ceiling — see below
MONTHLY_QUOTARaise the project’s monthly cap, or wait for the 1st UTC
VALIDATION_ERRORCheck the message; it names the offending field
NOT_FOUNDFlow, run or session isn’t in this key’s project
ENDPOINT_RETIREDYou’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

  1. truncated on /chat. A partial reply looks like a complete one.
  2. turn_endfinished. Stop reading on the wrong one and you lose the conversation.
  3. Triggering is two requests. The POST returns a URL; it does not stream.
  4. No WebSocket. Runs accept no input at all.
  5. API keys are project-wide. Never put one in a browser.