Skip to content

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> --live

A 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 & pathPurposeAuth
POST /runners/enrollEnroll using a one-shot tokenenrollment token
POST /runners/:id/heartbeatHeartbeatrunner signature
POST /runners/:id/pollLong-poll for a sandbox runrunner signature
POST /runners/:id/reportReport a sandbox run’s outcomerunner signature
POST /runners/:id/build-pollLong-poll for a buildrunner signature
POST /runners/:id/build-logsStream build outputrunner signature
POST /runners/:id/build-reportReport a build’s outcomerunner signature
GET /teams/:id/runnersList a team’s runners + healthrunner:read
POST /teams/:id/runners/enroll-tokensIssue an enrollment tokenrunner:manage
DELETE /teams/:id/runners/:idRevoke a runnerrunner:manage
GET /teams/:id/runners/diagnoseDiagnostic bundlerunner:read
POST /teams/:id/runners/:id/clear-divergence-flagReset a flagged runner’s strikesrunner:manage
POST /teams/:id/runners/:id/request-diagnosticsAsk a runner to self-report on its next heartbeatrunner:manage
GET /teams/:id/registriesList a team’s BYO registriesrunner:read
POST /teams/:id/registriesAdd a BYO registryrunner:manage
DELETE /teams/:id/registries/:idRemove a BYO registryrunner: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.