diff --git a/AGENTS.md b/AGENTS.md index 428c6d2b..f44c5fbc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,11 +109,12 @@ strut/ │ ├── artifact-refs.ts # deliverables (plans/jobs.md §3): a run output's `artifacts: [{ id, kind?, title, path | url | content, render?, from? }]` resolved to links for the `run.end` callback and GET …/runs/:runId/artifacts — `path` → `/jobs//files/…` (a job run) or `/artifacts//…`; `kind` from the extension when omitted (the host's renderer names). `kind: "app"` is strut's own, two forms: `app: ""` (+ `version?`) is an app PUBLISHED in the workspace — the builder's deliverable (2026-10-22) — resolved to the version's document (`/apps///files/dist/index.html`, the active version unless named; `appVersion` → `apps.versionDir` + `dist/build.json`) with `app: { name, version, hash, builtAt }` beside the url, `no such app: …` otherwise; a `path` is no app — an entry with an error saying to hand it over by name (a JOB's app directory is retired, plans/chained-orchestrators.md §6). An entry tagged `peer` (another strut's — strut/run-workflow tags them, plans/builder-job.md §3) is left as it came: its `path` (in `job`'s directory THERE) and `url` the peer's, no link made here, no `not found` — the reader resolves it against the peer by id (the handle rule, plans/federation.md §2.2). The WORK-PRODUCT kinds (plans/work-products.md): `sheet`, `table`, `timeline`, `docx`, `redline` (`.docx` → docx, `.csv`/`.tsv`/`.xlsx` → table), `render` (a file a page draws in the thing's place, resolved like `path`, left out when missing), `from` (the graph ref_ids it was made from → `StrutArtifact -[:DERIVED_FROM]->` each node the graph holds, projector.ts); `liftedArtifacts` — what a tool's result declared, put on its `step.end` by `wrapToolsWithEmit` │ ├── auth.ts # the pieces of createStrut's one gate in front of EVERY route, reads included — carriesApiKey (Bearer or `?key=`) + requireApiKey (the per-route twin) + warnIfUnconfigured (STRUT_API_KEY shared secret) + the PEER KEY (STRUT_PEER_KEY: carriesPeerKey, scopeFromKey — the default `resolveScope`; a `peer` request reads, launches a `peer: true` workflow, controls a run a peer launched, and the gate 403s the rest) + the PERSON TOKEN (personToken / verifyPersonToken / personOf: the peer key attenuated to one actor, `strut-person..` signed like a session, Bearer only, minted by `POST /peers/tokens` — scope `peer:person`, `isPeerScope`: a peer whose actor is its credential's own, which alone launches a `peer: person` workflow, opens its actor's own sessions and gets an app's `step_run` alone on `POST /mcp`; plans/chained-orchestrators.md §2, plans/apps.md §7.4) + actorFromHeader, the default `resolveActor` (x-strut-actor, honored only with the key; a person token's actor wins) + the FILE TOKEN (fileToken / fileScopeOf / carriesFileToken): the key attenuated to one run's or job's files, an HMAC the listing mints, what an artifact link carries as `?t=` so the key is never in a URL a served page can read. The app IDENTITY a served document carries: `appIdentity` (`app:@.`), read back by `appOfIdentity` → `ctx.app`. The CHANNEL TICKET (`channelTicket` / `channelTicketOf`, plans/app-channels.md §2): `strut-channel..` naming the viewer, the app and its version for a minute, the one credential that opens `GET /channels` — a browser host's upgrade, which carries no header (unsigned, and taken at its word, with no secret to sign under) │ ├── public-surface.ts # the PUBLIC SURFACE — the PUBLIC VIEW in the UI, never a "publish" (plans/public-surface.md §2, §4): what a PERSON chose to show anyone, live and read-only — Concept roots (the subtree under each by PARENT_OF), workflows (`runs: true` adds their runs, input whole), steps (with the `_` helpers their relative imports reach, `stepHelpers`), node types, apps (`reads`, `domains`: the app half is item 2), and what anyone may RUN (`runnable`, plans/paid-runs.md §2: `{ kind: workflow | step, name, base?, markup?, capUsd?, dailyCapUsd?, results? }`, dollars, independent of `workflows` and `steps`; `runnableShapeProblem` checks its fields and numbers, an older file reads as none; `PublicSurfaceService.runnable(kind, name)` finds one). One record, `/public.json` (`readSurface` / `writeSurface` / `surfaceProblem` / `normalizeSurface`), written only by `PUT /public-view` (full scope; no step or builder tool). `createPublicSurface` is what the routes read through: the public Concept trees (walked live from each root, reused 30 s), each public run's named nodes (`step.end.nodes`, kept per finished run in `/public/run-nodes/`), `visible(ref_id, type)` (§4: under a public root, named by a public run, or of a public type), and `reader()`, a GraphReader that answers only with visible nodes and edges between two of them (`publicReader`; the namespace registry refused). `publicEvent` / `publicSummary`: a run as a visitor reads it — no actor, principal, job, `startedBy`, callback or MCP origin; a session as a `/public` transcript link. A visitor's `step_run` (§5): `visitorRefusal` (an app in the view at the version it serves; run reads on public workflows, with runs; `run/launch` of a workflow anyone may RUN that the app grants (plans/paid-runs.md §10, item 10) and the run reads of one, its visit's own; `VISITOR_GRAPH_READS` through the filtered reader; the app's `reads`; any other launch, cancel, jobs, `app/*` and graph writes refused), `visitorRuns(inner, { surface, visitor?, visitRefusal?, launch? })` (the runs capability held to reads, records scrubbed, deliverable links under `/public`, a job run's files closed; a runnable workflow whose runs are not public reads only the runs this VISIT launched — `visitorOfVisit`, a hash of the `x-strut-visit` secret its host sends, recorded as `run.start.visitor` and on the summary, a `run/list` filter (`RunListQuery.visitor`); a launch with no visit or a malformed one is refused before anything is admitted or charged, `visitProblem` (`visit_required:`) — as far as the item's `results` lets its launcher (`output`: `outputOnlySummary`, the files `declaredPaths` names; `run`: everything), and its definition as `GET /public` lists it; `launch` is the public launcher's — `PublicDoors.launch`, src/public-routes.ts — read into the tool's result by `visitorLaunchResult`: `{ runId, existing?, credit? }` (never the run token), `{ payment_required: { macaroon, invoice, usd, sats, rate, description, expires, remaining_usd?, reason?, carries? } }` for a priced one no credit covers, else a thrown sentence; the credit token rides on the `/public/mcp` request's own `Authorization: L402`, never an argument or a result; cancel stays closed) -│ ├── public-routes.ts # the `/public/*` routes (decision 13, ruled): a route family of its own, answered from the surface alone WHATEVER the gate's mode, so a host gate in front of an unkeyed strut passes the prefix by path and nothing else opens; a route nobody mounted there does not exist there. Paths mirror the private reads (`/public/workflows/:name/flow`, `…/runs/:runId/events`, `/public/steps/:type/source`, `/public/concepts/:name`, `/public/graph/nodes/:ref_id`), shapes minus who and where; `/public/apps[/:name[/document]]` the public apps, each at the version the view serves, the document carrying a VISITOR's identity (`publicAppIdentity`, auth.ts: signed over `public-app:`, so it opens `POST /public/mcp` and nothing else) and `` (the kit's `useHost().public`), and `` — the granted workflows a visitor may start, each `{ name, results }` under the name the app grants it by (resolved package-first, createStrut's `workflowInApp`), `useHost().runnable` and `.results` (`withPublicIdentity`, src/apps/build.ts). CORS `*` + preflight, a per-address budget for reads and the visitor door (`PUBLIC_REQUESTS_PER_MINUTE`, `visitorAddress`), `PUBLIC_PATH` (the reads the gate opens) and `PUBLIC_POST` (the POSTs it opens by name: `/public/mcp`, a launch, its cancel, a tool's call, `/public/credit` and `/public/credit/claim`). PUBLIC RUNS (plans/paid-runs.md items 3 and 5): `POST /public/workflows/:name/run { input?, key? }` → `202 { runId, token, credit? }` — the address's own budget (`PUBLIC_LAUNCHES_PER_MINUTE`, 60), the item (404 unless `runnable`), the drain, the body (256 KB, `input` and `key` alone, the `input:` block), a priced item's CREDIT TOKEN read before the caps (`Authorization: L402 :`, or `LSAT`: `credit.verify` — 401 `invalid_token` — and `tokenRefusal`, src/l402.ts — 403 `token_not_for_this` / `token_over_max_charge`; its id scopes the `key`), the caps (src/public-runs.ts `admit`, 429 with `Retry-After`), the PRICE (§4, §5: no token, one past its end or replaced, or one whose free credit is under the reserve is a 402 — `credit.challenge`, for the shortfall carrying the token when no holder narrowed it, else the whole reserve, `reason` short | expired | replaced; a token's first use asks the wallet, 503 when it cannot; else `admission.hold` sets the reserve aside, and the same token and `key` as an earlier launch return that run), then `launches.launch` (createStrut: `launchDetached` with `origin: "public"`, no actor, no params, the host's `publicRuns.principal` else the owner, the run's cap, the credit token's id as `run.start.credit`, a visitor page's visit as `run.start.visitor`). That order is ONE function, `launch(PublicLaunchRequest) → PublicLaunchOutcome` (status, body, headers as data), which `publicRoutes` returns (`PublicDoors`) so a visitor's app page (`run/launch` on `POST /public/mcp`, plans/paid-runs.md §10) goes through it too, sharing the address's budget, its key scoped by the visit; CREDIT (src/public-credit.ts): `GET /public/credit` (a token's balance, its first use if it had none), `POST /public/credit { usd } | { sats }` (a 402 for more credit; a token presented un-narrowed carries; 404 when nothing here is priced), `POST /public/credit/claim` (the macaroon alone: pending, expired, or settled with the preimage — a phone wallet's payer; the token's first use recorded from that answer); the two GET-less ones 404 on a strut with no `L402_ROOT`; a 401 carries `WWW-Authenticate: L402 error="…"`, and `WWW-Authenticate` / `Retry-After` are exposed to a browser's script; the RUN TOKEN (`publicRunToken` / `isPublicRunToken` / `presentedRunToken`, auth.ts: an HMAC over `public-run:/`, `?t=` or Bearer) opens that run's `/public` reads — the launcher's view (`?wait=` ≤ 55 s, `charged` once charged — the ledger's `chargeOf` — the declared artifacts with `/public` links) and, by the item's `results`, the declared files alone (`output`) or the events, transcripts and every file (`run`) — and `POST …/runs/:runId/cancel`, never a private route nor another run. PUBLIC TOOLS (item 6): `POST /public/steps/:type/run { config }` → `200 { output, credit? }` — the address's own budget (`PUBLIC_TOOL_CALLS_PER_MINUTE`, 120) and the drain, the body (256 KB, `config` alone), then src/public-tools.ts `call` (specs/API.md §17.7). `POST /public/mcp` itself is createStrut's: `buildAppTools` with `visitor` (the public identity, `visitorRefusal` after the grants on the workflow name resolved in the app's package — `inAppPackage` — nothing kept) over a bag of reads alone — `http`, `secrets`, the graph's filtered reader, `visitorRuns` with the request's visit (`x-strut-visit`, which the preflight allows) and the launcher, its `Authorization` the credit token. What a host does with a `payment_required` is specs/API.md §17.6. The person's door is `/public-view` (GET / PUT, `needs`, `runnable`), behind the key; an app it adds is HELD at its version; a PUT checks every `runnable` item (src/public-runnable.ts) and refuses naming each. `GET /public` also lists what anyone may run (`runnable`: price, reserve, input, recent runs — never its definition unless that is public too). specs/API.md §17 +│ ├── public-routes.ts # the `/public/*` routes (decision 13, ruled): a route family of its own, answered from the surface alone WHATEVER the gate's mode, so a host gate in front of an unkeyed strut passes the prefix by path and nothing else opens; a route nobody mounted there does not exist there. Paths mirror the private reads (`/public/workflows/:name/flow`, `…/runs/:runId/events`, `/public/steps/:type/source`, `/public/concepts/:name`, `/public/graph/nodes/:ref_id`), shapes minus who and where; `/public/apps[/:name[/document]]` the public apps, each at the version the view serves, the document carrying a VISITOR's identity (`publicAppIdentity`, auth.ts: signed over `public-app:`, so it opens `POST /public/mcp` and nothing else) and `` (the kit's `useHost().public`), and `` — the granted workflows a visitor may start, each `{ name, results }` under the name the app grants it by (resolved package-first, createStrut's `workflowInApp`), `useHost().runnable` and `.results` (`withPublicIdentity`, src/apps/build.ts). CORS `*` + preflight, a per-address budget for reads and the visitor door (`PUBLIC_REQUESTS_PER_MINUTE`, `visitorAddress`), `PUBLIC_PATH` (the reads the gate opens) and `PUBLIC_POST` (the POSTs it opens by name: `/public/mcp`, a launch, its cancel, a tool's call, `/public/credit` and `/public/credit/claim`). PUBLIC RUNS (plans/paid-runs.md items 3 and 5): `POST /public/workflows/:name/run { input?, key? }` → `202 { runId, token, credit? }` — the address's own budget (`PUBLIC_LAUNCHES_PER_MINUTE`, 60), the item (404 unless `runnable`), the drain, the body (256 KB, `input` and `key` alone, the `input:` block), a priced item's CREDIT TOKEN read before the caps (`Authorization: L402 :`, or `LSAT`: `credit.verify` — 401 `invalid_token` — and `tokenRefusal`, src/l402.ts — 403 `token_not_for_this` / `token_over_max_charge`; its id scopes the `key`), the caps (src/public-runs.ts `admit`, 429 with `Retry-After`), the PRICE (§4, §5: no token, one past its end or replaced, or one whose free credit is under the reserve is a 402 — `credit.challenge`, for the shortfall carrying the token when no holder narrowed it, else the whole reserve, `reason` short | expired | replaced; a token's first use asks the wallet, 503 when it cannot; else `admission.hold` sets the reserve aside, and the same token and `key` as an earlier launch return that run), then `launches.launch` (createStrut: `launchDetached` with `origin: "public"`, no actor, no params, the host's `publicRuns.principal` else the owner, the run's cap, the credit token's id as `run.start.credit`, a visitor page's visit as `run.start.visitor`). That order is ONE function, `launch(PublicLaunchRequest) → PublicLaunchOutcome` (status, body, headers as data), which `publicRoutes` returns (`PublicDoors`) so a visitor's app page (`run/launch` on `POST /public/mcp`, plans/paid-runs.md §10) goes through it too, sharing the address's budget, its key scoped by the visit — and so does an agent's `run_workflow` (src/public-agents.ts); `PublicDoors` also hands out `call` (a tool's call: the calls' per-address budget, the drain, the body, then src/public-tools.ts `call` — what the route is a thin wrapper over), `run` (the launcher's view by its run token, `wait` held; once the run ended it waits at most 2 s for the charge and then answers `charge_pending: true` rather than holding the wait), `credit` and `claim` (the credit routes as data — the balance now with `runs`, the runs and calls its token paid for, read off its ledger entries, each run with its run token), `runnable` and `listing` (`GET /public`'s answer) and `overBudget` (the address's `/public` budget, for `GET /llms.txt`), each answering `{ status, body, headers? }`; `PublicLaunchRequest.defaultKey` is the key a PRICED launch with none of its own goes under, made from the parsed input (the outcome's `key`), never a free one's; CREDIT (src/public-credit.ts): `GET /public/credit` (a token's balance, its first use if it had none), `POST /public/credit { usd } | { sats }` (a 402 for more credit; a token presented un-narrowed carries; 404 when nothing here is priced), `POST /public/credit/claim` (the macaroon alone: pending, expired, or settled with the preimage — a phone wallet's payer; the token's first use recorded from that answer); the two GET-less ones 404 on a strut with no `L402_ROOT`; a 401 carries `WWW-Authenticate: L402 error="…"`, and `WWW-Authenticate` / `Retry-After` are exposed to a browser's script; the RUN TOKEN (`publicRunToken` / `isPublicRunToken` / `presentedRunToken`, auth.ts: an HMAC over `public-run:/`, `?t=` or Bearer) opens that run's `/public` reads — the launcher's view (`?wait=` ≤ 55 s, `charged` once charged — the ledger's `chargeOf` — the declared artifacts with `/public` links) and, by the item's `results`, the declared files alone (`output`) or the events, transcripts and every file (`run`) — and `POST …/runs/:runId/cancel`, never a private route nor another run. PUBLIC TOOLS (item 6): `POST /public/steps/:type/run { config }` → `200 { output, credit? }` — the address's own budget (`PUBLIC_TOOL_CALLS_PER_MINUTE`, 120) and the drain, the body (256 KB, `config` alone), then src/public-tools.ts `call` (specs/API.md §17.7). `POST /public/mcp` itself is createStrut's: `buildAppTools` with `visitor` (the public identity, `visitorRefusal` after the grants on the workflow name resolved in the app's package — `inAppPackage` — nothing kept) over a bag of reads alone — `http`, `secrets`, the graph's filtered reader, `visitorRuns` with the request's visit (`x-strut-visit`, which the preflight allows) and the launcher, its `Authorization` the credit token. What a host does with a `payment_required` is specs/API.md §17.6. The person's door is `/public-view` (GET / PUT, `needs`, `runnable`), behind the key; an app it adds is HELD at its version; a PUT checks every `runnable` item (src/public-runnable.ts) and refuses naming each. `GET /public` also lists what anyone may run (`runnable`: price, reserve, input, recent runs — never its definition unless that is public too). specs/API.md §17 │ ├── public-runs.ts # PUBLIC RUNS' policy (plans/paid-runs.md §3, §5, §6, §7; items 3 and 5): `createPublicRuns({ ledger, store, file?, maxInFlight, price? })` → `strut`'s one service — `admit(item, { workflowCapUsd, key, scope })`, SERIAL so the check and counting the run are one step (twenty launches at once never pass a cap): a `key` the same address sent for a run in flight returns it, then the server's limit on public runs in flight (`STRUT_PUBLIC_RUNS_MAX`, `publicRuns.maxInFlight`, 10), then the item's `dailyCapUsd` (`dailyRoom`: the ledger's `today(subject).cost` plus the cap of each of its runs in flight; a run is admitted while what is left holds its FULL cap, never a sliver the gateway would stop partway, and past that the last of the day goes to one run alone with what is left; else 429, `Retry-After` until midnight UTC when the day is spent); mints the run id and writes the run to `/payments/public-runs.json` before the launch; the run's CAP is the smaller of the item's per-run cap (`perRunCapUsd`, public-runnable.ts: its `capUsd`, else a tenth of its daily cap, `DAILY_CAP_SHARE` — so ten run at once, never one) and the workflow's own (`resolveRunCap`), or for the day's last run what is left (`RunOptions.maxCostUsd`); `release()` for a refusal after the caps (the 402), which frees a hold it made (a charge of nothing); `hold({ token, price, reserve, key? })` — a priced run: the token, the price of that moment and the reserve on the run's entry, ON FILE, then the ledger's `hold` under the run id the caps minted (refused, or naming another run — the same token and key — lets the admission go). `track` (a durable resume of one, with its `run.start.credit`), `ended` (the run end's charge: the ledger's `charge` — with the entry's token and price, by the rule, else no token, a charge of nothing whose cost counts in `daily` — then off file and out of flight; a run that never started has its hold freed; idempotent; a charge that fails is tried again after `SETTLE_RETRY_MS`, never left until the next boot), `charged` (the ledger's `chargeOf`: the launcher's view), `load` (boot: every run on file counts in flight; a file that does not read is moved aside, `.unreadable-`, and the boot goes on) and `reconcile(live, { holds })` (after auto-resume: what is not live is charged — its summary, else its log, a run cut off and never resumed as an error at what its log spent — a run with no `run.start` dropped; with `holds`, also every open `workflow:` hold in the ledger whose run is neither on file nor live, at the item's price now, `price`; a `step:` hold is src/public-tools.ts's) │ ├── l402.ts # L402, the CREDIT TOKEN as bytes and rules (plans/paid-runs.md §4; item 5), pure — no wallet, no ledger: libmacaroons v2 binary (`encodeMacaroon` / `decodeMacaroon`, base64 std or URL-safe, at most 8 KB and 64 caveats; v1, JSON, a cut or trailing byte refused) and its HMAC chain (the root key derived through `macaroons-key-generator`, a third-party caveat chained as libmacaroons does), tested byte for byte against Go's `gopkg.in/macaroon.v2` (`src/fixtures/l402-macaroons.json`, made by `scripts/l402-fixtures/`); L402's identifier (66 bytes: version 0, the payment hash, a random token id); a token's root key `HMAC-SHA256(L402_ROOT, token id)`; STRUT'S CAVEATS, five, in order, on every token — `services=strut:0`, `strut_sats`, `strut_credit` (micro-dollars, the locked quote), `strut_valid_until` (unix s), `strut_carry` (the token it inherits from, or `none`: always written, so the fifth caveat is always strut's and a holder cannot append one) — and a HOLDER's (`strut_capabilities`, `strut_max_charge`, `strut_valid_until`, `services` still naming `strut:0`), each of which must hold; any other caveat, a third-party one, or strut's own again REFUSES the token (`readCaveats`, `narrowed`). `mintToken`, `addCaveat` (a holder's narrowing, from the macaroon alone), `parseAuthorization` (`L402` / `LSAT`, the preimage optional for `claim`), `verifyToken` (decode, chain, preimage, caveats: §4.3 steps 1–3, `L402Error`), `tokenRefusal` (this request: `expired`, `not_this_item`, `over_max_charge`) │ ├── public-credit.ts # CREDIT, the service (plans/paid-runs.md §4, §5; item 5), what the `/public` routes call and items 6–9 reuse: `createCredit({ ledger, settings, rate, lightning })` → `verify` (src/l402.ts under `L402_ROOT`; 401 `invalid_token`), `use` (§4.3 step 4: the ledger knows the token, else its FIRST USE — the wallet's `lookup` must say settled, `ledger.firstUse` credits `strut_credit` in proportion to what settled at the quote's own rate, inheriting from `strut_carry`; concurrent requests for one token share one lookup, so the wallet is asked once; 503 `payment_pending` / `wallet_unavailable`, never 402), `check` (both), `challenge({ usd | sats, description, address, carry?, remaining?, reason? })` (the 402: a per-address allowance, `PUBLIC_INVOICES_PER_MINUTE` 20; the rate now, 503 `rate_unavailable`; sats rounded up; a ten-minute invoice, `PRICED_INVOICE_SECS`; a macaroon locking `strut_sats` / `strut_credit`, valid a year from the invoice's end; `WWW-Authenticate: L402 macaroon="…", invoice="…"` and §4.1's body; nothing stored; `L402_ROOT` minted if missing), `view(tokenId, { entries? })` (`GET /public/credit`'s balance: paid, spent, held, remaining in dollars and in sats at the token's rate, expired, replaced, the newest 50 entries), `claim(p, address)` (§4.4: the macaroon alone; pending / expired / settled with the preimage — whose answer records the first use; `PUBLIC_CLAIMS_PER_MINUTE` 120), `selling()` (`L402_ROOT` on file) │ ├── public-tools.ts # PUBLIC TOOLS (plans/paid-runs.md §3's table, §6 "A tool", decision 15; item 6): one runnable STEP anyone may call, answered at once. `createPublicTools({ ledger, surface, registry, services, credit?, draining?, principal? })` → `call({ type, config?, presented?, address })` — the ONE function the route and item 8's MCP `call_tool` share — answering `{ status, body, headers? }`: the drain (503), the item (404 unless `runnable` as a step, and item 2's save checks asked AGAIN, `stepRunnableProblem` in src/public-runnable.ts: the registry changes after a save), the config (an object parsed by the step's own input schema, 400 naming the field; one naming a secret, reach.ts `secretNamesIn`, 400), a priced item's token as a launch's (`credit.verify`, `tokenRefusal` with `step:`: 401 / 403), the caps (serial: the address's share of calls in flight, `PUBLIC_TOOL_CALLS_PER_ADDRESS` 5, the server's, `PUBLIC_TOOL_CALLS_MAX_IN_FLIGHT` 20, then the item's `dailyCapUsd` when set, by a run's rule — `perRunCapUsd` for the call's cap and public-runs.ts `dailyRoom` for whether it fits, so a daily cap with no `capUsd` admits ten at once — 429), the price (402 as a launch's, carry-over included; `credit.use`; `ledger.hold` under a minted call id, no key), the call (`runSingleStep` — never `runStep`, which keeps a run with claims — with the caller's config as the run's INPUT and the step's config `inputTemplates`, `{ k: '{{ input["k"] }}' }`, a sole expression the runner returns as it is, so nothing a stranger sends is evaluated; `origin: "public"`, no actor, the host's public principal, the item's cap as `maxCostUsd`, a `RunController` force-cancelled past `PUBLIC_TOOL_CALL_TIMEOUT_MS`, 60 s → 504; inside `asPublicCall`, src/public-call.ts; nothing reaches the run store, the ledger entry is its record), the charge (`ledger.charge` by the rule, which never passes the hold's reserve: base + markup × the `withUsage` cost, none its base; a throw, a time-out or a result over 5 MB (`PUBLIC_TOOL_RESULT_MAX_CHARS`), its cost and no base, 500 `step_failed` / `result_too_big` or 504; never past the reserve nor what the token holds; free: a charge of nothing; refused by the ledger, tried again now and then every `CHARGE_RETRY_MS` for an hour), `credit` on the answer (`charged_usd`, remaining, expires). `inFlight()` — what the drain's `idle` waits on; `reconcile()` — boot, beside item 5's: every open `step:` hold whose call is neither in flight nor waiting on a charge here is freed (the call never answered; a charge of nothing); `close()` stops the charge retries. `publicToolServices(bag, { graph, http, runs })` — the ALLOWLIST bag: the visitor's (`secrets`, `visitorRuns`) with `readOnlyGraph(g)` (createStrut passes `graphView(graph, graphReaders, "jobs")`: its reader, `dialect` and schema reads, a raw `query` that READS — the write keyword check, then the engine's own read-only path, a READ transaction on Neo4j or `lbug.readOnly()` on Ladybug, gated like any view's (`assertRawQuery`) and carried as the view's unscoped query (scope.ts `carryScope`), so `graph/find` holds to the lists — every write refused: `nodes`, `edges`, `tx`, the schema's create/extend/delete; no `bolt` / `lbug`), no graph a stand-in that refuses every read (never `graphCtx`'s Neo4j-from-env fallback), and `guardedHttp()` (src/guarded-http.ts, shared with the `remote/*` steps; SSRF: aieo's `validateFetchUrl` on every URL and every redirect, followed by hand, no loopback / private / link-local / reserved address nor a name resolving to one; a hop to another origin drops `authorization`, `cookie`, `proxy-authorization`, `withoutCredentials`, as fetch does; each request ≤ `PUBLIC_TOOL_HTTP_TIMEOUT_MS`; DNS rebinding between the check's lookup and the fetch's is a known limit); no `onRunEnd` / `onRunEvent`, so a call is never projected, verified or sunk +│ ├── public-agents.ts # AN AGENT AS A BUYER (plans/paid-runs.md §10 "An agent over MCP", item 8): `buildPublicAgentTools({ doors, who, authorization, publicUrl })` — the five tools `POST /public/mcp` serves beside a visitor page's app-only `step_run`, for an agent with no account: `list_public` (`GET /public`'s `runnable` with `call_with`), `run_workflow { workflow, input?, key?, payment?, wait? }` (through `PublicDoors.launch`, so the address's budget, the drain, the body, the caps, the price and the key are the HTTP door's; a PRICED launch with no key of the agent's goes under `defaultLaunchKey` — the workflow and the input AS ITS `input:` BLOCK PARSED IT, keys sorted (`canonicalJson`), applied by the launcher's `defaultKey` and scoped by the credit token — so the same launch sent again while its run is in flight is that run; a FREE launch gets a key only when the agent passes one: its scope is the address, which a hosted agent's vendor shares; the first yield is the run's handle `{ run_id, token, url }`), `call_tool { type, config?, payment? }` (`PublicDoors.call`; no key: a priced call is charged once it ran, even when the client gave up), `get_run { workflow, run_id, token, wait? }` (`PublicDoors.run`) and `get_credit { payment? }` (the balance and `runs`: the runs and calls the credential paid for, newest first, each run with its run token and url — the WAY BACK to a run whose answer was lost after it ended, which a key no longer finds; with a macaroon alone, the CLAIM). Every tool's description says to read get_credit before calling again after a timeout or a dropped answer. A priced call with no credit answers a RESULT, `{ payment_required: { macaroon, invoice, usd, sats, rate, description, expires, remaining_usd?, reason?, carries? }, next }` (`paymentRequiredResult`), never an error; the credential is the call's `payment` (`":"`, or the macaroon alone, claimed first — settled, its whole `credential` comes back on the answer), else the connection's own `Authorization: L402`. Nothing is kept between requests: the credential works on any connection. Every tool answers within `AGENT_DEADLINE_MS` (45 s, under the MCP SDK's 60 s default request timeout, which progress resets only when a client asks; `wait` ≤ 45): a launch still starting, a run still going (its handle and `next`) or a call still going (`still_running`, "check get_credit") answers what it has. Progress every `AGENT_PROGRESS_EVERY_MS` (5 s; the door sends a yield's progress when the next arrives). A run reads as still going by its own STATUS, so one that ended with its charge pending says so and stops (`charge_pending`). No builder cap on these results: a call's output comes back whole (the door's 5 MB), a run's output over `RUN_OUTPUT_INLINE_MAX` (100k chars) is left out with the run's HTTP url (`output_omitted.read_at`, the token on it). `publicMcpInstructions`; `publicLlmsTxt` — `GET /llms.txt` on a strut whose public view has anything (llmstxt.org): what anyone may run and its price (`priceWords`), its input, how an agent runs it (the MCP door, left out when `enableMcp` is off, or plain HTTP) and pays, and what else is public to read; `runnableSection` is the same list an INDEX strut that also sells appends to its own page. createStrut's `llmsPage` builds either page at most every 30 s per base URL (`LLMS_TXT_CACHE_MS`) and counts it against the address's `/public` budget (`PublicDoors.overBudget`) │ ├── public-call.ts # a PUBLIC CALL's scope (item 6): `asPublicCall(fn)` runs a public tool's call in an AsyncLocalStorage scope; `stepFailed(text)` — what a lib step answers a failure through when it answers one as TEXT for a model (`graph/*`'s `errText` and its inline "node not found" / "… failed" returns, `sheets/*`'s `errorResult` and catches) — returns the text as before, and inside a public call throws it as a `StepFailure`, so the call is charged as the failure it is (decision 24); a `catch` that wraps one rethrows it as it is │ ├── guarded-http.ts # the http capability for a STRANGER's URL (SSRF), shared by a public tool's bag (src/public-tools.ts) and the `remote/*` steps (src/remote.ts): aieo's `validateFetchUrl` on every URL and every redirect, followed by hand (at most 5), no loopback / private / link-local / reserved address nor a name resolving to one; a hop to another origin drops `authorization`, `cookie`, `proxy-authorization` (`withoutCredentials`), as fetch does; each request ≤ its `timeoutMs`; `allowLocal` (the remote steps' `STRUT_REMOTE_LOCAL=1`) lets loopback and private addresses by, never a link-local one or a name resolving to one. DNS rebinding between the check's lookup and the fetch's is a known limit │ ├── remote.ts # A STRUT THAT BUYS (plans/paid-runs.md §10 "Another strut", item 9; the CVE case: an hourly automation running another strut's priced workflow): `createRemote({ store, http, withdraw, canPay, proposal, rate })` → `strut.remote` — `capability` (`ctx.services.remote`, frozen; what the `remote/*` steps are thin over), `check()` (the purchase keeper; `start()` every minute and at boot), `held()` (`GET /remote/credit`'s read: what is held where, never a token). A SELLER is a base URL (`sellerOf`: https only — the credit is a bearer credential — no credentials, query, trailing `/` or `/public`; a strut under a path, `…/lab`, is its own seller), reached through the guarded http, no peer record. The CREDIT held there is ONE L402 token per seller in `entitlements.json` (`ENTITLEMENTS_FILE`), an encrypted `FileSecretStore` file of its own beside `payments.json` (memory where strut keeps no files), `ENTITLEMENT_` → `{ seller, credit?, purchase?, runs? }`; no step reads it (env.ts hides the `ENTITLEMENT_` prefix) and the capability presents it as `Authorization: L402 :` and never hands it out, the peers capability's rule. SPENDING (decision 16, no ask): `list` (`GET /public`'s `runnable` + `credit`, the balance read with `GET /public/credit`), `launch` (`POST …/public/workflows/:name/run { input, key }`, tried again under the same key — the seller returns the run it started while in flight — on a 429, a 5xx or no answer, up to `LAUNCH_ATTEMPTS` 5, honouring `Retry-After` up to `LAUNCH_RETRY_AFTER_MAX_S` 120 s; the run TOKEN kept in the seller's record, `runs`, never returned and never in the run log; at most `RUN_TOKEN_KEEP_MS`, 7 days), `wait` (the launcher's view, `?wait=25`, until `charged` appears; a seller that does not answer — a network error, a 5xx, a 429 — is read again after a wait doubling to `RUN_READ_BACKOFF_MAX_MS` 30 s for as long as the step waits, the run being the seller's and charged either way; only 404 / 401 / 403 end it, `run_unknown:`; a `stale` run reported as it stands after `STALE_READS_MAX` reads), `cancel`, `release`, `call` (`POST …/public/steps/:type/run { config }`, the type's slashes kept; never sent twice — the seller's door takes no key: a failure there, 500 `step_failed` / `result_too_big` or 504 `timeout`, which the seller CHARGED, is `failed_charged:`, never "try again", and a lost answer `may_have_run:`). `launch` and `call` take the calling run's `control` and refuse (`spend_refused:`, `deps.spendRefusal`) in a run tree createStrut marks as never spending (`noRemoteSpend`: a stranger's public run, a peer-key launch with no person's credential, a run either started by `startedBy`; walked with run-control.ts `runLineage`) — decision 16's "no ask" is for this strut's own agents; a `replaced` 402 whose token changed since it was read (a purchase settled meanwhile) is sent once more with the new one. A 402 is `payment_required:` naming the seller's price in dollars and sats and why (none held, short, expired, replaced) and that an admin buys with remote/buy-credit — never a payment; a 401 `token_refused:`; `RemoteError { code, detail: { status, retryAfter, price } }`. `STRUT_REMOTE_LOCAL=1` (`allowLocal`) allows an `http:` seller and the guard's loopback and private addresses. BUYING (`buyCredit`, the door `remote/buy-credit` runs): only the ACCEPTED record on disk of that step for that seller and amount (the person's edits merged), as package/install's door; `no_pay_credential:` before anything else when the wallet cannot pay; ONE purchase per seller at a time (`purchase_in_flight:` — two would inherit one credit, and the second stored would drop the first; one never handed to the payer is dropped for the next). It asks `POST /public/credit { usd }` presenting the token held (it carries, §4.5; a 401 → asked again without), CHECKS the challenge before paying (invoice and macaroon name one payment hash; the macaroon is strut's five caveats, un-narrowed, its `strut_sats` the invoice's and its `strut_credit` at least the dollars accepted; not expired; the sats within `QUOTE_TOLERANCE`, 10%, of this server's own rate when it has one — `quote_too_high:`), keeps the PURCHASE in the record BEFORE paying, then pays through item 7's withdrawals service (`withdraw({ invoice, msat, actor, purpose: "purchase" })`: one payment per hash, its pending handling, its payer, one `withdrawal` entry with subject `purchase`). Settled with a preimage → stored at once; else the seller's `POST /public/credit/claim { macaroon }` is asked. A token is stored ONLY when its preimage hashes to its payment hash, and it REPLACES the one held (it inherited it). A purchase `pending` stays on file and `check()` claims it every minute until the seller says settled (stored) or `expired` (dropped: the invoice ended unpaid) — it never pays; the same proposal accepted again reuses the purchase's INVOICE (never a second payment), a withdraw error on a purchase already handed to the payer never drops it, and one whose invoice expired while the seller is silent is never dropped if it was sent (`remote_unavailable:`, kept and claimed) — until `PURCHASE_STUCK_AFTER_MS` (an hour) past the invoice, when the payer says the payment failed or never left (`deps.sendState`, createStrut's over `withdrawals.read`): a seller that rotated its L402 root or stopped selling never holds purchases back for good; one the payer says settled is kept for an admin, who drops it with `DELETE /remote/purchases?seller=` (`dropPurchase`, full scope) @@ -142,7 +143,7 @@ strut/ │ │ ├── jamie/ # `strut/jamie` (plans/jamie-app.md §5): JAMIE, the person's assistant, as a first-party app (src/apps/tools.ts `firstPartyApp`: it acts as its viewer) — Hive's org-page chat ported onto steps. app.yaml (grants: orchestrator, job, builder; conversation/list, get, update; llm/models; graph/find; job/get, updates, verify, evidence; proposal/get, decide; sheets/get-values; github/pr-status, merge-pr; secret/add), src/App.tsx (the chat list beside the thread; opens on a new conversation, never the last one; a row or `?input=session%3D` opens an earlier one; the thread always shows: wide, the list is docked and folds away, remembered per viewer; narrow — a phone, or a host's side panel open beside the page — it is a drawer over the thread), src/hooks.ts (`conversation/list` read every 5 s; a conversation followed with `conversation/get { wait, since }`, a call a second at most, none while hidden; `run/launch` of `orchestrator` with `input: { prompt, session, context }`, `params.model`, `attachments`; `run/cancel` is Stop; `llm/models`; `graph/find` on `Concept` for "/"), lib/ (pure, tested by src/apps/jamie.test.ts: chat.ts — the context line (Hive's `orchestratorContextLine` + `WHERE`), day groups, `/<12 hex>` ids, what a refused send means (`session_busy:` the busy notice, `conversation_not_yours:` another person's conversation that is no longer shared — plans/jamie-app.md §2.2), "/" trigger and insert; artifacts.ts — where a deliverable shows: what strut/views draws (markdown, text, sanitized HTML, image, CSV table, walkthrough, a Google Sheet) inline through `run/files` / `sheets/get-values`, the rest (a PDF, video, app, a peer's file via `peerArtifactPath`) handed to the host as `?artifact=` / `?app=` (`ui/open-link`; the host's side panel, plans/jamie-app.md §4); jobs.ts — a job turn's pill, the live strip's line and tally, a claim's pill and open slot, a captcha's manifest, taps and `[captcha-answer]` turn, a build's `ask.secret` and its `[secret] NAME stored` turn; pr.ts — which artifact is a pull request (`kind: pull_request` with a GitHub link), its pill, its checks in a line, when Merge is offered, Fix's prompt), components/ (ChatList: a "Shared" group of the team's shared chats first, then by day, search, show more, rename inline, archive/restore, share or stop sharing, unread and working dots, a pull-request count; under each row its jobs — the newest, the rest behind "N more", a pulse while one runs — a click opens the conversation at that job's card (`focusJob`); Thread: turns oldest first — the person's words and files or a wake line (only for a job with no card in the thread), the working card, the reply as markdown, the question, on a wake what the job delivered (`delivered`), then JobCard, ProposalCard, ArtifactCard per what the turn holds — the reply's copy of a delivered thing, or an entry that opens nothing, left out (lib/artifacts.ts `turnDeliverables`), one card per pull request in a thread (its newest mention; `ThreadPrs`), Copy on a reply (`CopyButton`: the clipboard API, else `execCommand`); Attachments: a person's files under their message — an image a thumbnail that opens large, a text file to read in place, the rest by name — through `run/files` on the turn's run (lib/attachments.ts); Composer: files by clip/drop/paste checked against the server's caps, the model picker remembered per viewer, the "/" menu, Send becoming Stop; JobCard: `run/get` live — on the workflow the turn recorded, else the job's record's (`jobRunWorkflow`) — `job/get`, `useJobUpdates` while it runs, its reply, deliverables, ClaimsPanel (`job/verify` detached, `job/evidence` on a claim waiting on a look) and CaptchaCard (the crop through `run/files`, tiles or points or text, Skip; sent as a job turn naming the conversation) and SecretBox (a build's `ask.secret`: a password box under the question for the person who started the job, until the job takes another turn and never for a build on a peer — `secret/add`, then a `[secret] NAME stored` job turn naming the conversation), its pull requests in sight collapsed; PullRequestCard: `github/pr-status` with the viewer's token, again every 30 s while open — state, branches, checks failed first, the description folded — Merge (`github/merge-pr` at the head the viewer saw) and, on failed checks, Fix (a job turn naming the conversation); ProposalCard: the card's fields, `proposal/get` live, `proposal/decide` with the card's own button words). Excluded from the root tsc; `npx tsc -p kit/tsconfig.jamie.json` checks it against the kit and strut/views │ │ ├── knowledge/ # `strut/knowledge` (plans/knowledge-app.md; specs/API.md §14.13): the organisation's Concept tree as a first-party app, read-only — in place of Hive's org graph view. app.yaml (grants: graph/find, get-ontology, graph-edges, graph-get-batched, graph-neighbors, graph-search, and `history/read`; nothing that writes), src/App.tsx (the outline left with NodeSearch on `Concept` at its top, the GRAPH in the middle — NodeGraph on PARENT_OF only, round the concept picked there, each node's chip expanding or collapsing its neighbours — and the concept a click picks in a panel right, ✕ to close; narrow, the graph, the outline and the concept take the page in turn; `?input=concept%3D` opens one and each selection is sent back as `ui/open-link` to the app, so the host's address bar follows; the open rows remembered per viewer, `useAppState`), lib/tree.ts (pure, tested by src/apps/knowledge.test.ts: Hive's workbench model — roots have PARENT_OF children and no parent, biggest first; the unplaced; a row per path, so a concept with two parents is a row under each; the path through the first parent; cycle-safe), lib/read.ts (`graph/find` pages of 200, then `graph/graph-edges` 1,000 at a time with every Concept as `among`; capped at 5,000 Concepts), components/ (Tree: rows with Hive's amber dot — filled when there are docs — the selected row and its path lit, ↑ ↓ ← → ↵; Detail: path, description, docs through DocumentView, parents and children as chips, repo and file). From the HISTORY (plans/concept-history.md §8 item 4), through strut/views' ConceptDiff: a Concept's card has **History** — every commit that changed its page, what each did, two compared, the page then; and **Changes** over the graph swaps it for the tree between two moments (components/Changes.tsx: `from` / `to` typed as `now`, `1 day ago`, a date or a commit — lib/moments.ts `momentOf`, tested — opening on the last day, beside "Recent changes", the history's last commits on `concepts`, each a click into what it did); never the live change feed. No revert: a page's write on a Concept is refused (`reservedAppType`), first-party included. Excluded from the root tsc; `npx tsc -p kit/tsconfig.knowledge.json` checks it │ │ ├── views/ # `strut/views` (plans/apps.md §3.3, §3.4): strut's components for its OWN concepts as an app other apps import — `import { RunTimeline, RunGraph, LaunchForm } from "strut/views"` compiles these sources into the importer's document, tree-shaken (the build marks an installed app's modules side-effect free and appends a kit stylesheet only when its importer is in the document), and they run on the IMPORTER's grants. app.yaml (no grants), src/index.ts (every export), src/App.tsx (the GALLERY: each component over fixtures, through a fixture client answering the steps they call). RunStatus, RunList (`run/list`), RunTimeline (the run-overview fold), Transcript (the transcript fold, over messages you pass or read from a run's agent step — `{ workflow, runId, path, full? }` through `run/transcript`, its cut said under it), LaunchForm (a routine's face, §5.1: the `input:` block from `run/workflow`, enabled for a visitor only on a workflow in `useHost().runnable`, Cancel off for one; `useLaunch` with a dedupe key — workflow + input by default — the run it joined, the last run's status, cancel), ArtifactView (`run/files`: markdown, an HTML page and text through DocumentView, images, video, audio, the walkthrough scrubber; a PDF opens in strut — the document may not frame), DocumentView + SafeHtml (an HTML page drawn from `sanitizeHtml`'s tree with `createElement`, markdown, text — `terms` highlighted, the first scrolled to; a link opens through the host), RunHealth (`foldRunHealth`), WorkProducts (`useWorkProducts`: a run's work products as a shelf that fills while it goes — a card per product with its maker and what it draws on, opened in the viewer its kind reads in, a sheet or table through SheetView, `renderers` per kind; fixtures/matter.ts the gallery's legal matter), NodeCard / NodeSearch / NodeGraph (React Flow, straight border-to-border edges — FloatingEdge — each node's chip expanding its neighbours or collapsing what that expansion placed, a click `onSelect`, `selected` marked, the host's theme; `graph/graph-get-batched`, `graph-search`, `graph-neighbors` — lib/graph-steps.ts + lib/graph.ts), RunGraph (components/run-graph/: hive's component with lineage, replay and branch tree; a run screen's MASTER VIEW — `highlight` (a lens's ref_ids) dims the rest where it is, `onSelectNode` + a controlled `selected` let a lens's panel answer a click and select a node, `details={false}` leaves the node panel to the app, `calls={false}` starts the call list closed behind a toggle (the lens layout); it OPENS zoomed so a lane is 62% of the canvas's height (`LANES_SHARE`, at most 2×), at the run's start or a highlight's middle; PLAY is `play="steady"` by default — the canvas's clock pans across the lanes at one on-screen speed (`PLAY_PX_PER_S` × the speed), each call shown as the pan, led by `PLAY_LEAD` of the view's width, reaches its place in its lane (`marks`: the lane's calls spread over its width, never left of the call before), `play="timed"` the run's own pace; a scrub or step pans once to the call's LANE; a drag stops the following, Play / back / forward resume it (`refollow`), the locate button toggles it; one button cycles the calls shown (all → reads → writes); `onReplay` hands on the current call's time and `clock` (lib/replay-clock.ts `createReplayClock`) the play's time frame by frame, for an AgentTrace's `cursor` / `clock`; a highlight is FITTED (with room around it, never past the run's edge; a button returns to the whole run); at scale the layout folds what a cell cannot draw into "+N" nodes a click opens around them (40 at a time, laid over the layout so nothing moves) and STACKS a loop of more than three iterations into one box marked "×N" — the current call's iteration while a replay steps, a click lays them side by side — names only what is hovered, selected, the step's own few, highlighted, or folded (from zoom 0.6) — each beside its node inside its cell, over no other name and no node (labels.ts `placeLabels`, pure and tested: eight positions round the node, so one by a cell's right edge leans left; text measured in the page's font; a 16- or 10-character cut where the 30-character one, `clipName`, fits nowhere), drawn above every node, whole in the node's tooltip; highlighted names that fit nowhere are said as one "+N highlighted" (else "+N") per group close together, listing them on hover, beside the group or further down its cell, the group's last-placed names giving their room back when even that fits nowhere — and paints what stands out on top; the replay bar on top and one fixed caption row (a "partial" badge whose popover says what the graph did not answer, with Ask again), the legend one row under the graph, so a scrub never changes its size; its DATA PATH in the page — reader.ts: calls from `run/events`, nodes from graph-get-batched 50 a call, four in flight, up to 5,000, edges and lineage from `graph/graph-edges`, a call per 1,000 nodes (an app that has not granted it: a graph-neighbors per node, bounded), incremental for a live run; a child run through its launching call's `runId`; a peer-tagged ref badged, never read), AgentTrace (components/agent-trace/: `foldAgentTrace` over `run/events` read incrementally, live every 10 s; each FINISHED agent's transcript read once through `useTranscripts` for its reasoning; 34px rows over SVG lanes with fork and merge curves; a node opened through graph-get-batched and the run graph's NodeReader; non-Concept nodes, two or more in a row, folded into one row ("collapse all" folds them, "expand all" opens them); `cursor` (a replay's time) lights the rows at it and `clock` scrolls each agent's heading — a batch's, for agents side by side — to the top `HEAD_LEAD_MS` before it starts; "Download HTML" = the markup and the page's stylesheet as one file, snapshot.ts), SheetView (a spreadsheet work product drawn in the page — a Google Sheet through `sheets/get-values { grid: true }`, a CSV/TSV/JSON table through `run/files`, or `rows` — read again every 3 s while its `workflow` + `runId` is in flight (or `live`, or on `refresh`), the cells and rows each read changed lit; sticky header row and first column, numbers right, formulas in a bar and with fx, a tab strip, `sources` (a cell's A1 or a row's key → ref_ids) as corner marks opening NodeCards, "Open in Google Sheets" through the host; its pure parts in lib/sheet.ts, tested by src/apps/views-sheet.test.ts; `onSpotCheck` puts a Spot check button in the formula bar for the selected cell). SpotCheck (components/SpotCheck.tsx, plans/spot-check.md: where one thing a run made came from, over `run/provenance` — the moments oldest first, first seen and last write marked, the agent's words before each write and the calls just before it, a formula's cells, typed-in numbers, a passage's figures and a write's cited cells each a click into a spot check of its own, a back arrow; its words in lib/provenance.ts, tested by src/apps/views-spotcheck.test.ts; DocumentView's `onSpotCheck` offers it for selected text). ConceptDiff (components/ConceptDiff.tsx, plans/concept-history.md §8 item 4, rulings 7 and 12: two states of the Concept tree drawn as one, whatever produced them — `from` / `to` (commits or times, `main` for now) read through `history/read diff` in `path` (`concepts` by default), `proposal` (an id: `proposal/get`, the nodes it names through graph-get-batched, the page now through `history/read show { at: "main" }`, laid over by the fold), or `entries` you pass (an unsaved edit) — the tree with the marks (a parent the diff lacks drawn hollow, climbing through `parentsOf`), the counts, and a page opening to `PageDiff`: its name before → after, its parents struck and added, each changed attribute before and after, its docs line by line with unchanged runs folded; given `page` (`{ name }` or `{ path }`) its HISTORY instead — `history/read log { path }`, followed through renames, a row per commit (time, author, first line, the runs that wrote, `onOpenRun`), a click drawing what that commit did against the commit before it, two ticked compared, "The page then" (`PageView`) the page as it was; one more commit read than shown, so the oldest row's change is drawn too; a strut with no history says so (`NoHistoryError`). Its reads in lib/history.ts (`readLog`, `readPage`, `readDiff`, `readEntry`, `commitReads`, `compareReads`, `readProposalEntry`), tested by src/apps/views-concept-diff.test.ts against the gallery's fixture history (fixtures/history.ts: six commits of a legal tree, a rename git saw and one it did not, three proposals)). WalkView (components/walk/: a graph walk as it plays out — `findWalks` + `walkFrame` over `run/events`, live every 3 s; the trail of chosen nodes, each hop's other candidates behind "+N considered" (five shown, then "+N more"), the newest pulsing; every label one line, cut at `NAME_CHARS` (30, lib/format.ts `clipName`), the whole name in its tooltip and the drawer; live and replay one view on the kit's replay clock (`useReplay`, lib/replay.ts): joined in flight it shows the hops so far and follows, faster when behind, never skipping a beat; a finished walk replays from its log alone at the pace it ran (`replay` opens it playing from the start); ReplayBar's controls ABOVE the walk, which is laid out whole with the hops shown drawn in place (each row over an invisible copy of its whole-walk form; the tree's breadcrumb one line, first › … › current), so scrubbing never changes the view's height; a click opens the node in a drawer by it — beside the walk, which reflows to its left, when the walk is wide enough, else across it over a veil, so nothing is half under it (its text through the node reader's `NodeReaderBody`; ✕, Esc or a press outside closes it) — or hands it to `onOpenNode` and opens none; `mode="tree"` a d3 tidy tree of everything discovered, laid out once from the whole walk — a node's children past five (the path's and the kept ones always shown) folded into one "+N more" node a click opens; only the path, the selected or hovered node and the "+N" nodes labelled, the rest dots — its columns as wide as their measured labels, fitted to the width (never past 1:1), dragged to pan, pinched / ⌘-wheeled or buttoned to zoom; sized by its container, never the viewport; `path` picks one of several walks, the latest by default; a finished agent's transcript read once for what its previews cut). ReplayBar (components/ReplayBar.tsx): the replay controls RunGraph and WalkView share — a step back, play/pause, a step on, a slider, the speed (`REPLAY_SPEEDS`), Live on a run in flight once a person stepped away; presentational, the view owns the cursor. No ClaimsBadge: the workflow ledger is only `meta/list-claims`, sealed. views-seed.ts: `seedViewsApp` — known tree hash → made active again if it is not (a seed always matches git), nothing more; else built ONCE per machine per (sources, kit) into `/strut-views/`, copied into the version directory (strut's own sources: trusted as built), skipped without a built kit; `createStrut({ views: false })` seeds none. Excluded from the root tsc; `kit/tsconfig.views.json` checks it -│ ├── packages/ # PACKAGES (plans/marketplace.md): the bytes, the build, and the install from `file:`, `pk:` and `git+` (steps 1–2, the HTTP-first half of 6 and the `git+` source of 3, apps included; exporting to git and a person's update are later items). format.ts: a package is a directory, `strut.yaml` (zod, strict: `package: publisher/name` lowercase, `version`, `description?`, `requires { secrets, binaries, runtimes, packages: [{ package, digest, source }] }`) and `workflows/.yaml`, `steps/.ts` (`_` files helpers, `.claims.yaml` beside a step), `concepts/.md` (a Concept page, named by the `name` in its front matter whatever its file — the new form, which a build writes as `concepts/.md` with every attribute and every parent — or, in the old form, by its file), `apps//` (with app.yaml), `graph/.yaml` (a graph type, item kind `type`, named by its type; graph-types.ts); `readPackage(dir)` → `{ manifest, digest, files, items }`, each item named `/` (a Concept by its page name), refusing with `bad_package:` an unknown top-level folder, a file a folder does not hold, a name the step/app rules refuse, two Concept pages with one name, a symlink, a package with no items. The DIGEST is `sha256:` + `hashTree` (src/version.ts, `treeHash`'s function) over every file but dotfiles, `node_modules/` and each app's `dist/` + `data.db` (`packageExcluded`), `strut.yaml` included — so an app inside hashes as its version hash does. cache.ts: `/packages/sha256-/`, one directory per digest, written once through a scratch directory that is re-read and hashed before the rename (`PackageCache`: `get` re-hashes, `putDir`, `putArchive`); `resolvePackage(ref, { cache, digest?, base?, url?, fetch?, tokenSecret?, secret?, shell?, dataDir?, known? })` — `file:` copies a directory in, `pk:` goes through pk.ts, `git+` through git-source.ts. git-source.ts: the `git+[@][#]` SOURCE (§5; plans/app-repos.md §2, §4.7) — the repo any URL `git/checkout` takes (`parseRepo`: http(s) or `file://`, no credentials), the ref the remote's default branch when left out, `#` where `strut.yaml` is; fetched into git/checkout's own bare cache under `/repos/` (`fetchCache`, `remoteDefaultBranch`, `commitOf` in steps/lib/git/_shared.ts, which checkout.ts runs too) under its per-cache lock, the token — the secret `tokenSecret` names, default `GITHUB_TOKEN`, read as the installing actor where actor secrets are kept — in the child env behind the inline credential helper only and masked out of every error; the ref resolved as the remote's branch, then a tag, then a commit (never a branch an agent made in the cache), the tree at that commit under `#` written out through a throwaway index outside the cache (`read-tree` + `checkout-index`), then put in the cache by digest. Refusals name what failed: `package_repo_not_found:`, `package_repo_denied:` (credentials asked for or refused, naming the secret), `package_repo_unreachable:`, `package_ref_not_found:`, `package_source_invalid:` (`#` not there, or no `strut.yaml` in it), `package_digest_mismatch:` with the commit. The install records the commit on every version it writes (`upstream.commit`, beside `source.ref` as written — what an update re-resolves, a branch or a tag, never the commit; `commit` on `GET /packages` and the install report); a pinned `git+` requirement the cache holds is taken from it, as `file:` is; `known: { commit, digest }` answers a ref still at that commit from the cache without reading the tree out (serve mode). Every door takes it: `POST /packages/install { ref, tokenSecret? }`, `STRUT_PACKAGES` (an entry is here when an installed package records that very ref), a `requires.packages[].source` (the token only for a requirement on the origin of a ref the person named: a package never sends it to a host of its choosing). pk.ts: the `pk://[@digest]` SOURCE (§5, §13, HTTP-first 2026-10-28) — the publisher's catalog fetched from `url` (where it is served today: a strut's `/packages/catalog?public=1`, or an uploaded file; later an index row's or the key's Pkarr record, neither changing a check) and read as a card against the KEY, its `seq` against the last accepted from that key on this strut (`/packages/index/.json`), the release (the pin, which the catalog must list — `package_not_published` — else the newest not yanked), its archive by digest from the route beside the catalog (`releaseUrls`: the catalog's own query rides along) then the release's `urls`, and every required package the catalog lists, so the install finds them cached; the install pins `upstream.key` and `upstream.source.url`. archive.ts: the archive is a .tar.gz of the files (src/tar.ts), transport only — `unpackArchive` refuses a path that leaves the directory or one the digest never covers, caps size and count; `archiveProblem` packs a package as the route does and reads it back as `unpackArchive` would, which `buildPackage` (dry run included) asks before keeping anything, so a package no strut could fetch is refused at the build (`package_unarchivable:`); `fetchArchive({ urls, digest, cache, package?, headers? })` returns the cached digest or fetches from the first URL whose archive unpacks to it, passing over the rest and naming each (never a query) — the last hop of the later `pk:` resolver. `GET /packages/:publisher/:name/archive?digest=` (createStrut) serves any package the cache holds, behind the gate. install.ts: `installPackage(deps, ref, { digest?, actor? })` — the package and, FIRST, every `requires.packages` pin not installed here at that digest (the cache, else the pin's source, a relative `file:` from the requiring package's directory); ALL OR NOTHING: every name and file of the set checked before the first write (a local item under the prefix, a sealed namespace, a Concept under someone else's stamp or with a parent nowhere, an attribute the graph's `Concept` does not declare a warning, a workflow naming a step / grant / subflow neither here nor in the set or whose `name:` is not its path, a claims file that does not parse), each app copied out of the cache and BUILT against this strut's kit with the set's workflows and apps in reach; then steps (`source: "install"`, `upstream`, the actor), one registry rebuild and a load check (a step that does not load, or whose `defineStep` type is not its path, takes back everything written), workflows (owned by the actor), apps through `ctx.services.apps`' publish (a person's, each after the set's apps it imports — so what an app publish does, an install does), Concepts through `seedConcepts` stamped `pkg//` with `keepEdited`, then the claims with the package as speaker and the publish checks (a claim that will not record is a warning, never a take-back). The same digest again installs what is missing; another digest through a person's door is `package_update_not_built:` (it asks first, not built). The DISTRO's door (`strut.packages.install(ref, { door: "distro" })`, in process only, plans/marketplace.md §12) applies a release with no ask and UPDATES item by item (§4's table): an item whose active version the package wrote (`source: install | distro` + `upstream`) is fast-forwarded, a fork gets the release's version beside it (`activate: false`, `diverged`), an item the release drops is retired when untouched and kept when forked, an edited Concept is left out of the seeder's set; a required package at a new digest is updated first; versions record `source: "distro"` and the package is then the distro's (`package_distro_owned:` at a person's door). Every step an install makes active is TRIAL-LOADED from `/.install//trial/` before the first write (`stepFileType`, registry.ts), since no store deletes one version; a later failure re-points an updated item at its previous version. The fold's `installed` is the active version when the package wrote it, else the newest the package wrote, and the package's digest is its newest installed version's. Missing secrets, binaries and runtimes are reported, never refused. graph-types.ts: a package's GRAPH TYPES — `graph/.yaml` in `graph/create-schema`'s grammar plus `edges: [[EDGE, Target]]`; `allTypeFiles` (a snapshot's: every type in the ontology but Thing and strut's own, each with the ontology's rules whose source it is — never the edges the graph holds, which are facts and a scan of every edge), `exportTypes` (the build's `types: [...]`: each named type read off the ontology, its parents up to Thing / strut's / the seed's / a required package's, and the edges among them from the ontology's rules AND the edges the graph holds — a Jarvis allowlisted edge travels as a rule; an edge to a type left out is a warning; its key the attributes after the key spec's type prefix, whoever declared it — jarvis's `Content` and `Legal` inherit Thing's `thing-name` and export keyed by `name`; a setting naming an attribute the type neither declares nor inherits — jarvis's `ComputedFigure` indexes `matter_slug`, `Hint`'s index and title read `ref_id` — is left out of the file with one warning per type: an index field alone (an index left empty is no index), a key spec, title_key or description_key whole, so its default stands; a type declaring its own `title` that the graph titles by another field is a warning, since a strut titles it by `title`), `checkPackageTypes` (in the install's check pass: grammar, parents and edge ends in the set or the graph, `checkAgainstGraph` — apps/graph-types.ts, shared with `data.graph`), `registerPackageTypes` (before any other write, parents first, add-only through `registerTypes`; never taken back or uninstalled). Edge rules carry no temporal / cardinality / aliases yet. build.ts: `packageFiles(deps, picks)`, the ONE writer of a package's files (plans/concept-history.md §2): a build passes its picks, the history `EVERYTHING` — `wholeFiles`, a build's rules lifted: every workflow's and step's active version as published (no name stamped), each step's live claims (`ClaimsReader.claimedSubjects`, one read), every app's active sources, every live Concept in every namespace in two reads (`allConceptFiles`: the nodes, then every `PARENT_OF` into one; `concepts/.md` in the backend's own namespace, `namespaces//concepts/.md` in any other; strut's `Job` and every other stamp included), every type but Thing and strut's own (`allTypeFiles`), and `strut.yaml` (`WORKSPACE_PACKAGE`, `requires.packages` = every installed package at its digest); a page is `conceptPageFile`'s in both, so the same page is the same bytes in a package and in the history. `buildPackage` (`POST /packages/build`) — the named workflows' closures, each named app's grants and the apps it imports (off its `dist/build.json`), helpers, Concepts (each a new-form page under its node key, `concepts/.md`: every attribute the schema declares, every live parent; a parent outside the package a warning unless strut's or a required package's), step claims and `requires`, into the cache; an app as its version's sources, checked against its version hash; what the closure reaches that strut does not seed on every server — a host's seed, a service's item — is `leftToInstaller` on the answer and a warning (strut's own seeds, read off `src/workflows/`, stay silent); a carried workflow's category as a top-level `category:` line in its YAML, which the install sets (not in the strict `strut.yaml`, so an older strut still reads the package); `conceptsAlone` carries a page without the pages under it; in every carried page's docs and description each carried workflow name and custom step type is written as packaged (whole tokens; a one-word name only as a whole code span), and one the package does not carry is a warning. `uninstallPackage`: every item still as installed goes (never runs), untouched Concepts retired, forks, edited pages and helpers a forked step may import kept and listed — and a page with a page under it that stays (this strut's own under the package's root, an edited one), with every page above it (`heldPages`, install.ts; a release that drops pages keeps them the same way). installed.ts: the FOLD — `listInstalledPackages` from the workflow, step and app version records (`source: "install"` + `upstream`, under the package's prefix) and the `pkg/` Concept stamps, `modified` = a fork (or a page's text against its file in the cache); no store. fence.ts: `fenceStep` — a step whose active (or pinned) version carries an `upstream` reads only its package's `secrets` (`secret_fenced:` otherwise), wrapped by `buildRegistry({ fences })` from `workspaceFences` (`StepListEntry.upstream`) and by `resolveStep` for a pin, so every way a step runs is fenced. `GET /packages`, `POST /packages/install { ref, digest?, url?, index?, wait? }` (`wait: false`: 202 with the install's record at once), `GET /packages/installs[/:id]` (progress.ts: every install as it goes, whichever door — the route, a proposal's apply, the boot list, a subscription — its phase and `done` / `total`, "Concepts 312 / 752"; in memory, an hour after it ends; the Packages tab's Find polls it) (a bare `publisher/name` ref resolves through the indexes this strut follows, below), `DELETE /packages/:publisher/:name` (createStrut, one write at a time). indexes.ts: the indexes this strut FOLLOWS (§7, step 7's client half) — `{ id, key, urls, label? }` in `/packages/indexes.json` (public data, plain JSON), put on file at boot from `STRUT_PACKAGE_INDEXES` (else `DEFAULT_PACKAGE_INDEXES`) and `createStrut({ packageIndexes })`; `resolveName` reads each card through `fetchCatalog(…, { kind: "index" })` (the per-key `seq` rule), first card listing the prefix wins, and the install goes on as `pk:/` with the row's `urls` tried in order — so the key the SIGNED card named is what is pinned; `search` reads each index strut's derived `/index/packages` and every peer's `GET /packages` (never installed as it came: a hit installs by its name); `get(name, { index? })` one package's page from the first followed index whose card lists its publisher (a miss over memoized cards re-reads them once), `concepts(q)` / `concept(name)` the Concept tree across the followed indexes (one entry per name, the first index's) and one page — the derived pages, read through the index's open routes. Routes `GET`/`PUT`/`DELETE /packages/indexes[/:id]`, `GET /packages/search`; `ctx.services.packages` (`PackagesCapability`: the four reads, and `install`, package/install's door behind an ACCEPTED proposal — never a read that installs) for the `package/*` steps (steps/lib/package/: search, get, concepts; install asks), which the builder chat gets as `package_search` / `package_get` / `package_concepts` and the `builder`, `job` and `orchestrator` seeds grant, and for the assistant door's `search_packages` / `get_package` (ai/assistant-tools.ts). `briefPackagePage` is the page as a MODEL reads it (README cut at `README_BRIEF_CHARS`, Concept docs left out) and `INSTALL_IS_A_PERSONS_ACT` the sentence every agent-facing door ends with. card.ts: the catalog CARD (§13) — RFC 8785 JSON (`canonicalJson`; integers within ±(2^53 − 1) only, a float is `catalog_invalid`), the z-base-32 key, `sealCard` (Ed25519 over `strut-catalog\n` + the fingerprint, the sha256 of the card without `sig`) and `readCard`, a reader's checks in order: one byte form, the EXPECTED key, `v: 1`, the seal, `seq` against the last accepted (`catalog_rollback` / `catalog_conflict`); `releaseFor` (a channel's release, else the newest not yanked); `card.test.ts` holds the test vector. publisher.ts: this strut as a PUBLISHER (§13, §12.1) — TWO Ed25519 seeds in an encrypted file of its own, `publisher.json` beside `peers.json` (minting refused on a file store with `STRUT_SECRET_KEY` unset): the FLEET key, minted first, seals the fleet catalog under `/packages/catalogs/` (`sha256-.json` + `current`) — the whole picture, every release and channel, what a subscription follows; the PUBLIC key, minted at the first **Make public** (`makePublic(channel)`, the record's `publicChannels`), seals the public catalog under `catalogs/public/` — `projectPublic`: the public channels and every release one holds or has held, carried forward (what was published stays published) — since a card's `seq` rule is per key. The fleet card names the public key (`public`); the public card never names the fleet key. `publishRelease` (a release newest first, the channel moved, `public: true` marking it first) and `moveChannel` (`public: false` on a public channel refused, `catalog_channel_public`; a release going public that requires a package no public channel holds refused, `catalog_private_requirement`, before anything is written). `host: self`: a release lists no `urls`, its archive is the route beside the catalog. No Pkarr record yet. `GET`/`POST /publisher`, `POST /packages/publish` (build + release), `GET /packages/catalog[?digest=]` (`?public=1`: the public one, no credential — `publicPackageRead`, auth.ts, and the archive route serves a public digest the same way; a host's gate hands such a read through like `?t=`), `PUT /packages/channels/:channel`, `POST /publisher/tokens` (a subscriber's read token, auth.ts). index.ts: the package INDEX role (§7, step 7; `createStrut({ index })`, `STRUT_INDEX=1`) — the index key (`INDEX` in `publisher.json`, minted at the first row), the rows (`setRow` / `removeRow`: prefix → `{ key, urls, description?, url? }`, `indexRowProblem`) sealed as an INDEX card (`card.ts`: `kind: "index"`, domain `strut-index\n`, `publishers` where a catalog has `packages`, `urls` where this index is served) under `/packages/catalogs/index/`, the CRAWL (each row's public catalog through pk.ts's `fetchCatalog` from the row's `urls` in order, the accepted card and state under `catalogs/index/crawl/`; at the row, on the publisher's ping — `POST /packages/subscriptions/sync { key }` with the PUBLIC key, which a publish or channel move sends to every peer when the public catalog changed, `pingPublic` — and on the subscriptions' poll), `search` (`src/search.ts` over package name + description + publisher; each package at its newest release not yanked, with the row's key and urls — what a `pk:` install needs — and `detail`, the release's archive as read) and `package` (releases, public channels, `readAt`, `detail` / `detailError`); the DERIVED PAGES (the store front, 2026-10-29): after a catalog is accepted, each package's newest release not yanked is fetched by digest (`fetchArchive` over `releaseUrls`, into the index's cache — the mirror §7 allows) and read by detail.ts into `crawl/pages//.json`, kept by digest (read once; a fetch that fails is recorded and tried again next crawl, never the catalog's failure), `derived` on the crawl state; `concepts(q)` / `concept(name)` fold every page's Concept pages into one tree (the first package by name gives description, parents and docs; every package is listed; children — a page under each of its parents — and the ancestors along each one's first parent resolved across packages; a page derived before the new form kept one `parent`, read as its parents). Routes: `GET /index` (any credential), `PUT`/`DELETE /index/:prefix`, `PUT`/`DELETE /index/live/:prefix` (a LIVE row, plans/public-surface.md §8: where a publisher server's public view is served — its own `live` map on the card, beside `publishers`, so an older reader reads the card as before — read at the row and every crawl into `crawl/live/.json`; its trees folded into `/index/concepts`), `POST /index/crawl` (full scope); `GET /index/catalog`, `GET /index/packages[?q=]`, `GET /index/packages/:publisher/:name`, `GET /index/concepts[?q=]`, `GET /index/concepts/:name`, `GET /index/live[?q=]` OPEN to anyone (the gate's `INDEX_READ`, only with the role on), and so are `GET /llms.txt` (index-tools.ts `llmsTxt`) and ONE POST, `/index/mcp` (`INDEX_MCP`); with the UI served, `GET /` with no credential is a 302 to `./?store=1`, the store front (web/src/components/Store.tsx). Derived and unsigned past the card: an install re-verifies against the publisher. index-tools.ts: the store for an AGENT (2026-10-29) — `buildIndexTools(index, { store? })`, three READ tools over the index's own functions, served to anyone on `POST /index/mcp` through `toolsetServer` / `createMcpDoor` (ai/mcp.ts; part of the role, whatever `enableMcp` says of the keyed doors): `search_packages { query?, limit? }` (the hits with `detail`), `get_package { package }` (the page as a model reads it, `briefPackagePage`), `get_concept { name?, query? }` (the tree, or one page) — each with its `store` link when the index serves the UI and `install`, the sentence that a PERSON installs and where; `indexInstructions` says what the index is, that everything is derived and an install re-verifies against the publisher, and how a person installs — so any MCP-capable agent adds the store with one line (`claude mcp add --transport http strut-store https:///index/mcp`); and `llmsTxt` — `GET /llms.txt` at the index's root (llmstxt.org), the same store as ONE markdown page for an agent that fetches URLs: the blockquote (what the index is, derived, a person installs), the `claude mcp add` line, `## Reads` (the open routes), `## Packages` (every listed package with what its archive holds), `## Concepts` (every page), `## Optional` (the store page) — each entry a link to its JSON page here; the Store page's footer links it. listing.ts / index-requests.ts / publisher-listings.ts: SELF-SERVE LISTING (plans/index-listing.md, BUILT 2026-11-10) — a publisher asks an index for its name with a LISTING request, a third kind of card (`card.ts`: `kind: "listing"`, domain `strut-listing\n`, `sealVerifies`), sealed by its PUBLIC key: `{ v, index, prefix, key, base?, description?, url?, remove?, live?, seq, sig }`, the `base` an origin and at most one path segment, never a catalog URL — the index reads only `/packages/catalog?public=1`, so a token-bearing file on a trusted host never passes as its catalog, and it reads that URL alone (`fetchCatalog`'s `redirects: false`: a redirect is refused, since the host is a stranger's to choose; the body read no further than 4 MB, `readCapped`) (`listing.test.ts` holds a test vector). The index's DESK (`POST /index/requests`, open, 10 a minute an address) checks it cheapest first (form, its own key, `seq` fresh and increasing per key, seal, reserved, refused — a refusal is of what waited, never of a removal or the row's own key changing its row on the same host), reads the catalog, then lists at once — the row's own key on the same host, or a new name from an `autoHosts` host by a key and host with no row — or keeps it in `/requests/pending/` with its reason (`host`, `second name`, `same host`, `move`, `takeover`, `live row` — a name the operator lists as a live row with no key is held too) for the operator's `GET /index/requests`, `…/:id/approve`, `…/:id/refuse { reason }` (30 days), every outcome in `requests/log.jsonl`; `POST /index/ping { key }` (open) reads the rows naming a key at most once a minute. The publisher's side: `POST /publisher/listings { index, base, … }` seals and sends to the followed index strut beside its card, the answers in `/packages/listings.json`, `GET /publisher/listings` reads "listed" off the index's signed card, `pingPublic` pings every index asked and not withdrawn from (a failed or refused change leaves the row listed). detail.ts: `derivePackageDetail(cached, { stepInfo })` — what a release HOLDS, read off its directory with nothing loaded or run (the store's package page): the apps' manifests (title, icon, grants, `can` from the workflows they grant), each workflow's YAML (`description:`, the `input:` block, the closure's types with a package subflow folded in package-first, `agentTools` — `{{ params.X }}` read off the params' defaults — the `claims:` count, `peer` / `sealed`, the secrets `secretNamesIn` finds plus a lib step's one declared secret and the package steps'), each step's source by regex (`description`, `effect`, declared `secrets`, every `secrets.get("NAME")`) with `lintStepSource`'s findings, the Concept pages (`parseConceptFile`), the root README (capped); `can: { spends (agent / llm / acp / graph/walk), acts (a step declaring effect: write — this strut's registry through `stepInfo`, or the package step's source), code (exec or a package step) }`; `summarizeDetail` is the hit's short form. A READING, never a check — the install re-reads everything. subscriptions.ts: this strut FOLLOWING another's catalog (§12.1) — `{ key, url, channel?, token? }` in an encrypted file of its own, `subscriptions.json` (the operator's full-scope `PUT /packages/subscriptions/:id`), its state beside the cache (`/packages/subscriptions/`: the catalog accepted, the release applied); a SYNC (at the `PUT`, at boot, every `STRUT_DISTRO_POLL_MINUTES`, on the publisher's ping `POST /packages/subscriptions/sync`, which a PEER may send) checks the catalog against the key and the last `seq`, fetches every archive the channel holds by digest, installs the set as ONE install through the distro's door (`installPackages`), source ref `subscription:`, and uninstalls what the channel dropped; `GET /packages/subscriptions` is the fleet's view (applied release, `diverged` items). A publisher pings every peer on file after a publish or a channel move. serve.ts: SERVE MODE (plans/app-repos.md §2.1, §4; work item 1) — a strut started with `STRUT_SERVE=` (`createStrut({ serve: { ref, url?, tokenSecret? } })`; a name, `pk:` with `STRUT_SERVE_URL`, `git+` with `STRUT_SERVE_TOKEN_SECRET` (work item 2), or `file:`) is the box one package's app is served from. `serve.yaml`, a top-level file of the package (in the digest, read past by `readPackage`), strict: `app` (the app `/` shows), `title?`, `description?`, `image?` (an image file in the package, for `og:image`), `reads?`, `runnable?`, `concepts?` — `signIn` / `visitorKey` / `limits` refused as not here yet; `parseServeFile`, then `planServe` resolves its names package-first against what the package holds (`app` must be one of its apps) into the public view record, checked by `surfaceProblem`. A DEPLOY (`createServe` → `sync`, one at a time): the ref into the cache, `serve.yaml` read and planned (a bad one stops here, nothing written), the install through the distro's door, `runnableProblems` (via createStrut, which mints `L402_ROOT` for a priced item), the app HELD at the version just installed (`apps.promote`), the record written whole; a deploy that cannot resolve, install or apply keeps the previous one serving and says why. State in `/serve.json` (what is serving, its page — title, description, the app's lucide icon as an SVG read off the kit, `lucideIconSvg` — the last read, the last error), one log line per deploy. A `git+` ref FOLLOWS ITS BRANCH (§4.3): fetched every `STRUT_SERVE_POLL_MINUTES` (5; `createStrut({ servePoll })`) and on `POST /serve/sync` — a push webhook, open to anyone on a served box, at most one fetch a minute (`ping`: a ping inside the minute is one fetch when it is up, `pingAt` on `GET /serve`) — a commit still serving answered from the cache (`known`), a moved commit whose digest did not move deploying nothing (`serving.commit` follows it), a moved digest deploying, the install handed the commit (`InstallOptions.commit`) for `upstream`. Any other ref is followed as a subscription is: the subscriptions' poll and the publisher's ping (`follows(key)`: a `pk:` ref's key, a name's once resolved; a `file:` ref only on a ping with no key) read the ref again, a moved digest deploys. `servedHtml`: the UI's `index.html` with `` (empty before the first deploy), the title, the favicon and the `og:` tags — every page createStrut serves on a served box but `?shell=1` with the key; `GET /serve` (full scope) is the state, `GET /serve/image` (open) the image; `PUT /public-view` is `409 serve_owned:`; `strut/jamie` and `strut/knowledge` are not seeded. A build carries one: `BuildRequest.serve` (`POST /packages/build` / `publish { serve }`), the fields written as the package's `serve.yaml` and checked against what the build carries +│ ├── packages/ # PACKAGES (plans/marketplace.md): the bytes, the build, and the install from `file:`, `pk:` and `git+` (steps 1–2, the HTTP-first half of 6 and the `git+` source of 3, apps included; exporting to git and a person's update are later items). format.ts: a package is a directory, `strut.yaml` (zod, strict: `package: publisher/name` lowercase, `version`, `description?`, `requires { secrets, binaries, runtimes, packages: [{ package, digest, source }] }`) and `workflows/.yaml`, `steps/.ts` (`_` files helpers, `.claims.yaml` beside a step), `concepts/.md` (a Concept page, named by the `name` in its front matter whatever its file — the new form, which a build writes as `concepts/.md` with every attribute and every parent — or, in the old form, by its file), `apps//` (with app.yaml), `graph/.yaml` (a graph type, item kind `type`, named by its type; graph-types.ts); `readPackage(dir)` → `{ manifest, digest, files, items }`, each item named `/` (a Concept by its page name), refusing with `bad_package:` an unknown top-level folder, a file a folder does not hold, a name the step/app rules refuse, two Concept pages with one name, a symlink, a package with no items. The DIGEST is `sha256:` + `hashTree` (src/version.ts, `treeHash`'s function) over every file but dotfiles, `node_modules/` and each app's `dist/` + `data.db` (`packageExcluded`), `strut.yaml` included — so an app inside hashes as its version hash does. cache.ts: `/packages/sha256-/`, one directory per digest, written once through a scratch directory that is re-read and hashed before the rename (`PackageCache`: `get` re-hashes, `putDir`, `putArchive`); `resolvePackage(ref, { cache, digest?, base?, url?, fetch?, tokenSecret?, secret?, shell?, dataDir?, known? })` — `file:` copies a directory in, `pk:` goes through pk.ts, `git+` through git-source.ts. git-source.ts: the `git+[@][#]` SOURCE (§5; plans/app-repos.md §2, §4.7) — the repo any URL `git/checkout` takes (`parseRepo`: http(s) or `file://`, no credentials), the ref the remote's default branch when left out, `#` where `strut.yaml` is; fetched into git/checkout's own bare cache under `/repos/` (`fetchCache`, `remoteDefaultBranch`, `commitOf` in steps/lib/git/_shared.ts, which checkout.ts runs too) under its per-cache lock, the token — the secret `tokenSecret` names, default `GITHUB_TOKEN`, read as the installing actor where actor secrets are kept — in the child env behind the inline credential helper only and masked out of every error; the ref resolved as the remote's branch, then a tag, then a commit (never a branch an agent made in the cache), the tree at that commit under `#` written out through a throwaway index outside the cache (`read-tree` + `checkout-index`), then put in the cache by digest. Refusals name what failed: `package_repo_not_found:`, `package_repo_denied:` (credentials asked for or refused, naming the secret), `package_repo_unreachable:`, `package_ref_not_found:`, `package_source_invalid:` (`#` not there, or no `strut.yaml` in it), `package_digest_mismatch:` with the commit. The install records the commit on every version it writes (`upstream.commit`, beside `source.ref` as written — what an update re-resolves, a branch or a tag, never the commit; `commit` on `GET /packages` and the install report); a pinned `git+` requirement the cache holds is taken from it, as `file:` is; `known: { commit, digest }` answers a ref still at that commit from the cache without reading the tree out (serve mode). Every door takes it: `POST /packages/install { ref, tokenSecret? }`, `STRUT_PACKAGES` (an entry is here when an installed package records that very ref), a `requires.packages[].source` (the token only for a requirement on the origin of a ref the person named: a package never sends it to a host of its choosing). pk.ts: the `pk://[@digest]` SOURCE (§5, §13, HTTP-first 2026-10-28) — the publisher's catalog fetched from `url` (where it is served today: a strut's `/packages/catalog?public=1`, or an uploaded file; later an index row's or the key's Pkarr record, neither changing a check) and read as a card against the KEY, its `seq` against the last accepted from that key on this strut (`/packages/index/.json`), the release (the pin, which the catalog must list — `package_not_published` — else the newest not yanked), its archive by digest from the route beside the catalog (`releaseUrls`: the catalog's own query rides along) then the release's `urls`, and every required package the catalog lists, so the install finds them cached; the install pins `upstream.key` and `upstream.source.url`. archive.ts: the archive is a .tar.gz of the files (src/tar.ts), transport only — `unpackArchive` refuses a path that leaves the directory or one the digest never covers, caps size and count; `archiveProblem` packs a package as the route does and reads it back as `unpackArchive` would, which `buildPackage` (dry run included) asks before keeping anything, so a package no strut could fetch is refused at the build (`package_unarchivable:`); `fetchArchive({ urls, digest, cache, package?, headers? })` returns the cached digest or fetches from the first URL whose archive unpacks to it, passing over the rest and naming each (never a query) — the last hop of the later `pk:` resolver. `GET /packages/:publisher/:name/archive?digest=` (createStrut) serves any package the cache holds, behind the gate. install.ts: `installPackage(deps, ref, { digest?, actor? })` — the package and, FIRST, every `requires.packages` pin not installed here at that digest (the cache, else the pin's source, a relative `file:` from the requiring package's directory); ALL OR NOTHING: every name and file of the set checked before the first write (a local item under the prefix, a sealed namespace, a Concept under someone else's stamp or with a parent nowhere, an attribute the graph's `Concept` does not declare a warning, a workflow naming a step / grant / subflow neither here nor in the set or whose `name:` is not its path, a claims file that does not parse), each app copied out of the cache and BUILT against this strut's kit with the set's workflows and apps in reach; then steps (`source: "install"`, `upstream`, the actor), one registry rebuild and a load check (a step that does not load, or whose `defineStep` type is not its path, takes back everything written), workflows (owned by the actor), apps through `ctx.services.apps`' publish (a person's, each after the set's apps it imports — so what an app publish does, an install does), Concepts through `seedConcepts` stamped `pkg//` with `keepEdited`, then the claims with the package as speaker and the publish checks (a claim that will not record is a warning, never a take-back). The same digest again installs what is missing; another digest through a person's door is `package_update_not_built:` (it asks first, not built). The DISTRO's door (`strut.packages.install(ref, { door: "distro" })`, in process only, plans/marketplace.md §12) applies a release with no ask and UPDATES item by item (§4's table): an item whose active version the package wrote (`source: install | distro` + `upstream`) is fast-forwarded, a fork gets the release's version beside it (`activate: false`, `diverged`), an item the release drops is retired when untouched and kept when forked, an edited Concept is left out of the seeder's set; a required package at a new digest is updated first; versions record `source: "distro"` and the package is then the distro's (`package_distro_owned:` at a person's door). Every step an install makes active is TRIAL-LOADED from `/.install//trial/` before the first write (`stepFileType`, registry.ts), since no store deletes one version; a later failure re-points an updated item at its previous version. The fold's `installed` is the active version when the package wrote it, else the newest the package wrote, and the package's digest is its newest installed version's. Missing secrets, binaries and runtimes are reported, never refused. graph-types.ts: a package's GRAPH TYPES — `graph/.yaml` in `graph/create-schema`'s grammar plus `edges: [[EDGE, Target]]`; `allTypeFiles` (a snapshot's: every type in the ontology but Thing and strut's own, each with the ontology's rules whose source it is — never the edges the graph holds, which are facts and a scan of every edge), `exportTypes` (the build's `types: [...]`: each named type read off the ontology, its parents up to Thing / strut's / the seed's / a required package's, and the edges among them from the ontology's rules AND the edges the graph holds — a Jarvis allowlisted edge travels as a rule; an edge to a type left out is a warning; its key the attributes after the key spec's type prefix, whoever declared it — jarvis's `Content` and `Legal` inherit Thing's `thing-name` and export keyed by `name`; a setting naming an attribute the type neither declares nor inherits — jarvis's `ComputedFigure` indexes `matter_slug`, `Hint`'s index and title read `ref_id` — is left out of the file with one warning per type: an index field alone (an index left empty is no index), a key spec, title_key or description_key whole, so its default stands; a type declaring its own `title` that the graph titles by another field is a warning, since a strut titles it by `title`), `checkPackageTypes` (in the install's check pass: grammar, parents and edge ends in the set or the graph, `checkAgainstGraph` — apps/graph-types.ts, shared with `data.graph`), `registerPackageTypes` (before any other write, parents first, add-only through `registerTypes`; never taken back or uninstalled). Edge rules carry no temporal / cardinality / aliases yet. build.ts: `packageFiles(deps, picks)`, the ONE writer of a package's files (plans/concept-history.md §2): a build passes its picks, the history `EVERYTHING` — `wholeFiles`, a build's rules lifted: every workflow's and step's active version as published (no name stamped), each step's live claims (`ClaimsReader.claimedSubjects`, one read), every app's active sources, every live Concept in every namespace in two reads (`allConceptFiles`: the nodes, then every `PARENT_OF` into one; `concepts/.md` in the backend's own namespace, `namespaces//concepts/.md` in any other; strut's `Job` and every other stamp included), every type but Thing and strut's own (`allTypeFiles`), and `strut.yaml` (`WORKSPACE_PACKAGE`, `requires.packages` = every installed package at its digest); a page is `conceptPageFile`'s in both, so the same page is the same bytes in a package and in the history. `buildPackage` (`POST /packages/build`) — the named workflows' closures, each named app's grants and the apps it imports (off its `dist/build.json`), helpers, Concepts (each a new-form page under its node key, `concepts/.md`: every attribute the schema declares, every live parent; a parent outside the package a warning unless strut's or a required package's), step claims and `requires`, into the cache; an app as its version's sources, checked against its version hash; what the closure reaches that strut does not seed on every server — a host's seed, a service's item — is `leftToInstaller` on the answer and a warning (strut's own seeds, read off `src/workflows/`, stay silent); a carried workflow's category as a top-level `category:` line in its YAML, which the install sets (not in the strict `strut.yaml`, so an older strut still reads the package); `conceptsAlone` carries a page without the pages under it; in every carried page's docs and description each carried workflow name and custom step type is written as packaged (whole tokens; a one-word name only as a whole code span), and one the package does not carry is a warning. `uninstallPackage`: every item still as installed goes (never runs), untouched Concepts retired, forks, edited pages and helpers a forked step may import kept and listed — and a page with a page under it that stays (this strut's own under the package's root, an edited one), with every page above it (`heldPages`, install.ts; a release that drops pages keeps them the same way). installed.ts: the FOLD — `listInstalledPackages` from the workflow, step and app version records (`source: "install"` + `upstream`, under the package's prefix) and the `pkg/` Concept stamps, `modified` = a fork (or a page's text against its file in the cache); no store. fence.ts: `fenceStep` — a step whose active (or pinned) version carries an `upstream` reads only its package's `secrets` (`secret_fenced:` otherwise), wrapped by `buildRegistry({ fences })` from `workspaceFences` (`StepListEntry.upstream`) and by `resolveStep` for a pin, so every way a step runs is fenced. `GET /packages`, `POST /packages/install { ref, digest?, url?, index?, wait? }` (`wait: false`: 202 with the install's record at once), `GET /packages/installs[/:id]` (progress.ts: every install as it goes, whichever door — the route, a proposal's apply, the boot list, a subscription — its phase and `done` / `total`, "Concepts 312 / 752"; in memory, an hour after it ends; the Packages tab's Find polls it) (a bare `publisher/name` ref resolves through the indexes this strut follows, below), `DELETE /packages/:publisher/:name` (createStrut, one write at a time). indexes.ts: the indexes this strut FOLLOWS (§7, step 7's client half) — `{ id, key, urls, label? }` in `/packages/indexes.json` (public data, plain JSON), put on file at boot from `STRUT_PACKAGE_INDEXES` (else `DEFAULT_PACKAGE_INDEXES`) and `createStrut({ packageIndexes })`; `resolveName` reads each card through `fetchCatalog(…, { kind: "index" })` (the per-key `seq` rule), first card listing the prefix wins, and the install goes on as `pk:/` with the row's `urls` tried in order — so the key the SIGNED card named is what is pinned; `search` reads each index strut's derived `/index/packages` and every peer's `GET /packages` (never installed as it came: a hit installs by its name); `get(name, { index? })` one package's page from the first followed index whose card lists its publisher (a miss over memoized cards re-reads them once), `concepts(q)` / `concept(name)` the Concept tree across the followed indexes (one entry per name, the first index's) and one page — the derived pages, read through the index's open routes. Routes `GET`/`PUT`/`DELETE /packages/indexes[/:id]`, `GET /packages/search`; `ctx.services.packages` (`PackagesCapability`: the four reads, and `install`, package/install's door behind an ACCEPTED proposal — never a read that installs) for the `package/*` steps (steps/lib/package/: search, get, concepts; install asks), which the builder chat gets as `package_search` / `package_get` / `package_concepts` and the `builder`, `job` and `orchestrator` seeds grant, and for the assistant door's `search_packages` / `get_package` (ai/assistant-tools.ts). `briefPackagePage` is the page as a MODEL reads it (README cut at `README_BRIEF_CHARS`, Concept docs left out) and `INSTALL_IS_A_PERSONS_ACT` the sentence every agent-facing door ends with. card.ts: the catalog CARD (§13) — RFC 8785 JSON (`canonicalJson`; integers within ±(2^53 − 1) only, a float is `catalog_invalid`), the z-base-32 key, `sealCard` (Ed25519 over `strut-catalog\n` + the fingerprint, the sha256 of the card without `sig`) and `readCard`, a reader's checks in order: one byte form, the EXPECTED key, `v: 1`, the seal, `seq` against the last accepted (`catalog_rollback` / `catalog_conflict`); `releaseFor` (a channel's release, else the newest not yanked); `card.test.ts` holds the test vector. publisher.ts: this strut as a PUBLISHER (§13, §12.1) — TWO Ed25519 seeds in an encrypted file of its own, `publisher.json` beside `peers.json` (minting refused on a file store with `STRUT_SECRET_KEY` unset): the FLEET key, minted first, seals the fleet catalog under `/packages/catalogs/` (`sha256-.json` + `current`) — the whole picture, every release and channel, what a subscription follows; the PUBLIC key, minted at the first **Make public** (`makePublic(channel)`, the record's `publicChannels`), seals the public catalog under `catalogs/public/` — `projectPublic`: the public channels and every release one holds or has held, carried forward (what was published stays published) — since a card's `seq` rule is per key. The fleet card names the public key (`public`); the public card never names the fleet key. `publishRelease` (a release newest first, the channel moved, `public: true` marking it first) and `moveChannel` (`public: false` on a public channel refused, `catalog_channel_public`; a release going public that requires a package no public channel holds refused, `catalog_private_requirement`, before anything is written). `host: self`: a release lists no `urls`, its archive is the route beside the catalog. No Pkarr record yet. `GET`/`POST /publisher`, `POST /packages/publish` (build + release), `GET /packages/catalog[?digest=]` (`?public=1`: the public one, no credential — `publicPackageRead`, auth.ts, and the archive route serves a public digest the same way; a host's gate hands such a read through like `?t=`), `PUT /packages/channels/:channel`, `POST /publisher/tokens` (a subscriber's read token, auth.ts). index.ts: the package INDEX role (§7, step 7; `createStrut({ index })`, `STRUT_INDEX=1`) — the index key (`INDEX` in `publisher.json`, minted at the first row), the rows (`setRow` / `removeRow`: prefix → `{ key, urls, description?, url? }`, `indexRowProblem`) sealed as an INDEX card (`card.ts`: `kind: "index"`, domain `strut-index\n`, `publishers` where a catalog has `packages`, `urls` where this index is served) under `/packages/catalogs/index/`, the CRAWL (each row's public catalog through pk.ts's `fetchCatalog` from the row's `urls` in order, the accepted card and state under `catalogs/index/crawl/`; at the row, on the publisher's ping — `POST /packages/subscriptions/sync { key }` with the PUBLIC key, which a publish or channel move sends to every peer when the public catalog changed, `pingPublic` — and on the subscriptions' poll), `search` (`src/search.ts` over package name + description + publisher; each package at its newest release not yanked, with the row's key and urls — what a `pk:` install needs — and `detail`, the release's archive as read) and `package` (releases, public channels, `readAt`, `detail` / `detailError`); the DERIVED PAGES (the store front, 2026-10-29): after a catalog is accepted, each package's newest release not yanked is fetched by digest (`fetchArchive` over `releaseUrls`, into the index's cache — the mirror §7 allows) and read by detail.ts into `crawl/pages//.json`, kept by digest (read once; a fetch that fails is recorded and tried again next crawl, never the catalog's failure), `derived` on the crawl state; `concepts(q)` / `concept(name)` fold every page's Concept pages into one tree (the first package by name gives description, parents and docs; every package is listed; children — a page under each of its parents — and the ancestors along each one's first parent resolved across packages; a page derived before the new form kept one `parent`, read as its parents). Routes: `GET /index` (any credential), `PUT`/`DELETE /index/:prefix`, `PUT`/`DELETE /index/live/:prefix` (a LIVE row, plans/public-surface.md §8: where a publisher server's public view is served — its own `live` map on the card, beside `publishers`, so an older reader reads the card as before — read at the row and every crawl into `crawl/live/.json`; its trees folded into `/index/concepts`; and what anyone may RUN there with its price, plans/paid-runs.md §10: the listing's `runnable`, each item checked as a stranger's (`liveRunnableOf`: a kind, a name, prices that are finite numbers ≥ 0, strings cut, at most `LIVE_RUNNABLE_MAX`; never its input, which the store reads from the publisher), carried on `GET /index/live` as an item's `run` — beside the public item, or an item of its own with `definition: false` when only runnable), `POST /index/crawl` (full scope); `GET /index/catalog`, `GET /index/packages[?q=]`, `GET /index/packages/:publisher/:name`, `GET /index/concepts[?q=]`, `GET /index/concepts/:name`, `GET /index/live[?q=]` OPEN to anyone (the gate's `INDEX_READ`, only with the role on), and so are `GET /llms.txt` (index-tools.ts `llmsTxt`, with src/public-agents.ts `runnableSection` appended when this strut also sells; without the role the route is the public view's page, open the same way) and ONE POST, `/index/mcp` (`INDEX_MCP`); with the UI served, `GET /` with no credential is a 302 to `./?store=1`, the store front (web/src/components/Store.tsx). Derived and unsigned past the card: an install re-verifies against the publisher. index-tools.ts: the store for an AGENT (2026-10-29) — `buildIndexTools(index, { store? })`, three READ tools over the index's own functions, served to anyone on `POST /index/mcp` through `toolsetServer` / `createMcpDoor` (ai/mcp.ts; part of the role, whatever `enableMcp` says of the keyed doors): `search_packages { query?, limit? }` (the hits with `detail`), `get_package { package }` (the page as a model reads it, `briefPackagePage`), `get_concept { name?, query? }` (the tree, or one page) — each with its `store` link when the index serves the UI and `install`, the sentence that a PERSON installs and where; `indexInstructions` says what the index is, that everything is derived and an install re-verifies against the publisher, and how a person installs — so any MCP-capable agent adds the store with one line (`claude mcp add --transport http strut-store https:///index/mcp`); and `llmsTxt` — `GET /llms.txt` at the index's root (llmstxt.org), the same store as ONE markdown page for an agent that fetches URLs: the blockquote (what the index is, derived, a person installs), the `claude mcp add` line, `## Reads` (the open routes), `## Packages` (every listed package with what its archive holds), `## Concepts` (every page), `## Optional` (the store page) — each entry a link to its JSON page here; the Store page's footer links it. listing.ts / index-requests.ts / publisher-listings.ts: SELF-SERVE LISTING (plans/index-listing.md, BUILT 2026-11-10) — a publisher asks an index for its name with a LISTING request, a third kind of card (`card.ts`: `kind: "listing"`, domain `strut-listing\n`, `sealVerifies`), sealed by its PUBLIC key: `{ v, index, prefix, key, base?, description?, url?, remove?, live?, seq, sig }`, the `base` an origin and at most one path segment, never a catalog URL — the index reads only `/packages/catalog?public=1`, so a token-bearing file on a trusted host never passes as its catalog, and it reads that URL alone (`fetchCatalog`'s `redirects: false`: a redirect is refused, since the host is a stranger's to choose; the body read no further than 4 MB, `readCapped`) (`listing.test.ts` holds a test vector). The index's DESK (`POST /index/requests`, open, 10 a minute an address) checks it cheapest first (form, its own key, `seq` fresh and increasing per key, seal, reserved, refused — a refusal is of what waited, never of a removal or the row's own key changing its row on the same host), reads the catalog, then lists at once — the row's own key on the same host, or a new name from an `autoHosts` host by a key and host with no row — or keeps it in `/requests/pending/` with its reason (`host`, `second name`, `same host`, `move`, `takeover`, `live row` — a name the operator lists as a live row with no key is held too) for the operator's `GET /index/requests`, `…/:id/approve`, `…/:id/refuse { reason }` (30 days), every outcome in `requests/log.jsonl`; `POST /index/ping { key }` (open) reads the rows naming a key at most once a minute. The publisher's side: `POST /publisher/listings { index, base, … }` seals and sends to the followed index strut beside its card, the answers in `/packages/listings.json`, `GET /publisher/listings` reads "listed" off the index's signed card, `pingPublic` pings every index asked and not withdrawn from (a failed or refused change leaves the row listed). detail.ts: `derivePackageDetail(cached, { stepInfo })` — what a release HOLDS, read off its directory with nothing loaded or run (the store's package page): the apps' manifests (title, icon, grants, `can` from the workflows they grant), each workflow's YAML (`description:`, the `input:` block, the closure's types with a package subflow folded in package-first, `agentTools` — `{{ params.X }}` read off the params' defaults — the `claims:` count, `peer` / `sealed`, the secrets `secretNamesIn` finds plus a lib step's one declared secret and the package steps'), each step's source by regex (`description`, `effect`, declared `secrets`, every `secrets.get("NAME")`) with `lintStepSource`'s findings, the Concept pages (`parseConceptFile`), the root README (capped); `can: { spends (agent / llm / acp / graph/walk), acts (a step declaring effect: write — this strut's registry through `stepInfo`, or the package step's source), code (exec or a package step) }`; `summarizeDetail` is the hit's short form. A READING, never a check — the install re-reads everything. subscriptions.ts: this strut FOLLOWING another's catalog (§12.1) — `{ key, url, channel?, token? }` in an encrypted file of its own, `subscriptions.json` (the operator's full-scope `PUT /packages/subscriptions/:id`), its state beside the cache (`/packages/subscriptions/`: the catalog accepted, the release applied); a SYNC (at the `PUT`, at boot, every `STRUT_DISTRO_POLL_MINUTES`, on the publisher's ping `POST /packages/subscriptions/sync`, which a PEER may send) checks the catalog against the key and the last `seq`, fetches every archive the channel holds by digest, installs the set as ONE install through the distro's door (`installPackages`), source ref `subscription:`, and uninstalls what the channel dropped; `GET /packages/subscriptions` is the fleet's view (applied release, `diverged` items). A publisher pings every peer on file after a publish or a channel move. serve.ts: SERVE MODE (plans/app-repos.md §2.1, §4; work item 1) — a strut started with `STRUT_SERVE=` (`createStrut({ serve: { ref, url?, tokenSecret? } })`; a name, `pk:` with `STRUT_SERVE_URL`, `git+` with `STRUT_SERVE_TOKEN_SECRET` (work item 2), or `file:`) is the box one package's app is served from. `serve.yaml`, a top-level file of the package (in the digest, read past by `readPackage`), strict: `app` (the app `/` shows), `title?`, `description?`, `image?` (an image file in the package, for `og:image`), `reads?`, `runnable?`, `concepts?` — `signIn` / `visitorKey` / `limits` refused as not here yet; `parseServeFile`, then `planServe` resolves its names package-first against what the package holds (`app` must be one of its apps) into the public view record, checked by `surfaceProblem`. A DEPLOY (`createServe` → `sync`, one at a time): the ref into the cache, `serve.yaml` read and planned (a bad one stops here, nothing written), the install through the distro's door, `runnableProblems` (via createStrut, which mints `L402_ROOT` for a priced item), the app HELD at the version just installed (`apps.promote`), the record written whole; a deploy that cannot resolve, install or apply keeps the previous one serving and says why. State in `/serve.json` (what is serving, its page — title, description, the app's lucide icon as an SVG read off the kit, `lucideIconSvg` — the last read, the last error), one log line per deploy. A `git+` ref FOLLOWS ITS BRANCH (§4.3): fetched every `STRUT_SERVE_POLL_MINUTES` (5; `createStrut({ servePoll })`) and on `POST /serve/sync` — a push webhook, open to anyone on a served box, at most one fetch a minute (`ping`: a ping inside the minute is one fetch when it is up, `pingAt` on `GET /serve`) — a commit still serving answered from the cache (`known`), a moved commit whose digest did not move deploying nothing (`serving.commit` follows it), a moved digest deploying, the install handed the commit (`InstallOptions.commit`) for `upstream`. Any other ref is followed as a subscription is: the subscriptions' poll and the publisher's ping (`follows(key)`: a `pk:` ref's key, a name's once resolved; a `file:` ref only on a ping with no key) read the ref again, a moved digest deploys. `servedHtml`: the UI's `index.html` with `` (empty before the first deploy), the title, the favicon and the `og:` tags — every page createStrut serves on a served box but `?shell=1` with the key; `GET /serve` (full scope) is the state, `GET /serve/image` (open) the image; `PUT /public-view` is `409 serve_owned:`; `strut/jamie` and `strut/knowledge` are not seeded. A build carries one: `BuildRequest.serve` (`POST /packages/build` / `publish { serve }`), the fields written as the package's `serve.yaml` and checked against what the build carries │ ├── payments/ # PAID RUNS (plans/paid-runs.md; item 1, the LEDGER, item 2, the RECORD (src/public-runnable.ts), and item 4, the WALLET AND THE RATE, built — and item 3, FREE PUBLIC RUNS, which writes a charge of nothing per run (src/public-runs.ts), and item 5, PRICED WORKFLOWS: the credit token (src/l402.ts, src/public-credit.ts), the hold at a launch and the charge against it at the run's end (src/public-runs.ts), and item 7, EARNINGS AND WITHDRAW, below). ledger.ts: the pricing rule, PURE and the one copy items 3, 5 and 6 share — `chargeFor(price, cost, outcome, reserve?)` (base + markup × cost; a run that failed its cost, no markup, no base, never past its reserve), `reserveFor(price, cap)` (base + markup × cap, rounded as a charge is, so a run within its cap never passes it), `priceOf({ base, markup })` (the record's dollars), money as whole MICRO-dollars (`usdToMicros`); the `LedgerStore` interface and `openLedger({ dataDir })`, which imports `node:sqlite` on first use, never at boot. ledger-sqlite.ts: `/payments/ledger.db`, owner-only folder, WAL, `synchronous=normal` for holds and charges and `full` for a payment or a withdrawal, every write one `BEGIN IMMEDIATE` (two connections on one file never overdraw), `close()` folds the WAL. Three tables: `tokens` (`paid` credit in — payment and inherited — `spent` credit out — charges, carried on, expired — `held`, `rate`, `msat`, `expires`, `closed`, `successor`), `entries` (append-only by trigger: `payment`, `hold`, `charge`, `carry` — signed, − where it left — `expiry`, `withdrawal`; plus `outcome` on a charge, `fee_msat` and `actor` on a withdrawal), `daily` (day × subject: calls, failed, earned, cost; every earnings read sums it). Writes: `firstUse` (the token and its payment, credited in proportion to what SETTLED, capped at the quote; inherits a predecessor's free credit, `carry` on each), `hold` (the guarded `update … where paid - spent - held >= ?`; the same token, subject and key return that hold while its run is not charged — a key dedupes a launch in flight, never one that ended or never ran, as a free run's does — and the same run id its hold), `charge` (frees the hold, charges by the rule and never more than the hold's reserve — an overshoot is the seller's, never the buyer's other credit — upserts `daily`; credit it frees on a carried token goes to the live end of its successors, on a closed one expires; with no token, a free run's charge of nothing), `sweep` (closes tokens past their year, what was left earned under `expired`), `withdrawal` (every send OUT through the payer, ONE per payment hash, a unique partial index: the same hash again writes nothing and answers the first entry; its `purpose` — `withdrawal`, or `purchase` for item 9's bought credit — kept in the entry's `subject`, and `withdrawn` rows grouped by day and purpose). Reads: `token`, `entries` (a token's newest 50), `list { kind?, runId?, before?, limit? }` (entries newest first: the Earnings tab's lists, a run's hold and charge), `withdrawalOf(paymentHash)`, `findHold`, `chargeOf(runId, subject)` (a run's charge: the launcher's view), `openHolds` (for the boot reconcile), `earnings { by: item | day | package | total, prefix?, subjects?, from?, to? }` (`subjects`: a package published here, its items' bare names), `received` / `withdrawn` by day (partial indexes), `outstanding`, `recent` (a subject's 30 days), `today` (the daily cap reads `cost`), `scan` (audits only). ledger-memory.ts: the twin; ledger.test.ts runs one suite over both. `scripts/ledger-bench.ts`: the opt-in benchmark. createStrut opens it on first use (`strut.ledger()`: memory where the run store is; closed by `close()`), so a strut that sells and gives nothing never makes the file; item 2 reads its `recent` for `GET /public`, item 3 its `today` for the daily cap and `charge` at a public run's end, item 5 `firstUse`, `hold`, `charge` with a token and `chargeOf`; where a ledger file is, the boot reconcile charges its open holds and `sweep`s, and again once a day. Item 4, the WALLET AND THE RATE (§9), beside it: lightning.ts: `LightningCapability` — `makeInvoice { msat, description?, expirySecs? }`, `lookup(payment_hash)` (`pending | settled | expired`, the msat that SETTLED, its preimage), `received { since?, limit? }`, `balance()` — and NOTHING that pays; `lightningCapability(provider)` is what `ctx.services.lightning` is: the four methods, checked, frozen, nothing of the provider reachable through it; `LightningError` with a `code` a caller acts on (`wallet_unavailable` — item 5 answers 503, never 402 — `wallet_unauthorized`, `wallet_refused`, `invoice_unknown`, `wallet_bad_answer`, `bad_request`). sphinx-daemon.ts: `SphinxDaemonProvider` — the TREASURY daemon's URL and its RECEIVE token (a `#private` field), `POST /invoice { sats, description?, expiry? }` → `{ invoice, payment_hash, expires_at }`, `GET /invoice/:payment_hash`, `GET /payments?since=&limit=`, `GET /balance` (specs/LIGHTNING.md; today's daemon has only the first, without its two new fields: the provider names that file when it meets one). nwc.ts: `NwcProvider` — a RECEIVE connection URL (nwc-url.ts parses it), one relay socket per call (the global `WebSocket`): the wallet's info event (kind 13194: its methods, NIP-44 when its `encryption` tag says so, else NIP-04; ten minutes), the request (kind 23194, signed BIP-340 — `@noble/curves`, imported on first use — and encrypted with node:crypto: NIP-44 v2's ChaCha20 + HMAC + HKDF, NIP-04's AES-CBC), the wallet's answer (kind 23195, id and signature checked); `make_invoice`, `lookup_invoice`, `list_transactions`, `get_balance` only; NWC's names held equal to `kit/src/wallet/protocol.ts` by nwc.test.ts, which also checks NIP-44's own test vector. fake-wallet.ts: `FakeWallet` — invoices a test settles on command (in full or SHORT, §4.3), expires, lists, and takes down (`wallet_unavailable`), its preimages real; `bolt11: true` makes them readable BOLT11 (zero-signed), for a buyer's payer to pay (src/remote.test.ts); for this item's tests and the later items'. settings.ts: `payments.json` (`PAYMENTS_FILE`), an encrypted `FileSecretStore` of its own beside `peers.json` — never secrets.json, which every step reads (decision 20) — through `PaymentsSettings` (`wallet()` / `setWallet()`; `l402Root()` / `ensureL402Root()` — `L402_ROOT`, 32 random bytes every credit token's root key is derived from, minted when the first priced item is saved, never replaced, and hidden from steps by name, env.ts `HIDDEN_FROM_STEPS_NAMES`): the wallet's provider, its receive credential, and its PAY credential kept for the payer (payer.ts), never handed to a provider; `walletFromEnv` (`LIGHTNING_DAEMON_URL` + `_TOKEN` + `_PAY_TOKEN`, or `LIGHTNING_NWC_URL` + `_PAY_URL`; half of one or both fails the boot), `checkWalletUpdate` (a person's PUT merged over what is on file), `walletInfo` (what a route shows: provider, receive, pay, the daemon's URL or the relays' hosts — never a credential). rate.ts: the EXCHANGE RATE (decision 26) — a source answering `{ usdPerBtc, at, source }`: mempool.space's `/api/v1/prices` through strut's http capability, `STRUT_RATE_URL`, or a host's `createStrut({ payments: { rate } })`; `createRate` keeps a reading a minute (a burst is one request), serves the last for ten minutes with the source down, then throws `RateUnavailableError` (`rate_unavailable:`), no fixed fallback; `satsForUsd` (rounded up) / `usdForMsat`. wallet.ts: `createPayments` → `strut.payments` — `settings`, `rate`, `lightning()` (what the bag holds now), `wallet()`, `setWallet()` (the bag follows at once), `payer()` / `canPay()` (below) — writing the environment's wallet at boot; `receiveProvider` builds a provider from the receive credential alone. wallet-routes.ts: `GET`/`PUT /earnings/wallet`, FULL SCOPE only (a peer's read refused too), 501 when the host brought its own `services.lightning` (specs/API.md §18). The L402 challenge, which quotes with the rate and records a token's payment in the ledger, is src/public-credit.ts. Item 7, EARNINGS AND WITHDRAW (§8, §9 "The payer"): payer.ts: the PAYER, the one thing in strut that sends sats out — `Payer { provider, id, capsFee, minFeeMsat(msat), pay({ invoice, maxFeeMsat }), status({ invoice, paymentHash, maxFeeMsat, balanceBeforeMsat? }) }` → `{ paymentHash, state: settled | pending | failed, feeMsat (the fee the WALLET was charged), routingFeeMsat? (reported, when not what was charged), preimage? (kept only when it hashes to the payment), error?, balanceBeforeMsat? }`, a refusal before sending a `LightningError` carrying the wallet's own code (`walletCode`); `id` names the wallet (`sphinx:`, `nwc:`), never a credential. `SphinxDaemonPayer`: `POST /pay` with the PAY token as stakwork/sphinx#428 built it (75 s a call, past the daemon's 60 s wait; `pending`, then `status` POSTs the invoice again — idempotent per payment hash, only a failed one paid again; `max_fee_too_low` / `own_invoice` / `expired` / `bad_request` refused before sending); a fresh pay reads `GET /balance` first, since the mixer takes the amount and then the fee only from what is left (none when too little is: mixer/src/send.rs), so the fee booked is the one taken. `NwcPayer`: `pay_invoice` over the pay connection through nwc.ts's `NwcClient` (the request mechanics `NwcProvider` now holds too; no fee cap in NIP-47, `PAYMENT_FAILED` → failed, published with no answer in 60 s → pending, a result with no preimage that proves it → pending, never failed), `status` = `lookup_invoice` (on the receive connection when the pay one does not allow it; no record → pending). `payerFor(credentials)`: null without a pay credential. `strut.payments.payer()` is createStrut's ONE payer (built for the credentials on file, or a host's `createStrut({ payments: { payer } })`, a test's `FakePayer`; null when the host brought its own `services.lightning`) and `canPay()` says whether there is one; NEVER on the services bag, never a step's, and exactly two doors call it — `POST /earnings/withdraw` and an accepted `remote/buy-credit` (item 9). fee.ts: `lspFeeFloorMsat` (the Sphinx LSP's own limit, max(5 sats, 0.5%), what the daemon refuses a cap below) = `defaultMaxFeeMsat`, browser-safe for the dialog. bolt11.ts: a BOLT11 READER, pure and browser-safe (the Withdraw dialog imports it): amount, payment hash, description, expiry, network — bech32 checksum checked, the signature NOT (the wallet checks it when it pays); tested on BOLT11's own vectors; `encodeBolt11` writes a zero-signed one for tests and stand-ins. withdrawals.ts: `createWithdrawals` → `withdraw({ invoice, msat, maxFeeMsat?, actor?, purpose? })` — `msat` must be what the invoice asks (`amount_mismatch`), no amount or expired refused, a payment the books already hold answered `existing` with nothing sent; ONE pay or read per payment hash in flight, which a second caller (a double click, Ask again, the minute's check) JOINS, a caller answered within 25 s (`ANSWER_WITHIN_MS`: `pending`, `reading: true`, the read goes on and records); on file BEFORE it is sent (`/payments/withdrawals.json`, memory where the run store is) with the payer it went through, and read again ONLY through that payer (`other_wallet` otherwise); recorded as ONE `withdrawal` (who, amount, fee taken, hash, purpose, the dollars at the rate) when it settles, dropped when it fails or is refused before it was ever answered, kept on anything else — no answer, an unreadable answer, a refused credential; `check()`, every minute (`start()` at boot, never two at once), reads the pending ones again; a daemon payment whose pay call never answered is read unattended only once its invoice expired (the daemon's re-POST would pay one it never saw); past its invoice's expiry and an hour (`STALE_AFTER_EXPIRY_MS`) one is STALE — the mixer keeps the debit of a payment the LSP never answered and never refunds it, so the daemon says `pending` forever — and no longer polled: `read(hash)` reads it once more, `resolve(hash, { action: paid | drop, feeMsat? })` records it or forgets it. `purpose: "purchase"` is item 9's: an accepted `remote/buy-credit` (src/remote.ts) sends through the same service and its sends land in the books apart. `WithdrawError { status, code, walletCode? }`. earnings.ts: `readEarnings(deps, { from?, to?, by: item | day | package | total, prefix?, package?, wallet? })` — earned, cost, margin (dollars), calls, failed, outstanding, received, withdrawn, purchases and the sends still out, from the rollups alone; by item every runnable item has a row and its price; `package` is one package's items: published here, the subjects its newest release holds by their bare local names (createStrut's `packageSubjects`, over the fleet catalog and the package cache), else its prefix (installed); `wallet` adds the balance in sats and at the rate, `expectedMsat` (received − every send and its fee − what is still out, all time), `pendingMsat` and `differenceMsat`; `checkEarningsQuery`, `daysBack`. earnings-routes.ts: `GET /earnings`, `GET /earnings/entries`, `POST /earnings/withdraw`, `POST /earnings/withdrawals/:paymentHash/read` and `…/resolve`, FULL SCOPE only, reads included (specs/API.md §19); a strut that never sold anything reads zeros and makes no ledger file (`ledgerIfAny`, createStrut). `ctx.services.earnings` is `{ read }` — reads only — what `earnings/read` is thin over. │ ├── steps/ │ │ ├── core/ # 11 built-in steps: http, exec, log, if, loop, foreach, subflow, llm, agent, wait, pack (static import) @@ -261,6 +262,8 @@ strut/ ├── automation-form.ts # the Automations editor's flat form state ⇄ trigger draft (pure; no calendar math — the server owns that) ├── earnings.ts # Earnings' pure parts (plans/paid-runs.md §8; tested): fmtUsd / fmtSats, subjectLabel, the ranges, priceLine, withdrawBlocked (why Withdraw is off), maxWithdrawMsat, readWithdrawInvoice (the invoice read with src/payments/bolt11.ts and its fee cap from fee.ts — what the person confirms is what the route checks; the network left to the wallet), withdrawRefusal (a refusal said for a person) ├── store-links.ts # the STORE FRONT's pure parts (plans/marketplace.md §7; tested): parseStoreView / storeQuery (what `?store=1&pkg=|concept=|q=` names), the DEEP LINK out of the store — `installLink(strut, { pkg, index: { key, url } })` → `/?install=&index=&index_url=`, `parseInstallLink` (the shell's side), `indexIdOf` (the id a strut files a followed index under: its host), `normalizeStrutUrl` — and the folds the page draws from the open reads: `appsOf` (the hits' apps first, by title), `conceptRoots` (the tree's roots with how much sits under each), `canWords` + ├── store-run.ts # the store's RUN FORM, its pure parts (plans/paid-runs.md §10 "The store"; tested): `priceWords` / `priceBadge` / `priceNotes` (free, fixed, metered, both; the reserve; what recent runs were charged), `runFields` (a workflow's `input:` block as the Run popover draws it — run-inputs.ts `bindingsFromInputBlock` — or a step's input JSON Schema), `inputOf` (the values as an input: empty left out, numbers and JSON read, a required one asked for), `httpCallResult` (a publisher's HTTP answer in the shape `callPaying` reads: 2xx its body, 402 `{ payment_required }`, else an error with its code and `Retry-After`), `newLaunchKey` (one per press), the runs a tab launched (`loadRuns` / `rememberRun`, `RUNS_KEPT`), `runFileUrl` (a run's file on its publisher, the run token as `?t=`), the sessionStorage keys per publisher + ├── store-purse.ts # what the store page holds per publisher's server (browser glue over ./visitor-pay and ./store-run): `storePurse(base)` — the purse (credential, spares, invoices shown) and the runs launched there in this page's `sessionStorage` (`strut.store.purse:`, `strut.store.runs:`), one claim keeper per server resumed at load (`api.liveClaim`), one sheet at a time (`payInTurn`); `payWith` — show a challenge's sheet with its invoice claimed from that moment, a top-up replacing the credential it carries from ├── package-release.ts # the Packages tab's pure parts (plans/packages-ux.md): pickerGroups (the workflows a package may take, by category — an installed package's, a seed's and a service's left out), sidebarGroups / nameInGroup / installedTitle (the sidebar's groups: an installed package's workflows a group of its own, drawn from `GET /packages` and never written — its segment as the label, its version beside it, each workflow by its name inside the package), comesAlong (what a dry build carries beyond the picks, the picks it leaves to the host, and `needs` — its `leftToInstaller`, shown once as "Needs on the installing strut"), picksFromRelease (what a release held, as the names here; a Concept the release carried alone, `alone` on its items, picked alone again), packageLabel, nextVersion (the last number up by one), PACKAGE_NAME, PACKAGE_SEGMENT (tested) ├── icons.tsx # inline SVG icons ├── storage.ts # crash-safe localStorage wrapper (UI prefs, session state) @@ -271,7 +274,7 @@ strut/ ├── walk-graph.ts # foldWalk: graph_walk's progress outputs → nodes/edges/current/next — the kit's `foldWalkProgress` (kit/src/folds/walk/graph.ts) under the names the chat uses; tested against the real walk ├── elicitation.ts # the builder's open question (meta.elicitation, ACP's flat schema) → FieldDesc[] for ConfigField + form helpers (pure; tested) ├── embed.ts # deep links when iframed (Hive): replaceUrl() posts ?wf/run/v/chat/job/app/input to the host named by ?embed_origin - ├── visitor-pay.ts # a VISITOR's page paid for by its HOST (plans/paid-runs.md §10, specs/API.md §17.6), pure and tested: the PURSE (`VisitorPurse`: the visit, the credential sent, `spare` credentials paid for and not sent, `pending` invoices shown and not settled; `loadPurse` / `savePurse` over a Storage, `creditSettled`), `newVisit` (the tab's visit secret, sent as `x-strut-visit`), `visitorHeaders`, `createClaimKeeper` (every invoice shown claimed until it settles or ends — `CLAIM_GRACE_MS` past it — whether or not its sheet is open, a settled one into the purse; `resume` after a reload), `keptCreditResult` (a paid launch the server then refused: the error, and that the payment is kept), `launchOf` / `paymentRequiredIn` / `refusesCredential`, `callPaying` (the page's `tools/call` as it is; a `run/launch` answered `payment_required` first sent again with a spare, else handed to the host's payer and sent again with the credential the purse then holds — `Authorization: L402 :` on the request, never in the call; cancelled → a plain `payment_cancelled` error; a refused credential dropped), `payInTurn` (one sheet at a time: a second priced launch waits, then goes again with what the first bought), `waitForClaim` / `claimAnswerOf` (`POST /public/credit/claim` until settled or expired), the sheet's words (`formatUsd`, `formatSats`, `creditLine`, `invoiceQrText`, `secondsLeft`) + ├── visitor-pay.ts # a VISITOR's page paid for by its HOST (plans/paid-runs.md §10, specs/API.md §17.6), pure and tested: the PURSE (`VisitorPurse`: the visit, the credential sent, `spare` credentials paid for and not sent, `pending` invoices shown and not settled; `loadPurse` / `savePurse` over a Storage, `creditSettled`), `newVisit` (the tab's visit secret, sent as `x-strut-visit`), `visitorHeaders`, `createClaimKeeper` (every invoice shown claimed until it settles or ends — `CLAIM_GRACE_MS` past it — whether or not its sheet is open, a settled one into the purse; `resume` after a reload), `keptCreditResult` (a paid launch the server then refused: the error, and that the payment is kept), `launchOf` / `paymentRequiredIn` / `refusesCredential`, `callPaying` (the page's `tools/call` as it is — or, with `launch: { workflow }`, any call the caller says is a launch: the store's Run form over plain HTTP; a `run/launch` answered `payment_required` first sent again with a spare, else handed to the host's payer and sent again with the credential the purse then holds — `Authorization: L402 :` on the request, never in the call; cancelled → a plain `payment_cancelled` error; a credential the DOOR refused dropped and the call sent once more — `refusesCredential`: a result's `code: invalid_token` with `status: 401`, else an error that BEGINS `invalid_token:`; never a step's failure whose text mentions it, which keeps the credential and is never sent again), `payInTurn` (one sheet at a time: a second priced launch waits, then goes again with what the first bought), `waitForClaim` / `claimAnswerOf` (`POST /public/credit/claim` until settled or expired), the sheet's words (`formatUsd`, `formatSats`, `creditLine`, `invoiceQrText`, `secondsLeft`) ├── app-host.ts # strut's UI as an MCP Apps HOST (plans/apps.md §7.3): ext-apps' `AppBridge` over a `PostMessageTransport` on the frame's window, wired to the MCP SDK's `Client` + `StreamableHTTPClientTransport` at `/mcp` with the UI's credential (`apiHeaders`: the session cookie or the key, `x-strut-ui: 1`) — the bridge relays the page's `tools/call` by itself, as it is. `ui/message` → the builder chat (`appMessage` names the app), `ui/open-link` → for a strut deep link (`strutLink`: same origin and mount, `?wf/run/job/app/input/artifact`) the host's `onNavigate` — which in the canvas area and alone opens a served file or another app in the SIDE PANEL (`panelTarget`, components/AppWithPanel.tsx, plans/jamie-app.md §4) — and a new tab otherwise, `?input=` → `ui/notifications/tool-input` after the page's `initialized` and again in place when it changes (`appToolInput`; a link to the page on screen resends it, `namesApp`; a peer's page's `?app=` is `@/` and its `?artifact=` `/peers/`, `peerAppLink`), a theme change → `host-context-changed`. Nothing hand-rolled. app-links.ts holds the pure parts (`strutLink`, `appMessage`, the app link helpers, `panelTarget` — which links open in the side panel) and their test (app-host.test.ts): the root test run installs no web/node_modules, so a web test may import nothing that needs them For an app it shows to a signed-in viewer (not a visitor's page) it also CARRIES CHANNELS (plans/app-channels.md §3; a peer's app's ticket names the peer, and the strut relays the socket there, §4): `experimental["strut/channels"]` in `ui/initialize`, and the kit's `relayChannels` with a ticket from `POST /apps/:name/channel-ticket` and a `WebSocket` to `/channels` (`api.channelTicket`, `api.channelSocketUrl`), closed cleanly with the frame. For a VISITOR's page the relay names the visit and pays (plans/paid-runs.md §10): one purse per tab in this page's `sessionStorage` (`strut.visitor:`, never a frame's), its headers on every `/public/mcp` request (the transport's `fetch`); one `createClaimKeeper` that claims every invoice shown until it settles or ends, sheet or no sheet; once connected, `bridge.oncalltool` is `callPaying` over the client, its payer the host's `onPayment` (AppHost's sheet, handed the keeper's promise), and while a launch waits on it `notifications/progress` for the page's `progressToken` every 15 s, so the frame's 60 s request timeout never fires. ├── use-artifact-url.ts # useFileScope: a served path's SCOPE listing (`GET /artifacts/:runId` / `/jobs/:id/files` / `/apps/:name/:version/files` → its files + file token), one fetch per scope for the page's life (`api.fileScope`); useArtifactUrl: the tokened URL over it; useFileBlob: a served file's bytes as an object URL, read with the UI's credential (`api.fetchArtifactBlob`) — how a PEER's file shows, since no token minted here opens the read-through and the key never rides in a `src` ├── components/ @@ -282,7 +285,7 @@ strut/ │ ├── AppHost.tsx # an app's page, hosted (plans/apps.md §7.3): the version's files listed (`useFileScope` mints the token), `dist/index.html` fetched under it (the server writes the app's identity into it), the TEXT framed as the srcdoc of `