schof.co · 11d · discuss
Linear has their own coding agent, but I have an expensive Claude account, so I'd like to use it. To limit the friction of having Claude Code pick up an issue from Linear, I've set up a little flow that does this automatically: I assign the claude-ready-to-build label to an issue. This triggers a webhook that calls out to a little AWS Lambda function which calls the Claude Routines API to start a Claude execution. It adds a claude-building label to the issue and comments with the Claude session URL in case I want to follow along. Claude Code does it's normal thing, opens a PR, moves the issue to In review and comments with any specifics. The routine uses the following prompt: You are picking up a Linear issue. The issue payload is in the run context. 1. Comment on the issue that you've started, with a link to this session. 2. Implement it on a branch named `claude/<ISSUE-ID>`. Follow CLAUDE.md, add tests, run the suite. 3. Open a PR referencing the issue ID, so the Linear–GitHub integration links it. 4. Comment a summary on the issue. No need to move it to "In Review", that should happen automatically. If anything is ambiguous, comment your questions on the issue, set the label `needs-info`, and stop. Don't guess. 5. Remove the 'claude-building' label that our system might have automatically added to flag that the issue was sent to a Claude routine. 6. Listen for Github Copilot review comments on the PR. Guidance: - Implement this in the simplest way. Cleanness over complexity. Minimal and simple. Extra points for making it very clean and readable code. - Any comment you post on Linear or GitHub, always prefix with 'Claude: ', since it might show up with the user's name/profile picture, so we don't want to create confusion about who it is. - Where possible and relevant, comment on the issue with things that are still to be decided, and/or relevant screenshots. And the lambda Linear webhook listener code roughly looks like this: import { createHmac, timingSafeEqual } from "node:crypto"; const READY = 'ce149e24-dad4-4784-b540-dbc35fc76c67'; // label ID const BUILDING = 'e497deff-bf2f-4b95-84a8-5c4d5485eecf'; // label ID const ROUTINE_URL = 'https://api.anthropic.com/v1/claude_code/routines/.../fire'; const ROUTINE_TOKEN = process.env.CLAUDE_ROUTINE_TOKEN; const LINEAR_API_KEY = process.env.LINEAR_API_KEY; const LINEAR_WEBHOOK_SECRET = process.env.LINEAR_WEBHOOK_SECRET; const gql = (query, variables) => fetch("https://api.linear.app/graphql", { method: "POST", headers: { Authorization: LINEAR_API_KEY, "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }), }).then(r => r.json()); export const handler = async (event) => { const raw = event.isBase64Encoded ? Buffer.from(event.body, "base64").toString() : event.body; // 1. Verify Linear signature + freshness const sig = Buffer.from(event.headers["linear-signature"] ?? "", "hex"); const mac = createHmac("sha256", LINEAR_WEBHOOK_SECRET).update(raw).digest(); if (sig.length !== mac.length || !timingSafeEqual(sig, mac)) return { statusCode: 401 }; const p = JSON.parse(raw); if (Math.abs(Date.now() - p.webhookTimestamp) > 60_000) return { statusCode: 401 }; // 2. Only react when `ready-to-build` was just added if (p.type !== "Issue") return { statusCode: 200 }; const now = p.data.labelIds ?? []; const before = p.updatedFrom?.labelIds; const justAdded = now.includes(READY) && (p.action === "create" || (before !== undefined && !before.includes(READY))); if (!justAdded) return { statusCode: 200 }; // 3. Claim it: swap label so retries/duplicates don't double-fire await gql(`mutation($id:String!,$r:String!,$a:String!){ issueRemoveLabel(id:$id,labelId:$r){success} issueAddLabel(id:$id,labelId:$a){success} }`, { id: p.data.id, r: READY, a: BUILDING }); // 4. Fire the routine with the issue as context const res = await fetch(ROUTINE_URL, { method: "POST", headers: { Authorization: `Bearer ${ROUTINE_TOKEN}`, "anthropic-version": "2023-06-01", "Content-Type": "application/json", }, body: JSON.stringify({ text: `Linear issue ${p.data.identifier}: ${p.data.title}\nURL: ${p.url}\n\n${p.data.description ?? ""}`, }), }); if (!res.ok) console.error("routine fire failed", res.status, await res.text()); return { statusCode: 200 }; }; Not beautiful code, but it does the job perfectly. a[href="#internal-link"] { color: #9b9b9b; text-decoration: none !important; } document.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(heading => { if (!heading.textContent.includes('%% fold %%')) return; const details = document.createElement('details'); const summary = document.createElement('summary'); summary.innerHTML = heading.innerHTML.replace('%% fold %%', '').trim(); details.appendChild(summary); const content = document.createElement('div'); details.appendChild(content); let sibling = heading.nextElementSibling; const headingLevel = parseInt(heading.tagName[1]); while (sibling) { const next = sibling.nextElementSibling; if (/^H[1-6]$/.test(sibling.tagName) && parseInt(sibling.tagName[1]) <= headingLevel) break; if (sibling.textContent.includes('%% endfold %%') || sibling.textContent.includes('%% fold %%') || sibling.textContent.includes('❧')) break; content.appendChild(sibling); sibling = next; } heading.replaceWith(details); });