A step-by-step guide to running Cyrus as a 24/7 background agent on your own Linux box, driven by Linear, with Claude Code authenticated through your Max 20x subscription so the work draws from your… A developer published a step-by-step guide for running Cyrus as a 24/7 background agent on a Linux box, using Linear and Claude Code authenticated via a Max 20x subscription to avoid pay-as-you-go API billing. The guide includes fixes not found in upstream documentation and covers Docker deployment, OAuth token authentication, and billing considerations as of June 2026. A step-by-step guide to running Cyrus https://github.com/cyrusagents/cyrus as a 24/7 background agent on your own Linux box, driven by Linear, with Claude Code authenticated through your Max 20x subscription so the work draws from your subscription instead of pay-as-you-go API billing. Last verified: June 2026 · cyrus-ai v0.2.65 · Claude Code 2.1.x · AlmaLinux 9 Docker host. This guide is battle-tested against cyrus-ai v0.2.65 / Claude Code 2.1.x on an AlmaLinux 9 Docker host. It includes a number of fixes the upstream docs don't mention. Each is called out inline with⚠️ Whyso you understand what you're working around. Follow it top to bottom and you'll get a working loop on the first pass. Billing model and status verified June 2026 .Cyrus drives the real Claude Code CLI in headless mode, authenticated with the documented CLAUDE CODE OAUTH TOKEN from claude setup-token . With that subscription token, runs draw from your normal Claude subscription rather than platform.anthropic.com pay-as-you-go API billing. Anthropic had announced that on June 15, 2026, Agent SDK and claude -p usage would move off subscription limits onto a separate monthly Agent SDK credit, but postponed that change on the day it was due. For now, agent and headless usage keeps running against your subscription exactly as before, with no separate credit to claim and subscription limits unchanged, and Anthropic has said it will give advance notice before any future change. Note that most public write-ups still describe the credit as live, so treat the billing model as in flux and confirm your current usage at claude.ai/settings/usage . Whatever the model, the rule below holds: keep ANTHROPIC API KEY out of the environment so you stay on subscription auth and off metered API billing. A single Docker container cyrus , the agent runner that watches Linear for issues delegated to it, spins up an isolated git worktree per issue, runs a sandboxed Claude Code session, and opens a PR. Linear reaches it over your existing cloudflared tunnel . The container publishes its port on loopback only 127.0.0.1:3456 , and you add one public hostname route to the tunnel you already run. State lives in two named Docker volumes so it survives restarts: cyrus-data → /home/cyrus/.cyrus : config.json, cloned repos, worktrees, Linear tokens, the egress CA. cyrus-claude → /home/cyrus/.claude : Claude Code conversation transcripts. Persisting this is mandatory: without it, every container restart loses in-flight sessions and makes existing issues' agent sessions unresumable No conversation found with session ID … . The Bash sandbox egress allowlist + filesystem isolation runs via bubblewrap inside the container , which requires relaxing two container security options, covered in Step 4. Cyrus accepts two Claude Code auth methods. They are not interchangeable for your purpose: | Method | Env var | Where it bills | |---|---|---| | API key the Cyrus README's default | ANTHROPIC API KEY | Your API account, full pay-as-you-go rates, no credit offset | OAuth token use this one | CLAUDE CODE OAUTH TOKEN | Your Max 20x subscription normal usage limits | Use CLAUDE CODE OAUTH TOKEN . Make sure ANTHROPIC API KEY is not present anywhere in the environment, or Claude Code will silently prefer it and bill the API account. This is the exact mistake that has produced four-figure surprise bills. With subscription auth, headless Claude Code draws from your normal Claude subscription, not a separate metered API bill. Default the agent to Sonnet to keep it light on your subscription limits, and reserve Opus for tickets that need it. Model selection is covered in Step 8. - A Linux server with Docker Engine and the Docker Compose plugin. Linear workspace admin access required to create an OAuth app .- A domain managed in Cloudflare you'll point a subdomain like cyrus.yourdomain.com at the tunnel . - A machine where you already run Claude Code your laptop to mint the OAuth token. - A fine-grained GitHub PAT scoped to only the repos you want the agent to touch Contents + Pull Requests: read/write . Do not use a broad classic token. - The host kernel must allow user namespaces default on RHEL/AlmaLinux 9; the container relaxes seccomp to use them, see Step 4 . Verify with cat /proc/sys/user/max user namespaces should be 0 . On a machine where Claude Code is installed and logged in to your Max account: claude setup-token This prints a long-lived OAuth token tied to your subscription. Copy it. You'll paste it into the server's env file in Step 4. Keep it secret; treat it like a password. - In Linear, click your workspace name top-left → Settings → API under the Account section → OAuth Applications . - Click Create new OAuth Application and fill in: Name: Cyrus Description: Self-hosted Cyrus agent Callback URL: https://cyrus.yourdomain.com/callback - Enable the Client credentials toggle. - Enable the Webhooks toggle, then configure: Webhook URL: https://cyrus.yourdomain.com/linear-webhook App events . Check Agent session events required; this is what makes Cyrus show up as an assignable agent , and optionally Inbox notifications and Permission changes . - Save, then copy these three values the secret may only be shown once : Client ID Client Secret Webhook Signing Secret The webhook is authenticated by that signing secret, so the /linear-webhook path can be publicly reachable through the tunnel without additional gating. Note: "Authorized Applications" the user grant under your Linear account is different from the OAuth appitself. If you revoke the authorization to retry the flow, the OAuth app and its Client ID/Secret stay valid. You just need to re-run self-auth-linear to mint a fresh token. You donotneed to recreate the app. You already run cloudflared with other hostnames, so this is just one new public hostname pointing at the cyrus container's loopback-published port. If your tunnel is dashboard/token-managed Zero Trust → Networks → Tunnels → your tunnel → Public Hostnames , add: Subdomain/Domain: cyrus / yourdomain.com Service: HTTP → localhost:3456 If your tunnel is config-file managed , add an ingress rule alongside your existing ones: ingress: - hostname: cyrus.yourdomain.com service: http://localhost:3456 ... your other rules ... - service: http status:404 This works because the cyrus container publishes its port to 127.0.0.1:3456 on the host Step 4 . Binding to loopback keeps it off the public internet; only the tunnel can reach it. ⚠️ If you protect your domain with Cloudflare Access e.g. a wildcard .yourdomain.com Access policy in Zero Trust , it will intercept cyrus.yourdomain.com and redirect Linear's webhook POSTs to a login page. The agent then never fires. Add an Access application for cyrus.yourdomain.com with aBypasspolicy Everyone . A path-scoped bypass for just /linear-webhook is the most surgical option keeps the rest behind Access ; bypassing the whole host is simplest. The webhook is signature-verified, so a public /linear-webhook is safe by design.⚠️ Ifand you see intermittent 502s, set the tunnel Service to localhost resolves to IPv6 ::1 on your host http://127.0.0.1:3456 instead of localhost:3456 to force IPv4 Docker publishes the port on IPv4 only . Most hosts are fine with localhost ; this is a fallback. Create a working directory, e.g. ~/cyrus-stack/ , with the four files below. --- Claude Code auth subscription credit path --- CLAUDE CODE OAUTH TOKEN=paste-token-from-step-1 Leave ANTHROPIC API KEY UNSET. Its presence overrides the OAuth token and bills the API account. NOTE: ANTHROPIC MODEL does NOT control Cyrus's model; use CYRUS CLAUDE DEFAULT MODEL below. --- Cyrus server --- LINEAR DIRECT WEBHOOKS=true CYRUS BASE URL=https://cyrus.yourdomain.com CYRUS SERVER PORT=3456 REQUIRED in Docker: bind 0.0.0.0 INSIDE the container so Docker's port-forward can reach it. Without this, Cyrus binds container-localhost and is unreachable through the tunnel 502s . The host still only publishes on 127.0.0.1:3456, so it stays private. CYRUS HOST EXTERNAL=true --- Default model the REAL Cyrus lever; see Step 8 --- CYRUS CLAUDE DEFAULT MODEL=sonnet --- Linear OAuth from Step 2 --- LINEAR CLIENT ID=your client id LINEAR CLIENT SECRET=your client secret LINEAR WEBHOOK SECRET=your webhook signing secret --- GitHub for opening PRs --- GH TOKEN=your fine grained pat GIT AUTHOR NAME=Cyrus Agent GIT AUTHOR EMAIL=cyrus@yourdomain.com GIT COMMITTER NAME=Cyrus Agent GIT COMMITTER EMAIL=cyrus@yourdomain.com Lock it down: chmod 600 cyrus.env . ⚠️ The Cyrus server binds CYRUS HOST EXTERNAL=true is the single most important addition. localhost inside the containerby default; Docker's published port forwards to the container's bridge IP, so the app is unreachable and the tunnel returns 502 / "connection reset". This env var makes it bind 0.0.0.0 inside the container while the host still publishes loopback-only. This is Claude Code's user settings , baked into ~/.claude so the Bash sandbox can write git worktree metadata and read the egress CA. Details in the "Sandbox inside Docker" box below. { "sandbox": { "enabled": true, "filesystem": { "allowWrite": "/home/cyrus/.cyrus/repos", "/home/cyrus/.cyrus/worktrees" , "allowRead": "/home/cyrus/.cyrus/certs/cyrus-egress-ca.pem" } } } FROM node:20-bookworm-slim System deps: git, jq Claude Code parsing , gh PRs , certs/curl/gnupg for the gh apt repo socat + bubblewrap: REQUIRED by Claude Code's Bash sandbox on Linux RUN apt-get update && apt-get install -y --no-install-recommends \ git jq curl ca-certificates gnupg openssh-client socat bubblewrap \ && mkdir -p -m 755 /etc/apt/keyrings \ && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg /dev/null \ && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ && echo "deb arch=$ dpkg --print-architecture signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg https://cli.github.com/packages stable main" \ /etc/apt/sources.list.d/github-cli.list \ && apt-get update && apt-get install -y --no-install-recommends gh \ && rm -rf /var/lib/apt/lists/ Claude Code + Cyrus RUN npm install -g @anthropic-ai/claude-code cyrus-ai Make git use gh which reads GH TOKEN from the env as the HTTPS credential helper, so git clone https://github.com/... works non-interactively in the container. System scope /etc/gitconfig so it applies to the non-root cyrus user too. RUN git config --system credential."https://github.com".helper " gh auth git-credential" \ && git config --system credential."https://gist.github.com".helper " gh auth git-credential" Non-root runtime user. Pre-create BOTH state dirs owned by cyrus BEFORE declaring the VOLUMEs, so the named volumes inherit cyrus ownership on first creation. Otherwise the volume root is owned by root and the cyrus user gets EACCES mkdir .cyrus/repos fails . RUN useradd -m -s /bin/bash cyrus \ && mkdir -p /home/cyrus/.cyrus /home/cyrus/.claude \ && chown -R cyrus:cyrus /home/cyrus/.cyrus /home/cyrus/.claude Seed Claude Code user settings into the image. A fresh named volume mounted at ~/.claude initializes from this, so the sandbox filesystem grants are present. COPY --chown=cyrus:cyrus claude-settings.json /home/cyrus/.claude/settings.json USER cyrus WORKDIR /home/cyrus VOLUME "/home/cyrus/.cyrus", "/home/cyrus/.claude" ENTRYPOINT "cyrus" services: cyrus: build: . image: cyrus-local container name: cyrus restart: unless-stopped env file: - ./cyrus.env volumes: - cyrus-data:/home/cyrus/.cyrus - cyrus-claude:/home/cyrus/.claude persist Claude Code transcripts across restarts ports: - "127.0.0.1:3456:3456" loopback only; your existing cloudflared reaches it here mem limit: 8g starting point; size for concurrency see sizing note pids limit: 4096 each sandboxed session spawns many procs bwrap/node/git/socat ; 512 throttles parallelism security opt: - seccomp=unconfined bubblewrap needs to create user namespaces - systempaths=unconfined unmask /proc so bubblewrap can mount a fresh proc in its PID namespace volumes: cyrus-data: cyrus-claude: 📏 Sizing for concurrency.Cyrus hasno global concurrency cap. Every assigned issue launches a session in parallel the moment its webhook lands bounded only by these limits and Claude rate limits . So mem limit / pids limit areyour concurrency governor. A single build session comfortably fits in a couple of GB and ~100–200 processes, so scale roughly with how many issues you want running at once: a small VPS might stay at 4g / 512 a few at a time , while a large box can go much higher, e.g.on a 128 GB host for many concurrent sessions, leaving the rest of the host untouched. Edit the two lines and 32g / 8192 docker compose up -d to apply a ~5 s recreate; the volumes persist, so no data loss . Verify with docker inspect cyrus --format '{{.HostConfig.Memory}} {{.HostConfig.PidsLimit}}' . ⚠️ Running the Bash sandbox inside Docker.Cyrus's egress allowlist is enforced by Claude Code's bubblewrap sandbox, which by default cannot run in an unprivileged container. Two security opt relaxations make it work: seccomp=unconfined : lets bubblewrap create the user namespace otherwise:"No permissions to create new namespace" . systempaths=unconfined : unmasks Docker's read-only /proc paths so bubblewrap can mount a fresh /proc in its PID namespace otherwise:"Can't mount proc on /newroot/proc: Operation not permitted" .The container is still non-root, capability-dropped, memory/pid-limited, and loopback-only. You're trading the container's syscall filter for the egress domain allowlist, which is the more valuable control for an unattended agent. Don't want to relax seccomp?Set "enabled": false under sandbox in config.json Step 7 and drop the two security opt lines. The agent runs with no extra setup but hasunfiltered network egressduring Bash steps. Fine for throwaway repos, weaker for real ones. You can't keep egress filtering without bubblewrap; the two are coupled. cd ~/cyrus-stack docker compose build ⚠️ self-auth-linear refuses to run without an existing config.json "Config file not found … Run 'cyrus' first" , and nothing auto-creates it. Seed a minimal one into the volume first: docker run --rm -v cyrus-stack cyrus-data:/home/cyrus/.cyrus --entrypoint sh cyrus-local \ -c 'printf "%s" "{\"repositories\": }" /home/cyrus/.cyrus/config.json' Your tunnel route from Step 3 must be live and https://cyrus.yourdomain.com resolving first you'll get a connection error until the container is up, that's fine . --service-ports publishes 127.0.0.1:3456 so cloudflared can route /callback back to this one-off container. Keep this running until you've authorized. docker compose run --rm --service-ports cyrus self-auth-linear It prints an authorization URL. Open it on your laptop, click Authorize . The redirect flows through your tunnel to the container, and the token is saved into the cyrus-data volume. ⚠️ Don't curl the /callback path to "test" reachability while this is running. A code-less request to /callback is treated as the OAuth callback and terminates the one-shot server. To check the tunnel reaches the container, hit /status instead returns 404 on the auth server, which still proves reachability . docker compose run --rm cyrus self-add-repo \ https://github.com/yourorg/yourrepo.git "Your Workspace Name" The second argument is the display name of your Linear workspace. This clones the repo into the volume using the gh credential helper baked into the image and wires it to your workspace. Repeat for each repo. docker compose run --rm cyrus check-tokens You want ✅ Valid . If it says Invalid – not authenticated , the most common cause is that the Authorized Application was revoked in Linear, re-run 5c. docker compose up -d docker compose logs -f cyrus Healthy startup shows, among other lines: 🛡️ Sandbox egress proxy: starting... EgressProxy Listening — HTTP :9080, SOCKS :9081 ✅ Linear event transport registered direct mode SharedApplicationServer Shared application server listening on http://0.0.0.0:3456 ← 0.0.0.0, thanks to CYRUS HOST EXTERNAL Edge worker started successfully 📦 Managing 1 repositories Sanity-check the tunnel reaches the live server non-destructive paths : curl -s -o /dev/null -w "%{http code}\n" https://cyrus.yourdomain.com/status 200 curl -s -o /dev/null -w "%{http code}\n" -X POST https://cyrus.yourdomain.com/linear-webhook -d '{}' 403 signature check working A 403 on a forged webhook is correct, it's the signature check rejecting an unsigned payload. restart: unless-stopped keeps it running 24/7 and brings it back after a reboot. Cyrus stores config at ~/.cyrus/config.json inside the volume and hot-reloads changes no restart needed . Inspect/edit: docker compose exec cyrus sh -c 'cat ~/.cyrus/config.json' self-add-repo already created the repositories entry and linearWorkspaces with your tokens . Merge the following in. Don't overwrite the credentials or the repo entry: { "promptDefaults": { "scoper": { "allowedTools": "readOnly" }, "debugger": { "allowedTools": "safe" }, "builder": { "allowedTools": "safe" } }, "userAccessControl": { "allowedUsers": { "email": "you@yourdomain.com" } , "blockBehavior": "comment" }, "sandbox": { "enabled": true, "networkPolicy": { "preset": "trusted" } }, "repositories": { "// keep the fields self-add-repo created id, name, repositoryPath, baseBranch, linearWorkspaceId, … ": "and ADD:", "allowedTools": "Read", "Edit", "Bash git: ", "Bash gh: ", "Bash npm: ", "Task" , "labelPrompts": { "scoper": { "labels": "PRD", "RFC" }, "debugger": { "labels": "Bug" }, "builder": { "labels": "Feature", "Improvement" } } } } ⚠️ Cyrus reads sandbox goes TOP-LEVEL, not inside repositories . sandbox from the root of the config; nesting it under a repository silently disables it the egress proxy never starts . You can confirm it's active in the logs 🛡️ Sandbox egress proxy: starting… . What these levers do: . Only you can delegate issues, so nobody else in the workspace can spend your credit. userAccessControl.allowedUsers blockBehavior accepts "silent" or "comment" . . Routes issues to a prompt mode scoper/debugger/builder by Linear label and scopes tools per mode. labelPrompts scoper is read-only, ideal for cheap triage.. Restricts what Claude can run. Narrower = less wandering, less cost. Cyrus auto-adds allowedTools Skill . . Routes all Bash subprocess traffic git, gh, npm, curl through the local egress proxy with a ~200-domain allowlist package registries, version control , denying everything else. Add your own internal domains under sandbox / networkPolicy.preset: "trusted" networkPolicy.allow . Cyrus does support per-ticket model selection. The upstream config reference doesn't document it, but it's in the code. Resolution order highest first : Issue label → sets the model for that ticket. opus or sonnet → same effect. model=