Skip to main content

View source on GitHub

Two short scripts that react to OpenHands conversation state changes as they happen, instead of polling a REST endpoint in a loop. Each starts a sandbox, gets a conversation running on the sandbox’s agent-server, then subscribes to that conversation’s event stream over a WebSocket and prints every 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.

How It Works

Why the WebSocket instead of polling?

The agent-server emits a ConversationStateUpdateEvent 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.

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.

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

No LLM key (Cloud attach):
No start-task poll (agent-server direct): a conversation created directly on the agent-server does not inherit Cloud LLM credentials, so pass your own:
The default 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 like SessionStart, 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 — a Stop hook that POSTs 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 — a SessionStart hook that configures the environment at the start of every conversation, illustrating the “run something on a lifecycle event” shape more generally.
Rule of thumb: use the WebSocket (this example) when an external client needs to follow state live; use hooks (a plugin) when the reaction should happen inside the sandbox on a specific lifecycle event. See the OpenHands Hooks Guide for the full list of hook types and their blocking semantics.

Notes

  • Only the sandbox (and, for watch_attach.py, the start task) is polled, and only for provisioning. Waiting for RUNNING / READY is 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=all makes the subscription race-free: because an initial_message starts the run at create time, some events may already exist before the socket connects. Replaying them means the example never misses the first running. Use resend_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, or stuck. The full set of ConversationExecutionStatus values is idle, running, paused, waiting_for_confirmation, finished, error, stuck, deleting.
  • Going deeper — is it really done? These scripts read the per-field execution_status and stop on the first finished, which is perfect for reacting to transitions. If you specifically need to detect a confirmed terminal state (a per-field finished is advisory — a Stop hook can revert it), see watch-terminal-state. It builds on this example’s Cloud approach and adds handling for both ConversationStateUpdateEvent shapes (per-field and full_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}/conversations on start/pause/resume/stop and POST {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 or POST /api/init, and OpenHands Cloud sandboxes run with deferred_init=False — so there is no way to inject webhook config. GET /api/init makes this explicit, returning 404 with the message server is not running with deferred_init=True; the /api/init endpoint is not available (a POST is rejected at the auth layer first with 401). So agent-server webhooks require an agent-server you run yourself (e.g. via Docker). On Cloud, use the WebSocket here, or the in-sandbox Stop hook in finish-callback for a push-style finish notification.

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 id
    • POST /api/v1/app-conversations — attach a conversation to a sandbox (watch_attach.py only); returns a start task
    • GET /api/v1/app-conversations/start-tasks?ids=<id> — poll for the app_conversation_id (watch_attach.py only)
    • DELETE /api/v1/sandboxes/{id}?sandbox_id=<id> — clean up

2. Agent Server — runs inside the sandbox

  • Base URL: the entry in sandbox.exposed_urls with name == "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.py only). Body requires a workspace and an agent (with an llm); an initial_message makes 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=all to 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.json once the sandbox is RUNNING.