{"slug": "a-step-by-step-guide-to-running-cyrus-as-a-24-7-background-agent-on-your-own-box", "title": "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…", "summary": "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.", "body_md": "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.\n\n*Last verified: June 2026 · cyrus-ai v0.2.65 · Claude Code 2.1.x · AlmaLinux 9 Docker host.*\n\nThis guide is battle-tested against\n\n`cyrus-ai`\n\nv0.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.\n\nBilling model and status (verified June 2026).Cyrus drives the real Claude Code CLI in headless mode, authenticated with the documented`CLAUDE_CODE_OAUTH_TOKEN`\n\nfrom`claude setup-token`\n\n. 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`\n\nusage 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`\n\n. Whatever the model, the rule below holds: keep`ANTHROPIC_API_KEY`\n\nout of the environment so you stay on subscription auth and off metered API billing.\n\nA 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.\n\nLinear reaches it over your **existing cloudflared tunnel**. The container publishes its port on loopback only (`127.0.0.1:3456`\n\n), and you add one public hostname route to the tunnel you already run.\n\nState lives in **two** named Docker volumes so it survives restarts:\n\n`cyrus-data`\n\n→`/home/cyrus/.cyrus`\n\n: config.json, cloned repos, worktrees, Linear tokens, the egress CA.`cyrus-claude`\n\n→`/home/cyrus/.claude`\n\n: 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 …`\n\n).\n\nThe Bash sandbox (egress allowlist + filesystem isolation) runs via **bubblewrap inside the container**, which requires relaxing two container security options, covered in Step 4.\n\nCyrus accepts two Claude Code auth methods. They are not interchangeable for your purpose:\n\n| Method | Env var | Where it bills |\n|---|---|---|\n| API key (the Cyrus README's default) | `ANTHROPIC_API_KEY` |\nYour API account, full pay-as-you-go rates, no credit offset |\nOAuth token (use this one) |\n`CLAUDE_CODE_OAUTH_TOKEN` |\nYour Max 20x subscription (normal usage limits) |\n\nUse `CLAUDE_CODE_OAUTH_TOKEN`\n\n. Make sure `ANTHROPIC_API_KEY`\n\nis **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.\n\nWith 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.\n\n- A Linux server with Docker Engine and the Docker Compose plugin.\n**Linear workspace admin** access (required to create an OAuth app).- A domain managed in\n**Cloudflare**(you'll point a subdomain like`cyrus.yourdomain.com`\n\nat the tunnel). - A machine where you already run Claude Code (your laptop) to mint the OAuth token.\n- A\n**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\n`cat /proc/sys/user/max_user_namespaces`\n\n(should be > 0).\n\nOn a machine where Claude Code is installed and logged in to your Max account:\n\n```\nclaude setup-token\n```\n\nThis 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.\n\n- In Linear, click your workspace name (top-left) →\n**Settings**→** API**(under the Account section) →** OAuth Applications**. - Click\n**Create new OAuth Application** and fill in:**Name:**`Cyrus`\n\n**Description:**`Self-hosted Cyrus agent`\n\n**Callback URL:**`https://cyrus.yourdomain.com/callback`\n\n- Enable the\n**Client credentials** toggle. - Enable the\n**Webhooks** toggle, then configure:**Webhook URL:**`https://cyrus.yourdomain.com/linear-webhook`\n\n**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**.\n\n- Save, then copy these three values (the secret may only be shown once):\n**Client ID****Client Secret****Webhook Signing Secret**\n\nThe webhook is authenticated by that signing secret, so the `/linear-webhook`\n\npath can be publicly reachable through the tunnel without additional gating.\n\nNote: \"Authorized Applications\" (the user grant under your Linear account) is different from the OAuth\n\nappitself. 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`\n\nto mint a fresh token. You donotneed to recreate the app.\n\nYou already run cloudflared with other hostnames, so this is just one new public hostname pointing at the cyrus container's loopback-published port.\n\n**If your tunnel is dashboard/token-managed** (Zero Trust → Networks → Tunnels → your tunnel → Public Hostnames), add:\n\n**Subdomain/Domain:**`cyrus`\n\n/`yourdomain.com`\n\n**Service:**`HTTP`\n\n→`localhost:3456`\n\n**If your tunnel is config-file managed**, add an ingress rule alongside your existing ones:\n\n```\ningress:\n  - hostname: cyrus.yourdomain.com\n    service: http://localhost:3456\n  # ... your other rules ...\n  - service: http_status:404\n```\n\nThis works because the cyrus container publishes its port to `127.0.0.1:3456`\n\non the host (Step 4). Binding to loopback keeps it off the public internet; only the tunnel can reach it.\n\n⚠️\n\nIf you protect your domain with Cloudflare Access(e.g. a wildcard`*.yourdomain.com`\n\nAccess policy in Zero Trust), it will intercept`cyrus.yourdomain.com`\n\nand redirect Linear's webhook POSTs to a login page. The agent then never fires. Add an Access application for`cyrus.yourdomain.com`\n\nwith aBypasspolicy (`Everyone`\n\n). A path-scoped bypass for just`/linear-webhook`\n\nis 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`\n\nis safe by design.⚠️\n\nIfand you see intermittent 502s, set the tunnel Service to`localhost`\n\nresolves to IPv6 (`::1`\n\n) on your host`http://127.0.0.1:3456`\n\ninstead of`localhost:3456`\n\nto force IPv4 (Docker publishes the port on IPv4 only). Most hosts are fine with`localhost`\n\n; this is a fallback.\n\nCreate a working directory, e.g. `~/cyrus-stack/`\n\n, with the four files below.\n\n```\n# --- Claude Code auth (subscription credit path) ---\nCLAUDE_CODE_OAUTH_TOKEN=paste-token-from-step-1\n# Leave ANTHROPIC_API_KEY UNSET. Its presence overrides the OAuth token and bills the API account.\n# NOTE: ANTHROPIC_MODEL does NOT control Cyrus's model; use CYRUS_CLAUDE_DEFAULT_MODEL below.\n\n# --- Cyrus server ---\nLINEAR_DIRECT_WEBHOOKS=true\nCYRUS_BASE_URL=https://cyrus.yourdomain.com\nCYRUS_SERVER_PORT=3456\n# REQUIRED in Docker: bind 0.0.0.0 INSIDE the container so Docker's port-forward can reach it.\n# Without this, Cyrus binds container-localhost and is unreachable through the tunnel (502s).\n# The host still only publishes on 127.0.0.1:3456, so it stays private.\nCYRUS_HOST_EXTERNAL=true\n\n# --- Default model (the REAL Cyrus lever; see Step 8) ---\nCYRUS_CLAUDE_DEFAULT_MODEL=sonnet\n\n# --- Linear OAuth (from Step 2) ---\nLINEAR_CLIENT_ID=your_client_id\nLINEAR_CLIENT_SECRET=your_client_secret\nLINEAR_WEBHOOK_SECRET=your_webhook_signing_secret\n\n# --- GitHub (for opening PRs) ---\nGH_TOKEN=your_fine_grained_pat\nGIT_AUTHOR_NAME=Cyrus Agent\nGIT_AUTHOR_EMAIL=cyrus@yourdomain.com\nGIT_COMMITTER_NAME=Cyrus Agent\nGIT_COMMITTER_EMAIL=cyrus@yourdomain.com\n```\n\nLock it down: `chmod 600 cyrus.env`\n\n.\n\n⚠️\n\nThe Cyrus server binds`CYRUS_HOST_EXTERNAL=true`\n\nis the single most important addition.`localhost`\n\ninside 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`\n\ninside the container while the host still publishes loopback-only.\n\nThis is Claude Code's **user settings**, baked into `~/.claude`\n\nso the Bash sandbox can write git worktree metadata and read the egress CA. (Details in the \"Sandbox inside Docker\" box below.)\n\n```\n{\n  \"sandbox\": {\n    \"enabled\": true,\n    \"filesystem\": {\n      \"allowWrite\": [\n        \"/home/cyrus/.cyrus/repos\",\n        \"/home/cyrus/.cyrus/worktrees\"\n      ],\n      \"allowRead\": [\n        \"/home/cyrus/.cyrus/certs/cyrus-egress-ca.pem\"\n      ]\n    }\n  }\n}\nFROM node:20-bookworm-slim\n\n# System deps:\n#   git, jq (Claude Code parsing), gh (PRs), certs/curl/gnupg for the gh apt repo\n#   socat + bubblewrap: REQUIRED by Claude Code's Bash sandbox on Linux\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n        git jq curl ca-certificates gnupg openssh-client socat bubblewrap \\\n && mkdir -p -m 755 /etc/apt/keyrings \\\n && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \\\n        | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \\\n && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \\\n && echo \"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" \\\n        > /etc/apt/sources.list.d/github-cli.list \\\n && apt-get update && apt-get install -y --no-install-recommends gh \\\n && rm -rf /var/lib/apt/lists/*\n\n# Claude Code + Cyrus\nRUN npm install -g @anthropic-ai/claude-code cyrus-ai\n\n# Make git use gh (which reads GH_TOKEN from the env) as the HTTPS credential helper,\n# so `git clone https://github.com/...` works non-interactively in the container.\n# System scope (/etc/gitconfig) so it applies to the non-root cyrus user too.\nRUN git config --system credential.\"https://github.com\".helper \"!gh auth git-credential\" \\\n && git config --system credential.\"https://gist.github.com\".helper \"!gh auth git-credential\"\n\n# Non-root runtime user. Pre-create BOTH state dirs owned by cyrus BEFORE declaring the\n# VOLUMEs, so the named volumes inherit cyrus ownership on first creation. Otherwise the\n# volume root is owned by root and the cyrus user gets EACCES (mkdir .cyrus/repos fails).\nRUN useradd -m -s /bin/bash cyrus \\\n && mkdir -p /home/cyrus/.cyrus /home/cyrus/.claude \\\n && chown -R cyrus:cyrus /home/cyrus/.cyrus /home/cyrus/.claude\n\n# Seed Claude Code user settings into the image. A fresh named volume mounted at\n# ~/.claude initializes from this, so the sandbox filesystem grants are present.\nCOPY --chown=cyrus:cyrus claude-settings.json /home/cyrus/.claude/settings.json\n\nUSER cyrus\nWORKDIR /home/cyrus\n\nVOLUME [\"/home/cyrus/.cyrus\", \"/home/cyrus/.claude\"]\n\nENTRYPOINT [\"cyrus\"]\nservices:\n  cyrus:\n    build: .\n    image: cyrus-local\n    container_name: cyrus\n    restart: unless-stopped\n    env_file:\n      - ./cyrus.env\n    volumes:\n      - cyrus-data:/home/cyrus/.cyrus\n      - cyrus-claude:/home/cyrus/.claude   # persist Claude Code transcripts across restarts\n    ports:\n      - \"127.0.0.1:3456:3456\"   # loopback only; your existing cloudflared reaches it here\n    mem_limit: 8g               # starting point; size for concurrency (see sizing note)\n    pids_limit: 4096            # each sandboxed session spawns many procs (bwrap/node/git/socat); 512 throttles parallelism\n    security_opt:\n      - seccomp=unconfined      # bubblewrap needs to create user namespaces\n      - systempaths=unconfined  # unmask /proc so bubblewrap can mount a fresh proc in its PID namespace\n\nvolumes:\n  cyrus-data:\n  cyrus-claude:\n```\n\n📏\n\nSizing 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`\n\n/`pids_limit`\n\nareyour 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`\n\n/`512`\n\n(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`\n\n/`8192`\n\n`docker compose up -d`\n\nto apply (a ~5 s recreate; the volumes persist, so no data loss). Verify with`docker inspect cyrus --format '{{.HostConfig.Memory}} {{.HostConfig.PidsLimit}}'`\n\n.\n\n⚠️\n\nRunning 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`\n\nrelaxations make it work:\n\n`seccomp=unconfined`\n\n: lets bubblewrap create the user namespace (otherwise:\"No permissions to create new namespace\").`systempaths=unconfined`\n\n: unmasks Docker's read-only`/proc`\n\npaths so bubblewrap can mount a fresh`/proc`\n\nin 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\n\negress domain allowlist, which is the more valuable control for an unattended agent.\n\nDon't want to relax seccomp?Set`\"enabled\": false`\n\nunder`sandbox`\n\nin`config.json`\n\n(Step 7) and drop the two`security_opt`\n\nlines. 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.)\n\n```\ncd ~/cyrus-stack\ndocker compose build\n```\n\n⚠️\n\n`self-auth-linear`\n\nrefuses to run without an existing`config.json`\n\n(\"Config file not found … Run 'cyrus' first\"), and nothing auto-creates it. Seed a minimal one into the volume first:\n\n```\ndocker run --rm -v cyrus-stack_cyrus-data:/home/cyrus/.cyrus --entrypoint sh cyrus-local \\\n  -c 'printf \"%s\" \"{\\\"repositories\\\": []}\" > /home/cyrus/.cyrus/config.json'\n```\n\nYour tunnel route from Step 3 must be live and `https://cyrus.yourdomain.com`\n\nresolving first (you'll get a connection error until the container is up, that's fine).\n\n```\n# --service-ports publishes 127.0.0.1:3456 so cloudflared can route /callback back\n# to this one-off container. Keep this running until you've authorized.\ndocker compose run --rm --service-ports cyrus self-auth-linear\n```\n\nIt 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`\n\nvolume.\n\n⚠️ Don't\n\n`curl`\n\nthe`/callback`\n\npath to \"test\" reachability while this is running. A code-less request to`/callback`\n\nis treated as the OAuth callback and terminates the one-shot server. To check the tunnel reaches the container, hit`/status`\n\ninstead (returns 404 on the auth server, which still proves reachability).\n\n```\ndocker compose run --rm cyrus self-add-repo \\\n  https://github.com/yourorg/yourrepo.git \"Your Workspace Name\"\n```\n\nThe second argument is the **display name** of your Linear workspace. This clones the repo into the volume (using the `gh`\n\ncredential helper baked into the image) and wires it to your workspace. Repeat for each repo.\n\n```\ndocker compose run --rm cyrus check-tokens\n```\n\nYou want `✅ Valid`\n\n. If it says `Invalid – not authenticated`\n\n, the most common cause is that the Authorized Application was revoked in Linear, re-run 5c.\n\n```\ndocker compose up -d\ndocker compose logs -f cyrus\n```\n\nHealthy startup shows, among other lines:\n\n```\n🛡️  Sandbox egress proxy: starting...\n[EgressProxy] Listening — HTTP :9080, SOCKS :9081\n✅ Linear event transport registered (direct mode)\n[SharedApplicationServer] Shared application server listening on http://0.0.0.0:3456   ← 0.0.0.0, thanks to CYRUS_HOST_EXTERNAL\nEdge worker started successfully\n📦 Managing 1 repositories\n```\n\nSanity-check the tunnel reaches the live server (non-destructive paths):\n\n```\ncurl -s -o /dev/null -w \"%{http_code}\\n\" https://cyrus.yourdomain.com/status          # 200\ncurl -s -o /dev/null -w \"%{http_code}\\n\" -X POST https://cyrus.yourdomain.com/linear-webhook -d '{}'   # 403 (signature check working)\n```\n\nA `403`\n\non a forged webhook is correct, it's the signature check rejecting an unsigned payload. `restart: unless-stopped`\n\nkeeps it running 24/7 and brings it back after a reboot.\n\nCyrus stores config at `~/.cyrus/config.json`\n\ninside the volume and hot-reloads changes (no restart needed). Inspect/edit:\n\n```\ndocker compose exec cyrus sh -c 'cat ~/.cyrus/config.json'\n```\n\n`self-add-repo`\n\nalready created the `repositories[]`\n\nentry and `linearWorkspaces`\n\n(with your tokens). **Merge** the following in. Don't overwrite the credentials or the repo entry:\n\n```\n{\n  \"promptDefaults\": {\n    \"scoper\":   { \"allowedTools\": \"readOnly\" },\n    \"debugger\": { \"allowedTools\": \"safe\" },\n    \"builder\":  { \"allowedTools\": \"safe\" }\n  },\n  \"userAccessControl\": {\n    \"allowedUsers\": [ { \"email\": \"you@yourdomain.com\" } ],\n    \"blockBehavior\": \"comment\"\n  },\n  \"sandbox\": {\n    \"enabled\": true,\n    \"networkPolicy\": { \"preset\": \"trusted\" }\n  },\n  \"repositories\": [\n    {\n      \"// keep the fields self-add-repo created (id, name, repositoryPath, baseBranch, linearWorkspaceId, …)\": \"and ADD:\",\n      \"allowedTools\": [\"Read\", \"Edit\", \"Bash(git:*)\", \"Bash(gh:*)\", \"Bash(npm:*)\", \"Task\"],\n      \"labelPrompts\": {\n        \"scoper\":   { \"labels\": [\"PRD\", \"RFC\"] },\n        \"debugger\": { \"labels\": [\"Bug\"] },\n        \"builder\":  { \"labels\": [\"Feature\", \"Improvement\"] }\n      }\n    }\n  ]\n}\n```\n\n⚠️\n\nCyrus reads`sandbox`\n\ngoes TOP-LEVEL, not inside`repositories[]`\n\n.`sandbox`\n\nfrom 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…`\n\n).\n\nWhat these levers do:\n\n. Only you can delegate issues, so nobody else in the workspace can spend your credit. (`userAccessControl.allowedUsers`\n\n`blockBehavior`\n\naccepts`\"silent\"`\n\nor`\"comment\"`\n\n.). Routes issues to a prompt mode (scoper/debugger/builder) by Linear label and scopes tools per mode.`labelPrompts`\n\n`scoper`\n\nis read-only, ideal for cheap triage.. Restricts what Claude can run. Narrower = less wandering, less cost. (Cyrus auto-adds`allowedTools`\n\n`Skill`\n\n.). 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`\n\n/`networkPolicy.preset: \"trusted\"`\n\n`networkPolicy.allow`\n\n.\n\nCyrus **does** support per-ticket model selection. The upstream config reference doesn't document it, but it's in the code. Resolution order (highest first):\n\n**Issue label**→ sets the model for that ticket.`opus`\n\nor`sonnet`\n\n→ same effect.`[model=<name>]`\n\ntag anywhere in the issue descriptionin`repositories[].model`\n\n`config.json`\n\n→ per-repo default.(or`CYRUS_CLAUDE_DEFAULT_MODEL`\n\n`CYRUS_DEFAULT_MODEL`\n\n) env var → global default.- Built-in fallback:\n(Opus), which is why an unconfigured agent runs Opus. This is Cyrus's pinned default at this version; current model names shift, so check what your Cyrus and Claude Code version actually resolves to.`claude-opus-4-6`\n\nSo:\n\n**Global default → Sonnet:**`CYRUS_CLAUDE_DEFAULT_MODEL=sonnet`\n\nin`cyrus.env`\n\n(already in Step 4; requires a`docker compose up -d`\n\nto load). This is the biggest cost lever.**Run one ticket on Opus:** add anlabel, or put`opus`\n\nin the description. The`[model=opus]`\n\n`opus`\n\n/`sonnet`\n\nmodel label is independent of routing. The issue still needs its routing label (or assignment) to reach the repo.\n\n⚠️\n\nCyrus resolves the model itself and passes it explicitly to the Agent SDK, overriding both. Use`ANTHROPIC_MODEL`\n\nand Claude Code's`settings.json`\n\n`model`\n\nare both no-ops here.`CYRUS_CLAUDE_DEFAULT_MODEL`\n\nand the per-ticket label/tag. You can confirm the resolved model in the logs:`\"cqo.model\":\"sonnet\"`\n\n, and Cyrus posts a \"Using model: …\" thought on the Linear issue.\n\n(`CYRUS_CLAUDE_DEFAULT_FALLBACK_MODEL`\n\nalso exists if you want to control the retry-fallback model.)\n\nCyrus activates when an issue is **delegated/assigned to the Cyrus agent** (that's what the Agent session events deliver). Two ways to feed it:\n\n**Manual:** assign an issue to the Cyrus agent like you'd assign a teammate.**Automatic from a queue:** rather than firing on everything in Todo (which drains credit fast), create an`agent`\n\nlabel (matching`routingLabels`\n\n), and if your Linear plan supports workflow automations, add a rule that assigns issues to Cyrus when that label is added or when they enter a dedicated \"Agent Queue\" status. An explicit on-ramp you control beats auto-grabbing the backlog.\n\nStart with manual assignment on a single throwaway issue to confirm the loop end to end (issue → worktree → session → PR), then add the automation. In the logs you'll see `webhook_received … AgentSessionEvent`\n\n→ `RepositoryRouter`\n\n→ `Creating git worktree …`\n\n→ `session_started`\n\n→ the PR marker.\n\nOne Cyrus instance can serve many repos in the same Linear workspace. Add each with `self-add-repo`\n\n(Step 5d), repeat once per repo:\n\n```\ndocker compose run --rm cyrus self-add-repo https://github.com/yourorg/another-repo.git \"Your Workspace Name\"\n```\n\n`self-add-repo`\n\nruns non-interactively, clones into the volume, and creates a **bare** repo entry: `id`\n\n, `name`\n\n, `repositoryPath`\n\n, `baseBranch`\n\n, `linearWorkspaceId`\n\n, and `routingLabels: [\"<repo-name>\"]`\n\n. It does **not** copy the `allowedTools`\n\n/ `labelPrompts`\n\nhardening from Step 7. Those are per-repo, so after a bulk add, backfill them onto every entry (the top-level `sandbox`\n\n, `userAccessControl`\n\n, and `promptDefaults`\n\nblocks are global and already apply to all repos). A quick `jq`\n\nbackfill, run from inside the container, copies the fields from an already-hardened entry onto any that lack them:\n\n```\ndocker compose exec -u cyrus cyrus sh -c '\ncd ~/.cyrus && cp config.json config.json.bak\nTOOLS=$(jq -c \".repositories[]|select(.name==\\\"TEMPLATE_REPO\\\").allowedTools\" config.json)\nLP=$(jq -c \".repositories[]|select(.name==\\\"TEMPLATE_REPO\\\").labelPrompts\" config.json)\njq --argjson tools \"$TOOLS\" --argjson lp \"$LP\" '\\''\n  .repositories |= map(\n    (if .allowedTools==null then .allowedTools=$tools else . end)\n    | (if .labelPrompts==null then .labelPrompts=$lp else . end))'\\'' config.json > config.json.new\njq empty config.json.new && mv config.json.new config.json'\n```\n\nConfig **hot-reloads** (`🔄 Config file changed, reloading...`\n\n); confirm with `docker compose logs cyrus | grep \"Managing\"`\n\n→ `📦 Managing N repositories`\n\n.\n\nWhen an issue fires, `RepositoryRouter`\n\nselects the target repo by the first rule that matches (higher wins):\n\n**Existing active session** for that issue.. Explicit and needs no Linear setup. Supports`[repo=<name>]`\n\ntag in the issue description**multiple** repos and an optional per-repo branch:`[repo=api] [repo=web#feature-x]`\n\ndispatches to both, with`web`\n\nbased off`feature-x`\n\n.**Routing labels**. A Linear label whose name matches the repo's`routingLabels`\n\n.`self-add-repo`\n\nsets these to the repo name, so applying the Linear label`api`\n\nroutes the issue to the`api`\n\nrepo.**Project-based**. Set`projectKeys`\n\non a repo entry to bind a Linear project to it.**Team-based**. Set`teamKeys`\n\n, or rely on the issue identifier prefix (`API-123`\n\n→ team`API`\n\n). Best when each repo maps to its own Linear team.**Catch-all**. A repo with** no**`routingLabels`\n\n/`teamKeys`\n\n/`projectKeys`\n\nbecomes the default. This only works if**exactly one** repo has no routing config; with several unconfigured repos it's ambiguous and the first one wins, so don't rely on it once you're multi-repo.**No match → Cyrus asks.** It posts a repository picker on the issue rather than guessing. Safe (no misroute), but it means an untagged issue stalls until you choose.\n\n**Practical guidance:** the two reliable, low-setup ways to target a repo are the ** [repo=<name>] description tag** (nothing to configure) or a\n\n**Linear label matching the repo name**(already wired by\n\n`self-add-repo`\n\n). For a workspace where each repo has its own Linear team, set `teamKeys`\n\nper repo and routing becomes automatic from the issue prefix. Edit any of these fields in `config.json`\n\n; they hot-reload.-\n`CLAUDE_CODE_OAUTH_TOKEN`\n\nis set and`ANTHROPIC_API_KEY`\n\nis absent everywhere (env file, host shell, image). - Token refresh is calendared.\n`CLAUDE_CODE_OAUTH_TOKEN`\n\nfrom`claude setup-token`\n\nis a ~12-month token; when it expires the agent silently stops authenticating. Re-mint (Step 1) and update`cyrus.env`\n\nbefore then. - GitHub token is a fine-grained PAT limited to the target repos only.\n- No production credentials, SSH keys, or cloud profiles live on this box or in the mounted volumes.\n-\n`config.json`\n\nhas top-level`sandbox.enabled: true`\n\nwith the`trusted`\n\npreset; the egress proxy shows as started in the logs. - The\n`~/.claude/settings.json`\n\n`allowRead`\n\nexposes only the**public**`cyrus-egress-ca.pem`\n\n, never the`-key.pem`\n\n. -\n`userAccessControl.allowedUsers`\n\nrestricts delegation to you. - Container runs as non-root with memory and pid limits.\n`seccomp`\n\n/`systempaths`\n\nare relaxed only because the egress sandbox needs bubblewrap. The container boundary, non-root user, and egress allowlist still apply. - Port 3456 is published only on\n`127.0.0.1`\n\n, never`0.0.0.0`\n\non the host; all real ingress goes through the tunnel, and the only public path that matters (`/linear-webhook`\n\n) is signature-verified. - If you use Cloudflare Access on the domain, the\n`cyrus.yourdomain.com`\n\nbypass (or`/linear-webhook`\n\nbypass) is in place.\n\n- Global default model =\n**Sonnet**(`CYRUS_CLAUDE_DEFAULT_MODEL=sonnet`\n\n). Biggest single lever. Reserve Opus for hard tickets via an`opus`\n\nlabel. - Tight, well-scoped Linear issues with file references cost far less than vague ones; the agent skips most exploration.\n`allowedTools`\n\nscoping and a`readOnly`\n\nscoper mode keep cheap triage cheap.- Keep each repo's\n`CLAUDE.md`\n\nlean; it's paid as input on every run before caching kicks in. - Watch your usage at\n`claude.ai/settings/usage`\n\n. On subscription auth you draw from your plan's limits rather than a metered API bill, so there are no surprise charges as long as no`ANTHROPIC_API_KEY`\n\nis present.\n\nRoughly in the order you'll hit them on a fresh build:\n\n. The named volume is root-owned. The provided Dockerfile fixes this by`EACCES: permission denied, mkdir '/home/cyrus/.cyrus/repos'`\n\n`mkdir`\n\n+`chown`\n\n-ing`.cyrus`\n\nand`.claude`\n\nbefore`VOLUME`\n\n. If you hit it on an existing volume, remove the empty volume (`docker volume rm cyrus-stack_cyrus-data`\n\n) and recreate from the fixed image.. Seed`self-auth-linear`\n\n: \"Config file not found … Run 'cyrus' first\"`config.json`\n\nfirst (Step 5b).**Tunnel returns 502 / \"connection reset by peer\" / \"connection refused\"**. Almost always missing`CYRUS_HOST_EXTERNAL=true`\n\n(Cyrus bound container-localhost). Confirm the startup log says`listening on http://0.0.0.0:3456`\n\n. Secondary cause:`localhost`\n\nresolving to IPv6 on the host. Set the tunnel Service to`http://127.0.0.1:3456`\n\n.**Browser hits a Cloudflare Access login page** at`cyrus.yourdomain.com`\n\n. A wildcard Access policy is intercepting it; add the Bypass (Step 3).. The`git clone`\n\n: \"could not read Username for '[https://github.com](https://github.com)'\"`gh auth git-credential`\n\nhelper isn't configured. The provided Dockerfile sets it system-wide; confirm`GH_TOKEN`\n\nis in`cyrus.env`\n\n.. Add`bwrap: No permissions to create new namespace`\n\n`seccomp=unconfined`\n\nto`security_opt`\n\n.. Add`bwrap: Can't mount proc on /newroot/proc: Operation not permitted`\n\n`systempaths=unconfined`\n\nto`security_opt`\n\n.. The sandbox isn't granting write to the shared worktree`git`\n\n: \"Read-only file system\" on`…/.git/worktrees/<ISSUE>/index.lock`\n\n`.git`\n\n. Ensure`~/.claude/settings.json`\n\nhas`sandbox.filesystem.allowWrite`\n\nfor`/home/cyrus/.cyrus/repos`\n\nand`/home/cyrus/.cyrus/worktrees`\n\n.. Add the public CA to`git push`\n\nfails TLS / agent says the egress CA isn't readable`sandbox.filesystem.allowRead`\n\n(`/home/cyrus/.cyrus/certs/cyrus-egress-ca.pem`\n\n), as in the provided`claude-settings.json`\n\n. Never expose the`-key.pem`\n\n..`No conversation found with session ID …`\n\nafter a restart`~/.claude`\n\nwasn't persisted. Ensure the`cyrus-claude`\n\nvolume is mounted (Step 4). Existing poisoned issues won't resume; trigger a new issue.**Agent keeps running Opus despite config**.`ANTHROPIC_MODEL`\n\n/`settings.json model`\n\nare no-ops; set`CYRUS_CLAUDE_DEFAULT_MODEL=sonnet`\n\nand`docker compose up -d`\n\n. Verify`\"cqo.model\":\"sonnet\"`\n\nin the logs.**Webhooks not arriving**. The URL must be`CYRUS_BASE_URL/linear-webhook`\n\n(the bare`/webhook`\n\nis deprecated). Check`docker compose logs cyrus`\n\nfor`webhook_received`\n\n. A`Rejected … unauthorized IP`\n\nline is normal for stray probes.**Multi-repo: issue isn't picked up / Cyrus posts a \"which repo?\" picker**. No routing rule matched (Step 10). Add a`[repo=<name>]`\n\ntag to the description or apply the Linear label matching the repo name.**Wrong repo gets picked**. Usually multiple repos left without routing config, so the catch-all is ambiguous; give each repo a distinct`routingLabels`\n\n/`teamKeys`\n\n. New repos added by`self-add-repo`\n\nlack`allowedTools`\n\n/`labelPrompts`\n\nuntil you backfill them (Step 10).**Billing went to the API account**. An`ANTHROPIC_API_KEY`\n\nleaked into the environment. Remove it and restart.**Agent stopped firing after months of working, logs show an auth failure.** The`CLAUDE_CODE_OAUTH_TOKEN`\n\nexpired (these are ~1-year tokens). Re-run`claude setup-token`\n\n(Step 1), update`cyrus.env`\n\n, then`docker compose up -d`\n\n.**Port conflict during**. Don't run the one-off auth while the main`self-auth-linear`\n\n`cyrus`\n\nservice is up on 3456. Do the auth before`docker compose up -d`\n\n.\n\n- Cyrus self-hosting guide:\n[https://github.com/cyrusagents/cyrus/blob/main/docs/SELF_HOSTING.md](https://github.com/cyrusagents/cyrus/blob/main/docs/SELF_HOSTING.md) - Cyrus config reference:\n[https://github.com/cyrusagents/cyrus/blob/main/docs/CONFIG_FILE.md](https://github.com/cyrusagents/cyrus/blob/main/docs/CONFIG_FILE.md) - Cyrus Cloudflare Tunnel notes:\n[https://github.com/cyrusagents/cyrus/blob/main/docs/CLOUDFLARE_TUNNEL.md](https://github.com/cyrusagents/cyrus/blob/main/docs/CLOUDFLARE_TUNNEL.md) - Cyrus Git & GitHub setup:\n[https://github.com/cyrusagents/cyrus/blob/main/docs/GIT_GITHUB.md](https://github.com/cyrusagents/cyrus/blob/main/docs/GIT_GITHUB.md) - Claude Code sandboxing:\n[https://code.claude.com/docs/en/sandboxing](https://code.claude.com/docs/en/sandboxing) - Claude Code headless docs:\n[https://code.claude.com/docs/en/headless](https://code.claude.com/docs/en/headless)", "url": "https://wpnews.pro/news/a-step-by-step-guide-to-running-cyrus-as-a-24-7-background-agent-on-your-own-box", "canonical_source": "https://gist.github.com/devondragon/28fcf0077ff6f225f347da8f28093977", "published_at": "2026-06-15 19:55:47+00:00", "updated_at": "2026-06-16 02:47:43.227996+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "large-language-models", "ai-infrastructure"], "entities": ["Cyrus", "Linear", "Claude Code", "Anthropic", "Docker", "Cloudflare", "AlmaLinux", "Max 20x"], "alternates": {"html": "https://wpnews.pro/news/a-step-by-step-guide-to-running-cyrus-as-a-24-7-background-agent-on-your-own-box", "markdown": "https://wpnews.pro/news/a-step-by-step-guide-to-running-cyrus-as-a-24-7-background-agent-on-your-own-box.md", "text": "https://wpnews.pro/news/a-step-by-step-guide-to-running-cyrus-as-a-24-7-background-agent-on-your-own-box.txt", "jsonld": "https://wpnews.pro/news/a-step-by-step-guide-to-running-cyrus-as-a-24-7-background-agent-on-your-own-box.jsonld"}}