Serve API
Purpose
Define the local HTTP and SSE contract exposed by clawperator serve, including request bodies, success responses, status-code mapping, and how the serve layer wraps runExecution, skill, and emulator operations.
Sources
- HTTP server and handlers:
apps/node/src/cli/commands/serve.ts - Execution result contract:
apps/node/src/domain/executions/runExecution.ts - Result envelope:
apps/node/src/contracts/result.ts - Skills registry contract:
apps/node/src/contracts/skills.ts - SSE event names:
apps/node/src/domain/observe/events.ts - Emulator response types:
apps/node/src/domain/android-emulators/types.ts
Start The Server
clawperator serve [--host <string>] [--port <number>]
Defaults:
| Field | Value |
|---|---|
| host | 127.0.0.1 |
| port | 3000 |
| JSON request body limit | 100kb |
When the server starts successfully, it listens until the process exits. There is no structured JSON startup response because this is a long-running command.
Response Shapes
Most REST endpoints return one of these shapes.
Important boundary:
/execute,/snapshot, and/screenshotpass throughrunExecution()results on success and on cross-surface execution failures- malformed request bodies and route-local validation failures are serve-layer wrappers only and are not part of the shared execution contract
Success wrapper
{
"ok": true
}
and then endpoint-specific fields such as devices, skills, avds, output, or emulator state.
Execution result passthrough
/execute, /snapshot, and /screenshot return the runExecution() result object directly:
Successful shape:
{
"ok": true,
"deviceId": "emulator-5554",
"terminalSource": "clawperator_result",
"envelope": {
"commandId": "serve-snap-1710000000000",
"taskId": "serve-snap-1710000000000",
"status": "success",
"stepResults": [
{
"id": "snap",
"actionType": "snapshot_ui",
"success": true,
"data": {
"text": "<hierarchy rotation=\"0\">...</hierarchy>"
}
}
],
"error": null
}
}
Failure shape:
{
"ok": false,
"error": {
"code": "DEVICE_NOT_FOUND",
"message": "Device emulator-9999 not found or not in device state",
"details": {
"connected": ["emulator-5554"]
}
}
}
Success conditions for execution endpoints:
- HTTP status is
200 - response body has
"ok": true envelope.status == "success"- every
envelope.stepResults[i].success == true
Endpoint Summary
| Method | Path | Purpose |
|---|---|---|
GET |
/devices |
list adb-visible devices |
POST |
/execute |
run a caller-supplied execution payload |
POST |
/snapshot |
run a synthetic one-step snapshot_ui execution |
POST |
/screenshot |
run a synthetic one-step take_screenshot execution |
GET |
/skills |
list all skills or search by query |
GET |
/skills/:skillId |
fetch one skill registry entry |
POST |
/skills/:skillId/run |
run one skill script |
GET |
/android/emulators |
list configured AVDs |
GET |
/android/emulators/running |
list running emulators |
GET |
/android/emulators/:name |
inspect one configured AVD |
POST |
/android/emulators/create |
create an AVD |
POST |
/android/emulators/:name/start |
start an AVD and wait for boot |
POST |
/android/emulators/:name/stop |
stop a running AVD |
DELETE |
/android/emulators/:name |
delete an AVD |
POST |
/android/provision/emulator |
create or reuse a supported emulator and boot it |
GET |
/events |
subscribe to SSE execution events |
GET /devices
Returns the same parsed adb listing used by Devices.
Success response:
{
"ok": true,
"devices": [
{
"serial": "emulator-5554",
"state": "device"
},
{
"serial": "R58N12345AB",
"state": "unauthorized"
}
]
}
Meaning:
- this is observational output only
- it does not apply the execution-time
resolveDevice()filtering rules
Failure behavior:
- server-side listing failures return HTTP
500 - this route does not use the
errors.tsstatus mapping table
POST /execute
Request body
{
"execution": {
"commandId": "open-settings",
"taskId": "open-settings",
"source": "agent-http",
"expectedFormat": "android-ui-automator",
"timeoutMs": 30000,
"actions": [
{
"id": "a1",
"type": "open_app",
"params": {
"applicationId": "com.android.settings"
}
},
{
"id": "a2",
"type": "snapshot_ui"
}
]
},
"deviceId": "emulator-5554",
"operatorPackage": "com.clawperator.operator.dev"
}
Valid body rules enforced by the route:
- request body must be a JSON object
executionis requireddeviceId, when present, must be a stringoperatorPackage, when present, must be a non-empty string
Operator package resolution:
- if
operatorPackageis present in the request, the server uses it verbatim - otherwise it falls back to
process.env.CLAWPERATOR_OPERATOR_PACKAGEwhen that env var is non-empty - otherwise it uses
com.clawperator.operator
Then runExecution() applies full execution validation. See Actions, Selectors, and API Overview.
Representative serve-layer 400 wrappers for this route:
{
"ok": false,
"error": {
"code": "MISSING_EXECUTION",
"message": "Missing 'execution' in body"
}
}
Success response
{
"ok": true,
"deviceId": "emulator-5554",
"terminalSource": "clawperator_result",
"envelope": {
"commandId": "open-settings",
"taskId": "open-settings",
"status": "success",
"stepResults": [
{
"id": "a1",
"actionType": "open_app",
"success": true,
"data": {}
},
{
"id": "a2",
"actionType": "snapshot_ui",
"success": true,
"data": {
"text": "<hierarchy rotation=\"0\">...</hierarchy>"
}
}
],
"error": null
}
}
Failure response
Validation failure example:
{
"ok": false,
"error": {
"code": "EXECUTION_VALIDATION_FAILED",
"message": "press_key requires params.key",
"details": {
"path": "actions.0.params.key",
"actionId": "k1",
"actionType": "press_key"
}
}
}
Device-resolution failure example:
{
"ok": false,
"error": {
"code": "DEVICE_NOT_FOUND",
"message": "Device non-existent not found or not in device state",
"details": {
"connected": ["emulator-5554"]
}
}
}
POST /snapshot
This route builds a synthetic execution with:
source: "serve-api"expectedFormat: "android-ui-automator"timeoutMs: 30000- one action:
{ "id": "snap", "type": "snapshot_ui" }
Request body
{
"deviceId": "emulator-5554",
"operatorPackage": "com.clawperator.operator.dev"
}
Notes:
- body must still be a JSON object, but
{}is valid - omitted
operatorPackagefollows the same fallback chain as/execute
Success response
{
"ok": true,
"deviceId": "emulator-5554",
"terminalSource": "clawperator_result",
"envelope": {
"commandId": "serve-snap-1710000000000",
"taskId": "serve-snap-1710000000000",
"status": "success",
"stepResults": [
{
"id": "snap",
"actionType": "snapshot_ui",
"success": true,
"data": {
"text": "<hierarchy rotation=\"0\">...</hierarchy>"
}
}
],
"error": null
}
}
POST /screenshot
This route builds a synthetic execution with:
source: "serve-api"expectedFormat: "android-ui-automator"timeoutMs: 30000- one action:
{ "id": "shot", "type": "take_screenshot" } - optional
params.pathwhenpathwas supplied
Request body
{
"deviceId": "emulator-5554",
"operatorPackage": "com.clawperator.operator.dev",
"path": "/tmp/settings.png"
}
Route validation:
- request body must be a JSON object
path, when present, must be a non-empty string- omitted
operatorPackagefollows the same fallback chain as/execute
Success response
{
"ok": true,
"deviceId": "emulator-5554",
"terminalSource": "clawperator_result",
"envelope": {
"commandId": "serve-shot-1710000000000",
"taskId": "serve-shot-1710000000000",
"status": "success",
"stepResults": [
{
"id": "shot",
"actionType": "take_screenshot",
"success": true,
"data": {
"path": "/tmp/settings.png"
}
}
],
"error": null
}
}
GET /skills
Without query parameters, returns every registry entry:
{
"ok": true,
"skills": [
{
"id": "com.test.echo",
"applicationId": "com.example",
"intent": "echo text",
"summary": "Echo test skill",
"path": "skills/com.test.echo",
"skillFile": "skills/com.test.echo/SKILL.md",
"scripts": ["skills/com.test.echo/run.js"],
"artifacts": []
}
],
"count": 1
}
Optional query parameters:
| Query key | Type | Match behavior |
|---|---|---|
app |
string | exact applicationId match |
intent |
string | exact intent match |
keyword |
string | case-insensitive substring match across id, summary, and applicationId |
GET /skills/:skillId
Success response:
{
"ok": true,
"skill": {
"id": "com.test.echo",
"applicationId": "com.example",
"intent": "echo text",
"summary": "Echo test skill",
"path": "skills/com.test.echo",
"skillFile": "skills/com.test.echo/SKILL.md",
"scripts": ["skills/com.test.echo/run.js"],
"artifacts": []
}
}
POST /skills/:skillId/run
Request body
{
"deviceId": "emulator-5554",
"args": ["hello", "api"],
"timeoutMs": 4321,
"expectContains": "TEST_OUTPUT:hello"
}
Validation rules:
- body must be a JSON object
deviceId, when present, must be a stringargs, when present, must be an arraytimeoutMs, when present, must be a positive integerexpectContains, when present, must be a string
Argument mapping:
- if
deviceIdis a non-empty string, it is prepended to the script argument list args[]are appended after that, stringified withString()- if
timeoutMsis omitted,runSkill()uses its default timeout of120000ms
Success response
{
"ok": true,
"skillId": "com.test.echo",
"output": "TEST_OUTPUT:hello\nTEST_OUTPUT:api\n",
"skillResult": null,
"exitCode": 0,
"durationMs": 18,
"timeoutMs": 4321,
"expectedSubstring": "TEST_OUTPUT:hello"
}
Behavior:
- if
expectContainsis provided andoutputdoes not contain that substring, the route returns HTTP400 expectContainsis an assertion helper for tests and agent loops that need a simple stdout substring gate- if the skill ID does not exist, the route returns HTTP
404 - if skill registry loading fails, the route returns HTTP
500withREGISTRY_READ_FAILED - other
runSkill()failures, including non-zero exit and timeout, return HTTP400 - when a skill emits a framed
SkillResult, successful and error responses includeskillResult - malformed framed output returns
SKILL_RESULT_PARSE_FAILED - successful responses always include
exitCode: 0
Failure examples:
Output assertion failure:
{
"ok": false,
"error": {
"code": "SKILL_OUTPUT_ASSERTION_FAILED",
"message": "Skill com.test.echo output did not include expected text",
"skillId": "com.test.echo",
"output": "TEST_OUTPUT:api\n",
"expectedSubstring": "TEST_OUTPUT:hello",
"timeoutMs": 4321
}
}
Non-zero skill exit:
{
"ok": false,
"error": {
"code": "SKILL_EXECUTION_FAILED",
"message": "Skill com.test.echo exited with code 2",
"skillId": "com.test.echo",
"exitCode": 2,
"stdout": "partial output\n",
"stderr": "fatal error\n",
"skillResult": null,
"timeoutMs": 4321
}
}
Malformed framed result:
{
"ok": false,
"error": {
"code": "SKILL_RESULT_PARSE_FAILED",
"message": "SkillResult frame contained invalid JSON: ...",
"skillId": "com.test.echo",
"stdout": "[Clawperator-Skill-Result]\n{not-json\n",
"skillResult": null
}
}
Global Serve-Layer Failures
These wrappers come from Express middleware rather than a specific endpoint handler:
| HTTP status | Code | When it appears |
|---|---|---|
400 |
INVALID_JSON |
request body is malformed JSON |
413 |
PAYLOAD_TOO_LARGE |
request body exceeds the 100kb Express limit |
500 |
INTERNAL_SERVER_ERROR |
unhandled server-side exception reached the catch-all middleware |
Many individual route handlers also return INTERNAL_ERROR from local catch blocks. Treat both INTERNAL_ERROR and INTERNAL_SERVER_ERROR as host-side 500 failures rather than as stable execution-contract codes.
Emulator Endpoints
GET /android/emulators
Lists configured AVDs, merged with running-state information:
{
"ok": true,
"avds": [
{
"name": "clawperator-pixel",
"exists": true,
"running": false,
"apiLevel": 35,
"abi": "arm64-v8a",
"playStore": true,
"deviceProfile": "pixel_8",
"systemImage": "system-images;android-35;google_apis_playstore;arm64-v8a",
"supported": true,
"unsupportedReasons": []
}
]
}
GET /android/emulators/running
{
"ok": true,
"devices": [
{
"type": "emulator",
"avdName": "clawperator-pixel",
"serial": "emulator-5554",
"booted": true,
"supported": true,
"unsupportedReasons": []
}
]
}
GET /android/emulators/:name
Returns one ConfiguredAvd object merged into the success wrapper:
{
"ok": true,
"name": "clawperator-pixel",
"exists": true,
"running": false,
"apiLevel": 35,
"abi": "arm64-v8a",
"playStore": true,
"deviceProfile": "pixel_8",
"systemImage": "system-images;android-35;google_apis_playstore;arm64-v8a",
"supported": true,
"unsupportedReasons": []
}
POST /android/emulators/create
Request body:
{
"name": "clawperator-pixel",
"apiLevel": 35,
"abi": "arm64-v8a",
"deviceProfile": "pixel_8",
"playStore": true
}
Defaults when omitted:
| Field | Default |
|---|---|
name |
DEFAULT_EMULATOR_AVD_NAME (clawperator-pixel) |
apiLevel |
SUPPORTED_EMULATOR_API_LEVEL (35) |
abi |
arm64-v8a |
deviceProfile |
DEFAULT_EMULATOR_DEVICE_PROFILE (pixel_7) |
playStore |
true unless explicitly false |
Success response:
{
"ok": true,
"name": "clawperator-pixel",
"exists": true,
"running": false,
"apiLevel": 35,
"abi": "arm64-v8a",
"playStore": true,
"deviceProfile": "pixel_8",
"systemImage": "system-images;android-35;google_apis_playstore;arm64-v8a",
"supported": true,
"unsupportedReasons": []
}
POST /android/emulators/:name/start
Success response:
{
"ok": true,
"type": "emulator",
"avdName": "clawperator-pixel",
"serial": "emulator-5554",
"booted": true
}
Behavior:
- verifies the AVD exists
- rejects already-running AVDs
- starts the emulator, waits for adb registration, waits for boot completion, then enables developer settings
POST /android/emulators/:name/stop
{
"ok": true,
"avdName": "clawperator-pixel",
"stopped": true
}
DELETE /android/emulators/:name
{
"ok": true,
"avdName": "clawperator-pixel",
"deleted": true
}
POST /android/provision/emulator
This route calls provisionEmulator() and may reuse a supported running emulator, start an existing supported AVD, or create and start a new one.
Success response:
{
"ok": true,
"type": "emulator",
"avdName": "clawperator-pixel",
"serial": "emulator-5554",
"booted": true,
"created": false,
"started": true,
"reused": true
}
Meaning of flags:
created: a new AVD had to be createdstarted: the emulator process was started during this requestreused: an existing supported emulator or AVD was reused
GET /events SSE Stream
The server responds with:
Content-Type: text/event-streamCache-Control: no-cacheConnection: keep-alive
Initial heartbeat event:
event: heartbeat
data: {"code":"CONNECTED","message":"Clawperator SSE stream active"}
Execution-related events:
| Event name | Data shape |
|---|---|
clawperator:result |
{ "deviceId": "<serial>", "envelope": <ResultEnvelope> } |
clawperator:execution |
{ "deviceId": "<serial>", "input": <unknown>, "result": <RunExecutionResult> } |
Use /events when:
- you want push-style result observation instead of polling
- you need both raw execution outcomes and envelope-only results
HTTP Status Mapping
When a handler returns an enum-backed error from errors.ts, serve.ts maps it to HTTP status like this:
| Error code | HTTP status |
|---|---|
EXECUTION_CONFLICT_IN_FLIGHT |
423 |
DEVICE_NOT_FOUND |
404 |
NO_DEVICES |
404 |
MULTIPLE_DEVICES_DEVICE_ID_REQUIRED |
400 |
EXECUTION_VALIDATION_FAILED |
400 |
PAYLOAD_TOO_LARGE |
413 |
RESULT_ENVELOPE_TIMEOUT |
504 |
EMULATOR_NOT_FOUND |
404 |
EMULATOR_NOT_RUNNING |
404 |
EMULATOR_UNSUPPORTED |
409 |
EMULATOR_ALREADY_RUNNING |
409 |
| anything else | 500 |
Machine-checkable rule:
- for execution endpoints, use both HTTP status and
error.code - branch primarily on
error.code, not only on the HTTP status
Error Handling Notes
Two classes of failures exist:
- Stable enum-backed failures from
apps/node/src/contracts/errors.ts - Route-local HTTP validation failures for malformed or missing request bodies
For long-term agent logic, prefer branching on the enum-backed errors above. Route-local validation failures should be treated as “fix the request body and retry” rather than as durable cross-surface contract codes.
Examples of route-local validation failures:
- malformed JSON body -> HTTP
400 - body is missing or not a JSON object on POST routes -> HTTP
400 /executewithoutexecution-> HTTP400/screenshotwith blankpath-> HTTP400