--- name: openhands-api description: Reference skill for the OpenHands Cloud REST API (V1) and agent-server APIs, including how to start additional cloud or local backend conversations for fresh-context or delegated work. triggers: - openhands-api - openhands-api-v1 - openhands-cloud - openhands-cloud-api-v1 - oh-api-v1 - oh-cloud-api-v1 --- This skill documents the **OpenHands Cloud API** (V1), commonly used **agent-server APIs**, and small, easy-to-copy clients. Windows PowerShell equivalents for the shell examples in this skill are in `references/windows.md`. It is intentionally focused on common OpenHands API workflows: - Defaults to OpenHands Cloud (`https://app.all-hands.dev`). - Targets the **V1 app server REST API** under `/api/v1/...`. - Includes a few **agent server** endpoints (inside a sandbox) that use `X-Session-API-Key`. - Covers the **multi-conversation delegation pattern**: start separate Cloud conversations when you want fresh context windows or background work. - Covers **local Agent Canvas backend conversations**: start or inspect conversations by calling a local agent server directly. ## When to use this skill Use this skill when you need to: - start or inspect OpenHands Cloud conversations from code - monitor async startup via start-task polling - monitor execution status for long-running jobs - create separate Cloud conversations for parallel or background work - access sandbox agent-server endpoints once a conversation is running - start or inspect conversations on a local Agent Canvas backend or local agent server ## Auth ### App server (Cloud) Use Bearer auth: - Header: `Authorization: Bearer ` - Preferred env var: `OPENHANDS_CLOUD_API_KEY` - Backward-compatible env var: `OPENHANDS_API_KEY` ### Agent server (inside a sandbox) Use session auth: - Header: `X-Session-API-Key: ` How to obtain `agent_server_url` and `session_api_key`: 1. Start or fetch an app conversation via the app server (Bearer auth), e.g.: - `POST /api/v1/app-conversations` - or `GET /api/v1/app-conversations?ids=` 2. In the returned JSON, look for sandbox/runtime connection fields (names vary slightly by deployment/version). Common patterns: - a sandbox object containing `agent_server_url` (or similar) - a session key such as `session_api_key` (or similar) 3. Use those values to call the agent server directly: - Base: `{agent_server_url}/api/...` - Header: `X-Session-API-Key: ` Example (common field names; adjust to your deployment): ```python # using the minimal Python client (`OpenHandsAPI`) conv = api.app_conversation_get(app_conversation_id) session_api_key = conv.get("session_api_key") conversation_url = conv.get("conversation_url", "") # `conversation_url` often looks like: https:///api/conversations/ agent_server_url = conversation_url.rsplit("/api/conversations", 1)[0] ``` If those fields are not present on the conversation record, list/search sandboxes (`GET /api/v1/sandboxes/search`) and use the sandbox referenced by the conversation to locate the agent server URL + session key. ### Local Agent Canvas backend Use the local backend flow only for local Agent Canvas / agent-server development, such as `agent-canvas`, `agent-canvas --backend-only`, or `npm run dev` with ingress at `http://localhost:8000`. This calls the agent server directly with `X-Session-API-Key`. It is not an automation, and it is different from OpenHands Cloud delegation through `POST /api/v1/app-conversations`, which uses Bearer auth against the Cloud app API and may return asynchronous start-task records. When Agent Canvas runs locally, the launcher uses `LOCAL_BACKEND_API_KEY` when it is set. Otherwise it generates and persists the session API key at `~/.openhands/agent-canvas/api-key.txt`. Set `OH_SESSION_API_KEY_PATH` to override the persisted key path. Never print, log, or paste the actual key; use command substitution or an environment variable in examples and scripts. ```bash LOCAL_AGENT_SERVER_URL="${LOCAL_AGENT_SERVER_URL:-http://localhost:8000}" SESSION_API_KEY="${LOCAL_BACKEND_API_KEY:-$(cat "${OH_SESSION_API_KEY_PATH:-$HOME/.openhands/agent-canvas/api-key.txt}")}" ``` Check the local server before creating a backend conversation: ```bash curl -sS "${LOCAL_AGENT_SERVER_URL}/server_info" \ -H "X-Session-API-Key: ${SESSION_API_KEY}" ``` Start a backend conversation with `POST /api/conversations`. Include the agent settings and workspace expected by that backend. Local agent-server calls use an explicit `workspace` such as `{"kind": "LocalWorkspace", "working_dir": "/workspace"}`; Cloud app-conversation delegation instead uses app-server fields such as `selected_repository` and `selected_branch`. If you are starting the conversation from an existing Agent Canvas session, pass through the current configured settings or encrypted settings rather than hard-coding secrets into scripts. ```bash CONVERSATION_JSON=$(curl -sS -X POST "${LOCAL_AGENT_SERVER_URL}/api/conversations" \ -H "X-Session-API-Key: ${SESSION_API_KEY}" \ -H "Content-Type: application/json" \ -d @- <<'JSON' { "agent": { "kind": "Agent", "llm": { "model": "your-model-provider/your-model-name", "api_key": "**********" }, "tools": [ {"name": "terminal"}, {"name": "file_editor"}, {"name": "task_tracker"} ] }, "workspace": {"kind": "LocalWorkspace", "working_dir": "/workspace"}, "initial_message": { "content": [{"text": "Summarize the current workspace."}], "run": true } } JSON ) CONVERSATION_ID=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"${CONVERSATION_JSON}") printf 'Conversation: %s/api/conversations/%s\n' "${LOCAL_AGENT_SERVER_URL}" "${CONVERSATION_ID}" ``` Poll status and inspect recent events: ```bash curl -sS "${LOCAL_AGENT_SERVER_URL}/api/conversations/${CONVERSATION_ID}" \ -H "X-Session-API-Key: ${SESSION_API_KEY}" curl -sS "${LOCAL_AGENT_SERVER_URL}/api/conversations/${CONVERSATION_ID}/events/search?limit=20&sort_order=TIMESTAMP_DESC" \ -H "X-Session-API-Key: ${SESSION_API_KEY}" ``` If the same base URL serves the Agent Canvas UI, the browser route is: ```bash printf '%s/conversations/%s\n' "${LOCAL_AGENT_SERVER_URL}" "${CONVERSATION_ID}" ``` ## Common V1 app server endpoints The following are the main endpoints implemented in the minimal client: - `GET /api/v1/users/me` — validate auth and inspect current account - `GET /api/v1/app-conversations/search?limit=...` — list recent conversations - `GET /api/v1/app-conversations?ids=...` — fetch conversation records by id (batch) - `GET /api/v1/app-conversations/count` — count conversations - `POST /api/v1/app-conversations` — start a new conversation (creates a sandbox) - `GET /api/v1/app-conversations/start-tasks?ids=...` — check async start-task status - `GET /api/v1/conversation/{app_conversation_id}/events/search?limit=...` — read conversation events - `GET /api/v1/conversation/{app_conversation_id}/events/count` — count events - `GET /api/v1/sandboxes/search?limit=...` — list sandboxes - `POST /api/v1/sandboxes/{sandbox_id}/pause` / `.../resume` — manage sandbox lifecycle - `GET /api/v1/app-conversations/{app_conversation_id}/download` — download trajectory zip ## Delegating work with additional Cloud conversations Use the Cloud API when you want a **separate OpenHands conversation** with its own fresh context window. This is useful for: - background jobs that can run independently - parallel investigations or implementation tasks - long-running work where you want to keep the current conversation focused - task-specific contexts, such as one conversation building a component while another runs tests ### Delegation checklist When you start a delegated Cloud conversation: 1. Write a **self-contained task description**. Do not assume the new conversation has any context from the current one. 2. Include the **repository**, branch, relevant file paths, constraints, and expected output. 3. Start the new conversation with `POST /api/v1/app-conversations`. 4. Poll the start-task until `status` is `READY` and you have an `app_conversation_id`. 5. Monitor the delegated conversation via `GET /api/v1/app-conversations?ids=...`. 6. Share or store the conversation's `conversation_ui_url` from that response. It opens the conversation in Agent Canvas. If the server does not return it, use `/canvas/conversations/`, where `` is the server you called the API on. Never share `conversation_url`; it is the sandbox's agent-server API, not a page users can open. ### Minimal cURL flow ```bash curl -X POST "https://app.all-hands.dev/api/v1/app-conversations" \ -H "Authorization: Bearer ${OPENHANDS_CLOUD_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "initial_message": { "content": [{"type": "text", "text": "Investigate flaky tests in tests/test_api.py. Report the root cause and propose a fix."}] }, "selected_repository": "owner/repo" }' ``` If the response does not already include `app_conversation_id`, poll the start-task: ```bash curl -s "https://app.all-hands.dev/api/v1/app-conversations/start-tasks?ids=${START_TASK_ID}" \ -H "Authorization: Bearer ${OPENHANDS_CLOUD_API_KEY}" ``` Then check execution status and get the link to share (`conversation_ui_url`): ```bash curl -s "https://app.all-hands.dev/api/v1/app-conversations?ids=${APP_CONVERSATION_ID}" \ -H "Authorization: Bearer ${OPENHANDS_CLOUD_API_KEY}" ``` ### Minimal Python flow ```python from openhands_api import OpenHandsAPI api = OpenHandsAPI() # prefers OPENHANDS_CLOUD_API_KEY start = api.app_conversation_start( initial_message=( "Implement the requested dashboard component in src/dashboard.tsx. " "Update any related tests and summarize the changes." ), selected_repository="owner/repo", selected_branch="main", title="Dashboard component task", ) ready = start if not ready.get("app_conversation_id"): ready = api.poll_start_task_until_ready(start["id"]) conversation_id = ready["app_conversation_id"] print(f"Delegated conversation: {api.app_conversation_ui_url(conversation_id)}") status = api.app_conversation_get(conversation_id) print(status.get("sandbox_status"), status.get("execution_status")) api.close() ``` ### Parallelism guidance - Prefer **5 or fewer** concurrently running delegated conversations. - Before starting more, check recent conversations and count how many are still `execution_status == "running"`. - Batch specific conversation lookups with `GET /api/v1/app-conversations?ids=...` when you already know their ids. Example: ```python items = api.app_conversations_search(limit=50).get("items", []) running = [item for item in items if item.get("execution_status") == "running"] if len(running) >= 5: print("Wait for some delegated conversations to finish before starting more.") ``` ### Start-task vs `app_conversation_id` (common pitfall) In many deployments, `POST /api/v1/app-conversations` is **asynchronous** and returns a **start-task** object: - `id` is the **start_task_id** - `app_conversation_id` is the id you should use for conversation operations like: - `GET /api/v1/app-conversations/{app_conversation_id}/download` - `GET /api/v1/conversation/{app_conversation_id}/events/...` If `app_conversation_id` is not present in the initial response, fetch it via: - `GET /api/v1/app-conversations/start-tasks?ids=` If you pass a **start_task_id** to `/download`, you will get `404 Not Found`. ## Common agent server endpoints These run against `agent_server_url` (not the app server): - `POST {agent_server_url}/api/bash/execute_bash_command` - `GET {agent_server_url}/api/file/download/` - `POST {agent_server_url}/api/file/upload/` (multipart) - `GET {agent_server_url}/api/conversations/{conversation_id}/events/search` - `GET {agent_server_url}/api/conversations/{conversation_id}/events/count` ### Counting events (recommended approach) If you need to know how many events a conversation has, you can: 1. **App server count (fastest when working)** - `GET /api/v1/conversation/{app_conversation_id}/events/count` 2. **Agent server count (reliable fallback)** - `GET {agent_server_url}/api/conversations/{app_conversation_id}/events/count` 3. **Trajectory zip fallback (heavier, but still one call + gives full payloads)** - `GET /api/v1/app-conversations/{app_conversation_id}/download` - Unzip and count `event_*.json` files Do **not** rely on the last event `id` to infer the total number of events. In the agent-server API, event IDs are UUIDs (not monotonically increasing integers). ## Troubleshooting For common issues and solutions, see [TROUBLESHOOTING.md](references/TROUBLESHOOTING.md). ## Event structure (for debugging) Events returned by: - app server: `GET /api/v1/conversation/{id}/events/search` - agent server: `GET {agent_server_url}/api/conversations/{id}/events/search` …share the same high-level shape. Each event typically includes: - `id` (UUID) - `timestamp` - `kind` - `source` Common `kind` values: | kind | source (typical) | key fields (common) | purpose | |---|---|---|---| | `ActionEvent` | `agent` | `tool_name`, `tool_call_id`, `action` | tool call requested by the agent | | `ObservationEvent` | `environment` | `tool_name`, `tool_call_id`, `action_id`, `observation` | tool result produced by the sandbox/environment | | `MessageEvent` | `user` / `assistant` | `message` (or similar) | user/assistant chat messages | | `ConversationStateUpdateEvent` | `environment` | `key`, `value` | state transitions/metadata | Linking tool calls: - `ActionEvent.tool_call_id` == `ObservationEvent.tool_call_id` - `ObservationEvent.action_id` == `ActionEvent.id` Example (simplified): ```json { "id": "", "kind": "ActionEvent", "source": "agent", "tool_name": "terminal", "tool_call_id": "toolu_...", "action": {"command": "ls"} } ``` ```json { "id": "", "kind": "ObservationEvent", "source": "environment", "tool_name": "terminal", "tool_call_id": "toolu_...", "action_id": "", "observation": {"exit_code": 0, "stdout": "..."} } ``` ## Debugging one-liners (events) These assume you're querying the **app server** endpoint. For agent-server queries, swap the URL base + use `X-Session-API-Key`. ### Print a quick timeline ```bash curl -s "${BASE_URL:-https://app.all-hands.dev}/api/v1/conversation/${APP_CONVERSATION_ID}/events/search?limit=100" \ -H "Authorization: Bearer ${OPENHANDS_CLOUD_API_KEY:-$OPENHANDS_API_KEY}" \ -H "Accept: application/json" | \ python3 - <<'PY' import json, sys items = (json.load(sys.stdin) or {}).get("items", []) for i, e in enumerate(items): print(f"{i:04d} {e.get('timestamp','')} {e.get('source','')} {e.get('kind','')}") PY ``` ### Find error-like events ```bash curl -s "${BASE_URL:-https://app.all-hands.dev}/api/v1/conversation/${APP_CONVERSATION_ID}/events/search?limit=200" \ -H "Authorization: Bearer ${OPENHANDS_CLOUD_API_KEY:-$OPENHANDS_API_KEY}" \ -H "Accept: application/json" | \ python3 - <<'PY' import json, sys items = (json.load(sys.stdin) or {}).get("items", []) for i, e in enumerate(items): if e.get("kind") == "ErrorEvent" or ("code" in e and "detail" in e): print(i, e.get("kind"), e.get("code"), str(e.get("detail", ""))[:400]) PY ``` ### Check tool-call matching (unmatched actions / duplicate observations) ```bash curl -s "${BASE_URL:-https://app.all-hands.dev}/api/v1/conversation/${APP_CONVERSATION_ID}/events/search?limit=200" \ -H "Authorization: Bearer ${OPENHANDS_CLOUD_API_KEY:-$OPENHANDS_API_KEY}" \ -H "Accept: application/json" | \ python3 - <<'PY' import json, sys from collections import Counter items = (json.load(sys.stdin) or {}).get("items", []) action_ids = {e.get("id") for e in items if e.get("kind") == "ActionEvent"} obs_action_ids = [e.get("action_id") for e in items if e.get("kind") == "ObservationEvent" and e.get("action_id")] observed = set(obs_action_ids) print("actions:", len(action_ids)) print("observations:", len(observed)) unmatched = action_ids - observed print("unmatched actions:", list(unmatched)[:20] if unmatched else "none") dups = [aid for aid, c in Counter(obs_action_ids).items() if c > 1] print("duplicate observation action_ids:", list(dups)[:20] if dups else "none") PY ``` ## Quick start (Python) ```python # Copy `skills/openhands-api/scripts/openhands_api.py` into your project (e.g. as `openhands_api.py`), # then import it normally: from openhands_api import OpenHandsAPI api = OpenHandsAPI() # prefers OPENHANDS_CLOUD_API_KEY me = api.users_me() print(me) recent = api.app_conversations_search(limit=5) print(recent) api.close() ``` ## CLI examples Search conversations: ```bash export OPENHANDS_CLOUD_API_KEY="..." python skills/openhands-api/scripts/openhands_api.py search-conversations --limit 5 ``` Start a conversation from a prompt file: ```bash python skills/openhands-api/scripts/openhands_api.py start-conversation \ --prompt-file skills/openhands-api/references/example_prompt.md \ --repo owner/repo \ --branch main ``` ## Notes for AI agents extending this client - Prefer `.../search` endpoints with a small `limit`. - Avoid loops that could generate many API calls. - Start conversations only when asked: it may create sandboxes and cost money. - For sandbox file operations and command execution, use the agent server endpoints with `X-Session-API-Key`. See also: - `skills/openhands-api/scripts/openhands_api.py` - The original inspiration client: `enyst/llm-playground` → `openhands-api-client-v1/scripts/cloud_api_v1.py` - Troubleshooting content and real-world usage feedback → `https://github.com/jpshackelford/.openhands/tree/main/skills/openhands-cloud-api` ## Source of truth This skill is aligned against the current OpenHands API docs and implementation: - `OpenHands/docs/openhands/usage/cloud/cloud-api.mdx` - `OpenHands/docs/openhands/usage/agent-canvas/backend-setup/local.mdx` - `OpenHands/docs/sdk/arch/agent-server.mdx` - `OpenHands/docs/openhands/usage/api/v1.mdx` - `OpenHands/OpenHands/openhands/app_server/v1_router.py` - `OpenHands/OpenHands/openhands/app_server/app_conversation/app_conversation_router.py` - `OpenHands/OpenHands/openhands/app_server/app_conversation/app_conversation_models.py`