oyware [email protected]

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

2026-09-15Linear (free), Cyrus 0.2.71, Claude Code, Cloudflare Tunnel, launchd, GitHub

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 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:

  1. 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.
  2. A public URL. Linear has to reach the Mac. That is the tunnel, below.
  3. The Claude login. No API key is configured at all. Sessions inherit the claude CLI'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-repo quietly adds a label rule. Registering a repo writes routingLabels: ["<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-21 commit reached main and 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:

  1. curl localhost:3456/status — no answer means the service is down (launchctl print).
  2. 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?
  3. No routing match in the log — the ticket has no project, or the project's name does not exactly equal the projectKeys entry.
  4. Session started but quiet — read ~/.cyrus/logs/OYW-n/session-*.md, the readable transcript. The first real output takes about two minutes.
  5. 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.