The CLI for scripts and AI agents
Run commands, copy files, start background jobs and forward ports on your machines from a script or an AI agent — scoped, expiring agent tokens, the audit log, one-command revoke, exit codes, JSON, connection reuse and MCP.
gaiadesk-cli reaches your machines ("desks") from a script, a CI job or an AI agent. exec runs one command and hands back exactly what ssh host cmd would: the command's own output on stdout, its errors on stderr, and its exit code as the CLI's exit code — no prompt, no echo, no colour or cursor codes. Around it: cp copies files, run --detach starts jobs that outlive the connection (with CPU, memory and priority caps, and the desk kept awake if you ask), stats shows what a desk is doing, devices --probe says which desks a command will actually reach, forward carries ports, and a scoped agent token lets a program do only what you allowed.
gaiadesk-cli exec --desk-id 123456789 -- hostnameWhich shell runs your command? The desk's own, and it differs by desk: on macOS and Linux it is the desk user's login shell (
$SHELL -l -c— zsh on a Mac), on Windows it iscmd.exe. For a portable POSIX script, say so:--shell sh(/bin/sh -c). On Windows,--shell pwshfor PowerShell.--jsonand-vprint the shell the desk actually used.
gaiadesk-cli exec --desk-id 123456789 --shell sh -- 'uname -a; df -h /' # any Mac or Linux desk
gaiadesk-cli exec --desk-id 234567890 -- 'ver & hostname' # a Windows desk: cmd.exe
gaiadesk-cli exec --desk-id 234567890 --shell pwsh -- 'Get-Service | Where-Object Status -eq Running'None of these commands share or even read the desk's screen. See Terminal over the Mesh for installing the command and for the interactive gaiadesk-cli shell.
Setting up an AI agent: start with a scoped token
Never give an AI agent the desk's access code or password. Either one is full control of the computer, and it ends up pasted into prompts, config files, environment variables and chat logs. Give it a scoped, expiring agent token instead — it can only do what you allowed, only on the desks you named, only in the folder you named, stops working on its own, is revoked with one command, and everything it runs is recorded.
On your own machine, as the desk's owner (the desk's unattended password is asked for once):
gaiadesk-cli token create --desk 123456789 --name claude-site \
--scope exec,cp --expires 3d \
--cwd /Users/me/projects/site --low-priv \
--out ~/.config/gaiadesk/claude-site.token--scope exec,cp: run commands and copy files — no interactive shell, no port forwarding, no screen, no background jobs unless you add them.--expires 3d: it stops working by itself (the default is 7 days; the desk's owner sets the longest allowed, 30 days unless changed).--cwd: its commands start in that folder and it may only copy files inside it (links are followed before the check, on every platform).--low-priv: its commands run as the desk's agent user — a standard, non-administrator account you name on the desk (Agents → Limits and indicator → Run agent commands as). With none set, its commands are refused; they never run as you instead.
Then give the agent the token file, never the token text in a prompt:
export GAIADESK_TOKEN_FILE=~/.config/gaiadesk/claude-site.token
gaiadesk-cli exec --desk-id 123456789 -- npm testor, for an MCP client, "env": { "GAIADESK_TOKEN_FILE": "…" } (see AI assistants). When the work is done, or the moment anything looks wrong:
gaiadesk-cli audit --desk 123456789 --token claude-site # what it ran, with exit codes
gaiadesk-cli token revoke --desk 123456789 claude-site # stop it, nowCredentials: one rule for every command
shell, exec, cp, forward, run/ps/logs/kill, token and the MCP desk tools all choose what they present to the desk the same way, first match wins:
--code <code>— only together with--allow-code-in-argv. Every user on your computer can read a command line (ps, Task Manager), so without the second flag--codeis refused before anything is dialled. A code is never echoed.--token-file <file>— a scoped agent token.GAIADESK_TOKEN_FILE— a file holding an agent token.GAIADESK_CODE— the desk's unattended password (or a one-time code).An echo-free prompt — only on a terminal. With no terminal (a script, CI, an agent) the command fails at once and says what to set; it never waits for an answer nobody can give.
--code and --token-file together are refused. GAIADESK_AGENT_TOKEN is not part of this rule: it is the screen (Agent Access) credential of mcp and agent run.
export GAIADESK_TOKEN_FILE=~/.config/build-bot.token
gaiadesk-cli exec --desk-id 123456789 -- uptimeReaching desks anywhere. On your network or GaiaDesk Mesh nothing more is needed. Beyond them the desk is reached through the GaiaDesk server: with an agent token that needs no account and no sign-in; with a code, this machine must be signed in to the GaiaDesk app. (GAIADESK_TOKEN_FILE may instead name the app's account token file on a server; an account token there is used for the sign-in, never presented to a desk.)
Agent tokens
A program should not hold the desk's password. An agent token can only do what its scopes allow, expires, can be revoked at any moment, and everything it does is recorded.
Create one (the desk's owner)
gaiadesk-cli token create --desk 123456789 --name build-bot \
--expires 24h --scope exec,cp,jobs --out ~/.config/build-bot.tokentoken commands present the desk's unattended password (prompt, GAIADESK_CODE, or --code … --allow-code-in-argv); the desk checks it again for every token request. A one-time access code is not enough, and an agent token can never create, list or revoke tokens. Agent access must be on for the desk (Settings → Agent access).
The token is written to --out as a file only you can read (mode 0600). It is shown once and never again: the desk keeps only a hash of it. Without --out it is printed once on standard output.
--expires—30m,24h,7d,2w(default7d). The desk's owner sets the longest a token minted from the command line may last (Agents → Limits and indicator, 30 days unless changed); asking for more is refused.--scope— what the token may do (defaultexec,cp,jobs):
Scope | Allows |
|---|---|
| see and drive the screen (Agent Access, MCP screen tools) |
|
|
| an interactive |
|
|
|
|
|
|
--cwd <folder on the desk>— confines the token: its commands, shells and jobs start there, and every path it copies or lists must resolve inside it (links are resolved first, so a link pointing out is refused; relative paths are taken from that folder). Uploads to Downloads are refused. It is not a sandbox — a command may stillcd /— which is what--low-privis for.--low-priv— its commands, shells and jobs run as the desk's agent user, a standard account its owner names on the desk. How the desk does it: a background service running as root (macOS, Linux) switches to that account; the app running as you usessudo -n -u <agent user>, which needs one sudoers line the refusal message spells out; on Windows the work runs as the agent user when that account is the one signed in at the PC (or when GaiaDesk itself runs as it). An administrator is never accepted as the agent user, and with none set the work is refused — never run as you instead.--desk a,b,c(or--deskmore than once) — mints one token per desk under the same name, all into the one--outfile (one per line); the CLI offers each desk only its own.
The desk enforces every limit — not the CLI — on every request. --cwd, --low-priv and the shell scope are only sent to a desk new enough to enforce them; an older desk is refused with "update it first", and nothing is minted. Before shell was its own scope, exec included the shell; tokens minted then keep it.
A token without screen can never get a picture of the desk. A token with none of exec, shell, cp, forward, jobs is refused at the door (exit 254); a token admitted for one scope is refused, in the desk's words, the moment it asks for another — exec without the exec scope exits 254 with the reason (and in --json, "error" says which scope is missing). You can also create tokens, with scopes, a folder and the agent user, in the app: Agents → Agent tokens.
Use one (the agent)
export GAIADESK_TOKEN_FILE=~/.config/build-bot.token # or --token-file <file>
gaiadesk-cli exec --desk-id 123456789 -- make test
gaiadesk-cli cp -r ./dist 123456789:deploy/A token file that other users can read is refused; chmod 600 it. A token names its desk and is never sent to any other. The desk checks the token's scope and expiry on every request.
List and revoke
gaiadesk-cli token list --desk 123456789 # names, ids, scopes, limits, expiry
gaiadesk-cli token revoke --desk 123456789 build-bot # by name…
gaiadesk-cli token revoke --desk 123456789 9f3a1c2b7d004e11 # …or by the id `list` shows
gaiadesk-cli token revoke --desk 123456789 --all-for-desk # every live token on the desk
gaiadesk-cli token revoke --desk 123456789 build-bot --accountA revoke takes effect at once: the token is refused from the next request, every session it has open is disconnected, and the background jobs it started are stopped. --account does it without the desk's password, through the GaiaDesk server as the account signed in on this machine (gaiadesk-cli login): the server checks your account registered that desk and tells the desk over its own connection. A desk that is offline is reported as such — revoke it on the desk, or again when it is back (a token cannot be used on a desk nobody can reach).
exec: one command
Output and exit codes
Stream | What's on it |
|---|---|
stdout | the command's stdout, byte for byte |
stderr | the command's stderr, then any line from |
Exit code | Meaning |
|---|---|
0–255 | the command's own exit code |
124 |
|
130 | interrupted (Ctrl-C); the command was stopped too |
254 | the desk refused: wrong password, a token without the scope (or expired, revoked), no permission, declined |
255 |
|
A command that can't be found exits 127, as in a shell. On Windows, an exit code above 255 is reduced to its low byte (never to 0).
stdin is closed by default (-n), so a program that waits for input ends instead of hanging, and can't swallow input meant for a later command. Pass -i to forward your stdin:
cat report.csv | gaiadesk-cli exec -i --desk-id 123456789 -- wc -lJSON
--json prints exactly one JSON object on stdout and nothing else:
gaiadesk-cli exec --json --desk-id 123456789 -- hostname{"exit": 0, "remote_code": 0, "stdout": "office-pc\r\n", "stderr": "",
"duration_ms": 412, "desk": "123456789", "route": "LAN", "mode": "pipes",
"shell": "C:\\Windows\\system32\\cmd.exe /d /v:off /s /c",
"timed_out": false, "error": null, "notes": [], "truncated": false}exitis what the process exits with;remote_codeis the command's own.route: how the desk was reached (Mesh,LANorthe GaiaDesk server).mode:pipesnormally;terminalfor a desk with an older GaiaDesk (see below).shell: the shell the desk actually ran the command with — the program and the flags in front of your command line (/bin/zsh -l -c,/bin/sh -c,cmd.exe /d /v:off /s /c,powershell.exe -NoProfile -NonInteractive -EncodedCommand, ornone);nullfrom a desk with an older GaiaDesk.errorisnullwhen the command ran and ended on its own. Otherwise it is the error envelope's object,{"kind": …, "message": …, "reason": …}:refusedwhen the desk refused the command (a token withoutexec, no control, a--cwdoutside the token's folder),failedwhen it could not start, was stopped or ran out of time, and — when it never got as far as the desk —unreachable,connection_lost,usageorprotocol, with the finer cause inreason(offline,unknown_desk,not_online,network,not_signed_in,timeout,local).Each stream is kept up to 16 MB (
truncatedsays if more was dropped).
Streaming JSON
--json-stream prints one JSON object per line while the command runs — for an agent that wants output before the end:
gaiadesk-cli exec --json-stream --desk-id 123456789 -- 'make test'{"event":"stdout","data":"building…\n"}
{"event":"stderr","data":"warning: unused variable\n"}
{"event":"exit","exit":0,"remote_code":0,"duration_ms":5120,"desk":"123456789","route":"LAN","mode":"pipes","shell":"/bin/zsh -l -c","timed_out":false,"error":null,"notes":[]}stdout/stderr events carry text as it arrives (a character split across two chunks is held until it is whole). The last line is exit — --json's object without the output — or, when the command never ran, {"event":"error","exit":255,"error":{"kind":…,"message":…}}. --json and --json-stream can't be combined.
The working directory
--cwd <dir> starts the command in <dir> on the desk (a relative path is taken from the desk user's home): gaiadesk-cli exec --desk-id 1 --cwd /srv/app -- make. A token confined to a folder (token create --cwd) may only name a directory inside it — a relative --cwd is taken from that folder, and anything outside it is refused (exit 254) and nothing runs. A directory that doesn't exist fails with exit 1. run --detach and shell (interactive, a script on stdin, or a command after --) take --cwd too. A desk with a GaiaDesk too old for --cwd is told so (protocol) rather than running the command somewhere else.
Timeouts and progress
--timeout <duration>stops the command after that long (30s,10m,2h;0for no limit). The default is 30 minutes.--connect-timeout <duration>gives up reaching the desk (default 60s). The error names the step that never finished, from what the CLI saw:stuck looking for the desk on the LAN and GaiaDesk Mesh: no route to it found yet— no route;stuck reaching the GaiaDesk server (the relay)…— the server is down or blocked on this network;reached the desk via … and asked it to connect, but it never answered — it may be waiting for someone at it to approve the connection…— the desk's own checks (code, approval);the desk is restarting (busy updating)/the desk is asleep— the desk is busy;the desk accepted …, but the data channel to it did not open— no direct path and no relay got through.
What each road that failed said comes after it in brackets (
[earlier: LAN: not found on this network]).-v(--verbose) prints one line on stderr after the command: the route, the mode, the shell used, the exit code and the time.While it's waiting for the desk,
gaiadesk-cliprints a line on stderr every few seconds (waiting for desk 123456789 (connecting, 10s of 60s)…).-qturns that off.
When a desk can't be reached the error says why, for example:
desk 123456789 is offline (last seen 4 min ago)— one of your account's or team's desks that isn't connected;no desk 123456789 is online, and none by that id belongs to your account or team — check the id;desk 123456789 refused the connection: …— the desk answered no.
Choosing the shell, and quoting on Windows
By default the command runs in the desk's own shell: the desk user's login shell on macOS and Linux ($SHELL -l -c — zsh on a Mac, with their profile and PATH), Command Prompt (cmd.exe) on Windows. --shell picks another: default, none, sh (/bin/sh -c: use it for POSIX scripts that must behave the same on every Mac and Linux desk), bash, zsh, cmd or pwsh (also spelled powershell: PowerShell 7, or Windows PowerShell when 7 isn't installed). PowerShell is started non-interactively — -NoLogo -NoProfile -NonInteractive, the script decoded by a plain -Command rather than -EncodedCommand — so it never prompts and never writes its #< CLIXML progress block ("Preparing modules for first use") into stderr.
Environment variables
--env KEY=VALUE (repeatable), --env KEY (the value of $KEY where gaiadesk-cli runs — the way to pass a secret without putting it on a command line other users can read) and --env-file <file> set variables for the command, on top of the desk's environment — for exec and run --detach alike. The file is KEY=VALUE lines (# comments, blank lines and an export prefix are fine; one pair of quotes around a value is removed). A later flag wins. The values travel to the desk in the request and are never logged by the CLI or the desk (the desk logs the names of a job's variables, not their values). A token that runs its work as the desk's low-privilege agent user (--low-priv) can't pass variables (sudo drops them) and is refused.
gaiadesk-cli exec --desk-id 234567890 --env CARGO_TERM_COLOR=never --env RUST_LOG=info -- cargo test
gaiadesk-cli run -d --name build --desk-id 234567890 --env-file build.env --shell pwsh -- './build.ps1 -Release'A program Windows refused to start
With Smart App Control or WDAC (App Control for Business) on, Windows can refuse an unsigned program — a fresh build's build script, a downloaded tool — with OS error 4551 (or 225). The desk says so instead of a bare failure: exec --json's error is {"kind": "failed", "reason": "blocked_by_os_policy", "message": …} (the message names Smart App Control / WDAC and the CodeIntegrity event log, and plain exec prints it on stderr), and a background job ends as exited (blocked by Windows Smart App Control / WDAC) with "reason": "blocked_by_os_policy".
On Windows, commands run over plain pipes — never a console window or a pseudo-console — so nothing waits on a terminal, and no line is cut at a console width. cmd is switched to UTF-8 first (chcp 65001) and PowerShell writes UTF-8, so é, ü and 日本 arrive as UTF-8. A program that writes in its own legacy code page still does; convert it there if you need to.
gaiadesk-cli exec --desk-id 234567890 -- systeminfo # Windows, cmd.exe
gaiadesk-cli exec --desk-id 234567890 -- 'dir /b "%USERPROFILE%\Desktop"'
gaiadesk-cli exec --desk-id 234567890 --shell pwsh -- 'Get-CimInstance Win32_OperatingSystem | Select-Object Caption, Version'
gaiadesk-cli exec --desk-id 234567890 --json -- 'exit /b 3' # "exit": 3, "remote_code": 3What you pass after -- is read like this:
One argument is a command line, given to the shell as it is. Quote it for your local shell once, and write it in the remote shell's language:
gaiadesk-cli exec --desk-id 123456789 --shell pwsh -- 'Get-Process | Sort-Object CPU -Descending | Select-Object -First 5' gaiadesk-cli exec --desk-id 123456789 --shell cmd -- 'dir "C:\Program Files" & ver'Several arguments are separate arguments. GaiaDesk quotes each one for the chosen shell, so the program receives exactly those strings — spaces, quotes,
%PATH%,$HOMEand all:gaiadesk-cli exec --desk-id 123456789 --shell pwsh -- Get-ChildItem 'C:\Users\Dana\My Documents' gaiadesk-cli exec --desk-id 123456789 -- findstr /c:"two words" notes.txtFor
cmd, each argument is quoted the way Windows programs read their command line and every charactercmdwould act on (& | < > ^ % ! ( ) ") is escaped, so nothing is expanded or run by accident.cmd's own built-in commands (echo,dir) don't read their arguments that way — give them one command line instead. An argument containing a line break can't be passed tocmd; usepwsh.For
pwsh, the script travels as-EncodedCommand, so nothing on the Windows command line can change it, and a failed command's exit code comes back (a native program's own code, 1 for a failed cmdlet).--shell noneruns the program directly, with no shell at all: the first argument is the program, the rest its arguments.
Don't nest shells: instead of -- powershell -c "..." inside cmd, use --shell pwsh. The same rules apply to run --detach: ONE argument after -- is the command line, given to the desk's shell verbatim (-- 'msbuild app.sln /m'); several are separate words, each quoted for that desk's shell (sh on macOS/Linux, cmd.exe on Windows — where an argument holding a cmd metacharacter is refused: give the whole line as one argument).
Desks with an older GaiaDesk
A desk running a GaiaDesk from before plain-pipe commands still works: the command runs in a terminal there. The output then arrives on stdout only (stderr merged into it), with the command line echoed, and gaiadesk-cli says so on stderr (and in notes). Update GaiaDesk on the desk to get separate, clean streams.
shell: a terminal, or a script
On a terminal, gaiadesk-cli shell --desk-id <id> is an interactive shell on the desk, like ssh host (see Terminal over the Mesh). Not on a terminal, it runs like exec — plain pipes, stdout and stderr apart, no prompt and no echo, and the remote exit code:
gaiadesk-cli shell --desk-id 123456789 -- 'brew list | wc -l' # one command
printf 'cd /tmp\nls\nexit 3\n' | gaiadesk-cli shell --desk-id 123456789 --shell sh
echo $? # 3
gaiadesk-cli shell --desk-id 234567890 --json < cleanup.cmd # a Windows desk: cmd.exe reads itA script on stdin is handed whole to the desk's shell on macOS and Linux (--shell sh for /bin/sh instead of the login shell) and to PowerShell with --shell pwsh; on Windows cmd.exe reads it line by line and exits with the last command's code. --json, --timeout and -v work as for exec.
shell never fails silently: the reason is printed on stderr (or in --json), and it never exits 0 unless the remote shell or command did.
Exit code | Meaning |
|---|---|
0–255 | the remote shell's or command's own exit code |
124 |
|
130 | interrupted (Ctrl-C) |
253 | remote error: the connection was lost mid-session, or the desk ended the terminal |
254 | refused: wrong code, a token without the |
255 | could not connect: offline, unreachable, timed out (also bad arguments) |
cp: copy files
gaiadesk-cli cp report.pdf 123456789:Documents/ # upload into a folder
gaiadesk-cli cp -r ./site 123456789:/var/www/site # a folder, to a new name
gaiadesk-cli cp 123456789:logs/app.log ./app.log # downloadA destination that is an existing folder, or ends in
/, receives the source under its own name. Otherwise the destination is the new name. Relative desk paths are under the desk user's home folder.Folders need
-r. Existing files are overwritten.Interrupted? Run the same command again: it continues from where it stopped (the partial file is kept as
name.gdpart, and its bytes are checked by hash before anything is resumed).Every file is checked end to end (SHA-256). Paths the other side sends are never allowed outside the destination folder.
Progress and a summary line go to standard error. With
--json, standard output is one summary object (files, bytes, resumed bytes, failures) and standard error stays quiet — as with every command's--json— unless you add-v; a copy with failed files still says so there.Windows paths are shown without their
\\?\prefix (C:\Users\…).Needs the desk's file-transfer permission (and, for a token,
cp; a token confined with--cwdcopies only inside its folder).
Background jobs: run --detach, ps, logs, kill
Long work goes in a background job — a build, a test suite, a render, a backup, anything that takes longer than you want to hold a connection open. Don't background it inside exec (start /b, nohup … &, a Task Scheduler task): exec ends its whole process tree when the command returns, and a scheduled task is invisible to ps, logs and kill.
gaiadesk-cli run --detach --name nightly --desk-id 123456789 -- ./build.sh --release
gaiadesk-cli ps --desk-id 123456789
gaiadesk-cli logs nightly --desk-id 123456789 -f
gaiadesk-cli wait nightly --desk-id 123456789 --timeout 2h # block until it ends; its exit code
gaiadesk-cli kill nightly --desk-id 123456789On a Windows desk (the command runs in cmd.exe, as with exec):
gaiadesk-cli run -d --name build --desk-id 234567890 --priority low --cpu 50 -- "msbuild App.sln /m /p:Configuration=Release"
gaiadesk-cli run -d --name tests --desk-id 234567890 --mem 4G --keep-awake -- "cd /d C:\src\app && npm test"
gaiadesk-cli logs build --desk-id 234567890 -f
gaiadesk-cli ps --desk-id 234567890 --jsonThe job runs under the desk's GaiaDesk host, as the same user, not under your connection: it keeps running after you disconnect, after the terminal you ran
gaiadesk-clifrom closes, and after the CLI's held connection ends. On Windows it runs in a Job object of its own, broken away from the GaiaDesk session's, with no console window and its own process group, owned by the long-lived GaiaDesk host (the background service's session host, or the app); on macOS and Linux it runs in a session of its own (setsid), so no hang-up reaches it. If GaiaDesk itself restarts on the desk, the job keeps running and the new GaiaDesk picks it up again. (A job that ends while GaiaDesk is restarting on Windows is shown aslost: its exit code could not be seen.) Jobs do not survive a reboot.Its output (standard output and error together) is kept on the desk, up to about 8 MB per job (the oldest output is dropped first).
logs -ffollows until the job ends (Ctrl-C stops following, not the job);--tail <bytes>shows only the end.logs --jsonprints{"job": {…}, "output": "…"}; with-f, one event per line ({"event":"output","data":"…"}, then{"event":"end","job":{…}}).--cwd <dir>starts the job in a directory on the desk (inside a confined token's folder, or refused).ps --jsonprints{"jobs": […]}.--shell sh|bash|zsh|cmd|pwshruns the command in that shell instead of the desk's job shell (sh -c,cmd /c);--env/--env-fileas forexec.wait <job>blocks until the job is no longer running and exits with the job's exit code (1 when it was killed or lost without one; 124 when--timeoutran out first; 254/255 refused/unreachable).--jsonprints the job. The desk holds the request; an older desk is asked every second.Finished jobs are kept for 7 days (at most 64), then removed. At most 16 run at once.
Needs control of the desk (and, for a token,
jobs). From the Windows background service a job runs as the user signed in at the PC, and is refused while nobody is; it never runs as SYSTEM or root.
Caps: priority, CPU and memory
GaiaDesk enforces these on the desk, for the job and everything it starts:
Option | Windows | Linux | macOS |
|---|---|---|---|
| the Job object's priority class | nice 10 / -5 | nice 10 (+ background QoS) / -5 |
| Job object CPU rate, hard cap | cgroup v2 | GaiaDesk pauses the job to hold it there |
| Job object memory limit: an allocation past it fails | cgroup v2 | GaiaDesk stops the job past it |
highneeds a GaiaDesk host running as root on macOS and Linux (raising a priority is privileged); otherwise the job runs at normal priority and says so.On Linux without a systemd user session (no cgroups v2 for GaiaDesk to use),
--cpufalls back to GaiaDesk pausing the job and--memto an address-space limit (RLIMIT_AS) plus GaiaDesk's own watchdog. An address-space limit counts reserved, not used, memory: programs that reserve a lot up front (Java, Node, Go) may not start under it.A job stopped for its memory shows
killed (memory limit 512 MB)inps.pshas a CAPS column;run --jsonandps --jsonaddlimitsandenforcement— one line per cap saying how this desk enforces it, and what it could not do here.A desk with an older GaiaDesk can't enforce caps;
runwith any of these options refuses to start the job there (exit 1) rather than run it uncapped.
Keeping the desk awake
--keep-awake holds off idle sleep for as long as the job runs, and lets go the moment it ends — the display may still sleep and lock. macOS uses caffeinate -i tied to the job, Windows SetThreadExecutionState from the GaiaDesk host, Linux a logind sleep inhibitor. A desk can make it the default for every job: set GAIADESK_JOBS_KEEP_AWAKE=1 for its GaiaDesk, or put {"keep_awake": true} in jobs/.settings.json in GaiaDesk's data folder there; --no-keep-awake opts one job out.
forward: ports
gaiadesk-cli forward 123456789:5432 localhost:15432
gaiadesk-cli forward 123456789:db.internal:5432 localhost:15432 123456789:80 localhost:8080Connections to the local port go to the port on the desk (or, with a host name, a machine on the desk's network). Several pairs run at once; it runs until Ctrl-C. It listens on localhost only. Reverse forwarding (-R) is not available: the desk only connects out. Needs control of the desk (and, for a token, forward).
Desk operations: exit codes
Code | Meaning |
|---|---|
0 | done |
1 | it ran but did not fully succeed (a file failed to copy, no such job) |
254 | the desk refused: wrong password, token expired/revoked/not scoped, permission off |
255 | the tool's own error: bad arguments, desk unreachable, connection lost |
Whether a reply was a refusal (254) or a failure (1) comes from the desk itself — a field on its answer — not from its wording; only a desk with a GaiaDesk from before that field is read by its words.
JSON: errors and shapes
Every command that takes --json fails the same way: one object on stdout,
{"error": {"kind": "refused", "message": "this agent token is not scoped for background jobs: it needs the `jobs` scope"}}with the same exit code as without --json (the sentence also goes to standard error). kind is one of six:
kind | meaning | exit |
|---|---|---|
| bad arguments — fix the call | 255 |
| the desk or server said no: credential, scope, permission, a confined token's folder | 254 |
| the desk or server could not be reached: offline, unknown id, no route, timed out ( | 255 |
| it was reached, and the connection went away mid-request | 255 |
| allowed, and it didn't succeed: no such job, a file failed, a local error | 1 (or 255) |
| the desk answered something the CLI can't use — usually a GaiaDesk too old for the request | 255 |
exec/shell --json keep their whole object and put the same {kind, message, reason} in its error.
Command |
|
|---|---|
|
|
|
|
|
|
|
|
| the job: |
|
|
|
|
|
|
|
|
|
|
| one event per line: |
|
|
gaiadesk-cli --version prints the app's release (the number the desktop app shows); --version --json adds features, a list an SDK can check before it uses a flag or a shape (exec_json_stream, exec_cwd, json_error_envelope, mcp_lifecycle, …).
Changes in 0.10.324
ps,token listandauditprint an object ({"jobs": […]}, …), not a bare array.Job and token errors are the envelope, not
{"error": "<text>"};cp's refusal is the envelope, not{"refused": "<text>"}.exec --json'serrorisnullor{kind, message, reason}, not a string; a run that never started has one of the six kinds, with the old kind (offline,network, …) asreason.MCP tool names are
gaiadesk_<tool>(see AI assistants).
--json=v1 keeps the old shapes for one release.
The schema, and SDK types
Every --json shape — results, the error envelope, the events of --json-stream, logs -f and login — is one JSON Schema:
gaiadesk-cli schema --json > gaiadesk.schema.json
node --experimental-strip-types scripts/gen-sdk-types.mts ./sdk --schema gaiadesk.schema.json # types.generated.ts, types_generated.pyThe CLI is built on gaiadesk-client (the client/ crate in the source), a typed async Rust API — Client::new(options), client.desk(id).exec(spec), cp_up/cp_down, run_job/jobs/job_logs/wait_job/kill_job, forward_start, stats, measure, mint_token/tokens/revoke_token/audit, devices, whoami, mesh_status/mesh_ip, and screen sessions — whose results ARE these shapes and whose one Error has the six kinds above. The CLI's JSON is the serde of those types, so the CLI, the MCP server's desk tools and the Node and Python bindings cannot disagree.
Connection reuse
Reaching a desk takes a few seconds. The first command to a desk — exec, shell, cp, forward, a jobs command, or an MCP tool call — leaves a small background process holding the connection, and every later one rides it: twenty execs, a cp and a forward to the same desk cost one handshake. It closes after 10 minutes without use, when the desk goes away, or when you say so:
gaiadesk-cli disconnect --desk-id 123456789
gaiadesk-cli disconnect --all--persist 30m (on exec and shell) or GAIADESK_PERSIST=30m (every command) changes how long it stays; 0 connects fresh every time and leaves nothing behind.
A held connection is kept per desk and per credential. One opened with an agent token is used only by commands presenting that same token — never by a command using the desk's code, and never by another token — and one opened with a code is never used by a token. So a token's scopes always apply to everything that rides its connection. A command that would prompt for the code doesn't ask while a code-authenticated connection to that desk is held.
Only you can use a held connection: it listens on a socket in a directory only your account can open (macOS and Linux) or on a named pipe only your account can open (Windows). The credential is handed to it privately, never on a command line or in the environment.
Finding desks
gaiadesk-cli devices
gaiadesk-cli devices --json
gaiadesk-cli devices --probe # dial each desk now
gaiadesk-cli devices --probe -d 234567890 # just this one; exit 1 if unreachablelists every desk you can reach — your account's and your team's (when this machine is signed in: devices says as whom on standard error, e.g. signed in as dana@example.com (from the GaiaDesk app on this machine), and --json adds "identity"; gaiadesk-cli whoami prints just that), desks paired with this machine, GaiaDesk Mesh peers, and desks you've connected to before — with its name (hostname), OS, GaiaDesk version, owner and when each was last seen. Name, OS and version come from what each desk last told the server (or the Mesh, or this CLI when it last connected); a desk running a GaiaDesk too old to say shows - until it's updated.
Online is not the same as reachable. ONLINE is presence: the desk's GaiaDesk holds a connection to the GaiaDesk server (or the LAN or Mesh saw it). A desk can be online while every command to it times out — its agent stuck, no road to it, the server's relay down. So devices also shows REACH, what this machine saw the last time it tried:
ok 4 min ago via Mesh— the last connect worked, by that road;failed 2 min ago: <kind>— the last one did not, and where it stuck:
Kind | Meaning |
|---|---|
| not found on the LAN or GaiaDesk Mesh |
| the GaiaDesk server (the relay) did not answer |
| the desk was asked and never answered: its agent is busy or stuck, or someone at it has to approve the connection |
| the desk is asleep, or restarting into an update |
| the desk accepted, but no path carried the session |
| connected, but its GaiaDesk did not answer a round trip ( |
| as |
online(silent 5m) means the server has not heard from the desk's connection for that long. --probe dials each desk now (four at a time, 15 seconds each), proves its agent answers with a round trip, and shows ok LAN 12ms or FAILED <kind>; with --probe, devices exits 1 if a desk it dialled was unreachable. A probe presents a credential like any connect, and only one meant for that desk: an agent token is shown only to the desk it names, and $GAIADESK_CODE (or --code) only when you probe one desk with -d — a wrong code counts toward a desk's lock-out, so it is never tried on all of them. A desk with no credential is listed as not dialled.
In --json, each desk has online (presence), reachable (true/false from a probe, else null), last_ok (at, route, connect_ms, rtt_ms), last_failure (at, kind, message), probe (with --probe), and from the server signal_idle_secs and anytime (its owner says it starts with the computer and works at the lock screen).
What a desk is doing: stats
gaiadesk-cli stats -d 234567890
gaiadesk-cli stats -d 234567890 --jsondesk 234567890 office-pc Windows 11 Pro (windows)
cpu 37.5% of 8 CPUs
memory 4.0 GB free of 16.0 GB (75% used)
disk C:\ 64.0 GB free of 500.0 GB (12% free)
uptime 3d 4h
jobs 2 runningCPU busy share (a short sample), load average (macOS and Linux), free and total memory, free space on each volume, uptime, and the background jobs running — straight from the desk's GaiaDesk. --json prints one object: hostname, os, os_version, cpu_percent, cpus, load, mem_total_mb, mem_free_mb, disks (mount, total_mb, free_mb), uptime_secs, jobs_running. Any admitted session may ask (it reads no screen and no files).
ssh and scp over GaiaDesk Mesh
When both machines are on GaiaDesk Mesh, every desk has a stable Mesh address (100.x.y.z) that plain ssh, scp and rsync can use. Mesh names are not published in DNS, so look the address up:
gaiadesk-cli mesh status # every desk on your Mesh: id, Mesh address, online
ssh me@$(gaiadesk-cli mesh ip 123456789)The desk must be running its own ssh server for this; GaiaDesk does not provide one. Without Mesh, use forward: gaiadesk-cli forward 123456789:22 localhost:2222, then ssh -p 2222 me@localhost.
What agents did: the audit log
Every action an agent token takes — each command it runs (the command line, the folder, and when it ends its exit code and how long it took), each shell line, each file it copies (paths and bytes), each port it forwards, each job it starts or stops, each session it opens, and each request refused — is recorded with the token's name and id, the time and the desk:
on the desk, in the app under Agents → Agent activity, and from the command line as the desk's owner:
gaiadesk-cli audit --desk 123456789 # newest first gaiadesk-cli audit --desk 123456789 --token build-bot --limit 200 --jsonin your GaiaDesk account, when the desk is signed in, readable from any of your machines (
gaiadesk-cli audit --desk 123456789 --account). Revokes made from your account are recorded there too.
Both copies are kept for 90 days. Passwords, tokens and file contents are never recorded. An agent token can never read the audit log.
Seeing it on the desk: the agent indicator
The person at the desk can see when an agent is at work, and stop it:
An AI agent on the screen (Agent Access, MCP screen tools): a banner across the top and a small panel in the corner — on by default; the desk's owner can turn it off.
Command-line work (
exec,shell,cp, background jobs — by an agent token or bygaiadesk-cliwith the desk's code): off by default; the owner turns on Show when an AI agent or the command line is working on this computer.
Both switches are in the app under Agents → Limits and indicator. The panel ("An agent is working on this PC") lists what is running and who started it, with Pause agent jobs (everything running is suspended and new work is refused until Resume), Stop agent jobs (everything running is ended and new work refused until Resume) and, while an agent is connected, Disconnect agent. It works the same on macOS, Windows (also when GaiaDesk runs as the background service: the panel is in the signed-in user's session) and Linux, and it is kept out of the picture an agent on the screen sees.
AI assistants (MCP)
gaiadesk-cli mcp serves GaiaDesk tools to an MCP client. Next to the screen tools it offers the desk tools: gaiadesk_exec (exactly like exec: arguments desk_id, command — one command line — or argv, shell, timeout_seconds, stdin, cwd and env; a result with the same fields as --json), gaiadesk_copy_files, gaiadesk_job_run (with optional cwd, priority, cpu_percent, mem_mb, keep_awake, shell and env), gaiadesk_job_list, gaiadesk_job_logs, gaiadesk_job_wait (name, timeout_seconds: {job, timed_out}), gaiadesk_job_kill, gaiadesk_forward_start and gaiadesk_forward_stop. They ride the same held connections as the CLI. Each returns its result as structuredContent too, and lists an outputSchema.
The server speaks the standard MCP lifecycle — initialize (protocol versions 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05 are negotiated), notifications/initialized, ping — and, beside it, the stateless 2026-07-28 revision (per-request _meta, server/discover). Over Streamable HTTP a session opened with initialize gets an Mcp-Session-Id. serverInfo.version is the app's release.
Tool names use only letters, digits, _ and - (gaiadesk_exec), which every client and model provider accepts. The dotted names from before 0.10.324 (gaiadesk.exec) still work in a call for one release, but are no longer listed.
Each screen session is recorded under --audit-dir — by default mcp-audit in your GaiaDesk data folder (never the folder the client started the server in).
What the desk tools present is the server's, never a tool argument (a model can't be talked into using another credential):
over stdio, the rule above without flags or prompt: an agent token from
GAIADESK_TOKEN_FILE, elseGAIADESK_CODE; failing both, the server's own agent token (GAIADESK_AGENT_TOKEN);over HTTP, only the server's agent token.
With a token, its scopes and limits decide: gaiadesk_exec needs exec, copying cp, jobs jobs, forwards forward; a missing scope, a path outside its folder or a refused agent user comes back as a tool error in the desk's words. A token file holding several desks' tokens works here too: each desk is offered its own.
{
"mcpServers": {
"gaiadesk": {
"command": "gaiadesk-cli",
"args": ["mcp"],
"env": { "GAIADESK_TOKEN_FILE": "/home/me/.config/build-bot.token" }
}
}
}The screen tools (gaiadesk_screenshot, clicks, typing) need an agent token with the screen scope in GAIADESK_AGENT_TOKEN; see Agent Access.
Help
Every command has its own help: gaiadesk-cli exec --help, gaiadesk-cli cp --help, gaiadesk-cli help token, gaiadesk-cli --help for the list. A mistyped command or option gets a suggestion.