openhands-api-v1
This skill documents the OpenHands Cloud API V1 and provides a small, easy-to-copy Python client.
It is intentionally minimal and focused on common operations:
- 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.
Auth
App server (Cloud)
Use Bearer auth:
- Header:
Authorization: Bearer <OPENHANDS_API_KEY> - 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:
- 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>
- 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)
- a sandbox object containing
- Use those values to call the agent server directly:
- Base:
{agent_server_url}/api/... - Header:
X-Session-API-Key: <session_api_key>
- Base:
Example (common field names; adjust to your deployment):
# using the minimal Python client (`OpenHandsV1API`)
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.
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 accountGET /api/v1/app-conversations/search?limit=...— list recent conversationsGET /api/v1/app-conversations?ids=...— fetch conversation records by id (batch)GET /api/v1/app-conversations/count— count conversationsPOST /api/v1/app-conversations— start a new conversation (creates a sandbox)GET /api/v1/app-conversations/start-tasks?ids=...— check async start-task statusGET /api/v1/conversation/{app_conversation_id}/events/search?limit=...— read conversation eventsGET /api/v1/conversation/{app_conversation_id}/events/count— count eventsGET /api/v1/sandboxes/search?limit=...— list sandboxesPOST /api/v1/sandboxes/{sandbox_id}/pause/.../resume— manage sandbox lifecycleGET /api/v1/app-conversations/{app_conversation_id}/download— download trajectory zip
Start-task vs app_conversation_id (common pitfall)
In many deployments, POST /api/v1/app-conversations is asynchronous and returns a start-task object:
idis the start_task_idapp_conversation_idis the id you should use for conversation operations like:GET /api/v1/app-conversations/{app_conversation_id}/downloadGET /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_commandGET {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/searchGET {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:
- App server count (fastest when working)
GET /api/v1/conversation/{app_conversation_id}/events/count
- Agent server count (reliable fallback)
GET {agent_server_url}/api/conversations/{app_conversation_id}/events/count
- Trajectory zip fallback (heavier, but still one call + gives full payloads)
GET /api/v1/app-conversations/{app_conversation_id}/download- Unzip and count
event_*.jsonfiles
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.
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)timestampkindsource
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_idObservationEvent.action_id==ActionEvent.id
Example (simplified):
{
"id": "<action-event-uuid>",
"kind": "ActionEvent",
"source": "agent",
"tool_name": "terminal",
"tool_call_id": "toolu_...",
"action": {"command": "ls"}
}
{
"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
curl -s "${BASE_URL:-https://app.all-hands.dev}/api/v1/conversation/${APP_CONVERSATION_ID}/events/search?limit=100" \
-H "Authorization: Bearer ${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
curl -s "${BASE_URL:-https://app.all-hands.dev}/api/v1/conversation/${APP_CONVERSATION_ID}/events/search?limit=200" \
-H "Authorization: Bearer ${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)
curl -s "${BASE_URL:-https://app.all-hands.dev}/api/v1/conversation/${APP_CONVERSATION_ID}/events/search?limit=200" \
-H "Authorization: Bearer ${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)
# Copy `skills/openhands-api-v1/scripts/openhands_api_v1.py` into your project (e.g. as `openhands_api_v1.py`),
# then import it normally:
from openhands_api_v1 import OpenHandsV1API
api = OpenHandsV1API() # uses OPENHANDS_API_KEY
me = api.users_me()
print(me)
recent = api.app_conversations_search(limit=5)
print(recent)
api.close()
CLI examples
Search conversations:
export OPENHANDS_API_KEY="..."
python skills/openhands-api-v1/scripts/openhands_api_v1.py search-conversations --limit 5
Start a conversation from a prompt file:
python skills/openhands-api-v1/scripts/openhands_api_v1.py start-conversation \
--prompt-file skills/openhands-api-v1/references/example_prompt.md \
--repo owner/repo \
--branch main
Notes for AI agents extending this client
- Prefer
.../searchendpoints with a smalllimit. - 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-v1/scripts/openhands_api_v1.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