Docs · Runners
Self-hosted runners
A Reactor remote agent runs on a vm made for each run unless you give it a machine of your own. A runner is that machine: a server or a Mac your company keeps on, which takes the agent's runs in seconds and runs each in a container of its own. This page is everything about runners, from the idea to removing one.
The idea
The general idea
By default every run gets a fresh vm, which Selan makes for it and throws away after. That needs nothing from you, and nothing is left between runs. A runner is the other choice: a machine you keep on, that asks Reactor for work and starts a run in seconds instead of waiting for a vm to boot.
Use a runner when you want one of these:
- Speed. A run starts as soon as the runner's next poll picks it up.
- A machine you chose. Your own hardware and your own network, or a Mac, so an agent can build with Xcode.
Keep the vm per run for everything else. It is the safer default: nothing a run does outlives it, and no machine of yours holds an agent's secrets.
Adding a runner takes four steps:
- An owner adds it in Reactor, under Runners (add a runner).
- Reactor shows an install line, which contains the runner's token. The token is shown once, so copy the line then.
- Run the line on the machine: the Linux tab's on an Ubuntu server, the Mac tab's on an Apple Silicon Mac.
- Once the runner shows as online, set an agent's Runs on to it.
What a runner is
A runner is a machine your company keeps on, such as a VPS or a Mac mini.
A small program on it, the selan-runner container, polls Reactor over
HTTPS. Reactor answers with the runs to start and the runs to stop. The runner only
ever calls out, so the machine needs no inbound port.
Each run gets its own container, built from the same agent image a vm
runs, with its own Docker daemon inside. Runs use the
Sysbox
runtime and are never --privileged. The machine holds
several runs at once, plus the runner's own token, and outlives every run. Root in one
container would mean every concurrent run's credentials, so no run gets it.
| How it behaves | |
|---|---|
| Which agents | An agent is pinned to one runner, or runs on a vm made per run. That is the agent's Runs on field in Reactor, which appears once your company has a runner and offers Selan Isolated VM beside each runner's name. Over Selan MCP it is the agent's runnerId, with null for the vm. |
| Slots | How many runs it takes at once, from 1 to 16. A new runner starts at 2. Reactor holds the number, and the runner takes a new value at its next poll. |
| Offline or full | A runner is online while it has polled in the last 60 seconds. A run sent to a runner that is offline or full ends at once as unprovisioned, saying which. A run that no poll picks up within 60 seconds ends the same way. It does not wait, and it does not fall back to a vm. |
| Restarts | Runs survive a restart of the runner. A run is a container of its own, labelled with its run and deadline, and a restarted or updated runner reads them back from Docker and carries on. Only a runner that stops polling for 2 minutes has its runs closed as lost. |
| Deadlines | The runner stops a run that passes its deadline, as a vm would be stopped. |
| Secrets | Every run sent to a runner is handed all of its agent's secrets, which then sit on a machine you run. That is why only an owner adds, changes or removes a runner. |
Setting one up
Add a runner in Reactor
In Reactor, open Runners.
Every member can see the company's runners there: each one's state
(online · 1 of 2 busy, offline · last seen … or
never connected) and the version it runs. Only an owner adds
one.
Type a name under Add a runner, of 1 to 40 characters and not one another of your runners has, then click Add runner. Reactor answers with three tabs, each one line to copy and paste on the machine:
| Tab | For |
|---|---|
| Linux | An Ubuntu 24.04 server. |
| Mac | An Apple Silicon Mac. |
| Mac privileged | The Mac line with --mac-host, for Xcode builds on the Mac. |
ps.
Rename it and set its slots later by clicking its row: the dialog has
Name and Runs at once, and below them a chart of its
newest runs. An owner can set the same two over
Selan MCP with
selan_remote_runner_update, which replaces both at once, so send the
current name to change only the slots. selan_remote_runners_list shows
every runner's id, name, slots,
busy, online, lastSeenAt and
version. An owner can also add one with
selan_remote_runner_create, which answers the same three lines. No Selan
MCP tool removes a runner: do that in Reactor.
RUNNER_SLOTS in the line is what the install script needs to start. After
that, Reactor's value is the one that counts.
Install on Linux
Ubuntu 24.04, on amd64 or arm64. Paste the Linux
tab's line on the machine as a user who can sudo. It installs what it
needs and connects on its own.
export SELAN_RUNNER_TOKEN=selanrunner_…; curl -fsSL https://reactor.selan.ai/runners/install.sh | sudo --preserve-env=SELAN_RUNNER_TOKEN REACTOR_URL=https://reactor.selan.ai RUNNER_SLOTS=2 bash
| It installs | |
|---|---|
| Docker | Ubuntu's docker.io, with curl, python3 and cron, when Docker is missing. A machine that is not Ubuntu needs Docker installed first, and the rest of the script is for Ubuntu only. |
| Sysbox | Sysbox 0.7.1, checked against the checksum its release publishes for that architecture. Its installer restarts Docker, so the script stops and asks you to stop any running containers first. It does not finish without the sysbox-runc runtime. |
| The runner | The selan-runner container, started --restart=always with the Docker socket mounted so it can start runs beside itself. It also pulls the agent image, so the first run does not wait minutes for it. |
| A daily update | A root crontab line runs /usr/local/sbin/selan-runner-install at 04:17, logging to /var/log/selan-runner-install.log. Each update installs the script that came with the newest runner, and replaces the container only when its image or its settings changed. Runs going at the time carry on. |
Its settings are in /etc/selan-runner.env, readable by
root alone: REACTOR_URL, SELAN_RUNNER_TOKEN and
RUNNER_SLOTS. Every later run of the script reads them from there, so
running it again by hand needs no token:
sudo /usr/local/sbin/selan-runner-install
It prints selan-runner is current or selan-runner started.
The runner's own log is docker logs selan-runner, one JSON line per event.
Install on a Mac
An Apple Silicon Mac runs the runner inside an Ubuntu VM. macOS
cannot be the runner's Docker host: a run gets the Sysbox runtime, never
--privileged, and Docker Desktop's VM cannot take Sysbox. So the Mac line
makes a Lima VM and
runs the Linux install inside it.
Paste the Mac tab's line in Terminal as the user who logs in to the
Mac, not with sudo:
export SELAN_RUNNER_TOKEN=selanrunner_…; curl -fsSL https://reactor.selan.ai/runners/install-mac.sh | REACTOR_URL=https://reactor.selan.ai RUNNER_SLOTS=2 bash
To size the VM, end the line with bash -s -- and the flags, such as
bash -s -- --cpus 6 --memory 16:
| Flag | What it sets |
|---|---|
--cpus N | The VM's CPUs. Default 4, and fewer than the Mac has. |
--memory GiB | The VM's memory. Default 8, and less than the Mac has. |
--disk GiB | The VM's disk. Default 100, and set only when the VM is made. |
-
The VM is
selan-runner: Ubuntu 24.04 on Virtualization.framework (vz, arm64), made with no mounts, so a run cannot reach the Mac's home directory, and with no port forwards. - It starts at login. The VM is registered to start when that user logs in, so a Mac that restarts to the login screen has no runner until somebody logs in.
- It needs Lima 2 or newer, and installs it with Homebrew when it is missing.
-
Running the Mac line again, without the token, updates the runner.
It reuses the VM and re-runs the update inside it, which reads the VM's own
/etc/selan-runner.env. A--cpusor--memorythat differs stops the VM to resize it, and the runs going in it end aslost.
curl -fsSL https://reactor.selan.ai/runners/install-mac.sh | REACTOR_URL=https://reactor.selan.ai RUNNER_SLOTS=2 bash
Xcode builds on the Mac
A run in the VM cannot run Xcode, which needs macOS. An owner who
wants agents to build Apple apps can opt the Mac in with the Mac
privileged tab's line, which is the Mac line with --mac-host. It
asks for the Mac's password. On a runner already installed, leave out the token.
export SELAN_RUNNER_TOKEN=selanrunner_…; curl -fsSL https://reactor.selan.ai/runners/install-mac.sh | REACTOR_URL=https://reactor.selan.ai RUNNER_SLOTS=2 bash -s -- --mac-host
Other flags go after it, such as bash -s -- --mac-host --cpus 6 --memory 16.
It sets up five things:
-
A standard macOS user,
selan-build, which is where builds run. -
Other users' homes closed to it. macOS creates a home readable to
the
staffgroup, which every user is in, so--mac-hostsets the other homes, the owner's included, to700and checks thatselan-buildcannot list them. -
SSH limited to that user, and to connections from this Mac. The
runner's VM reaches the Mac at
192.168.5.2. - A key served only to containers inside the runner VM, so nothing outside the VM can fetch it.
-
A
macrunhelper, so a run can call the Mac:macrun ssh mac "xcodebuild …"to run a command there, andmacrun rsyncto copy a tree to the Mac and results back.
macrun. The helper fetches its key from a key server that only
containers inside the VM can reach, then connects over SSH to the Mac at
192.168.5.2 as selan-build. The build runs there with
xcodebuild and its output comes back to the run. Each run is isolated in
the VM; on the Mac every run is the same selan-build, sharing its files and
able to read whatever is world-readable. Other users' homes, the owner's included, are
closed to it.
selan-build, and on the Mac the runs are not kept apart. That user
is not an admin and has no sudo, but anything selan-build can
do, every agent pinned to this runner can do.
Where isolation ends
- In the VM, each run is isolated: its own Sysbox container and Docker daemon, in a VM with no mounts from the Mac.
-
On the Mac, it is not. Every run on a
--mac-hostrunner acts as the sameselan-builduser, so runs share its home,~/work, the simulators and DerivedData, and can see each other's files. -
Whatever is world-readable on the Mac is readable to them, such as
/Applications,/opt/homebrew,/Users/Sharedand/tmp. -
The owner's home is closed, along with every other user's: set to
700, as above.
So use a Mac kept for this, or one whose owner account holds nothing sensitive, and
pin only agents you trust to it. A runner installed without --mac-host
stays isolated from macOS, as described above. There is no macOS
isolation per run today; a macOS VM per run is the direction it may take.
Simulator platforms
Xcode must be installed on the Mac. A build for a simulator also needs that platform, which you install once on the Mac. Each is 5 to 10 GB:
xcodebuild -downloadPlatform iOS
Turning it off
Running the Mac line again without --mac-host keeps it on.
--no-mac-host removes it. After that, a run on the runner can no longer
reach the Mac.
curl -fsSL https://reactor.selan.ai/runners/install-mac.sh | REACTOR_URL=https://reactor.selan.ai bash -s -- --no-mac-host
An Apple build agent
An agent pinned to this runner, with a system prompt along these lines. Replace the repository and the scheme with your own:
You build and test the iOS app in the repository named in your task, on this
company's Mac. You run on the runner "mac-mini", and macrun lets you run
commands on that Mac as the user selan-build.
Setup, every run:
1. Fetch the macrun helper, then run: macrun ssh mac "xcodebuild -version"
If either fails, say so in your final message and stop. Nothing below
works without it.
Build:
2. Clone the repository in your container and make the change you were asked for.
3. Copy the tree to the Mac with macrun rsync, into ~/work/<run>, where
<run> is the run id the first line of your instructions names. Never
build anywhere else.
4. Build for the simulator, unsigned:
macrun ssh mac "cd ~/work/<run> && xcodebuild -scheme <scheme>
-destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build"
5. On a failure, read the log, fix the cause and build again, at most 3 times.
Clean up, always, even after a failure:
6. macrun ssh mac "rm -rf ~/work/<run>"
End with what you changed, whether the build passed, and the last error if not.
The remote-agents skill helps you write and review a prompt like this one.
Remove a runner
Delete it in Reactor first. Open the runner and click Delete, as an owner. Delete stays off while an agent runs on it, until you move that agent to another runner or a Selan Isolated VM, and while a run is still going on it. Once it is deleted, Reactor refuses its token. A runner whose token is refused stops every run it still holds and takes no more work.
Then remove it from the machine. On a Mac,
turn off --mac-host first if you turned it on,
then delete the VM:
limactl stop selan-runner
limactl delete selan-runner
On Linux, remove the container and its daily update. Without the cron line, the update would keep trying to install it again:
sudo docker rm -f selan-runner
sudo crontab -l | grep -v selan-runner-install | sudo crontab -
sudo rm -f /etc/selan-runner.env /usr/local/sbin/selan-runner-install
Docker and Sysbox stay installed, along with the images. Remove those as you would any other package.
Around it
The remote-agents skill
A Claude Code plugin for designing and reviewing remote agents: an agent's prompt, its schedule or event trigger, handoffs between agents, workspace rules, the supervisor's mission and the questions agents ask people. Run these two lines inside Claude Code:
/plugin marketplace add selan-ai/selan-remote-agents
/plugin install selan-remote-agents@selan-remote-agents
Then ask for what you need, such as "design an agent that watches our prod
logs" or "audit my remote agents", or run
/selan-remote-agents:remote-agents. Auditing reads your agents through
Selan MCP, so connect that first. An audit changes nothing.
The source is public at
github.com/selan-ai/selan-remote-agents.
Where it helps with runners: the skill is about agents, not machines, so it does not install or configure a runner. What it does help with is the agent that runs there. It can write the Apple build agent's prompt so that it works with nobody watching: setup checks that stop early, a working directory per run, cleanup, and one honest final line. It can also give that agent a trigger, and audit which agents you have and what each one is for before you pin any to a runner.
More
-
Selan MCP:
selan_remote_runners_list,selan_remote_runner_create,selan_remote_runner_update, and therunnerIdthatselan_remote_agent_createandselan_remote_agent_updatetake, beside every other Reactor tool. -
Agents: running an agent in your own CI with
selan agent-runrather than in Reactor. - Hiding secrets: the Secrets feed marks a detection that came from a remote agent's run.