Skip to content
Select themeSelect language

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.

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.

"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.

"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.

"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.

"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": 900000

cwd 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 is untrusted, 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.

"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.

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. *.ext matches by extension, case-insensitively; anything else matches the file name exactly.
  • external_resource — the name of a seafile_webdav resource 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 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 1
echo $(( $(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" }]
}

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.

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.

  • 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.
  1. Install the tool and its pipeline on the machine.
  2. Add one entry under desktop.tools with the six axes above. Pick the report_kind that matches what the pipeline actually writes; if it writes a JSON report with a pass field, json_pointer covers it without any code.
  3. Add the capability label to the runner so placement can route to it.
  4. Call tool.list with probe: true and confirm the binary resolves and the version satisfies your contract.
  5. Run it once and read the evidence.provenance block. If it says opaque, or the state is stale, fix the declaration before trusting a green.

There is no step involving SupaCloud’s source.