Deep reference for Loom's internals. AGENTS.md is the short how-to-work guide and links here; this file is for when you need the full map.
Loom exposes one registered operation model through several adapters:
loom— the orchestrator: the REST + SSE server, the Vue web UI, the per-session detached Tapestry runtime supervisor + agent process (via thesessionstable), the background monitor, and thegit worktreeshell-outs. It is the only process that opens the sqlite database directly. Its CLI, MCP servers, and browser are thin clients of the same registered REST operations.
loom CLI / MCP ──HTTP (REST)──▶ loom server run
│
├─ sqlite ─▶ ~/.weaver/weaver.db
├─ axum REST + SSE
├─ terminal + git wrap.
├─ agent launcher
├─ monitor (consumes
│ `events` rows that
│ `loom hook` posted)
└─ Vue SPA ──REST + SSE──▶ (browser)
Only the server opens the sqlite file directly. The monitor watches the
events table for new hook rows; loom hook posts them through the same REST
boundary as other commands.
crate-layering.md covers the crate split itself: why the loom crates are cut where they are, the module cycles that constrain the cut, and the rule for placing a new module.
| Path | What's in it |
|---|---|
crates/weaver-core/ |
legacy-named internal domain crate: branches, issues, events, db/migrations, git, config, artifacts, reviews, repository configuration, transcripts, and agent helpers. No commands and no routes of its own — the loom crates link it as a library. |
crates/weaver-api/ |
legacy-named typed Loom REST client + DTOs (Client, *View/*Req types, endpoint::default_client() for resolving $WEAVER_API/$LOOM_TOKEN). Zero server deps (no axum, no sqlite driver); it is the cross-process API seam. |
crates/smartdoc/ |
the markdown-convention layer: parse references (#N, artifact:<name>), project live status into the render. Dependency-free of weaver. See artifacts.md. |
crates/loom/src/cli/ |
everything behind the loom command line: clap_bind.rs (the whole of Loom's clap knowledge, driven by an operation's operand list), dispatch.rs (invoke a declared operation and render it), host/ (operator commands that run before there is a server), commands/ (operator commands that talk to one), agent/ (what an agent runs inside a session) |
crates/loom/src/web/ |
axum routes, request/response types, SSE — the HTTP API (incl. the auth middleware + login/token/user handlers) |
crates/loom-ctx/ |
leaf utilities and Ctx (the storage handle, event bus and server address every layer above threads through). No loom dependency of its own; knows nothing about sessions |
crates/loom-store/ |
durable records and storage operations: sessions, chat, channels, layout, runs, history, and the profile record |
crates/loom-agent/ |
agent mechanisms: ACP, builtin/custom runtimes, and trusted MCP adapters |
crates/loom-policy/ |
launch and access policy: profiles, authentication, automation, custom MCP administration, and composed database initialization |
crates/loom-core/ |
shared engine operations: launch resolution, shells, and detached-session ownership reconciliation |
crates/loom-editor/ |
attaching a human to a live session through the terminal or embedded editor |
crates/loom-forge/ |
GitHub, registered repositories, credentials, runtime lifecycle, and AppState |
crates/loom-launch/ |
repository/worktree preparation, provisioning, metadata assistance, and handoff |
crates/loom-watch/ |
status monitor, watch scheduler, builtin programs, and background maintenance |
crates/loom-deliver/ |
Slack and submitted-review delivery |
crates/loom/src/lib.rs |
crate boundary: the HTTP adapter and CLI over the ten engine crates, re-exported here so loom::session, loom::AppState and friends resolve regardless of which crate defines them. The crate does not publicly re-export weaver-core storage/domain modules |
crates/loom-policy/src/auth.rs |
authentication core: token/password crypto, the users/api_tokens/auth_sessions tables, the machine-local token, and the GitHub OAuth calls. axum-free so it unit-tests directly |
crates/loom-ctx/src/client_context.rs |
named endpoint and credential resolution for the loom CLI: XDG user config, private credentials, and repository context selection |
crates/loom/src/server.rs |
bind, write server.json, spawn bg tasks |
crates/loom-watch/src/monitor.rs |
status detection, orphan marking, hook-event consumer, and the shared lifecycle-promotion path (promote_lifecycle) both the terminal hook consumer and the ACP turn-boundary driver (record_acp_lifecycle) run through |
crates/loom-watch/src/watch.rs |
the reactive watch engine: dynamic-wake timer, event dispatcher, and script round executor |
crates/loom-watch/src/builtins.rs |
the builtin watch program registry; the script programs are real Python files in crates/loom-watch/watches/, embedded into the binary |
python/weaver-loom/ |
the pure-Python layer over the loom REST API (weaver_loom: client + watch round context); stdlib-only, uv-buildable, vendored onto every script's PYTHONPATH by the engine; server-free contract tests in tests/ (uv run pytest, CI's python-binding job) |
crates/loom-agent/src/agent.rs |
AgentManager plus launch mapping: resolves registered runtimes, launches terminal agents, builds ACP launches, and runs transient ACP judgement prompts for handoff summaries and POST /api/agents/oneshot |
crates/loom-agent/src/mcp/ |
trusted builtin MCP registry and stdio adapters: provider-neutral versioned capability sets, exact permission translation, and the fixed GitHub/messaging/self-history bridges |
crates/loom-policy/src/custom_mcp.rs |
operator-authored MCP definitions: grouped path identities, immutable sqlite revisions, bounded uv validation, and exact session-snapshot execution |
crates/loom-policy/src/remote_mcp.rs |
operator-registered Streamable HTTP MCP definitions: grouped identities, immutable URL/auth revisions, deployment ownership, and profile pinning |
crates/loom-policy/src/profile.rs |
named launch policy, including provider-neutral mcp_access resolution and the restricted-profile trust boundary |
crates/loom-core/src/launch.rs |
canonical profile-template and override resolution for previews, creates, clones, and handoffs; returns the concrete private launch snapshot plus its transport-safe view |
crates/loom-launch/src/handoff.rs |
provider handoff orchestration: canonical/legacy target resolution, conversation continuity, lifecycle fencing, rollback, and replacement cleanup; depends on runtime/domain owners, never the REST adapter |
crates/loom-launch/src/provision.rs |
ordinary session provisioning: trusted actor attribution, canonical launch resolution, repository/worktree lifecycle, stamped launch snapshots, tracking, recoverable launch-failure surfacing, and title generation; returns only the created Session + Branch domain facts |
crates/loom-ctx/src/scratch.rs |
shared Scratch validation and filesystem storage for launch-time attachments and live route mutations; Axum-free semantic errors keep transport mapping in web/ |
crates/loom-store/src/session.rs |
Session row + sqlx queries |
crates/loom-store/src/channels.rs |
same-id session channels and custom communication contexts: atomic creation, append-only typed messages, per-subject subscriptions/read markers, lifecycle, and runtime-delivery receipts |
crates/loom-store/src/session_layout.rs |
durable Spaces → Groups → Sessions placement, defaults, ordering, optimistic mutation revisions, and revision-invalidation publication; independent of immutable provenance and launch policy |
crates/loom-core/src/session_manager.rs |
database-backed ownership reconciliation for detached agent/debug supervisors; removes Loom-namespaced runtimes without a live session or active launch-reservation owner |
crates/loom-deliver/src/review_delivery.rs |
submitted-review outbox and protected conversation-inbox delivery, including ACP claim fencing and terminal retry/rehome behavior |
crates/loom-launch/src/metadata_assist.rs |
bounded generated task-title and resumption-cue assistance on the session's ACP runtime, with privacy eligibility, source fences, and deterministic fallback |
crates/loom-store/src/chatlog.rs |
conversation log: capture at archive (write the iris chat.json + rendered chat.md under session.log_dir) and serve it for the Conversation tab (conversation() — a terminal session's live transcript, an acp session's chat journal mapped to iris (journal_to_log), else the capture) |
crates/loom-store/src/history.rs |
provider-neutral session-history records and bounded paging/literal search across the ACP journal or terminal Iris normalization; optional fields describe only source-supplied data |
crates/loom-ctx/src/backend.rs |
the terminal-management seam: every programmatic terminal op (create/has/capture/send/kill/list) drives the session's tapestry supervisor. Also the ACP transport seam — new_relay_session/subscribe_relay/relay_write/relay_ack drive a session's tapestry relay supervisor (a durable JSON-RPC frame spool) |
crates/tapestry/ |
the per-session detached supervisor that outlives loom. Two modes: a terminal (PTY + vt100 screen emulator + unix control socket, streaming raw PTY bytes so an attached xterm owns its own scrollback/search), and a relay (a headless stdio subprocess whose stdout is split into newline-delimited frames, spooled with monotonic seqs, and replayed to a subscriber from any cursor — the durable transport under loom::acp) |
crates/loom-editor/src/terminal.rs |
WebSocket ⇄ live-terminal bridge: xterm.js ⇄ the tapestry session socket |
crates/loom-editor/src/ide.rs |
lazy per-session code-server ownership plus the authenticated same-origin HTTP/WebSocket reverse proxy used by the advanced editor panel |
crates/loom-agent/src/acp/ |
the Agent Client Protocol client: one tokio task per protocol='acp' session drives a headless adapter subprocess (under a tapestry relay) over JSON-RPC 2.0 — consolidating streaming session/updates into journal blocks, block-boundary acking the relay spool, running the turn state machine, and answering permission requests. start/attach register a task into the AppState.acp registry the /chat, /prompt, /permissions, /mode, /interrupt routes drive. acp/wire.rs holds the JSON-RPC line codec + serde types |
crates/loom-store/src/chat.rs |
the ACP chat journal: the durable, block-structured (chat_blocks, one row per (session_id, turn, seq)) conversation record loom::acp writes idempotently and the /chat routes read |
crates/loom-forge/src/github.rs |
gh CLI shell-out: issue seeding, PR opening, and the PR-status poll loop (snapshots each branch's PR; archives on merge) |
crates/loom/src/client.rs |
HTTP client used by the loom CLI to talk to its own daemon |
crates/loom/src/bin/loom.rs |
the loom command tree and its tests, and nothing else; the commands themselves live in crates/loom/src/cli/ |
crates/loom/frontend/ |
Vue 3 SPA, rspack, Tailwind. api.ts + views in views/; the visual rules live in loom-ui.md |
crates/loom/static/dist/ |
Build output (placeholder; real build overwrites) |
crates/loom/tests/ |
integration tests: integration/ (server suites) + hook_monitor.rs; need git (they spawn tapestry supervisors, built by the same cargo test) |
e2e/ |
Playwright; talks to a real loom server run. Separate package.json |
crates/loom/build.rs |
Builds the SPA into static/dist (npm + rspack); writes a placeholder when Node is unavailable |
cargo build builds the SPA into static/dist via build.rs; loom serves it
from there at runtime (web::static_dir). rerun-if-changed makes the SPA build
a no-op when no frontend source changed, so backend-only edits don't re-run
rspack; a Node-less checkout still builds the backend and serves a placeholder.
There is no skip flag.
Tests are proportional by layer. Rust and frontend unit tests own pure module
logic; crates/loom/tests/integration/ proves cross-module wiring against an
isolated live server; Playwright owns browser journeys; Python package and
builtin-watch logic lives in pytest. scripts/test-representative.sh amortizes
one selected journey from each setup-heavy feature after running the unit
suites. Full workspace and Playwright suites remain the exhaustive CI gates.
scripts/pre-commit.sh is the deterministic local/CI gate: Rust formatting and
clippy plus frontend unit tests, typecheck, and Prettier when npm is available.
The separate agent lint-review policy and invocation live in
docs/lint.md and the pull-request skill, not in the commit hook.
The integration tests shell out to real git and spawn tapestry terminal
supervisors (detached PTY processes). The harness kills its supervisors on drop;
if one hangs, look for stray tapestry supervise processes.
The e2e/ suite drives the real UI against a real server. It boots one
loom server run per Playwright worker (not per test) on a random port, each with
its own WEAVER_HOME / sqlite db (which also scopes the tapestry terminal
sockets) and a throwaway git repo (see e2e/fixtures/weaver.ts). Sessions launch
under a deterministic, command-less custom agent (a bare login shell) the fixture
seeds as shell, so tests never spawn a real agent CLI. The per-test weaver fixture wipes every
session (branch + worktree) between tests, so each starts from a clean slate and
count-based assertions hold regardless of order. Workers are fully isolated, so
the suite runs in parallel (fullyParallel, workers > 1) and — because every
session it touches is scoped to a worker's private socket and db — can't disturb
a long-running dev server or your ~/.weaver sessions. A globalSetup runs
cargo build once up front so workers never race on the build.
cd e2e
npm install # first run only; also fetches the browser (see below)
npx playwright install chromium
npm test # runs the suite; rebuilds loom + the SPA if staleOn a Linux distro Playwright doesn't ship a prebuilt browser for (e.g.
ubuntu26.04, where playwright install errors with "does not support
chromium"), force the nearest supported fallback build with
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE, and set it for the test run too so the same
binary is launched:
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64 npx playwright install chromium
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64 npm test- SQLite at
$WEAVER_HOME/weaver.db(default~/.weaver/weaver.db), opened only byloom; every external adapter reaches it over HTTP. WAL mode handles concurrency among loom's own connections.- Core tables:
branches,issues,events,settings. - Loom tables (
crates/loom-store/src/db.rs):sessions(origin— the channel it was created through:user/agent/github/slack/watch/actions/ops, stamped server-side at create;class—interactive/automation, retained as machine provenance and for policy, not as a separate view over the fleet. A request may setclassexplicitly; otherwisewatch/actions/opsorigins default toautomationwhilegithub/slackstayinteractive— a person asked for those sessions and expects to find them on the board;turn_count— incremented on eachworkinglifecycle edge;tracking_issue_id— the optional explicitly claimed/imported compatibility work item. One active session per branch is enforced by a partial unique index onbranch_idwherestatus NOT IN ('done', 'error', 'archived')— an archived session releases its branch slot, so relaunching a done/archived branch is never blocked by its predecessor),session_layout_state,session_spaces,session_groups,session_placements(one canonical ordered placement per session),session_placement_defaults(origin/profile routing), anduser_session_group_state(operator-local collapse preferences),channels,channel_messages,channel_subscriptions, andchannel_deliveries(durable communication context, append-only stream, per-subject read/mode state, and separate runtime acceptance receipts),recent_repos,slack_routes(Slack threads an automation delivery pointed at a branch, keyed on the thread: many alert threads may route to one operator session, which is why this is not the single-valuedslackbranch tag),branch_github(per-branch PR snapshot),chat_blocks(the ACP chat journal: one row per(session_id, turn, seq)block),session_acp_metadata(the latest provider-advertised composer controls, retained when its live task exits or Loom restarts),session_metadata_assistance(per-session generated-title enable/status and the cached resumption cue keyed by a bounded conversation/content + immutable-artifact fingerprint), and the auth tablesusers(the approved-operator allowlist, seeded with the owner),api_tokens(hashed bearer tokens), andauth_sessions(hashed login cookies). See Authentication. Loom-owned tables have their own ordered migration stream undercrates/loom-store/migrations/, recorded inloom_schema_migrations. This is a separate stream because the core migrations run before loom creates its tables. A pre-stream loom database is adopted once by presence-based introspection, then stamped at the baseline version; do not add more error-message-drivenALTER TABLEprobes. - Schema migrations (
weaver-core/src/migrations.rs): ordered SQL files undercrates/weaver-core/migrations/(NNNN_name.sql, embedded withinclude_str!), applied at startup and recorded in aschema_migrationsindicator table so each runs once. Add a change as a new numbered file plus a row inMIGRATIONS; never edit one that has shipped. A pre-framework database is brought to the baseline by a one-timelegacy_bootstrapon first run.
- Core tables:
Issues are repository-owned. source_branch is immutable provenance;
claimed_branch is the optional current owner and alone defines a branch's
working set. Removing a session clears its claims back to the backlog rather
than deleting issues. The in-worktree CLI reads its claims plus a bounded
unclaimed backlog; the Loom board reads the repository-wide set.
Visible sessions have one shared Spaces → Groups → Sessions placement. Attention, All, and History are views over it. Successful automation is placed as an ordinary session in Ops; an unmatched provisioning or failed run is projected as an Intervention in Attention/Ops without inventing a browser- local session. Hidden warm infrastructure has no placement. GitHub and Slack triggers have their own origin-default Inbox spaces; delegated sessions inherit their parent's placement.
The schema has two owners even though both currently share one SQLite file:
weaver-coreowns the durable domain tables (branches, issues, tags, events, artifacts, discussions, watches) andschema_migrations. Its original baseline also physically createssettings; operator config is a loom concern, but moving that table is still future boundary work.loomowns host-local runtime, identity, integration, and agent-config tables (sessions, chat, users/tokens, repos, agent config) andloom_schema_migrations.
The target rule is to keep cross-owner links as identifiers in application
code. Two existing exceptions remain explicit split prerequisites: the
sessions.branch_id schema has a physical foreign key to branches, and the
issue-list view joins sessions to branches to apply automation visibility.
Remove those before putting the owners in different databases, and do not add
new cross-owner joins in the meantime.
weaver-core::db is the backend seam: callers use its Db and
DbTransaction aliases, time values are computed by Rust and bound, row
decoding uses FromRow, and new conflict clauses should use portable
ON CONFLICT forms. The implementation is still deliberately SQLite:
Db = SqlitePool, connection setup uses WAL/BEGIN IMMEDIATE, migrations use
SQLite introspection, and runtime queries use SQLite placeholders. These
changes make a future backend explicit; they do not claim PostgreSQL support.
The first useful shared-PostgreSQL move is the durable domain tables, not
host-local sessions. Its main data-model gate is a logical repository
identity: today domain rows key repos by absolute checkout paths, so two
hosts would otherwise create two unrelated histories for the same repository.
After that change — and after removing the FK/join and relocating settings
noted above — a PostgreSQL implementation of the database seam and a real
PostgreSQL CI lane can move those tables while each runtime host keeps its
sessions and chat in local SQLite.
server.jsonin$WEAVER_HOME: pid + bound addr, written whenloomcomes up. TheloomCLI uses it to find the daemon whenWEAVER_APIis unset.- Deployment settings use runtime rows in
settings, deployment defaults indeployment_settings, and immutable defaults declared inweaver-core::config::REGISTRY; reads resolve in that order. Both binaries use the same helpers. Personal overrides useuser_preferences, keyed by username and preference key. See configuration policy. Repository workflow instructions live inWEAVER.mdandAGENTS.md. Launch policy belongs to administrator-managed profiles. - Worktrees live under
<repo>/.worktrees/<slug>onweaver/<slug>(unless--branchreused an existing branch). - Which repo a session forks from is either a local checkout (
CreateReq.cwd— the server resolves its main worktree) or a managed repo (CreateReq.repo: a GitHubowner/nameslug or clone URL). A managed repo is cloned into the repo store ($WEAVER_REPOS_DIR, default$WEAVER_HOME/repos, laid out<root>/<owner>/<name>) on first use and fetched thereafter, and the worktree is cut from that clone. Naming one on an authenticated create registers it in therepostable, soloom launch --repo acme/widgetsworks against a repo this machine has never checked out. That table doubles as the clone allowlist for the unauthenticated GitHub webhook, which resolves its own clone throughrepo::resolve_cloneand refuses a repo that is not on it.
Routes live under /api; the Vue SPA, CLI, and MCP adapters are clients of the
same HTTP API. The rule is one line:
Anything that reaches the API is a registered operation. The only axis that varies is response encoding.
operations.md walks the whole flow with diagrams: what the macro emits, how each of the HTTP API, the CLI, and MCP is assembled from it, and the invariants that keep them in step. This section is the summary.
An operation is declared exactly once, in
crates/weaver-api/src/operations/ —
one file per top-level group, and its id is its module path:
sessions.shells.delete is sessions.rs's shells::delete and nowhere else.
A declaration is one attribute on the operation's Input:
/// Close one of a session's worktree debug shells.
#[operation(id = "sessions.shells.delete", actor = SessionSelf, scope = Session,
risk = Write, grants = ["loom/sessions/write@v1"])]
pub struct Input {
/// Which of the session's debug shells to close.
#[operand(positional)]
pub index: u32,
#[operand(context)]
pub session: String,
}
pub type Output = ShellsView;actor says which credentials may call it, scope which resource it is
authorized against. The attribute emits the serde and schema derives, an
Operands impl describing each field as data, the Op marker handlers bind
against, and a Scoped impl that reads the context field scope names — so
the declared scope and the enforced one cannot drift.
Everything else is derived from that:
| derived artifact | derived how |
|---|---|
| REST route | POST /api/sessions/shells/delete — the id with dots as slashes. Not declared; computed. |
| JSON Schema | schemars over Input, minus #[operand(context)] fields; Output answers for the response |
| MCP tool | name, description, and schema from the same declaration |
| CLI command | Operands::OPERANDS describes each field as data; loom::cli::clap_bind turns that into a clap::Command and reads the parse back, so an advertised invocation and the parser that accepts it are one declaration read twice |
| authority | actor, grants, and the resource named by Scoped |
register::<O>(handler) in crates/loom/src/web/ binds the declaration to its
implementation; the type parameters are the check, so a handler cannot accept or
return something the declaration does not promise. Startup asserts the two sets
are equal, so a descriptor with no handler fails the build before it ever
reaches production.
#[operand(context)] marks a field the dispatcher supplies — session,
branch, repo_root. It is stripped from the caller-facing schema and filled
server-side from the calling credential, so an agent says "my session" by saying
nothing. An explicit value still wins, and still faces the scope check.
Render is how an operation's result becomes text. The default is the
operation's own JSON, which is complete for the long tail of administrative
commands, so a new operation has a working CLI the moment it is declared. Bundles a human reads all day say render = custom and write an impl
in crates/weaver-api/src/render/ — 50 of
them do. The CLI and MCP both call it, so one operation prints one way on both.
A renderer is a pure function of Output and the view flags. It gets no client
and makes no calls, which is the line that decides whether a command can be
served by its declaration at all: loom summary and loom issues ls stay
hand-written because the text they print needs reads the operation does not
return.
view = View names a struct of presentation flags — six operations have
one. They never appear in the request body; they choose what is printed, so
they live beside the operation rather than in its Input. loom issues list --mine is a View field, --all is an Input field, and the difference is
whether the server needs to know.
io names how an operation answers, and it is a field on the declaration rather
than a reason to leave the registry:
io |
method | served by |
|---|---|---|
Json |
POST | the generic dispatcher |
Session |
POST | hand-written, because the response must carry a Set-Cookie |
Stream |
GET | hand-written, because the body is an SSE stream |
Duplex |
GET | hand-written, because the response is a websocket upgrade |
Upload |
POST | hand-written, because the request body is the payload's raw bytes |
Download |
GET | hand-written, because the response is raw bytes plus a content type |
A Stream, Duplex or Download operation takes its operands from the query
string (GET /api/sessions/terminal?session=abc), so its real route equals its
derived one. Its handler lives in
crates/loom/src/web/encodings.rs and calls
the same authorize the JSON dispatcher does — a custom encoding does not mean
custom authority.
The multiplexed events.stream is a container: reaching it grants nothing,
because each requested topic is authorized as the single-topic operation it
stands in for.
Eighteen paths, each mounted in
crates/loom/src/web/mod.rs with its reason
written beside it:
- the reverse proxy to the embedded editor, which forwards an arbitrary sub-path into a container and streams back whatever comes out;
- unauthenticated infrastructure (
/health,/metrics,/ready), the HMAC-authenticated GitHub webhook, and the browser OAuth redirects; - the three
io = Sessionoperations, which are registered operations — they are mounted beside the auth routes only because their response must carry aSet-Cookie; - registry discovery (
/api/meta,/api/operations,/api/openapi.json) — making those operations would be circular.
Outside /api, root_router mounts the remote MCP connector: /mcp (a
transport, like the editor proxy) and the OAuth 2.1 authorization server that
issues its credentials (/oauth/authorize, /oauth/token, and the
/.well-known/oauth-* documents), whose paths and shapes the MCP and OAuth
standards fix. See Remote MCP connector.
That is the entire hand-mounted route list, and it is the whole of what
.route( appears for. An endpoint carrying a JSON contract and a Loom principal is an
#[operation]; its route is derived from its id and mounted by the dispatcher,
so writing one here is visible in review as the exception it is.
Reaching it took eleven new operations — issues.update, issues.board,
sessions.summary.list, sessions.tags.replace, sessions.github.access.list,
channels.archive, artifacts.raw, and the sessions.scratch.* trio — plus one
new Io variant, Download, the mirror of Upload: a GET that answers bytes
and a content type. Four existing declarations were widened to match the full
behavior of the routes they now serve.
grant_allows in crates/loom/src/web/auth.rs
decides seven raw paths: the IDE proxy and registry discovery. Everything else is
actor on the declaration plus authorize() at the dispatcher — one decision,
reached identically by REST, the CLI and MCP, so an adapter cannot widen
authority by choosing a different transport.
Keeping this list short matters. A path-based authority model running alongside
the registry fails two ways: a newly declared operation is refused until someone
adds its URL to the list (this once 403'd permissions.requests.create for the
very session it was declared for), and an entry naming a deleted route stands as
a pre-authorization for whatever gets mounted at that path next.
no_grant_allows_path_is_unmounted checks for the second failure regardless of
how short the list is.
Resource access is one check, driven by Scoped on typed input: a session
credential reaches only its own session tree, its own repository, its own
tree's branches, and the channels its tree is subscribed to. web/scope.rs
holds the first three; channels::session_may_reach holds the last.
A channel gets its own OperationScope rather than riding on Branch because
a channel is reachable from more than one branch — a session subscribes across
its tree. scope = Branch would compare branch, a context operand always
naming the caller's own, which every session-credentialed request satisfies:
any channel id would pass. Declaring scope = Channel forces the check to
run, so a new channel operation cannot forget it.
A child reports completion with a typed result in its own channel. Loom
appends one linked, system-authored message to the immediate parent's channel
and queues its delivery behind an active ACP turn (or sends it to the terminal
pane). Source publication, parent notice, delivery receipts, and ACP inbox
entries commit in one transaction. The notice includes up to 2,000 characters
of the result and an exact source reference. The link uses the source message
id for idempotency. System authorship
matters here:
appending under the child's identity would subscribe it to the parent channel
and widen its access. This handoff grants the child no access to the parent
channel.
Agent-authored input begins with Update from loom session <id>, derived from
the authenticated sender. Direct ACP sends and channel messages use the
shared conversation_inbox, alongside queued user feedback. Each input carries
its provenance, editability, attachments, and optional interruption deadline.
Normal agent updates wait for a turn boundary for up to 60 seconds, then cancel
ordinary work (including turns opened by earlier session updates) and open an
update turn. Channel urgency attention/blocked interrupts immediately;
debug never interrupts due to age. Status and other
bookkeeping kinds do not prompt agents. Direct agent sends use normal urgency.
Explicit Stop, compaction, submitted UI review turns, session generation, and turn
caps hold automatic delivery. Human-prompted work is ordinary work for this
policy. Submitted UI reviews retain their immediate delivery path.
Session updates store an immutable interrupt_at deadline (null for debug).
The one-second ACP inbox tick also checks deadlines during active work; wakes
are hints and retries never reset the clock. Migration gives the existing
backlog a fresh 60-second grace while preserving publication timestamps. Later
restarts retain deadlines, so an overdue update can interrupt on the first tick
after adoption. Policy cancellations journal cancelled_by: update_policy so a
failed replacement turn does not become a human Stop on recovery. Terminal
delivery still pastes and submits immediately without ACP deadline semantics.
The chat snapshot and queue stream event expose one pending_inputs list.
User feedback and agent updates use the same claim, dispatch, force, and
settlement path. Operators can force a selected input with
sessions.prompt.create using force_queued and its delivery_key; omitting
the key sends the oldest queued input. This preserves source provenance and
other queued messages, resumes after Stop, and waits for compaction to finish.
Only editable user feedback can be retracted. Adjacent user messages coalesce
into one draft without crossing an intervening agent message.
An agent read or wait consumes any corresponding queued notice, while a peek
and a human read do not. Use channels wait --after <last-seen-seq> --kind result
for results; lifecycle waiting does not imply a result was published.
Stable publication keys deduplicate retries; distinct keys remain distinct even when the text matches. ACP claims fence concurrent dispatch. Once a session update is journaled, abandonment does not redispatch it. The unacknowledged relay write still has a crash window before journal persistence; this is not an exactly-once process-delivery guarantee. Terminal channel delivery records an uncertain outcome before writing to the PTY, preventing concurrent or blind retries. After checking the terminal, an operator can explicitly republish with a new key if needed. Existing concatenated prompt text is preserved on upgrade.
One capability is narrower now: an automation credential reaches no raw path,
only actor = Internal operations — runs.create alone. Nothing used its old
ability to GET /sessions/{id} for sessions it had created, so the narrowing
was safe; automation credentials are scoped to Internal operations.
Review drafts are REST-private and emit no branch-wide event until submission;
other tabs refresh the creator's draft when they regain focus. ReviewDto
separates the subject's internal id from its public round-trippable key,
returns a monotonic draft_revision, and exposes the exact server-rendered
message. A 409 optimistic-revision response carries the fresh review under
details.review. ACP delivery commits into conversation_inbox, a
stable-key, branch-addressable immutable lane consumed at turn boundaries.
The shared queue marks submitted reviews as non-editable, so prompt retraction
can never expose submitted feedback. Delivery workers claim fenced lease
tokens, leave offline targets at zero attempts, and may rehome queued feedback
to the branch's next usable conversation.
SessionView (the sessions.* operations) returns session-specific fields
top-level (id, status, work_dir, term_session, agent_kind, model,
effort, github_repo, github_issue (the repo + number
linked on the session's tracking issue), last_activity_at,
created_at, updated_at, parent_id, protocol (terminal or acp),
acp_session_id, current_mode, usage ({used, size} context window, from
the journal's latest usage block), origin (the channel that created it:
user/agent/github/slack/watch/actions/ops), class
(interactive/automation), turn_count (incremented on each working
lifecycle edge), placement (qualified space/group plus integer rank), and
tracking_issue (an optional explicit claimed/imported compatibility work item,
populated on every read)) plus a nested
branch: BranchView
(id, name, title, goal, description, tags,
repo_root, branch, base_branch, created_at, updated_at,
open_issue_count, github).
BranchView::tags is the branch's tag list — each a TagView
(key, value, note, set_by, set_at). A tag is a single-valued
(key, value) annotation on a branch; the well-known keys are attention (the
agent's self-report) and triage (a watch's assessment), and any other
key is a free-form, quiet pill. Absence of a key is the calm/default state —
there is no stored ok value; the list is empty for an unmarked branch. The
signal is value-driven. An attention value (attention/blocked) raises
the branch on the dashboard whatever its key. The review watch's quiet
awaiting: review mark sorts below the calm default because it describes work
waiting on an external actor with nothing for the operator to do. The internal
value sets live in weaver_core::tags.
SessionView::parent_id is the branch id of the session that launched this
one — the parent in loom's session tree — or null for a top-level session. It
is stamped onto the sessions row at create time from the resolved
parent_branch (so reads need no extra query and the link can't drift), and is
null too when that parent is later untracked. It is launch provenance for
delegation displays; workbench grouping and ordering come from the session's
canonical space/group placement.
BranchView::github is the branch's latest GitHub pull-request snapshot
(pr_number, pr_url, pr_state, pr_title, is_draft, review_decision,
checks, mergeable, merged_at, head_sha, head_updated_at, fetched_at),
or null when GitHub polling is off, there is no PR, or the App is unavailable.
See GitHub integration.
Status is two orthogonal axes. The session's status is the lifecycle
(orchestrator-owned, mechanical): created / running / orphaned /
suspended / done / error / archived. The branch's attention tag
(value attention | blocked, absent ⇒ calm) plus its description (a
one-line current-state message) are the agent-declared "does this need
me?" signal, both set via loom status. The dashboard resolves and filters on
the attention signal.
Suspend/wake is deliberate dormancy between running and archived:
sessions.suspend (loom sessions suspend) stops a session's runtime —
agent supervisor, debug shells, IDE sessions — freeing its memory, while the
session row, worktree, branch, and conversation stay in place. The retention
reaper suspends ordinary running sessions automatically after
session.idle_suspend_secs (off by default; warm watch-managed
sessions are exempt when enabled; the auto-suspend: disabled tag opts a
session out). Any
ingress signal wakes a suspended session transparently before driving it — a
sent message or prompt (sessions.send, sessions.prompt.create), a channel
delivery, an automation run, a GitHub @loom trigger, a submitted review —
and sessions.wake (loom sessions wake / the Wake action in the row ⋯ and
Details menus) does it manually.
Wake replays the adoption machinery (respawn + session/load reopen for ACP,
--continue for terminal agents), so it costs seconds, not a rebuild: the
deliberately missing supervisor is never mistaken for an orphaned session, and
the TTL ladder stays layered — suspend at hours, archive at days.
There is no /api/hook endpoint — see Status & tags.
Scratch files are reference material dropped into the worktree's scratch/
directory (git-ignored, so it never enters the agent's diff). They can be added
to a live session via sessions.scratch.write (POST /api/sessions/scratch/write?session=…&name=…, the file's bytes as the body —
the one io = Upload operation, since there is no JSON envelope to carry
operands in and they ride the query string instead), or attached up-front in
the New Session form: those ride in the create request as scratch and are
written before the agent launches, with a note appended to the launch prompt
so a fresh agent knows the files are there. The stored branch goal stays the
clean text the user typed. Launch-time and live uploads share a 20-file,
25-MiB-per-file, 50-MiB-total decoded limit and the same filename validation.
The create route has a base64-aware transport envelope, validates the whole
batch before repo/worktree/branch/issue/session side effects, counts ordinary
dotfiles, and reserves .gitignore for Loom's scratch/* exclusion guard.
Resolved launch snapshots. A profile is a reusable template. A canonical
LaunchSelection layers optional one-launch selectors over it; omissions
inherit through the template, agent metadata, and policy defaults. Resolve
returns field provenance plus profile_revision and a stable
resolver_revision. Canonical create, clone, and handoff carry those guards and
receive 409 with a fresh preview on drift. Once accepted, resolved_launch is
the concrete non-secret snapshot stamped for that runtime launch. Subsequent
template/default/registry edits cannot silently mutate it; an explicit handoff
can replace it with a newly resolved snapshot.
Builtin ACP launches have one additional admission check because provider account entitlements cannot be inferred from Loom's static agent metadata. After resolving the repository/profile environment but before creating a worktree or session row, Loom opens a disposable, prompt-free ACP session and applies the selected model, effort, and mode through the adapter's live configuration calls. An unavailable selector is returned as launch validation; the transient relay is always removed and receives no MCP servers or session token. Operator-defined adapters retain the recoverable failed-session path because their launch commands and validation semantics are operator-owned.
Launch base. A new session's worktree forks from base. When the create
request omits it, git::default_base resolves the repo's default branch on
origin and fetches it, so the branch starts from a fresh origin/<default>
rather than the launching checkout's current branch. A remote-less repo (no
origin) degrades to the local current branch. The caller — the CLI's --base
or the create form's base field — can pin any ref instead.
Driving the terminal. send / interrupt / preview are one-shot HTTP
primitives over the supervisor's control socket (see backend::send_literal,
send_key, capture), distinct from the interactive terminal WebSocket: they
let an agent or script type into, interrupt, or read back a child session
uniformly. For a terminal session each requires a live terminal (else 409). An
acp session has no PTY, so the same verbs map onto the protocol — keeping the
CLI (loom sessions {send,interrupt,preview}) and its nudge audit uniform across
backends: send cancels a live turn and starts the message as a normal prompt,
interrupt is a session/cancel,
and preview renders the last journal blocks as compact plain text instead of
a vt100 screen capture.
Embedded editor. The optional editor is a lazy per-session code-server
process rooted at the worktree and bound to loopback with its own authentication
disabled. It is reachable only through Loom's authenticated same-origin
HTTP/WebSocket proxy, so the iframe carries the Loom cookie and cannot choose an
arbitrary upstream or another session's worktree. The proxy preserves
Host/Origin for code-server's WebSocket checks. sessions.ide_info is the availability
probe; a development install without code-server remains usable. Archive and
remove stop the editor with the session. The UI exposes it under
Details → Advanced, not as the primary file browser or editor.
- API-first. New agent features land as executable operation registrations
first. Routine operations declare arguments and authority beside their typed
implementation; the registry derives Axum routes and discovery, while CLI and
MCP call those routes through the REST client. Custom adapters remain for the
minority of transport-specific behavior. Don't put business logic in
the command line (
crates/loom/src/cli/), MCP dispatch, or the Vue layer. - Errors: the server returns
AppError(status + message + optionaldetailsmap of per-field reasons); theloomCLI usesanyhowand printserror: {e:#}. - Async: tokio everywhere on the server side. External processes (Tapestry
runtime supervisors, git, gh, and agent adapters) go through
tokio::process::Command. TheloomCLI remains synchronous-feeling while delegating its reads and writes to the internal typed API client over HTTP. - Events: state changes flow through
EventBus; the SSE handlers inweb/fan them out.loom hookposts to the branch events route; Loom writes the row, and the monitor tick promotes it into session status and a freshEventBusnotification. Browsers cap HTTP/1.1 at 6 connections per origin and an EventSource holds one for its whole life, so the SPA subscribes toevents.streamand receives every topic over that single connection; the per-stream routes remain the single-stream API for other clients. - No tracking-branch state in the server: loom can be killed and restarted
at any time. Terminal and relay supervisors and worktrees survive (the
supervisor is a detached process, independent of
loom server run); "orphaned" is an ordinary status, recovered vialoom sessions adopt(or the Adopt button in the UI). On startup and periodically afterward, the active loom generation re-attaches every live-relay ACP session missing its in-process driver so its journal keeps flowing; aloom.jsonownership fence prevents an older draining server from competing for Tapestry's single relay subscription. That fence is advisory across a restart overlap, so the durable one issessions.acp_driver_epoch: a driver claims it before subscribing, and only the holder may transition the row's runtime status. A driver evicted from the relay by its successor therefore exits quietly instead of marking a sessionorphanedthat someone else is driving — a contradiction that would leave the row unadoptable (adoption refuses a live task) and unrepairable (the sweep skips one). Whoever attaches a driver settles the row afterwards, clearing a staleorphanedan outgoing driver raced in. ACP cursors flush periodically rather than once per streaming frame, and a failed durable journal write yields the task so the repair pass replays from the last ACK instead of accumulating an unbounded backlog. Tapestry drains durable replay in a separate back-pressured task, keeping ACK, write, ping, and replacement subscriber control responsive even when the replay exceeds every bounded stream buffer. Adopt re-attaches when the relay outlived a crashed task, or respawns the adapter and reopens the conversation viasession/load(falling back to a fresh session re-oriented from the goal) when the relay is gone too. - No unowned session runtimes: the database is the ownership authority for
detached supervisors. Startup and periodic reconciliation remove
weaver-<id>agent supervisors andloom-shell-<id>-<index>debug shells without a non-archived session/active-launch-reservation owner; inspectabledone/errorsessions remain owners, and the monitor handles the inverse mismatch by marking a session row with no supervisororphaned. See Session lifecycle. - Shell placement and environment follow the agent: each per-session debug
shell is derived by the owning Tapestry supervisor. It therefore starts in
the same Docker container (when configured) with the agent's exact materialized
launch environment, including profile/repository values and its session-scoped
credential-broker identity. The global operator Shell is the deliberate
exception: its supervisor runs beside
loom server run, giving operators the control-plane container's process/filesystem view and its Docker-socket view of sibling sessions.
Two distinct axes (see the SessionView note above): the mechanical lifecycle
sessions.status, and the agent-declared attention carried as a tag on the
branch.
Tags are single-valued (key, value) annotations on a branch, each with a
note, set_by, and set_at, stored in the shared tags table (one row per
(branch_id, key), registry in weaver_core::tags).
Loudness lives in the value, not the key: a tag whose value is on the
attention | blocked ladder is loud (raises a badge) regardless of key, so
agents and watches both add loud tags without a privileged key registry. A tag's
key is its type (the chip label — attention, review, stuck, …) and its
value is the severity; every other value is a free-form, quiet pill. The
agent authors the well-known attention key for its own self-report; a watch
authors its own typed keys. The well-known idle key is a quiet exception:
loom stamps it mechanically when an agent goes quiet (the soothing "resting, no
one needed" state), carrying the non-loud value idle so it never raises a badge
— the dashboard surfaces it as a calm "Idle" mark, and the status watch may
replace it with a real loud status. Unlike a loud outside mark it is not subject
to activity-staleness (below): it is the agent's own lifecycle mark, cleared
event-driven by the next working hook (a submitted prompt), not retired by
last_activity_at advancing — the turn-ending output that fires the idle hook is
itself a pane change that bumps last_activity_at, so a stale-check would retire
the mark the instant it lands. Absence is the calm/default state — there
is no stored ok; returning to calm clears the tag. A tag is stale when its
set_at predates the session's last_activity_at (the session moved on since it
was set). The dashboard resolves the loudest non-stale loud tag into one
attention signal, with attribution (the agent's own, or an outside mark). The
agent's own attention self-report stays the server-side signal — what
loom status, resolve_attention, and loom issues wait read — so a watch's
outside marks surface on the dashboard without spuriously waking sub-agent
tracking.
Protocol axis. Every agent declares an execution protocol — terminal
(the agent runs in a PTY loom drives by keystroke) or acp (a headless adapter
loom drives over the Agent Client Protocol).
The builtins are terminal; a custom agent carries its own custom_agents.protocol
column. A create may override to acp where the agent allows it (both Claude
and Codex opt in), and the resolved protocol is stamped on the sessions row at
create, immutable thereafter. The row's
protocol — not the agent kind — is what every downstream path (launch, lifecycle,
drive routes, adopt, archive) branches on.
Codex ACP's agent mode uses the workspace-write sandbox. Loom sets
sandbox_workspace_write.network_access and enables Codex's network proxy with
only 127.0.0.1, localhost, and the ContainerRunner's loom network alias
allowed. This lets the injected $WEAVER_API remain usable for status,
artifacts, and channels without opening arbitrary shell-command egress. A plain
workspace-write network_access = true without the proxy would be broader than
the control-plane requirement.
For shared caches outside the workspace, configure absolute writable roots in
an operator-managed profile's CODEX_CONFIG. For example (replace the paths
with directories on the agent host):
loom profiles env set codex CODEX_CONFIG --value '{"sandbox_workspace_write":{"writable_roots":["/home/user/.cache/uv","/home/user/.cache/cargo-build"]}}'This command replaces CODEX_CONFIG; merge these roots into any existing
configuration. Set UV_CACHE_DIR and CARGO_TARGET_DIR in the profile as needed
so tools use those directories. Loom forwards the roots as codex-acp's
additionalDirectories on both session/new and session/load: the adapter
constructs its sandbox policy for each turn, so a home config's writable roots
alone do not supply these grants. Roots must be absolute paths; duplicates are
removed. These are filesystem write grants, independent of Loom API permissions.
Profile edits apply to new sessions or an explicit handoff; recovery keeps the
session's saved launch configuration. Transient summary prompts do not inherit
these extra filesystem grants.
Lifecycle is driven by that protocol. A terminal session's lifecycle rides
Claude Code's hooks, so that path merges a hooks block into the worktree's
.claude/settings.local.json (see loom::agent::install_hooks and
weaver_core::agent::hooks_json); hookless terminal agents — Codex, and any
custom agent whose reports_status is off — start running immediately:
| Claude hook event | shells out to |
|---|---|
SessionStart |
loom hook --event session-start (also injects additionalContext: a repository WEAVER.md override, or Loom's compact code-registered primer, on a genuine start/resume/clear; after a compaction it injects a loom summary re-orientation) |
UserPromptSubmit |
loom hook --event working |
Notification |
loom hook --event waiting |
Stop |
loom hook --event idle |
loom hook posts an events row keyed on the branch resolved from
$WEAVER_BRANCH (set by the launcher). Loom's monitor (apply_hook)
consumes new hook rows on its next tick. A working / waiting / idle hook
means the agent process is alive, so each sets status = running (this also
promotes a freshly launching session); session-start is recorded for the
primer injection but the launch path owns the initial status, so it drives no
liveness here. Liveness is all a work hook proves, so that is all the
orchestrator tracks — it does not infer working/waiting/idle from stillness.
An acp session drives the same lifecycle from the protocol's turn boundaries
rather than hooks: the acp task calls monitor::record_acp_lifecycle at turn
start (working) and turn end (idle), which records the very hook event row
loom hook would and then runs the shared promote_lifecycle path — so the
status lift and tag mutations live in exactly one place across both backends. The
monitor's apply_hook therefore ignores an acp session (a stray work-cycle hook
a user's own settings might still fire must not move it), and the acp task is the
sole driver. Claude-over-ACP installs only the SessionStart primer hook (the
additionalContext injection is still wanted); the work-cycle hooks and the
launch-gate seed are redundant under ACP, where the protocol's turn edges and the
bypassPermissions posture replace them.
The hooks also stamp a soothing, quiet idle tag — the calm "resting, no one
needed" state, deliberately not on the loud ladder, so an idle agent never
reads as needing the user. working (a prompt was submitted — the user is
engaged) returns the agent to calm, clearing both the idle mark and the agent's
own attention tag. waiting (a Notification lull) and idle (a turn ending)
both stamp the idle mark; they leave the agent's attention tag untouched, so a
loud self-report still wins the badge. We don't try to mechanically separate
"truly idle" from "waiting on a sub-agent or shell" — the finished-turn hook is a
good-enough idle signal, and the status watch upgrades it when warranted (below).
The attention tag is otherwise the agent's own call, set via loom status set --tag <level> [--message "<message>"]. That calls sessions.status.set
(POST /api/sessions/status/set), which writes the tag (and, when a message is given, the description) and
records a tag event the monitor re-broadcasts over SSE, atomically in one
request — ok clears the tag, the two loud levels upsert it. The message rides
the event as its note, so the event log carries the full status trail —
the progress log the dashboard's activity feed renders, loom sessions events prints,
and a GitHub-wired session mirrors publicly (see GitHub
integration). Omitting --message changes only the level
and keeps the last message. Last write wins, so an explicit declaration
overrides the hook-inferred default. The general loom sessions tags group
writes any key the same way, over the
branches.tags.set / branches.tags.delete operations; the session-scoped
sessions.tags.set / sessions.tags.delete serve the UI. sessions.tags.replace
atomically replaces one author's complete tag set in a single transaction; the
typed Rust client's set_tags uses it. The weaver_loom Python client's
set_tags still reconciles a watch's complete author-scoped set by listing the
branch's current tags and issuing one sessions.tags.set or
sessions.tags.delete call per changed key, so a concurrent writer can
interleave with those calls in a way sessions.tags.replace would prevent. The builtin
status watch, when a session goes idle (the agent's finished-turn hook), asks the
judge model for the set of tags the session warrants and reconciles its own
typed marks to that set — never mirroring the agent's own attention. When the
judge names a genuine need, that session is actively waiting, not resting, so
the watch replaces the soothing idle mark with the real loud status; a
"nothing needed" verdict leaves idle in place.
Archiving a session clears its loud tags and the soothing idle mark: the
agent is gone, so a torn-down workstream can't still "need me" nor is it
"resting", and the dashboard stops flagging or labelling it. The UI also treats
any archived session as calm regardless of a stale tag left on the branch.
Archiving also captures the agent's conversation log (crate::chatlog,
inside the shared web::archive, so both the Archive button and the
merge-archive poller get it). For a terminal session the agent's transcript
lives outside the worktree — Claude Code under ~/.claude/projects/<munged-cwd>/,
Codex under ~/.codex/sessions/ — so it survives the worktree removal; capture
locates it and normalizes it through weaver_core::transcript. An acp session
has no external JSONL: its transcript is loom's own chat journal, mapped to
the same iris format (chatlog::journal_to_log). Either way capture produces the
same pipeline output (raw → iris format → a rendered markdown log) and writes
chat.json (iris) + chat.md under
<session.log_dir>/<branch>/ (session.log_dir defaults to
~/.iris/logs/sessions). It is best-effort: a missing or unreadable transcript
is a logged warning, never a failed archive. The same conversion/render pipeline
backs loom sessions transcript, which renders the current worktree's (or a named file's)
transcript on demand.
The dashboard surfaces this as a Conversation tab on the session detail,
backed by sessions.conversation (chatlog::conversation → for a
terminal session the live transcript when present, else the archived
chat.json; for an acp session the chat journal mapped to iris live, so the
existing tab keeps working before the SPA rewires onto /chat). The Vue viewer
renders the iris log natively — user/assistant turns, collapsible thinking, and
each tool call with its result — so a session stays reviewable in the UI after
its terminal is gone. While the agent is still live the tab is also drivable: a
composer at its foot sends a new prompt straight to the agent pane via
sessions.send (POST /api/sessions/send, type + Enter), and the log auto-refreshes on the
agent's lifecycle edges (the status/tag SSE events that fire at each
turn boundary), so a reply lands without a manual reload. The composer hides
once the terminal is gone (orphaned/done/archived), leaving the read-only log.
Agent recall uses the related
normalized history/search contract. ACP records come
directly from chat_blocks; terminal records reuse the fingerprint-cached Iris
normalizer on read and the archived chat.json fallback. The trusted
loom.session_history and loom.session_search tools call these REST routes.
Both resolve the
caller through session-scoped context, so neither adds a parallel data model or
authorization path.
Orphan detection is independent: if the session's supervisor is no longer alive,
the session becomes orphaned and is eligible for loom adopt.
Archive and recovery serialize their external supervisor/worktree changes.
Archive stops the supervisor before committing archived, escalating to runtime
removal and finally committing with a warning if it cannot be confirmed stopped
— archiving is how a person disposes of a session, so it always terminates;
recovery first moves archived → created with a compare-and-set, which reserves
the branch's unique active-session slot before it rebuilds or launches anything.
A failed recovery cleans up its new external state before restoring archived.
Recovery also repairs historical partial archives by adopting an already-live
supervisor instead of leaving the row in an unusable in-between state.
Both operations publish a durable lifecycle_transition (archiving or
adopting) plus a human-readable lifecycle_step while external work is in
flight. REST detail/summary views expose these as transition; the SPA shows
the stage and suppresses lifecycle actions until completion. Transition claims
are atomic across overlapping server generations. A marker whose owner process
is gone, or that has outlived TRANSITION_STALE_SECS, is abandoned: startup
reconciles it before normal supervisor inventory runs, the monitor reconciles it
again on the retention cadence, and a user-driven archive or recovery takes it
over rather than refusing. A live marker still refuses concurrent operations, so
a draining generation keeps its in-flight teardown.
Retention and automation lifecycle. Ordinary interactive sessions inherit a
ten-day idle archive policy (864000 seconds); a profile override is stamped
onto the session at launch, and an explicit 0 disables that TTL. The monitor
uses durable last_activity_at, falls back to created_at for untouched work,
and can archive completed/error rows as well as live ones once they are old.
Retention is layered: before the archive TTL fires, the same reaper suspends an
ordinary running session idle past session.idle_suspend_secs (off by
default — set a positive number of seconds to enable) — stopping its runtime to
free memory while everything
durable stays in place (see Suspend & wake). The suspend
trigger shares the reaper's guards (no live ACP turn, no lifecycle transition,
warm sessions exempt, a fresh-activity grace window) and its own
auto-suspend: disabled per-session opt-out. A suspended session remains an
archive candidate, so the ladder reads suspend-at-hours, archive-at-days.
A class = automation session — every session not
launched interactively by a human, excluding a watch's own warm sessions —
carries a turn cap (automation.turn_cap, default 100, 0 disables)
counted by sessions.turn_count. Exceeding the cap raises a loud blocked
attention tag and the ACP driver refuses to start a new turn. The monitor also
reaps automation sessions: a legacy explicit work-item session is archived once
its tracking_issue_id closes; any automation session is eligible after
automation.idle_archive_secs (default 28800, 0
disables) of inactivity — both guarded by a no-live-turn check and a grace
period, so a session mid-turn or only just gone quiet is never torn down out
from under it. Every automatic retention path skips a branch carrying the exact
quiet tag auto-archive: disabled; the session Details popover toggles it, while a
manual Archive deliberately ignores it. The automation.* settings live in
weaver-core::config::registry() under the Automation group.
Slack-origin sessions remain interactive but share the retention reaper's
lifecycle safeguards. slack.idle_archive_secs defaults to 86400 seconds;
the reaper uses the same durable last_activity_at, no-live-ACP-turn guard,
grace period, and per-session auto-archive: disabled opt-out before archiving
a Slack conversation.
A session's own repository is its baseline App scope — it is stamped at launch whether or not the profile allowlists it, because an interactive session already selects the launching user's far broader Account PAT first.
Expanding beyond that repository goes through
loom permissions request github-repository <owner/repo> --reason "…". That
raises the session's attention tag, posts to its channel, and mirrors the
status to Slack; a human approves or denies from Session Details in the SPA or
with loom permissions approve|deny, and can grant or revoke directly with
loom permissions grant|revoke github-repository. An agent must never ask a
person to run a CLI command — the request operation is the whole mechanism, and
a human operator can act on it from a browser.
When the launch profile carries an owner/* allowlist entry, a matching
request is applied immediately instead of waiting on a person: the pattern is a
standing decision for that owner. The grant is still validated against the App
installation, still recorded, and still revocable, and the token is still
scoped only to the repositories actually asked for. Loom validates write access
against the GitHub App installation before storing any audited override.
When the GitHub App is configured, loom keeps a per-branch pull-request
snapshot alongside the session. A second background loop
(github::poll, sibling of the monitor, spawned in server::serve) runs on
startup and every minute. It polls sessions active in the previous ten minutes
on every tick, the rest of the first quiet day every ten minutes, and older live
sessions hourly (using creation time until first activity). Poll attempts are
remembered in memory so empty results and failures follow the same cadence. For
each due tier it groups sessions by repository and sends one aliased GraphQL
query per repository: one open-head lookup for each live branch, plus exact
lookups for explicitly mapped or previously open PR numbers. The exact lookups
preserve merge/close detection after a PR leaves the open set, without returning
to one GitHub request per session. <branch> is the worktree's live HEAD
(falling back to loom's stored branch identity when the worktree cannot be
read), so an agent that switches or renames its branch before opening a PR is
still discovered.
The result — PR
number, URL, state (OPEN/CLOSED/MERGED), draft
flag, reviewDecision, a rolled-up checks verdict (passing/failing/
pending), head SHA/update age, and mergeability — is written to the loom-owned
branch_github table (one row per branch, keyed branch_id) and served as
BranchView.github.
The dashboard renders it on the session list and session header; sessions.github.refresh
(POST /api/sessions/github/refresh) forces an immediate re-poll.
The loop self-gates and degrades quietly: it is always spawned but does nothing
while the github.poll setting is off, the GitHub App is unavailable, or the
repo has no GitHub remote (the repository batch is
logged at debug and skipped). So it is a no-op on non-GitHub repos rather
than a failure.
The session header always renders PR and issue association pills, including an
empty state. Existing associations are direct GitHub links; adjacent edit
controls keep reassociation secondary. The PR editor can pin an explicit number
through sessions.github.set (POST /api/sessions/github/set) or return to
live-branch discovery through sessions.github.clear
(POST /api/sessions/github/clear); the issue editor patches the GitHub link
on the session's weaver tracking issue, which remains the only place that
association is written.
Archive on merge. When a poll finds a branch's PR has merged and
github.archive_on_merge is on (the default), loom archives the session
automatically — the same teardown as the Archive button: the terminal killed,
worktree removed, branch and weaver history kept. The worktree is removed with
--force, so any uncommitted work in it is discarded; a merged PR is taken to
mean the workstream is done. The session-level auto-archive: disabled tag
suppresses this without changing the global policy. Turn the behaviour off
globally with loom config set github.archive_on_merge false (or in the
settings pane).
Both settings live in weaver-core::config::registry() under the GitHub
group.
GitHub snapshot logic lives in crate::github: fetch_pr (the single-PR App
request used by manual refresh), fetch_pr_batch (the repository-wide App
GraphQL request), apply_refresh_result / apply_snapshot (store → announce →
maybe archive), and poll (the loop). The merge-archive decision is split into
apply_snapshot so it is testable without contacting GitHub.
The status card. A branch carrying the quiet github tag
(owner/name#number — stamped by the @loom trigger, or set by hand with
loom sessions tags set github …, format-validated at set time) mirrors its status
trail onto that GitHub thread: github::sync_status_comment, spawned detached
by the status endpoint and by artifact writes, renders one comment — the
session link, links to the branch's artifacts, and the trail of the agent's
own attention events since wiring — and edits it in place through the
trigger's GithubApi gateway (post_issue_comment returns the comment id;
update_issue_comment PATCHes it, reporting a deleted comment as Ok(false)
so the card is reposted, while transient errors retry). A process-wide lock
serializes syncs so racing writes can't double-post. The comment id lives in
the machine-owned github.status_comment tag (note = the wiring it belongs
to, so re-pointing the github tag posts fresh instead of editing the old
thread); it and github.linked are refused by the tag-set routes and hidden
from the dashboard's pill row. See
github-trigger.md "The status card".
Authentication is a server-only concern — the CLI authenticates like any
other REST client, sending $LOOM_TOKEN as a bearer token when set (falling
back to loom's machine-local token). It lets loom be exposed off the loopback
interface (so the dashboard and the API are reachable without an SSH tunnel)
while gating who may drive the fleet. The core (crypto, the tables, the
GitHub OAuth calls) lives in crate::auth, deliberately axum-free; the HTTP
glue (the middleware, cookie handling, route handlers) lives in crate::web.
Every /api route except the public health routes (/api/health,
/api/health/live, /api/health/ready, /api/ready), the public login routes
(/api/auth/me, /api/auth/login, /api/auth/logout, /api/auth/github/*),
the OIDC-authenticated /api/auth/federate, and the HMAC-authenticated GitHub
webhook passes through the require_auth middleware. The root /metrics
aggregate scrape is also public (and intended
to be restricted to the host metrics agent at the deployment edge). The static
SPA needs no API principal. Protected requests resolve the
request to a Principal three ways, in order:
- API token — an
Authorization: Bearer loom_…header. This is the token a remoteloomCLI saves withloom login, or that an ephemeral client passes inLOOM_TOKEN. Tokens are random secrets stored only as a SHA-256 hash (api_tokens.token_hash); the plaintext is shown once at creation. Managed under Settings → Account orloom token. - Session cookie — the opaque
loom_sessioncookie set by a successful GitHub or username/password login, stored hashed inauth_sessions. - Loopback trust — a request from
127.0.0.1/::1is taken to be the machine owner (the seeded primary user), gated on theauth.trust_loopbacksetting (on by default). This keeps the local CLI, the agent's in-worktreeloomcalls, and watch scripts working with zero configuration. To get the peer address, the server runsinto_make_service_with_connect_info; the decision uses the real socket peer, never a forwarded header.
A request that resolves to none of these gets 401; the SPA's router guard
turns that into the login screen.
Users and roles. users rows are approved human users. A fresh database is
seeded with one owner — whichever GitHub login and immutable numeric id
LOOM_OWNER_GITHUB and LOOM_OWNER_GITHUB_ID name at first run. There is no
default: leave the login unset and no owner row is seeded at all. A login-only
migrated row cannot use GitHub sign-in until an admin binds its verified numeric
id in People & security.
GitHub login succeeds for a manual user whose immutable GitHub id matches. When
auth.github_organizations is configured, an identity may also enter by
proving active membership in a login:numeric-id organization through the
GitHub API. Loom creates a retained user row whose authorization source is
github_organization and whose one-hour deadline is checked by every browser,
personal-token, and session-token lookup. Without the setting, an unknown
identity is authenticated by GitHub but rejected by Loom. A user may have a
github_login, a password_hash (argon2), or both. The persisted role is
admin or user. Both roles operate normal Loom work, use per-session debug
shells, and read diagnostics, watch activity, and redacted logs. Admins
additionally change deployment settings, integrations, profiles, federations,
shared environment, watches, and user access; they can use the host scratch
shell and read the raw operator log. Existing rows migrate to admin, while
newly approved users default to user. Browser sessions and personal tokens
resolve the current role on every request, so role changes take effect
immediately. Organization-derived users remain fixed at the user role unless
an admin converts the authorization source to manual.
GitHub OAuth is configured per-deploy: register a GitHub App and set its id
and secret via Settings → Integrations or the LOOM_GITHUB_CLIENT_ID /
LOOM_GITHUB_CLIENT_SECRET env vars. The callback is
<base>/api/auth/github/callback, where <base> is the auth.base_url setting
or, unset, {X-Forwarded-Proto|http}://{Host}. The login route sets a short
CSRF-state cookie, exchanges the callback code for a user access token, and
fetches the authenticated profile. Organization authorization additionally
calls GET /user/memberships/orgs/{org} with that token and requires an active
membership. Before the one-hour lease expires, the background revalidator mints
a short-lived token for each configured organization's GitHub App installation,
resolves the account's current login with GET /user/{id}, and calls
GET /orgs/{org}/memberships/{login}. It verifies the configured immutable
organization id and expected immutable user id in the responses. Active
membership in any configured organization wins. The same path revalidates an expired lease
at the @loom webhook boundary. Only state = active renews the lease; inactive
and indeterminate results expire it immediately and close the user's active
sessions. Identity and session history remain stored. The App therefore needs
the Members: Read-only organization permission. Until an OAuth app is
configured the GitHub button is hidden and auth.me reports
methods.github = false.
The machine-local token. On startup loom mints (and persists, 0600, at
$WEAVER_HOME/loom-token) a kind = 'local' api_tokens row owned by the
primary user, and the loom CLI reads it as its same-host fallback credential.
This makes auth.trust_loopback = false a fully working mode: behind a
same-host reverse proxy (where forwarded requests look like loopback and so
trust must be off) local automation still authenticates via this token, while
remote callers must present their own. The local token is hidden from the token
list and is not revocable from the UI. Once auth.github_organizations puts
Loom in shared mode, authorization ignores the machine token and loopback trust
so neither can bypass the per-user lease. That
shared-mode latch is written durably when the organization setting first becomes
nonempty and is never cleared by configuration reconciliation, user removal, or
session lifecycle. Configuration loss, failed workload teardown, and an
in-flight launch therefore cannot reopen local admin authority. Loom exposes no
automatic transition back to single-user trust; that would require a separate,
audited recovery procedure. Removing a user still closes their sessions.
Engine tokens. Loom's own subprocesses — each watch script spawn and the
operator scratch shell — receive a kind = 'engine' api_tokens row as
LOOM_TOKEN instead. The server mints one per process with the primary user's
current role, never writes its plaintext to disk, and revokes it when the
process ends (a watch spawn's token also expires shortly after the round
budget). Because it is issued per process rather than persisted, it stays
valid in shared mode, which the machine-local token does not. Subprocesses that
need narrower authority use kind = 'scoped_engine' with an explicit serialized
grant and an expiry. Handoff summarizers use a session read grant to call the
ordinary history MCP tools, including when the source runtime is in error.
Scoped engine grants are not widened to the owner's role or the runtime's
unrestricted policy; the same engine-token cleanup revokes both kinds.
CLI contexts. The loom CLI stores named server URLs in
$XDG_CONFIG_HOME/loom/config.toml and their personal API tokens in a separate
mode-0600 credentials.toml. A repository .loom/client.toml may select a
context by name, but cannot provide a URL or token. Resolution order is
--context, an explicit WEAVER_API, LOOM_CONTEXT, repository selection,
the user default, then local daemon discovery. LOOM_TOKEN overrides the
selected context's token unless an explicit context selects a different
endpoint than WEAVER_API. The machine-local token fallback is limited to
loopback URLs, so a local token is never sent to a context that names a remote
host.
Workload federation. An admin-managed federation mapping fixes the provider,
issuer, exact audience, identity, service tag, and allowed strict automation
profiles. GitHub mappings bind the numeric repository id plus exact workflow
ref, with optional event/ref restrictions. Google mappings bind both the
service account's immutable numeric sub and exact email; the verified token
must also carry email_verified = true. Loom selects a candidate mapping only
to discover its configured JWKS endpoint, then verifies signature, issuer,
audience, algorithm, and all identity claims before minting. Google and the
production GitHub issuer require RS256. The resulting token has only the
automation grant and mapped profiles, carries non-secret provider/service-tag
audit context, and expires after ten minutes. A caller obtains a new Google ID
token and exchanges again; no refresh token or service-account key is stored by
Loom. Automation run records and metrics persist the mapping's bounded service
tag, so operators can distinguish Marin, Grafana, and Actions traffic without
using the workload subject as an observability label.
Restricted sessions. A restricted profile is a stamped security posture,
not a task template. It is valid only for strict, environment-cleared Claude ACP
automation with prelude = none, mode = default, no ambient allowlist, and
scoped Claude SDK tool rules. The first prompt is the caller's complete
session.goal; Loom does not add WEAVER.md or infer rewrite instructions.
Profiles select reviewed built-in MCP capability sets such as
loom/github/comment@v1; the MCP registry expands them into exact permissions at
session creation and derives the trusted aggregate-server command from those stamped
rules. Repository/profile data never supplies executable MCP configuration.
Restricted launch and recovery omit repository environment and Claude
user/project/local settings. Repository reads are path-scoped, and GitHub
mutations use a fixed MCP bridge backed by a session-scoped REST endpoint. Loom
uses the configured GitHub App client to call GitHub's REST API against the
session's fixed repository and linked thread. The restricted agent reaches
GitHub only through this fixed bridge. Allowed rules execute directly;
any remaining ACP permission request is answered with the adapter's one-shot
rejection (or a cancelled outcome), including after session/load. Runtime
handoff and permission-mode changes are forbidden. The stock github_comment
profile contains the policy only; its reviewed JSON manifest is seeded when
absent and then remains operator-editable.
Git and GitHub CLI credentials. Loom installs Git credential and gh
adapters because those clients do not speak Loom's session API. The Docker image
provides the Git adapter and a system gh adapter; native and ACP launches put a
session-private gh adapter at the front of PATH. They are transport adapters,
not policy owners: every fresh, resumed, warm, or handed-off session receives a
reserved LOOM_GITHUB_AUTH_MODE selected by Loom.
direct uses the launching user's write-only PAT stored in Loom Account
settings, broker requests a short-lived App installation token, and
disabled rejects direct GitHub CLI access. The adapters fail closed when
the reserved mode is absent.
A broker token is minted per repository. An App installation covers one
owner, so a session holding access under two owners has no single token that
spans them. credential.useHttpPath is enabled for github.com, so git names the
repository in every helper request and the helper forwards it as
loom github-token --repository owner/name; the gh wrapper resolves the same
from an explicit --repo/-R flag, GH_REPO, or the checkout's origin, in
that order. The server refuses a repository the session has no access to, so
naming one narrows the token and never widens it. Omitting the repository falls
back to the session's whole set, which the App can mint only while it stays
single-owner. The gh wrapper translates the selected mode into the stock
CLI's native authentication contract for each invocation. In broker mode every
invocation asks Loom for a current token, so long-running programs can make
calls beyond GitHub's one-hour installation-token lifetime without managing the
credential themselves.
In Codex ACP agent mode, Loom also allows github.com and api.github.com
through the sandbox network proxy when the session uses broker or direct
authentication. Explicit operator domain rules take precedence. Reaching Loom's
broker does not by itself permit the subsequent GitHub request: a proxy denial
can make gh auth status report a valid token as invalid.
The fixed-thread GitHub MCP tools also work for unrestricted sessions that hold the matching tool and repository grants. Repository revocation takes effect on subsequent calls. Both postures keep the linked-thread target check; restricted sessions keep their tool-only access without exposing App tokens.
Git adapter registration stays in the image's system config: linked worktrees
share repository-local config, agents may clone additional repositories, and
the helper must be available before a target repository exists. Session
selection and credentials remain Loom-owned policy; the image registration
only teaches stock git and gh how to ask for them.
MCP/profile control plane. A profile stores mcp_access as none, all,
or an explicit group list. Saving resolves the trusted builtin registry,
enabled validated custom definitions, and enabled remote definitions, then pins
the exact result to that profile revision. Launch copies capability identities,
digests, custom source revisions, and remote HTTP descriptors into
sessions.policy_mcp_access. Every ACP runtime receives native mcpServers
descriptors. Custom-server tools are filtered to the stamped rules; remote
servers use ACP's HTTP transport directly and advertise their tools with MCP
tools/list rather than passing through a Loom proxy process.
One built-in loom MCP server exposes namespaced context, channel, artifact,
session, watch, messaging, permission, issue, and fixed-repository GitHub tools.
Resource tools return concise text plus
machine-readable MCP structuredContent; their DTOs are the same ones used by
the REST client and CLI. The ordinary first-party domains obtain their names,
descriptions, input schemas, and invocation target from OperationSpec;
genuinely nested or special behavior remains explicit. Builtin
capability digest goldens ensure this declaration migration cannot invalidate a
pinned profile accidentally.
Small remote domains map session defaults when needed and add the
established text presentation. The shared descriptor-driven dispatcher retains
runtime allow-list gating and object-argument checks; authorization and domain
validation remain authoritative at the REST boundary.
Neither an unchanged profile nor recovery re-resolves current capability
definitions; identities that no longer exist are omitted when the server
configuration is built.
Custom definitions live under
absolute identities such as /engineering/search/docs; their first segment is
the selectable group. A save runs initialize and tools/list, then optional
tests, through uv run --script. Runtime children start from a cleared
environment with only PATH, Loom-controlled uv cache/interpreter paths, and
session-scoped Loom API context. Custom code is
admin-authored and dependency-contained, not sandboxed; it cannot use
builtin-reserved group names or enter restricted sessions. Loom also refuses to
remove a server pinned by a profile, or the last server in a group while an
explicit profile selection still references it. See
MCP and profile control plane.
Cookies are HttpOnly; SameSite=Lax; Path=/; the Secure attribute is
added when auth.cookie_secure is on (set it when loom is reached over HTTPS).
loom terminates no TLS itself — run it behind a TLS-terminating proxy for remote
use. The auth.* settings live in weaver-core::config::registry() under the
Authentication group; the GitHub client id/secret are stored outside the
registry so the secret never rides settings.get.
An outside assistant — a claude.ai custom connector, say — reaches Loom
through a Streamable HTTP MCP endpoint at /mcp and holds a person's own
credential. Everything here answers 404 until connector.enabled is on.
- Tools.
loom_agent::mcp::connectorlists nine tools, each anexport_userof a registered operation:launch,list,get,status,messages,send,interrupt,url, andarchive.export_useris the people-facing sibling ofexport: it compiles only forSessionSelfandUseroperations. The person must name the resource field the operation's scope implies, such assession, orchannelformessages. Other context operands stay empty, so a launch has no parent branch and counts as a person's launch. A tool accepts only the arguments it advertises, andlaunchis narrowed totitle,goal,repo,baseandprofile, whose default comes fromconnector.default_profile. The waiting operations and permission answers are deliberately absent. - Transport.
web/mcp.rsis stateless: one JSON-RPC message perPOST, one JSON response, noMcp-Session-Id, and405forGET. Framing is the samehandle_messagethe in-session stdio server uses. A call runs in-process throughoperations::invoke, which uses the bound operation's decode andauthorize(), so the endpoint cannot widen authority. Only a bearer token authenticates. The login cookie never does, so a web page cannot ride a person's sign-in into a tool call. Session and automation credentials get403. - Authorization server. A missing or invalid token gets
401withWWW-Authenticate: Bearer resource_metadata=…(RFC 9728), naming/.well-known/oauth-protected-resource/mcp. That document names Loom itself as the issuer (RFC 8414)./oauth/authorizeaccepts only the code flow with PKCE S256 and a client and exact redirect URI registered withauth.oauth_clients.create(loom oauth-client add) or declared as a public client bydeployment.reconcile; a loopbackhttpredirect matches on any port (RFC 8252). A bad client or redirect URI is shown to the person, never redirected. The identity step is ordinary sign-in: without a login cookie the person goes to/login?redirect=…, and GitHub sign-in carries a validated same-originnextback. The consent page is framing-proof. Its form carries an HMAC keyed by the login cookie over the exact request and the time the page was shown, valid for ten minutes, and a POST with a foreignOriginis refused. A sibling subdomain that can plant cookies still cannot forge an approval./oauth/tokenauthenticates confidential clients byclient_secret_basicorclient_secret_post, public ones by PKCE alone. - Credentials.
loom_policy::oauthstores clients, single-use codes (five minutes), and rotating refresh tokens. The access token is anapi_tokensrow of kindoauthcarrying theusergrant whatever the person's role. It authenticates only at/mcp, throughlookup_connector_token; the REST API refuses it, so a connector cannot mint a personal token, set a password, or do anything else the consent page does not list. It lives an hour, matching the organization lease, and a lapsed lease stops it exactly as it stops a browser session. A refresh re-checks the lease instead of extending it, and is refused but kept while the lease is lapsed. The grant itself ends 30 days after consent. Each refresh marks its token used and revokes the previous access token. A used token presented again revokes the whole grant (RFC 9700 section 4.14.2), and removing a client revokes everything it was issued.
A script watch is a reactive program over the fleet: it
wakes on a session event or a one-shot recheck, surveys the sessions in
scope, and acts within an explicit capability set. The engine (loom::watch,
spawned in server::serve, self-gated on the
watch.enabled setting) runs each round under non-optional guardrails
— no-overlap, cooldown, a wall-clock timeout, no-recursion — and records it in
watch_runs, the audit trail the panel's round history renders.
A round runs the program the watch names:
- Builtin scripts — real Python files in
crates/loom-watch/watches/, embedded into the binary and registered inloom::builtins:builtin:status(an opt-in agentic watch that stamps atriagemark on a stale in-scope session, judging via the configuredpromptthrough the daemon's one-shot agent when available, otherwise leaving its marks untouched),builtin:review-wait(mark a session whose open, non-draft PR awaits an external review —review_decisionREVIEW_REQUIRED— with a quietawaiting: reviewmark that sinks it below the calm default in the fleet sort, and clear it once review lands, the PR merges, or it un-drafts; needsmark),builtin:pr-label(add the configured label when a PR first appears; enabled by default and needsmark) andbuiltin:archive-merged(flag live sessions whose PR has merged, excluding those withauto-archive: disabled). The archive advisor is read-only and opt-in — the actual archive is stillgithub.archive_on_merge, above. Watches granted thejudgecapability are agentic and the engine limits their automatic rounds to at most one every 15 minutes; manual runs still bypass the interval. Agent judgements use the watch's selected automation-safe ACP profile. The stockwatchprofile is Codexgpt-5.6-solat medium effort, in plan mode with a cleared environment and no profile-granted external tools or MCP servers; a watch's non-empty model/effort fields override the profile defaults. The Watch panel andloom watch programslist the registry; script sources render read-only (they ship with the binary). - A custom program file — an absolute path, conventionally
~/.weaver/watches/<name>.py(loom watch newscaffolds one).
Builtin scripts and custom files run on one executor: an env-stripped
subprocess that reaches the fleet only through the loom REST API — everything
loom can do is an HTTP route (including one-shot agent judgement, at
POST /api/agents/oneshot), and Python is purely a convenience layer on top.
There is deliberately no privileged in-Rust program shape: a builtin sees
exactly the API a custom program sees.
The contract: $WEAVER_API carries the daemon's base URL, $WEAVER_WATCH
the round's config ({id, name, program, params, scope, capabilities, profile, model, effort, dry_run}), and the script prints one JSON object —
{outcome, summary, actions} — as its final stdout line. A non-zero exit, no
result object, or a
blown round budget records the round as an error. A mutating program must
honor dry_run (record {would: …} actions instead of acting) and stay inside
its granted capabilities.
That convenience layer is weaver_loom (python/weaver-loom/, stdlib-only):
a capability-gated Client over the REST routes plus the Round context
(config, scope-filtered survey, action log, result emission). The engine vendors
the module onto every script's PYTHONPATH, so programs import it with no
install step; for standalone iteration it installs with
uv pip install -e python/weaver-loom. The interpreter is python3, or
uv run --script when the script declares PEP 723 inline metadata and uv is
installed — so a custom program can declare third-party dependencies (the
builtins are stdlib-only and need neither).
| Var | Purpose | Default |
|---|---|---|
WEAVER_HOME |
state directory | ~/.weaver |
WEAVER_DB |
sqlite path, read only by loom |
$WEAVER_HOME/weaver.db |
WEAVER_API |
explicit loom URL (server bind input and CLI override) | http://127.0.0.1:7878 |
LOOM_CONTEXT |
named context for the loom CLI when WEAVER_API is unset |
user default |
WEAVER_BRANCH |
compatibility branch key set by loom sessions launch in the worktree for branch-relative commands |
— |
LOOM_TOKEN |
explicit bearer token for Loom CLI/MCP and automation; loom otherwise uses its selected context credential or a loopback-only machine token |
— |
LOOM_OWNER_GITHUB |
GitHub login seeded as the owner on a fresh database; unset seeds no owner at all | — |
LOOM_OWNER_GITHUB_ID |
Immutable numeric GitHub id bound to the seeded owner | — |
LOOM_GITHUB_CLIENT_ID / LOOM_GITHUB_CLIENT_SECRET |
GitHub OAuth app credentials (override the settings-stored values) | — |
WEAVER_TAPESTRY_DIR |
directory holding tapestry's per-session control sockets | $WEAVER_HOME/sock |
WEAVER_TAPESTRY_BIN |
the tapestry supervisor binary loom re-execs (else a sibling of loom); set by the tests |
sibling of loom |
RUST_LOG / EnvFilter |
tracing filter | loom=info,weaver_core=info,tower_http=warn |
Cron and interval watches choose a mechanical Python program or an agent
target: an automation-safe ACP profile, owner/repo, task prompt, and allowed
Slack channel IDs. Scripts execute directly; agents launch sessions. Existing
scheduled scripts keep their enabled state and cadence through migration.
Reactive scripts retain their event subscriptions and dynamic wake_at.
The independent scheduling loop queues an occurrence and advances its
cadence in one SQLite transaction. A partial unique constraint keeps one active
occurrence per watch, including manual runs. Each occurrence snapshots its
revision and definition, and dispatches to the chosen executor. Agents launch through the automation
reservation path with the occurrence ID as the idempotency key. Interrupted
agent dispatch retries that key;
external callers cannot select the scheduler's watch source.
The schedule and executor are separate choices. The UI offers
intervals, daily times, weekdays, and an advanced cron expression. Calendar
schedules use an IANA time zone (UTC by default); the API and IaC accept
five-field cron or an every duration such as 30m.
Missed runs coalesce into the latest scheduled run if it is no more than ten
minutes late. Older runs and runs that overlap an active occurrence are skipped. Execution times out after
five minutes unless the watch sets run_timeout_secs. Pausing suppresses pending
and future work; resume starts with the next scheduled time.
Scheduled scripts use the existing round contract for output, actions, state, and dynamic wakes. A durable process lease claims each script once. A gated process-group leader remains alive through execution and teardown; the daemon stops the entire group before releasing overlap. An interrupted script settles as an error without replaying potentially completed actions. Output, state, wake, and occurrence completion are committed together. Script execution does not create an agent session.
A completed ACP turn, stopped session, or occurrence deadline begins durable
settlement. The loop stops the agent before releasing the overlap constraint.
Completed sessions remain visible for about five minutes, then the retention reaper
archives them through the shared lifecycle path. The auto-archive: disabled
tag retains a session for inspection. Each watch has history in watches.runs
and occurrence/run/session identifiers in watches.occurrences. watches.preview shows five upcoming times.
Agents use branch-scoped watches.state for object-valued persistent memory,
with a version check for replacement. branches.slack.post checks the owning
occurrence's destination snapshot and reserves an action key before contacting
Slack. Definite rejections allow a later retry with that key; 429 responses
persist Retry-After. Ambiguous delivery remains uncertain and is not reposted.
The loom/watches/state@v1 capability in the watch MCP group exposes
watch_state. The separate loom/messaging/post@v1 capability in the
messaging group exposes messaging_slack_post. Profiles can select either
capability or both; neither grants access to the other's operation.
Deployment reconciliation applies profiles before named watches. It
preserves IDs, state, history, runtime pause, and cadence when unchanged. Pruning
pauses omitted managed watches and leaves operator-owned watches alone. Marin's
infra/loom stack renders these definitions and confined prompt-file contents
into the deployment manifest.