---
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 <OPENHANDS_CLOUD_API_KEY>`
- 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: <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=<conversation_id>`
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: <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://<runtime-host>/api/conversations/<id>
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 Cloud URL: `https://app.all-hands.dev/conversations/<app_conversation_id>`.

### 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:

```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.base_url}/conversations/{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=<start_task_id>`

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/<absolute_path>`
- `POST {agent_server_url}/api/file/upload/<absolute_path>` (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": "<action-event-uuid>",
  "kind": "ActionEvent",
  "source": "agent",
  "tool_name": "terminal",
  "tool_call_id": "toolu_...",
  "action": {"command": "ls"}
}
```

```json
{
  "id": "<observation-event-uuid>",
  "kind": "ObservationEvent",
  "source": "environment",
  "tool_name": "terminal",
  "tool_call_id": "toolu_...",
  "action_id": "<action-event-uuid>",
  "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`
