Coding Agent Runner
Windshift can run AI coding agents for you. Assign a work item to an agent and Windshift checks out the repository, runs the agent in a throwaway container, pushes the run branch, and opens a draft pull request with the result.
How execution works. When you enable coding agents, the Windshift server acts as an orchestrator: it prepares each run and dispatches it, but it never runs an agent container itself. Agents always execute on a windshift-runner, a separate process that claims work from a runner pool, starts one fresh agent container per job on its Docker daemon, pushes the result branch, and reports back. Your SCM, LLM, and secret credentials stay on the Windshift server and are brokered to the agent at run time, so they never reach the runner.
You can place runners two ways:
- On the same host as Windshift: the simplest setup. Run the
windshift-runneralongside Windshift in one Compose file, connected by an internal Docker network (see Run a runner on the same host). This keeps agent execution out of the Windshift process while needing only one machine. - On separate runner hosts: offload execution to one or more dedicated machines, so Docker and untrusted code stay off your Windshift server and you can scale out across a pool.
A runner can be installed as a systemd service or run as a Docker container. In both modes it starts one fresh, sandboxed agent container per job.
This guide assumes you have already installed and are running Windshift.
Before you start
You need:
- A Docker daemon on the runner host. Agent jobs run in ephemeral containers. The Windshift server itself does not need Docker.
- A Git connection configured in your workspace, so the agent has a repo to clone and push to.
- An LLM connection exposed to the workspace if the agent should use a managed model connection.
- An agent binding in the workspace. A run starts when a work item is assigned to the bound agent user, or when the agent is @mentioned in a comment on an item. You can also attach a skills library and custom instructions to a binding. See Coding Agents in the user guide for those workspace-level settings.
Set up a runner
Every runner host joins a runner pool and pulls work from Windshift. The quickest setup runs a single runner on the same host as Windshift (see Run a runner on the same host). Add dedicated runner hosts when you want Docker and untrusted code off the Windshift server, or when you want to scale execution across several machines.
The egress network is created for you. The runner spawns each agent container into a dedicated Docker network (
coding-agent-egressby default) and creates that network on first start if it does not exist. Because a plain bridge does not filter egress, the runner logs a loud warning pointing at the egress-filtering docs. The same-host Compose setup below replaces that warning with aninternalnetwork, which needs no firewall rules at all.
A runner can run either:
- as a Docker container that talks to the host Docker daemon; or
- as a systemd service installed on the host.
In both cases the runner uses the host Docker daemon to start the per-job
windshift-agent containers. The runner itself is not the agent; it claims jobs,
prepares the checkout, starts the agent container, pushes the result branch, and
reports the result.
1. Create a runner pool and registration token
In Windshift, an admin creates a runner pool and mints a registration token
(wsrt_…). Registration tokens are one-time bootstrap secrets. On first start,
the runner exchanges the token for a per-instance credential (wsrc_…) and then
stores/reuses that credential for future restarts.
Use one registration token per runner instance. For immutable deployments you
can also inject an existing per-instance credential with WSRUNNER_CREDENTIAL.
2. Run the runner with Docker
This is the easiest remote-runner setup. The runner container talks to the host Docker daemon through the mounted socket and asks it to start sibling agent containers.
services:
windshift-runner:
image: ghcr.io/windshiftapp/windshift-runner:latest
restart: unless-stopped
environment:
WS_API_URL: https://windshift.example.com/api
WSRUNNER_REGISTRATION_TOKEN: wsrt_your_one_time_token
WSRUNNER_IMAGE: ghcr.io/windshiftapp/windshift-agent:latest
# Optional display name shown in Windshift:
# WSRUNNER_NAME: runner-1
volumes:
# Lets the runner start sibling agent containers on the host daemon.
- /var/run/docker.sock:/var/run/docker.sock
# Must be the same path on host and in the runner container.
- /var/lib/windshift-runner:/var/lib/windshift-runner
# Required on SELinux-enforcing hosts (Fedora, RHEL, CentOS Stream) —
# see the SELinux note below before first start.
# security_opt:
# - label=disableSELinux hosts (Fedora / RHEL / CentOS Stream): read this before the first start. With SELinux enforcing, the runner's writes to
/var/lib/windshift-runnerare blocked even though the container runs as root — the failure is a plainpermission denied, with the real denial only visible in the audit log. The runner still registers successfully, but cannot persist its per-instance credential. Because registration tokens are single-use, that combination burns the token: the first start consumes it, the credential is lost on restart, and re-registration fails with 401 until you mint a new token.Run the container with
security_opt: ["label=disable"](compose) or--security-opt label=disable(docker run). The usual:zvolume relabel is not sufficient here: the runner also needs the Docker socket, and/var/run/docker.sockcannot be relabeled without affecting the host daemon — socket access would still be denied at the first job claim. Disabling label confinement for this one container is the standard Docker-out-of-Docker setup on SELinux hosts; the agent containers the runner spawns are unaffected and keep their own sandbox (read-only root, dropped capabilities, restricted network).
Then start it:
docker compose up -dThe agent itself is not a long-running service. The runner starts a fresh,
sandboxed agent container for each job and pulls WSRUNNER_IMAGE on the host the
first time it is needed.
The
/var/lib/windshift-runnermount must resolve to the same absolute path on the host and inside the runner container. The host Docker daemon — not the runner container — bind-mounts each prepared checkout into the agent container as/workspace.
Podman works too. With rootful Podman, point the socket mount at the Podman socket instead:
volumes:
- /run/podman/podman.sock:/var/run/docker.sock
- /var/lib/windshift-runner:/var/lib/windshift-runnerRootless Podman also works but needs extra care: because it remaps user IDs, the
agent's /workspace mount can hit permission issues unless you account for the
mapping. Rootful Podman is the smoother path.
Run a runner on the same host
When the runner runs on the same host as a dockerized Windshift, put both services in one Compose file and connect them through an internal Docker network. Agent containers are spawned into that same network, and because an internal network has no route out of the host, agents and the runner can reach Windshift — which brokers all LLM and git traffic — and nothing else. Docker's network isolation is the egress policy: no firewalld or iptables allowlist to set up, nothing to re-apply when the API host's IP changes, and runner traffic never takes a round trip through your public URL.
services:
windshift:
image: ghcr.io/windshiftapp/windshift:latest
ports:
- "8080:8080"
tmpfs:
- /tmp:exec,size=64M
environment:
- BASE_URL=https://windshift.example.com
- SSO_SECRET=${SSO_SECRET}
# In-network API address; the broker URLs handed to agent containers
# resolve on the internal network below. Must end in /api.
- CODING_AGENT_WS_API_URL=http://windshift:8080/api
volumes:
- windshift-data:/data
networks:
- default
- coding-agent
restart: unless-stopped
windshift-runner:
image: ghcr.io/windshiftapp/windshift-runner:latest
environment:
# In-Compose control plane. Plaintext HTTP is acceptable only because
# this traffic never leaves the host's Docker bridge.
WS_API_URL: http://windshift:8080/api
WSRUNNER_ALLOW_INSECURE: "1"
WSRUNNER_REGISTRATION_TOKEN: ${WSRUNNER_REGISTRATION_TOKEN}
WSRUNNER_IMAGE: ghcr.io/windshiftapp/windshift-agent:latest
volumes:
# Lets the runner start sibling agent containers on the host daemon.
- /var/run/docker.sock:/var/run/docker.sock
# Must be the same path on host and in the runner container.
- /var/lib/windshift-runner:/var/lib/windshift-runner
networks:
- coding-agent
depends_on:
- windshift
restart: unless-stopped
# SELinux-enforcing hosts: see the SELinux note above.
# security_opt:
# - label=disable
networks:
# Agent containers are spawned into this network — the name matches the
# runner's default. "internal: true" means containers on it can reach each
# other but nothing outside the host.
coding-agent:
name: coding-agent-egress
internal: true
volumes:
windshift-data:Bootstrap order: start Windshift first (docker compose up -d windshift), mint
the pool registration token in the admin runner-pool view, add it to .env as
WSRUNNER_REGISTRATION_TOKEN, then docker compose up -d.
Notes:
WSRUNNER_ALLOW_INSECURE=1is safe only in this shape: the control-plane traffic stays on the host's Docker bridge. Never use it across hosts.- The internal network does not affect image pulls — the host Docker daemon
pulls
WSRUNNER_IMAGE, not the runner container. - The same warning about Docker access applies: the runner can control the host's Docker daemon, so this setup trades the isolation of a dedicated runner host for convenience. Windshift itself still stays free of Docker access; only the runner talks to the Docker daemon.
3. Or install the runner as a systemd service
Use the systemd install when you prefer native host processes or do not want the runner itself containerized.
Each host needs:
- Linux with systemd
- Docker
git- Outbound HTTPS to your Windshift server
Download the windshift-runner and windshift-triage binaries plus the runner
installer from your Windshift release, then run:
sudo ./install.sh --bin-dir ./distThe installer sets up the runner as a systemd service, creates its user and
working directory, installs windshift-runner and windshift-triage, and writes
a configuration file at /etc/windshift-runner/runner.env.
Edit /etc/windshift-runner/runner.env:
WS_API_URL=https://windshift.example.com/api
WSRUNNER_REGISTRATION_TOKEN=wsrt_your_one_time_token
WSRUNNER_IMAGE=ghcr.io/windshiftapp/windshift-agent:latestThen pull the agent image and start the service:
docker pull ghcr.io/windshiftapp/windshift-agent:latest
sudo systemctl start windshift-runner
journalctl -u windshift-runner -fYou should see the runner register, persist its per-instance credential, then claim and run jobs assigned to its pool.
Runner configuration reference
| Variable | Required | Default | Purpose |
|---|---|---|---|
WS_API_URL |
✓ | — | Windshift API base URL ending in /api (for example https://host/api). The runner control plane and brokers live here; this is not /rest/api/v1 and not the bare host URL. HTTPS is required unless WSRUNNER_ALLOW_INSECURE=1 is set for development. |
WSRUNNER_REGISTRATION_TOKEN |
first bootstrap | — | One-time pool registration token (wsrt_…). The runner exchanges it for a per-instance credential and does not need it again after the credential is stored. |
WSRUNNER_CREDENTIAL |
optional | — | Inject an existing per-instance runner credential (wsrc_…) instead of registering with a token. Useful for immutable deployments. |
WSRUNNER_CREDENTIAL_FILE |
optional | <cache>/credential |
Path where the runner stores/reuses its per-instance credential. |
WSRUNNER_IMAGE |
✓ | — | Agent container image to run, usually ghcr.io/windshiftapp/windshift-agent:latest. |
WSRUNNER_NAME |
hostname | Name shown for this runner. | |
WSRUNNER_DOCKER |
docker |
Docker-compatible CLI to invoke. | |
WSRUNNER_TRIAGE_BIN |
windshift-triage |
Path to the triage helper used for git prepare/push. | |
WSRUNNER_CACHE_ROOT |
/var/lib/windshift-runner/cache |
Host-local bare-clone cache. Keep it under the same-path bind mount when running the runner in Docker. | |
WSRUNNER_POLL_INTERVAL |
2s |
How often to check for work when idle. | |
WSRUNNER_HEARTBEAT_INTERVAL |
30s |
How often to renew the runner lease. | |
WSRUNNER_INITIAL_PROMPT |
server prompt | Emergency fallback only; normal jobs receive the server-managed prompt in the claim. | |
WSRUNNER_ALLOW_INSECURE |
unset | Set to 1 to allow a plaintext http:// WS_API_URL. Only for local development, or for the same-host Compose setup where runner traffic never leaves the host's Docker bridge. |
|
WSRUNNER_ALLOW_UNLABELED_IMAGE |
unset | Set to 1 to accept an agent image that lacks the agent-contract label. The runner otherwise refuses to start an unlabeled image. Use only for custom images you build yourself. |
Security
- Your SCM and LLM credentials stay on the Windshift server. Remote runners authenticate with a per-instance credential and per-run tokens. Git and LLM access flow through Windshift brokers, which inject the real provider credentials server-side.
- The agent container has no raw provider credentials. It uses short-lived run tokens and broker URLs instead.
- Each run is sandboxed. Agents run as a non-root user in a container with a read-only root filesystem, dropped capabilities, tmpfs scratch space, and the configured Docker network.
- A run can only push its own branch (
agent-runs/run-<id>). The git proxy gates pushes to the run's single granted ref. - Docker access is powerful. The runner process controls the Docker daemon on its host, which is effectively root on that host. Run runner hosts as dedicated, disposable machines. The Windshift server never needs Docker access.
- Registration tokens are single-use. Remove
WSRUNNER_REGISTRATION_TOKENafter first bootstrap if your deployment process allows it; the persisted per-instance credential is what the runner uses on restart.
Troubleshooting
- Runs never start: confirm the pool exists and
WSRUNNER_IMAGEis set, and that the agent image can be pulled by the runner's Docker daemon. - Runner says
WS_API_URL must be https://: use an HTTPS URL ending in/api, or setWSRUNNER_ALLOW_INSECURE=1(only for local development or the same-host Compose setup, where traffic stays on the Docker bridge). - Agent can't reach Windshift: check
WS_API_URLand outbound connectivity from the runner. IfBASE_URLis not reachable from agent containers, setCODING_AGENT_WS_API_URLon the Windshift server.localhostinside a container is not your Windshift server. - Permission denied talking to Docker: the runner process or container must be able to access the Docker daemon.
- Remote git prep/push fails: confirm
windshift-triageis installed (or present in the runner image),gitis available, and the runner can reachWS_API_URLover HTTPS. - Runs fail with
prepare checkout: ... setup askpass: stat /tmp: no such file or directory: the runner host or container is missing a usable/tmp; the runner uses it as git scratch space. Mounttmpfs: ["/tmp:exec,size=64M"]on the runner. If/tmpis mounted but withoutexec, the same git steps fail with a permission error executing the askpass credential helper instead, because Docker tmpfs mounts arenoexecby default. See The /tmp tmpfs mount for details. - Runner keeps asking for a registration token after restart: check that
WSRUNNER_CREDENTIAL_FILEis writable and persisted. In Docker, keep/var/lib/windshift-runnermounted as a volume. - Runner logs
warning: could not persist credential ... permission deniedand gets 401 after a restart (Fedora/RHEL/CentOS): SELinux blocked the write to/var/lib/windshift-runnereven though the container runs as root. Recreate the container with--security-opt label=disable(see the SELinux note in the Docker runner section), then mint a new registration token, because the old one was consumed by the registration whose credential could not be saved. The orphaned instance is auto-revoked by the lease reaper.
See Environment Variables
for the full configuration reference, and Coding Agents
for wiring editor agents like Claude Code or Cursor to the ws CLI.