Skip to main content

Watching Agent Runs

Overview

An agent run streams as it happens: text, reasoning, tool calls, tool results, and a terminal done. Until now the only way to see that stream was the WebSocket path — mint a short-lived JWT at GET /api/v1/ws-token, upgrade the connection, subscribe to the session:{chatId} channel. That is the right shape for the dashboard, which already holds a socket open. It is the wrong shape for a script, a CI step, or an agent driving Crewship from a shell, all of which would have to implement a WebSocket client and a token dance before reading a single line. crewship chat stream and the endpoint behind it, GET /api/v1/chats/{chatId}/stream, close that gap. Same events, same ordering, same access control — delivered as newline-delimited JSON over the ordinary authenticated HTTP surface. curl -N is a sufficient client. The WebSocket path is untouched; this is additive. Both read one event source and one replay buffer, so they cannot drift apart on what you are allowed to watch or on what order events arrive in.
Every run with a chat streams — not just the ones you started. A run dispatched by a routine step, a webhook trigger, the scheduler (a cron’d agent) or the internal agent-start IPC publishes on the same session channel as a chat message does, so crewship chat stream <chat-id> works on all of them. Take the chat id from the routine’s step, the chat list, or the run record, and attach.Two kinds of run are deliberately not streamed:
  • A run with no chat. The agent-start IPC can execute an agent without creating a chat row. No chat, no session: channel, nothing to attach to — use the Crew Journal for those.
  • A delegated or peer sub-agent. When an agent delegates work (assignments, peer queries), the sub-agent runs against the delegating chat’s id. Its output belongs to the parent agent’s turn, so it is not published as a turn of its own; you see the parent’s reply, not the sub-agent’s raw stream.

When to use it

  • Watch a run somebody else started. crewship run and crewship ask already follow the run they launch. This follows a run started by the web UI, another shell, a routine step, a webhook or the scheduler — you only need the chat id.
  • Script against agent output. --format ndjson prints the server’s frames verbatim, one JSON object per line, which is what jq wants. crewship chat stream <id> --format ndjson | jq -r 'select(.type=="text").content' is the whole integration.
  • Capture a reply to a file. The run’s text goes to stdout and nothing else does, so crewship chat stream <id> > reply.md yields the reply and only the reply. Thinking, tool activity and stream notices go to stderr.
  • Gate a pipeline on a run. The command exits when the run finishes and exits non-zero if it ended in an error, so crewship chat stream <id> || notify-failure works.
  • Tail a session over time. --follow keeps the connection open past a run’s terminal event, so the next run on the same session streams too.
Reach for the WebSocket path instead when you need to send into the chat mid-stream (send_message, cancel_message), or when you are building a browser UI that already holds a socket.

Key concepts

Quickstart

Raw HTTP, for anything that is not the CLI:
-N matters: without it curl buffers and you see the whole run at the end instead of as it happens.

What the stream looks like

stream.open is always first and stream.end is always last. stream.end carries a reason: stream.heartbeat appears after roughly 20 seconds of silence. It exists so proxies and NAT tables do not reap an idle connection; ignore it. It deliberately does not reset the idle timer — a heartbeat proves the socket is alive, not that the run is.

Reconnecting without losing or duplicating output

Every run frame carries a seq. The CLI tracks the highest one it printed and, if the connection drops, reconnects on a bounded backoff with ?last_seq=<n>. The server replays the buffered gap, then resumes live delivery. A frame you already saw is never printed twice, because the reader drops anything at or below its watermark. Two limits are worth knowing:
  • Replay is only offered while the run is active. A finished run is already persisted, so its transcript comes from crewship chat <chat-id> (GET /api/v1/chats/{chatId}/messages). Replaying it here too would double it.
  • The buffer is capped at 5000 frames or 8 MiB per run. Past that the run loses its replayability for the rest of its life. What that means depends on who is asking: a caller resuming with last_seq is ended with reason: replay_truncated, because serving the surviving tail would render as a complete run to a client with no way to know the head is missing. A first attach asked for no replay, so nothing it wanted was lost — it gets an informational stream.reset telling it earlier output is unavailable, and then streams live as normal.
  • Access is re-checked while you stream, not only when you connect. If you are removed from the chat’s workspace mid-run the stream ends with reason: access_revoked within about 30 seconds.

Flags and parameters

The CLI flags map onto the endpoint’s query parameters:

Access control

Streaming a chat requires the same authorization as subscribing to its WebSocket channel: an authenticated caller who is a member of the chat’s workspace. Tenancy is resolved from the chat row itself, so the route takes no workspace parameter and ignores X-Workspace-ID — there is nothing for a caller to get wrong. A chat you may not watch answers 404, the same as a chat that does not exist. That is deliberate: a 403 would confirm the id is real in somebody else’s workspace.

Gotchas

  • curl without -N buffers the whole run. You will see everything at once, at the end, and conclude the stream is broken.
  • A chat with no active run ends immediately. That is the honest answer — nothing is generating, and a finished run’s transcript comes from crewship chat <chat-id>, not from here. To wait for a run instead of watching one already in flight, use --follow (bounded by --idle so it still terminates).
  • error is followed by done. The CLI records the error, waits for the terminal frame, then exits non-zero. Do not treat the first error frame as the end of the stream.
  • Streaming does not suppress your inbox item. Unlike a browser tab on the chat, an open HTTP stream does not count as “watching live”, so the “agent replied” inbox item is still created. That is deliberate: the inbox row is the durable record of the reply, and a stream that is redirected to a file — or wedged on a reader that stopped consuming — is no evidence anyone saw it. A redundant bell beats a lost record.
  • An unattended run’s reply is kept, but never belled. A routine step, a scheduled agent or a webhook writes its turn to the chat like any other run, so crewship chat <chat-id> shows the prompt and the answer after the stream is over. It raises no “agent replied” inbox item, because nobody asked it a question — the bell exists for a human who sent a message and walked away. Routine outcomes have their own notifications; see the notify step and Notification categories.