A ticket delegated to an agent in your issue tracker starts a Claude Code session on your own machine within seconds. It ends as a PR, a commit or a deploy, whichever that repo's rules allow. Progress, questions and the final summary appear in the ticket thread, and replying there continues the same session.
Phone / laptop
│ file a ticket, set the agent as DELEGATE
▼
┌───────────────┐ webhook (AgentSessionEvent)
│ Linear (free) │ ─────────────────────────────┐
└───────────────┘ ▼
▲ thoughts, questions, summary Cloudflare edge <agent-host>.<domain>
│ │ tunnel (outbound from your machine)
│ ▼
│ ┌─────────────────────────────────────────────────────┐
└── │ always-on machine — service manager keeps Cyrus up │
│ ~/.cyrus/repos/<repo> one clone per repo │
│ ~/.cyrus/worktrees/<KEY-n> one per ticket │
│ claude ← the machine's own login, no API key │
└──────────────────────────┬──────────────────────────┘
▼ git push / gh pr create
GitHub ──▶ deploy only when told
Why this shape: nothing new to pay for (Linear free, Cyrus is open source, Cloudflare Tunnel is free, Claude runs on your existing subscription). The work also happens where your repos, test databases and deploy scripts already live.
| Input | Example placeholder |
|---|---|
| Linear workspace slug | <workspace> |
| One team key | <KEY> (tickets become <KEY>-1, <KEY>-2 …) |
| Public hostname for the webhook | <agent-host>.<domain> |
| Repos to connect | <github-user>/<repo> |
| Autonomy level per repo | PR-and-stop / commit-to-branch / agent-deploys |
Install Cyrus on the always-on machine: npm i -g cyrus-ai. Read its
docs/SELF_HOSTING.md and docs/CONFIG_FILE.md (in the cyrusagents/cyrus repo).
Use "community self-hosted" mode: you bring your own OAuth app, tunnel and Claude login.
Check: cyrus --version prints a version.
Create the Linear structure: one team, one project per repo. The free plan
caps teams at 2 but projects are unlimited. A team-per-repo layout stops working
at the third repo. Name each project after its repo (my-app → My App).
Check: every repo you plan to connect has a project with an exact, final name.
Routing matches this string.
Create a Linear OAuth app (Settings → API → Applications). Its webhook URL is
https://<agent-host>.<domain>/linear-webhook. The Cyrus repo can generate a
pre-filled app from a manifest URL, which avoids typos. Keep the client id, client
secret and webhook signing secret in Cyrus's .env only.
Check: the app appears as a user in the workspace (it is what you will delegate to).
Give the machine a public URL with a Cloudflare Tunnel. No port forwarding:
the tunnel daemon dials out to Cloudflare. Create a named tunnel, add a
proxied CNAME <agent-host> → <tunnel-id>.cfargotunnel.com, origin
http://localhost:3456. Put the tunnel token in .env as CLOUDFLARE_TOKEN, and
Cyrus starts cloudflared itself. You then have one service instead of two.
Check: later, curl -s https://<agent-host>.<domain>/status returns
{"status":"idle"}. HTTP 530 means the process behind the tunnel is down.
Fill ~/.cyrus/.env (secret; never print its values): CYRUS_BASE_URL,
CYRUS_SERVER_PORT=3456, LINEAR_DIRECT_WEBHOOKS=true, CYRUS_HOST_EXTERNAL=true,
CLOUDFLARE_TOKEN, LINEAR_CLIENT_ID, LINEAR_CLIENT_SECRET, LINEAR_WEBHOOK_SECRET.
No Claude key: sessions inherit the CLI login from the OS keychain.
Check: cut -d= -f1 ~/.cyrus/.env lists the key names (it prints names only).
Authorize the workspace: create ~/.cyrus/config.json containing
{"repositories":[]}, make sure port 3456 is free, run cyrus self-auth-linear,
and click Authorize in the browser.
Check: config.json now holds a linearWorkspaces entry. Don't print it; it holds tokens.
Run it as a service. macOS: a LaunchAgent with RunAtLoad + KeepAlive,
working directory ~/.cyrus, log to ~/.cyrus/cyrus.log. Linux: a systemd user
unit with Restart=always.
Check: curl -s localhost:3456/status → idle, and it survives a reboot.
Add each repo and route by project.
bash
cyrus self-add-repo https://github.com/<github-user>/<repo>.git
# self-add-repo also writes routingLabels:["<repo>"]; labels beat projects, so delete it:
jq '.repositories |= map(if .name=="<repo>"
then del(.routingLabels) | . + {projectKeys:["<Project Name>"]} else . end)' \
~/.cyrus/config.json > /tmp/c.json && mv /tmp/c.json ~/.cyrus/config.json
Routing priority, first match wins: routingLabels > projectKeys > teamKeys.
Check: grep "Config file changed" ~/.cyrus/cyrus.log | tail -1 (it hot-reloads).
jq -c '.repositories[]|{name,projectKeys,routingLabels}' ~/.cyrus/config.json shows no labels.
Write the autonomy rule into each repo's CLAUDE.md. The ticket and that file
are the only briefing a session gets. Pick one level per repo:
| Level | Use for | The agent may… |
|---|---|---|
| PR-and-stop | apps with users you care about | branch, test, open a PR, stop. Merges and deploys only when a human writes deploy on the ticket |
| Commit to develop | apps where you review a branch, not PRs | commit to develop; a human fast-forwards main and deploys |
| Agent deploys | demos, pitches, staging-only sites | commit, deploy, push main with Fixes <KEY>-n |
Spell out which words count as "deploy" ("deploy", "ship it", "make it live"), and
add: "'Done when it works' is not a deploy instruction; if unsure, ask in the thread."
An agent told "deploy only when asked" will read many things as asking.
Check: the rule is in CLAUDE.md on the default branch (Cyrus worktrees start from origin/<base>).
File the first ticket from inside the project page (so the project field is set). Set the Cyrus app as Delegate (not assignee). Keep it small: ```markdown Title: Settings: Log out asks before it logs you out
Add a confirm step to Log out in Settings.
Done when: - Tapping Log out shows "Log out?" with Cancel / Log out. - Cancel leaves you on Settings, still logged in. - The logout browser flow still passes.
Don't touch the route guard.
``
**Check:** a "thinking" line in the thread within ~10 s, real work (files read,
commands) at ~2 min, a worktree at~/.cyrus/worktrees/
Close the loop after a merge. Merging a PR on GitHub only moves main.
Nothing on your server watches GitHub, so a human (or a deploy reply) still has to
pull, apply any new migration to the local test DB, and run the deploy script.
Check: the live site shows the change, and the ticket has a comment naming the deployed commit.
| Symptom | Cause | Fix |
|---|---|---|
| Ticket assigned to the agent, nothing happens | agents live in the delegate field; the webhook fires on delegation | set Delegate. Via API it is delegateId; query delegate, not assignee |
| Re-delegating a ticket that errored does nothing | a ticket that already had a session never fires a second "created" event | reply in the thread (same session) or file a new ticket |
| "No routing match" in the log | the ticket has no project, or the project name ≠ projectKeys string |
create tickets from the project page; names must match exactly |
| Ticket went to the wrong repo | the auto-added routingLabels beat projectKeys |
delete routingLabels after every self-add-repo |
self-auth-linear exits with "Missing authorization code", then Linear's redirect 502s |
you (or a health check) hit /callback without a code while it waited |
never touch /callback; restart self-auth and reload the authorize tab |
| Public URL returns 530 | Cloudflare has the hostname, nothing answers behind it | the Cyrus process is down; check the service, not DNS |
| Second Cyrus "fails with a config error" | port 3456 already belongs to the service | restart the service instead of starting another copy |
| Sessions "parked" forever | a Linear blocked-by relation parks a delegated ticket; parked sessions live only in memory and wake only when that exact blocker goes Done or a human replies (the app's own replies don't count). A restart drops them | set relations correctly before delegating; after fixing them, restart and file fresh "Continue" tickets |
| Sessions stop with "session limit" errors | the Claude usage window is shared with your own sessions; parallel Opus sessions exhaust it fast | one ticket at a time; small tickets; pick cheaper models via a tag or label if your runner supports it |
| API calls start returning 401 | the OAuth access token expired | use the stored refresh token against Linear's /oauth/token, write both new tokens back without printing them, restart while idle |
| App token gets 403 on some admin calls | the app token can create issues, projects, workspace labels and teams, but not rename/delete teams or create team labels | do those few in the Linear UI |
Fixes <KEY>-n commit did not close the ticket |
Linear's "link commits to issues" is not perfectly reliable | check, and close by hand |
| Work left in unpushed worktrees after a crash | a dead session keeps no memory; a follow-up ticket restarts from the ticket text | list ~/.cyrus/worktrees/* with git status, push or salvage by hand |
It is tempting to split a whole project into phase tickets linked by "blocked by", then let a small cron/launchd script delegate each next ready ticket and file "Continue" tickets when a session stops early. In practice this failed within a day. Parallel phases hit the usage limit, sessions died mid-phase with unpushed work, restarts dropped parked sessions, and the log turned into "NEEDS HUMAN" every two minutes.
The rule that came out of it: one small, checkable change per ticket. A phase of
work is not a ticket; it is an interactive session you sit in. If you do run a chain,
give it a kill switch (rename the service file to .disabled), cap it at one active
ticket, and detect the usage-limit error so it does not count as a retry.
curl localhost:3456/status and https://<agent-host>.<domain>/status both answerprojectKeys entry and no routingLabelsCLAUDE.md states its autonomy level and what "deploy" means