Loom captures agent session transcripts, ships them to a central receiver, folds them into a normalized summary database, and surfaces both raw state and durable knowledge through an interactive TUI. See docs/overview.md for the project philosophy.
| Subsystem | State | What it does |
|---|---|---|
transport |
v1, usable | Ships agent session JSONL files to a central receiver |
summarizer |
v1, usable | Folds received sessions into ~/.loom/summaries.db (sqlite) |
extractor |
v1, usable | Runs extractors/extract.py over newly summarized sessions |
tui |
v1, usable | Interactive dashboard for projects, sessions, and knowledge |
loom CLI |
v1, usable | Unified entry point (tui, summarize, install, status, ...) |
Agents supported: Claude Code (sessions at ~/.claude/projects/<sanitized-cwd>/<uuid>.jsonl) and Codex CLI (rollouts at ~/.codex/sessions/**/rollout-<ts>-<uuid>.jsonl). Cursor CLI SQLite sessions at ~/.cursor/chats/<workspace-hash>/<uuid>/store.db and their children are captured and summarized too. See Cursor summary mappings and coverage.
extractors/ is a Python project (truth/decision extraction from session summaries) that operates over the durable knowledge store at ~/.loom/knowledge/ — its own git repo, see that store's SCHEMA.md. The TUI reads that store and the loom extract agent invokes extract.py against new sessions. Every write to the store — from the TUI's promote/reject/edit gestures and from the Python extractor alike — goes through one Go entry point that commits what it wrote and pushes the commit; see docs/knowledge-store-writes.md. What the extraction path strips out of transcript text, where that text is allowed to flow, and what a consumer may trust it for are in docs/transcript-trust-and-redaction.md. Extraction is gated per project on a scope directory in that store, and which name a session resolves to — plus how to onboard a project that has none — is in docs/knowledge-scopes.md. The extractor routes each candidate to one of two destinations — a truth for promotion, or a ticket for filing into tk after a duplicate check — both behind human review; see docs/candidate-destinations.md.
- macOS (the shipper uses launchd; Linux support is not planned until there is a real reason)
- Go 1.22 or newer (1.26 tested)
loom/
go.mod # module "loom"
install.sh # transitional forwarder over `loom install`
cmd/loom/ # the only binary; cobra-based subcommand surface
internal/
config/ # loom-wide config (Home, config.json schema)
launchd/ # plist generation + bootout/bootstrap helpers
parse/ # session-transcript parsers + sqlite store
summarize/ # received → summaries.db sweep
tui/ # interactive dashboard
transport/
shipper/ # client logic (capture, ship, daemon)
receiver/ # server logic (HTTP ingest)
internal/ # wire, cursor, source, staging, notify
docs/ extractors/ # non-Go subsystems
knowledge/ # eval fixtures only — durable store is ~/.loom/knowledge/
Everything is driven by the unified loom binary.
Download the latest release tarball for your OS/arch from GitHub Releases and extract loom to ~/.local/bin/loom:
OS=$(uname -s | tr '[:upper:]' '[:lower:]') # darwin | linux
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') # amd64 | arm64
VER=$(curl -fsSL https://api.github.com/repos/EnderRealm/loom/releases/latest | grep -m1 '"tag_name"' | cut -d'"' -f4 | sed 's/^v//')
mkdir -p ~/.local/bin
curl -fsSL "https://github.com/EnderRealm/loom/releases/download/v${VER}/loom_${VER}_${OS}_${ARCH}.tar.gz" | tar -xzf - -C ~/.local/bin loomThen install the launchd agents you want and the updater that keeps the binary on the latest release:
loom install server # or remote / receiver / summarizer / extractor / shipper
loom install updater # downloads + installs each new release; see Auto-updateBuild the binary locally to run unreleased changes — this is independent of the installed/daemon binary:
git clone git@github.com:EnderRealm/loom.git
cd loom
go build -o ./loom ./cmd/loom # or: make dev
./loom <command>loom install server # receiver + summarizer + extractor; records role=server
loom install remote # shipper (client side); records role=remote
loom install receiver # receiver only
loom install summarizer # summarizer only
loom install extractor # knowledge extraction trigger only
loom install shipper # shipper only (no role change)
loom install updater # keep the binary on the latest release — see Auto-update
loom uninstall # remove all loom launchd agents
loom status # show state of all installed components
loom ui # open the dashboard (alias: loom tui)
loom ui --run <id> # open on one run's detail; w lists recent runsThe server and remote profiles also record the machine's role under $LOOM_HOME/role. loom dev and loom status scope their health rollup to the daemons that role expects, so a remote machine running only the shipper no longer reports "degraded" for the receiver/summarizer it was never meant to run. Installing individual components (receiver, summarizer, extractor, shipper, updater) leaves the role untouched.
install.sh is preserved as a thin forwarder for muscle memory: each --install-X flag does go build -o $LOOM_BIN_DIR/loom ./cmd/loom then loom install X. Either entry point works; new docs prefer the loom binary directly.
State lives under $LOOM_HOME (default ~/.loom); the binary lives at ~/.local/bin/loom, the path the launchd plists pin and the updater installs new releases over.
LOOM_BIN_DIR=/usr/local/bin ./install.sh --install-shipper
LOOM_HOME=/srv/loom loom install receiverIf
~/.local/binisn't in your$PATH, add it to your shell rc file. launchd runs the binary by absolute path regardless, so this is purely for interactive use.
The loom updater daemon polls EnderRealm/loom's latest GitHub Release on an interval. When a newer release than the running binary ships, it downloads the platform tarball, verifies its checksums.txt entry, installs the extracted binary over ~/.local/bin/loom, and kickstarts every loom agent (itself last). No git checkout and no Go toolchain are needed on the host — the installed binary is always a published semver release.
loom install updater
tail -f ~/.loom/updater.logDefault: 5-minute poll. Override via plist environment:
| env var | default | purpose |
|---|---|---|
LOOM_UPDATER_INTERVAL_MINUTES |
5 |
poll cadence |
The reusable pattern (Go binary on launchd that updates itself) is documented in docs/auto-update-pattern.md; loom runs its artifact-fetch variant.
The loom extract agent closes the loop between capture and the durable knowledge store: every sweep it walks summarized sessions that have not been extracted yet and runs extractors/extract.py over each one, so candidates land in ~/.loom/knowledge/_candidates/<type>s/<scope>/ and show up in the TUI's candidate review screen.
loom install extractor
tail -f ~/.loom/extractor.log- Scope comes from the repo's
.loom-projectmarker whenever the session's recorded cwd names a checkout on this host: the walk runs from that directory up to the repo root and the nearest usable marker wins, the same walkextractors/resolve_project.pydoes. Otherwise it is the basename of the session's normalized git remote (github.com/enderrealm/loom→loom). Either name must be one safe path segment with aknowledge/truths/<scope>/directory behind it; a marker that isn't is skipped and the walk continues, so a session whose marker chain yields nothing still resolves through its remote. A session that resolves through neither is not extracted — there is no default scope, since a wrong default files a project's truths under another project's name irreversibly. That exclusion is counted in the sweep's summary rather than recorded in the ledger, so creating the missing directory later rescues the sessions it was created for:loom statuslists the scopes this host's sessions resolve to that have none, andloom knowledge scope add <name>creates one (committed and pushed). Seedocs/knowledge-scopes.md, which also covers what collides and why the.loom-projectmarker is the tiebreaker. Two deliberate divergences fromresolve_project.py: a marker's value is lowercased here (a resolved scope is lowercase by construction), and thetruths/<scope>/check has no counterpart there, since it has no knowledge store to consult. - The log accounts for the derivation. Every extraction line carries
source=markerorsource=git-remote. A marker that names a different scope than the remote does is logged outright, naming both — one of the two is stale and only you can say which — as are a marker the store can't accept (the walk continues up past it), a marker that exists but can't be read at all (a symlink, which is never followed; a non-regular file; bytes that aren't UTF-8; more than 4 KiB, which is not a one-line marker), and a winning marker below the repo root (as likely a vendored subtree's declaration as the project's). Those lines state a fact about a repo rather than about a session, so each is stated once per run however many of that repo's sessions are resolved, and a rejected name is echoed truncated — it is arbitrary file content, and the log is an audit record. "No marker here" is silent; it is most of every walk. - Forward only. A watermark is stamped in
~/.loom/extract.statethe first time the agent runs; sessions summarized before it are left alone. The historical backlog isloom extract --backfill's job (below). - Claude Code and Cursor CLI transcripts are supported. Cursor journals use the installed Loom parser through
LOOM_BIN(orloomon PATH), followed by the same Python redaction and extraction stages. Codex rollouts remain unsupported and are skipped with a logged reason. Transcript runtime does not select the extraction provider. See Cursor extraction. - Stub sessions are below the bar. A session the summarizer folded fewer than
--min-turnsturns for (default3, tunable throughLOOM_EXTRACT_MIN_TURNS) is passed over, on both the sweep and the backfill. Measured over 467 extracted sessions here, 1- and 2-turn sessions return zero candidates 44% and 50% of the time against 13% at 3–4 turns and 7% at 5+ — and they are 1371 of this machine's 1850 summarized sessions, so the default drops a full backfill from 510 round trips to 192. A session at the threshold is extracted,0disables the filter entirely, and a session whose turn count the DB doesn't carry is never treated as a stub. The exclusion is not recorded inextract.state, unlike the sweep's other skips: lowering the threshold later re-admits every session a higher one passed over. - At most once: every visited session is recorded in
~/.loom/extract.statewith its outcome (extracted/skipped/failed), so re-running a sweep is a no-op. Re-running one session means deleting its entry. The two reversible exclusions — below--min-turns, and no scope directory — are deliberately not recorded, and the scope-skip records written before that was true are retired from the ledger on read. A run that completed but scored below the extractor's coverage threshold isextracted, notfailed— only a run that never produced a result is a failure. extract.pyis not in the release tarball. The agent resolves it from a checkout; a missing script makes each sweep a logged no-op instead of a crash loop, andloom statusshows the resolved path.
| env var | default | purpose |
|---|---|---|
LOOM_EXTRACTORS_DIR |
~/code/loom/extractors |
where extract.py lives |
LOOM_KNOWLEDGE_ROOT |
$LOOM_HOME/knowledge |
the knowledge store to write into |
LOOM_EXTRACT_PROVIDER |
claude |
extractor LLM provider |
LOOM_EXTRACT_MODEL |
sonnet |
extractor model |
LOOM_EXTRACT_MIN_TURNS |
3 |
--min-turns default (0 disables it) |
Set them before loom install extractor. Install persists them to $LOOM_HOME/extract-env and bakes them into the plist, since launchd jobs don't inherit a login shell's environment and the auto-updater re-installs the agent from its own. loom status prints what the agent resolves, which is the persisted value unless the invoking environment overrides it.
loom extract --backfill is the operator-run pass over the sessions the watermark excludes. It runs once — --watch is rejected — and ignores the per-sweep cap, so clearing hundreds of sessions isn't paced at four per quarter hour. It shares ~/.loom/extract.state and the same provider/model tunables with the trigger, so neither re-extracts what the other already visited, and an interrupted run resumes where it stopped.
loom extract --backfill --dry-run # what a real run would do, spending nothing
loom extract --backfill --scope loom --limit 20--dry-runreports the selection — how many sessions resolve to each scope, by which derivation (via marker=…, git-remote=…), and how many are excluded and why — and spends nothing: no LLM call, no write to the knowledge store, no ledger entry (not even the watermark, on a host where the trigger has never run).--scoperestricts the run to named scopes (repeatable, or comma-separated) so one can be judged before committing to the rest. It is matched case-insensitively, and a value with noknowledge/truths/<scope>/directory is rejected up front — no scope resolves to it, so the run would otherwise report "0 to extract" and exit 0, which reads exactly like an already-cleared backlog.--limitbounds the run, stopping between sessions rather than mid-session. A negative value is rejected;0is the default and means unbounded. All three flags require--backfill, and that is judged on the flag being passed, so--limit 0or--dry-run=falsehanded to a sweep is an error rather than a silent no-op.--min-turnsis not one of them — the threshold means the same thing to a sweep — but a negative value is rejected there too, since0is the one spelling of a disabled threshold.- Skips are logged but not recorded — none of them, where a sweep still records the agent and artifact ones. The scope skips are unrecorded on both paths, for the reason most of this backlog's are
unknown scope: recording them would mean creatingknowledge/truths/<scope>/later could never rescue the sessions it was created for. The backfill logs its skips one per session, unlike the sweep's aggregate — it is operator-initiated and runs in the foreground, so the report is what it is read from. - Progress — the selection report, one line per session as it completes, and a running count every 10 — prints to the terminal and is appended to
~/.loom/extractor.log, so a run that spends hours of LLM time leaves its trail in the same audit log the agent writes, without a shell redirect. - Safe to run while the agent sweeps, in this specific sense: ledger writes merge under a file lock, so a backfill holding its snapshot for hours and a quarter-hour sweep holding its own don't erase each other's records; and the backfill re-reads the ledger immediately before each extraction, dropping — with a logged line, and a
skipped=count in its final tally — any session the trigger recorded since the plan was computed. The plan's up-front counts are therefore a forecast: a run can extract fewer sessions than it announced. What is not covered is the overlap window itself — neither side records a session until it finishes, so the trigger can still start on one the backfill is mid-way through.
loom retrospect <namespaced-ticket-id> pushes new memory out of completed work: it resolves every summarized session whose commits carry the ticket's [<id>] subject marker and runs the extractor over each, for truths and for decisions. An epic id — a project's or Root's (_root/<id>) — expands through tk to its exact children across every project; each session is extracted once however many of them it landed for, and files under the scope its own checkout resolves to. Root is never a scope.
loom retrospect loom/add-loom-retrospect-e222- Candidates only. Output lands in
~/.loom/knowledge/_candidates/<type>s/<scope>/withsession:andticket:sources filled in, and each run appends one## [date] retrospect <ticket-id> | <scope> | N truth candidates, M decision candidatesline to the store'slog.md, with, K ticket candidatesappended when the run produced any.truths/anddecisions/are never written — promotion stays human-gated. - The ledger is bypassed on purpose.
~/.loom/extract.stateexists to stop the unattended trigger double-spending, and the sweep has usually already visited a just-closed ticket's sessions; honoring it would make the command a no-op in exactly the case it exists for. Candidate filenames carry a run timestamp, so a re-run files siblings rather than overwriting. - A ticket with no commits in the summary DB is reported and exits 0. A missing
extract.py, an extraction backend that isn't installed at the absolute pathextract.pyinvokes it by, or a malformed ticket id are errors and exit non-zero — this is a foreground command, not a daemon that must not crash-loop. Sessions skipped for the sweep's own reasons (unsupported agent, unresolvable scope) are reported and don't fail the run.
loom work-report measures how the /work runs in summaries.db actually behaved: for each one it reports whether the review fan-out was dispatched, how many review rounds ran, how many lens verdicts reported contaminated context, how many acceptance criteria the contract lens left unverified, how long passed between the run's first and last ticket edit, and whether the run committed. It is the baseline a change to the skill gets measured against instead of argued about.
loom work-report --since 2026-08-01 > before.json
loom work-report --since 2026-08-01 --until 2026-09-01 | jq .totals- A run is one
/workinvocation, not one session: a session that invoked/workthree times holds three runs, each spanning from its invocation turn to the turn before the next one. Invocations are recognized from transcript content — the command tags at the start of a Claude prompt; on Codex the expanded<skill>block namingwork, with the typed invocation and its ticket id trailing it, or the older typed#work <ticket>its earlier rollouts recorded — rather than from a marker a run has to write, so history nobody instrumented is covered. The anchoring is deliberate: a summarizer session quotes the whole skill body, tags included, and a lens session is handed that body mid-turn; a substring match would count every one of those as a run. - Runtimes are distinguished because the fan-out's shape is. Claude dispatches lens subagents and leaves subagent tool rows behind — named
Taskby one client andAgentby another, so they are matched on the normalized tool kind; Codex and Cursor inline the passes and leave only acodex-lens.sh --lenscall or the verdict blocks themselves. Reading Codex against Claude's shape would report every Codex run as non-compliant by construction. Cursor journals are ingested through the same invocation and lens-response model. - Weak evidence is discounted, but the report does not resist forgery. A fenced block counts as a verdict only when it names one of the three lenses and, when it parsed whole, a
satisfied/findingsverdict — so a quoted verdict template or a junk block is neither a metric nor a dispatch. Inlined passes stand as dispatch evidence only across distinct lenses, contract and quality among them, which one verdict re-quoted by the merge cannot produce; and a lens router call counts only when the command matches, script plus--lens, and the call's recorded output carries a lens verdict, so echoing the invocation does not count as making it. The residual limitation is plain: on Codex and Cursor the inlined passes exist only as assistant-authored verdict blocks, so a run that fabricated two well-formed verdicts plus a commitment line would read as compliant. The report grades honest runs and detects skipped work; it does not withstand deliberate self-forgery. - A Claude lens verdict arrives one of two ways, and both are read: asynchronously, as a task notification on a later turn, or synchronously, as the dispatching call's own tool result. A stored tool result is cut at 800 characters and on a contract verdict the cut usually lands inside
criteria[], socriteria_unverifiedis null on most synchronous runs — null means "no contract verdict parsed whole", never "zero unverified". A partial criteria list would under-report, which is worse than reporting nothing.review_iterationsis null on the same principle: it counts the rounds the run's own fan-out commitment lines named, so a run whose fan-out is on the record only as tool calls reports null rather than a self-contradictory zero rounds. open_to_done_msis the span from the run's first ticket edit to its last, measured from the earliest and latest timestamps rather than the first and last row, since tool rows are sequenced as recorded and can run out of order. It is not a provenopen→donetransition: the report sees thatticket_editwas called, never which status the call set. It is null when the run made fewer than two ticket edits — one edit is a point, not a span — and on Codex it can be unmeasurable outright: an MCP call recorded only at call-end is stored under the tool namemcp, carrying no name to match.- Unknown is never compliant. A run whose runtime the parser cannot name, whose commitment line names a round it cannot count, whose invocation is followed by nothing, or whose signals conflict is classified
unknown. The other classifications arecompliant(a fan-out with this runtime's evidence behind it),skipped_fanout(a commit landed with no fan-out at all) andincomplete(no commit and no fan-out — blocked at the contract gate or abandoned, which is not a compliance failure). - JSON only, and diff-stable. Runs are ordered by invocation time then session id — a stable sort, so runs that tie (two in one session, or two carrying no invocation timestamp) hold their agent/session/turn order — and the document carries no generation timestamp, so two reports over the same range are byte-identical and a before/after diff shows only what changed.
--sinceis inclusive,--untilexclusive; both takeYYYY-MM-DD(local midnight) or RFC3339.--dboverrides the database read. - A missing or pre-v4
summaries.dbis an error, not an empty report: "no runs" is the number being measured, and a zero meaning "nothing to read" reads exactly like a zero meaning "nobody ran/work".
loom cost-report measures what each /work run in summaries.db cost: turns, input/output/cache tokens and the dollars they price to, tool calls broken down by kind, subagents dispatched, how long they ran and what they cost, whether the run committed, and two measures of elapsed time. It is the evidence behind "tickets feel slower than they did", which cannot otherwise be confirmed or refuted.
loom cost-report --since 2026-08-01 > before.json
loom cost-report --since 2026-08-01 | jq '[.runs[].wall_clock_ms | values] | add / length'- A run is one
/workinvocation, recognized and bounded exactly aswork-reportdoes it: from the invocation turn to the turn before the next invocation in the same session, else to the session's end. Session duration cannot stand in for ticket cost — a third of the sessions that commit a ticket commit two or more, one of them 22 — so attributing a whole session to a ticket over-counts by up to an order of magnitude. Cost is an intra-session span. - Two measures of time, on purpose.
wall_clock_msruns from the invocation to the run's commit and is what is actually felt, idle included: every minute the human was asleep, in a meeting or reading the diff.active_mssums the span's turn wall clock and its tool durations, so it is immune to that idle but is not the thing on the calendar. The gap between the two is itself the signal — it separates "the agent got slower" from "I was slower to respond". The two terms ofactive_msoverlap by construction (a tool call runs inside its turn's wall clock), which makes it an upper bound on attention rather than a disjoint sum. - Null means unmeasurable, never zero. A run that never committed reports
wall_clock_msas null rather than running its span to the session end, so an abandoned run cannot inflate the trend; so does a run whose invocation or whose commit carries no usable timestamp, one whose commit timestamp precedes its invocation, and one whose only ticket-named commit landed after the next invocation —committedstays true there, but the commit belongs to a later run and cannot time this one.subagent_duration_msis null when no dispatch in the span carried a duration — a background dispatch's span often cannot be resolved, and "not measured" must not read as "returned instantly". The counts beside them (turns,tool_calls,subagents) are still reported for a run that never committed: it cost what it cost. - Every recognized run is reported, including the ones
work-reportclassifiesunknown. Cost is not a compliance grade, and dropping the runs that are hardest to classify would bias the trend toward the easy ones. - Each run says under what conditions it ran.
models,effortsandcli_versionsare every distinct value the span's turns carried, in the order first seen — taken per turn, not per session, so a run whose model or effort changed mid-span lists both, in the order it happened — and are null when no turn in the span recorded the field, never an empty list.errorscounts the error rows whose turn falls inside the span, so a session holding three runs attributes each error to at most one of them.human_interactionscounts the turns in the span where the human acted: the stored user message, with any leading<system-reminder>blocks stripped, is non-empty and does not start with a harness envelope — a slash-command tag (<command-name>,<command-message>; the/workinvocation itself does not count), a<task-notification>, local command output (<local-command-stdout>,<local-command-caveat>,<bash-stdout>,<bash-stderr>), Codex's expanded<skill>body, or Codex's<recommended_plugins>/<environment_context>preamble. Tool results never open a turn and Claude's injected skill body is dropped as a meta record, so neither reaches the rule.<bash-input>counts: the human ran a!command. The rule is one function, applied to Claude and Codex turns alike. The per-turn columns arrived in schema 6. - Each run is priced, from a table in the repo. Transcripts carry tokens, never cost, so
cost_usdis computed: the run's own turns, each at its turn's model and speed, at the rate in force at the run's invocation —internal/pricing/rates.jsonis keyed by model and effective date, so re-running the report over an old window does not reprice history.cache_read_tokensandcache_creation_tokensare priced at their own rates, not the input rate; a cache write is split by TTL, the share the transcript labelled 1-hour at the 1h write rate and the rest (5-minute or unlabelled) at the 5-minute rate, the API's default. A turn recorded atspeed: fastprices input and output at the model's fast rates, with the cache multipliers stacked on the fast input rate.subagent_cost_usdis a separate figure: each dispatch in the span priced from its own transcript's usage split and model, never from the parent's blended count. Cost is null, never a default or zero, whenever anything in it could not be priced — a model the table does not carry, an invocation before the model's first effective date, a turn whose model was not recorded, a turn or dispatch whose records disagree on model or speed, fast mode on a model with no fast rate, or a dispatch with no shipped transcript — andpricing_warningsthen names every cause (unpriced model "codex-auto-review" at 2026-09-15,subagent 2 (reviewer): no transcript); it is null when nothing went unpriced. Each turn is priced under its runtime's cache accounting: Claude records input exclusive of the cache buckets, so every bucket prices at its own rate; Codex records cached input as a subset of input, so the cache read is taken back out of the input before the two price at the list and cached-input rates, and a read larger than its input is a broken record (turn 3: cache read exceeds input) rather than a discount. Cursor journals document no cache convention, so a Cursor turn prices only when it carries no cache tokens at all and otherwise reportscache accounting semantics unknown for cursor-cli— and the journals Cursor CLI writes today carry no billing counters, so a Cursor run reportstoken_usage_unavailableand a null cost whatever its model. A span with nothing to price costs 0, not null. The table records itssourceand thecheckeddate the rates were last read against it, and an entry may carry its own where it came from a different vendor page; the rates are assumed to have held since each entry's effective date, so when a vendor page changes, add an entry with the new effective date and updatechecked— the figures a reader gets are only as current as that date. Which Codex and Cursor identities the table covers, and which it deliberately does not, is in Model pricing coverage; every figure is an estimate at published API rates, not what a subscription invoiced. The pricing columns (per-turn cache-creation tokens and speed, per-dispatch usage) arrived in schema 7, so the report refuses an oldersummaries.dbuntilloom summarize --rebuild. - JSON only, and diff-stable, on
work-report's terms: the same ordering, the same--since/--until/--dbflags, and no generation timestamp, so two reports over the same range are byte-identical and a before/after diff shows only what changed.
loom run-report --run <id> measures everything attributable to one run — the parent's own span and every execution under it (subagents, routed lenses, Weft stage attempts, commands), nested and retried alike — where cost-report meters the parent span and the subagent rows it can see. It is one JSON document per run, for the CLI and for a UI, and is the read surface over the execution tree internal/runs assembles (docs/execution-records.md).
loom run-report --run 0f4c3a6e-2d1b-4b7e-9c8a-5e2f1d0a9b31 | jq .metrics.total
loom run-report --run transcript:claude-code:<session>:0 | jq '.telemetry.gaps'- Three scopes, each transcript counted once.
metrics.parentis the root's parent-only span — for a declared run it starts at the first turn beginning at or after the record'sstarted_at(telemetry.root_span: started_at), except that a run started during its invocation turn keeps that turn (invocation); historical runs remain invocation-anchored, and a root with no recognized invocation uses the whole session (session).metrics.descendantsis every other execution, deduplicated by session;metrics.totalis recomputed over the counted set. An execution naming a session another already counted — a stage recorded against the root's own transcript — is listed withcounted: falseandcounted_bynaming the one that did. Each scope carries turns, tool calls by kind and how many errored, tool time, tokens, failures, the models, efforts and CLI versions in force with how many turns carried each, execution time with how many executions were timed, and cost. Human interactions are the root's by construction — a descendant's user messages are the dispatching agent's prompts — sodescendantsreports 0 andtotalequalsparent. - Tokens stay per runtime. Claude records input exclusive of the cache buckets; Codex records cached input as a subset of input.
tokens_by_runtimekeeps each runtime's buckets under acache_semanticslabel (cache_separate,cache_read_included_in_input) and each runtime'stotalis summed under its own semantics, sototal_tokensnever counts a Codex cache read twice — and cost is priced under the same label, the Codex read taken out of the input before either is priced. Cursor journals expose context occupancy without billing counters: their bucket usesunknowncache semantics and NULL counters/cost, and a scope containing unavailable usage has NULL total tokens. A Cursor session that did record counters prices only while it carries no cache tokens, sinceunknownnames exactly the convention that would be needed to price one. See Cursor measurement coverage. - Failures apart from hook signals.
failuresclasses the error rows by source —tool(tool_error,exec_error,patch_error),api(api_error),process(turn_aborted), andotherlisted by source;hook_signalscountsstop_hookrows separately, since a hook telling the agent to continue is not a failure. - Four measures of time, named.
time.wall_msis one span, the run'sstarted_atto itsended_at(wall_basis), or tolast_observed_atfor a run still going.execution_time_mssums every counted execution's own span, so parallel children add where the wall does not.tool_time_mssums tool durations over every counted execution.legacy_active_msis the parent span's alone — the same figurecost-reportreports asactive_msfor the run — and carrieslegacy_active_ms_semanticsbeside it: its terms overlap, and it is an upper bound on attention rather than exclusive activity. The descendants' and total sums are undermetrics.descendants.legacy_active_msandmetrics.total.legacy_active_ms. - Outcome is the record's; completeness is separate.
run.outcomeis what the run record said (completed,failed,stopped),runningwhen it declared a start and nothing has closed it,unknownwhen no record says — never inferred from telemetry.telemetry.stateiscompleteonly when every execution has ended and every one naming a transcript has that session in the database; otherwisepartial, withgapsnaming each cause — a session not folded, an execution still pending, a historical dispatch with no usage or no measured duration, an unresolved execution left uncounted. Partial coverage is named, never reported as zero usage. A terminal record arriving after a start-only one flips the outcome without changing usage. - Breakdowns and the tree.
executionslists every node depth-first, root first, then the unresolved ones (placement), each with its own span's metrics and duration;stagesgroups stage attempts by(stage, occurrence)with aretriescount;lensesjoins the lens execution records with the transcript's lens attempts on(lens, round, attempt), listing an attempt from either side alone, each with anattribution—executionwhere a record joined,transcriptwhere only the transcript paired a response (a native subagent lens writes no record),unknownwhere neither stands behind it;attemptsis the flat retry view.tree,unresolvedanddiagnosticsare the run asinternal/runsreads it, with every empty list rendered[]. - A missing rate leaves the metrics standing. Cost is priced as
cost-reportprices it, at the rates ininternal/pricing/rates.jsonin force at the run's start; a model the table does not carry (coverage) leavescost_usdnull,pricing.availablefalse and the cause inpricing_warnings, and every other figure is still reported. Each scope is priced on its own — a Claude parent at Claude's rates and semantics, a Codex descendant at OpenAI's — and the total is their sum, or null when any scope in it is.pricingnames the table's currency, source and checked date. An unknown run id is an error (run not found: <id>); asummaries.dbolder than schema 9 is refused untilloom summarize --rebuild.
loom escapes attributes every done bug in the tk store to the agent sessions whose commits introduced it, and prints the share of loom commits that introduced a bug per (agent, model, cli_version), with precision against the hand-labelled fixture beneath.
loom escapes
jq -c 'select(.class == "unattributable") | {ticket, reason}' ~/.loom/escapes.jsonl- SZZ over the fix. A bug's fix commits are those on the registered repo's HEAD whose subject carries its
[<id>]marker. The lines they deleted or modified are blamed at each fix's parent with whitespace ignored on both sides; generated paths (tests/golden/) are skipped, as are lines written by the bug's own fix commits. - One class per bug.
single(one introducing commit),multi(several, credit 1/n each), orunattributablewith a reason: no fix commit found, an add-only fix (no culprit is guessed), a git failure while blaming (written to stderr; the run continues), or no introducing commit in loom'scommitstable, which is where a human commit lands too. A blamed hash is joined to a loom commit only in the same repo, by origin remote (normalized, so SSH and HTTPS forms match) or cwd, so short-hash collisions across repos never cross-attribute. - The rate is introducing commits over commits.
commitscounts every distinct loom commit in the slice reachable from HEAD in a registered repo,introducinghow many of them introduced at least one attributed bug, andrateis their ratio.creditis the fractional sum beside it: 1 per single bug, 1/n per commit of a multi bug, so a commit that introduced several bugs counts once inintroducingand several times incredit. - Precision is printed with the rates. The hand-labelled fixture (
internal/escapes/testdata/labels.jsonl) is embedded in the binary; the precision line (precision on fixture blame-stage singles) scores the fixture bugs the blame stage classessingle, before the loom join, against their labelled introducing commits, the same measureTestPrecisionAgainstFixtureholds at 60% or better. - Per-bug JSONL. Every done bug is written once to
$LOOM_HOME/escapes.jsonl(--outoverrides) with its fix commits, blamed commits and line counts, and the matched session with its/workruns, so a wrong attribution can be debugged from the file.--dboverrides the database read; one older than schema 8 is refused.
A lightweight agent-session shipper. The client (loom shipper daemon) walks agent session files, ships byte-delta batches to the server (loom receiver) over HTTP, and persists per-session cursors so subsequent runs are incremental and idempotent. The shipper runs as a long-lived KeepAlive daemon with an in-process ticker (driven by interval_minutes in config); it does not rely on launchd's StartInterval, which dasd coalesces aggressively on modern macOS.
Agents supported: Cursor CLI (the received cursor-store-v1 journals, with children as separate sessions), Claude Code (sessions at ~/.claude/projects/<sanitized-cwd>/<uuid>.jsonl, plus the transcripts of the subagents that session dispatched, anywhere under ~/.claude/projects/<sanitized-cwd>/<uuid>/subagents/ — directly in it for a plain dispatch, one level deeper under workflows/<wf_id>/ for a workflow one, whose journal.jsonl is the workflow's own bookkeeping and is not a transcript) and Codex CLI (rollouts at ~/.codex/sessions/<yyyy>/<mm>/<dd>/rollout-<ts>-<uuid>.jsonl).
A session run from a throwaway working directory — a review lens started in an empty mktemp -d so the reviewed repo cannot instruct its reviewer — reports that directory as its cwd, which would make every run its own project and leave it with no knowledge scope. Its launcher stamps the checkout it is about in ~/.loom/attribution.jsonl and the Codex adapter resolves identity through it; the producer contract is docs/attribution-stamps.md.
~/.loom/
config.json # client: server URL, auth token, interval
transport/
cursors/source/<agent>/<key>.cursor # client: next byte read from source
cursors/ship/<agent>/<key>.cursor # client: next byte shipped to receiver
staging/<agent>/<project>/<uuid>.jsonl # client: bytes captured locally
staging/<agent>/<project>/<uuid>.meta.json # client: per-session project identity
staging/<agent>/<project>/<parent>/subagents/
<agent-id>.jsonl # client: subagent bytes captured locally
<agent-id>.subagent.json # client: dispatch metadata (parent, agent type, tool use id)
attribution.jsonl # producers: throwaway work root → checkout
shipper.lock # client: flock, one shipper at a time
shipper.log # client: launchd stdout/stderr capture
received/ # server: default storage root
<agent>/
<sanitized-project>/
<uuid>.jsonl # appended-to session file
<uuid>.offset # next expected byte (idempotency)
<uuid>.meta.json # project identity (git remote, raw cwd)
<parent>/subagents/
<agent-id>.jsonl # subagent transcript, with its own .offset/.meta.json
<agent-id>.subagent.json # dispatch metadata (parent, agent type, tool use id)
summaries.db # server: normalized summary database
summarizer.log # server: summarizer launchd capture
extract.state # server: extractor watermark + visited sessions
extract-env # server: extractor tunables baked into its plist
extractor.log # server: extractor launchd capture
knowledge/ # durable knowledge store (separate git repo)
knowledge-git.log # knowledge-store commits that could not be recorded or pushed
State lives per-user per-machine. Override the root with LOOM_HOME=/some/path.
<agent> is claude-code, codex-cli, or cursor-cli. The two-stage shipper captures from the agent's source directory into local staging/, then ships staging deltas to the receiver — so the agent can clean up its own session files without losing data. Cursor's mutable SQLite records become a lossless journal in staging; the other transcript sources retain their JSONL byte framing.
A subagent transcript travels under the session that dispatched it: it stages at staging/<agent>/<project>/<parent>/subagents/, lands at received/<agent>/<project>/<parent>/subagents/, and carries a .subagent.json sidecar with what the agent recorded about the dispatch. Its cursor key <key> is <parent>.<agent-id>; for a transcript nested below subagents/, <agent-id> is its path under that directory joined with . (workflows.wf_5daf2eee-720.agent-a0e0), so it stays a single path component and two dispatches under one parent can never share a staging file or a cursor. A top-level session's <key> is its <uuid>.
Each session carries an authoritative project identity captured at the shipper:
git_remote—git -C <cwd> remote get-url originat capture time, normalized (SSH and HTTPS variants of the same origin collapse). Canonical when present.cwd— the raw, unsanitized working directory the agent reported. Authoritative fallback when there's no git remote.root_slug— the legacy sanitized path used as the on-disk storage directory, kept for backward compatibility.
Identity travels in wire.IngestRequest.ProjectIdentity (optional; pre-identity clients omit it), is persisted by the receiver as a <session>.meta.json sidecar next to the JSONL, and is folded into summaries.db (sessions.git_remote, sessions.cwd_raw). The TUI groups projects by git_remote, then cwd, then root_slug — so the same repo shipped from two laptops with different sanitized paths rolls up into one project, and basename collisions like code/loom vs other/loom stay separate.
The server can be any Mac reachable from the client(s). It can be the same machine as the client for experimentation.
export LOOM_RECEIVER_TOKEN="$(openssl rand -hex 32)"On first loom install receiver, this value is persisted to ~/.loom/receiver-token (mode 0600), so later installs and the auto-updater's re-bootstrap don't need it re-exported. If you skip the export, an interactive install prompts for the token; a non-interactive install with no env var and no token file errors instead.
The same value goes into the client's ~/.loom/config.json on each shipping machine.
loom install receiverThis:
- Resolves the bearer token (
LOOM_RECEIVER_TOKEN→~/.loom/receiver-token→ interactive prompt) and persists it to~/.loom/receiver-token(0600) - Creates
$LOOM_HOME/received/ - Writes
~/Library/LaunchAgents/com.loom.receiver.plistwithKeepAlive=true(restarts on exit),RunAtLoad=true(starts immediately), andLOOM_HOMEbaked intoEnvironmentVariables. The token is read from~/.loom/receiver-tokenat runtime, not the plist, so it's not exposed via the plist orlaunchctl print - Validates the plist with
plutil -lint - Boots out any prior instance, bootstraps the new one, and waits for launchd to run a process for it —
kickstarting once if none appears, and failing the install by label if none comes up - Polls
http://127.0.0.1:8765/healthzfor up to 10s to confirm it came up
The plist runs the loom binary at the absolute path of whichever loom was on $PATH at install time. Rebuild to the same path and the next respawn picks up new code.
For a remote server, replace 127.0.0.1 with its reachable address when verifying from elsewhere; also open port 8765 or set up a tunnel as needed.
For foreground debugging:
LOOM_RECEIVER_TOKEN=<token> loom receiver
# → listening on :8765, storage=/Users/<you>/.loom/received| Flag | Default | Purpose |
|---|---|---|
--addr |
:8765 |
Listen address |
--storage |
$LOOM_HOME/received |
Where to write session files |
--auth-token |
(empty; falls back to env) | Shared bearer token; empty disables auth |
Auth token can also come from LOOM_RECEIVER_TOKEN, or the persisted ~/.loom/receiver-token written at install time. If none is set, the server accepts all requests (dev/localhost only).
Each machine you want to ship sessions from runs its own loom shipper daemon + config + launchd agent.
{
"server_url": "http://your-server:8765",
"auth_token": "the-same-token-as-the-server",
"interval_minutes": 10
}| Field | Required | Default | Notes |
|---|---|---|---|
server_url |
yes | — | Base URL of the receiver; no trailing /v1/ingest |
auth_token |
no | empty | Bearer token; must match the server |
interval_minutes |
no | 10 |
In-process ticker cadence inside loom shipper daemon |
interval_seconds |
no | unset | Same cadence in seconds; when set it takes precedence over interval_minutes. Must be positive. |
summarizer_interval_seconds |
no | 30 |
Watch-mode sweep cadence baked into the summarizer plist by loom install summarizer. Must be positive. |
The seconds fields are the only ones the server role reads: a server has no server_url, and loom install summarizer reads summarizer_interval_seconds from $LOOM_HOME/config.json when the file exists, falling back to 30 when it does not. A zero or negative value in either seconds field is rejected rather than defaulted.
Permissions: chmod 600 ~/.loom/config.json — it contains the bearer token.
loom install shipperThis:
- Loads
$LOOM_HOME/config.jsonto read the tick cadence (interval_seconds, elseinterval_minutes) - Writes
~/Library/LaunchAgents/com.loom.shipper.plistwithKeepAlive=true,ThrottleInterval=10,RunAtLoad=true, andProgramArguments=[<loom>, shipper, daemon] - Validates the plist with
plutil -lint - Boots out any prior instance and bootstraps the new one
The shipper plist intentionally has no StartInterval — dasd coalesces those aggressively on modern macOS (observed gaps of 24h+ in production). The daemon stays resident, runs the capture+ship pass on its own time.Ticker, and respawns on crash via KeepAlive.
loom status
tail -f ~/.loom/transport/shipper.logFirst tick walks every session under ~/.claude/projects/ and ~/.codex/sessions/ and ships each one in full. Subsequent ticks ship only new bytes per session.
loom uninstallRemoves every loom launchd agent (tolerant of any being absent) and their plist files. Preserves ~/.loom/ state and the installed binary. Full wipe:
rm -rf ~/.loom
rm -f ~/.local/bin/loomThe loom binary is the single entry point. Subcommands:
| Command | What it does |
|---|---|
loom shipper daemon |
Long-lived shipper; capture+ship on interval_minutes. The launchd target. |
loom shipper once |
Single pass; ship any new bytes and exit. Manual debugging. |
loom shipper health |
Last-sync, pending count, uncaptured bytes per project. |
loom receiver |
Run the ingest server (:8765 by default). |
loom summarize [--watch] |
Fold received sessions into ~/.loom/summaries.db. |
loom summarize --rebuild |
Drop and re-fold the summary DB; the upgrade path for schema bumps. |
loom summarize --strict |
One-shot sweep that exits non-zero when any session errored; the form a ticket verify: line uses. |
loom extract [--watch] |
Run extract.py over summarized sessions that haven't been extracted yet. |
loom retrospect <ticket-id> |
Re-extract every session whose commits closed a ticket, truths + decisions. |
loom work-report |
/work-run compliance metrics from the summary DB, as JSON. |
loom cost-report |
Per-/work-run cost — turns, tokens, tools, time, conditions — as JSON. |
loom run-report --run <id> |
One run's tree with parent, descendant and total metrics, as JSON. |
loom ui |
Interactive dashboard (alias: loom tui); --run <id> opens a run's detail. |
loom install <component> |
Components: server / remote / receiver / summarizer / extractor / shipper. server/remote also record the machine role. |
loom uninstall |
Remove every loom launchd agent. State preserved. |
loom status |
Launchctl state per component + sync health + config presence. |
./install.sh is a transitional forwarder (--install-X → loom install X); use the loom binary directly going forward.
The key design fact: the launchd plist pins an absolute binary path at install time. Whatever writes a new binary to that path — the updater installing a release, or you building locally — the next scheduled tick runs it, no reinstall needed.
Install the updater (see Auto-update). It polls GitHub Releases, installs each new release over ~/.local/bin/loom, and kickstarts every agent. This is the production update channel; nothing manual is required.
To run code that hasn't been released yet, build in place over the pinned path and kickstart the daemons. The shipper reads its interval at daemon startup, the receiver re-execs on KeepAlive, and the summarizer's watch loop is interruptible — kickstarting all three is enough.
cd loom
go build -o ~/.local/bin/loom ./cmd/loom
launchctl kickstart -k gui/$(id -u)/com.loom.receiver
launchctl kickstart -k gui/$(id -u)/com.loom.summarizer
launchctl kickstart -k gui/$(id -u)/com.loom.shipperThe updater will reinstate the latest release on its next tick, so use this only for local testing.
Re-run loom install <component> when any of these change:
- Binary path — you moved the loom binary off the absolute path the plist pinned
interval_minutes/interval_secondsin config — only take effect at daemon startup; reinstall (or kickstart -k) the shipper after editingsummarizer_interval_secondsin config — baked into the summarizer plist as--intervalat install time; reinstall the summarizer after editingLOOM_HOME— if non-default, it's baked intoEnvironmentVariableson every plistLOOM_RECEIVER_TOKEN— persisted to~/.loom/receiver-tokenat install time; re-export and reinstall (or edit the file) to rotate
loom install boots out any prior instance with the same label before bootstrapping the new plist, so reinstall is always safe.
Both sides import loom/transport/internal/wire. If IngestRequest or IngestResponse ever changes shape, update both sides at roughly the same time. There's no version byte and no negotiation. A mismatch produces 400-level responses on the server and failed= counts on the client until both are on the same version. Failed batches retry on the next tick — no data loss during a brief skew.
Both are plain decimal integers in files on disk. Not negotiated, not serialized across the wire. Unlikely to change; if they ever do, a one-shot migration would run on each side independently.
~/.loom/config.json— user-owned, never touched by the binary~/.loom/transport/cursors/— client cursors; keep them, they make updates resumable~/.loom/received/— server storage; keep it~/.loom/transport/shipper.log— append-only, rotate manually if it grows~/.loom/summaries.db— disposable. The summarizer rebuilds it from~/.loom/received/on demand. When this binary's schema is newer than the on-disk DB, the summarizer refuses to open it and instructs the user to re-run with--rebuild.
loom summarize --rebuild # drops summaries.db and re-folds from received/Use this whenever the summarizer reports summary db schema is outdated. The DB is treated as a derived artifact — there's no migration path because there doesn't need to be one.
A parser fix is honestly checked by a full sweep reporting zero errors, so a loom ticket's criterion is written:
verify: loom summarize --force --strictticket_verify execs that line as a single argv with no shell, so a | grep -c errored=0 over the log is literal text and never runs; the exit code is the only channel it reads, and the binary has to own it. Without --strict a sweep exits 0 whatever its errored count, and a bare loom summarize criterion would record pass on any corpus. --strict makes the one-shot sweep exit non-zero when any session errored; it is refused together with --watch, which has no exit to report.
The flag was chosen over a ./scripts/verify-sweep.sh wrapper: the wrapper would need a shell to grep the count out of the log, plus one more entry in the host's verify_allow, and with bash already listed on this host the narrowing buys nothing. Listing loom itself in the machine-local verify_allow in ~/.ticket/config.yaml grants more than a sweep — loom extract spawns an agent, and the sweep writes ~/.loom/summaries.db — but the DB is a disposable derived artifact rebuilt from received/, the list is already shell-wide through bash, and this is an attended single-user host, so neither adds reach a verify line does not already have. A machine wanting the narrower list drops bash and keeps loom, accepting loom extract as reachable, or replaces loom with a ./scripts/… entry.
A full forced sweep is ~36s on this host today (3204 sessions), inside tk's default 120s verify_timeout; raise loom's per-project verify_timeout in ~/.ticket/config.yaml when the corpus outgrows it.
First stop for any issue: loom status. It reports which agents are loaded, whether the config exists, and prints key launchctl fields.
config not found at ~/.loom/config.json — client hasn't been configured. Create the file (see "Client setup" step 1), then re-run loom install shipper.
LOOM_RECEIVER_TOKEN not set and no ~/.loom/receiver-token (from a non-interactive loom install receiver) — export the token first: export LOOM_RECEIVER_TOKEN="$(openssl rand -hex 32)", then re-run the install, or run the install interactively to be prompted.
All POSTs return 401 — token mismatch. Check that the server's ~/.loom/receiver-token matches auth_token in ~/.loom/config.json on the client. To rotate, edit ~/.loom/receiver-token (or re-export LOOM_RECEIVER_TOKEN and reinstall) and reload the receiver.
Shipper logs shipped=0 skipped=60 failed=0 — nothing to ship. All cursors are at EOF for every session. This is the steady state.
Shipper logs shipped=0 skipped=0 failed=60 — everything failed. Check server_url (is it reachable? curl <url>/healthz), check auth, and check the receiver's log at ~/.loom/receiver.log.
another shipper is running, skipping — the flock is held. Usually means the daemon is running and you tried loom shipper once against it. That's expected; the daemon already covers the workload.
launchctl print says "Could not find service" — the agent isn't loaded. Re-run loom install <component>, or check the relevant plist exists under ~/Library/LaunchAgents/ and is valid (plutil -lint <path>).
Shipper runs but nothing appears on the server — partial lines aren't shipped until the trailing \n arrives. If you're watching an active session, the last turn may lag the shipper by one tick. Confirm steady-state by reading the log a few ticks later.
Shipper hasn't fired in hours despite KeepAlive=true — check launchctl print gui/$UID/com.loom.shipper. state = running means the daemon is alive; if so, look at ~/.loom/transport/shipper.log for done captured=… ticks. state = not running with last exit code != 0 means the daemon is crash-looping; the log will show why.