Linear CLI + claude -p: the poor-man's background agent in 30 lines of shell
If you want a Claude Code agent triggered by Linear issues, you have two paths. The full one is a hosted agent with isolated worktrees, mid-run approvals, and streaming events back to the issue — this is the shape Cyrus builds. The fast one is a 30-line shell loop that polls Linear, pipes the issue body into claude -p, and posts the result back as a comment. It works. It's the pattern every team tries first. Here's how to build it, where it breaks, and when you graduate out of it.
The Linear CLI you want
The community CLI worth installing is @schpet/linear-cli. It's git-aware, shell-friendly, and every command you need for the agent loop is a one-liner.
# Install (pick one)
brew install schpet/tap/linear
npm install -g @schpet/linear-cli
deno install -A --reload -f -g -n linear jsr:@schpet/linear-cli
# Auth + per-repo config
linear auth login
cd my-project-repo
linear configCreate an API key at linear.app/settings/account/security. You need workspace member access to create one.
The three commands the agent loop uses
You only need three Linear CLI commands to build the loop:
# 1. List your unstarted issues (assigned to you, not yet in progress)
linear issue mine
# 2. View a specific issue's full body + metadata
linear issue view ABC-123
# 3. Post a comment on the current branch's issue
linear issue comment addBonus: linear issue start ABC-123transitions an issue to “In Progress” and checks out the right branch. Useful inside the loop when you want to claim the issue before working it.
The 30-line shell loop
This is the minimal thing that actually works. It polls for new issues, runs Claude Code headless against each one, posts the result as a Linear comment, and transitions the issue.
#!/usr/bin/env bash
# background-claude.sh — poor-man's Linear agent
set -euo pipefail
TEAM=${LINEAR_TEAM_ID:-ENG}
POLL_INTERVAL=60 # seconds between polls
MAX_BUDGET=3 # USD per issue
MAX_TURNS=10
while true; do
# 1. Ask Linear for your queued issues (JSON)
issues=$(linear issue mine --format json 2>/dev/null || echo "[]")
# 2. Pick the first with label:agent-ready
ident=$(echo "$issues" \
| jq -r '.[] | select(.labels[]?.name == "agent-ready") | .identifier' \
| head -n1)
if [ -n "$ident" ]; then
echo "[$(date -u +%FT%TZ)] picking up $ident"
# 3. Pull the full body
body=$(linear issue view "$ident" --format json | jq -r '.description')
# 4. Transition + checkout
linear issue start "$ident"
# 5. Hand to claude -p headless
result=$(claude -p "$body" \
--output-format text \
--permission-mode dontAsk \
--max-turns "$MAX_TURNS" \
--max-budget-usd "$MAX_BUDGET")
# 6. Post the result back
echo "$result" | linear issue comment add --stdin
# 7. Never re-pick this issue
linear issue update "$ident" --remove-label agent-ready
fi
sleep "$POLL_INTERVAL"
doneThirty lines. Assumes you have jq, linear, and claudeon PATH, and you're authenticated to all three. The agent-ready label is what turns “any issue assigned to me” into “any issue I want the agent to handle.” Move the label on, it runs. Move it off, it doesn't.
Why this works at all
Three things make the loop viable without extra infrastructure:
claude -pis stateless. Each invocation is a clean session — no hook baggage, no keychain prompts in --bare mode. You can spawn and forget.--max-budget-usdcaps the blast radius. A confused Claude can't drain your monthly budget on one issue — the agent stops at $3 or whatever you set.- Linear labels are cheap state.You don't need a database. The
agent-readylabel is the queue, the absence of the label is the dead-letter. Everything is observable inside the Linear UI.
Four things the shell loop breaks on
I have run something close to this pattern. You should know exactly where it falls over before you rely on it for anything real.
1. Concurrent issues fight over the working directory
The moment two issues arrive within POLL_INTERVAL seconds of each other, both invocations of Claude will checkout different branches in the same repo, and the second one will either fail or silently destroy the first one's work. The fix is git worktree isolation — one worktree per issue — which adds another 50 lines of bash and a directory layout convention you now have to maintain.
2. No live feedback while the agent runs
The user opened the Linear issue, added the agent-readylabel, and walked away. The agent thinks for 6 minutes, posts one enormous comment with the result, done. If Claude hits a mid-run question (“this is ambiguous, should I A or B?”), either you wired permission modes to auto-accept everything (risky), or it blocks forever. There is no streaming status back to Linear. This is fine for demos, but unacceptable for real work where humans want to course correct.
The right answer is stream-json output format plus a bridge that converts events into Linear comments or reactions in near-real-time. That's another 100 lines of code and a persistent service.
3. The polling loop is the wrong primitive
sleep 60in a while loop means: up to 60 seconds of latency between the label change and the agent picking up, the process holds a shell open (restart on reboot is your problem), and you're hitting Linear's API every minute whether there's work or not. Linear has webhooks specifically to avoid this. Wiring webhooks means: public endpoint, HMAC verification, a queue, and a worker. You're four services deep now.
4. There is no audit trail
Where did the agent run? What version of Claude, what model, what budget, what tools did it invoke, what files did it change, what was the final cost? On the shell loop, the answer is “my laptop” and “no idea.” For anything security-sensitive or team-shared, this is a non-starter. You'd need a structured log sink, cost tracking per-run, and a way to go back and replay a specific invocation. That's another day of work.
When the shell loop is the right answer
- You're the only user. Single-player mode, one concurrent run, no team audit trail needed.
- Issues are low-stakes.“Summarize this PR,” “draft a reply,” “update the README.” Low cost of failure, easy to human-verify.
- You want to learn the patterns. Every piece of the loop — polling, headless, budget caps, structured output — is part of the real background agent stack. Building the toy loop teaches you what the production version needs.
When you graduate out
- The second concurrent issue. The worktree problem shows up on day one of multi-issue use.
- The first mid-run ambiguity. When Claude needs to ask “should I A or B?” and the answer matters.
- The first security review. Your team will ask for audit, cost, and isolation — all three.
- More than one channel. The moment you want GitHub issues, GitLab MRs, or Slack to trigger the same kind of run, the shell loop becomes N shell loops.
That list is the exact shape of a real background agent, and it's what Cyrus is — a long-running service that subscribes to Linear webhooks, runs Claude Code (or Codex, Cursor, Gemini) in per-issue git worktrees, streams events back into the issue, handles mid-run approvals, and gives you a real audit trail. The shell loop is a great first step. Cyrus is what day two looks like.
Takeaways
@schpet/linear-cli+claude -p+ while-loop +jq= a real, working Linear agent in 30 lines.- You need exactly three CLI commands:
linear issue mine,linear issue view,linear issue comment add. - It breaks on: concurrency, mid-run feedback, polling inefficiency, and audit/observability — in that order.
- Use it to learn the pattern. Graduate when you hit two concurrent issues or need mid-run human review.
Webhooks. Worktrees. Audit. Mid-run approvals.
Cyrus runs Claude Code (or Codex, Cursor, Gemini) in isolated git worktrees per Linear issue, streams agent activity back to the issue as it happens, supports mid-run approvals, and gives you a real audit trail. Community self-hosted is free forever.
Try Cyrus free →