Skip to content

API reference

The /v1 REST API for apps and scripts, covering boxes, files, automations, runs, cloud sessions and errors.

The API lives at https://api.wack.sh/v1. It speaks JSON with camelCase fields, and times are ISO 8601 strings with an offset. Agents don't need it: they use the MCP tools. It's for apps, scripts and integrations.

Authentication

Every /v1 call (except the device sign-in) needs an API key in the Authorization header:

Authorization: Bearer wack_…

Create a key under Settings → API keys. It's shown once, and wack stores only a hash. Apps can get a key through the device sign-in instead. Keys are never accepted in query strings.

curl https://api.wack.sh/v1/me -H "Authorization: Bearer $WACK_API_KEY"

DELETE /v1/key revokes the key that makes the call and returns 204. Apps call it when someone signs out, and the key gets 401 from then on. Revoking a key, here or under Settings → API keys, also stops the processes it started.

GET /v1/me returns the key owner's email, plan and usage:

{
  "email": "you@example.com",
  "plan": {
    "plan": "pro",
    "status": "active",
    "trialEndsAt": null,
    "periodStart": "2026-09-01T00:00:00.000Z",
    "periodEnd": "2026-10-01T00:00:00.000Z",
    "cancelAtPeriodEnd": false,
    "billingEnabled": true
  },
  "usage": {
    "awakeSeconds": 44640, "awakeLimitSeconds": 540000,
    "boxes": 2, "boxesLimit": 3,
    "awakeBoxes": 1, "awakeBoxesLimit": 2,
    "automations": 4, "automationsLimit": 25
  }
}

Boxes

Method and path Returns
GET /v1/boxes Box[]
GET /v1/boxes/{id} Box
POST /v1/boxes/{id}/wake Box, once it's awake
POST /v1/boxes/{id}/sleep Box, or 409 box_busy while something runs
GET /v1/boxes/{id}/connect ConnectInfo: { boxId, boxName, mcpUrl, connections, firstExecAt }
POST /v1/boxes/{id}/connect/rotate ConnectInfo with a new mcpUrl
{
  "id": "box_…",
  "name": "my box",
  "status": "awake",
  "busy": false,
  "awakeSince": "2026-09-22T09:00:04.000Z",
  "lastActiveAt": "2026-09-22T09:03:10.000Z",
  "sleepsAt": "2026-09-22T09:13:10.000Z",
  "createdAt": "2026-09-01T12:00:00.000Z",
  "lastError": null,
  "clients": [{ "name": "claude-code", "lastSeenAt": "2026-09-22T09:03:10.000Z" }]
}

status is provisioning, awake, asleep or error. sleepsAt is when an idle box will go to sleep. A box also has size (standard, large, xl or gpu), screen (whether its screen is on), ephemeral when an agent made it as a throwaway, and its setup and image when it has them (Setups and images).

Boxes are made, set up and deleted in the dashboard or by agents through the MCP tools. The API reads them, wakes them and puts them to sleep.

mcpUrl is the box's connect URL. Apps use it to give the agents they run on your computer the box as an MCP server. Treat it as a secret: anyone who has it can use the box. When the person disconnects your app, or stops giving its agents the box, call POST /v1/boxes/{id}/connect/rotate: the old URL stops working at once, including any copy an agent kept.

Files

Method and path Body Returns
GET /v1/boxes/{id}/files?path=/abs/path The file's bytes
PUT /v1/boxes/{id}/files?path=/abs/path The raw bytes { "path": "…", "bytes": 1234 }

Paths are absolute. Both calls wake the box if it's asleep and handle files up to 100 MiB. PUT replaces an existing file.

curl -X PUT "https://api.wack.sh/v1/boxes/$BOX/files?path=/mnt/user-data/uploads/data.csv" \
  -H "Authorization: Bearer $WACK_API_KEY" \
  --data-binary @data.csv

Automations

Method and path Body Returns
GET /v1/automations Automation[]
POST /v1/automations AutomationInput Automation (201)
POST /v1/automations/{id}/runs Run (202)
PUT /v1/automations/external/{clientId} ExternalAutomationInput Automation
GET /v1/automations/external/{clientId} Automation, or 404
DELETE /v1/automations/external/{clientId} 204
POST /v1/automations/external/{clientId}/runs Run (202), or 409 while the last one hasn't finished
GET /v1/automations/external/{clientId}/runs { "runs": RunSummary[], "nextBefore": "…" }

An AutomationInput is what the dashboard form sends:

{
  "name": "Daily digest",
  "boxId": "box_…",
  "kind": "agent",
  "agent": "claude",
  "permission": "full",
  "prompt": "Write a one-page digest of…",
  "trigger": "schedule",
  "schedule": { "cron": "0 9 * * *", "timezone": "Europe/Berlin" },
  "notify": "always"
}
  • kind is agent (needs agent and prompt) or shell (needs script).
  • trigger is schedule (needs schedule), webhook or manual.
  • permission is full or edits, and notify is always, failures or never.
  • workspace is shared (the default) or snapshot. See snapshot runs.
  • Optional fields: model, cwd (an absolute path), workspace and enabled.

External automations

Apps that keep their own list of scheduled jobs can mirror them into wack with PUT /v1/automations/external/{clientId}, where clientId is the app's own id for the job. Calling it again with the same id updates the automation instead of creating another, so the app can sync without keeping wack's ids.

The app says exactly how to start its agent: a program and its arguments, run as given with stdin closed, and how to install the program when the box doesn't have it. wack keeps no list of agents, so any CLI works.

{
  "name": "Morning triage",
  "prompt": "Triage new issues and…",
  "agent": "codex",
  "command": "codex",
  "args": ["exec", "--json", "--skip-git-repo-check", "Triage new issues and…"],
  "install": "npm install -g @openai/codex",
  "schedule": { "kind": "weekdays", "time": "08:30", "dayOfWeek": 1, "tz": "America/New_York" },
  "missedRunGraceMinutes": 30,
  "notify": "failures",
  "workspace": "snapshot",
  "enabled": true
}
  • agent names the agent (lowercase letters, digits and -). Its runs get the agent's sign-in, which the app saves first; without one, a run fails with needs_credentials before the box wakes.
  • command is a program name. args (up to 256) are passed to it one by one, so nothing in them is read by a shell. Models, permission modes and the prompt all go in args: prompt is only what wack shows on the automation's page.
  • install (optional) is a shell command that runs when command isn't found, for example the agent's install script. It has 10 minutes.
  • schedule.kind is hourly (runs at minute), daily, weekdays or weekly (on dayOfWeek, where 0 is Sunday). time is HH:MM in tz.
  • notify is always (the default), failures or never. workspace is shared (the default) or snapshot, which needs cwd to be a git repository and saves what each run changed as changes.patch. See snapshot runs. secrets is agent (the default: only the agent's sign-in) or all (every Toolhouse secret as well). Every PUT replaces the whole automation, so leaving any of these out sets it back to its default.
  • boxId, cwd and missedRunGraceMinutes are optional. Without boxId, the automation runs on your oldest box; with no box at all the call fails with 409 conflict. missedRunGraceMinutes is how late a missed run may still start before it is recorded as skipped.

The Automation it returns has agent: null and the app's launch: { "agent", "command", "args", "install" }. A run's summary is the last lines the agent printed to stdout. In the dashboard these automations can be paused and resumed, and run now; anything else is changed in the app.

POST /v1/automations/external/{clientId}/runs starts a run now. It answers 409 conflict while a run is waiting to start, or while the last run started this way is still going, so a double click starts one run.

GET /v1/automations/external/{clientId}/runs lists that automation's runs newest first, 20 at a time. Pass nextBefore back as ?before= to get the next page; it is null on the last one. A RunSummary is a Run without the automation and box names, summary, errorCode and artifacts. It keeps errorMessage, which says why a run failed or was skipped.

Runs

Method and path Returns
GET /v1/runs?since={cursor}&automation={id} { "runs": Run[], "cursor": "…" }
GET /v1/runs/{id} Run
POST /v1/runs/{id}/cancel Run, or 409 once it has finished
GET /v1/runs/{id}/events Server-sent events

GET /v1/runs returns runs oldest first. Pass the cursor from each response as since on the next call to get only newer runs, which makes it a cheap way to poll for results. A run is listed a few seconds after it is queued. automation narrows the list to one automation.

{
  "id": "run_…",
  "automationId": "aut_…",
  "automationName": "Daily digest",
  "automationClientId": null,
  "status": "succeeded",
  "trigger": "schedule",
  "queuedAt": "2026-09-22T07:00:00.000Z",
  "startedAt": "2026-09-22T07:00:01.000Z",
  "finishedAt": "2026-09-22T07:03:40.000Z",
  "exitCode": 0,
  "boxId": "box_…",
  "boxName": "my box",
  "summary": "Wrote digest.md with 9 items.",
  "errorCode": null,
  "errorMessage": null,
  "artifactCount": 1,
  "artifacts": [{ "path": "/mnt/user-data/outputs/runs/run_…/digest.md", "name": "digest.md", "bytes": 5120 }]
}

automationClientId is the clientId of an external automation, and null for any other run. POST /v1/runs/{id}/cancel cancels a queued run at once and stops a running one.

status is queued, running, succeeded, failed, canceled or skipped. A failed run's errorCode is one of needs_credentials, box_unavailable, limit_reached, timeout, interrupted, canceled or failed_to_start. Download files through the files API using their path.

Run events

GET /v1/runs/{id}/events streams a run as server-sent events. Each event has an id, so you can resume with the Last-Event-ID header after a disconnect.

Event Data
run.started The run
harness.line { "stream": "stdout" or "stderr", "line": "…" }, the agent's raw output (Claude Code and Codex emit JSON lines)
run.artifact An Artifact: { "path", "name", "bytes" }
run.finished { "status", "exitCode", "summary" }
run.error { "code" }

Cloud sessions

Method and path Body Returns
POST /v1/device/code { "clientName" } DeviceCodeResponse (no key needed)
POST /v1/device/token { "deviceCode" } { "accessToken", "tokenType", "keyId" } or an error (no key needed)
GET /v1/agents { [agent]: AgentSignInStatus }
PUT /v1/agents/{agent}/sign-in AgentSignInInput AgentSignInStatus
DELETE /v1/agents/{agent}/sign-in 204
POST /v1/boxes/{id}/procs SpawnProcInput Proc (201, or 200 for a running sessionId)
GET /v1/boxes/{id}/procs?sessionId=… Proc[]
GET /v1/procs/{id} Proc
GET /v1/procs/{id}/events?since={seq} Server-sent events
POST /v1/procs/{id}/stdin { "data" } 204, or 409 once the process has exited
POST /v1/procs/{id}/kill Proc

A SpawnProcInput is { sessionId?, command, args?, cwd?, env?, install?, signIn?, secrets?, idleTimeoutSeconds? }, with up to 256 args. install is a shell command run when command isn't found. signIn is an agent whose sign-in the process gets. With signIn, secrets is agent (the default: only the sign-in) or all (every Toolhouse secret as well). idleTimeoutSeconds (60 to 86400) stops the process once it has gone that long with no output and no stdin. A Proc is:

{
  "id": "prc_…",
  "boxId": "box_…",
  "boxName": "my box",
  "sessionId": "tab-42",
  "client": "monocode",
  "apiKeyId": "key_…",
  "command": "claude",
  "args": ["--output-format", "stream-json"],
  "cwd": "/root/projects/app",
  "status": "running",
  "exitCode": null,
  "startedAt": "2026-09-22T09:00:00.000Z",
  "endedAt": null,
  "lastSeq": 118,
  "idleTimeoutSeconds": 900
}

Agent sign-ins

An agent's sign-in is what its CLI keeps on the person's computer: environment variables, and files under the home folder. An app saves it once per agent, and wack gives it to that agent's processes and automation runs.

PUT /v1/agents/codex/sign-in
{
  "source": "computer",
  "env": {},
  "files": [{ "path": ".codex/auth.json", "content": "{…}" }],
  "hash": "9f2c…"
}
  • source is computer (copied from the person's computer) or override (a token the person gave for wack). A computer upload never replaces an override: the call answers 200 with the override's status.
  • env maps variable names to values, and files lists up to 16 files of up to 256,000 characters each. path is relative to the home folder, with no . or .. parts. At least one variable or file is needed.
  • hash is the app's own hash of the sign-in. Uploading the same source and hash again changes nothing, so an app can send it before every start.

The answer is an AgentSignInStatus, { "source", "hash", "updatedAt" }, and GET /v1/agents lists one per agent. Values never come back. The files are written to the box readable by root only, and only when the saved sign-in changed, so a token the agent refreshed in the box stays. Files that leave a sign-in, or all of them after DELETE, are removed from awake boxes right away and from the others before the agent's next start. The sign-in is stored encrypted and apart from your Toolhouse secrets.

apiKeyId is the API key that started the process, so two installs of an app with the same name can tell their processes apart. Killing a process, by hand or through idleTimeoutSeconds, also stops everything it started in the background. A process that exits on its own leaves its background jobs running, like any job left in the box.

Cloud sessions walks through the whole flow.

Errors

Every error has the same shape and a message that is safe to show to people:

{
  "error": {
    "code": "limit_reached",
    "message": "Your plan includes 3 boxes. Upgrade to add more: https://wack.sh/settings/billing"
  }
}

Some errors add a details field. For invalid_request it lists each field's problem.

Code Status Meaning
unauthorized 401 The API key is missing, malformed or revoked.
forbidden 403 The key is valid but can't do this.
not_found 404 No such resource, or it belongs to someone else.
invalid_request 400 The body or query failed validation. details lists the fields.
conflict 409 The resource is in the wrong state, for example a run already queued.
rate_limited 429 Too many requests. Wait for the seconds in Retry-After.
plan_required 402 The trial ended or the subscription isn't active.
limit_reached 403 A plan limit: awake hours, boxes, awake boxes or automations.
box_busy 409 The box is running something and can't sleep yet.
box_asleep 409 The operation needs the box awake. Wake it first.
box_unavailable 503 The box couldn't start. Retry after Retry-After if present.
box_reclaimed 410 The GPU box was reclaimed (its spare capacity was taken back) and its disk is gone. Make a new one.
needs_credentials 409 The agent has no credentials. Add them in Toolhouse, or its sign-in via /v1/agents.
billing_unavailable 503 Billing is temporarily unavailable.
internal 500 Something failed on our side. The message includes a reference id.

Rate limits

Scope Limit
/v1 600 requests a minute per API key
/v1/device/* 10 requests a minute per IP address
Webhooks 30 requests a minute per webhook URL
MCP, your account's URL 3,000 requests a minute
MCP, a box's URL 300 requests a minute

Past the limit you get 429 rate_limited with a Retry-After header. JSON request bodies can be up to 1 MiB.

Last updated