Linear webhook drain, polling sync, and one-time backfill.
The Linear domain archives Linear entities — issues, comments, cycles, projects, and labels — as individual JSON files with reverse-diff change history. Three ingestion paths feed the archive:
drain.ts) — real-time stream from Linear's webhook system via the jeeves-server Event Gateway. Each incoming event is routed by action + type and persisted immediately.sync-issues.ts, sync-comments.ts) — scheduled runner jobs that poll the Linear GraphQL API for changes since the last cursor. Fills in any gaps when webhooks are missed.backfill.ts) — one-time historical import via the Linear GraphQL API. Run once when bootstrapping a new instance or after data loss. Existing files are skipped.Linear uses human-readable field names natively — no custom field translation is needed.
flowchart TD
linear["Linear"] -->|webhook POST| gw["jeeves-server\nEvent Gateway"]
gw -->|stdin pipe| drain["drain.ts\n(routes by action + type)"]
drain -->|"Issue create/update"| issue["issue/ACME-123.json"]
drain -->|Issue remove| del["delete file"]
drain -->|"Comment create/update"| comment["comment/{uuid}.json"]
drain -->|Comment remove| del
drain -->|"Cycle create/update"| cycle["cycle/{number}.json"]
drain -->|"Project create/update"| project["project/{uuid}.json"]
drain -->|"IssueLabel create/update"| label["label/{uuid}.json"]
drain -->|unrecognised| unmatched["_unmatched/ts-action-type.json"]
sync_i["sync-issues.ts\n(every 23 min)"] -->|"GraphQL /graphql"| issue
sync_c["sync-comments.ts\n(every 29 min)"] -->|"GraphQL /graphql"| comment
backfill["backfill.ts\n(one-time, manual)"] -->|"GraphQL paginate"| issue
Domain dir =
getBasePathForLinear()/linear/
| Script | Description |
|---|---|
drain.ts |
Webhook drain — reads stdin, routes by action + type, persists entity snapshots |
sync-issues.ts |
Polling sync for issues - runner job linear-sync-issues, every 23 minutes |
sync-comments.ts |
Polling sync for comments - runner job linear-sync-comments, every 29 minutes |
backfill.ts |
One-time historical import via GraphQL (--team KEY [--type issue|comment] [--live]) |
Supporting modules in lib/:
| Module | Description |
|---|---|
lib/linear-client.ts |
loadConfig, linearQuery, paginateIssues, paginateComments, enrichComment, linearConfigSchema — typed GraphQL client and helpers |
../lib/entity-store.ts |
upsertEntity, backfillEntity, deleteEntity, writeUnmatched, readStdinJson — shared file I/O with diff history (see entity-store docs) |
{CONTENT_DIR}/linear/ ← or silo basePath/linear/ in multi-tenant
issue/
ACME-1.json
CRE-2.json
...
comment/
{uuid}.json
...
cycle/
1.json
2.json
...
project/
{uuid}.json
...
label/
{uuid}.json
...
_unmatched/ ← raw payloads for events not yet routed
1718800000000-create-Unknown.json
| Entity Type | Payload Object | Key Field | Example File |
|---|---|---|---|
issue |
body.data |
identifier |
issue/ACME-1.json |
comment |
body.data |
id |
comment/{uuid}.json |
cycle |
body.data |
number |
cycle/1.json |
project |
body.data |
id |
project/{uuid}.json |
label |
body.data |
id |
label/{uuid}.json |
Each entity file is a JSON object with the following structure:
{
"entityType": "issue",
"entityKey": "ACME-1",
"current": {
"id": "abc123",
"identifier": "ACME-1",
"title": "Fix login bug",
"state": { "id": "...", "name": "In Progress", "type": "started" },
"assignee": {
"id": "...",
"name": "Jane Smith",
"email": "jane@example.com"
},
"priority": 2,
"priorityLabel": "High",
"updatedAt": "2026-06-29T11:00:00.000Z"
},
"history": [
{
"ts": "2026-06-28T10:00:00.000Z",
"patch": [{ "op": "replace", "path": "/state/name", "value": "Todo" }]
}
],
"meta": {
"firstSeen": "2026-06-01T08:00:00.000Z",
"lastWebhook": "2026-06-29T11:00:00.000Z",
"lastBackfill": null,
"version": 7
}
}
Reverse-diff model: history entries are JSON patches that transform the current snapshot back to the previous state. Apply patches in order from newest to oldest to reconstruct any prior state.
Linear delivers payloads with an action + type discriminator (unlike Jira's webhookEvent):
{
"action": "create",
"type": "Issue",
"data": {
"id": "abc123",
"identifier": "ACME-1",
"title": "Fix login bug",
"...": "..."
},
"url": "https://linear.app/team/issue/ACME-1",
"createdAt": "2026-06-29T11:00:00.000Z"
}
Action values: "create", "update", "remove"
Type values: "Issue", "Comment", "Cycle", "Project", "IssueLabel"
https://your-jeeves-server.example.com/event?key=<event-key> (all event types share the same endpoint; routing is body-based via schema matching)webhookSecret in LINEAR_CONFIG_PATH.Note: Linear webhooks deliver events within seconds. There is no replay mechanism — events missed while the gateway is down will not be redelivered. The polling sync jobs (
sync-issues.ts,sync-comments.ts) fill in missed windows automatically.
Add this block to your jeeves-server Event Gateway configuration:
{
"events": {
"linear": {
"schema": {
"type": "object",
"required": ["action", "type"],
"properties": {
"action": { "type": "string" },
"type": { "type": "string" },
},
},
"cmd": "node $JEEVES_SCRIPTS_DIR/bin/jeeves-scripts.js run linear-drain",
"timeoutMs": 10000,
},
},
}
$JEEVES_SCRIPTS_DIR — absolute path to the instance repo (paths().scriptsDir)linear-drain: a core runnable with no manifest (event-driven: the event gateway runs run linear-drain)schema — JSON Schema validated against the POST body (ajv, first match wins across all configured events)cmd — the server spawns this command and pipes the request body JSON to stdintimeoutMs — kill timeout for the drain process (falls back to global eventTimeoutMs)Webhook URL:
POST https://<instance>/event?key=<event-key>— all event types share the same endpoint; routing is body-based via schema matching. This is the same pattern used by the Jira drain.
The two sync jobs use runner scalar state to track a cursor (ISO timestamp of the most recently seen updatedAt):
| Job | State namespace | State key | Schedule |
|---|---|---|---|
sync-issues.ts |
linear |
sync-issues-cursor |
Every 23 min |
sync-comments.ts |
linear |
sync-comments-cursor |
Every 29 min |
On first run (no cursor), all issues/comments are fetched from the beginning. On subsequent runs, only entities updated since the last cursor are fetched. Rate-limited at 200 ms between pages.
Run the backfill once after setting up the webhook to populate historical data: run the core runnable linear-backfill (no manifest needed):
# Dry run (preview — no writes)
node bin/jeeves-scripts.js run linear-backfill --team CRE
# Live run (actual writes)
node bin/jeeves-scripts.js run linear-backfill --team CRE --live
# Backfill comments (no team filter needed)
node bin/jeeves-scripts.js run linear-backfill --type comment --live
CLI arguments:
| Argument | Default | Description |
|---|---|---|
--team KEY |
(required for issue) | Linear team key (e.g. CRE) |
--type TYPE |
issue |
Entity type: issue or comment |
--live |
false | Perform actual writes (dry-run by default) |
Rate limiting: 200 ms pause between API pages to avoid hitting Linear rate limits.
Skip behaviour: The backfill skips any entity file that already exists on disk. This ensures sync/webhook-delivered data (which is fresher) is never overwritten by the backfill.
The GraphQL client in lib/linear-client.ts uses Linear's standard GraphQL endpoint with plain Authorization: <key> header (not Bearer). No extra dependencies — uses Node's built-in fetch.
Key differences from the Jira client:
config.apiUrl) instead of REST (GET)Authorization: <key>, not Bearer) instead of Basic authafter: $cursor, pageInfo.endCursor) instead of offset paginationLinear entities require watcher inference rules and maps before they appear in semantic search. These are instance-side watcher config, not template code.
Inference rules (4 required):
linear-issue — matches linear/issue/*.jsonlinear-comment — matches linear/comment/*.jsonlinear-cycle — matches linear/cycle/*.jsonlinear-project — matches linear/project/*.jsonMaps (2 required):
linear-issue — extracts issue fields (identifier, title, state, assignee, priority)linear-comment — extracts comment fields (body, user, issue reference)Tracked as jeeves-tools #99.
Meta autoSeed entries for automatic .meta/ directory creation on new Linear entities are tracked as jeeves-tools #98.
| Prerequisite | Where to configure |
|---|---|
| Linear API key | Linear Settings → API → Personal API keys |
LINEAR_CONFIG_PATH |
LINEAR_CONFIG_PATH env, else integrations.linear.configPath, else {credentialsDir}/linear.json |
| Config file format | { "apiKey": "lin_api_...", "apiUrl": "https://api.linear.app/graphql" } |
| Linear webhook configured to POST to jeeves-server | Linear Settings → API → Webhooks |
jeeves-server Event Gateway configured with linear schema |
jeeves-server config (see Event Gateway Config above) |
| Watcher inference rules + maps for Linear entities | Instance watcher config (jeeves-tools #99) |
| Meta autoSeed entries for Linear entity discovery | Instance meta config (jeeves-tools #98) |
| File | Purpose |
|---|---|
drain.ts |
Webhook drain entry point — stdin pipe from Event Gateway |
sync-issues.ts |
Polling sync for issues (runner job, every 23 min) |
sync-comments.ts |
Polling sync for comments (runner job, every 29 min) |
backfill.ts |
One-time historical backfill via GraphQL API |
lib/linear-client.ts |
Typed Linear GraphQL client (fetch, paginate) |
../lib/entity-store.ts |
File I/O helpers with reverse-diff history (shared with Jira) |
core jobs/linear.json |
Runner job manifests (sync-issues, sync-comments schedules) |
../lib/constants.ts |
constants(): LINEAR_CONFIG_PATH, LINEAR_MAX_HISTORY from integrations.linear in the instance config |