Skip to content

Instantly share code, notes, and snippets.

@dylancwood
Created March 21, 2026 20:00
Show Gist options
  • Select an option

  • Save dylancwood/4c5728626050a1c288ee18d4c3c2a9ab to your computer and use it in GitHub Desktop.

Select an option

Save dylancwood/4c5728626050a1c288ee18d4c3c2a9ab to your computer and use it in GitHub Desktop.
Linear Daemon for Claude Code — polls Linear for labeled issues and spawns autonomous Claude Code agents to implement them

Linear Daemon for Claude Code

A bash daemon that polls Linear for labeled issues and spawns autonomous Claude Code instances to implement them.

How It Works

  1. Polls Linear's GraphQL API every 60 seconds
  2. Finds issues with a configurable label (default: claude) in Todo status
  3. Spawns a claude -p instance in an isolated git worktree for each issue
  4. The Claude agent autonomously: reads the issue, creates a plan, implements the fix, runs tests, merges to main, and pushes
  5. Marks the issue as Done in Linear and comments with the transcript path
  6. If the agent fails or times out, the issue is moved back to Todo

Up to 5 concurrent workers. 30-minute timeout per worker. Graceful shutdown moves in-progress issues back to Todo.

Prerequisites

Setup

  1. Copy env.example to .env and fill in your values:
cp env.example .env

You'll need your Linear team ID and workflow state IDs. The easiest way to find these:

  • Team ID: Open your team settings — it's in the URL
  • State IDs: Open browser dev tools, go to the Network tab, and watch for GraphQL responses when viewing your board — or use the Linear API explorer
  1. Create a label in Linear (default: claude) that you'll use to tag issues for the daemon.

Usage

# Start the daemon in the background
./linear-daemon.sh start

# Check status and active workers
./linear-daemon.sh status

# Stop the daemon (gracefully moves in-progress issues back to Todo)
./linear-daemon.sh stop

# Restart
./linear-daemon.sh restart

# Run in foreground (for debugging)
bash linear-daemon.sh _run

Assigning Work

  1. Create (or find) a Linear issue in your team
  2. Add the trigger label (default: claude)
  3. Set the status to Todo
  4. The daemon picks it up on the next poll cycle (within 60 seconds)

Important: Only label issues that are well-defined and scoped. The daemon works best on tasks with clear acceptance criteria. Complex or ambiguous work should stay for human handling.

What the Agent Does

For each issue, the spawned Claude Code agent:

  1. Reads the issue and explores the codebase
  2. Creates an implementation plan
  3. Executes the plan (using subagents for multi-step work if available)
  4. Debugs systematically if it hits test failures
  5. Runs all relevant tests
  6. Merges the worktree branch into main and pushes

If the agent decides the task is too complex or it can't proceed, it comments on the Linear issue explaining why and moves it back to Todo.

Logs

  • Daemon log: logs/daemon.log — polling activity, worker lifecycle events
  • Transcripts: logs/PROJ-XXX.jsonl — full Claude Code session for each issue (stream-json format)

Monitor a transcript in real time:

tail -f logs/PROJ-*.jsonl | jq -r '.type + ": " + (.message.content[0].text // .subtype // "")'

Configuration

Variable Default Description
POLL_INTERVAL 60 Seconds between poll cycles
MAX_WORKERS 5 Maximum concurrent Claude instances
WORKER_TIMEOUT 1800 Seconds before killing a stalled worker (30 min)
LINEAR_LABEL claude Label that triggers daemon pickup

Shutdown Behavior

When stopped (via stop or SIGTERM):

  1. All in-progress Linear issues are moved back to Todo
  2. A comment is added to each issue noting the daemon was stopped
  3. Worker processes are terminated gracefully (SIGTERM, then SIGKILL after 10s)
  4. Worktree directories are cleaned up

My Workflow

I use this with Claude mobile to brainstorm features and bug fixes on the go. I talk through ideas with Claude, have it create Linear issues, and label the well-defined ones for autonomous implementation. The daemon picks them up, implements them, and pushes to staging where I can test.

To keep the Linear connection on Claude mobile stable (MCP OAuth tokens expire frequently), I use Bindify — an auth proxy I built that handles token refresh so the connection stays permanent.

License

MIT

# Linear API key (create at: https://linear.app/settings/api)
LINEAR_API_KEY=lin_api_YOUR_KEY_HERE
# Linear team ID (find in team settings URL)
LINEAR_TEAM_ID=your-team-uuid
# Linear workflow state IDs (find via Linear API or browser network tab)
LINEAR_STATUS_TODO=your-todo-state-uuid
LINEAR_STATUS_IN_PROGRESS=your-in-progress-state-uuid
LINEAR_STATUS_DONE=your-done-state-uuid
# Optional: assign completed issues to a specific user
# LINEAR_OWNER_ID=your-user-uuid
# Optional: change the label that triggers pickup (default: "claude")
# LINEAR_LABEL=claude
#!/usr/bin/env bash
set -uo pipefail
# Note: no `set -e` — a long-running daemon must not exit on transient failures
# (e.g., a single bad JSON response from Linear). Errors are handled explicitly.
# === Bash Version Check ===
if (( BASH_VERSINFO[0] < 4 )); then
echo "ERROR: bash 4+ required (found bash $BASH_VERSION). Install via: brew install bash" >&2
exit 1
fi
# === Dependency Check ===
for cmd in curl jq claude; do
if ! command -v "$cmd" &>/dev/null; then
echo "ERROR: Required command '$cmd' not found on PATH" >&2
exit 1
fi
done
# === Constants ===
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
PID_FILE="$SCRIPT_DIR/.linear-daemon.pid"
LOG_DIR="$PROJECT_DIR/logs"
DAEMON_LOG="$LOG_DIR/daemon.log"
POLL_INTERVAL=60
MAX_WORKERS=5
WORKER_TIMEOUT=1800 # 30 minutes in seconds
# === Linear API Constants ===
LINEAR_API_URL="https://api.linear.app/graphql"
LINEAR_LABEL="${LINEAR_LABEL:-claude}" # Label that triggers daemon pickup
# === Load .env ===
if [[ -f "$SCRIPT_DIR/.env" ]]; then
# shellcheck source=/dev/null
source "$SCRIPT_DIR/.env"
fi
# === Required Environment Variables ===
for var in LINEAR_API_KEY LINEAR_TEAM_ID LINEAR_STATUS_TODO LINEAR_STATUS_IN_PROGRESS LINEAR_STATUS_DONE; do
if [[ -z "${!var:-}" ]]; then
echo "ERROR: $var not set. See .env.example" >&2
exit 1
fi
done
# Optional: assign completed issues back to a specific user
LINEAR_OWNER_ID="${LINEAR_OWNER_ID:-}"
# === Worker Tracking ===
# Associative arrays: keyed by PID
declare -A WORKER_ISSUE_ID=() # PID → Linear issue UUID
declare -A WORKER_IDENTIFIER=() # PID → issue identifier (e.g. PROJ-170)
declare -A WORKER_START_TIME=() # PID → epoch timestamp
STATUS_FILE="$SCRIPT_DIR/.linear-daemon.status"
# Write current worker status to file (readable by cmd_status in a separate process)
update_status_file() {
local count=${#WORKER_ISSUE_ID[@]}
local identifiers=""
for pid in "${!WORKER_IDENTIFIER[@]}"; do
identifiers+=" ${WORKER_IDENTIFIER[$pid]}"
done
echo "Active workers: $count / $MAX_WORKERS" > "$STATUS_FILE"
if [[ -n "$identifiers" ]]; then
echo "Working on:$identifiers" >> "$STATUS_FILE"
fi
}
# === Usage ===
usage() {
echo "Usage: $0 {start|stop|status|restart}"
echo ""
echo " start Start the daemon in the background"
echo " stop Stop the running daemon"
echo " status Show daemon and worker status"
echo " restart Stop then start"
exit 1
}
# === PID Helpers ===
is_running() {
local pid_file="$1"
if [[ ! -f "$pid_file" ]]; then
return 1
fi
local pid
pid=$(cat "$pid_file")
if kill -0 "$pid" 2>/dev/null; then
return 0
else
rm -f "$pid_file"
return 1
fi
}
write_pid() {
echo $$ > "$PID_FILE"
}
remove_pid() {
rm -f "$PID_FILE"
}
# === Logging ===
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
# === Linear API ===
linear_query() {
local query="$1"
local response
response=$(curl -s -w "\n%{http_code}" -X POST "$LINEAR_API_URL" \
-H "Content-Type: application/json" \
-H "Authorization: $LINEAR_API_KEY" \
-d "$query" 2>/dev/null) || {
log "ERROR: curl failed for Linear API"
return 1
}
local http_code body
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" -ge 400 ]]; then
log "ERROR: Linear API returned HTTP $http_code: $body"
return 1
fi
echo "$body"
}
fetch_claude_issues() {
local query
query=$(cat <<GRAPHQL
{
"query": "{ issues(filter: { team: { id: { eq: \"$LINEAR_TEAM_ID\" } }, labels: { name: { eq: \"$LINEAR_LABEL\" } }, state: { id: { eq: \"$LINEAR_STATUS_TODO\" } } }, orderBy: createdAt) { nodes { id identifier title description } } }"
}
GRAPHQL
)
local result
result=$(linear_query "$query") || return 1
echo "$result" | jq -c '.data.issues.nodes[]' 2>/dev/null
}
update_issue_status() {
local issue_id="$1"
local status_id="$2"
local query
query=$(printf '{"query": "mutation { issueUpdate(id: \\"%s\\", input: { stateId: \\"%s\\" }) { success } }"}' "$issue_id" "$status_id")
linear_query "$query" > /dev/null
}
assign_issue() {
local issue_id="$1"
local assignee_id="$2"
local query
query=$(printf '{"query": "mutation { issueUpdate(id: \\"%s\\", input: { assigneeId: \\"%s\\" }) { success } }"}' "$issue_id" "$assignee_id")
linear_query "$query" > /dev/null
}
comment_on_issue() {
local issue_id="$1"
local body="$2"
# Escape newlines and quotes for JSON
body=$(echo "$body" | jq -Rs '.')
local query
query=$(printf '{"query": "mutation { commentCreate(input: { issueId: \\"%s\\", body: %s }) { success } }"}' "$issue_id" "$body")
linear_query "$query" > /dev/null
}
# === Worker Management ===
build_prompt() {
local identifier="$1"
local title="$2"
local description="$3"
cat <<PROMPT
You are an autonomous Claude Code agent.
## Your Task
Issue: $identifier
Title: $title
Description:
$description
## AUTONOMY RULES — READ CAREFULLY
NO HUMAN IN THE LOOP. DO NOT WAIT FOR INPUT. DO NOT ASK QUESTIONS.
LOG YOUR OWN DECISIONS INSTEAD.
If any skill or workflow tells you to:
- "discuss with human" → skip, comment your reasoning on the Linear issue
- "ask for help" or "get approval" → skip, make your best judgment call
- "wait for user response" → skip, proceed with your best judgment
- present options for the user to choose → choose the simplest option yourself
If you truly cannot proceed autonomously, comment WHY on the Linear issue
(identifier: $identifier), move it to "Todo", and exit immediately.
## Workflow
1. Read the issue carefully. Explore the codebase to understand context.
2. Create an implementation plan.
3. Execute the plan. Use subagents for multi-step work if available.
4. If you encounter bugs or test failures, debug systematically.
5. Run all relevant tests and ensure they pass.
6. Merge your worktree branch into main.
7. Push to origin.
## Merge Conflicts
If the merge to main fails due to conflicts:
- DO NOT force-push or discard changes.
- Comment on the Linear issue explaining which files conflict.
- Move the issue to "Todo" and exit. A human will resolve the conflict.
## Progress Updates
Comment on the Linear issue (identifier: $identifier) at these milestones:
- When you've finished investigating and have a plan
- When implementation is complete and tests pass
- If you hit a blocker or decide to bail out
## Bail-Out Conditions
Move the issue back to "Todo" and comment explaining why if:
- The task requires architectural decisions beyond your confidence
- You've failed 3+ fix attempts on the same problem
- The scope is significantly larger than the issue description suggests
- You cannot proceed without human input that you can't reasonably infer
- Merge to main fails due to conflicts
PROMPT
}
spawn_worker() {
local issue_id="$1"
local identifier="$2"
local title="$3"
local description="$4"
local log_file="$LOG_DIR/${identifier}.jsonl"
local prompt
prompt=$(build_prompt "$identifier" "$title" "$description")
log "Spawning worker for $identifier: $title"
# Move issue to In Progress
update_issue_status "$issue_id" "$LINEAR_STATUS_IN_PROGRESS"
# Spawn Claude Code with process substitution for correct exit code capture.
# CLAUDECODE= unsets the env var that prevents nested Claude Code sessions.
# Note: process substitution means tee may not flush its final buffer before
# wait returns — transcript logs may be missing the last few lines on crash/kill.
CLAUDECODE= claude -p "$prompt" \
--worktree \
--permission-mode bypassPermissions \
--output-format stream-json \
--verbose \
> >(tee "$log_file") 2>&1 &
local pid=$!
WORKER_ISSUE_ID[$pid]="$issue_id"
WORKER_IDENTIFIER[$pid]="$identifier"
WORKER_START_TIME[$pid]=$(date +%s)
log "Worker $identifier started (PID $pid)"
update_status_file
}
reap_workers() {
local now
now=$(date +%s)
for pid in "${!WORKER_ISSUE_ID[@]}"; do
local issue_id="${WORKER_ISSUE_ID[$pid]}"
local identifier="${WORKER_IDENTIFIER[$pid]}"
local start_time="${WORKER_START_TIME[$pid]}"
local elapsed=$(( now - start_time ))
local transcript_path
transcript_path="$PROJECT_DIR/logs/${identifier}.jsonl"
# Check if still running
if kill -0 "$pid" 2>/dev/null; then
# Check timeout
if (( elapsed > WORKER_TIMEOUT )); then
log "Worker $identifier (PID $pid) timed out after ${elapsed}s — killing"
kill "$pid" 2>/dev/null || true
sleep 2
kill -9 "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
update_issue_status "$issue_id" "$LINEAR_STATUS_TODO"
comment_on_issue "$issue_id" "⏱ Timed out after 30 minutes. Transcript: $transcript_path"
cleanup_worktree "$pid"
unset "WORKER_ISSUE_ID[$pid]"
unset "WORKER_IDENTIFIER[$pid]"
unset "WORKER_START_TIME[$pid]"
update_status_file
fi
else
# Process exited — capture exit code
local exit_code=0
wait "$pid" 2>/dev/null || exit_code=$?
if (( exit_code == 0 )); then
log "Worker $identifier (PID $pid) completed successfully"
update_issue_status "$issue_id" "$LINEAR_STATUS_DONE"
if [[ -n "$LINEAR_OWNER_ID" ]]; then
assign_issue "$issue_id" "$LINEAR_OWNER_ID"
fi
comment_on_issue "$issue_id" "✅ Completed. Transcript: $transcript_path"
else
log "Worker $identifier (PID $pid) failed (exit code $exit_code)"
update_issue_status "$issue_id" "$LINEAR_STATUS_TODO"
comment_on_issue "$issue_id" "❌ Failed (exit code $exit_code). Transcript: $transcript_path"
fi
cleanup_worktree "$pid"
unset "WORKER_ISSUE_ID[$pid]"
unset "WORKER_IDENTIFIER[$pid]"
unset "WORKER_START_TIME[$pid]"
update_status_file
fi
done
}
worker_count() {
echo "${#WORKER_ISSUE_ID[@]}"
}
cleanup_worktree() {
local pid="$1"
local identifier="${WORKER_IDENTIFIER[$pid]:-}"
local log_file="$LOG_DIR/${identifier}.jsonl"
# Extract worktree path from the completed transcript (stable, no race)
local wt=""
local br=""
if [[ -n "$identifier" && -f "$log_file" ]]; then
wt=$(grep -m1 '"subtype":"init"' "$log_file" | jq -r '.cwd // empty' 2>/dev/null)
if [[ -n "$wt" && -d "$wt" ]]; then
br=$(git -C "$wt" rev-parse --abbrev-ref HEAD 2>/dev/null)
fi
fi
if [[ -z "$wt" ]]; then
if [[ -n "$identifier" ]]; then
log "WARNING: Could not extract worktree path for $identifier — orphan cleanup may be needed"
fi
return 0
fi
local resolved_wt safe_prefix
resolved_wt=$(realpath "$wt" 2>/dev/null) || true
safe_prefix=$(realpath "$PROJECT_DIR/.claude/worktrees" 2>/dev/null) || true
if [[ -n "$resolved_wt" && -n "$safe_prefix" && -d "$wt" && "$resolved_wt" == "$safe_prefix"/* ]]; then
rm -rf "$wt"
git -C "$PROJECT_DIR" worktree prune
if [[ -n "$br" ]]; then
git -C "$PROJECT_DIR" branch -d "$br" 2>/dev/null || true
fi
log "Cleaned up worktree: $wt"
fi
}
cleanup() {
SHUTTING_DOWN=true
log "Shutting down — stopping ${#WORKER_ISSUE_ID[@]} worker(s)..."
# Move all in-progress issues back to Todo and comment
for pid in "${!WORKER_ISSUE_ID[@]}"; do
local issue_id="${WORKER_ISSUE_ID[$pid]}"
local identifier="${WORKER_IDENTIFIER[$pid]}"
local transcript_path="$PROJECT_DIR/logs/${identifier}.jsonl"
log "Stopping worker $identifier (PID $pid)"
update_issue_status "$issue_id" "$LINEAR_STATUS_TODO" || true
comment_on_issue "$issue_id" "⚠️ Daemon stopped while work was in progress. Transcript (partial): $transcript_path" || true
kill "$pid" 2>/dev/null || true
done
# Wait up to 10 seconds for workers to exit
local i=0
while (( i < 10 )); do
local any_alive=false
for pid in "${!WORKER_ISSUE_ID[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
any_alive=true
break
fi
done
if [[ "$any_alive" == "false" ]]; then
break
fi
sleep 1
(( i++ ))
done
# Force-kill any stragglers
for pid in "${!WORKER_ISSUE_ID[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
log "Force-killing worker PID $pid"
kill -9 "$pid" 2>/dev/null || true
fi
done
# Clean up all worktrees
for pid in "${!WORKER_ISSUE_ID[@]}"; do
cleanup_worktree "$pid"
done
rm -f "$STATUS_FILE"
remove_pid
log "Daemon stopped."
}
poll_and_spawn() {
log "Polling Linear for $LINEAR_LABEL-labeled issues..."
local issues
issues=$(fetch_claude_issues) || {
log "Poll failed — will retry next cycle"
return 0
}
if [[ -z "$issues" ]]; then
log "No issues found"
return 0
fi
log "Found issues to process"
# Build set of issue IDs already being worked on
local -A active_issues
for pid in "${!WORKER_ISSUE_ID[@]}"; do
active_issues["${WORKER_ISSUE_ID[$pid]}"]=1
done
while IFS= read -r issue_json; do
local count
count=$(worker_count)
if (( count >= MAX_WORKERS )); then
log "Reached max workers ($MAX_WORKERS) — deferring remaining issues"
break
fi
local issue_id identifier title description
issue_id=$(echo "$issue_json" | jq -r '.id')
identifier=$(echo "$issue_json" | jq -r '.identifier')
title=$(echo "$issue_json" | jq -r '.title')
description=$(echo "$issue_json" | jq -r '.description // "No description provided."')
# Skip if already being worked on
if [[ -n "${active_issues[$issue_id]:-}" ]]; then
continue
fi
spawn_worker "$issue_id" "$identifier" "$title" "$description"
done <<< "$issues"
}
# === Commands ===
cmd_start() {
if is_running "$PID_FILE"; then
echo "ERROR: Daemon is already running (PID $(cat "$PID_FILE"))" >&2
exit 1
fi
mkdir -p "$LOG_DIR"
echo "Starting linear daemon..."
nohup bash "$0" _run >> "$DAEMON_LOG" 2>&1 &
local bg_pid=$!
sleep 1
if is_running "$PID_FILE"; then
echo "Daemon started (PID $(cat "$PID_FILE")). Logs: $DAEMON_LOG"
else
echo "ERROR: Daemon failed to start. Check $DAEMON_LOG" >&2
exit 1
fi
}
cmd_stop() {
if ! is_running "$PID_FILE"; then
echo "Daemon is not running."
return 0
fi
local pid
pid=$(cat "$PID_FILE")
echo "Stopping daemon (PID $pid)..."
kill "$pid" 2>/dev/null || true
local i=0
while kill -0 "$pid" 2>/dev/null && (( i < 10 )); do
sleep 1
(( i++ ))
done
if kill -0 "$pid" 2>/dev/null; then
echo "Force-killing daemon..."
kill -9 "$pid" 2>/dev/null || true
fi
remove_pid
echo "Daemon stopped."
}
cmd_status() {
if is_running "$PID_FILE"; then
local pid
pid=$(cat "$PID_FILE")
echo "Daemon is running (PID $pid)"
local status_file="$SCRIPT_DIR/.linear-daemon.status"
if [[ -f "$status_file" ]]; then
cat "$status_file"
else
echo "Active workers: unknown (status file not yet written)"
fi
else
echo "Daemon is not running."
fi
}
cmd_run() {
write_pid
log "Daemon started (PID $$)"
SHUTTING_DOWN=false
trap cleanup SIGTERM SIGINT EXIT
while [[ "$SHUTTING_DOWN" == "false" ]]; do
# Reap completed workers first
reap_workers
# Poll for new issues if under capacity
local count
count=$(worker_count)
if (( count < MAX_WORKERS )); then
poll_and_spawn
else
log "At capacity ($count/$MAX_WORKERS workers) — skipping poll"
fi
# Sleep in 1-second increments so we can respond to signals
local i=0
while (( i < POLL_INTERVAL )) && [[ "$SHUTTING_DOWN" == "false" ]]; do
sleep 1
(( i++ ))
# Also reap during sleep so we notice exits quickly
reap_workers
done
done
}
# === CLI Dispatch ===
case "${1:-}" in
start) cmd_start ;;
stop) cmd_stop ;;
status) cmd_status ;;
restart) cmd_stop; cmd_start ;;
_run) cmd_run ;;
*) usage ;;
esac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment