threadloom
v0.1.0
Published
Loom — local-first control plane for coding agents: one CLI, shared memory, baton handoffs and routes across Claude Code, OpenCode, and friends.
Maintainers
Readme
Loom
One CLI for all your coding agents. Loom weaves Claude Code, OpenCode — and bridges like Antigravity — into a single shared thread per project: one conversation, one shared memory, one baton.
iOS app ── tailnet ──┐ (v1.5)
laptop CLI ───────────┤ (v1 · now)
▼
loom daemon
│
┌───────────────┼───────────────┐
project A project B project C ← independent batons
│
┌───────┴────────┐
│ event log (SoT)│ ← every message/tool call/edit, streamed live
└───────┬────────┘
projections on handoff
┌────────┼─────────────┐
[adapter] [adapter] [bridge]
Claude OpenCode Antigravity
Code serve API debug port (read-only)
└── one working tree · baton = write lock ──┘Why
Every coding agent keeps its own brain. Claude Code's memory can't be read by Antigravity; OpenCode doesn't know what you decided with Claude an hour ago. Switching tools means re-explaining your project, every time.
Loom fixes the seam:
- Shared thread — one conversation; agents take turns.
- Shared memory — an append-only event log is the source of truth; on every handoff
it's projected into the next agent's native context (namespaced — your own
CLAUDE.md/AGENTS.mdare never touched). - The baton — exactly one agent holds the write lock per project. Handoffs are explicit, confirmed, and interrupt-safe.
- Roles — declare a planner / executor / reviewer; Loom suggests handoffs at natural boundaries ("plan looks complete — hand to the executor?"). You confirm.
- Routes — or let Loom drive the chain:
loom route ship "add dark mode"runs plan → execute → review as one command, pausing (and notifying you) whenever an agent has a question, resuming when you answer. - Fire-and-notify — agents run in the background across many projects; Loom notifies you when one finishes or needs input.
- Phone-ready — bind the daemon to your Tailscale interface, pair a device with a single-use QR token, and every surface talks to the same API. (Native iOS app: v1.5.)
Install
Requires Node ≥ 22.5 (Loom's event log uses the built-in node:sqlite).
npm install -g threadloom # → `loom` on your PATHOther paths:
# one-liner from source (clones ~/.loom-src, builds, links; re-run to update)
curl -fsSL https://raw.githubusercontent.com/nickthelegend/loom/main/scripts/install.sh | bash
# straight from git
npm install -g github:nickthelegend/loom
# hackable checkout
git clone https://github.com/nickthelegend/loom.git && cd loom
npm install && npm run build && npm linkThen verify the setup:
loom doctor # checks node, agents, tailscale, daemon, and your projectThe native app lives in app/ — cd app && npx expo install &&
npx expo start, scan with Expo Go. The daemon-served web app needs no install at all
(loom pair → scan).
Quickstart
cd your-project
loom init # detects installed agents (claude, opencode), assigns roles
loom # opens the TUI — one full-screen thread over every agent ██ ▄████▄ ▄████▄ ▄█▄▄█▄
██ ██ ██ ██ ██ ██▀▀██
██ ██ ██ ██ ██ ██ ██
██████ ▀████▀ ▀████▀ ██ ██
one thread · every agent
10:44 claude-code here's the plan: …
⟶ baton: claude-code → opencode
10:45 opencode implementing step 1 …
╭──────────────────────────────────────────────╮
│ › Ask anything… "/route ship: add dark mode" │
│ opencode · executor ⟵ baton │
╰──────────────────────────────────────────────╯
tab shift agent · /help · esc interrupt loom 0.1.0
~ my-project · baton opencode ➤ ship 2/3tab shifts the active agent/IDE — claude-code → opencode → back. The handoff
(interrupt-safe, memory projected, briefing armed) happens when you hit enter, so
switching is one keystroke, not a ceremony. ctrl+p opens the command palette
(fuzzy-filtered: shift to any agent, launch a named route, decision, interrupt, pair…).
esc interrupts, /help lists the slash commands.
Prefer plain line-mode (SSH, scripts)? loom chat is the same thread as a classic REPL,
and every action also exists as a one-shot command (loom send, loom handoff, …).
Routing — multi-hop pipelines
Handoffs are unlimited and manual by default. Routes automate a chain of them:
loom route auto "add a dark-mode toggle" # DYNAMIC: an LLM picks each hop
loom route ship "add a dark-mode toggle" # named pipeline from config
loom route planner,executor "fix the flaky test" # ad-hoc: roles…
loom route claude-code,opencode,claude-code "…" # …or agent ids, any lengthauto is dynamic routing: after every hop, a router looks at the task, the hop
history, and the last replies, then picks the next agent — or declares the task done.
The router is Claude (headless, small model, JSON out) with a deterministic
plan→execute→review rules engine as automatic fallback, so routes never stall on a
router failure. Every decision is logged with its reason
(➤ hop 2 → opencode (plan ready — execute it)), a hop budget caps runaways
(--max-hops, default 8), and --router rules skips the LLM entirely.
What happens per hop: interrupt-safe handoff → shared-memory projection → briefing → the step's role instruction. Then:
- step finishes cleanly → Loom advances to the next agent automatically;
- the agent asks a question → the route pauses (
waiting_human), you get a notification,loom route --statusand the board show the question; you answer in the shared thread (loom send "…") and the route resumes by itself; - an agent errors or a step times out (45 min default) → the route fails loudly;
- you always outrank the route: any manual
handoff/interruptcancels it, andloom route --abortstops it and interrupts the in-flight turn.
--detach returns immediately (fire-and-notify); following with Ctrl-C also leaves the
route running server-side. One route per project at a time (the baton is one write
lock); run routes across different projects in parallel freely.
Define named pipelines in .loom/config.json — steps are roles or agent ids, and any
step can carry its own focus:
"routes": {
"ship": ["planner", "executor", "reviewer"],
"api-only": [
{ "step": "planner", "instruction": "design the endpoint contract only" },
{ "step": "executor", "instruction": "only touch src/api — no schema changes" },
"reviewer"
]
}Per-step instructions are appended to the role guidance for exactly that step — the
next hop never sees them. loom init seeds a ship route automatically when it
detects at least two roles.
Commands
| Command | What it does |
|---|---|
| loom | The TUI — full-screen thread, tab shifts agents, /-commands inline |
| loom init | Make the current directory a Loom project (auto-detects agents) |
| loom chat | Same thread as a plain line REPL (/handoff, /interrupt, @agent) |
| loom send <text> | One-shot message (-a <agent> to address someone specific) |
| loom handoff <agent> | Pass the baton — interrupts, projects memory, briefs the target |
| loom route <spec> "<task>" | Run a pipeline (name, or a,b,c ids/roles); --status / --abort / --detach |
| loom routes | List named pipelines defined for this project |
| loom interrupt | Stop the current holder's turn (cancels an active route) |
| loom decision <text> | Record a decision into shared memory |
| loom log [-f] | Show (or follow) the project event log |
| loom costs | Project spend: total + per-agent turns, $ and agent time |
| loom agents / loom projects / loom status | Who's who, board of projects, daemon health |
| loom up [--tailnet] [--restart] / loom down / loom daemon | Daemon lifecycle (--tailnet binds to your Tailscale IP) |
| loom pair | QR deep link that pairs a phone (single-use token) |
| loom clients [--revoke <id>] [--ping] | Paired devices: list, revoke, or send a test push |
| loom doctor | Diagnose env, daemon, binding, and project config — with fixes |
Supported agents
| Agent | Tier | Transport | Status |
|---|---|---|---|
| Claude Code | adapter (full-duplex) | headless CLI, stream-json, --resume, briefing via --append-system-prompt | ✅ verified against 2.1.83 |
| OpenCode | adapter (full-duplex) | opencode serve HTTP + SSE (/prompt, /interrupt, /event) | ✅ verified against 1.17.20 |
| Echo | adapter (demo/tests) | in-process | ✅ |
| Antigravity | bridge (read-mostly) | Chromium debug port — presence + memory projections only | 🔶 experimental |
Adapters implement the full contract (send / stream / injectMemory / interrupt / diff) and may hold the baton. Bridges only observe and receive shared-memory projections — they never hold the write lock. That's a design decision, not a gap: GUI agents without a stable API can't be trusted with interrupt-safe writes. See docs/integration-notes.md for the verified surfaces.
How it works
- Event log (
.loom/log.db, SQLite vianode:sqlite, JSONL fallback) — every message, tool call, file edit, decision, and handoff, appended in order. The log is the project's memory; everything else is a view of it. - Projection — on handoff, Loom distills the log into
.loom/memory/<agent>.md(persistent, namespaced) and arms a short one-shot briefing injected with the target's next turn (system-prompt append for Claude Code, delimited preamble for OpenCode). Two renderers behind one interface:- template (default) — deterministic, instant, free;
- llm — a small Claude model distills the recent log into a dense doc
(mission / current state / decisions / risks / next moves). Opt in per project:
"projection": { "mode": "llm", "model": "haiku" }. Any failure or timeout falls back to the template — a broken Claude never blocks a handoff. Bridges always get template views (no N×LLM waste per hop).
- Baton — persisted per project (
.loom/state.json). Messages route to the holder; addressing a non-holder returns409 not_holderand the surface asks you to confirm a handoff. Ghost holders (agent removed from config) self-heal. Every handoff snapshots the outgoing agent's working-tree state (dirty flag +git status) into the log. - Decisions —
loom decision <text>pins a fact, and any agent line startingDecision: …is captured automatically. Decisions ride every future projection. - Cost telemetry — agents that report per-turn cost (Claude Code, OpenCode) feed a
live ledger:
loom costsbreaks it down per agent, the board/TUI/phone app show the project total, and every route logs exactly what it spent (✔ route completed (3 steps) · $0.0421). Totals rehydrate from the event log, so they survive restarts. - Daemon — one process, many projects. REST for commands, WebSocket for the live stream. Config edits hot-reload when the project is quiet.
Your phone (Android today, over Tailscale)
The daemon serves a full phone app at /app — board, live thread, agent chips, routes.
No app store, no build step; it ships inside Loom.
loom up --tailnet # daemon binds to your Tailscale IP (never 0.0.0.0)
loom pair # QR appears in the terminalScan the QR with your phone camera (phone must be on your tailnet — install the
Tailscale app and sign in). The link opens …/app#pair=<token>; the app claims the
single-use, 10-minute pairing token from the URL fragment (fragments never hit the
network log) and exchanges it for its own client token. Then:
- Board — every project, needs-input dots, baton holder, live route progress.
- Thread — the same shared conversation, streaming over WebSocket.
- Agent chips — tap
opencode, hit send: baton shifts (projection + briefing included), exactly liketabin the TUI. - Routes — the ➤ button opens a picker: choose auto (LLM picks each hop), any named pipeline, or custom steps, type the task, go. Live banner with hop progress and reasons, an abort button, and when a route pauses on a question you answer right there and it resumes.
- Chrome menu → Add to Home screen installs it like an app.
Push notifications come with the native app (app/): open it once
after pairing and it registers its Expo push token with the daemon. From then on your
phone buzzes when an agent needs input, when a route completes or fails, and
when a solo turn finishes — route hops are deliberately silent (a 5-step pipeline
buzzes once, not five times). Verify with loom clients --ping.
Security model
- The daemon binds to
127.0.0.1by default, or your Tailscale interface with--tailnet— never0.0.0.0. The tailnet is the trust boundary: device auth and E2E encryption come from Tailscale. - Every request needs a bearer token (
~/.loom/daemon.json, mode 0600). - Pairing:
loom pairmints a short-lived (10 min), single-use token, rendered as a QR. The device exchanges it for a long-lived client token. Secrets never ride in URLs. - Paired clients are not admins: they can't mint new pairing tokens.
Adapter SDK
Add an agent in ~40 lines — implement the contract, register the kind:
import { AdapterBase, registerAgentKind, type SendInput } from "loom-agents/sdk";
class MyAgentAdapter extends AdapterBase {
async available() { return true; }
async start() {}
async stop() {}
async send(input: SendInput) {
this._busy = true;
try {
// …drive your agent; stream progress:
this.emit({ kind: "message", payload: { text: "done!" } });
this.emit({ kind: "run_complete", payload: {} });
} finally { this._busy = false; }
}
async interrupt() {}
}
registerAgentKind("my-agent", (cfg, dir) => new MyAgentAdapter(cfg.id, "my-agent", dir));Full guide: docs/adapters.md. Design rationale and every decision with its why: ARCHITECTURE.md.
Configuration
.loom/config.json (created by loom init, hot-reloaded on edit):
{
"name": "my-project",
"agents": [
{ "id": "claude-code", "kind": "claude-code", "role": "planner" },
{ "id": "opencode", "kind": "opencode", "role": "executor",
"options": {} },
{ "id": "antigravity", "kind": "antigravity", "role": "general",
"options": { "debugPort": 9222 } }
],
"defaultAgent": "claude-code",
"routes": { "ship": ["planner", "executor", "planner"] }
}Roles: planner · executor · reviewer · general. Claude Code options:
permissionMode (default acceptEdits), model. OpenCode options:
model ("providerID/modelID", e.g. "opencode/minimax-m2.5" — set this: headless
sessions don't inherit your TUI default), agent, baseUrl to reuse a running server.
Development
npm test # 37 tests: unit + full HTTP/WS end-to-end
npm run build # tsc → dist/
npm run dev # run the CLI from source (tsx)Roadmap
- Native mobile app (push notifications on needs input / done) on the same daemon API the served app already uses.
- LLM-synthesized projections behind the same interface.
- More adapters/bridges via the SDK — contributions welcome.
License
MIT © Nivesh Gajengi
