Streaming to MCP and REST clients¶
Long-running work streams to clients as it happens instead of being polled for: a command's output as it prints, background processes you can attach to, a session's or job's events as the agent produces them, and file changes in a sandbox. Everything here goes through the sandbox's daemon (hangar-envd on Docker and Kubernetes, E2B's own envd on e2b; see envd.md), and falls back to the provider's exec when there is none.
Commands: exec(stream=true)¶
Over MCP, exec with stream: true sends each chunk of output as a notifications/progress on the call, in order
(progress is the bytes so far, message the text, stdout and stderr interleaved as a terminal shows them), then
returns exec's usual result: exit_code, the last 256 KiB of stdout and stderr, and streamed. Ask for progress
the usual way (a progressToken in the call's _meta; the SDKs do it when you pass a progress callback).
The 2026-07-28 revision has no other way to stream a tool's output: tool results are single responses, and request-scoped notifications (progress, log messages) are what flows on a call's response stream (changelog, item 4). Progress works the same way for 2025-era clients.
# REST: Server-Sent Events. `stdout` / `stderr` events ({"data": text}) as it prints, then one `result` event.
curl -N -H "Authorization: Bearer $HANGAR_TOKEN" -H 'Content-Type: application/json' \
-d '{"command": "npm test", "timeout": 600}' "$HANGAR_URL/api/sandboxes/api-refactor/exec/stream"
A command whose client goes away (the call cancelled, the connection closed), or whose token is revoked, is killed.
One cut off by a shutdown or a destroyed sandbox isn't: it runs to its end or its timeout, like an unstreamed exec,
and the result says closed and why.
Without a daemon the command runs through the provider's exec and its output arrives in one piece at the end
(streamed: false).
Background processes: process¶
E2B's commands.run(background=True), connect, send_stdin, kill and list, as one MCP tool:
action |
Arguments | |
|---|---|---|
start |
command, cwd |
Starts it (bash -lc) and returns its process_id at once |
attach |
process_id, since, wait_seconds (up to 600) |
Output from since (default: all still kept), then whatever comes for wait_seconds, as progress notifications; returns the tail (64 KiB), state, exit_code and next (pass it as since next time) |
input |
process_id, input, eof |
Writes to its stdin; eof closes it |
kill |
process_id |
SIGTERM to its process group, SIGKILL 3 s later if it is still running |
list |
Your processes in the sandbox |
hangar follows each process and keeps the last 256 KiB of its output (stdout and stderr together) while nobody is attached, so attaching later shows what it printed. A process belongs to the token that started it: no other token can list, attach to, write to or kill it. Exited processes stay listed for 10 minutes.
REST: POST /api/sandboxes/{ref}/processes ({"command", "cwd"}), GET .../processes, GET .../processes/{id}?since=
(the buffered output, for polling), GET .../processes/{id}/stream?since= (SSE: output events with id = the
offset, so Last-Event-ID resumes; then end), POST .../processes/{id}/input ({"input", "base64", "eof"}),
DELETE .../processes/{id}.
After a control-plane restart, processes keep running in their sandboxes. hangar finds a token's processes again by
their envd tag (hangar:<id>:<MAC>, a MAC of the owner, sandbox and id under the control plane's key, so the agent,
which can start processes through its own daemon, can't make one up for a token) the next time that token lists or
attaches; their earlier output is gone (adopted: true).
Without a daemon a process runs detached (setsid), its output goes to ~/.hangar/bg/<id>.out and hangar polls it
(every 0.5 s while someone is attached, 5 s otherwise). input needs the daemon.
Background processes and streamed commands run as the sandbox user, like exec. On Docker they don't get
no_new_privs, unlike a turn's harness: they are commands hangar runs as the agent outside a turn
(what the split doesn't cover). On GKE the pod's allowPrivilegeEscalation: false
covers them.
Turn events, live¶
A session's turn watcher used to read the run log every session_poll_seconds (5 s). It now follows the log through
the daemon (tail -F, started after the first poll), so each event is recorded, and reaches live readers, as the
agent writes it. Polling takes over whenever following isn't possible: no daemon, the log truncated or replaced
(tail says so on stderr), the stream breaking, or the log passing HANGAR_STREAM_MAX_BYTES (following is paced
like any stream; past the cap polling's bounded reads carry on). The read position is saved with the turn as before, so a restart
resumes without recording an event twice. HANGAR_STREAM_TURN_LOGS=false turns following off.
In a split sandbox (the uid split) the follower tails hangar's own copy of the run log, never the agent's link to it. The daemon runs as the agent, so what it passes on is informational, like the rest of the log: approvals still come from the driver's own file, and the turn's status, exit code and summary from hangar's own reads.
Following them:
- SSE:
GET /api/sessions/{ref}/events/stream?since=<seq>andGET /api/jobs/{id}/events/stream?since=<seq>(ajobstoken sees only its own jobs). Each event isid: <seq>anddata: <event JSON>; reconnecting withLast-Event-IDresumes. A comment every 15 s keeps proxies from timing the connection out. - MCP: subscribe to
hangar://sessions/<name>/eventsorhangar://jobs/<id>/events(subscriptions/listen, orresources/subscribefor 2025-era clients). New events sendnotifications/resources/updatedas they are recorded (at most every 250 ms per URI); readhangar://sessions/<name>/events?since=<seq>for just the new ones.
The Fleet, live¶
GET /api/fleet/stream is the Fleet page's read (GET /api/fleet) as it changes: a snapshot, then agent (one
unit, at most one per agent every 2 s), gone, inbox and summary events, resumable with Last-Event-ID. A
browser gets a one-time ticket from POST /api/fleet/tickets (any user token: the stream shows that person's
fleet). See the fleet stream.
File watches¶
Subscribe to hangar://sandboxes/<sandbox>/watch?path=<dir>[&recursive=true] and hangar watches that directory
(envd's WatchDir, inotify) for as long as you stay subscribed. Each change sends
notifications/resources/updated; reading the URI returns the latest events ({"seq", "ts", "name", "type"}, type
create, write, remove, rename or chmod, the last 200 kept) and next; add &since=<next> for only newer
ones. path is absolute or relative to the sandbox's repo. Without a daemon the resource says the watch needs one.
REST: GET /api/sandboxes/{ref}/watch?path=...&recursive=true (SSE).
Limits and closing¶
| Setting | Default | |
|---|---|---|
HANGAR_STREAM_MAX_PER_TOKEN |
16 | Open streams per token: streamed execs, attaches, event and watch streams. One more is refused (MCP error, REST 429). |
HANGAR_STREAM_MAX_TOTAL |
512 | Open streams in all, hangar's own included |
HANGAR_STREAM_BYTES_PER_SECOND |
1 MiB | Per stream; a command printing faster is slowed down, as by a slow terminal (envd's queue is bounded). Each message also counts 64 bytes, so output a byte at a time can't become a flood of notifications |
HANGAR_STREAM_MAX_BYTES |
64 MiB | Per stream; past it nothing more is sent (the result still has the end of the output) |
HANGAR_STREAM_MAX_HOURS |
12 | An event or watch stream open longer is closed |
HANGAR_BACKGROUND_MAX_PER_TOKEN |
16 | Running background processes per token |
HANGAR_WATCH_MAX_PER_TOKEN |
8 | Directory watches per token (envd allows 64 per sandbox) |
HANGAR_STREAM_RECHECK_SECONDS |
5 | How often open streams check that their token is still valid |
HANGAR_STREAM_TURN_LOGS |
true | Follow running turns' logs through the daemon |
Every stream ends, with what it has so far and why (closed, or an SSE close event), when:
- its sandbox is destroyed or paused;
- the token that opened it is revoked (static tokens,
mcp-scoped tokens and OAuth access tokens alike; checked everyHANGAR_STREAM_RECHECK_SECONDS): streamed commands and background processes it started are killed; - hangar shuts down (#57): streams end at once rather than holding the pod for the grace period,
subscriptions/listenstreams get their final result, and new streams are refused until the next pod answers; - it hits a limit above.
Observability¶
Each stream is a span (stream exec, stream events, stream watch, stream process, stream turn_log, ...) with
its bytes and why it ended. Metrics:
| Metric | |
|---|---|
hangar_streams_open{kind} |
Open streams |
hangar_streams{kind, reason} |
Streams closed: done, client, shutdown, destroyed, paused, revoked, lifetime, error |
hangar_stream_bytes{kind} |
Bytes streamed |
hangar_stream_first_output_seconds{kind} |
From a stream's start to its first output |
Numbers¶
Docker, 5 runs of a command printing a line every 100 ms for 3 s (for i in $(seq 30); do sleep 0.1; echo ...),
each line stamped with the time it was printed (scripts/bench/streams.py,
bench/results/2026-10-13-streams-docker.json, a 2-vCPU VM):
| How the client sees output | First line (p50 / p95) | Each line's delay (p50 / p95) |
|---|---|---|
exec(stream=true), progress notifications |
112 / 117 ms | 0.8 / 2.0 ms |
process start + attach |
111 / 116 ms | 0.9 / 2.3 ms |
exec, the result at the end (before) |
3,124 / 3,132 ms | 1,553 / 2,909 ms |
terminal_open + terminal_read every second (before) |
1,058 / 1,063 ms | 503 / 981 ms |
The first line is printed 100 ms after the start, so streaming shows it about 12 ms after it exists.
Turn events, from a run log line written to its event recorded, for a real Claude Code log replayed a line every
100 ms: 15 / 22 ms following the log, against 4,727 / 4,936 ms polling every session_poll_seconds (5 s).
With tail's default 1 s interval, following was 714 ms p50 in a container (on the host it is instant), consistent with
GNU tail polling the file rather than using inotify on the container's overlayfs; hangar runs it with -s 0.1.