Skip to content

About

Watch Linear cards and wake Claude Code when one of them needs work

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

linear-watcher

Watch Linear cards and wake Claude Code when one of them needs work.

It polls (and optionally listens through a webhook) for the issues in Todo (or those carrying a given label), and runs claude -p "/linear SMA-123" in your repository whenever a new card shows up, a card changes state, or a human comment asks for it.

Zero runtime dependencies — Node builtins only.

Install

npm i -g linear-watcher      # or: npx linear-watcher

Then, from the repository you want Claude to work in:

linear-watcher init          # installs .claude/commands/linear.md
linear-watcher               # start watching

init drops a starting-point /linear slash command in .claude/commands/. It encodes a full ticket workflow (branch, implement, test, PR, reply to reviewers) — read it and adapt it to your project before relying on it.

Authentication

The watcher needs a Linear API key, taken from the first of:

  1. LINEAR_API_KEY in the environment;
  2. env.LINEAR_API_KEY in <repo>/.claude/settings.local.json (the file Claude Code already uses, and which is usually gitignored).

Create one at linear.app/settings/api.

Usage

linear-watcher                    # poll every 10 minutes
linear-watcher --tunnel           # + cloudflared tunnel and Linear webhook
linear-watcher --once --dry-run   # one cycle, never start claude
linear-watcher --help
Option Default Role
--http — start the HTTP server (dashboard, GET /health); implied by --tunnel and PUBLIC_URL
--tunnel — open a cloudflared quick tunnel and register a Linear webhook, so events wake the watcher instantly
--once — run a single cycle and exit (cron, CI)
--dry-run — log what would happen, never start claude
--verbose — explain every decision (see Troubleshooting)
--cwd <dir> current directory repository claude runs in
--label <name> — only watch the cards carrying this label; without it, every card, one per run (see Without a label)
--states <a,b,c> Todo (Todo,In Progress,In Review with --label) states in which a new card or a state change wakes claude (comments wake it in any state)
--assignee <who> — only watch cards assigned to <who> (see below)
--command <cmd> /linear slash command handed to claude
--port <n> 4747 HTTP port of the server

Every option also has an environment variable — see linear-watcher --help. Notable ones: POLL_INTERVAL_MS, PORT, PUBLIC_URL, CLAUDE_BIN, CLAUDE_ARGS, LINEAR_BOT_MENTION, LINEAR_WEBHOOK_SECRET, LINEAR_WATCHER_ADMIN_TOKEN.

Dashboard

With --http (or --tunnel/PUBLIC_URL, which start the server for the webhook), the HTTP server serves a dashboard on http://localhost:4747/:

  • Process: what the watcher is doing (polling, running claude and on which cards, sleeping until when), the last run and the last error;
  • Rules: change the label, the states, the assignee and the poll interval without restarting. They are saved in <repo>/.claude/linear-watcher.config.json (gitignore it) and win over the CLI flags and the environment until Revert to CLI/env;
  • State: every card with its live Linear state and what the watcher remembers of it. Reprocess forgets a card (or all of them), so the next cycle treats it as new and runs claude if it is in a watched state; Re-baseline marks every current card and comment as seen without running anything;
  • Logs: the last lines of the watcher and of claude.

It has no password by default. Set LINEAR_WATCHER_ADMIN_TOKEN to put it behind HTTP basic auth (any user name, that password) — do so behind --tunnel or PUBLIC_URL, where the dashboard is public. Changes are refused while claude is running.

The same actions are available as JSON (content-type: application/json is required on POSTs):

curl -u :$TOKEN localhost:4747/api/status
curl -u :$TOKEN -H 'content-type: application/json' -d '{"assignee":"me"}' localhost:4747/api/rules
curl -u :$TOKEN -H 'content-type: application/json' -d '{"reset":true}' localhost:4747/api/rules
curl -u :$TOKEN -H 'content-type: application/json' -d '{"mode":"baseline"}' localhost:4747/api/reset
curl -u :$TOKEN -H 'content-type: application/json' -d '{}' localhost:4747/api/poll

Filtering by assignee

Without --assignee, every card in scope is watched, whoever it is assigned to. --assignee (or LINEAR_ASSIGNEE) narrows that down:

Value Meaning
me the owner of the Linear API key
git user.email, else user.name, from the git config of the repository
anything else a name, display name or email

A literal value matches Linear's name or displayName loosely (case insensitive, substring) and its email exactly — git config user.name is often a first name (Sven) where Linear stores a full one (Sven Gaubert).

git is the one to put in the package.json of the project being watched: every developer gets their own cards, with nothing to configure and no shell interpolation (so it also works on Windows).

{
  "scripts": {
    "linear:watch": "linear-watcher --tunnel --assignee git"
  }
}

"linear-watcher --assignee \"$(git config user.name)\"" does the same thing on a POSIX shell, if you prefer the value to be visible in the script.

Without a label

Without --label, there is no label filter: claude works through the board itself instead of the cards tagged for it. Pass --label Alan to only watch the cards carrying that label.

linear-watcher --assignee me          # the first card in Todo, one at a time
linear-watcher --label Alan           # only the cards labelled "Alan"
  • --states defaults to Todo in this mode, so a first run does not pick up every card the team already has in progress;
  • claude -p "/linear SMA-123" always gets a single card: pending comments (mentions, answers to its questions) first, then the first card in Todo, by priority then board order. The other cards wait for the next cycle, which starts right after a successful run;
  • comments still need the @Alan mention (see below), on any card;
  • only the 100 most recently updated cards are fetched: narrow the scope with --assignee on a busy workspace.

The dashboard switches to this mode when its label field is emptied.

Comment trigger rule

A comment wakes claude whatever the state of the card. --states gates the card triggers — a card that shows up for the first time, a card that changes state — but a follow-up on a card claude already moved to Done must not be lost, so its comments are still read. The first time a card outside --states is seen, it is recorded without a run: only the comments posted from then on can wake claude.

Claude answering every passing remark is noise. So a new human comment only wakes claude when it mentions @Alan (case insensitive; @Alanis does not count). Comments claude wrote itself start with FROM @Alan: and are skipped.

Linear rewrites a mention picked from its autocomplete into a markdown link, so the stored body is [Alan](https://linear.app/acme/issue-label/alan) and never contains @Alan — that form counts as a mention too, label and profile links alike.

Exception — a direct answer to a question claude asked is always processed, mention or not. Claude started that exchange, so the reply is already addressed to it. A comment qualifies when it follows a FROM @Alan: comment containing a question mark and either:

  • it is in the thread of that comment (its parent is the question), or
  • it is a root comment posted immediately after the question, with nothing in between.

A reply in the thread of a claude comment that asked nothing triggers nothing. Comments that fail the rule are ignored silently — no run, no reply — and the watermark moves past them so they are not re-evaluated at every poll.

Rename the mention with LINEAR_BOT_MENTION (it also defines the FROM …: prefix). The same rule is spelled out in the /linear command template, since the watcher decides whether to wake claude and the command decides which comments to answer — keep the two in sync.

How it runs

  1. Reads the API key.
  2. With --http, --tunnel or PUBLIC_URL, starts a small HTTP server (GET /health, POST /webhook, the dashboard, port 4747); without them, it only polls. When 4747 is taken it slides to the next free port, so several watchers can run side by side; an explicitly chosen --port/PORT is never moved and fails loudly if busy.
  3. If a public URL is available (PUBLIC_URL or --tunnel), deletes leftover webhooks from a previous run and registers a Linear webhook (Issue + Comment). Requests are HMAC-SHA256 verified against linear-signature with a per-run random secret; unsigned requests get a 401. The webhook, the tunnel and the server are torn down on exit.
  4. Fetches every card (carrying --label, if set) — whatever its state — with its comments, and compares them with the local state to find: a card never seen, a comment passing the trigger rule, or a state change. A card outside --states only counts through its comments (see below); the first time one is seen it is recorded silently, so its old comments never fire.
  5. If there is work: runs claude -p "/linear SMA-123" for a single card, or claude -p "/linear" for several, streaming its output live.
  6. Otherwise: sleeps, interruptible at any time by a webhook.

Watching several repositories

Run one watcher per repository — each has its own state file and its own webhook. Nothing needs to be configured for that: the port slides to the next free one, and the Linear webhook is labelled after the repository (directory name + a hash of its path), so a watcher only ever cleans up its own leftovers.

cd ~/code/app  && linear-watcher --tunnel   # port 4747
cd ~/code/api  && linear-watcher --tunnel   # port 4748

Docker

The image clones a (private) GitHub repository and runs its linear:watch script, with Claude Code, gh and cloudflared installed:

cp .env.example .env     # GITHUB_REPO, GITHUB_TOKEN, LINEAR_API_KEY, ANTHROPIC_API_KEY
docker compose up -d --build
docker compose logs -f

At startup, the entrypoint:

  1. authenticates gh and git with GITHUB_TOKEN (through a credential helper — the token is never written into .git/config) and sets the commit identity from the GitHub account, unless GIT_USER_NAME/GIT_USER_EMAIL;
  2. clones the repository into the workspace volume, or reuses it on restart, so the watcher state and claude's branches survive;
  3. installs the /linear template when the repository has no such command;
  4. registers Linear's MCP server (https://mcp.linear.app/mcp) in Claude Code as linear-server, authenticated with LINEAR_API_KEY;
  5. installs the dependencies (npm ci, pnpm or yarn, from the lockfile);
  6. runs npm run linear:watch -- $LINEAR_WATCHER_ARGS, or linear-watcher directly when the project has no such script.

ANTHROPIC_API_KEY can be replaced by CLAUDE_CODE_OAUTH_TOKEN (from claude setup-token) to use a Claude subscription. With --assignee git, set GIT_USER_EMAIL to your Linear email: the GitHub one rarely matches. .env.example lists every setting.

The container runs as node, with passwordless sudo: a postinstall running playwright install --with-deps needs root for apt-get, and would otherwise hang on a password prompt. Chromium's system packages are already in the image, and the browsers are kept on the workspace volume.

Coolify

  1. New resource → Public repository: https://github.com/svengau/linear-watcher, build pack Docker Compose, compose file /docker-compose.coolify.yml.
  2. Environment variables: Coolify lists the ones of the compose file — fill in GITHUB_REPO, GITHUB_TOKEN, LINEAR_API_KEY and ANTHROPIC_API_KEY (or CLAUDE_CODE_OAUTH_TOKEN).
  3. Webhook, either:
    • LINEAR_WATCHER_ARGS=--tunnel (unless the linear:watch script already has it): nothing to expose, cloudflared opens the tunnel;
    • or a domain on the service pointing to port 4747 (https://linear-watcher.example.com:4747) and PUBLIC_URL=https://linear-watcher.example.com — PUBLIC_URL wins over --tunnel.
  4. Deploy. The workspace volume keeps the clone across redeploys; delete it from the Storages tab to start from a fresh clone.

The container is capped at MEM_LIMIT (default 6g): a big pnpm install with native modules can otherwise use all the memory of the server, which then stops answering Coolify. On an 8 GB server, add swap as well, and lower INSTALL_CONCURRENCY if the install still gets killed. /health answers from the start of the setup, so a long first clone and install does not mark the service unhealthy. The container always starts the watcher's HTTP server (LINEAR_WATCHER_HTTP=1, same as --http) for this healthcheck.

Troubleshooting

--verbose explains every decision:

  • the Linear filter actually sent to the API;
  • for each card, its comments, the watermark, and why each comment did or did not trigger a run (with the raw body — mentions are markup, not text);
  • when a webhook fires for a card that is not in the fetched set, which criterion excludes it:
· VIS-2606: state "Done", labels [Bug], assignee svengau
· VIS-2606 is not in the fetched set — labels: NO, state: outside --states, comments only

That is the usual answer to "the webhook fired but nothing happened": the label is missing (or the assignee does not match). A state outside --states is not a blocker for comments — it only means the card itself is not watched.

Local state

<repo>/.claude/linear-watcher.state.json (gitignore it) stores, per issue, the last comment seen and the card state. Delete it to force a full reprocessing, or use the dashboard.

Comment watermarks are frozen on the snapshot taken before the claude run, so a human comment posted while claude works is still caught on the next cycle. State names are re-read after the run, so claude's own transitions (Todo → In Progress → In Review) do not re-trigger it.

Programmatic use

The decision logic is pure and importable:

import { computeWork } from "linear-watcher";

const { work, ignored } = computeWork(issues, state);

Development

npm test        # node --test

Caveat

The default CLAUDE_ARGS include --permission-mode bypassPermissions: claude runs unattended, so it does not stop to ask. Point it at a repository you are comfortable letting an agent commit and push to, and review its pull requests.

License

MIT

About

Watch Linear cards and wake Claude Code when one of them needs work

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages