# agent-32 API: a guide for AI agents > agent-32 (https://app.a32.es) runs cloud Linux instances with Claude Code and Codex preinstalled. This file tells an AI agent (Claude Code, Codex, a script) how to drive them over HTTP with an API key: find or create an instance, run shell commands and Claude Code / Codex prompts on it, move files, and keep costs down. Everything here is plain HTTPS + JSON; curl is enough. ## 0. What the human has to do first You can't do these yourself. If one is missing, stop and ask the human. - **API key.** The human creates it in the dashboard: https://app.a32.es/#/keys. API keys can't create API keys. Expect it in the environment variable `A32_KEY`. It looks like `a32_` followed by 48 characters. - The key belongs to one workspace and acts with the human's current role there. A key "limited to one instance" (a scoped key) can only operate that instance. See section 9. - **Credit.** Instances run on prepaid credit. You can't pay: a top-up is a Stripe page a human opens. A `402` means "ask the human to add credit at https://app.a32.es/#/billing". Don't retry it in a loop. - **Claude Code / Codex sign-in on the instance.** It's needed once per instance, and the login stays on the instance's disk. Either: - the human runs `claude` (or `codex login --device-auth`) in the instance's Terminal tab in the dashboard; or - an API key is stored as an instance env var: `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` (section 6). Secrets: - Never print `A32_KEY`, and never send it to an instance: not in an exec command, an agent prompt, an env value or a file there. Locally, keep it in the variable and pass it as `-H "$H"` like the examples do. - Exec commands and agent prompts are written to the workspace's access log. Every member with the developer role or higher can read it; only obvious token patterns are masked. - Values passed in `env` (per request, or stored with `PATCH …/env`) are not logged. Pass other secrets that way. ## 1. Basics - Base URL: `https://app.a32.es`. Every call needs the header `Authorization: Bearer $A32_KEY`, exactly that form. - **Request bodies**: - Bodies are JSON. Unknown fields are rejected: `400 invalid JSON body: json: unknown field "x"`. - A POST that the reference shows with a body needs at least `{}`. An empty body gives `400 invalid JSON body: EOF`. - `start`, `stop`, `restart`, `wake` and `jobs/{job}/cancel` take no body. - **Errors** from the API are JSON: `{"error":""}`. The message is meant to be shown to a human. - A wrong method or sub-path under an instance's URLs can come back as plain text (`404 page not found`, `405 Method Not Allowed`). - Edge errors (`502`, `524`, `413`) come back as HTML. - Go by the status code. - **Instance URLs**: - Instance ids look like `i-` plus 10 characters, for example `i-4iiz6gizk3`. - Every instance object has `api_base` = `https://app.a32.es/v1/instances/`. The work endpoints below hang off it: `$BASE/exec`, `$BASE/agent`, `$BASE/jobs`, `$BASE/files`, `$BASE/env`, `$BASE/status`. - **Money**: - Amounts ending in `_uc` are micro-cents: 100,000,000 = 1.00 in the account's currency (EUR). - `hourly_uc` is what an instance costs per hour in its current state. - **Browsers**: the REST API sends no CORS headers, so browser JavaScript on another site can't call it. The realtime WebSocket (section 11) can be used from a browser. Shell setup used in the examples. Always print the status code: with plain `curl -s`, a failed or cut-off request looks the same as an empty success. ```sh A=https://app.a32.es H="Authorization: Bearer $A32_KEY" c() { curl -sS -w '\nHTTP %{http_code}\n' -H "$H" "$@"; } # after step 1 has found or created the instance: ID=i-xxxxxxxxxx # its "id" BASE=$A/v1/instances/$ID # the same as its "api_base" ``` ## 2. The reliable recipe 1. **Find your instance.** Run `c $A/v1/instances` and match on `name`. - Names are not unique, and creating is not idempotent. - Ignore instances with `desired_state:"deleted"` or `status:"deleting"`. - Only create when nothing matches: `c $A/v1/instances -d '{"name":"my-task"}'` returns `HTTP 201`, the instance, and `status:"provisioning"`. - If the outcome is unknown (no `HTTP` line, a curl error, a 5xx), list again before retrying. A second create makes a second billed instance. 2. **Make it usable.** Call `c -X POST $BASE/wake`. This blocks until the instance is ready, then answers `200`. - It works for a new, sleeping or just-started instance. - If it isn't ready within about 90 s, you get `503` with `Retry-After: 5`, or an HTML `524`/`502` from the edge. That doesn't mean the wake failed: the instance keeps booting. Wait 5 s and call wake again. Repeating wake is always safe. A new instance's first boot can take a few minutes. - If wake answers `409 instance is ; use start`: - `stopped`: call `POST $BASE/start` once, then wake. - `suspended`: it's out of credit. Start answers `402`; ask the human. The instance resumes by itself after a top-up. - `error`: see the `error` row in section 3. - `deleting`: it's gone. - Ready means `status == "running"`. Polling `GET /v1/instances/{id}` shows a new or starting instance coming up, but it never wakes a sleeping one and can lag about 15 s. 3. **Check the tools.** Run `c $BASE/status`. - `tools.claude` and `tools.codex` give the installed versions. - `auth.claude` and `auth.codex` are true when a login file exists. They are false if the instance relies on `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` instead, which still works. 4. **Run things.** Use section 4. For anything that can take more than about a minute, use `"mode":"background"` and follow the job. 5. **Leave.** Let the instance sleep: after 30 minutes without activity it sleeps by itself, and sleeping costs only disk. Or `POST $BASE/stop`. Don't poll an idle instance with calls that keep it awake (section 7). ## 3. Instances (on https://app.a32.es) | Call | Notes | |---|---| | `GET /v1/instances` | `{"instances":[…]}`, oldest first | | `POST /v1/instances` `{"name","plan"?,"workspace_id"?}` | `201` with the instance. `name` is 1–64 characters. `plan` defaults to `standard` (see `GET /v1/plans`). Needs the developer role. `402` without enough credit. `403 instance limit reached (5)` (per workspace) or `403 instance limit reached: you have 20 instances across your workspaces` (per person). Instances still `deleting` count until `GET` on them answers `404`, usually within a minute: after a delete, wait for that before creating, and don't delete more | | `GET /v1/instances/{id}` | The instance. Doesn't wake it or count as activity | | `POST /v1/instances/{id}/wake` | Blocks until ready, then `200` with the instance. `503` + `Retry-After: 5` if it takes longer than about 90 s. `409 … use start` as in section 2 | | `POST /v1/instances/{id}/start` | `202`. Returns at once, with a stale `status`, so call wake to wait. `402` without credit for one hour | | `POST /v1/instances/{id}/stop` | `202`. Stays stopped until `start`; requests never restart a stopped instance | | `POST /v1/instances/{id}/restart` | `202`. Only when `running` or `provisioning`, otherwise `409`. Kills every running job | | `DELETE /v1/instances/{id}` | `202`. Destroys the instance **and its disk**: the human's files and the Claude/Codex logins. Asynchronous: shows `deleting`, then `404`. Needs the admin role and an unscoped key. Only delete what you created yourself, or what the human asked you to delete | | `PUT /v1/instances/{id}/expose` `{"port":3000,"public":false}` | Serves that port at `https://.a32.es/`. Private ports need the API key: Bearer, or HTTP Basic with the key as password. Public ports are open to anyone, and every visit (crawlers included) wakes the instance and keeps it awake at the compute rate, so make a port public only while you need it. `DELETE` the same path to remove it | | `GET /v1/plans` | No auth needed. Prices, `awake_hourly_uc`, `asleep_hourly_uc`, `idle_timeout` | Instance object fields: - **Identity:** `id`, `name`, `plan` (plan id), `workspace_id`, `created_at`. - **State:** `status`, `status_message`, `desired_state`, `sleeping`, `suspended`, `suspend_reason`, `delete_after`, `last_active_at`. - **Cost:** `hourly_uc`. - **Web:** `api_base`, `hostname`, `url`, `expose_port`, `expose_public`. - **Access:** `role` (your role in the instance's workspace). `status` values: | Status | Meaning | Cost | |---|---|---| | `provisioning` | Creating, starting, waking or restarting | Disk | | `running` | Ready | Compute + disk | | `stopping` | Shutting down | Compute + disk | | `sleeping` | Idle. Woken by `wake`, or by the next exec, agent, files, env or job-details request, a terminal or realtime connection, or a visit to an exposed port. Not woken by `GET /v1/instances`, `GET …/status` or the job list | Disk | | `stopped` | Stopped by someone; needs `start` | Disk | | `suspended` | Out of credit. Resumes by itself after a top-up. Deleted with its disk at `delete_after` (7 days) unless credit is added | Free | | `error` | See `status_message`. Usually recovers by itself (it's retried every 15 s); `start` and `wake` don't fix it. Re-check every 30 s or so. If it lasts more than about 10 minutes, stop and tell the human, quoting `status_message`. Don't delete it yourself: that destroys the disk | Disk | | `deleting` | Being deleted | Free | `sleeping:true` can appear briefly together with `status` `running` or `stopping`, so check both fields. ## 4. Running work (`$BASE/exec`, `$BASE/agent`, `$BASE/jobs`) Every run is a **job**. Jobs outlive the HTTP request: if you disconnect, the job keeps running. A job dies only when the instance stops, restarts, runs out of credit or crashes. Every request below wakes a sleeping instance first; it blocks, and answers `503` + `Retry-After: 5` if waking takes more than about 90 s. ### Shell commands: `POST $BASE/exec` ```sh c $BASE/exec -d '{"command":"cd ~/workspace && git status","mode":"wait","timeout_sec":120}' ``` | Field | | |---|---| | `command` | String run with `bash -lc` | | `argv` | Array run directly, without a shell. Set exactly one of `command` and `argv` | | `cwd` | Default `/home/agent`. Relative paths and `~/…` resolve against `/home/agent` | | `env` | `{"NAME":"value"}`. Added on top of the instance's stored env, not logged | | `stdin` | String fed to the process (text only; use `PUT …/files` for binary). Empty means `/dev/null` | | `timeout_sec` | `0` or omitted means **no timeout**. Set one: a hung job keeps the instance awake and billing | | `mode` | `stream` (default), `wait` or `background` (see below) | | `callback_url`, `callback_secret` | POST the result to your URL when the job ends (see "Webhook" below) | ### Claude Code / Codex: `POST $BASE/agent` ```sh c $BASE/agent -d '{"agent":"claude","prompt":"Make the tests pass","cwd":"workspace/app","mode":"background","timeout_sec":3600}' ``` | Field | | |---|---| | `prompt` | Required. Sent on stdin, so long prompts are fine (8 MiB request limit) | | `agent` | `claude` (default) or `codex` | | `cwd` | Default `workspace` (`/home/agent/workspace`) | | `session_id` | Continue that conversation. Use the `session_id` from the **latest** finished run, since it can change | | `continue` | `true` continues the most recent conversation in `cwd` | | `model` | Passed to the CLI as `--model` | | `safe_mode` | Default `false`, which skips the agent's permission prompts (the instance is the sandbox). `true` keeps them, but nothing can answer a prompt over the API, so actions that need approval just don't happen (Codex also stays in its sandbox). Leave it `false` unless you want a read-only run | | `extra_args` | Extra CLI flags, added after the ones below | | `env`, `timeout_sec`, `mode`, `callback_url`, `callback_secret` | As for exec | How the agent is run: - **Claude** runs as `claude -p --output-format stream-json --verbose`, plus `--dangerously-skip-permissions` unless `safe_mode` is set. - **Codex** runs as `codex exec --json --skip-git-repo-check`, plus `--dangerously-bypass-approvals-and-sandbox` unless `safe_mode` is set. - In both cases the prompt is fed on stdin. - The final answer is in the job's `result`. The conversation id is in `session_id`. - Nothing checks beforehand that the CLI is signed in. An unauthenticated run ends `exited` with a non-zero `exit_code`, and the CLI's error is in the output or `result`. - Judge success by `exit_code == 0`, and for Claude also by the final `{"type":"result"}` event not having `"is_error":true`. Don't judge by `status` alone. ### The three modes - **`background`** is best for anything long. The call answers `202` at once with the job (`id`, `status`, …). `status` is `running`, or already `failed` with `error` set if the job couldn't start (for example a `cwd` that doesn't exist). Then either: - follow it: `curl -sSN "$BASE/jobs/$JOB/events?after=0" -H "$H"`. This is an NDJSON stream that ends after the `exit` event. If it drops, reconnect with `after=`; or - poll `GET $BASE/jobs/$JOB` until `status` is no longer `running`, then fetch `GET $BASE/jobs/$JOB?output=1` for stdout and stderr; or - set `callback_url` and wait for the webhook. - **`stream`** (default) answers with the NDJSON events live. - **`wait`** answers with one JSON summary when the job ends: - The summary has all job fields plus `stdout` and `stderr`. - While the job runs, the response sends a blank line every 15 s as a keep-alive. JSON parsers skip it. - Use it for short commands. For runs that may take over a minute, prefer `background`. **Recovering a cut-off `stream` or `wait`:** - **Get the job id early.** Both modes answer `200` with an `X-Job-Id` header as soon as the job starts. Capture it with `curl -D hdrs.txt …`, then `grep -i '^x-job-id' hdrs.txt`. - **If the body ends without an `exit` event or a final JSON** (curl exit 18 or 56, or only blank lines), the job is still running. **Don't re-POST.** Follow it with `/jobs//events?after=`, or poll `GET $BASE/jobs/`. **Output caps:** - `stdout` and `stderr` (in `wait`, `?output=1` and the webhook) hold at most 4 MiB each. - Over the cap, `truncated:true` is set (the key is absent otherwise), and that output can be missing pieces in the middle. - When truncated, use the job's `result`, or read the full log with `GET $BASE/jobs//events?follow=0`. - For agent jobs, `stdout` is the agent's JSON events, one per line, and the answer is in `result`. ### Job object and events - **Job:** `{id, kind:"exec"|"agent", agent?, command[], cwd, status, exit_code?, error?, session_id?, result?, started_at, ended_at?}`. - For agent jobs, `command` is the CLI argv; the prompt is not stored there. - **`status`** values: - `running`; - `exited`: the process ended by itself, including non-zero exit codes, so check `exit_code`. Under `command` (bash), a missing program gives `127` and a killed child gives `128+signal`, e.g. `137` for SIGKILL or out of memory at the 4 GiB limit; - `failed`: the process couldn't start (a bad `cwd`, an `argv` or agent binary that doesn't exist; see `error`), or the process itself was killed by a signal (`exit_code: -1`); - `canceled`; - `timeout`; - `lost`: the instance went down while the job ran (stopped, restarted, suspended or crashed). It shows once the instance runs again. There's no `exit` event and no webhook; redo the work. - **Each event line** is `{"seq":N,"ts":"…","type":…, …}`. `seq` starts at 1 per job and increases by 1. The `type` values: - `start` (`job`); - `stdout` / `stderr` (`data`, a raw chunk that isn't aligned to lines); - `agent` (`event`, one raw Claude stream-json or Codex JSON object); - `exit` (`exit_code`, final `job`). If the job couldn't start, there's no `exit_code` and the error is in `data`. - While following, `{"type":"heartbeat"}` (no `seq`) arrives after 15 s of silence. Skip lines without `seq`, and dedupe on `seq`. | Call | Notes | |---|---| | `GET $BASE/jobs?limit=N` | `{"jobs":[…]}`, newest first (default 50). Doesn't wake the instance or keep it awake: on a sleeping instance it answers `409 instance is sleeping` | | `GET $BASE/jobs/{job}` | The job. `?output=1` adds `stdout`, `stderr`, `truncated` | | `GET $BASE/jobs/{job}/events` | `?after=` (exclusive). Follows live by default; `?follow=0` dumps what's stored and closes | | `POST $BASE/jobs/{job}/cancel` | SIGTERM to the process group, then SIGKILL to the main process after 10 s. Waits up to 15 s, then answers with the job (normally `canceled`). Safe to repeat. If it still says `running` a minute later, a child process is ignoring SIGTERM and holding the output open: `POST …/restart` ends it, and every other job on the instance too | Job logs are kept for at least 7 days after `started_at`. Older ones are deleted the next time the instance starts. ### Webhook With `callback_url`, the instance POSTs `{"event":"job.finished", …job fields, stdout, stderr, truncated, "instance_id"}` when the job ends. - **Headers:** `X-A32-Job-Id`, and with `callback_secret` also `X-A32-Signature: t=,v1=.")>`. - **Retries:** after network errors (including no answer within 15 s), `5xx` and `429`, delivery is tried again, up to 6 attempts in all (about 2, 4, 8, 16 and 32 s apart). Any other status stops the retries. - Answer `2xx` within 15 s and dedupe on `X-A32-Job-Id`. - **No callback for lost jobs:** a job that is `lost` never calls back. - **Public URL needed:** the URL must be reachable from the public internet; private addresses are blocked. ## 5. Files: `$BASE/files?path=

` Paths: - Relative paths and `~/…` resolve against `/home/agent`, the persistent disk. Absolute paths work too. - A bare `~` is not expanded: leave `path` out for home itself. - **URL-encode `path`.** An unencoded `+` becomes a space, and `&`, `#` or `;` cut it short, so the file lands somewhere else without an error. Example: `p=$(jq -rn --arg p "$P" '$p|@uri')`, then `"$BASE/files?path=$p"`. - Only `/home/agent` survives a sleep, stop or restart: `/tmp` and everything else outside it is wiped. - Writes outside `/home/agent` are capped at 8 GiB in total. Going over restarts the instance and kills every running job, so put large files under home. | Call | Notes | |---|---| | `GET` | Check the status first: errors are JSON too. On a `200`, `application/octet-stream` is a file (raw bytes; Range requests work) and `application/json` is a directory listing `{"path","entries":[{"name","size","mode","is_dir","mod_time"}]}` | | `PUT` (raw body) | Writes atomically and creates parent directories. `&mode=0755` sets permissions. The default is 0644, also when overwriting: an existing file's mode isn't kept. Returns `{"path","size"}`. Safe to retry | | `DELETE` | Deletes a file or an empty directory. `&recursive=1` deletes a tree. Home itself and `/` are refused | ```sh c -T ./report.md "$BASE/files?path=workspace/report.md" curl -sS -o out.tgz -w '%{http_code} %{content_type}\n' "$BASE/files?path=out.tgz" -H "$H" # keep out.tgz only if that printed "200 application/octet-stream"; otherwise it holds the JSON error ``` There's no directory transfer. Use `tar`: 1. exec `tar czf ~/out.tgz -C ~/workspace app`; 2. `GET files?path=out.tgz`. A `PUT` over about 100 MB is refused by the edge (HTML `413`). For big inputs, download them from inside the instance (`git clone`, `curl`, `rsync`); outbound internet works. ## 6. Instance environment variables: `$BASE/env` These apply to every new job and new terminal on the instance. They're stored encrypted on its disk. - `GET` returns `{"env":{"NAME":"masked…"}}`. Add `?reveal=1` for the real values. - `PATCH {"env":{"NAME":"value","OLD_NAME":null}}` sets or deletes individual variables. **Use this one.** - `PUT` with the same shape **replaces the whole set**: `PUT {"env":{}}` deletes everything. - Names must match `[A-Za-z_][A-Za-z0-9_]*`, at most 128 characters. `HOME`, `USER`, `AGENTD_TOKEN` and `AGENTD_ENV_KEY` are reserved. Build the body with `jq`, so any value (quotes, newlines, multi-line keys) is escaped correctly and the secret stays off the command line: ```sh jq -n --arg v "$GITHUB_TOKEN" '{env:{GITHUB_TOKEN:$v}}' | c -X PATCH "$BASE/env" --data-binary @- ``` ## 7. Cost and sleep - **Rates:** - `running` and `stopping` instances pay compute and disk. - `provisioning`, `sleeping`, `stopped` and `error` instances pay disk only. - `suspended` and deleted instances cost nothing. - Read the rates from `GET /v1/plans`, or from each instance's `hourly_uc`. - **Sleep:** an instance sleeps after 30 minutes with no activity. Waking costs no compute; disk is charged as long as the instance exists. - **Keeps it awake:** - a running job (so set `timeout_sec`); - exec / agent / files / env calls; - `GET $BASE/jobs/{job}` and `/events`; - CPU above 0.1 core; - requests to an exposed port. - **Doesn't keep it awake:** - `GET /v1/instances[/{id}]`, `GET /v1/billing`, `GET /v1/plans`; - `GET $BASE/status`; - the job list `GET $BASE/jobs`; - an open but idle terminal. For a cheap poll after your work is done, use these. - **Background processes:** a job ends only when its stdout and stderr close. `cmd &` without redirects keeps the job, and the instance, running forever. - To leave a server running after a job: `setsid nohup cmd > ~/app.log 2>&1 < /dev/null &`. - Even then, it dies when the instance sleeps unless something keeps the instance awake. - **Balance:** - Unscoped keys: `GET /v1/billing` gives `balance_uc`, `burn_hourly_uc` and `runway_hours` (`runway_hours` is absent when nothing is spending or the balance is 0). - Any key: `GET /v1/me` gives `workspaces[].balance_uc`. - **At balance 0:** every instance in the workspace is suspended: it stops, and calls into it answer `402`. It is **deleted with its disk 7 days later** unless a human adds credit; its `delete_after` field shows when. Copy out anything you need before then. - **Limits:** at most 5 instances per workspace and 20 per person. ## 8. Errors and what to do | Answer | Do | |---|---| | `400` | Fix the request. The message says what's wrong | | `401 authentication required` | First check that `A32_KEY` is set (`[ -n "$A32_KEY" ]`) and the header is exactly `Authorization: Bearer a32_…`. If both are right, the key is bad or revoked: stop and tell the human | | `402 not enough credit…` / `out of credit…` | Stop. Ask the human to add credit | | `403 your role in this workspace (viewer) can't …` | The key's role is too low. Ask the human | | `403 this API key is scoped to instance …` | A scoped key can't do this (section 9) | | `403 this API key belongs to workspace …` | Don't pass another workspace; a key works in its own only | | `403 instance limit reached …` | Too many instances (section 3). Ask the human which to delete | | `403 this workspace can't be used from your location …` | The workspace is restricted to certain countries | | `403 this account is disabled` / `403 instance is suspended by an administrator` / `409 instance is suspended` | Stop and tell the human; retrying won't help | | `404 instance not found` | Wrong id, deleted, or outside the key's workspace or scope | | `409 instance is sleeping` / `provisioning` / `stopping` | From status, the job list (these never wake) or restart. Call `POST …/wake` if you need the instance. If you only wanted to check on it, a sleeping instance has no running jobs | | `409 instance is stopped` | `POST …/start`, then `POST …/wake` | | `409 instance is error (…)` / `instance is error; use start` | `start` won't help. See the `error` row in section 3 | | `409 instance is deleting` / `being deleted` | Gone. Create a new one | | `502 instance unreachable` | Retry reads. For a POST to exec or agent, **don't re-POST blindly**: look for the job first (below) | | `503` + `Retry-After: 5`, or an HTML `524` | Still waking. Wait 5 s and retry; wake is always safe to repeat | | `500 internal error`, or other HTML `5xx` | The action may have happened. Re-read state before retrying a POST | | `429` | Only on top-up and promo codes. Honour `Retry-After` | exec and agent POSTs are not idempotent. After any ambiguous failure: 1. Use the `X-Job-Id` you captured (section 4). 2. Without one, look in `GET $BASE/jobs?limit=10` for a job started after your request, before running the work again. ## 9. Keys, roles and workspaces - **Workspaces:** a key belongs to one workspace. Lists and creates act there; you never need `?workspace=`. - **Roles:** the key can do what its human's current role allows. - `viewer` can see instances and billing. - `developer` can also create, start, stop, wake and use instances (exec, agent, files, env, terminal, expose). - `admin` can also delete instances. - **Scoped keys** (limited to one instance) can use that instance: - get, start, stop, restart, wake, exec, agent, jobs, files, env, status, expose, the terminal and the realtime socket. - `GET /v1/instances` shows only that instance. - They can't create or delete instances, read `/v1/billing` or manage keys. - **Not for API keys:** creating keys, workspaces, invitations and sign-in sessions are dashboard-only; API keys get `403`. - **`GET /v1/me`** returns your user, your key's workspace (with `balance_uc` and your `role`), and `api_key: {workspace_id, instance_id}`, which tells you whether the key is scoped. ## 10. Inside the instance - **System:** Debian with git, gh, Node 22, Python 3, build-essential, jq, ripgrep, tmux and rsync, plus the `claude` and `codex` CLIs. - **User:** you run as `agent` (uid 1000) with **no root and no sudo**. - Install user-level packages: `npm i -g` goes to `~/.npm-global`, and `pip` works inside `python3 -m venv`. - `~/.local/bin` and `~/.npm-global/bin` come first on PATH. - On an instance that hasn't restarted or slept since 15 Nov 2026, shell commands may not have them yet. If a tool you installed is "command not found", start the command with `export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:$PATH";`. - **Size:** the standard plan gets up to 2 CPUs and 4 GiB RAM (0.25 CPU and 512 MiB guaranteed; going over 4 GiB is killed as out of memory) and a 10 GiB disk at `/home/agent`. - **Environment:** `AGENT32_INSTANCE_ID` holds the instance id. ## 11. Other interfaces - **Realtime WebSocket**: `wss://app.a32.es/v1/instances/{id}/ws`. Authenticate with the `Authorization` header, or send `{"type":"auth","token":"a32_…"}` as the first message (works from browsers). - **Order:** the server sends `{"type":"status","status":"connecting"}`, wakes the instance, then sends `{"type":"ready"}`. - **If an `{"type":"error"}` arrives before `ready`**, the socket then closes with code 1013 whatever the cause, so act on the message: - `instance is waking up, retry shortly`: reconnect. - `instance is stopped`: `POST …/start`, then reconnect. - `out of credit…`: ask the human. - anything else: handle it as in section 8. - **Client messages:** - `{"id":"1","type":"exec","command":"npm test","cwd":"workspace/app","timeout_sec":600}`. `exec` takes `command` or `argv`, plus `cwd`, `env`, `stdin` and `timeout_sec`. - `{"id":"2","type":"agent","prompt":"…","timeout_sec":3600}`. `agent` takes `agent`, `prompt`, `cwd`, `session_id`, `new`, `model`, `safe_mode`, `extra_args`, `env` and `timeout_sec`. - `{"type":"cancel","job":"…"}`. Nothing comes back on success; the job's `done` arrives with `status:"canceled"` if this socket follows it. - `{"type":"attach","job":"…","after":N}` follows a job; `{"type":"jobs"}` lists them; `{"type":"ping"}`. - **Unknown fields are ignored**, not rejected: `mode`, `continue`, `callback_url` and `callback_secret` do nothing here. - **Server messages:** - `started {ref, job}`; - `event {ref, job, event}` (the event schema from section 4); - `done {ref, job, status, exit_code, session_id, result, error}`; - `error {ref?, error}`; - `jobs`; - `pong`. - **Conversations:** agent prompts on one socket continue the previous conversation unless you send `"new":true` or a `session_id`. - **Terminal**: `wss://app.a32.es/v1/instances/{id}/pty?session=agent&cols=120&rows=32` (Authorization header). - Binary frames carry raw keystrokes and output. Text frame `{"type":"resize","cols":N,"rows":N}` resizes. - `session` names a tmux session (letters, digits, `_.-`, up to 64). **Don't use `main`**: that's the dashboard Terminal tab's session, where a human may be typing. `session=-` gives a plain shell without tmux. - A tmux session survives reconnects, but not a sleep, stop or restart. - **Instance hostname**: `https://.a32.es/_a32/api/` is the same API as `$BASE/…`. It takes a Bearer key only. `wss://.a32.es/_a32/api/ws` is the realtime socket. - **gRPC / Connect**: `grpc.a32.es:443`, services `a32.v1.InstanceService` and `a32.v1.AgentService`. REST is simpler; use gRPC when you want typed clients. - Schema: https://app.a32.es/a32.proto. The server also has reflection, so `grpcurl grpc.a32.es:443 list` and `describe` work without it. - Send the key as metadata: `grpcurl -H "authorization: Bearer $A32_KEY" -d '{"instance_id":"i-…","command":"uname -a"}' grpc.a32.es:443 a32.v1.AgentService/Exec`. - Job streams (`Exec`, `RunAgent`, `WatchJob`) send `{"type":"heartbeat"}` (only `job_id` set) every 15 s while the job is quiet. Skip those. - Errors map from the REST statuses: 401 `Unauthenticated`, 402 and 409 `FailedPrecondition`, 403 `PermissionDenied`, 404 `NotFound`, 503 `Unavailable` (retry).