kitdocs · github

Agent pipeline — tickets in, pull requests out

raw markdown workflows/agent-pipeline.md verified 2026-09-26

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

Inputs

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

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://<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).

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

  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/<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.

  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 <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>).

  1. 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.

  2. 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 <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

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