Skip to content

The event stream ​

POST /prompts and POST /runs/resume answer text/event-stream: the run's events as they happen, then one terminal frame. Each frame is a name and one JSON line:

text
event: event
data: {"type":"text_delta","text":"Added pagination to "}

event: event
data: {"type":"tool_started","call_id":"call-1","name":"replace_in_file","arguments":{"path":"src/users.py"}}

event: result
data: {"text":"Added pagination …","session_id":"01a15…","usage":{…},"tools":[…],"stop":null}

A blank line ends a frame. The stream closes after its terminal frame. Three names occur:

EventPayloadMeaning
eventone of the types belowSomething happened during the run.
resulta run resultThe model finished; this is what a non-streaming client would have returned.
error{"error","message"}The run failed after the stream opened.
cancelled{"type":"cancelled"}The run was stopped (/runs/cancel, or the connection closed).

The events ​

typeFieldsMeaning
text_deltatextA piece of the answer; append.
reasoning_deltatextA piece of the model's reasoning, for the providers that report it.
reasoning_started—A span of reasoning began.
reasoning_finished—The span ended, whether the model went on to answer or to call a tool.
tool_startedcall_id, name, argumentsA tool call began.
tool_finishedcall_id, name, output, ok, skippedIt ended, in success or failure; skipped when steering took over.
approval_requestedcall_id, command, work_dir, approvedThe model wants to run a shell command. See below.
question_askedcall_id, question, options, allow_multiple, answeredThe model asked something.
usage_updatedusageThe session's totals after a model call.
compaction_started—The conversation is being summarized.
compaction_deltatextA piece of the summary being written; append.
compaction_finishedapplied, summaryIt ended.
model_stoppedreason, messageA step stopped for something other than finishing: refusal, max_output, context_full, paused, interrupted, other.
steeredtextA message sent with /runs/steer reached the running request.
steering_finished—The request that adopted a steering message has answered it.
steering_interruptedtextA steering message was superseded by a newer one before the request got to it.
noticetext, levelNews that is not part of the answer, such as an MCP server starting or down, or an answer that timed out. level is "info" for progress and "warn" for something you may need to act on; a frame without one is progress.
finishedtextThe run's last event before its result.
eventnameAn event this server does not know; kept rather than dropped, so a newer SDK does not look like silence.

Answering a run ​

A run that needs an answer is suspended, not guessed. With approvals.mode: "ask" a shell command arrives as an approval_requested frame and the run waits; with questions: "ask" a question arrives as question_asked and the run waits. The answer is a second request, which is what lets a client with one connection still drive both directions:

bash
# terminal A: the stream stops after this frame
event: event
data: {"type":"approval_requested","call_id":"call-1","command":"pytest -q","work_dir":"clients/web","approved":null}

# terminal B: the answer
curl -X POST http://127.0.0.1:3290/runs/approvals \
  -H 'content-type: application/json' \
  -d '{"call_id": "call-1", "approve": true, "remember": true}'
json
{"resolved": true}
  • work_dir is the directory the command runs in when the call names one instead of the workspace, and null otherwise; a client that shows where a command will run reads it here. What "remember" stores is still the command text alone, so a remembered command is offered again wherever it is run.
  • answer a question with POST /runs/questions {"call_id": "...", "answer": "sqlite"}; null declines it.
  • resolved is false when the run is not waiting on that call — because it was already answered, or the run ended.
  • An approval waits at most answer_timeout seconds (300 by default) and then takes the safe default: the command is denied and a notice frame says so. A question has no deadline: it waits until it is answered, or until the run is cancelled or stopped.
  • GET /runs/current says what is waiting, so a reconnecting client can show the request again:
json
{"running": true, "pending": {"kind": "approval", "call_id": "call-1", "command": "pytest -q", "work_dir": "clients/web", "remember_supported": true}}

The approved field of the frame is null because the frame asks the question; the answer is the POST's. With deny, auto or commands, the rule answers before the frame is sent and approved is true or false.

Steering, stopping, continuing ​

CallEffect
POST /runs/steer {"text": "also update the docs"}The run drops what it is doing and takes the message into account. {"steered": false} when nothing is running; otherwise text is what the steered event will carry. images and mentions work as on a prompt.
POST /runs/cancelStops the run; its stream ends with cancelled. Closing the connection does the same.
GET /runs/resumable{"resumable": true, "kind": "error", "description": "The provider dropped the connection"} when a request was interrupted.
POST /runs/resumeContinues it, streaming exactly like /prompts. 409 nothing_to_do when there is nothing to pick up.

A client loop ​

ts
const response = await fetch(`${base}/prompts`, {
  method: "POST",
  headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
  body: JSON.stringify({ text, approvals: { mode: "ask" }, questions: "ask" }),
});
for await (const frame of frames(response.body!)) {
  switch (frame.event) {
    case "event":
      handleEvent(frame.data);
      if (frame.data.type === "approval_requested") {
        const approve = await askTheUser(frame.data.command);   // a dialog in the editor
        await fetch(`${base}/runs/approvals`, {
          method: "POST",
          headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
          body: JSON.stringify({ call_id: frame.data.call_id, approve }),
        });
      }
      break;
    case "result":
      return frame.data;
    case "error":
      throw new Error(frame.data.message);
  }
}

frames is SSE parsing: split on a blank line, take the event: name and join the data: lines. Nothing here needs a browser, so a Node extension reads the same stream with response.body as an async iterable.

Released under the MIT license.