Skip to content

Linear source: issue notifications for agents, writes through Linear's MCP - #596

Draft
FRosner wants to merge 2 commits into
ClickHouse:mainfrom
FRosner:worktree-linear-support
Draft

FRosner wants to merge 2 commits into
ClickHouse:mainfrom
FRosner:worktree-linear-support

Conversation

@FRosner

@FRosner FRosner commented Oct 29, 2026

Copy link
Copy Markdown

Why

Nerve agents can't work with Linear yet. They should pick up issues assigned to them, react to mentions and comments, and write back (comment, change status or assignee).

The agent acts as a regular Linear user, through a service account or a personal API key, not as a native Linear agent app. An agent app would need webhooks, agent sessions, an OAuth install and a 10-second ack, and Nerve has no public webhook receiver. This PR is self-hosted only: triggers are set in Nerve's config, and the hosted contract and nerve-private are untouched. Records are shaped so that a hosted variant can be added later without changing them.

Building this exposed a generic bug, fixed in its own commit: a new source never woke the inbox-processor. consumer_has_unread ignores sources without a consumer cursor, and get_consumer_cursor then starts the cursor past the source's first records. This already affects github_repos (#593).

What

1. New sources reach existing consumers (nerve/db/sources.py, nerve/sources/runner.py)

  • Database.seed_consumer_cursors(source): before a source's first run (no source cursor), every consumer that has a cursor elsewhere gets one on the new source at its current MAX(rowid).
  • Seeding happens before anything is stored, so the first run's records are unread too, not only those after a baseline.

2. sync.linear source (nerve/sources/linear.py, modelled on github_repos, #593 / #594)

  • Polls the key's user's notifications over GraphQL with httpx. types maps to Linear notification types:
    • assigned → issueAssignedToYou
    • mentioned → issueMention, issueCommentMention
    • comments → issueNewComment
    • Reactions are never requested.
  • Design decisions:
    • The cursor is based on createdAt, because updatedAt moves on read and when the reminder e-mail goes out. It is JSON with {since, at, listed_at, viewer}; at breaks ties within the same millisecond.
    • Nothing is marked read. Marking read would change a human's Linear inbox, break the pure-ingestor contract, and lose notifications when a run fails.
    • The baseline reports nothing. So does a cursor that can't be read, or a key that now belongs to a different user.
    • After a gap of more than a day, only the last day plus an hour is reported, as in GitHub Repos source: report the last day after a gap, not nothing #594.
    • Every page is read, up to 10 pages of 50; past that cap the source logs a warning.
    • A mention in a comment and the matching new-comment notification collapse into one record, keyed by the comment and keeping the mention.
  • Filtered before storage: the agent's own actions, apps/bots/external users (unless include_bots; assignments always pass), deny_actors, and allow_teams/deny_teams. linear_inbox_filter repeats these rules as the runner guardrail.
  • HTTP errors, 429/RATELIMITED, GraphQL errors returned with HTTP 200, auth failures and timeouts all raise. The runner then keeps the cursor and backs off.

3. Writes go through Linear's official MCP server

  • _parse_mcp_servers adds built-in servers. With sync.linear enabled, mcp: true and a key, it registers linear (https://mcp.linear.app/mcp, Authorization: Bearer <key>).
  • This is the single place both startup and load_mcp_servers (mcp_reload) get their servers from, so a reload adds or removes the server.
  • A user entry mcp_servers.linear wins. The key never reaches the DB, and Codex receives it through env_http_headers.

Public config (new, everything off by default and follows a reload):

sync:
  linear:
    enabled: false
    schedule: "*/5 * * * *"
    api_key: ${LINEAR_API_KEY}   # config.local.yaml
    types: [assigned, mentioned, comments]
    include_bots: false
    deny_actors: []
    allow_teams: []
    deny_teams: []
    mcp: true
    batch_size: 50
    condense: false
  • sync.linear.api_key is added to the setup provenance credential paths.
  • ("sync", "linear") is added to the effectful settings keys, because it registers an MCP server.

Surfaces

  • nerve doctor: a Linear line covering off, no key, types, schedule and MCP status. It warns in external auth mode, which ignores configured MCP servers.
  • A Linear row in the onboarding facts.
  • TOOLS.md templates: linear in the source list, plus a line on mcp__linear__*.
  • Sources page: LinearRenderer showing identifier, team, event and state badges, with a "View in Linear" link.
  • Docs: docs/sources.md, docs/config.md, docs/worker-guide.md (a linear-tasks job for workers, which have no inbox-processor), and config.example.yaml.
Manual verification (live Linear workspace)

Prerequisites: a test Linear workspace, a service-account user with a personal API key exported as LINEAR_API_KEY, and a second (human) user to assign, mention and comment.

  1. Configure. In config.local.yaml:

    sync:
      linear:
        enabled: true
        api_key: ${LINEAR_API_KEY}
  2. Doctor. Run nerve doctor. Expect [OK] Linear source: types assigned, mentioned, comments; schedule */5 * * * *; MCP registered as linear, and no Linear warning.

  3. Baseline. Run nerve sync linear twice. The first run takes a baseline and stores nothing; the second reports nothing new.

  4. Triggers. As the human user, on a test issue:

    • assign it to the service account → [ID] Assigned to you by <name>: <title>
    • mention the service account in the description → [ID] <name> mentioned you: <title>
    • post a comment on an issue the service account is subscribed to → [ID] <name> commented: <title>

    Run nerve sync linear. Expect three records with these summaries (Sources page → linear, rendered with the Linear card).

  5. Dedupe. Post one comment that mentions the service account → one record (mentioned), not two. Post two plain comments → two records.

  6. Self-filter. In a chat, run mcp_reload, then mcp__linear__get_issue on the test issue (it should work), then mcp__linear__save_comment on it. After nerve sync linear, expect no record for that comment.

  7. Inbox processing. Personal mode: the next inbox-processor run picks up the records via poll_all_sources. Worker mode: add the linear-tasks job from docs/worker-guide.md, assign an issue, and check that the agent comments, creates a task and moves the issue to In Progress.

  8. Off again. Set enabled: false and reload. nerve doctor shows the source off, the source:linear job is unscheduled, and after mcp_reload the linear MCP server is gone.

Before a source's first run (no source cursor), start a cursor on it for
every consumer that already has one, at its newest message. Without it
consumer_has_unread ignores the new source, so the inbox-processor's
messages gate never wakes for it, and the first poll starts past its first
records (this already affects github_repos).
…'s MCP

The agent acts as a regular Linear user (service account or personal API
key). sync.linear polls its issue notifications (assigned, mentioned,
comments) by createdAt, never marking anything read, and drops the agent's
own actions, bots (assignments exempt), denied actors and filtered teams
before storage; the runner guardrail repeats the settings. With mcp on,
Linear's hosted MCP server is registered as "linear" with the same key,
for startup and reload alike. Doctor, onboarding facts, TOOLS.md, docs,
example config and a Sources page renderer come with it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant