View source on GitHub
execution_status transition (running → finished/error) the moment the
agent-server emits it.
This is the event-driven counterpart to the polling loops in
start-sandbox and clone-and-attach,
which repeatedly GET the status until it changes. Here nothing polls the
conversation’s execution state — the script blocks on the socket and is woken
by the server.
Two ways to get the conversation running
Both scripts watch state identically over the same WebSocket. They differ only in how the conversation is created, and that choice comes with a genuine trade-off:
In one sentence: if you want to avoid passing an LLM key, you accept a brief
start-task poll; if you want to avoid the poll, you accept passing an LLM key.
Neither poll is on the conversation’s execution state — that is always
event-driven.
watch_attach.py’s poll is provisioning latency (waiting for the
sandbox to prepare skills/repo and start the conversation); once it hands back an
id, the socket takes over.
Both approaches attach to a sandbox first, exactly like
clone-and-attach — the WebSocket lives on the
sandbox’s agent-server regardless of which endpoint created the conversation.
APIs used
1. Cloud App Server — manages the sandbox lifecycle
- Base URL:
https://app.all-hands.dev - Auth header:
X-Session-API-Key: <OH_API_KEY> - Endpoints:
POST /api/v1/sandboxes— start a sandbox (optional?sandbox_spec_id=…)GET /api/v1/sandboxes?id=<id>— batch-get sandboxes by idPOST /api/v1/app-conversations— attach a conversation to a sandbox (watch_attach.pyonly); returns a start taskGET /api/v1/app-conversations/start-tasks?ids=<id>— poll for theapp_conversation_id(watch_attach.pyonly)DELETE /api/v1/sandboxes/{id}?sandbox_id=<id>— clean up
2. Agent Server — runs inside the sandbox
- Base URL: the entry in
sandbox.exposed_urlswithname == "AGENT_SERVER". - Auth header:
X-Session-API-Key: <session_api_key>returned by the sandbox-create call (different from your Cloud API key). - Endpoints:
POST /api/conversations— create a conversation directly (watch_direct.pyonly). Body requires aworkspaceand anagent(with anllm); aninitial_messagemakes it start running immediately.GET /sockets/events/{conversation_id}— WebSocket event stream. This is the shared star of both scripts. Authenticate by sending{"type": "auth", "session_api_key": "…"}as the first frame. Add?resend_mode=allto replay events already produced since the conversation started (this closes the create→connect race without polling).
Full Agent Server schema is at<agent_server_url>/openapi.jsononce the sandbox isRUNNING.
The flow
Why the WebSocket instead of polling?
The agent-server emits aConversationStateUpdateEvent with
key == "execution_status" on every transition. Subscribing means you learn
about finished (or error/stuck) the instant it happens, with no request
spam and no latency floor set by your poll interval. The socket also carries the
other events (MessageEvent, ActionEvent, StreamingDeltaEvent, …) if you
want to follow the agent’s work in real time.
What they print
watch_attach.py (note the short start-task poll, then the socket):
watch_direct.py (no start-task poll; id is immediate):
Run it
gpt-4o-mini expects a real OpenAI key. If you route through a proxy
(e.g. a LiteLLM endpoint), also pass --llm-base-url and a model name your proxy
recognizes:
Flags
Shared by both scripts:watch_direct.py also takes:
By default the sandbox is deleted when the script exits. Pass
--keep to leave
it running and print the DELETE command instead.
Another option: react from inside the sandbox with hooks
The WebSocket approach is for a client that holds a connection open and reacts externally. If instead you want the sandbox itself to act on a lifecycle event — notify a URL, run setup, gate an action — you can use lifecycle hooks delivered by a plugin. Hooks run inside the agent-server on events likeSessionStart, PreToolUse, PostToolUse, and Stop, so there is nothing to
poll and no socket to keep open.
Two examples in this repo show the pattern:
finish-callback— aStophook thatPOSTs to a URL you control the moment a conversation finishes. This is the closest analogue to “tell me when it’s done” without a held-open socket: the sandbox pushes to you. (It also documents the callback-plus-polling hybrid for reliability.)gpg-commit-signing— aSessionStarthook that configures the environment at the start of every conversation, illustrating the “run something on a lifecycle event” shape more generally.
Notes
- Only the sandbox (and, for
watch_attach.py, the start task) is polled, and only for provisioning. Waiting forRUNNING/READYis lifecycle, not conversation execution state. Once the agent-server is up and the id is known, the conversation is entirely event-driven. - First-frame auth keeps the session key out of the URL (and therefore out
of reverse-proxy / load-balancer access logs). A deprecated
?session_api_key=…query parameter also works but is not recommended. resend_mode=allmakes the subscription race-free: because aninitial_messagestarts the run at create time, some events may already exist before the socket connects. Replaying them means the example never misses the firstrunning. Useresend_mode=since&after_timestamp=…if you have already fetched history over REST and only want newer events.- Terminal states. The scripts stop at
finished,error, orstuck. The full set ofConversationExecutionStatusvalues isidle,running,paused,waiting_for_confirmation,finished,error,stuck,deleting. - Going deeper — is it really done? These scripts read the per-field
execution_statusand stop on the firstfinished, which is perfect for reacting to transitions. If you specifically need to detect a confirmed terminal state (a per-fieldfinishedis advisory — a Stop hook can revert it), seewatch-terminal-state. It builds on this example’s Cloud approach and adds handling for bothConversationStateUpdateEventshapes (per-field andfull_state), plus log-safe first-message auth. - Webhooks (reacting from an external service, no held-open socket). The
agent-server can also push to an HTTP endpoint you host —
POST {base_url}/conversationson start/pause/resume/stop andPOST {base_url}/events/{id}for event batches. That is a good fit when an external service, not a long-lived client, needs to react. However, those webhooks are supplied via the agent-server’s startup config orPOST /api/init, and OpenHands Cloud sandboxes run withdeferred_init=False— so there is no way to inject webhook config.GET /api/initmakes this explicit, returning404with the messageserver is not running with deferred_init=True; the /api/init endpoint is not available(aPOSTis rejected at the auth layer first with401). So agent-server webhooks require an agent-server you run yourself (e.g. via Docker). On Cloud, use the WebSocket here, or the in-sandboxStophook infinish-callbackfor a push-style finish notification.

