View source on GitHub
react-to-state-websocket. That
example covers the basics — start a Cloud sandbox, attach a conversation (no
LLM key needed), open the agent-server WebSocket
(/sockets/events/{conversation_id}), and print every execution_status
transition. Read it first.
This example keeps the same Cloud approach and layers on what you need to know
to answer one specific question reliably: “is this conversation actually
done?” — and then exit. It adds three things on top of the basics:
- Both event shapes.
ConversationStateUpdateEventarrives two ways; a robust “am I done?” check reads both. finishedis advisory;error/stuckare immediate. A per-fieldfinishedcan be reverted by a Stop hook, so it is confirmed with afull_statesnapshot.- First-message auth, keeping the session key out of URLs and proxy logs.
watch_terminal_state.py— start a sandbox, attach a conversation, watch the socket, and report the confirmed terminal state.
Prerequisite
Start withreact-to-state-websocket for the
Cloud sandbox lifecycle (create → attach → start-task poll → delete) and the
socket subscription. Everything here builds directly on that; the sandbox and
attach code is intentionally identical so you can focus on the new parts.
What this adds
1. Both ConversationStateUpdateEvent shapes
The agent-server reports state over the socket in two shapes, and they carry
the same execution_status field in different places:
react-to-state-websocket reads only the per-field shape — perfect for printing
transitions as they happen. For a reliable terminal check you want both,
because the authoritative confirmation comes in the full-state snapshot (next
point). watch_terminal_state.py handles both in _status_from_event().
2. finished is advisory; error / stuck are immediate
The SDK treats a per-field finished as provisional: a Stop hook can
intercept the stop and resume the run (that is exactly what
finish-callback does). The authoritative signal is
the full_state snapshot emitted once the run settles. So this example:
- remembers a per-field
finishedbut does not exit on it, - exits when a
full_statesnapshot reportsfinished, - exits immediately on
errororstuckin either shape (those are not revertible).
finished,
then the confirming full_state.
3. First-message auth (log-safe)
The socket accepts the session key three ways: anX-Session-API-Key header, a
?session_api_key=… query parameter (deprecated), or a first WebSocket
frame {"type":"auth","session_api_key":"…"}. This example uses the first
frame, so the key never appears in the URL — and therefore never lands in
reverse-proxy or load-balancer access logs. (resend_mode=all stays in the
query string; it is not a secret.)
APIs used
Same asreact-to-state-websocket:
- Cloud app server (
https://app.all-hands.dev,X-Session-API-Key: <OH_API_KEY>):POST /api/v1/sandboxes— start a sandboxGET /api/v1/sandboxes?id=<id>— poll untilRUNNINGPOST /api/v1/app-conversations— attach a conversation (returns a start task)GET /api/v1/app-conversations/start-tasks?ids=<id>— poll for the idDELETE /api/v1/sandboxes/{id}?sandbox_id=<id>— clean up
- Agent server (the sandbox’s
AGENT_SERVERexposed URL,session_api_key):GET /sockets/events/{conversation_id}— the WebSocket event stream (wss://…), first-frame auth,?resend_mode=allto replay events emitted before the socket connected.
Run it
Flags
What it prints
Notes
- No conversation-state polling. Only the sandbox lifecycle and the
start-task are polled, and only for provisioning. Every
execution_statussignal comes off the socket. - The terminal set is
finished,error,stuck(ConversationExecutionStatus.is_terminal()).idleis excluded — it is also the initial state before a run starts. - Simpler variant. If you do not care about the advisory/confirmed
distinction, reading only the per-field shape (as
react-to-state-websocketdoes) and stopping on the firstfinishedis shorter — just slightly less precise if a Stop hook is in play.
Running locally without Cloud
The audience for this example is Cloud. If you have no Cloud account, the identical socket also runs against an agent-server you start yourself in Docker — pointws://localhost:<port>/sockets/events/{id} at it and pass the
SESSION_API_KEY you launched the container with. See
start-sandbox for the agent-server image and
server-info-idle for a local Docker fallback pattern.
The event handling in watch_terminal_state.py is unchanged; only how you obtain
the agent-server URL + session key differs.
Related
react-to-state-websocket— start here: the basics of subscribing to the socket and reacting to every transition, with two ways to create the conversation (Cloud-attach vs. agent-direct)server-info-idle— the coarse pull alternative: poll/server_info.idle_timefor “the workspace has gone quiet”finish-callback— a Stop-hook callback onFINISHED(why per-fieldfinishedis advisory)

