<< All versions

Skill v1.0.0

currentTrusted Publisher100/100
anthropics/claude-quickstarts/linear
──Details
PublishedSeptember 29, 2026 at 08:28 AM
Content Hashsha256:e8b49416abdf8d4e...
Git SHA
──Files
Files (1 file, 15.2 KB)
SKILL.md15.2 KBactive
SKILL.md · 135 lines · 15.2 KB

version: "1.0.0"


Setup tips & tricks: Linear × Claude Managed Agents webhook bridge

Things that aren't obvious from the docs and tend to cost debugging time.


Mental model

A webhook is "call me when something happens"

It's an HTTP POST a service sends to a URL you gave it, with a small JSON body describing an event. You register the URL once, and the service calls it whenever the event fires. No polling, no held-open connections.

The bridge is mandatory (today)

Linear's Agent Platform and Managed Agents don't share a wire format or credentials. Something has to translate "Linear @mention" → "user.message" on the way in, and "session idle" → "Linear comment" on the way out, while holding both sets of keys. That's the bridge.

Two webhooks, one bridge

  • Linear → bridge (/linear-webhook) fires on @mention or assignment. Payload carries agentSession.id, organizationId, and issue context.
  • Anthropic → bridge (/managed-agents/webhook) fires on session idle. Payload carries only the session ID.

Neither carries a "callback URL." Neither carries the agent's output. Both are signals with IDs.

The webhook is a doorbell, not a delivery

Anthropic's session.status_idled payload is deliberately thin: {type, id}. You always follow up with sessions.retrieve(id) (to get metadata) and sessions.events.list(id) (to get the output). Push the signal, pull the data. Retries stay cheap and data is never stale.

metadata is the entire routing state

When the bridge creates the session it sets metadata: {linear_session_id, linear_org_id}. When the idle webhook arrives later with only a session ID, the bridge retrieves the session, reads those keys back, and knows exactly where to reply, with nothing stored in the bridge itself. This is what makes it stateless.


Gotchas

Anthropic webhooks are workspace-scoped

The endpoint you register in Console only receives events for sessions in that same workspace. If your Anthropic credentials belong to workspace A but you registered the endpoint in workspace B, you get zero deliveries, silently. Match the workspace picker on the Console's Webhooks page to the Workspace column on your API key, or to the workspace ant auth login signed you into.

A workspace webhook fires for every session in the workspace

Not only yours. If the Anthropic workspace is shared with other agents, scripts, or teammates, every session.status_idled in that workspace hits your endpoint. Your handler must filter: sessions.retrieve(id) → check for your metadata keys → return 204 for anything that isn't yours. Also catch 404/403 on sessions.retrieve, and only those. Sessions created under other API keys in the same workspace aren't readable by yours, but a 429 or 5xx is a real failure that should be retried.

Corollaries: subscribe only to the event types you need (session.status_idled, session.status_terminated), not "All events". For production, consider a dedicated Anthropic workspace so there are no unrelated sessions to discard.

Linear OAuth apps are workspace-admin-only

OAuth apps live at linear.app/<your-workspace>/settings/api. In the sidebar it's Administration → API, then the OAuth Applications section. If you're not an admin of your company workspace, spin up a free personal workspace for testing. It takes two minutes and you won't install an experimental agent into production.

When creating the app, the Developer URL field is required but cosmetic (it's a link shown on the consent screen). Any real https:// URL is fine.

actor=app is the load-bearing OAuth parameter

scope=app:assignable,app:mentionable + actor=app is what creates an app user in the Linear workspace that shows up in the @-picker. A personal LINEAR_API_KEY won't do this. The bridge must post back as the app, via the OAuth token.

BASE_URL has to match the callback URL exactly

src/oauth.ts builds the redirect URI as ${BASE_URL}/oauth/callback and sends it on both the authorize redirect and the token exchange. Linear rejects the exchange unless it matches the callback URL registered on the OAuth app character for character. A new ngrok URL means updating both .env and the Linear app.

The first install owns the bridge

/oauth/authorize has no login in front of it. Anyone who can reach the server could install the agent into a Linear workspace they administer, and from then on every mention there would run a session on your Anthropic credentials. So the gate fails closed. With LINEAR_ALLOWED_ORG_IDS unset, the first workspace to install is the only one the bridge serves: a later install from a different workspace gets a 403 and its token is revoked, and webhook events from it are ignored. Install your own workspace right after you start the server, and check the log line names it.

Set LINEAR_ALLOWED_ORG_IDS to a comma-separated list of organization IDs to pin that choice or to serve more than one workspace. The server logs the organization ID on each install. To hand the bridge to a different workspace, delete .linear-tokens.json and install again.

The flow also uses a single-use state value that expires after 10 minutes, so a callback only succeeds if this server started it. Restarting the server forgets pending states. If you see "Unknown or expired state", start again at /oauth/authorize.

The idle webhook only trusts routes it signed

metadata says where to reply, but it does not prove the session is the bridge's. Anyone with credentials for the same Anthropic workspace can start a session, on this same agent, with linear_session_id and linear_org_id of their choosing. A handler that trusted those keys would post their text into your Linear issue with your token. Checking session.agent.id and session.environment_id is a useful first filter and nothing more, because agent IDs are not secrets.

So at kickoff src/agent.ts stores linear_route_sig, an HMAC over the Claude session ID, the Linear session ID, and the organization ID, keyed by a secret derived from LINEAR_CLIENT_SECRET, which never leaves the bridge. src/managed-agents-webhook.ts recomputes it and compares in constant time before posting anything. The Claude session ID is part of the signed text on purpose: metadata is readable by the same people who can forge it, so a signature over the route alone could be copied onto another session. The signature is also single-use. A signed session is still a session that anyone with workspace credentials can send another message to, and its next idle would post their text into the issue. So once the bridge has delivered, or decided it never will, it deletes linear_route_sig from the metadata, and later idles are refused. A retryable failure leaves it in place so the retry can still verify. The bridge also remembers delivered sessions in memory and takes a per-session lock right after verifying, because the remote delete can fail and two idle events for one session carry different event ids. And the signed text includes an issued-at time: a route older than 24 hours is refused, so one whose delivery keeps failing does not stay live with no end. An agent run longer than that loses its reply. Raise MAX_AGE_SECONDS in src/route-signature.ts if yours do. If you fork this, keep the verify call between the metadata read and the first Linear call, and keep the delete after the delivery. Rotating LINEAR_CLIENT_SECRET invalidates the signatures of sessions still running, and their replies are dropped with a log line.

Issue text is untrusted input

The prompt is the issue title, description, and comments, which anyone with access to the Linear workspace can write. src/agent.ts wraps each piece in a tag such as <linear_issue_description> and the system prompt tells the agent to treat tagged text as data. That lowers the odds that an injected instruction is followed. It does not remove them. The agent has the full toolset (bash, web fetch) with always_allow, in a sandbox with unrestricted egress, and nobody approves a step. Treat it like running a script a stranger wrote: keep secrets out of the sandbox, and if you mount a repo or add MCP tools, scope their credentials to what a hostile issue should be able to touch. environments/linear-assistant.yaml is where to switch networking to limited with an allowlist.

Linear's 10-second ack rule

The bridge must post some agentActivity (a {type: "thought"} is enough) within 10 seconds of receiving the AgentSessionEvent, or Linear marks the session failed. Do this before creating the Managed Agents session.

event.id is your idempotency key

Anthropic retries failed deliveries with the same top-level event.id. Dedupe on it, but only mark an id handled once handling finished. If it threw, the retry has to be processed or the reply is lost. A duplicate that arrives while the first attempt is still running gets a 503, not a 204: acking it would mark the event delivered while the outcome is still unknown. Return 2xx once you've either handled it or decided to ignore it. Anything else triggers a retry, and ~20 consecutive failures auto-disables your endpoint. That is why a Linear error a retry can't fix (no token for the org, a revoked install or a refused token refresh, an agent session Linear has closed) is logged and acked instead of thrown.

Stop interrupts the run, best effort

Linear sends Stop as a prompted event whose activity carries signal: "stop". Treated as a prompt, it would start a second session. src/agent.ts remembers which Claude session is working on each Linear session, sends that session user.interrupt, posts "Stopped.", and the idle webhook then skips the interrupted run's half-finished text.

That map is the one piece of state the bridge keeps, and it lives in memory. After a restart the bridge has no record of runs started before it, so Stop says exactly that instead of claiming a stop that did not happen. Reply routing never depends on the map. It still reads metadata. For production, keep linear_session_id → session.id in Redis or a DB.

No approval surface, so tools are always_allow

agents/linear-assistant.md sets permission_policy: {type: always_allow} on the toolset. An always_ask tool idles the session to wait for a confirmation, the idle webhook fires, and the bridge posts whatever partial text exists. If you add an approval flow (Linear's elicitation activity plus user.tool_confirmation), switch the risky tools back to always_ask.

Signature header names

The docs say X-Webhook-Signature. The wire uses Webhook-Signature / Webhook-Id / Webhook-Timestamp (Standard Webhooks spec). The SDK's webhooks.unwrap() handles this, so it only matters if you're verifying by hand.


Local dev checklist

  1. ngrok http 3000 (or cloudflared tunnel) → note the public URL and set it as BASE_URL in .env (cp .env.example .env first). Everything below uses it.
  2. ant auth login (or export ANTHROPIC_API_KEY), then ant apply agents environments. It creates the agent and environment and writes their IDs to claude-lock.json, which the bridge reads at startup. Nothing to paste into .env for them.
  3. Linear OAuth app (Administration → API → OAuth Applications → Create new): Developer URL = any real https:// URL (cosmetic). Callback <url>/oauth/callback. Webhook <url>/linear-webhook, events = Agent session events → copy client ID/secret + webhook secret into .env.
  4. Claude Console → Manage → Webhooks: <url>/managed-agents/webhook, events = session.status_idled + session.status_terminated → copy whsec_… → ANTHROPIC_WEBHOOK_SIGNING_KEY. Same workspace as your Anthropic credentials.
  5. bun run dev.
  6. Visit <url>/oauth/authorize → approve → "Agent installed." The server logs [oauth] installed in "<name>" (<org-id>). That workspace now owns the bridge. Put the ID in LINEAR_ALLOWED_ORG_IDS to pin it.
  7. @mention it in an issue.

Debugging a silent failure

  • "Thinking…" never appears → Linear webhook isn't reaching you. Check the Linear app's webhook URL and that ngrok is up. If the log shows ignored event from org …, a different workspace installed first or that workspace isn't in LINEAR_ALLOWED_ORG_IDS.
  • "Thinking…" appears but no reply → check curl localhost:4040/api/requests/http (ngrok's request log). If no POST to /managed-agents/webhook: workspace mismatch on the Anthropic side, or endpoint not saved. If POST arrives with 401: signing key mismatch. If the log shows could not post to linear=…: the message names the cause, usually a missing .linear-tokens.json entry (reinstall at /oauth/authorize).
  • `FATAL: … .linear-tokens.json is not valid` at startup → the token file is corrupt. The bridge refuses to start on it, because reading it as empty would let any workspace install. Fix the JSON, or delete the file and reinstall at /oauth/authorize.
  • `FATAL: no agent or environment ID` → ant apply agents environments hasn't run in this directory yet, so there is no claude-lock.json beside package.json, or it stopped partway and the lockfile lacks one of the two (run it again and read its error). If you ran it from another directory, the lockfile is there instead: run it again from here.
  • `ant apply` fails with 409 on the environment → environment names are unique per workspace and someone already created quickstart-linear-assistant-env there (an earlier run of this quickstart's old agents/setup.sh, usually). The environment is created first, so the agent was not created either. ant apply can't adopt an existing resource: change name in environments/linear-assistant.yaml and run ant apply agents environments again. To reuse the existing environment instead, delete environments/linear-assistant.yaml, set its ID as CLAUDE_ENVIRONMENT_ID in .env, and run ant apply agents.
  • The agent answers with an old prompt or model → check the Using agent ... (from ...) line the bridge prints at startup. from CLAUDE_AGENT_ID means there is no claude-lock.json entry and an ID from .env is in use (an older setup of this bridge wrote IDs there): run ant apply agents environments here, or delete the stale lines.
  • `Linear refused to refresh the token for org …` in the log → Linear answered invalid_grant: the workspace uninstalled the app. Reinstall at /oauth/authorize. An invalid_client in the log instead means LINEAR_CLIENT_SECRET is wrong.
  • "Linear rejected the install" after approving → the server log has Linear's response. The usual cause is BASE_URL not matching the app's callback URL.
  • The bot answers with an old prompt or model → Bun loads .env.local over .env. If you have one left from the cookbook version of this bridge, delete it.

Production notes

  • Replace ngrok with a real deploy (Cloudflare Workers, Fly, etc.). Nothing else changes.
  • Replace the in-memory seenEventIds Set and the pending OAuth state map with Redis or a DB for multi-instance deploys.
  • Replace the .linear-tokens.json file with a real secret store. The bridge writes it owner-only (0600) and replaces it atomically, which is enough for a laptop and not for a server.
  • Narrow the Anthropic endpoint's event subscription to exactly what you handle.
All versions