Skip to content

Running your own agent

An agent of your own is the spool binary running spool agent run on a machine you control: a server in your network, an arm64 box, a Mac, a pod. It dials out to Hyperspool, so it needs no inbound ports, and it takes the steps that ask for capabilities it advertises. Agents says when that is worth doing instead of using managed environments.

Getting one running takes two commands, one to mint a token and one to start the agent with it:

Terminal window
spool agent token create build-1 --scope acme/web --cap shell --cap build > ~/agent-token
spool agent run https://api.hyperspool.dev build-1 \
--cap shell --cap build --token-file - < ~/agent-token

An org owner issues an agent token, naming the agent id and the capabilities the token may advertise:

Terminal window
spool agent token create build-1 \
--name "build agent (hel1)" \
--scope acme/web \
--expires-in 90d \
--cap shell --cap polyglot --cap build

The command prints the token once; store it now. Agent tokens start hsat_ (csat_ on older ones, which still work).

The scope decides whose work the agent may claim, and so whose secrets it receives:

  • --scope <org/project> binds the token to that one project.
  • --scope <org> binds it to every project in that org.

Either needs the owner role in that org. See teams and projects.

The capabilities are a ceiling: an agent can advertise fewer at runtime but never more, so a leaked token can’t claim work you didn’t grant it. --expires-in puts a clock on it, and a token minted with a personal access token never outlives that token.

Agent ids are unique across Hyperspool, so an id another org already holds comes back as a conflict; pick another. Minting again for an id your scope already holds rotates its token. To pull a leaked token before it expires, find its id with spool agent token list and revoke it with spool agent token delete <token-id>.

The list says who minted each token. A token keeps working after the person who minted it leaves the org or has their account disabled, because the team’s runners are often using it. Look for tokens minted by people who have gone, and rotate them.

Running the same agent on several machines, or scaling a Kubernetes Deployment, doesn’t need a token each. Mint one for a pool:

Terminal window
spool agent token create --pool build \
--name "build agents" \
--scope acme/web \
--cap shell --cap polyglot --cap build

It authenticates as any agent id beginning build-, so each process still has an id of its own:

Terminal window
spool agent run https://api.hyperspool.dev build-$(hostname) --cap shell

On Kubernetes, name the pool after the Deployment and pass the pod name through:

env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
args: ["agent", "run", "https://api.hyperspool.dev", "$(POD_NAME)", "--cap", "shell"]

spool agent list shows the pool as one line with a replica count. One credential backs every replica, so --expires-in is what bounds a leak. A pool name and an agent id can’t overlap: minting the pool build is refused while an agent build-1 exists, and the other way round.

Terminal window
spool agent run https://api.hyperspool.dev build-1 \
--cap shell --cap polyglot --cap build \
--token-file - < ~/agent-token

The arguments are the server URL and the agent id. The id can come from $HYPERSPOOL_AGENT_ID instead, which suits a scheduler that varies the environment per replica but not the command line.

The token comes from one of two places:

  • --token-file - reads it from stdin, and --token-file <path> reads it from a file and then deletes the file, so a job started later can’t find it there.
  • Without the flag the agent reads $HYPERSPOOL_AGENT_TOKEN and removes it from its own environment at once. The variable stays visible wherever the environment is recorded (docker inspect, a pod spec), so prefer the file when you can.

Capabilities come from repeated --cap flags, else $HYPERSPOOL_AGENT_CAPABILITIES (comma-separated), else shell,polyglot. The token still caps whatever you pick.

The agent itself is one static binary. The languages your steps use (bash, python, node, go, …) must be on the host’s PATH.

Jobs run in a scrubbed environment. The agent keeps a small allowlist (USER, LOGNAME, SHELL, LANG, TERM, TMPDIR, the TZ and locale vars, and the TLS certificate paths SSL_CERT_FILE, SSL_CERT_DIR and NIX_SSL_CERT_FILE), then adds PATH, PYTHONPATH, NODE_PATH, the pipeline’s env: block and the run’s SPOOL_* vars. Anything else in the agent’s environment stays out, so a variable you forgot about is dropped rather than forwarded.

To give a job a variable of its own, set it in the pipeline’s env: block. The value reaches the job’s process as $NAME. Expressions can’t read it, since a job’s if: is decided before the job reaches an agent; a constant a condition needs belongs in the pipeline’s vars: block.

Each job gets an empty HOME of its own, deleted when the job ends, and XDG_CONFIG_HOME, XDG_CACHE_HOME, XDG_DATA_HOME and XDG_STATE_HOME point inside it. The job starts in that directory, so relative paths in a step resolve there, and caches under ~ start cold in every job. Choose what HOME jobs get shows how to change that.

Jobs run as the agent’s user unless you say otherwise, on a machine that also holds the agent’s token. The defaults keep the token out of their reach as far as a same-user process allows; the settings below go further. Use them on any agent that runs work other people can submit.

The agent hides itself from its jobs, so they can’t read its memory or the environment it started with through /proc. They can read any file the agent’s user can, though. Don’t point --token-file at a file the agent can’t delete, such as a Kubernetes Secret volume (read-only): the token stays there for every job to read, and $HYPERSPOOL_AGENT_TOKEN from the Secret is the better choice.

None of this holds against root, so an agent whose jobs run as root gives them its token.

On Linux, an agent running as root can start every job as another user:

Terminal window
useradd --create-home spool-job
spool agent run https://api.hyperspool.dev build-1 \
--cap shell --cap build --step-user spool-job \
--token-file - < ~/agent-token

$SPOOL_STEP_USER does the same, which suits a systemd unit: add Environment=SPOOL_STEP_USER=spool-job in a drop-in for spool-agent.service. The agent keeps the token as root, and each job process runs as spool-job with that user’s groups, so a job can’t read the agent’s files, memory or /proc entries. Files a job hands the agent by path (spool artifact put, spool checkout) are read and written with the job’s own access, and what the agent writes for a job belongs to it.

Give the step user only what jobs need: its own home, the groups for devices a build uses (kvm), subordinate ids if jobs run rootless containers, and no sudo. Anything that needs root, such as installing system packages or writing /etc, belongs in the machine’s setup rather than in a job.

The agent refuses to start when it isn’t root, when the user doesn’t exist or is root, and with SPOOL_JOB_HOME=agent (below), which would hand jobs root’s HOME.

Without a step user, a job runs as the agent’s user and can read that user’s files if it knows where they are. An agent that runs jobs other people can submit should then run as a user of its own, with nothing else in its home.

The empty HOME per job keeps spool, git and cloud CLIs in a job from picking up your stored token or credentials just because you started the agent from your own account. ssh finds keys through the user database rather than HOME, so this doesn’t hide ~/.ssh.

SPOOL_JOB_HOME on the agent changes it:

SPOOL_JOB_HOME Jobs get For
unset an empty HOME each an agent you run from your own account
agent the agent’s own HOME, and start there a machine or account that exists only to run jobs, whose HOME holds caches and tool config
a directory, e.g. /var/lib/spool-jobs that directory, shared warm caches, when every job comes from people you trust

The agent refuses agent while a spool config lives under its HOME, and a shared directory that is its own HOME or holds its spool config; either way it fails the job with the reason. With --step-user, a shared directory should belong to the step user, or jobs can’t write to it.

Every job can write to a shared or agent HOME, and the next job reads what it left: a .gitconfig, a .npmrc, a shell profile. Use either only on an agent whose jobs all come from people you trust.

To hand jobs a variable from the agent’s own environment, set SPOOL_ENV_PASSTHROUGH on the agent to a comma-separated list of names or globs (MYAPP_*,REGION). Matching variables reach every job that agent runs; the agent token is never passed through.

Every job on the agent sees what you pass through, so keep secrets out of it and use project secrets or credentials instead.

An agent of yours takes work from untrusted events too: a fork’s pull request, a comment from someone outside the repo. That work gets none of the project’s secrets, and a step that declares a credential is refused, but its code runs on your machine as the agent’s user (or the step user). Keep a token whose scope receives such events on a machine where that is acceptable, and use a step user and an empty HOME there.

Terminal window
curl -fsSL https://hyperspool.com/install.sh | sh -s -- \
--service \
--server https://api.hyperspool.dev \
--id build-1 \
--token-file /root/agent-token \
--caps shell,polyglot,build

--token-file keeps the token off the command line, where ps and cloud-init logs would show it; --token hsat_... also works. Without --service the script only installs the spool binary and prints the spool agent run command.

The unit is spool-agent.service. The token is in /etc/spool-agent.token (mode 0600), which systemd hands the agent on stdin, and the capabilities are in /etc/spool-agent.env:

Terminal window
systemctl status spool-agent
journalctl -u spool-agent -f

It runs as a throwaway DynamicUser. An agent whose steps install their own toolchain (Nix, devenv) needs a persistent home, so pass --user root or a real account. With --user root its jobs run as root and can read the token file, so keep such an agent to work you trust, or give it a step user.

--service needs systemd, so on macOS the script prints the spool agent run command and exits with an error. Supervise the agent yourself, for example with a launchd job, and set HYPERSPOOL_AGENT_UPDATE_URL if you want it to update itself.

There is no published agent image. The agent is a single static binary, so the Dockerfile is short:

FROM docker.io/library/debian:stable-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates bash git python3 \
&& rm -rf /var/lib/apt/lists/*
ADD https://get.hyperspool.dev/linux/spool-amd64 /usr/local/bin/spool
RUN chmod +x /usr/local/bin/spool
ENTRYPOINT ["spool", "agent", "run"]
Terminal window
docker run -d --restart unless-stopped \
-e HYPERSPOOL_AGENT_TOKEN=hsat_... \
my-registry/spool-agent:2026-09-12 \
https://api.hyperspool.dev build-1 --cap shell --cap polyglot

Put the interpreters your steps use in the image, and keep --cap to what it can run. polyglot covers every script language, so the image above runs a python script and fails a node one.

Pass the token at run time, never in an image layer. The agent hides its environment from its jobs, but anyone who can run docker inspect on the host sees the variable. In a container, leave HYPERSPOOL_AGENT_UPDATE_URL unset: a restart re-runs the same image, so roll the image tag instead.

The systemd service fetches the published binary on every start and checks it against a signed .sha256 manifest before launching. A running agent with HYPERSPOOL_AGENT_UPDATE_URL set (the installer sets it) polls that manifest every 5 minutes. When a newer build ships it finishes its current step and exits, systemd restarts it, and the pre-start fetch installs the new binary. An unreachable download host, a bad signature or a checksum mismatch keeps the current binary, so a restart always succeeds.

Under another supervisor, set HYPERSPOOL_AGENT_UPDATE_URL and make sure something restarts the agent after it exits: exiting is the update.

spool agent list lists your agents: id, status, capabilities, scope, build id and when each was last seen. Managed environments show there too while they run, and drop off a day after they finish (--include-finished keeps them). A build id marked outdated differs from the agent seen most recently, which during a rollout shows who hasn’t picked up the new binary yet.

An agent that stops cleanly leaves the list at once. One that dies without a word drops off after a day of silence; a running agent reports at least every 25 seconds, so idling never costs it its entry. An agent id with no last-seen time has a token but nothing running under it.

Stop your agent the way you stop any process: systemctl stop spool-agent, docker stop, Ctrl-C. On SIGTERM it gives the step in flight about ten seconds to finish, then kills it and exits, and that step goes back to the queue.

An agent that dies mid-step loses the step once its lease lapses, and another capable agent (or a managed environment) runs it again from the start; When an agent dies has the timings. Completed spool step calls inside a script return their recorded output instead of running twice.

A step on an agent of yours has a one-hour ceiling unless the job sets timeout:, up to 24 hours. Past it, the step’s processes are killed and the step fails. The agents inside spool run and spool dev have no ceiling.

spool agent dispatch is what starts managed environments, and you can run one for your org. It watches for work none of your agents can pick up and runs a launch command you supply to bring up an agent for it:

Terminal window
spool agent token create dispatcher-1 --scope acme --cap dispatch
export HYPERSPOOL_AGENT_TOKEN=hsat_...
spool agent dispatch https://api.hyperspool.dev \
--provides shell --provides polyglot -- ./launch.sh

The launch command gets a JSON document on stdin (agent id, token, scope, capabilities, expiry) and must start an agent with that id and token, exiting non-zero if it couldn’t. An org-scoped dispatch token serves only that org. --provides names what your environments can run, once per capability.