selan.ai

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:

  1. An owner adds it in Reactor, under Runners (add a runner).
  2. Reactor shows an install line, which contains the runner's token. The token is shown once, so copy the line then.
  3. Run the line on the machine: the Linux tab's on an Ubuntu server, the Mac tab's on an Apple Silicon Mac.
  4. Once the runner shows as online, set an agent's Runs on to it.
1 Add a runner Reactor → Runners 2 Copy the line token shown once 3 Run it on the machine Runner online it polls Reactor 4 Pin an agent Runs on: the runner
Add the runner in Reactor, copy its install line while the token is on screen, run the line on the machine, wait for the runner to come online, then point an agent at 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.

Reactor reactor.selan.ai Agent A Runs on: mac-mini Agent B Runs on: Selan Isolated VM B's runs A vm made per run the alternative: no runner at all poll over HTTPS jobs, cancels mac-mini: the runner machine a VPS, a server, or a Mac mini selan-runner container polls, starts runs, stops them at their deadline Run 1 agent image Sysbox runtime own dockerd Run 2 agent image Sysbox runtime own dockerd free slot Slots: how many runs it takes at once. No inbound port: the runner only calls out.
Agent A is pinned to the runner and agent B runs on a vm made per run. The runner polls Reactor over an outbound HTTPS connection and reads its jobs from the answer. It starts each run in a container of its own with the Sysbox runtime and its own Docker daemon, up to its slots.
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:

TabFor
LinuxAn Ubuntu 24.04 server.
MacAn Apple Silicon Mac.
Mac privilegedThe Mac line with --mac-host, for Xcode builds on the Mac.
The token is in the line, and it is shown only once. Reactor keeps only a hash of it. Copy the line before you close the dialog. It reaches the install script through the environment rather than the command line, where any user on the machine could read it from 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:

FlagWhat it sets
--cpus NThe VM's CPUs. Default 4, and fewer than the Mac has.
--memory GiBThe VM's memory. Default 8, and less than the Mac has.
--disk GiBThe 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 --cpus or --memory that differs stops the VM to resize it, and the runs going in it end as lost.
curl -fsSL https://reactor.selan.ai/runners/install-mac.sh | REACTOR_URL=https://reactor.selan.ai RUNNER_SLOTS=2 bash
macOS · Apple Silicon the VM starts when the Mac's user logs in Lima VM selan-runner Ubuntu 24.04 · vz · arm64 · no mounts · Docker and Sysbox selan-runner container polls Reactor and starts each run beside itself Run container Sysbox · own dockerd Run container Sysbox · own dockerd free slot Your home directory is not mounted: a run cannot reach it. Docker Desktop is not used: its VM cannot take Sysbox.
macOS holds one Lima VM, Ubuntu with no mounts. The VM runs Docker and Sysbox and the runner container, and the runner starts each run in its own Sysbox container inside the VM.

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 staff group, which every user is in, so --mac-host sets the other homes, the owner's included, to 700 and checks that selan-build cannot 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 macrun helper, so a run can call the Mac: macrun ssh mac "xcodebuild …" to run a command there, and macrun rsync to copy a tree to the Mac and results back.
ISOLATED PER RUN SHARED BY EVERY RUN Lima VM selan-runner Ubuntu, no mounts Run container one per run: own Sysbox container, own dockerd macrun ssh mac … macrun rsync … Key server the selan-build key reachable only from inside this VM key macrun helper a container of its own ssh · rsync SSH to 192.168.5.2 as selan-build output back: build log, exit code, files macOS · this Mac SSH accepts selan-build only, from this Mac selan-build, one user for all a standard user: not admin, no sudo ~/work/<run> · xcodebuild one home, ~/work, simulators, DerivedData, shared by every run: runs see each other's files Readable to it as well anything world-readable, such as /Applications /opt/homebrew /Users/Shared /tmp Other users' homes, the owner's set to 700 by --mac-host: selan-build cannot list them
A run calls 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.
Any run on that runner can run commands on the Mac as 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-host runner acts as the same selan-build user, 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/Shared and /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 the runnerId that selan_remote_agent_create and selan_remote_agent_update take, beside every other Reactor tool.
  • Agents: running an agent in your own CI with selan agent-run rather than in Reactor.
  • Hiding secrets: the Secrets feed marks a detection that came from a remote agent's run.