A small FastAPI control plane intended to sit between Agent Canvas and sandbox-hosted openhands-agent-server runtimes.
Agent Canvas -> app_server -> sandbox -> agent-server
This repository is intentionally smaller than OpenHands/OpenHands: it keeps sandbox metadata, conversation metadata, app-server auth, and proxy/gateway routes, while leaving agent execution to openhands-agent-server.
Install and test:
python -m pip install -e '.[dev]'
python -m ruff check .
python -m pytestThe suite has two tiers. Unit tests run app_server against a fake agent-server;
integration tests (marked integration) stand up a real openhands-agent-server
subprocess and drive conversation start, the event proxy, and send-message
through it — the only thing that proves the StartConversationRequest we build
is schema-valid against the pinned runtime. Select a tier with:
python -m pytest -m "not integration" # fast, no subprocess
python -m pytest -m integration # real agent-serverIntegration tests skip automatically if openhands-agent-server is not
installed; they need no LLM API key (conversations are created idle).
Run against an existing agent-server runtime:
SESSION_API_KEY=app-secret \
AGENT_SERVER_URL=http://127.0.0.1:18100 \
AGENT_SERVER_SESSION_API_KEY=runtime-secret \
python -m uvicorn app_server.app:create_app --factory --host 0.0.0.0 --port 8000Use X-Session-API-Key: app-secret when calling app_server. app_server uses AGENT_SERVER_SESSION_API_KEY when it calls the sandbox-hosted agent-server.
Self-hosted Agent Canvas can use a normal app-server session key. Send it as X-Session-API-Key; Authorization: Bearer <same-key> is also accepted as a compatibility bridge for current Agent Canvas cloud-style backend calls. The intended Agent Canvas shape is app_server protocol routes with session-key auth; current Agent Canvas may still need a backend kind/auth-mode split to express that cleanly.
Run with Docker sandbox orchestration instead of a pre-existing runtime:
SESSION_API_KEY=app-secret \
APP_SERVER_SANDBOX_PROVIDER=docker \
AGENT_SERVER_IMAGE=ghcr.io/openhands/agent-server:1.38.0-python \
SANDBOX_CONTAINER_URL_PATTERN='http://localhost:{port}' \
python -m uvicorn app_server.app:create_app --factory --host 0.0.0.0 --port 8000In Docker mode, POST /api/v1/app-conversations creates a new agent-server container, injects OH_SESSION_API_KEYS_0, maps the agent-server port, waits for the sandbox to report RUNNING, stores the resulting sandbox metadata, and then starts the runtime conversation.
Runtime state is file-backed under APP_SERVER_STATE_DIR (default .app-server-state): app-conversation records, start tasks, and known sandbox metadata are restored when the process restarts. Docker mode can also rediscover live containers through the sandbox provider's Docker search APIs.
- Health/status:
/alive,/health,/ready,/server_info - App conversations:
POST /api/v1/app-conversationsGET /api/v1/app-conversations/searchGET /api/v1/app-conversations?ids=...GET /api/v1/app-conversations/start-tasks?ids=...POST /api/v1/app-conversations/{id}/send-message
- Sandbox specs/control:
GET /api/v1/sandbox-specs/searchGET /api/v1/sandbox-specs?id=...GET /api/v1/sandboxes/searchGET /api/v1/sandboxes?id=...POST /api/v1/sandboxesPOST /api/v1/sandboxes/{id}/pausePOST /api/v1/sandboxes/{id}/resumeDELETE /api/v1/sandboxes/{id}
- Agent-server proxy routes:
POST /api/conversations/{id}/eventsGET /api/conversations/{id}/events/countPOST /api/conversations/{id}/events/respond_to_confirmationPOST /api/conversations/{id}/ask_agentPOST /api/conversations/{id}/pausePOST /api/conversations/{id}/runGET /api/v1/conversation/{id}/events/searchGET /api/v1/git/changes?conversation_id=...&path=...GET /api/v1/git/diff?conversation_id=...&path=...
- WebSocket gateways:
WS /ws/events/{id}-> runtime/sockets/events/{id}WS /ws/bash-events/{id}-> runtime/sockets/bash-events
app_server owns user settings and secrets, and builds every conversation from them. agent_settings and conversation_settings are stored as the SDK's own models, so a saved setting is already in the shape agent-server consumes: conversation start turns them into a StartConversationRequest via ConversationSettings.create_request rather than hand-rolled JSON.
That makes openhands-sdk a hard dependency, pinned in lockstep with the default agent-server image tag. Bump both together.
# Configure an LLM before starting conversations
curl -X POST localhost:8000/api/v1/settings \
-H 'X-Session-API-Key: app-secret' -H 'Content-Type: application/json' \
-d '{"agent_settings_diff": {"llm": {"model": "anthropic/claude-sonnet-4-5", "api_key": "sk-..."}}}'Notes:
- Writes are partial:
agent_settings_diffandconversation_settings_diffdeep-merge onto what is stored, so saving one settings page never clobbers another's fields. Sending the legacy nestedagent_settings/conversation_settingskeys is rejected with a 400. - Secret values are never echoed.
GET /api/v1/settingsnullsllm.api_keyand strips MCP credentials, reportingllm_api_key_setinstead. AGET -> edit -> POSTround trip preserves the stored secrets. POST /api/v1/app-conversationsreturns 400 if no LLM is configured, before any sandbox is started.- Custom secrets become environment variables in the conversation.
GET/POST /api/v1/settingsGET /api/v1/settings/agent-schema,GET /api/v1/settings/conversation-schema(served from the SDK, for rendering a settings UI)- LLM profiles:
GET /api/v1/settings/profilesGET/POST/DELETE /api/v1/settings/profiles/{name}POST /api/v1/settings/profiles/{name}/activatePOST /api/v1/settings/profiles/{name}/rename
- Secrets:
GET/POST /api/v1/secrets,PUT/DELETE /api/v1/secrets/{name}POST/DELETE /api/v1/secrets/provider-tokens/{provider}
Start a conversation with a one-off LLM override by passing {"llm_profile": "<name>"}; the override is not persisted.