Documentation

Linear sync

Two-way sync between a factory project and one Linear team/project: issues, their statuses, and their comments. The factory is the source of truth for which statuses exist.

Turning it on

  1. Credential (factory-wide). Put a Linear API key in .env and redeploy:

    LINEAR_API_KEY=lin_api_xxx
    LINEAR_WEBHOOK_URL=https://factory.example.com   # your public/tunnel host

    With LINEAR_API_KEY unset the feature is completely inert — no Linear calls anywhere, no errors. LINEAR_WEBHOOK_URL is what Linear posts to; without it sync still works but only on the poll interval (LINEAR_POLL_SEC, default 300s), and the server logs a warning at first link.

  1. Per project (opt-in). Add a linear: block to the project's YAML (or fill in the Linear block on the project's settings → Connection tab):

    linear:
      team: ENG              # team key, name, or id — required
      project: "Website"     # Linear project name or id — optional
      manage_states: true    # optional, default true; see the warning below

    Projects without the block are untouched. Removing it stops all pushes and makes inbound events no-ops; nothing is deleted on either side.

What syncs

Direction
Issue createboth ways
Title / description / statusboth ways
Comment createboth ways, attributed (**Linear (name)** commented: / **AI Factory** commented:)
Workflow statesfactory → Linear only
Issue delete in Linearunpairs; the factory issue stays

Not synced (v1): assignees, labels, priority, attachments, sub-issues, cycles, comment edits/deletes, and per-person authorship — everything the factory pushes appears as the API key's user.

An inbound status change goes through the same code path as a status change made in the dashboard, so it triggers the pipeline exactly the same way.

⚠ State reconciliation rewrites the whole Linear TEAM

Linear workflow states belong to a team, not a project. When the factory's status list changes it creates missing states, renames/reorders existing ones, and archives states that aren't in the factory workflow — after moving any issues on them to the closest remaining state of the same type. That affects every Linear project in the team.

Point the link at a dedicated team, or set manage_states: false to make reconciliation additive-only (create/rename, never archive).

Loop prevention

Each paired issue stores a shadow of the last value both sides agreed on, per field. Outbound pushes when local != shadow and then sets shadow = local; inbound applies when remote != shadow and then sets shadow = remote. So the webhook echo of our own push finds remote == shadow and does nothing, and the beat after an inbound apply finds local == shadow and pushes nothing. If both sides moved since the last agreement, the factory wins (logged), and the next beat pushes its value once.

Comments use the same idea by existence: an inbound comment gets its linear_comment_links row in the same transaction as the comment, so the outbound pass never sees it as unpushed.

Caveats

  • Not retroactive. Sync pairs issues from the moment the project links. Issues that already existed in Linear are not adopted; issues that already exist in the factory are pushed to Linear as new issues.
  • One team per project, no fan-out.
  • Linear errors are logged, surfaced as last_error on the project settings page, and retried on the next beat. They never crash the server and never affect projects that aren't linked.

Manual smoke test (not in CI)

The automated suite stubs the Linear API entirely. To verify the real path once: set the key and a reachable LINEAR_WEBHOOK_URL, link a scratch team, then check that (a) the factory registered a webhook in Linear → Settings → API → Webhooks, (b) creating an issue on either side produces its pair, and (c) moving a status in Linear moves it in the factory within a second or two.