Skip to main content

View source on GitHub

A follow-on to 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:
  1. Both event shapes. ConversationStateUpdateEvent arrives two ways; a robust “am I done?” check reads both.
  2. finished is advisory; error/stuck are immediate. A per-field finished can be reverted by a Stop hook, so it is confirmed with a full_state snapshot.
  3. First-message auth, keeping the session key out of URLs and proxy logs.
One file:
  • watch_terminal_state.py — start a sandbox, attach a conversation, watch the socket, and report the confirmed terminal state.

Prerequisite

Start with react-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 finished but does not exit on it,
  • exits when a full_state snapshot reports finished,
  • exits immediately on error or stuck in either shape (those are not revertible).
You can see both steps in the real run below: a provisional per-field finished, then the confirming full_state.

3. First-message auth (log-safe)

The socket accepts the session key three ways: an X-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 as react-to-state-websocket:
  • Cloud app server (https://app.all-hands.dev, X-Session-API-Key: <OH_API_KEY>):
    • POST /api/v1/sandboxes — start a sandbox
    • GET /api/v1/sandboxes?id=<id> — poll until RUNNING
    • POST /api/v1/app-conversations — attach a conversation (returns a start task)
    • GET /api/v1/app-conversations/start-tasks?ids=<id> — poll for the id
    • DELETE /api/v1/sandboxes/{id}?sandbox_id=<id> — clean up
  • Agent server (the sandbox’s AGENT_SERVER exposed URL, session_api_key):
    • GET /sockets/events/{conversation_id} — the WebSocket event stream (wss://…), first-frame auth, ?resend_mode=all to replay events emitted before the socket connected.

Run it

No LLM key is required: attaching through the Cloud app server injects your account’s configured LLM.

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_status signal comes off the socket.
  • The terminal set is finished, error, stuck (ConversationExecutionStatus.is_terminal()). idle is 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-websocket does) and stopping on the first finished is 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 — point ws://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.
  • 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_time for “the workspace has gone quiet”
  • finish-callback — a Stop-hook callback on FINISHED (why per-field finished is advisory)