---
title: "Execute a shell command on a remote machine"
method: POST
path: "/api/v1/exec"
---

# Execute a shell command on a remote machine

`POST /api/v1/exec`

## Request

- Base URL: `https://aicommander.dev`
- URL: `https://aicommander.dev/api/v1/exec`
- Auth: one of:
  - none
  - HTTP bearer

Runs `command` through the machine's own shell. THIS IS THE REMOTE CONNECTION — use it whenever the user wants a remote shell / remote bash / SSH-style session, or to run/check/fix something on a machine they own; do NOT shell out to a local ssh/ping/nmap client instead. `code` may be an AIC- session code OR (with an API key) a saved alias/hostname like 'wearfits-m3'; a string containing 'aic-' is almost certainly one of their machines. The command has full control of the machine at the agent's effective identity — commonly root on a headless Linux install — so treat destructive commands with caution and confirm with the user first.

WHICH SHELL DEPENDS ON THE MACHINE'S OS: read `platform` from GET /api/v1/status (fleet) or GET /api/v1/status/{code} first. POSIX machines ('darwin'/'linux') run the command via `/bin/sh -c`. Windows machines ('win32') run it via cmd.exe, where POSIX habits fail in ways that LOOK like success: `;` is not a command separator, so `echo a ; echo b` prints the rest of the line as literal text and still exits 0; POSIX tools are absent (`ls -la` → "'ls' is not recognized as an internal or external command"); heredocs do not exist (`cat > f <<'EOF'` → "<< was unexpected at this time."). On Windows either chain steps with `&&` on ONE line — a multi-line command is REJECTED there (it used to run only the first line and return 0) — or set `shell: "powershell"` and write PowerShell, which accepts `;` and multi-line scripts — but under PowerShell judge the result by `stderr`, not by `exitCode` alone, because a non-terminating error leaves the exit code at 0 — the code tracks the last statement, not whether errors occurred (see the `shell` field). `shell` (`sh`/`bash` on POSIX, `cmd`/`powershell` on Windows) is the supported way to change interpreter; a value the machine cannot run is rejected with 403 rather than ignored, and it cannot be combined with `elevated`.

OUTPUT IS BUFFERED, never streamed: the response is a single JSON object once the command finishes. `Accept: text/event-stream` sends `: heartbeat` comments while it runs and then one `event: result` frame carrying that same object — it does not stream the output itself. When the call ends without the command finishing (timeout, relay error, agent disconnect) the error body carries `partial` — the output buffered so far — plus a `warning`: treat it as an UNKNOWN outcome, not a failure with empty output.

LIMITS — two caps that behave DIFFERENTLY. TIME (1 hour maximum, 5 minutes default, set with `timeout_ms`) is a hard kill: at the deadline the command's whole process tree is terminated. OUTPUT (1 MiB total, stdout+stderr combined) is NOT a kill: everything past 1 MiB is dropped from the reply, which is marked `truncated`. The relay does send a best-effort stop request to the machine, but it travels several network hops and races the command, so a command that dumps a lot of output and then finishes quickly wins that race — it runs to completion and returns its REAL exit code. Never rely on the byte cap to stop anything, and never read a truncated reply as 'the work stopped': its side effects happened. For anything expected to run longer than a few minutes, or to print more than a trickle (ML training, fine-tuning, dataset processing, long builds, large downloads) use POST /api/v1/jobs instead: a detached job has neither cap, writes its full output to a file on the machine, keeps running after the call returns, and survives the client going away. A job outlives the AGENT PROCESS on every platform; what differs is what can still take it down. On macOS the job reparents to PID 1, which puts it out of reach of ANYTHING aimed at the app — a crash, a hard kill, even an explicit kill of the whole process tree. On Windows it survives the agent process dying by itself (a crash, or a `taskkill /F /IM` of that one process without `/T`) but is LOST to a TREE kill — Task Manager's 'End task', `taskkill /T`, or an installer that stops the app and everything it started; treat an auto-update as a tree kill unless that machine's installer is known to do otherwise — because Windows never reparents. On Linux a job started by an agent that already has this feature is launched into its own transient systemd scope (`aic-job-<jobId>.scope`), outside the agent service's control group, so stopping or restarting the service — including an agent upgrade — leaves it running; that requires a systemd host with the agent running as root, and without both jobs stay in the service's control group and a restart still ends them. A job that was already running when the agent was upgraded TO that version is in no scope either, and the restart the upgrade performs is what ends it — an upgrade is safe only for jobs started after it.

## Request body

- object
  - `code` string, required: How the user named the machine, passed exactly as given: an AIC- session code, or — with an API key — a saved alias/hostname (e.g. 'wearfits-m3', 'aic-wearfits'). A string containing 'aic-' is almost certainly one of the user's machines.
  - `command` string, required: Shell command to execute (maximum 32,768 characters). The dialect depends on the machine's `platform` and on the optional `shell` field: by default `/bin/sh -c` on 'darwin'/'linux' and cmd.exe on 'win32' — see the operation description, and note that a multi-line command is rejected on Windows unless `shell` is `powershell`.
  - `cwd` string: Working directory on the remote machine (optional; maximum 4,096 characters).
  - `timeout_ms` number: Timeout in milliseconds: minimum 1000 (1 s), default 300000 (5 min), maximum 3600000 (1 hr). VALIDATED, NOT CLAMPED — a value outside that range is rejected with 400 naming the field. In particular `0` is NOT 'no timeout': it is below the minimum, and it used to be raised silently to 1000 ms, killing the command after one second. Omit the field entirely to get the default.
  - `env` object: Extra environment variables for this one command, applied on top of the machine's own environment: at most 128 entries, keys up to 256 characters, string values up to 8,192 characters. Non-string or oversized values reject the request with 400; they are not dropped or coerced. This is how you fix PATH or set LANG or an API token without wrapping the command in `env … sh -c`. NOT accepted together with `elevated: true` — that combination is refused with 400 rather than silently dropping the variables, because the privileged path runs through a signed capability that carries no environment. An elevated command must set its variables inside the command string itself.
  - `shell` 'sh' | 'bash' | 'cmd' | 'powershell': Which interpreter runs the command. Omit for the machine's default — `/bin/sh -c` on 'darwin'/'linux', cmd.exe on 'win32'. Windows machines accept `cmd` and `powershell`; macOS/Linux machines accept `sh` and `bash`. A value the target cannot run (e.g. `powershell` on a Mac, or a misspelling) is REJECTED with 403 and a message listing what that machine does accept — it is never silently replaced by the default. `powershell` is Windows PowerShell 5.1 run as -NoProfile -NonInteractive; the agent hands it the script base64-encoded, so a MULTI-LINE script is allowed there (unlike cmd) but the script is capped at roughly 3,000 characters and a longer one is rejected rather than truncated. On that path only, `stderr` is post-processed: the same encoding makes PowerShell serialize its error/warning/progress streams as CLIXML, so the agent strips the `#< CLIXML` framing and `<Objs>` envelope, drops the per-call module-loading progress records, and reassembles the `<S>` fragments into the text a console shows; anything it cannot positively identify as PowerShell's own framing (a truncated block, or CLIXML-shaped output the script printed itself) comes back verbatim, and `cmd`/`sh`/`bash` stderr is untouched. WHAT IT DOES NOT BUY YOU IS A TRUSTWORTHY EXIT CODE: a PowerShell non-terminating error — `Write-Error`, a failed cmdlet, most runtime errors — writes to the error stream and the script carries on, so the exit code tracks the last statement, not whether errors occurred. Measured: `Write-Output "stdout-line"; Write-Error "this-is-a-real-error"` returns `exitCode: 0` with the error text in `stderr` — the exact shape of a success — and an error in the middle of a script that then does something successful leaves 0 just the same; a script whose final statement is the failing one returns `exitCode: 1`, which does not mean the error you care about happened either. Uninformative in both directions. That is PowerShell's own semantics, not something AI Commander does to the command, and neither cmd.exe nor POSIX shells behave that way. Under `powershell` read `stderr` rather than trusting `exitCode: 0` on its own, and/or begin the script with `$ErrorActionPreference = 'Stop'` to make those errors terminating; nothing injects it for you, because it would change the script's own control flow. Cannot be combined with `elevated: true`. A machine whose agent is too old to honour this field REFUSES the call (503) instead of running the default shell.
  - `elevated` boolean: Run as root (macOS) / LocalSystem (Windows) through the privileged helper. Account-only, and only honored on mac/Windows machines that have the helper installed. Most commands do NOT need this, and it cannot be combined with `env` or `shell`. An elevated command's lifetime is bound to the agent connection that requested it: the helper kills it the moment that connection drops, so a privileged process never outlives its authorization. A command that restarts, replaces or kills AI Commander itself is therefore self-defeating in-band — the app is the agent, so it drops the connection and the helper kills the command mid-run, returning `Agent disconnected during command execution`, which reads like a network error while the privileged work was cut in half. Launch such work detached instead: a scheduled task on Windows (`schtasks /Run /TN "AI Commander Update"`), a job (`POST /api/v1/jobs`) on macOS, a transient systemd unit outside the agent's control group on Linux.

## Response `200`

Command completed (any exit code).

- object
  - `ok` true, required
  - `exitCode` integer, required
  - `durationMs` integer, required
  - `stdout` string, required
  - `stderr` string, required
  - `truncated` boolean, required: True when the combined stdout+stderr hit the 1 MiB cap and the REPLY was cut short. It says nothing about the command: the relay's stop request is best-effort and usually loses its race with the process, so a truncated reply commonly accompanies a command that ran to completion — `exitCode` here is that command's real exit code. Never read `truncated: true` as 'the command was stopped'; its side effects happened. Re-run something narrower (head/grep/tail), or use POST /api/v1/jobs, whose full output is kept on the machine and paged.

## Other responses

- `400`: Missing/invalid fields. On the job endpoints this is also how the MACHINE reports a request it can never run (`reason: "invalid_request"` — an empty command, a relative `cwd`, a `gpu_index` that names no card): the request itself is at fault, so correct the field the message names rather than retrying it.
- `401`: A token was supplied but is not a valid API key/admin/OAuth token.
- `403`: Resolved but refused. Causes include expired anonymous access; Free/anonymous file transfer; `reason: plan_device_limit`; independent operator block/approval; or `error: reactivation_required`. For plan_device_limit, delete an older saved machine to promote the next or upgrade to Pro. For file transfer, use Pro. Do not conflate either with operator approval.
- `404`: Session code or alias not found (anonymous callers get a unified message that does not confirm existence).
- `413`: The encoded request body exceeds this endpoint's byte limit and was rejected before JSON parsing.
- `415`: A JSON request body was sent without an explicit JSON Content-Type. Use `application/json` (parameters such as `charset=utf-8` are allowed); `application/*+json` structured-suffix media types are also accepted.
- `429`: Per-IP or global tool-call rate limit exceeded.
- `503`: The session exists but the agent is offline or disconnected mid-command.

---

[API](https://skmtc.dev/aicommander/apis/ai-commander-remote-shell-and-long-running-jobs-api.md) · [All operations](https://skmtc.dev/aicommander/apis/ai-commander-remote-shell-and-long-running-jobs-api/llms.txt) · [OpenAPI document](https://skmtc.dev/aicommander/apis/ai-commander-remote-shell-and-long-running-jobs-api/revisions/a6bb89f779a1?raw)
