Add a local desktop tool
An agent can drive a tool that lives on a real machine: a GPU workstation, a licensed CAD box, a build host. You do that by writing a declaration, not by changing SupaCloud. A new tool is a new declaration plus a capability label; there is no code to add and nothing to deploy.
This page is the whole contract. If you follow it, the tool works and the agent can tell a real failure from a machine that was never asked.
The shape
Section titled “The shape”Declarations live on an ssh_host resource, in a desktop block. The resource
already carries the machine’s address, the login user, the SSH credential (in its
secret bag) and the host-key pin; the desktop block adds what may be run there.
{ "host": "workstation.tailnet", "port": 22, "username": "runner", "host_key_fingerprint": "SHA256:…", "desktop": { "read_command": "cat --", "stat_command": "/usr/local/bin/mtime-ms", "max_report_bytes": 524288, "tools": { "kicad": { "…": "one declaration per tool" } }, "gui_only_tools": [], "input_command": null, "screenshot_command": null }}read_command and stat_command are appended one validated path token by the
driver. read_command prints a file to stdout; stat_command prints that file’s
modification time as epoch milliseconds (epoch seconds are also understood). They
are declared rather than assumed because cat / type / Get-Content are not the
same word on every host.
Without a read_command nothing can be read back, and every run on that host says
so instead of pretending the process exit is a verdict.
The six axes of a tool declaration
Section titled “The six axes of a tool declaration”1. Identity and capability label
Section titled “1. Identity and capability label”"kicad": { "label": "kicad" }The key (kicad) is what an agent passes as tool. The label is the free-form,
flat capability label the runner advertises for placement — blender, gpu,
windows, whatever your enrollment reports. Nothing in SupaCloud parses it and no
list of known tools exists anywhere; a label is an opaque token that either matches
or does not.
2. Binary resolution
Section titled “2. Binary resolution”"binary": "kicad-cli","binary_env": "KICAD_CLI","probe_command": "which"probe_command gets the binary appended and must print the resolved path. If it
prints nothing, the tool is unavailable — a named state that comes back as
verdict: "untrusted" with tool_availability.available: false, and the work never
runs. That is deliberately different from a failed run: “the exporter is not
installed” and “the board failed DRC” must never look alike.
binary_env is named in the refusal so the operator is told which variable would
override the path.
Omit all three and no probe happens.
3. Version contract
Section titled “3. Version contract”"version_command": "kicad-cli version","version": "10.0.5","version_contract": "exact"Three semantics, and the declaration says which one applies:
version_contract |
Meaning | When it is right |
|---|---|---|
exact |
must equal the declared version | a deterministic pipeline whose output is a manufacturing artifact — an exporter’s output can change between patch releases, and a Gerber that differs is a different board |
minimum |
must be ≥ the declared version | a tool used for its features, where newer is fine |
minor_series |
same MAJOR.MINOR, patch ≥ declared | an LTS line that promises patch compatibility |
An unknown word falls back to exact — the strictest reading, so a typo can never
silently widen what you accept. A version that violates the contract blocks the run
the same way an absent binary does.
So does a version that cannot be read: if your version_command fails, exits
non-zero, or is missing entirely while a version is declared, the run is blocked
rather than waved through. A pin nobody checks is worse than no pin, because the
declaration says it is enforced — and this is not hypothetical, OpenSCAD shipped
for years with --version exiting 1 instead of 0. Run tool.list with
probe: true once and read reported_version before you rely on a pin.
Omit version and nothing is checked.
4. Entry point and argument shape
Section titled “4. Entry point and argument shape”"command": "python /opt/bw-tools/pcb-release-pipeline/bin/validate_release.py --package-root /srv/boards/frg1","cwd": "/srv/boards/frg1","arg_allowlist": ["--release"],"timeout_ms": 900000cwd is where your declared relative evidence paths are resolved — the
report, the log and the artefacts. It is deliberately not applied to the
command: a cd X && … prefix is a syntax error in Windows PowerShell 5.1 and
does not change drive in cmd.exe, and the remote dialect is not knowable, which
is the same reason arguments are refused rather than escaped. Your entry point
places itself — use an absolute path, or wrap it (sh -lc 'cd … && …').
The pipeline is the contract. Declare the entry point the pipeline itself documents; never assemble a tool invocation yourself and never point at the raw binary when a pipeline wraps it. A driver that interpreted a tool’s output on its own would be a second truth beside the pipeline’s.
The agent may append arguments, and each one must be free of whitespace, shell
metacharacters and any .. segment. A token that is not is refused by name, not
escaped — the remote shell dialect is not knowable (a Windows OpenSSH login lands in
cmd.exe or PowerShell depending on a registry key), so escaping correctly is not a
thing that can be done. Free-form content goes in script, which travels over
stdin, which no shell parses.
arg_allowlist, when non-empty, restricts which flags the agent may pass. Plain
operands are unaffected.
5. The evidence contract — the important one
Section titled “5. The evidence contract — the important one”"report": "08_Test_Validation/aggregate.json","report_kind": "pcb_aggregate","log": "Artifacts/unity/editor.log"A bare exit code is never the answer. The declaration says where the pipeline writes its own verdict and how to read it:
report_kind |
Reads | Notes |
|---|---|---|
fmctl_envelope |
status + exit_code |
details is human free text and is never read for the verdict |
pcb_aggregate |
status, and independently native_kicad and counts.critical |
without release mode the top-level status deliberately ignores a failing native gate, so both are read and either can say no; manufacturing_status is hard-coded and is context, never a verdict |
publish_ready |
the marker’s presence + id/assets |
the marker is deleted at run start and rewritten only after every gate passes, so its fresh presence is the verdict; the build manifest is not consulted because it is not pre-cleared and can be a leftover |
json_pointer |
the value at your pass_pointer |
list the accepted values in pass_values, or the failing ones in fail_values; nothing is inferred |
opaque |
nothing | the file is fetched and surfaced, and the result says out loud that it carries no verdict |
When the pipeline’s own pass rule is a count rather than a word, list what fails instead of what passes:
"report_kind": "json_pointer","pass_pointer": "/summary/parts","fail_values": ["0"]Declaring only fail_values inverts the rule — every value passes except the ones
you name — because the passing values of a count cannot be enumerated. Deny wins:
a value in fail_values is red whatever pass_values says. This is not a
convenience. The 3D pipeline refuses its own FreeCAD stage with
if (!objects.parts.length) throw, and a declaration that could only check the
report’s shape would have called an empty conversion green.
Two guards apply to every kind and you do not configure them:
- Freshness. The report must be shown to belong to this run — by mtime
(
stat_command), or, on a host that cannot report one, by the file not having existed before the run or having changed across it. A report that cannot be dated isuntrusted, never green. This exists because a tool that exits before writing its report leaves the previous run’s green report on disk, and a leftover looks exactly like a fresh one. - Disagreement. A green needs every available signal to agree — the report, the report’s own exit code, and the process exit. Any dissent is a failure. This exists because a tool that exits 0 on failure will hand you a self-consistent lie.
log is an extra file that is fetched and scanned for the markers a batch harness
emits (error CS, Aborting batchmode, Application will terminate with return code N). The scan can only ever take a green away, never grant one: compile
errors reach no report, but “no marker found” is also what an empty or unwritten log
looks like.
6. Artefacts and provenance
Section titled “6. Artefacts and provenance”"artifacts": ["Artifacts/unity/health.json", "Artifacts/assets/report.json"],"provenance": "kicad-cli-native"artifacts are fetched after the run and returned with the result, which becomes
the MCP tool-call audit row’s payload. Declare anything you would want to read
later: a machine’s report tree is usually gitignored and local, so evidence that is
not lifted is gone the moment the run ends.
provenance is a free-form tag stamped onto the result so a reader can tell which
path produced an outcome, not merely that one exists.
7. Where a produced asset belongs
Section titled “7. Where a produced asset belongs”Text artefacts ride the result inline. Binaries do not, and they must never be
committed raw — a repository that swallows a 40 MB .blend per iteration is
unusable within a week, and no history rewrite gets it back.
So declare where this host’s binaries go, once, per host:
"assets": { "lfs_patterns": ["*.blend", "*.fbx", "*.png"], "external_resource": "renders"}lfs_patterns— binaries the project genuinely versions alongside its source (a reference render, a golden mesh). They are tracked through Git LFS, so the commit carries a pointer, not the bytes.*.extmatches by extension, case-insensitively; anything else matches the file name exactly.external_resource— the name of aseafile_webdavresource that takes everything else: turntables, intermediate bakes, per-run output the repository should not track at all.
Every lifted artefact then carries a storage object saying where it belongs:
"storage": { "route": "git_lfs", "stored": false, "pattern": "*.blend", "gitattributes": "*.blend filter=lfs diff=lfs merge=lfs -text"}The gitattributes line is given verbatim on purpose — put it in the repository’s
.gitattributes before committing anything that matches. The trailing -text is
not decoration: without it Git may end-of-line-normalise a file it reads as
text-ish and corrupt the asset on checkout.
Declare nothing and every binary comes back as "route": "unrouted" with a
reason naming the pattern you are missing. That is deliberate. The obvious next
move for an agent holding an unexplained binary is git add, which is exactly the
outcome this rule exists to prevent — so the route is never guessed.
stored is always false. SupaCloud routes the asset; it does not move it.
Delivering the bytes to an external library would need that library’s credential on
the run path, and a desktop runner is untrusted-by-default and secret-denied
(see the isolation note below). Treat the route as the instruction for whoever —
agent or human — does the commit or the upload.
A worked example
Section titled “A worked example”A KiCad release check on a Linux workstation, end to end.
{ "host": "bench-01.tailnet", "username": "runner", "host_key_fingerprint": "SHA256:9k2…", "desktop": { "read_command": "cat --", "stat_command": "/usr/local/bin/mtime-ms", "tools": { "kicad": { "label": "kicad", "binary": "kicad-cli", "binary_env": "KICAD_CLI", "probe_command": "which", "version_command": "kicad-cli version", "version": "10.0.5", "version_contract": "exact", "command": "python /opt/bw-tools/pcb-release-pipeline/bin/validate_release.py --package-root /srv/boards/frg1", "cwd": "/srv/boards/frg1", "arg_allowlist": ["--release", "--allow-missing-kicad"], "report": "08_Test_Validation/PCB_Release_Pipeline_RC3/aggregate.json", "report_kind": "pcb_aggregate", "artifacts": [ "08_Test_Validation/PCB_Release_Pipeline_RC3/native_kicad/native_kicad.json" ], "provenance": "kicad-cli-native", "timeout_ms": 1800000 } } }}mtime-ms is a two-line script you install once on the host:
#!/bin/sh# prints a file's modification time in epoch milliseconds[ -e "$1" ] || exit 1echo $(( $(stat -c %Y "$1") * 1000 ))An agent then discovers and runs it:
tool.list { "host": "bench-01", "probe": true }tool.run { "host": "bench-01", "tool": "kicad", "args": ["--release"] }and gets back a verdict it can act on, with the reasoning attached:
{ "tool": "kicad", "verdict": "failed", "truth_source": "envelope", "provenance": "kicad-cli-native", "tool_availability": { "available": true, "reported_version": "10.0.5", "version_satisfied": true }, "evidence": { "state": "fresh", "contract": "pcb_aggregate", "provenance": { "fields_read": ["status", "native_kicad", "counts.critical"], "values": { "status": "fail", "native_kicad": "fail", "critical": 1 } } }, "warnings": ["native_kicad is FAIL — a native ERC/DRC/export gate failed. …"], "artifacts": [{ "path": "…/native_kicad.json", "lifted": true, "encoding": "utf-8" }]}Start from a reference declaration
Section titled “Start from a reference declaration”docs/desktop-tools/reference-declarations.json ships a ready-to-paste entry for
each tool this organisation actually drives — Blender, Unity, FreeCAD, KiCad and
OpenSCAD (twice: once as a batch script, once with a machine-readable report).
Copy the entry object into your desktop.tools block and adjust the paths.
Each one carries more than the declaration. It names where every claim comes
from, why that evidence contract is the right one for that pipeline, what is
still unverified, and a status word you should read before trusting a green:
grounded— the entry point, report path and pass rule were read out of the pipeline’s own source. Not executed against hardware.declared— the form is right and the evidence contract is reasoned from the tool’s documented behaviour, but the pipeline’s source was not available. Probe it and run it once before you believe it.
The declarations also record the traps that are specific to each tool, and they
are worth reading even if you are declaring something else — they are the shapes
that repeat. FreeCAD’s exit code cannot be believed at all, because FreeCAD’s
interpreter swallows the script’s SystemExit; OpenSCAD’s --summary-file does
not exist in the last tagged stable release; a pipeline that takes its arguments
in an environment variable needs a small wrapper on the host, because the exec
channel carries a command and stdin but no environment.
The GUI fallback
Section titled “The GUI fallback”Some tools have no scriptable path at all. For those, and only those, the machine can be driven through its screen:
"screenshot_command": "/usr/local/bin/desktop-shot","input_command": "/usr/local/bin/desktop-input","gui_only_tools": ["legacy_cam"]desktop.observe captures the screen; desktop.act injects one input event. The
gate is structural: a tool with a declared entry point can never be actuated, and
the refusal names tool.run instead. Only a tool you listed in gui_only_tools may
be. That ordering is not negotiable by config — if you declare a tool both ways, the
entry point wins.
legacy_cam above is a placeholder for a class, not a product we drive. Every
tool in the reference declarations has a headless path, so on those hosts
gui_only_tools is empty and this whole section stays unused — which is the
intended outcome, not an omission.
One thing this fallback needs that the drivers do not: an interactive desktop
session. A command started over SSH on Windows lands in a non-interactive
session with no desktop to capture or click. The headless drivers are unaffected;
desktop.observe and desktop.act require the machine to be logged in and the
agent to run inside that session. See
Arm a desktop host for SSH.
Both commands run on the machine and call its own OS input API. Nothing goes through VNC or RDP; those remain a way for a human to watch, never the way anything acts.
Trust, in plain terms
Section titled “Trust, in plain terms”- The declaration is authored by an operator, never reported by the runner. It decides which command runs on that machine and which field is believed as truth. A runner that authored its own would be declaring its own success criterion, and an edge runner is untrusted by default.
- The SSH credential never leaves the server. The agent names a resource; it never sees key material, and neither does the machine’s own tooling.
- Tool licences stay on the machine. SupaCloud pushes no licence secret to a desktop host. A dongle or a node-locked seat has nothing for it to hold.
- The machine is outside the container boundary. A desktop host runs your agent’s
commands as a real user on a real OS, with no
cap_drop, no read-only root and no pid limits. Use a dedicated workstation, not a daily driver, and expect the isolation you get from a container to be absent here.
Adding your own tool
Section titled “Adding your own tool”- Install the tool and its pipeline on the machine.
- Add one entry under
desktop.toolswith the six axes above. Pick thereport_kindthat matches what the pipeline actually writes; if it writes a JSON report with a pass field,json_pointercovers it without any code. - Add the capability label to the runner so placement can route to it.
- Call
tool.listwithprobe: trueand confirm the binary resolves and the version satisfies your contract. - Run it once and read the
evidence.provenanceblock. If it saysopaque, or the state isstale, fix the declaration before trusting a green.
There is no step involving SupaCloud’s source.