loom-beads
v0.1.0
Published
Reliable bidirectional sync between Linear and Beads. Unified CLI for beads + sync.
Downloads
106
Readme
loom-beads
Reliable bidirectional sync between Linear and Beads.
Why
bd linear sync is the current sync path between Beads and Linear. It works for initial imports but breaks down in real-world multi-user workflows:
The state sync bug (beads #2046): bd linear sync --push does not update issue state in Linear. The IssueToTracker field mapper in internal/linear/fieldmapper.go sends title, description, and priority — but omits stateId. CreateIssue handles state correctly; UpdateIssue does not. This means:
- A PM marks a Linear issue back to "Todo" with a comment
bd linear syncpulls the "Todo" state into beads correctly- Push detects the change (hash mismatch) but sends no state field
- Linear sees nothing to update. State drifts.
Hierarchy doesn't sync (beads #1528): Parent-child relationships (epics, sub-tasks) are not maintained. Linear's API supports parentId but beads doesn't send it on push.
Polling-only: bd linear sync must be run manually or via cron. No real-time reaction to changes on either side. Changes can sit unseen for hours.
Fragile conflict resolution: Last-write-wins by timestamp. When a PM and a developer both update the same issue, the newer timestamp wins silently. No visibility into what was overwritten.
No PR linking: Beads has no concept of linked PRs. Linear's native GitHub integration handles this, but there's no path from beads → Linear → PR status.
These aren't edge cases. In active projects with 3-5 concurrent agents and a PM managing the board, sync drift is a daily problem that erodes trust in the whole workflow.
What loom-beads does
A lightweight sync service that sits between Linear and Beads, handling bidirectional state synchronization with proper conflict resolution.
Inbound: Linear → Beads (polling by default, webhooks optional)
- Polls Linear API on a configurable interval (default: every 30 minutes) — works out of the box with just an API key
- Optionally receives Linear webhook events for instant sync when
webhook_secretis configured lb sync MEE-123syncs a single issue on demand- Maps Linear fields → bead fields using configurable state/priority/label mappings
- Creates, updates, or archives beads accordingly
- Preserves hierarchy (parent/child, epic/sub-task)
- Writes to
.beads/directory, which beads picks up natively
Outbound: Beads → Linear (near real-time)
- Watches
.beads/directory for changes (inotify on Linux, polling fallback) - Diffs against last-known Linear state (stored in local sync DB)
- Pushes deltas to Linear API via GraphQL — including state, priority, labels, hierarchy
- Debounced to avoid rapid-fire updates from agent activity
Conflict resolution
- Tracks last-synced state for every field independently (not just a whole-issue timestamp)
- Three-way merge: compares local, remote, and last-synced baseline
- If only one side changed a field: that side wins (no conflict)
- If both sides changed the same field: configurable strategy per field type
status→ prefer Linear (PM is source of truth for status)description→ prefer local (agents are source of truth for implementation details)- Other fields → prefer Linear by default, configurable
- All conflicts logged with full before/after context
PR visibility
loom-beads does not replace Linear's native GitHub integration for PR linking — that works well and should be enabled separately. What loom-beads adds:
- When a bead's PR number is known (set by agents during implementation), sync it to a custom field or comment on the Linear issue
- When Linear's GitHub integration updates PR status, that's visible on the Linear issue without loom-beads involvement
Architecture
┌────────────┐ polling / webhook ┌─────────────┐
│ Linear │ ──────────────────────► │ loom-beads │
│ │ ◄────────────────────── │ service │
└────────────┘ GraphQL API └──────┬──────┘
│
┌──────────┼──────────┐
│ │ │
▼ ▼ ▼
┌─────────┐ ┌────────┐ ┌────────┐
│ .beads/ │ │ sync │ │ config │
│ files │ │ state │ │ │
└─────────┘ │ (SQLite│ └────────┘
└────────┘Single process. Runs the HTTP server (with polling and optional webhook receiver) and the file watcher in the same Node.js process. Sync state stored in SQLite (separate from beads' own database).
Sync state model
For each synced issue, loom-beads tracks:
sync_state (
bead_id TEXT,
linear_id TEXT,
field TEXT, -- 'status' | 'title' | 'description' | 'priority' | 'parent' | ...
last_synced TEXT, -- last known value at sync time (the baseline)
synced_at DATETIME,
direction TEXT -- which side last wrote this field
)This per-field baseline is what enables three-way merge. Without it, you're stuck with whole-issue timestamps and silent data loss.
Getting started
Install loom-beads globally and run the setup wizard:
npm install -g loom-beads
lb initlb init bootstraps everything in one step: initializes beads if .beads/ doesn't exist, creates .loom-beads/config.yaml, sets environment variables, and installs the git hook.
lb is the unified CLI for both beads and loom-beads. All beads commands work through lb, and Linear slugs (e.g. MEE-123) are resolved to bead IDs automatically.
# Sync commands
lb start # start server with live sync and activity feed
lb start --verbose # start with detailed field-level change logging
lb health # check server health
lb status MEE-123 # sync state for an issue
lb create # create paired bead + Linear ticket
lb sync # trigger sync manually
lb sync MEE-123 # sync a single issue on demand
lb import # import existing beads with external_ref into sync
# Beads commands (forwarded to bd)
lb list # list beads
lb ready # show ready work
lb show MEE-123 # show bead details (slug resolved automatically)
lb close MEE-123 # close a bead (slug resolved automatically)lb start is the primary command — it starts the server, runs a startup
reconcile (catching up on changes made while offline), watches .beads/
for local changes, and prints a color-coded activity feed to the terminal
showing all sync activity as it happens. Use --verbose (-v) for
field-level detail on every change.
For LLM agents, the wizard generates AGENT.md with workflow
instructions. Add @AGENT.md to your CLAUDE.md.
See docs/setup.md for the full setup guide and manual setup instructions.
Configuration
# .loom-beads/config.yaml
linear:
api_key: ${LINEAR_API_KEY}
team_id: "abc-123"
# webhook_secret: ${LINEAR_WEBHOOK_SECRET} # optional — enable for instant webhook sync
beads:
project_dir: "/path/to/project"
bd_path: "bd" # optional, defaults to "bd"
sync:
poll_interval_ms: 1800000 # 30 min default; set to 0 to disable polling
debounce_ms: 3000
conflict_strategy:
status: prefer_linear
priority: prefer_linear
description: prefer_local
title: prefer_local
labels: prefer_linear
default: prefer_linear
server:
port: 3848
host: "0.0.0.0"
state_map:
backlog: open
triage: open
unstarted: open
started: in_progress
completed: closed
canceled: closed
duplicate: closedAPI
GET /health -- service health and uptime
POST /webhook/linear -- Linear webhook receiver (HMAC verified, optional)
POST /hook/commit -- git post-commit trigger for outbound sync
GET /status/:slug -- sync state for a Linear issue (e.g. MEE-123)
GET /lookup/:slug -- lookup sync state by Linear slug
GET /lookup/bead/:beadId -- lookup sync state by bead ID
GET /log -- recent sync operations (?limit=N&slug=X)
GET /conflicts -- unresolved conflicts with context
POST /conflicts/:id/resolve -- manually resolve a conflict
POST /sync/trigger -- trigger full sync (inbound + outbound)
POST /sync/trigger/inbound -- trigger inbound sync only
POST /sync/trigger/inbound/:slug -- sync a single issue (e.g. MEE-123)
POST /sync/trigger/outbound -- trigger outbound sync onlyRelationship to Loom
loom-beads is a standalone service. It does not depend on Loom and can be used independently in any project that uses Beads + Linear.
Loom consumes loom-beads' API to:
- Check sync health before launching threads
- Trigger manual sync after agent completion
- Surface sync conflicts in the Loom UI
Current status
v0.1.0 — all core sync infrastructure is implemented and tested:
- Inbound sync (Linear → Beads) via polling (default) or webhooks, with conflict detection
- Outbound sync (Beads → Linear) via filesystem watcher and git post-commit hooks
- Startup reconcile: catches up on changes from both sides when the server starts
- Live activity feed: color-coded terminal output showing all sync activity in real time
- Per-field conflict resolution with configurable strategies (prefer_linear, prefer_local, most_recent, manual)
- Three-way merge for multi-line fields using node-diff3
- Echo guard to prevent sync loops
- Webhook idempotency with automatic delivery cleanup
- Retry logic with exponential backoff for Linear API calls
- Graceful shutdown with SIGTERM/SIGINT handling
- Interactive setup wizard via
lb init— bootstraps beads and loom-beads in one step lbunified CLI — handles sync commands directly, forwards all other commands tobd- Linear slug resolution —
lb show MEE-123resolves to the corresponding bead ID - Agent instruction template (
AGENT.md) generated by the init wizard
See docs/roadmap.md for planned features and future work.
