Watching Agent Runs
Overview
An agent run streams as it happens: text, reasoning, tool calls, tool results, and a terminaldone. 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 runandcrewship askalready 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 ndjsonprints the server’s frames verbatim, one JSON object per line, which is whatjqwants.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.mdyields 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-failureworks. - Tail a session over time.
--followkeeps the connection open past a run’s terminal event, so the next run on the same session streams too.
send_message, cancel_message), or when you are building a browser UI that already holds a socket.
Key concepts
Glossary — frame, seq, replay, control frame
Glossary — frame, seq, replay, control frame
Quickstart
-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 aseq. 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_seqis ended withreason: 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 informationalstream.resettelling 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_revokedwithin 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 ignoresX-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
curlwithout-Nbuffers 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--idleso it still terminates). erroris followed bydone. The CLI records the error, waits for the terminal frame, then exits non-zero. Do not treat the firsterrorframe 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 thenotifystep and Notification categories.
Related
- Chat & Sessions — the session model this streams from.
crewship chat— the full command tree, includingchat stream.- Conversations API — the endpoint reference, frame-by-frame.
- Crew Journal — the workspace-wide event stream, if you want everything rather than one run.