Canopy Runner
The Canopy Runner is a small daemon you run on infrastructure you control. It enrolls with Canopy’s control plane, then long-polls for sandbox runs and builds assigned to your team, executes them locally, and reports the result back.
Outbound-only, by design. The runner never accepts an inbound connection - there is no listening socket anywhere in the runner process. Canopy never gains network access into your infrastructure; every interaction is the runner calling out to Canopy on its own schedule.
Your private key never leaves the runner. On first start it generates its own Ed25519 keypair locally. Only the public key is ever sent to Canopy (as part of enrollment). Every request after that - heartbeat, poll, report - is signed locally with the private key; Canopy verifies the signature and never sees or stores anything that could reconstruct it.
Status: gated behind the host.runners platform feature
(lifecycle: experimental, internal visibility) and the deeper
RUNNERS_ENABLED kill switch. Both protocol and enrollment (Part A) and
real builds + BYO registry + degraded-mode visibility (Part B) are real
and shipped - this isn’t a roadmap description.
Which team is this for
A team is either Canopy-operated (the default - every sandbox run
and build executes on Canopy’s own infrastructure) or
customer-operated (assigned a region whose operator is
customer). Only a customer-operated team’s work ever gets routed to a
runner; a Canopy-operated team is unaffected regardless of whether it
has runners enrolled.
Enrolling a runner
canopy runners enroll --team <team_id>Issues a one-shot enrollment token (single-use, 15-minute lifetime). Pass it to the runner container’s own enroll step - the token is consumed on first use and never shown again.
canopy runners list --team <team_id>canopy runners status <runner_id> --team <team_id>canopy runners revoke <runner_id> --team <team_id>Revoking a runner rejects its next poll/heartbeat and releases any run or build it was holding back to the queue.
What a runner executes
Sandbox runs - purpose: 'agent_task'-shaped work (the same kind of
run Canopy’s own sandbox executes for a Canopy-operated team), isolated
the same way: read-only root filesystem, non-root user, dropped
capabilities, resource limits, no network by default.
Builds - Dockerfile or buildpack, matching your environment’s own
build_type setting (auto/dockerfile/buildpack, same setting
Canopy-operated builds use). For auto, the runner clones first, then
checks for a Dockerfile at your build path itself - the control plane
never has its own checkout to inspect, so that call can only be made
runner-side. A buildpack build shells out to the same pinned,
admin-curated pack build CLI/builder images (heroku/builder:24 or
Paketo) the Canopy-operated pipeline uses. Either way, the runner then
publishes the image to your own registry (see below), and optionally
deploys it onto a Dokku instance the runner itself manages locally.
Canopy never talks to that Dokku host directly - only to the runner.
Domain/TLS for the deployed app is your own responsibility; the runner
reports back whatever address the deploy ends up bound to, and Canopy
displays it verbatim.
Bring your own registry
canopy registries add --team <team_id> --host <registry_host> [--namespace <ns>] [--default]canopy registries list --team <team_id>canopy registries remove <registry_id> --team <team_id>A team with no registry configured has nothing change - Canopy’s own local registry stays the default for every existing build. Configuring one is what makes a runner-executed build’s published image and a customer-operated deploy actually work end to end.
Degraded-mode visibility
A customer-operated team with no enrolled runner (or one that’s gone
silent past its heartbeat threshold) while work is waiting shows up as
degraded in the dashboard’s Runners page, with a summary of how much
work is stuck. Canopy also emits a runner.degraded notification the
first time a team transitions into that state (debounced - you won’t get
paged every few minutes for the same outage).
Diagnosing a runner fleet
canopy runners diagnose --team <team_id> [--json]canopy runners diagnose --team <team_id> --liveA redacted diagnostic bundle: roster and heartbeat recency, live/expired
lease counts, recent runner-related events, divergence-strike counts,
and configuration presence booleans (never values - it tells you
whether RUNNERS_ENABLED is set, never what it’s set to). Never
includes a runner’s public key or an enrollment token.
--live reaches further, as far as the trust model allows: Canopy has
no inbound path to a customer-operated runner, so it can’t fetch
anything from it directly. Instead --live asks each enrolled runner to
attach a small self-diagnostics object (Docker/Dokku CLI versions,
workspace disk headroom) to its next heartbeat, then waits briefly for
one to report back before rendering the bundle.
Billing and divergence enforcement
Runner-executed time is metered the same way Canopy-operated time is. A runner may report its own measured duration back with a run/build’s result, but that self-report is recorded for reconciliation only - control-plane lease timings (when a run was claimed, when it was reported) are what’s actually billed, unconditionally. That never changes based on anything below.
A divergence beyond a tolerance threshold between a runner’s self-report
and the control-plane-measured elapsed time is recorded as a
runner.duration_divergence event and accumulates a strike against that
runner. Once a runner crosses RUNNER_DURATION_DIVERGENCE_STRIKE_THRESHOLD
strikes, it’s refused new work - its next poll/build-poll comes back
empty, the same way a non-enrolled runner’s would, until a team owner
clears it:
canopy runners clear-divergence-flag <runner_id> --team <team_id>This is a trust/reliability gate, not a pricing mechanism - a runner that repeatedly can’t account for its own elapsed time is worth a human look, independent of anything it’s ever billed for.
The unified timeline
A runner-executed build is still a Canopy build - it appears in
the unified timeline
alongside Canopy-operated builds and any Connect-ingested external CI
runs, with a “via <runner name>” chip showing which runner executed
it.
REST API
| Method & path | Purpose | Auth |
|---|---|---|
POST /runners/enroll | Enroll using a one-shot token | enrollment token |
POST /runners/:id/heartbeat | Heartbeat | runner signature |
POST /runners/:id/poll | Long-poll for a sandbox run | runner signature |
POST /runners/:id/report | Report a sandbox run’s outcome | runner signature |
POST /runners/:id/build-poll | Long-poll for a build | runner signature |
POST /runners/:id/build-logs | Stream build output | runner signature |
POST /runners/:id/build-report | Report a build’s outcome | runner signature |
GET /teams/:id/runners | List a team’s runners + health | runner:read |
POST /teams/:id/runners/enroll-tokens | Issue an enrollment token | runner:manage |
DELETE /teams/:id/runners/:id | Revoke a runner | runner:manage |
GET /teams/:id/runners/diagnose | Diagnostic bundle | runner:read |
POST /teams/:id/runners/:id/clear-divergence-flag | Reset a flagged runner’s strikes | runner:manage |
POST /teams/:id/runners/:id/request-diagnostics | Ask a runner to self-report on its next heartbeat | runner:manage |
GET /teams/:id/registries | List a team’s BYO registries | runner:read |
POST /teams/:id/registries | Add a BYO registry | runner:manage |
DELETE /teams/:id/registries/:id | Remove a BYO registry | runner:manage |
The /runners/* (no :team_id prefix) endpoints are machine-to-machine
- called by the runner itself, authenticated by its own Ed25519 signature, never by a human session token.