Remote shell, jobs & file transfer API documentation
Technical details behind AI Commander: the agent, relay, session codes, remote shell, detached long-running jobs, temporary file transfer, MCP tools, REST API, desktop apps, and security model.
remote_job_start / POST /api/v1/jobs, not remote_exec. It returns a jobId immediately and the work continues after the call, connection, and conversation end. The five-tool lifecycle covers start, list, status, logs, and cancellation.Overview
AI Commander lets an AI client run shell commands on machines you own — without exposing inbound ports, SSH, or a VPN. It has two parts:
- The relay — a hosted endpoint at
https://aicommander.devthat speaks both MCP and a REST API. Your AI client talks to this. There is nothing to deploy or host. - The agent — a small program on each machine you control: a Linux systemd service or a bundled macOS / Windows desktop app. It dials out to the relay over an encrypted WebSocket, so nothing listens for inbound connections on the machine.
Each machine is addressed by a stable session code like AIC-7K3P-WX9M-RTBN. The relay routes a command for that code to the matching agent, which runs it and returns stdout/stderr in one buffered result. The same core operation — run a command by code — is available through MCP tools, the REST API, and an installable Agent Skill.
End to end, a command flows client → relay → agent; the relay buffers the returned output until the command finishes, then sends one result to the client. The relay never stores command text or output — see Security.
The Linux agent
On Linux (servers, VMs, Raspberry Pi) the native install is a single standalone binary — no Node or npm — run as a systemd service that starts on boot and opens an outbound WebSocket to the relay. Because it runs as root, the install guide downloads a versioned installer plus detached signature, verifies it before sudo, and only then executes the verified local file. The old curl …/install | sudo bash flow is intentionally unsupported.
The same agent is also published on npm as @aicommander/agent for Node-managed environments (dev boxes, containers, CI; needs Node ≥20 on the target). npx @aicommander/agent (or aicommander-agent run) is an ephemeral foreground run: it registers and prints the full session code you hand to your AI client, but stops when the process or terminal closes and provides no service or reboot persistence. sudo aicommander-agent install is different: it requires Linux with a running, reachable systemd manager; merely having a systemctl binary is not enough, as is common in containers. Where systemd is reachable, install it with sudo npm i -g @aicommander/agent followed by sudo aicommander-agent install to write and enable aicommander-agent.service. Without systemd, use the foreground mode only for temporary access, or configure aicommander-agent run under the platform's native init/process manager or container restart policy for persistence.
aicommander.dev cannot protect against compromise of that same origin.Once running it registers with the relay and produces a stable session code. Logs and normal status output mask the code; reveal the full value only when you need to copy it:
$ sudo aicommander-agent status --reveal
# Output:
Session: AIC-7K3P-WX9M-RTBN
Supported targets: Linux x86_64 and Linux arm64 (e.g. Raspberry Pi, ARM servers) — each creates the same systemd unit, aicommander-agent.service.
Controller commands
The agent binary doubles as its own service controller:
$ sudo aicommander-agent status # masked code, uptime, state
$ sudo aicommander-agent status --reveal # print the full session code
$ sudo aicommander-agent change-code # mint a NEW code — old code dies, clears ALL links & blocks (prompts; --yes to skip)
$ sudo aicommander-agent list-admins # accounts linked to this machine (masked emails)
$ sudo aicommander-agent block-admin 2 # block one account by list number / id (any time)
$ sudo aicommander-agent unblock-admin 2 # unblock to restore that account's access
$ sudo aicommander-agent disable # stop and disable on boot
$ sudo aicommander-agent uninstall --force
$ aicommander-agent doctor # diagnose this installation, one verdict + remedy per check
list-admins shows every account currently linked to this machine; emails are masked (e.g. lu****@wear****.com) and never leave the relay unmasked. block-admin blocks one account at any time — it refuses that account's access and stops it re-linking, but the account stays listed (in a "Blocked" section) so unblock-admin restores it instantly. To lock everyone out at once, reset the code instead. See Security. uninstall stops any leftover aic-job-*.scope units before it deletes the job directories, since stopping the service no longer takes running jobs down with it.
Diagnostics
doctor checks an installation end to end — files present and intact, signs that security software has interfered with them, connectivity to the relay, autostart / service registration, and the privileged helper — and prints each check with a verdict and a remedy. It deliberately does not require root, so run it as the user whose installation you are asking about (on a systemd install, that is sudo):
$ aicommander-agent doctor
$ aicommander-agent doctor --report ./aicommander-diagnostics.txt # also write a report file to attach to a support case
$ aicommander-agent doctor --json # the same report as JSON, for tooling
$ aicommander-agent doctor --offline # skip every check that touches the network
$ aicommander-agent doctor -v # --verbose: each check's structured facts
It exits 1 when any check failed, and also when --report was given but the file could not be written; otherwise 0. Warnings and checks it honestly could not run do not change the exit code — so treat non-zero as "a human needs to look", and read the verdicts (or --json) if you need to know whether it was the machine or the report path. The report written by --report is redacted by construction — no access code, no agent token, no command text and no command output, and user account names stripped from every path — which is what makes it safe to attach to a support ticket or an antivirus vendor's false-positive form.
aicommander-agent on your PATH. The same checks are two clicks away instead: the tray / menu-bar item "Run Diagnostics…", and — on Windows, for when the app itself will not start — the Start-menu shortcut "AI Commander Diagnostics", which runs from the privileged helper's own folder beside the application and therefore survives the application folder being emptied. Both are described in Troubleshooting. Ships in the next release.Upgrading
One command, and it does the whole procedure rather than describing it:
$ sudo aicommander-agent self-update
It returns immediately because it detaches into its own transient systemd unit — installing restarts the agent, and anything left inside the agent's own control group would be killed by that restart, mid-swap. The detached worker fetches the versioned installer, verifies an Ed25519 signature against a key compiled into the binary (a key downloaded from the same origin as the payload would only prove the payload matches what that origin wanted), backs up the current binary, installs, and then checks two things: that the service really restarted, and that it reports the expected version. If either fails it restores the backup and restarts. Follow it with tail -f /var/lib/aicommander/self-update.log, or simply watch the reported version change.
--force reinstalls the version you are already on. Going backwards also requires it: an old release keeps a valid signature forever, so a stale version pointer must never be able to walk a fleet back onto known-vulnerable code on its own.
aic-job-<jobId>.scope), outside the agent service's control group, so the restart every upgrade performs does not reach it — measured running gaplessly straight through a systemctl restart. Where the agent could not create scopes — a host without systemd, or an agent not running as root — jobs are still in the service's control group and the upgrade ends them; the agent states which of the two it got in one line at startup (journalctl -u aicommander-agent), and again only if a later re-probe changes that answer. One more case is not covered on any box: a job that was already running when the agent was upgraded to a version with scopes is in no scope, and the restart the upgrade itself performs is exactly what ends it — the protection only reaches jobs started after that upgrade. So check remote_job_list before upgrading a machine with work in flight, always on a fallback host and at least once on every other one. Also note that "the agent is running" is not evidence an upgrade worked: after a failed one the old agent is still running perfectly well. Only the version tells you.Machines without systemd (QNAP/QTS and similar) are refused deliberately: there the restart authority is the box's own keepalive, so nothing in the updater can promise the agent comes back. Use the QNAP procedure instead. On macOS and Windows the desktop app updates itself — see Desktop apps.
Session code lifetime
Session codes are stable: generated once at install, reused across reboots, service restarts, reconnects, and updates. They do not auto-rotate or expire on a timer. A code changes only when you run aicommander-agent change-code (Linux) or click "Reset Access Code" in the desktop tray, and it is removed only on a full uninstall.
change-code / "Reset Access Code" (see Security).Desktop apps
On macOS and Windows the agent ships inside a lightweight menu-bar / tray application — there is no command-line agent installer for those platforms. The app bundles the agent, so launching it registers the machine and there's no terminal involved. From the menu-bar / tray icon you can:
- Copy the current session code — it's shown right in the icon's menu.
- Reset the code ("Reset Access Code") — confirms first, since it resets everyone's access (mints a new code and clears all links and blocks).
- Review and block accounts via "Linked Accounts…" — lists every account linked to the machine (masked emails) and lets you block or unblock any of them at any time.
- Enable or disable remote access with a single click.
- Get notified when a remote operator connects — a brief alert (rate-limited so steady use isn't noisy) with a one-click Disable Remote Access.
- Run diagnostics — "Run Diagnostics…" runs the same checks as
aicommander-agent doctorand shows what failed, with Copy Report / Save Report… for the redacted report you attach to a support case. On Windows there is also a Start-menu shortcut, "AI Commander Diagnostics", for when the app will not start at all.
The code is persisted in the app's user-data directory, survives app updates, and is removed only on a full uninstall. The status indicator reflects the agent's connection:
- Connected — agent is running and reachable.
- Reconnecting — agent is trying to re-establish the WebSocket.
- Disabled — access is paused; the agent will not accept commands.
Download links (macOS Apple Silicon / Intel, Windows x64) are on the home page and in the Connect guide.
Session codes
Session codes are high-entropy identifiers that map an online agent to a relay session. Format: AIC-XXXX-XXXX-XXXX — three groups of 4 characters drawn from an unambiguous alphabet (no I, L, O, or U), giving ~59 bits of CSPRNG entropy. Example: AIC-7K3P-WX9M-RTBN. A code is generated once at install and stays stable across reboots and restarts.
How clients use them
An AI client recognizes the AIC- prefix in conversation and routes commands to the right machine via remote_exec. There is no per-machine MCP config — you use the current code for the machine you want to reach. With an account you can use a friendly alias instead of the raw code.
Multiple machines
You can have any number of agents registered simultaneously. Each gets its own code and its own isolated session. A command sent to AIC-7K3P-WX9M-RTBN is routed only to that session, not to AIC-9F4D-2HQN-VK7T.
Sharing codes
A session code is sensitive. Anyone who knows a current code can run commands on that machine, so only share codes with people and agents you trust. To give someone ongoing access, share the current code and have them sign in. To cut access, either block a specific account from the machine itself — aicommander-agent block-admin / unblock-admin or the desktop tray's "Linked Accounts…" — or reset the code with change-code. Resetting is the master "start over" action: the old code stops working immediately and all links and blocks are cleared.
MCP tools
The relay speaks standard Model Context Protocol over HTTP with SSE responses at https://aicommander.dev/mcp. Adding it to a client (see Connect) exposes the tools below. Connecting to /mcp is account-bound by default: the client runs a one-time browser sign-in — it auto-registers via Dynamic Client Registration, so there are no manual credentials to paste — and the connection binds to your account, so your saved aliases resolve. An unauthenticated request gets an HTTP 401 with a WWW-Authenticate challenge, which is what kicks off that sign-in. To drive a machine by raw AIC-… code without signing in, connect to https://aicommander.dev/mcp?anonymous=1 instead (allowed during the code's first hour). Each tool accepts a code that is either a raw AIC-… session code or, on a signed-in connection, one of your saved aliases.
remote_exec(code, command, cwd?, timeout_ms?)
Runs a shell command on the machine for code and returns the result.
code— the target machine's session code or alias.command— the shell command to run.cwd(optional) — working directory for the command.timeout_ms(optional) — per-command timeout; the command is killed if it overruns.
The buffered result has the shape:
{ "ok": true, "exitCode": 0, "stdout": "…", "stderr": "…", "truncated": false }
Output is buffered until the command finishes. With Accept: text/event-stream, SSE sends heartbeat comments while it runs and then one final result event; it does not stream stdout or stderr incrementally. At the 1-hour deadline the process tree is hard-killed. At 1 MiB of total output the reply is truncated (truncated: true) and the relay sends a best-effort stop, but that request can lose the race and the process may keep running. Output is delivered only to the connection that issued the command.
session_status(code)
Reports whether the machine for code is online, without running anything. Anonymous callers can use this during the code's first hour; an account works after that. Unknown and stale codes return the same message, so there is no code-existence oracle.
With an account credential and an online machine, it also returns machine notes: the path of a note file kept on that machine (notePath over REST, e.g. $HOME/.aicommander/notes/u-3f9c1a7b20de.md, or %USERPROFILE%\.aicommander\notes\… on Windows), where an AI client records what it learned about the box — installed toolchains, where projects and data live, how to build and run them, quirks — so the next session starts informed instead of re-exploring. The relay only computes the path: nothing about the note is stored here, and the client reads and writes it with ordinary remote_exec. The value is shell syntax rather than a literal path — the relay does not know the machine's home directory — so use it inside double quotes and let the machine's shell expand it. The read command handed to the client is capped (16 000 bytes, or 200 lines through PowerShell on Windows) so that a note grown out of hand — or padded on purpose by someone else with access to the machine — cannot flood the client's context.
Every account gets its own file, so people sharing a machine keep separate notes — but that is scoping, not isolation: commands run as the same operating-system user for all of them, so any operator of the machine can read and write every note on it. Keep secrets out of it, and treat what a note says as information to verify rather than instructions to follow. Delete a note by deleting the file.
list_machines() — requires an API key
Lists every machine saved to your account. blocked (operator approval/block state) and planRestricted (account plan) are independent. Free keeps every saved record but only the 10 oldest by creation time, then id, are usable; deleting one promotes the next. Pro enables every saved record up to the technical ceiling of 100. Restricted entries remain visible but are not probed for telemetry: they report online:false, lastSeenAt:null, and omit platform/GPU details. Do not interpret that as proof the machine itself is offline; attempts to use it fail with reason:"plan_device_limit".
Each entry also carries the machine's own identity: its hostname, and — from agents that report them — its localIps, the non-loopback addresses of its network interfaces (at most 8). The alias is only what you typed when you saved the machine, so on a fleet of aic-wfs-pc, aic-wfs-pc2, aic-wfs-pc3 these are what say which physical box a row is; session_status reports the same pair for one machine. They are a label, never a route: the addresses live on the machine's own side of its NAT, nothing connects to them, and every command still goes through the relay. An absent localIps means that machine's agent did not report any (an older agent, or none it was sure of) and never that the machine has no network; while a machine is offline both are last known, and DHCP may since have moved an address to a different machine. Both are what the machine says about itself, so the relay repeats them only when they look like what they claim to be — an entry that is not address-shaped, or a hostname containing line breaks or other characters no machine name has, is dropped instead of printed, and the row simply appears without it.
Detached jobs — remote_job_start / _list / _status / _logs / _cancel
remote_exec has two caps with different effects. Its 1-hour wall-clock deadline hard-kills the process tree. At 1 MiB of total output, the reply is truncated and the relay sends a best-effort stop, but the request can lose its race and the process may keep running unseen. Never read a truncated reply as proof that work stopped. That still makes exec the wrong tool for a training run: you can lose the log within minutes, and the run itself is killed at one hour. A detached job removes both limits by keeping the output on the machine — the agent writes stdout and stderr to a file there, records the exit code to disk, and only bounded slices ever cross the relay. A job keeps running after the call returns, through a network drop, and after the conversation ends. On macOS and Windows it also survives the agent itself restarting; on Linux each job is launched into its own transient systemd scope, outside the agent service's control group, so stopping or restarting the service — including an agent upgrade — leaves it running. That path needs a systemd host and a root agent; without both, jobs stay in the service's control group and a restart still ends them.
remote_job_start(code, command, cwd?, env?, name?, gpu_index?)— spawns the job and returns itsjobIdimmediately.cwdmust be an absolute path that already exists;envtakes string values only. There is noelevatedoption for jobs.remote_job_list(code, status?, include_command?)— running jobs plus finished ones still retained (about a week, after which the agent removes them automatically in the background).remote_job_status(code, job_id, include_command?)—running,exited(with the exit code) orunknown(the process is gone and no exit code was recorded — an agent restart, aSIGKILL, an OOM kill or a cancellation that escalated all land here — so the outcome genuinely cannot be determined).remote_job_logs(code, job_id, tail_lines?, offset_bytes?, max_bytes?)— stdout and stderr interleaved. Default is the last 200 lines; each reply is capped at 256 KiB and carries anextOffsetBytesyou feed back asoffset_bytesto follow a growing log.remote_job_cancel(code, job_id)— kills the whole process tree and releases any reserved GPU.
Passing gpu_index reserves that NVIDIA card exclusively: the machine takes a lock and sets CUDA_VISIBLE_DEVICES for the job, and a second job asking for the same card is refused with gpu_busy (naming the holder) rather than both runs hitting CUDA OOM. On agents 1.0.50 and newer, when the machine's GPU list is known (a clean nvidia-smi probe — a missing binary or a wedged driver counts as "could not tell", and stays permissive), an index that is not on that list is refused with invalid_request rather than starting a phantom job whose CUDA_VISIBLE_DEVICES points at nothing. An older agent still accepts any in-range index, and that job fails inside CUDA instead. The agent also rejects a value that could not name a device on any machine (non-integer, negative, or above 4095) the same way. Take the index from the gpus array list_machines / session_status already return. Other structured refusals are too_many_jobs (32 running jobs per machine), not_found and invalid_request (the request itself cannot run anywhere — an empty command, a relative cwd).
The job's log file is capped at 256 MiB — on overflow the agent appends a notice and stops recording, but does not kill the job. Job state lives entirely on the target machine; the relay stores nothing and, as everywhere else, never logs a command string or a byte of output.
For a worked example — picking a GPU box, starting a training run, following its log and collecting the result — see Start ML training on your own GPU. Step-by-step client commands are in Connect → Long-running jobs.
~/.cache/huggingface, ~/.cache/torch and ~/.triton into a root-owned directory you can't see from your own account — on most boxes, on the system partition. A job's HOME therefore defaults to the home of the user who owns its cwd (and a caller-supplied HOME always wins), but remote_exec has no such defaulting. Set HF_HOME / TORCH_HOME explicitly for anything that downloads weights.File transfer — remote_pull / remote_push
For a shorter, task-oriented introduction with pull and push examples, see Move files to and from a remote machine. File transfer is a Pro feature: Free and anonymous callers cannot start an upload, pull, or push.
remote_exec cannot move a file: its reply is capped at 1 MiB and it corrupts binary data, so a checkpoint, a rendered image or a dataset can't come back through it. remote_pull(code, path) reads an absolute path off the machine and stores it as a temporary blob on the relay, returning a blob id and a download link; remote_push(code, blob_id, dest_path) writes a stored blob back onto a machine. The REST equivalents are POST /api/v1/pull and POST /api/v1/push, with POST /api/v1/files to upload your own bytes and GET /api/v1/files/{blobId} to fetch them.
- 100 MiB per file. This is a platform limit — a transfer crosses the relay as a single HTTPS request body — not a policy one. For a multi-GB checkpoint, have the job itself copy the artifact to storage you already control (
aws s3 cp,rclone,scp) as its last step. - Paths are absolute, and a pull needs a regular file. Archive a directory first (
tar -czf) and pull the archive. - A push replaces any existing file at the destination. It is written atomically — into a temp file beside the destination, then renamed — so a reader never sees a half-written file and a dropped transfer leaves the previous one untouched. An overwrite keeps the old file's ordinary permission bits (
mode & 0o777), and its owner and group where the agent has the privilege to set them (an unprivileged agent, or root over an NFS root-squash mount, keeps the mode but leaves the owner as its own user, and still reports success).setuid,setgidand the sticky bit are deliberately dropped — re-applying them would re-arm a setuid-root binary around content someone else supplied — so a push over a04755binary lands as0755. Only a regular file donates anything: if the destination is a symlink (or a directory, or a device) nothing at all is inherited and the new file keeps0600, which is also what a file the push creates gets, owned by the user the agent runs as. - A push that cannot fit is refused before any bytes move — the machine checks the declared size against the 100 MiB ceiling and against free space at the destination up front, and aborts mid-transfer if more bytes arrive than were declared.
- Pro is required for every direction.
remote_pull,remote_push, andPOST /api/v1/filesrefuse Free and anonymous callers. A blob and download link created before downgrade remain usable only until their original 24-hour / one-hour expiry. - An upload must declare its size.
POST /api/v1/fileschecks the request for aContent-Lengthand refuses 411 without one, before any body is read: the relay stores a blob at exactly its declared length and will not take a stream of unknown size. So upload from a file (curl --data-binary @train.csv), not from a pipe —cat train.csv | curl --data-binary @-streams chunked, carries no length, and fails. The declared length is enforced rather than believed: a body that delivers a different number of bytes is refused with 400 and nothing is stored. - A transfer call answers in about 55 seconds. Nothing streams back while the machine works, and MCP clients abandon a request at 60 seconds by default, so a longer server-side budget only produced transfers nobody was still waiting for. A slower transfer is failed with an explanation rather than left hanging, and giving up voids the machine's upload credential first: a machine that has not started sending is refused outright and no blob is ever created. Only an upload already in flight leaves a copy behind; it is charged, becomes unreadable at its 24-hour expiry, and is removed by the hourly, retrying sweep. For something bigger or slower, move it through storage you already control.
- Transfers are quota'd. 60 transfers an hour per account (pulls and pushes together), 60 uploads an hour to
POST /api/v1/files(5,000 an hour across the whole relay), and 5 GiB of relay storage per subject per rolling day — that daily budget is charged by the two doors that put bytes into the relay, an upload and a pull, because both park bytes in the same place. A push does not spend it: it sends bytes the relay already holds and already charged for, so a day spent pulling never costs you the ability to send a file to a machine. The daily figure is a hard ceiling — no more than 5 GiB is ever admitted into relay storage within any rolling 24 hours — and it is counted in hourly steps, so bytes stay counted for up to 25 hours rather than exactly 24: a spent allowance comes back hour by hour, the last of it up to an hour after the day is formally over. Over any of them the answer is 429 withreason:"rate_limited"; the windows roll forward, so a short pause clears the per-hour limits, and a genuinely large artifact belongs in storage you already control rather than in the courier. - A pull holds 100 MiB of the daily budget until it finishes. It cannot know a file's size before the machine sends it, so it reserves the per-file maximum and settles to the true size the moment the bytes land — a refund, in the ordinary case. Two things follow, and both are the allowance behaving as designed rather than a fault: with less than 100 MiB of the day left, a pull is refused even for a small file (that last slice is reachable by an upload, which declares its size up front), and pulls running at the same time draw on the same headroom — each holds the full 100 MiB until it settles, so enough of them at once hold the whole day's 5 GiB between them and the next one is refused until some of them land.
- Downloads are rate-limited per IP.
GET /api/v1/files/{blobId}allows 120 requests a minute per IP (20,000 relay-wide) and answers 429 above that. It is the one transfer endpoint reachable with no credential at all — its authorization checks happen only after a storage read — and the ceiling is deliberately generous, since a browser fetching a legitimate 100 MiB link may retry or resume it several times. - A blob id is not a key. Blobs belong to whoever created them: a push checks ownership, and a download needs either the one-hour link token or an account credential that owns the blob. An unknown id, an expired one, and someone else's are indistinguishable.
Authorization composes the same machine checks as remote_exec — including operator blocks and plan_device_limit — with the separate Pro gate. The bytes never cross the relay's WebSocket: it carries a path and a single-use token, and the machine then moves the file over ordinary HTTPS. The relay never stores the file's name, only its bytes and size, and never reads or indexes the contents.
REST API
The same operations are exposed as a plain REST API for clients that don't speak MCP — or for a single HTTP call from a script, a cron job, or any chat model with a fetch/browse tool. The machine-readable spec is at /openapi.json.
Authentication
This REST surface has its own auth, separate from the MCP endpoint. No token means anonymous access during the code's first hour. Authorization: Bearer <api-key> enables alias-based access for your account (kept active by opening the dashboard within the last 24h — see Security). There is no OAuth browser flow on this surface; REST clients paste an API key.
GET /api/v1/status/{code}
Returns whether the machine for {code} is online. Works anonymously during the code's first hour.
$ curl -s https://aicommander.dev/api/v1/status/AIC-7K3P-WX9M-RTBN
POST /api/v1/exec
Runs a command and returns a buffered JSON result. Body: { "code", "command", "cwd"?, "timeout_ms"? }.
$ curl -s -X POST https://aicommander.dev/api/v1/exec \
-H 'Content-Type: application/json' \
-d '{"code":"AIC-7K3P-WX9M-RTBN","command":"df -h"}'
The response mirrors remote_exec: { "ok": true, "exitCode": 0, "stdout": "…", "stderr": "…", "truncated": false }. Send Accept: text/event-stream for heartbeat comments while the command runs and one final result event; stdout and stderr remain buffered until that event.
GET /api/v1/status — requires an API key
The token-authed counterpart to list_machines: returns every saved record with blocked and planRestricted. Plan-restricted records intentionally carry no live telemetry.
/api/v1/jobs — detached jobs, no 1 h / 1 MiB cap
The HTTP twin of the five remote_job_* tools, over the same core and the same authorization as /api/v1/exec. code goes in the JSON body for the POST and as a query parameter everywhere else.
$ curl -s -X POST https://aicommander.dev/api/v1/jobs \
-H 'Authorization: Bearer YOUR_API_KEY' -H 'Content-Type: application/json' \
-d '{"code":"gpu-box","command":"./.venv/bin/python train.py","cwd":"/home/u/aic-jobs/mytrain","name":"mytrain","gpu_index":0}'
# → {"ok":true,"job":{"jobId":"9f2c1b7ad4e05386","name":"mytrain","status":"running","exitCode":null,"gpuIndex":0,"logBytes":0,"truncated":false, …}}
$ curl -s 'https://aicommander.dev/api/v1/jobs?code=gpu-box' -H 'Authorization: Bearer YOUR_API_KEY'
$ curl -s 'https://aicommander.dev/api/v1/jobs/9f2c1b7ad4e05386?code=gpu-box' -H 'Authorization: Bearer YOUR_API_KEY'
$ curl -s 'https://aicommander.dev/api/v1/jobs/9f2c1b7ad4e05386/logs?code=gpu-box' -H 'Authorization: Bearer YOUR_API_KEY'
$ curl -s -X DELETE 'https://aicommander.dev/api/v1/jobs/9f2c1b7ad4e05386?code=gpu-box' -H 'Authorization: Bearer YOUR_API_KEY'
A log reply is { "ok": true, "logs": { "chunk": "<base64>", "offsetBytes": …, "nextOffsetBytes": …, "eof": …, "truncated": … } } — chunk is base64 because process output is arbitrary bytes, and each slice is capped at 256 KiB, so page a long log with offset_bytes rather than asking for more at once. Machine-side refusals get their own status: 409 gpu_busy (with heldBy), 429 too_many_jobs, 404 not_found, 400 invalid_request.
To get a checkpoint or a render off the machine, use POST /api/v1/pull — it returns a download link, and access to the stored copy ends after 24 hours. Above its 100 MiB limit, have the job push the artifact to storage you already control (aws s3 cp, rclone, scp) as its final step.
POST /api/v1/secure-exec — sandboxed, non-root, service-token only
Runs an allowlisted, sandboxed, non-root command on the one machine a service token is pinned to. Body: { "argv": [...], "input"?: base64, "cwd"?, "timeout_ms"? } — argv is an exec-style vector (no shell). This is the deliberate opposite of /api/v1/exec: it never reaches the root command surface, and the only accepted credential is a service token (aics_…) — an account API key, admin token, or anonymous are rejected.
$ curl -s -X POST https://aicommander.dev/api/v1/secure-exec \
-H 'Authorization: Bearer aics_…' \
-H 'Content-Type: application/json' \
-d '{"argv":["git","status","--porcelain"],"cwd":"/repo"}'
The response mirrors /api/v1/exec ({ "ok": true, "exitCode": 0, "stdout": "…", "stderr": "…", "truncated": false }, output capped at 8 MiB). A service token is long-lived and revoke-only (no expiry), scoped to one machine + an allowlist of command basenames, and bypasses the 24h dashboard re-activation gate.
Token creation returns 400 secure_exec_command_denied for known generic execution primitives: common shells; Python/Node/Perl/Ruby/PHP/Lua runtimes (including common version-suffixed names); env/busybox/find/xargs/awk/sed runners; sudo/su/doas/pkexec; Docker/Podman/nerdctl; and kubectl (whose plugin and exec surfaces can dispatch other programs). The agent applies the same shared policy before spawn even if the relay sends a hostile allowlist.
Before spawning, the Linux agent also refuses the aicommander-exec account when its primary or supplementary groups include root, wheel, sudo, docker, podman, lxd, incus, disk, libvirt, or numeric gid 0. Unreadable, missing-primary, malformed or duplicate local passwd/group data fails closed. This denylist is deliberately not complete: the dedicated non-root uid/group boundary—not command-name classification—is the containment mechanism.
/api/v1/secure-tokens & /api/v1/secure-exec/audit — requires an API key
Create service tokens manually in the dashboard, where the plaintext token is shown once. Account API keys can manage existing tokens but cannot mint new long-lived credentials: POST /api/v1/secure-tokens returns 403 with a dashboard link; GET /api/v1/secure-tokens lists tokens (no secrets); DELETE /api/v1/secure-tokens/{id} revokes; GET /api/v1/secure-exec/audit lists recent runs (command basename, exit code, duration, caller IP, bytes, truncated, time — never arguments, input, or output).
SKILL.md into your agent's skills directory. The agent auto-loads it and drives machines over the REST API, with no MCP server to add and no OAuth. Setup: Connect → REST API & Skill; view it at /skill/SKILL.md.uv workspace per run, reserving a card with gpu_index, paging a training log, keeping model caches off the system partition, and getting artifacts out. They're collected in a third skill: /skill/gpu/SKILL.md./usr/local and /etc are rebuilt from a ramdisk at every boot, and the standard installer fails with curl: (23) Failure writing output to destination. A second, install-time skill covers it: /skill/qnap/SKILL.md.Security
The access model is simple: a session code is the credential for its machine. Anyone who knows a current code can run commands on that machine — for as long as the code stays valid — until the owner resets the code or blocks their account. The code is stable (it doesn't auto-rotate); it's minted/refreshed only when someone with physical/admin access installs the agent or runs change-code.
Who can use a code
- Sign in for ongoing access. With an account — personal API key or magic-link OAuth — first use auto-links the machine and saves it under an alias. You can then use the alias or raw code, subject to the daily re-activation gate (it covers API keys and OAuth connectors alike). If the first link happens within the code's first hour, access starts immediately. If it happens later, the account is linked but starts blocked pending operator approval, because the code may have leaked by then. Access lasts until the owner resets the code or blocks your account.
- No account needed for the first hour. During the first hour after a code is created or refreshed, anyone can use ordinary command/status onboarding. File transfer is excluded and always requires Pro. After the hour, anonymous access stops working.
- Giving someone access: share the current code and have them sign in. A signed-in account can also add a machine from the dashboard (
POST /api/machines) with a code of any age. - Blocking an account (owner-side): from the machine itself, the owner can list linked accounts and block or unblock one account with
aicommander-agent list-admins,block-admin,unblock-admin, or the desktop tray's "Linked Accounts…". Emails are masked server-side (e.g.lu****@wear****.com). Blocking refuses that account's access and stops it re-linking; unblocking restores it. To remove everyone at once, reset the code, which clears all links and blocks.
| Component | Details |
|---|---|
| Session code | AIC-XXXX-XXXX-XXXX, generated from a high-entropy CSPRNG with an unambiguous alphabet. The credential for a machine. Stable across reboots/restarts; changes only on change-code, removed only on uninstall. Anyone who knows it can use it while access is allowed — see above. |
| Codes at rest | The plaintext code is never stored. Every record is keyed by a one-way hash of the code, so a database leak contains no usable codes and they can't be brute-forced from it — the only exception being the first two characters of an account-linked machine's code, kept as a non-secret pairing fingerprint that can't reconstruct or use the code. |
| Agent tokens | Minted fresh per registration and stored only as hashes. The agent rotates its token automatically while idle (never mid-command), so any single token is short-lived even if leaked. |
| Daily re-activation | An account's API keys and its OAuth connectors stay paused until the owner opens the dashboard, and re-arm together each time the dashboard is opened (roughly once a day). A key sitting in a config file can't act on its own without recent dashboard access; a paused OAuth connector stays installed and authorized — it keeps refreshing normally, only reports that access is paused, and never asks to be re-authorized or reinstalled; the same tokens resume working the moment the owner signs in again. Service tokens and first-hour anonymous code access are outside this gate. On by default; an account can opt out in the dashboard — one setting for both credential classes, flagged as risk-increasing. |
| Accounts & API keys | Passwordless sign-in by passkey or email magic link. API keys are stored as salted hashes and are revocable from the dashboard. Used as the Bearer token to reach machines by alias. |
| OAuth (MCP clients) | Passwordless OAuth (OAuth 2.1 + PKCE): the user enters an email code or uses a passkey, and the access token binds to their account so aliases work. Every approval appears under Connected apps; disconnecting it immediately blocks both access and refresh tokens. Refresh tokens rotate atomically and detect reuse; every successful rotation rolls the connector's window forward to 30 days from that refresh, up to a 12-month ceiling from the original approval — so a connector in regular use keeps working until that ceiling, while an abandoned one expires 30 days after its last refresh. |
| Platform secrets | Stored in a dedicated secret store — never in code or any database, never returned by any API. Secret comparisons are constant-time (no timing side-channel). |
| Rate limiting | Agent registration and /mcp tool calls are rate-limited per IP (with a global ceiling) — covering session_status too, since it is a brute-force oracle for codes. Limits are enforced atomically to resist races and IP rotation. Magic-link sends are limited per IP, per email, and by a global domain-wide ceiling. Anonymous lookups return an identical message for not-found vs. stale codes, so there is no code-existence oracle. |
| CORS & CSRF | Credentialed CORS is locked to the dashboard origin for cookie-authed routes only; everything else is token- or IP-authed. Mutating dashboard requests carry an X-Requested-With/Origin check on top of a SameSite=Lax session cookie. |
| Cleanup job | A scheduled cleanup job purges expired web sessions, magic links, old revoked API keys, and expired OAuth token-family rows. Consumed refresh predecessors stay about 30 days past their consumption, which is the window in which replaying one revokes the whole token family. |
| No command logging | We never log or persist payloads. Commands and their stdout/stderr are never written to logs or any database — no command text or output is stored at all. Data is processed only transiently in the relay's memory while it streams through, then discarded. |
| Output isolation & caps | A command's output is delivered only to the connection that issued it — another connection on the same session never sees it. At the 1-hour deadline the process tree is killed. At the 1 MiB output cap the reply is truncated and a best-effort stop is requested, but the process may win that race and keep running. |
| Encryption in transit | All connections — agent↔relay and client↔relay — run over TLS to the edge (wss:// / HTTPS). This is not end-to-end: the relay processes command text and output in plaintext in memory only long enough to forward it — it is never written to disk or any store. |
| Safe defaults / opt-in | Higher-reach features are off by default and must be explicitly enabled by the machine's owner — e.g. desktop screen sharing (remote_screenshot) is disabled until toggled on in the tray. The desktop app also notifies the owner by default when a remote operator connects. |
| Install integrity | Release CI signs a versioned installer and each Linux agent binary with Ed25519. The documented flow verifies the installer before sudo; the verified installer pins its release and enforces both the binary's SHA-256 checksum and detached signature. Missing OpenSSL 3, LibreSSL, missing pkeyutl -rawin, missing key/signature, or verification failure all abort. This protects against altered CDN artifacts only when the public-key fingerprint was obtained independently; the website cannot authenticate its own trust anchor. |
| Prompt-injection | The MCP tools instruct the model to treat command output strictly as untrusted data to relay back — never as instructions to act on — so a malicious file or log line can't hijack the agent. |
| Web hardening | Explicit OAuth consent, optional Turnstile, strict security headers, and a CSRF check on top of the SameSite=Lax dashboard cookie. Every cookie-authenticated dashboard API response (including failures and preflights) is no-store, varies on Origin and Cookie, and exposes credentialed CORS only to the dashboard origin. Public pages deliberately load the trusted CoderAI feedback widget under explicit coderai.dev script and connection CSP allowances. The authenticated dashboard never loads that SDK: its feedback links hand off to /?feedback=dashboard, while its conjunctive meta CSP keeps scripts and connections same-origin. OAuth loopback completion is scriptless and can frame/navigate only to its validated local callback origin, while hosted callbacks remain redirects. |
| Session isolation | Each session is a separate, fully isolated session instance. One session cannot read another's traffic. |
| Network | Agent connects outbound only via WebSocket. No inbound ports are opened on the target machine. |
| Root access | The agent runs as root by default for administration workflows. The machine owner controls who can act on the session code (by resetting the code or blocking accounts). To restrict command permissions, run the agent as a non-root user. |
Security FAQ
Do you log or store my commands and their output?
No — this is a guarantee, not best-effort. We never log or persist payloads. Commands and their stdout/stderr are never written to logs or any database; no command text or output is stored at all. Data is processed only transiently in the relay's memory while it streams through, then discarded. We keep no command history.
Is the connection encrypted?
Yes, in transit: every link (agent↔relay and client↔relay) runs over TLS to the edge. It is not end-to-end encrypted, though — the relay handles command text and output in plaintext in memory in order to forward it (and only for that — it is never stored). If you need the relay itself to never see plaintext, this isn't the right tool. We're deliberate about saying so.
And be honest with yourself about what end-to-end would even mean here: the moment you drive a remote machine through an AI tool — Claude, Codex, or any other AI client — that tool is one of the endpoints. It has to read your command output and write the next command in plaintext to do its job, and that plaintext lives on the AI provider's side too. So "no one but me ever sees the text" isn't achievable with this kind of workflow, no matter how the transport is encrypted. If that's a hard requirement, an AI-driven remote shell isn't the right fit.
What happens if my session code leaks?
The code is the credential, so treat it like an SSH private key. To lock everyone out instantly, reset it with change-code — the old code stops working immediately and all account links and blocks are cleared. (You can also block an individual account at any time.) Codes are also stored only in keyed (hashed) form, so a database leak never exposes them.
How do I see who has access to a machine, and remove someone?
From the machine itself: run aicommander-agent list-admins (or open the desktop tray's "Linked Accounts…") to see every account currently linked. Emails are masked (e.g. lu****@wear****.com) — the full address is never sent from the relay. To block one account, use aicommander-agent block-admin <number> (or the Block button in the tray window) — this works at any time, refuses that account's access, and stops it re-linking; the account stays listed in a "Blocked" section so unblock-admin (or the Unblock button) restores it instantly. To lock everyone out at once, reset the code (change-code / "Reset Access Code"), which clears all links and blocks.
I signed in and added a machine but it says "No access" — why?
You connected after the code's first hour. That first hour (after the code is created or reset) is a trusted onboarding window; connect within it and your account is linked with access instantly. After it, the code may have leaked, so for safety a brand-new account is linked but starts blocked until the machine's operator approves it. Ask them to open "Linked Accounts…" on the machine (or run aicommander-agent unblock-admin) and unblock your account — then the "No access" badge clears and it works.
What if my account API key leaks?
API keys are stored hashed and are revocable from the dashboard at any time. By default they're also gated by daily re-activation: a key stays paused unless the owner has opened the dashboard recently, so a key sitting in a file can't be used on its own indefinitely. The same gate covers the account's OAuth connectors, which pause and resume with the keys. A last used timestamp helps you spot unexpected use.
Are any risky features on by default?
No. Higher-reach capabilities are opt-in. Desktop screen sharing is off until you enable it in the tray, and the daily re-activation gate is on by default — you can turn it off, but the dashboard flags that as risk-increasing.
Can an AI agent be tricked into doing something via command output?
We mitigate this: the MCP tools instruct the model to treat all command output strictly as untrusted data to relay back to you, never as instructions to act on. Output is also size-capped so a flood can't destabilize the relay.
Does this open a port on my machine?
No. The agent only makes one outbound WebSocket connection to the relay. Nothing listens for inbound connections, so there's no port to scan, and it works behind NAT and firewalls without changes.
Can other people see my machines or output?
No. Each session is an isolated instance, and a command's output is routed only to the connection that issued it.
Why does my antivirus flag or quarantine AI Commander?
Because it's a remote administration tool, and behavioural engines judge a program by what it does. Running commands you send it, capturing the screen, moving files and holding an outbound connection open looks a lot like a remote access trojan, so a heuristic fires now and then — usually on the small per-job helper scripts the agent writes on Windows rather than on the signed executable. It's a false positive, not a compromised build. Some engines then delete files instead of just warning, which is why the app can stop starting entirely. Troubleshooting → antivirus has the check that confirms it and the ordered fix (restore, exclude both folders, add the behavioural exception, restart, reinstall).
Is this a sandbox?
No — it's remote shell access. The agent runs as root by default for administration work; run it as a non-root user to restrict what commands can do. Anyone who knows a current code can run commands on that machine, so the security model is about controlling who holds the code (reset it to lock everyone out, or block an individual account).
Architecture
AI Commander has two parts: the hosted relay (the MCP/REST endpoint your AI client talks to) and a small agent on each machine you control. The agent dials out to the relay, so nothing listens for inbound connections on your machine.
How a command flows
- The agent starts and opens an outbound, encrypted connection to the relay, registering under its session code.
- You mention
AIC-7K3P-WX9M-RTBNto Claude → Claude callsremote_exec("AIC-7K3P-WX9M-RTBN", "df -h"). - The relay authenticates the request and routes it to the isolated session for that code.
- The agent runs the command and sends stdout/stderr chunks to the relay, which buffers them for the requesting client.
- The process exits → the client receives one result with the buffered output and exit code. SSE clients receive heartbeats while waiting and one final
resultevent.