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.csvAutomations
| 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"
}kindisagent(needsagentandprompt) orshell(needsscript).triggerisschedule(needsschedule),webhookormanual.permissionisfulloredits, andnotifyisalways,failuresornever.workspaceisshared(the default) orsnapshot. See snapshot runs.- Optional fields:
model,cwd(an absolute path),workspaceandenabled.
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
}agentnames the agent (lowercase letters, digits and-). Its runs get the agent's sign-in, which the app saves first; without one, a run fails withneeds_credentialsbefore the box wakes.commandis 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 inargs:promptis only what wack shows on the automation's page.install(optional) is a shell command that runs whencommandisn't found, for example the agent's install script. It has 10 minutes.schedule.kindishourly(runs atminute),daily,weekdaysorweekly(ondayOfWeek, where 0 is Sunday).timeisHH:MMintz.notifyisalways(the default),failuresornever.workspaceisshared(the default) orsnapshot, which needscwdto be a git repository and saves what each run changed aschanges.patch. See snapshot runs.secretsisagent(the default: only the agent's sign-in) orall(every Toolhouse secret as well). EveryPUTreplaces the whole automation, so leaving any of these out sets it back to its default.boxId,cwdandmissedRunGraceMinutesare optional. WithoutboxId, the automation runs on your oldest box; with no box at all the call fails with409 conflict.missedRunGraceMinutesis 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…"
}sourceiscomputer(copied from the person's computer) oroverride(a token the person gave for wack). Acomputerupload never replaces anoverride: the call answers200with the override's status.envmaps variable names to values, andfileslists up to 16 files of up to 256,000 characters each.pathis relative to the home folder, with no.or..parts. At least one variable or file is needed.hashis the app's own hash of the sign-in. Uploading the samesourceandhashagain 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