--- title: Agent pipeline — tickets in, pull requests out summary: Turn issue-tracker tickets into Claude Code sessions on your own machine (Linear free plan + self-hosted Cyrus), with a per-repo rule for how far the agent may go when_to_use: You have several small repos, want to file work from a phone and review it later, and have one always-on machine (Mac or Linux box) with Claude Code logged in prerequisites: A GitHub account with the gh CLI logged in; Claude Code installed and logged in on the machine; a domain on Cloudflare (for the tunnel); Node 20+; a Linear workspace (free) related: [skills/agent-pipeline/SKILL.md, workflows/parallel-agents.md, workflows/browser-flow-testing.md, reference/CLAUDE.md.template, reference/working-with-ai.md] last_verified: 2026-09-26 --- # Agent pipeline — tickets in, pull requests out ## Goal 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 . │ │ tunnel (outbound from your machine) │ ▼ │ ┌─────────────────────────────────────────────────────┐ └── │ always-on machine — service manager keeps Cyrus up │ │ ~/.cyrus/repos/ one clone per repo │ │ ~/.cyrus/worktrees/ 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. ## Inputs | Input | Example placeholder | |---|---| | Linear workspace slug | `` | | One team key | `` (tickets become `-1`, `-2` …) | | Public hostname for the webhook | `.` | | Repos to connect | `/` | | Autonomy level per repo | PR-and-stop / commit-to-branch / agent-deploys | ## Steps 1. **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. 2. **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. 3. **Create a Linear OAuth app** (Settings → API → Applications). Its webhook URL is `https://./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). 4. **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 `` → `.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://./status` returns `{"status":"idle"}`. HTTP **530** means the process behind the tunnel is down. 5. **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). 6. **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. 7. **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. 8. **Add each repo and route by project.** ```bash cyrus self-add-repo https://github.com//.git # self-add-repo also writes routingLabels:[""]; labels beat projects, so delete it: jq '.repositories |= map(if .name=="" then del(.routingLabels) | . + {projectKeys:[""]} 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. 9. **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 -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/`). 10. **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/`, then a PR. 11. **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. ## Traps | 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 -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 | ### Automatic ticket chains, and when to switch them off 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. ## Done when - [ ] `curl localhost:3456/status` and `https://./status` both answer - [ ] The service restarts on its own after a reboot - [ ] Every connected repo has a Linear project with an exact matching `projectKeys` entry and no `routingLabels` - [ ] Every connected repo's `CLAUDE.md` states its autonomy level and what "deploy" means - [ ] A test ticket went from delegation to PR (or commit) without touching the machine - [ ] You know the post-merge deploy step for each repo, and it is written down