Tickets in, pull requests out: an agent pipeline on a free Linear plan
Linear, self-hosted Cyrus and Claude Code on the Mac — how a ticket becomes a PR, the rules each repo runs on, and the week it took to learn what not to hand an agent
By the middle of September I had four small apps on one server and more ideas than evenings. What I wanted was to run it like a company: write a ticket, have someone do it, review the result. The "someone" is Claude Code. The missing piece was the plumbing between a ticket and a session.
Linear has that plumbing built in — on a paid plan. This is the free version: Linear's
free workspace, the open-source agent runner Cyrus, and the claude login already
on my Mac. Set up on 15 September, extended to a fourth repo on 18 September, and
reined back in on 19 September after the most ambitious use of it failed.
What this covers
- The shape: Linear → webhook → a tunnel → Cyrus on the Mac → Claude Code → GitHub
- Why it is one team and one project per repo, not one team per repo
- Delegate, not assignee — the field that actually starts an agent
- Three repos, three different rules for how far an agent may go
- Why merging an agent's PR does not put anything live
- An automatic chain of tickets, and why I switched it off after a day
- The traps, most of which fail silently
The shape of it
Phone / laptop
│ write a ticket, set Cyrus as delegate
▼
┌──────────────┐ webhook (AgentSessionEvent)
│ Linear (free)│ ───────────────────────────────┐
└──────────────┘ │
▲ progress, questions, summary ▼
│ ┌────────────────────────┐
│ │ Cloudflare edge │
│ │ cyrus.oyware.com │
│ └───────────┬────────────┘
│ │ tunnel (outbound from the Mac)
│ ▼
│ ┌──────────────────────────────────────────────────┐
└──── │ Mac — launchd keeps Cyrus up on localhost:3456 │
│ ~/.cyrus/repos/<repo> one clone per repo │
│ ~/.cyrus/worktrees/OYW-n one per ticket │
│ claude ← the Mac's own login, no API key │
└──────────────────────┬───────────────────────────┘
│ git push / gh pr create
▼
GitHub ──▶ (deploy only when told)
Three properties made this worth building:
| Property | Why it matters |
|---|---|
| Nothing new to pay for | Linear free, Cyrus is MIT-licensed, the tunnel is free, Claude runs on the subscription I already have |
| The work happens on my machine | the same repos, tools, test databases and deploy scripts I use by hand |
| The ticket is the conversation | Cyrus posts its thinking, questions and summary into the issue thread; replying there continues the same session |
The cost is that the Mac must be awake. That was already true — it is set up as an always-on machine driven from the phone.
Cyrus: the runner
Cyrus (npm i -g cyrus-ai, version 0.2.71 here) is a small server that receives
Linear's agent webhooks, creates a git worktree per issue, runs Claude Code inside it,
and streams what Claude does back into the issue as "agent activities".
It runs in its "community self-hosted" mode, which means three things are yours:
- A Linear OAuth app. Created in Linear's settings (Settings → API → Applications), with its webhook pointing at your public URL. Cyrus shows up in the workspace as an app user named after it.
- A public URL. Linear has to reach the Mac. That is the tunnel, below.
- The Claude login. No API key is configured at all. Sessions inherit the
claudeCLI's login from the macOS keychain, so they run on my subscription — and share its usage window with everything else I do.
Configuration is two files, and one of them is secret:
| File | Holds |
|---|---|
~/.cyrus/config.json |
the repositories and how tickets route to them, plus the workspace's OAuth tokens |
~/.cyrus/.env |
the tunnel token, the OAuth client id/secret, the webhook signing secret |
Both hot-reload: edit the routing and the log says Config file changed, reloading,
no restart.
Kept alive by launchd
A LaunchAgent with RunAtLoad and KeepAlive runs it at login and restarts it if it
dies:
launchctl print gui/$(id -u)/com.example.cyrus | grep -E "state|pid"
launchctl kickstart -k gui/$(id -u)/com.example.cyrus # restart
curl -s localhost:3456/status # {"status":"idle"} or "busy"
Don't start a second copy by hand to "see what it does". Port 3456 already belongs to the service, and the second one fails in a way that looks like a configuration problem. Restart the service instead.
The tunnel: a public URL without opening a port
The Mac is behind a home router with no public address, and I was not going to
forward a port to it. A Cloudflare Tunnel reverses the direction: a small daemon on
the Mac (cloudflared) dials out to Cloudflare and keeps the connection open;
requests for the hostname ride back down it.
| Tunnel | one named tunnel for the Mac |
| DNS | cyrus → CNAME <tunnel-id>.cfargotunnel.com, proxied |
| Origin | http://localhost:3456 |
Who runs cloudflared |
Cyrus itself — give it CLOUDFLARE_TOKEN in .env and it spawns the daemon |
That last row saved a service. There is no separate cloudflared LaunchAgent to keep in step with Cyrus; if Cyrus is up, the tunnel is up.
Checking it from outside is the same status call through the tunnel:
curl -s https://cyrus.example.com/status
# {"status":"idle"} — working
# HTTP 530 — Cloudflare has the hostname, but nothing is on the other end
A 530 there almost always means the Cyrus process is down, not that the DNS is wrong.
Linear: one team, one project per repo
The first layout I built was the obvious one: a Linear team per repo, so GYM-7 is
obviously the gym tracker. It lasted an hour. The free plan allows two teams. With
four repos that was never going to fit.
What Linear's free plan does not limit is projects. So:
| Linear | Maps to |
|---|---|
one team, Oyware, key OYW |
the whole workspace; every ticket is OYW-n |
| project Gym Tracker | gym-tracker repo |
| project Episode Tracker | episode-tracker repo |
| project Triplehit Sessions | triplehit-sessions repo |
| project Burrito District Website | burrito-district-website repo (added 18 Sep) |
Cyrus routes on it. Each repository in config.json carries the name of the project
that feeds it:
{
"name": "gym-tracker",
"repositoryPath": "~/.cyrus/repos/gym-tracker",
"baseBranch": "main",
"projectKeys": ["Gym Tracker"]
}
Routing checks routingLabels, then projectKeys, then teamKeys, and the first match
wins. I use projectKeys only.
cyrus self-add-repoquietly adds a label rule. Registering a repo writesroutingLabels: ["<repo>"]into its entry, and labels beat projects. A stray label on a ticket could send it to the wrong repo. Delete that field after every add:
bash jq '.repositories |= map(if .name=="gym-tracker" then del(.routingLabels) | . + {projectKeys:["Gym Tracker"]} else . end)' \ ~/.cyrus/config.json > /tmp/c.json && mv /tmp/c.json ~/.cyrus/config.json
A ticket with no project does not guess. Cyrus asks in the thread which repository to use. That is the right failure: a question, not a pull request against the wrong codebase. The habit that avoids it is creating tickets from inside the project's page, so the field is already filled.
Adding a fifth repo is now about two minutes: cyrus self-add-repo <git url>, create a
Linear project with the same name, set projectKeys, delete routingLabels.
Delegate, not assignee
The thing that starts an agent is not the field you would guess.
Linear treats agents differently from people. A ticket has an assignee (a human who
owns it) and, separately, a delegate (an agent working it on their behalf). Setting
Cyrus as the delegate is what fires the AgentSessionEvent created webhook. So a ticket
I file for myself looks like:
| Field | Value |
|---|---|
| Assignee | me |
| Delegate | Cyrus |
| Project | the repo's project |
Pick Cyrus in the assignee menu in the UI and Linear moves it to the delegate field for
you. Through the API it is delegateId, and if you query assignee to check who is
working a ticket you will find nobody.
How fast. Creating a ticket with the delegate already set, the Issue create and
the agent session event land in the same second. The first "thinking" line appears in
the thread about 10 seconds later; real work — files read, commands run — starts around
two minutes in.
Re-delegating does not restart anything. Changing the delegate on a ticket that already had an agent session is silently ignored — no new webhook. To have another go, reply in the thread (same session, same branch) or file a new ticket.
Each repo sets its own limit
The same pipeline feeds four repos with different stakes, so "what may the agent do
without me" is decided per repo — and written into that repo's CLAUDE.md, because
the ticket and that file are the only briefing a session gets.
| Repo | Who uses it | The agent may… | Then |
|---|---|---|---|
| gym-tracker | me and a few friends | branch, test, open a PR and stop | I reply deploy on the ticket, or merge and deploy myself |
| episode-tracker | real users | commit straight to develop, no PR |
I say "deploy tracker": fast-forward main, run the deploy |
| triplehit-sessions | a pitch, no users yet | commit, deploy itself, push main |
the commit's Fixes OYW-n closes the ticket |
| burrito-district-website | staging only | small single tasks | see the chain, below |
"Open a PR and stop"
On 18 September I wrote the gym tracker's limit down explicitly. It reads, in the repo's
CLAUDE.md:
## Linear tickets (Cyrus): PR by default, deploy only when told
- **Default: open a PR and stop.** Do not merge it and do not deploy, even when
everything passes. End the summary with: "Not deployed. Reply `deploy` on this
issue to merge and ship it."
- **Deploy only when Omar tells you to on the issue**, in the description or in a
reply in the thread ("deploy", "ship it", "make it live").
"Done when it works" or "test it" is not a deploy instruction.
If you are not sure, don't deploy; ask in the thread.
A later deploy reply on the thread continues the same session, which then follows a
numbered list: tests and browser flows pass, gh pr merge --squash, check out the
merged origin/main (not the branch), apply any new migration to the test database,
run the deploy script, run the touched flow on production as the test account, and
comment the deployed commit on the ticket.
The explicit list of phrases matters. An agent that is told "deploy only when asked" will read a lot of things as asking.
Merging a PR does not deploy it
This one catches me, not the agent. Merge a Cyrus PR on GitHub and nothing appears
on the site, and nothing can: merging only moves main on GitHub. Nothing on the server watches GitHub. The
deploy is a script run from a checkout.
So after a merge done by hand, someone still has to:
cd ~/gym-tracker && git pull
# if the PR added a Prisma migration, the local test DB needs it first,
# or the deploy's test step fails with 500s everywhere:
cd api && npx prisma generate && set -a && . ./.env.test && npx prisma migrate deploy && cd ..
./scripts/deploy.sh
PRs #4 and #5 went live together that way, as one deploy, on 18 September. It is also why the
deploy reply path above exists: it keeps "merged" and "live" as one step.
Closing tickets without a PR
For the repos that commit straight to a branch, there is no PR to close the ticket.
Linear's GitHub integration has "Link commits to issues": a commit on the default
branch whose message contains Fixes OYW-n moves the ticket to Done.
It is not perfectly reliable. On 19 September a
Fixes OYW-21commit reachedmainand the ticket stayed open. Check, and close by hand when it doesn't.
The tally so far
From 15 to 25 September the log shows 25 tickets that got an agent session. On the gym tracker, five PRs came from Cyrus:
| Ticket | PR |
|---|---|
| OYW-6 | README: a "Working with agents" section |
| OYW-7 | Settings: Log out asks before it logs you out |
| OYW-9 | Screenshot import: reads a plan written out by hand |
| OYW-12 | Machine number: type one by hand, and long names stay in their column |
| OYW-13 | Settings: a profile of who you are and what you train for |
All five merged. Each is one screen, one behaviour, checkable in a minute. That turned out to be the whole lesson.
The chain: tickets that start each other
The Burrito District website was going to be the big test. On 18 September I split the build into ten phase tickets — scaffold and staging, design system, menu page, home page, scroll hero, Arabic, quality gate, domain, hand-over — linked with Linear's "blocked by" relations. I wanted to write them once and not review anything until the end.
Cyrus does not chain tickets by itself, so a small script did: chain.py, run every
two minutes by its own LaunchAgent.
DEPS = {
'OYW-15': [], # S1 scaffold + staging
'OYW-16': ['OYW-15'], # S2 design system
'OYW-17': ['OYW-16'], # S3 /menu/ } could run
'OYW-18': ['OYW-16'], # S4 home } in parallel
'OYW-19': ['OYW-16'], # S5 scroll hero }
'OYW-21': ['OYW-17', 'OYW-18', 'OYW-19'], # S7 quality gate
}
Each tick it asked Linear which tickets were Done (or had a Fixes OYW-n commit on
origin/main), delegated the next ready one to Cyrus, and — the ambitious part — if a
session ended without finishing, filed a "Continue OYW-n" ticket quoting where it
stopped and told it to resolve the blocker itself. Three retries, then it logged
NEEDS OMAR. It never touched the phases that needed the restaurant's answers or a
real domain. A [model=opus] or [model=fable] tag in each description picked the
model.
The first phases went through unattended. Then it ran into everything at once:
| What happened | Why |
|---|---|
| Parallel phases ran into the Claude usage limit | the 5-hour window is shared; several Opus sessions plus mine exhaust it fast |
| Sessions stopped mid-phase with work left in unpushed worktrees | a session that dies leaves no memory; a "Continue" ticket starts again from the ticket text |
| Restarts cost the sessions that were waiting | parked sessions live only in memory (trap below) |
| Tickets sat "parked" and never woke | see the trap below |
By 00:40 on 19 September the log read, every two minutes:
OYW-17 NEEDS OMAR (3 follow-ups did not finish it); OYW-18 NEEDS OMAR (…); OYW-19 NEEDS OMAR (…)
I first cut it to one ticket at a time to stay inside the usage limit. Later that day I
moved the phases out of Linear entirely, finished them in an interactive session (phases
1–5 and 7 went live on staging that day), and disabled the job by renaming its plist to
.disabled.
"Blocked by" parks sessions, and parked sessions are fragile. When a delegated ticket is blocked by another, Cyrus logs
Session parked: issue X is blocked by Y. Parked sessions live only in memory. They wake when that exact blocker goes Done, or when a human replies in the thread — a reply posted by the app's own token does not count. Restart Cyrus and they are gone. If the relations are wrong when you delegate, the tickets wait forever. Get the relations right before delegating.
The rule that came out of it, now in the operator's notes for every repo: don't hand Cyrus multi-hour, multi-agent work. One small, checkable change per ticket. A phase is not a ticket; a phase is a session I sit in.
What a good ticket looks like now:
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.
- scripts/browser-flows/landing.mjs still passes.
Don't touch the logout timing in RequireAuth.
Title is the change. A Done when: list of two to four lines that can be checked,
including which test must still pass. What not to touch. Whether to deploy.
Traps, in the order I hit them
Hitting the OAuth callback yourself kills the login. cyrus self-auth-linear starts
a listener and waits for Linear to redirect to /callback?code=…. I curled /callback
to check the tunnel worked. Any request without a code makes it exit with "Missing
authorization code" — so when I then clicked Authorize, Linear's redirect hit nothing
and got a 502. The one-time code is still in the browser tab's URL: restart
self-auth-linear and reload the tab.
self-auth-linear has preconditions it doesn't state. It needs ~/.cyrus/config.json
to exist ({"repositories":[]} is enough) and port 3456 free — so stop the service
first. It also opens the browser with open, which spawns a new window; a tiny open
shim earlier on PATH that logs the URL instead let me paste it into an existing tab.
Linear's Copy buttons only copy when the browser is in front. The OAuth app's client secret and webhook signing secret sit behind Copy buttons. Scripted clicks did nothing until the window was frontmost and the button was focused before the click. Rotating a secret does not reveal it, so the Copy button is the only way.
The app token can do most admin, not all. Through the API it can create issues, projects, teams and workspace labels. Renaming or deleting a team and creating team labels return 403 — those are done in the UI.
Transient 503s from Linear. Once during setup Cyrus failed to post an activity with a 503. It wrote the error into the thread and carried on; the session was fine.
Usage limits show up as session errors. You've hit your session limit · resets …
appears in cyrus.log as Session error. Nothing is broken; nothing will resume either
until the window resets and someone replies in the thread.
Tokens expire. On 25 September API calls with the stored token started returning
AUTHENTICATION_ERROR 401 and cyrus check-tokens hung. No re-authorisation needed: a
POST to Linear's /oauth/token with grant_type=refresh_token, the stored refresh
token and the app's client id/secret returns a new pair; write both back into
config.json and restart Cyrus while it is idle.
Commits carry whoever gh is. Pushes use the keychain credential helper that
gh auth login set up, so the agent pushes as me — and with whatever user.email
git has globally. Check that before the first ticket, or the history fills up with the
wrong address.
When a ticket doesn't start
The same five checks, in order, every time:
curl localhost:3456/status— no answer means the service is down (launchctl print).grep webhook_received ~/.cyrus/cyrus.log | tail -3— nothing new means Linear never called: is the ticket delegated, not just assigned? Does the public URL give 530?No routing matchin the log — the ticket has no project, or the project's name does not exactly equal theprojectKeysentry.- Session started but quiet — read
~/.cyrus/logs/OYW-n/session-*.md, the readable transcript. The first real output takes about two minutes. Session parked— a "blocked by" relation. Fix the relation or reply in the thread.
Was it worth it
For what it is good at, yes. Filing "the logout button should ask first" from the gym floor and finding a tested PR waiting later is exactly what I wanted, and it cost nothing beyond the subscription.
It is not a team. It has no memory between sessions except what is in the repo, it shares one usage window with me, and it cannot tell when a task is too big for it. The work that went well was the work I would have given a new colleague on their first day: small, specific, with a way to check it. The work that went badly was the work I would not have given anyone without sitting next to them.
The server side of all four apps is in Self-hosting a web app on a $6 droplet and The first week live; the gym tracker the PRs landed in is What the gym tracker is built from.