Skip to content

Runner

Copy page

@plandesk/runner is the machine-side agent. It polls a Plan Desk board, claims one actionable task, briefs a headless coding-agent CLI in an isolated git worktree, runs the task’s own gate command, and writes the outcome back.

The runner is not the agent. Every job it does exists because it is not the agent: it decides what to work on, isolates where the work happens, bounds how long it may run, and judges the result from an exit code rather than from the worker’s opinion of its own output.

Terminal window
npm i -g @plandesk/runner

This provides the plandesk-runner binary. The package is versioned independently of @plandesk/cli — 0.1.0 means one proven worker family, one slot, no concurrency.

Config lives at ~/.plandesk/runner.toml, or wherever PLANDESK_RUNNER_CONFIG points, or --config <path>.

board_url = "http://127.0.0.1:7526"
agent_key = "" # see "Credentials" below
name = "local-dev"
workdir = "/Users/you/.plandesk/work"
workers = ["pi"] # [] means "every worker the repo declares"
default_worker = "pi"

Only board_url and agent_key are required. Everything else has a default — plandesk-runner doctor prints the resolved config with the key redacted.

agent_key is required as a field, and its empty value carries meaning:

valuebehaviour
agent_key = "sk-…"sent as Authorization: Bearer sk-…
agent_key = ""no Authorization header at all — the board resolves the caller as org owner over loopback
field absentconfig error — an omitted credential is a mistake, an empty one is a decision

An empty key is the correct setting for a local board. A local board cannot mint an agent key: owner keys come only from the plandesk login device flow, and plandesk connect locally mints no token by design. Sending an invalid bearer is worse than sending none, because the board rejects any bearer that is not a real key rather than falling through to the loopback path.

Use a real key against a hosted board.

Which worker runs is a three-way intersection, and none of the three is sufficient alone:

  1. The repository declares it — a .md file under .agents/factory/workers/ carrying a headless: key. A worker file without that key stays valid for interactive use and is skipped here.
  2. The machine enables it — workers in runner.toml. Empty means “accept everything the repo declared”.
  3. A live probe passes — the worker’s probe command exits 0. A binary on PATH may still be unauthenticated.

A failing probe removes only that worker; the others still resolve.

The declarations are read from the worktree, not from wherever the runner was launched. A repository the runner is asked to work in must ship its own .agents/factory/workers/, or there are no usable workers for it.

Terminal window
plandesk-runner doctor # config, board reachability, auth mode, worker rows
plandesk-runner --once --project <id> # claim at most one task, settle it, exit
plandesk-runner --project <id> # poll forever

doctor probes the board twice: once unauthenticated for reachability, once authenticated. Health alone is not enough — it answers without a credential, so a runner whose key the board rejects would otherwise report a healthy board and then fail every real call.

board http://127.0.0.1:7526: reachable (HTTP 200) — auth loopback: accepted (HTTP 200)
board https://board.example.com: reachable (HTTP 200) — auth bearer: REJECTED (HTTP 401) — every board call will fail

A task states its own validation command in its description. The runner looks for either form:

```gate
pnpm --filter @plandesk/runner test
```
gate: pnpm --filter @plandesk/runner test

The gate is one command, because it is exec’d as argv rather than through a shell. A task with no gate resolves failed — the runner never assumes success for work it cannot check.

Outcome is decided in this order, and nothing else participates:

conditionoutcome
.plandesk/NEEDS_INPUT.md exists in the worktreeneeds_input → task to scope
worker exit ≠ 0failed (the gate is not run) → task to todo
gate exit 0done
gate exit ≠ 0failed → task to todo

On done, the lane decides what happens next: auto closes the task, while approve and full leave it in_progress with a progress event saying it awaits a human.

Each attempt gets its own git worktree under <workdir>/worktrees/<taskId>, branched from a full commit OID resolved from the remote’s default branch.

Cleanup fails closed. A worktree is removed only when the tree is provably clean and the branch is provably pushed. Dirty, failed, parked, or unprovable worktrees are retained with a reason, because a wrong git worktree remove destroys work no one has seen. Ignored-only content such as node_modules does not block removal, and is listed in the decision.

Workers are spawned into their own process group with a constructed environment — PATH HOME USER LANG TERM TMPDIR and nothing else — so the board credential never reaches a worker.

On startup the runner reconciles orphans: a task left in_progress by a crashed run is returned to todo, and its worktree is retained for inspection. Reconcile never deletes anything.

The runner is the headless form of the factory cycle: pull → brief → execute → prove → settle, with the gate command as the proof and the lane as the human checkpoint. A supervising agent in an interactive session follows the same contract by hand; the runner is that contract as a daemon.