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.
npm i -g linear-watcher # or: npx linear-watcherThen, from the repository you want Claude to work in:
linear-watcher init # installs .claude/commands/linear.md
linear-watcher # start watchinginit 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.
The watcher needs a Linear API key, taken from the first of:
LINEAR_API_KEYin the environment;env.LINEAR_API_KEYin<repo>/.claude/settings.local.json(the file Claude Code already uses, and which is usually gitignored).
Create one at linear.app/settings/api.
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.
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/pollWithout --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 --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"--statesdefaults toTodoin 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 inTodo, by priority then board order. The other cards wait for the next cycle, which starts right after a successful run;- comments still need the
@Alanmention (see below), on any card; - only the 100 most recently updated cards are fetched: narrow the scope with
--assigneeon a busy workspace.
The dashboard switches to this mode when its label field is emptied.
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
parentis 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.
- Reads the API key.
- With
--http,--tunnelorPUBLIC_URL, starts a small HTTP server (GET /health,POST /webhook, the dashboard, port4747); without them, it only polls. When4747is taken it slides to the next free port, so several watchers can run side by side; an explicitly chosen--port/PORTis never moved and fails loudly if busy. - If a public URL is available (
PUBLIC_URLor--tunnel), deletes leftover webhooks from a previous run and registers a Linear webhook (Issue+Comment). Requests are HMAC-SHA256 verified againstlinear-signaturewith a per-run random secret; unsigned requests get a 401. The webhook, the tunnel and the server are torn down on exit. - 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--statesonly counts through its comments (see below); the first time one is seen it is recorded silently, so its old comments never fire. - If there is work: runs
claude -p "/linear SMA-123"for a single card, orclaude -p "/linear"for several, streaming its output live. - Otherwise: sleeps, interruptible at any time by a webhook.
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 4748The 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 -fAt startup, the entrypoint:
- authenticates
ghandgitwithGITHUB_TOKEN(through a credential helper — the token is never written into.git/config) and sets the commit identity from the GitHub account, unlessGIT_USER_NAME/GIT_USER_EMAIL; - clones the repository into the
workspacevolume, or reuses it on restart, so the watcher state and claude's branches survive; - installs the
/lineartemplate when the repository has no such command; - registers Linear's MCP server (
https://mcp.linear.app/mcp) in Claude Code aslinear-server, authenticated withLINEAR_API_KEY; - installs the dependencies (
npm ci,pnpmoryarn, from the lockfile); - runs
npm run linear:watch -- $LINEAR_WATCHER_ARGS, orlinear-watcherdirectly 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.
- New resource → Public repository:
https://github.com/svengau/linear-watcher, build pack Docker Compose, compose file/docker-compose.coolify.yml. - Environment variables: Coolify lists the ones of the compose file —
fill in
GITHUB_REPO,GITHUB_TOKEN,LINEAR_API_KEYandANTHROPIC_API_KEY(orCLAUDE_CODE_OAUTH_TOKEN). - Webhook, either:
LINEAR_WATCHER_ARGS=--tunnel(unless thelinear:watchscript 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) andPUBLIC_URL=https://linear-watcher.example.com—PUBLIC_URLwins over--tunnel.
- Deploy. The
workspacevolume 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.
--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.
<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.
The decision logic is pure and importable:
import { computeWork } from "linear-watcher";
const { work, ignored } = computeWork(issues, state);npm test # node --testThe 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.
MIT