Skip to content

Deploying agents

An agent is a long-running process that dials out to the server, claims activities matching its capabilities, runs them, and reports back. It never accepts inbound connections, so it runs behind a firewall or inside a private network as long as it can reach the server over HTTPS.

This guide covers minting a token, running an agent by hand, installing it as a systemd service, self-update, and what happens when an agent dies.

An org owner issues an agent token, naming the agent id and the capabilities the token is allowed to advertise:

Terminal window
cryo agents issue build-runner-1 \
--name "build runner (hel1)" \
--project acme/web \
--expires-in 90d \
--cap shell --cap polyglot --cap build

The command prints the token plaintext once. Store it now; it isn’t recoverable later. Agent tokens are prefixed csat_.

An agent token has two independent bounds: capability and scope.

The token’s capabilities are a ceiling. An agent can advertise a subset of them at runtime but can’t widen past them, so a leaked token can’t claim work the operator didn’t grant.

Scope decides whose work the agent may claim, and therefore whose secrets it receives:

  • --project <org/project> binds the token to that one project. A bare --project <name> with no org is rejected as ambiguous.
  • --org <slug> binds it to every project in that org: the runner claims any of the org’s runs and receives each run’s project secrets.
  • Omitting both mints a deployment-wide token. That agent can claim work and read secrets in every org and project on the instance.

Who may mint follows the scope. Either scoped form needs the owner role in the org named. Minting unscoped is a deployment operator’s call, since the credential reaches every tenant on the instance. An operator who needs a scoped token for an org they hold no membership in passes --admin, which mints through the instance route and skips the org role check.

Operators are the addresses in CRYOSLEEP_INSTANCE_ADMINS, a comma-separated list on the server. Set it: while it is empty, that check falls back to accepting any org’s owner, which on a deployment with more than one org lets one tenant mint a credential that reads the others’ secrets.

The unscoped form is the shared-fleet choice, worth making on purpose rather than by typing less. --expires-in <dur> puts a clock on the credential either way. See teams and projects for how org and project scope relate.

The agent id is claimed instance-wide, so a scoped mint naming an id another org already holds comes back as a conflict - pick another rather than reusing one. Re-minting an id your own scope holds is how you rotate its token.

Revoking a token (cryo agents revoke <token-id>) is a deployment operator’s action today, so ask one to pull a leaked credential. --expires-in keeps that window short in the meantime.

Terminal window
export CRYOSLEEP_AGENT_TOKEN=csat_...
cryo agent run https://cryo.example.com build-runner-1 \
--cap shell --cap polyglot --cap build

The two positional arguments are the server URL and the agent id. The token comes from $CRYOSLEEP_AGENT_TOKEN (the agent scrubs it from its own environment right after reading it, so jobs it runs never inherit the server credential).

Capabilities resolve in this order: repeated --cap flags win; otherwise $CRYOSLEEP_AGENT_CAPABILITIES (comma-separated); otherwise the default shell,polyglot. Whatever you pick is still capped by the token.

The languages a job uses (bash, python, node, go, …) must be present on the agent host’s PATH. The agent binary itself is a single static executable with no runtime dependencies.

Jobs run in a scrubbed environment. The agent clears its own env and re-adds only a small allowlist (HOME, USER, LANG, TERM, the TZ/locale vars, and the TLS cert paths), then layers on PATH, the pipeline’s env: block, and the run’s CRYO_* vars. Everything else the agent process carries stays out of the job, so an operator var or an injected credential can’t leak into workflow code. This is deliberately fail-closed: a var you forgot about is dropped, not forwarded.

Two ways to widen it when a job needs more:

  • Per workflow: set it in the pipeline’s env: block. That flows through to the job as-is.
  • Per agent: set CRYO_ENV_PASSTHROUGH on the agent to a comma-separated list of names or globs - MYAPP_*,REGION - and any matching var in the agent’s environment is forwarded to every job it runs. Use this when a runner image or host sets env you want all its jobs to see. The bearer token is scrubbed from the agent’s env at startup, so it is never forwardable, even by *.

Either way the value reaches the job’s process, read as $NAME. It is not visible to a workflow’s expressions: a job’s if: is evaluated before the job reaches an agent, and two agents can hold different values for the same name, so a gate reading one could not decide the same way twice. A constant a gate needs belongs in the pipeline’s vars: block instead.

The agent also forwards PYTHONPATH and NODE_PATH by default (they’re search paths, not secrets), so a runner image that vendors the language SDKs at those paths makes them importable without any extra config.

The install script fetches the binary and, with --service, registers a systemd unit:

Terminal window
curl -fsSL https://cryosleep.io/install.sh | sh -s -- \
--service \
--server https://api.cryosleep.io \
--id build-runner-1 \
--token csat_... \
--caps shell,polyglot,build

Without --service it installs the cryo binary and prints the manual cryo agent run command. --service needs systemd (Linux); on other platforms it prints the manual command instead.

The unit runs as a throwaway DynamicUser by default. A build runner that installs its own toolchain in-flow (nix, devenv) needs a persistent, privileged home, so pass --user root (or a real account) to pin a fixed user.

The service unit fetches the published binary on every start and verifies it against a .sha256 manifest before launching.

A running agent polls that manifest every 5 minutes only when CRYOSLEEP_AGENT_UPDATE_URL points at it. When a newer build ships, the agent drains (finishes the in-flight job) and exits; whatever supervises it restarts it, the pre-start fetch pulls the new binary, and the agent comes back on the new build. An unreachable download host or a checksum mismatch keeps the current binary, so a restart always succeeds.

install.sh --service sets CRYOSLEEP_AGENT_UPDATE_URL for you. An agent you run by hand, or under your own supervisor (nomad, docker, a hand-written unit), has to set it as well as be supervised by something that restarts and re-fetches on exit: exiting is the update, so with nothing to restart it the drain just stops the agent.

Manifests are signed (minisign) and both consumers check the signature against a public key compiled into cryo before acting: the poll won’t drain and the pre-start fetch won’t swap the binary on a missing or invalid signature, so a compromised download host can’t push code onto the fleet. The pre-start check runs through the already-installed binary (cryo agent verify-manifest <manifest> <sig>), which also rejects a manifest published for a different os/arch.

Ask an agent to drain and exit cleanly:

Terminal window
cryo agents shutdown build-runner-1

cryo agents ls shows the registry: each agent’s id, status, capabilities, last build id, and last-seen time.

When an agent dies without draining, its in-flight activity holds a 30-second lease. Once the lease expires, the activity returns to the queue and another capable agent claims it; the activity re-runs from the start. A durable cryo step cache makes that replay fast because completed steps return their recorded output instead of re-running.

An agent started with cryo agent run also enforces a per-activity timeout of one hour. A job that wedges past the ceiling is abandoned so it can’t hold the agent’s claim slot forever. The in-process agents that back cryo run and cryo dev don’t apply this timeout - they’re interactive, and Ctrl-C is right there.