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 source:
apps/node/src/contracts/result.ts; canonical docs: Result Envelope - 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; readenvelopefields through the canonical Result Envelope contract- 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",
"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 |
/ping |
health probe for the local serve process |
GET |
/version |
report the CLI version and build identity |
GET |
/devices |
list adb-visible devices |
POST |
/execute |
run a caller-supplied execution payload |
POST |
/snapshot |
run a synthetic one-step snapshot 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 /ping
Health probe for the serve process.
Response:
{
"ok": true
}
Meaning:
- the Express app is running and can answer requests
- no adb, device, Operator APK, skill registry, or execution readiness check is performed
GET /version
Reports the CLI version and build identity for the running server process.
Response:
{
"version": "0.9.4",
"buildIdentity": {
"entryPath": "/path/to/clawperator/dist/cli/index.js",
"mtimeMs": 1710000000000,
"size": 123456
}
}
Meaning:
versionis the same package version returned by the CLI version helperbuildIdentity.entryPathis the resolved CLI entrypoint path when Node can resolve itbuildIdentity.mtimeMsandbuildIdentity.sizecome fromstatSync()and arenullwhen the entrypoint cannot be inspected- no device or Operator APK check is performed
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"
}
]
},
"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-blank - 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",
"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" }
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",
"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
Unframed success (skillResult: null, example com.test.echo):
{
"ok": true,
"status": "success",
"skillId": "com.test.echo",
"output": "TEST_OUTPUT:hello\nTEST_OUTPUT:api\n",
"skillResult": null,
"exitCode": 0,
"durationMs": 18,
"timeoutMs": 4321,
"expectedSubstring": "TEST_OUTPUT:hello"
}
Success: top-level status, skillId, output, and exitCode are
omitted. The HTTP layer includes ok: true and the nested skillResult
(read skillResult.result for the domain answer), plus durationMs and
optional timeoutMs / expectedSubstring when set.
{
"ok": true,
"skillResult": {
"result": { "kind": "text", "text": "ok" },
"status": "success",
"contractVersion": "1.0.0",
"skillId": "com.example.framed",
"checkpoints": [],
"source": { "kind": "script" }
},
"durationMs": 18,
"timeoutMs": 4321
}
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 - success JSON omits duplicate top-level
status,skillId,output, andexitCode; indeterminate responses with a parsedskillResultkeep wrapperstatus,code, andmessagebut omitskillId,exitCode, andoutput - malformed framed output returns
SKILL_RESULT_PARSE_FAILED - success responses omit
exitCodeat the top level; process exit is still0on the run
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
}
}
Error Layers
Serve has three distinct error layers. Keep them separate when recovering from a failure.
| Layer | Where it comes from | Shape | Recovery |
|---|---|---|---|
| Route-local wrapper error | Express JSON parsing or per-route request checks in serve.ts |
{ "ok": false, "error": { "code": "INVALID_BODY", ... } } or similar route-local codes |
Fix the HTTP request shape and retry. These codes are not the shared execution contract. |
| Shared execution error | runExecution() returns ok: false for /execute, /snapshot, or /screenshot |
{ "ok": false, "error": { "code": "<errors.ts code>", ... } } |
Branch on error.code and use Errors for recovery. |
| Failed result envelope | Android returned an execution envelope with envelope.status == "failed" |
{ "ok": true, "envelope": { "status": "failed", ... } } in success-wrapper passthrough cases |
Read Result Envelope, then branch on envelope.errorCode or failed stepResults[].data.error. |
| Feature-specific wrapper error | Skills and emulator routes call their subsystem helpers directly | { "ok": false, "error": { "code": "SKILL_NOT_FOUND", ... } } or emulator codes |
Use the owning feature page: Skills CLI, Serve skills routes, or emulator endpoint notes below. |
Machine-checkable rule:
- first check HTTP status and top-level
ok - for execution endpoints, also inspect
envelope.statuswhen an envelope is present - do not treat route-local codes such as
INVALID_BODY,INVALID_DEVICE_ID, orMISSING_EXECUTIONas publicerrors.tscodes
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:
{
"apiLevel": 35,
"abi": "arm64-v8a",
"deviceProfile": "pixel_8",
"playStore": true,
"storageSize": "12G"
}
Defaults when omitted:
| Field | Default |
|---|---|
name |
derived from storage size, for example clawperator-pixel-12gb |
apiLevel |
SUPPORTED_EMULATOR_API_LEVEL (35) |
abi |
arm64-v8a |
deviceProfile |
DEFAULT_EMULATOR_DEVICE_PROFILE (pixel_7) |
playStore |
true unless explicitly false |
storageSize |
12G |
storageSize accepts positive integer gigabyte values such as 12G, 12GB,
or 16G. The aliases size, diskSize, and dataPartitionSize are also
accepted. Only one storage size field may be provided.
When name is omitted, the server derives the AVD name from the normalized
storage size. For example, 12G and 12GB both default to
clawperator-pixel-12gb.
Success response:
{
"ok": true,
"name": "clawperator-pixel-12gb",
"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.
Optional request body:
{
"storageSize": "16GB"
}
When a new AVD is created, storageSize controls the internal app storage data
partition. It accepts the same gigabyte-only values and aliases as
POST /android/emulators/create. Omit it to use 12G.
When a new AVD is created and no name is provided by the caller, provisioning
uses the same storage-derived default name, such as clawperator-pixel-12gb
for the default 12G size.
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 |
DEVICE_NOT_INTERACTIVE |
409 |
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