Blog October 2, 2026

Automatically Invoke Claude Code From Linear

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:

  1. I assign the claude-ready-to-build label to an issue.
  2. This triggers a webhook that calls out to a little AWS Lambda function which calls the Claude Routines API to start a Claude execution.
  3. It adds a claude-building label to the issue and comments with the Claude session URL in case I want to follow along.
  4. 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.