From c5374687f196c4b8f0d056f9395cbbecbdd1c9f6 Mon Sep 17 00:00:00 2001 From: Evanfeenstra Date: Tue, 10 Nov 2026 19:37:36 -0800 Subject: [PATCH 1/3] Paid runs 8: the agent's tools on /public/mcp, llms.txt for a public view, prices in the index's live items list_public, run_workflow, call_tool, get_run and get_credit beside a visitor page's step_run, over the same doors as the HTTP routes (PublicDoors gains call, run, credit, claim, runnable, listing). An unpaid priced call is a payment_required result; payment or the connection's L402 header pays; a macaroon alone claims. A launch carries a key (the agent's, else one from the workflow and input) so a retry is the same run while in flight. Long runs answer a handle get_run waits on, with progress. GET /llms.txt is open: the index's page (with a run section when it sells), else the public view's. The index's live crawl keeps each server's runnable items, checked, and GET /index/live carries their prices. --- src/createStrut.test.ts | 6 +- src/createStrut.ts | 46 +++- src/packages/index.test.ts | 53 ++++- src/packages/index.ts | 102 +++++++- src/public-agents.test.ts | 398 +++++++++++++++++++++++++++++++ src/public-agents.ts | 471 +++++++++++++++++++++++++++++++++++++ src/public-routes.ts | 199 +++++++++++----- 7 files changed, 1197 insertions(+), 78 deletions(-) create mode 100644 src/public-agents.test.ts create mode 100644 src/public-agents.ts diff --git a/src/createStrut.test.ts b/src/createStrut.test.ts index 50dc7c77..4ef7ddc5 100644 --- a/src/createStrut.test.ts +++ b/src/createStrut.test.ts @@ -1400,7 +1400,8 @@ describe("the API key gate", () => { )].filter((r) => r !== "GET /health" && r !== "POST /auth/session" && r !== "DELETE /auth/session"); // `GET /public/*` is open by design (src/public-routes.ts): it serves only // what a person made public, and with nothing public it serves nothing. - const publicReads = routes.filter((r) => /^GET \/public(?:\/|$)/.test(r)); + // So is `GET /llms.txt` (plans/paid-runs.md §10): the public view as a page. + const publicReads = routes.filter((r) => /^GET \/public(?:\/|$)/.test(r) || r === "GET /llms.txt"); // A visitor's page calls its app here (plans/public-surface.md §5): open, // and with nothing in the public view it opens no app. And anyone's // launch and the launcher's cancel (plans/paid-runs.md §3): open, by @@ -1427,6 +1428,7 @@ describe("the API key gate", () => { assert.equal((await strut.app.request(path, { method, headers: { authorization: "Bearer nope" } })).status, 401, route); } assert.ok(publicReads.length >= 15, publicReads.join(", ")); + assert.ok(publicReads.includes("GET /llms.txt"), "llms.txt is open"); for (const route of publicReads) { const path = route.slice(4); const res = await strut.app.request(path); @@ -1742,7 +1744,7 @@ describe("the API key gate", () => { assert.equal(session.role, "member", "no role is a member"); // Open to anyone, a member included, as they are today. const open = (r: string) => - r === "GET /health" || r === "POST /auth/session" || r === "DELETE /auth/session" || /^POST \/public\/(?:mcp|credit|credit\/claim|workflows\/x\/(?:run|runs\/x\/cancel)|steps\/x\/run)$/.test(r) || /^GET \/public(?:\/|$)/.test(r); + r === "GET /health" || r === "POST /auth/session" || r === "DELETE /auth/session" || /^POST \/public\/(?:mcp|credit|credit\/claim|workflows\/x\/(?:run|runs\/x\/cancel)|steps\/x\/run)$/.test(r) || /^GET \/public(?:\/|$)/.test(r) || r === "GET /llms.txt"; const routes = everyRoute(strut).filter((r) => !open(r)); // plans/member-rights.md "What a member's session opens", and the brand // the app is shown in. diff --git a/src/createStrut.ts b/src/createStrut.ts index 1fa04454..9f68fc43 100644 --- a/src/createStrut.ts +++ b/src/createStrut.ts @@ -1048,7 +1048,7 @@ export async function createStrut( * request its seal, the ping nothing (it only makes the index read a * catalog it already lists). Budgeted per address. */ const INDEX_POSTS = new Set(["/index/requests", "/index/ping"]); - const INDEX_READ = /^(?:\/llms\.txt$|\/index\/(?:catalog|packages(?:\/|$)|concepts(?:\/|$)|live$|mcp$))/; + const INDEX_READ = /^\/index\/(?:catalog|packages(?:\/|$)|concepts(?:\/|$)|live$|mcp$)/; /** The index's MCP door (src/packages/index-tools.ts): the one POST the * role opens, the three read tools over the same derived pages. */ const INDEX_MCP = "/index/mcp"; @@ -2039,7 +2039,7 @@ export async function createStrut( // from the surface alone, so open to anyone — reads and a browser's // preflight for them. const open = read - ? path === "/health" || (serveUi && UI_FILE.test(path)) || (!!serveRef && path === SERVE_IMAGE_PATH) || carriesFileToken(c) || !!catalogTokenOf(c) || publicPackageRead(c) || (!!indexConfig && INDEX_READ.test(path)) || PUBLIC_PATH.test(path) || !!channelTicketOf(c) + ? path === "/health" || path === "/llms.txt" || (serveUi && UI_FILE.test(path)) || (!!serveRef && path === SERVE_IMAGE_PATH) || carriesFileToken(c) || !!catalogTokenOf(c) || publicPackageRead(c) || (!!indexConfig && INDEX_READ.test(path)) || PUBLIC_PATH.test(path) || !!channelTicketOf(c) : (path === "/auth/session" && (c.req.method === "POST" || c.req.method === "DELETE")) || (!!indexConfig && (path === INDEX_MCP || INDEX_POSTS.has(path)) && c.req.method === "POST") || (c.req.method === "OPTIONS" && PUBLIC_PATH.test(path)) || (c.req.method === "POST" && PUBLIC_POST.test(path)) || (!!serveRef && c.req.method === "POST" && path === "/serve/sync"); if (open) return next(); if (memberOnly(c)) { @@ -4739,9 +4739,34 @@ export async function createStrut( app.get("/llms.txt", async (c) => { const { llmsTxt } = await import("./packages/index-tools.js"); const publicUrl = publicUrlOf(c); - const text = await llmsTxt(index, { publicUrl, ...(serveUi ? { store: publicUrl } : {}) }); + let text = await llmsTxt(index, { publicUrl, ...(serveUi ? { store: publicUrl } : {}) }); + // An index that also sells (plans/paid-runs.md §10): what anyone may + // run HERE, after the store. + const runnable = await publicDoors.runnable().catch(() => []); + if (runnable.length) { + const { runnableSection } = await import("./public-agents.js"); + text += `\n# Run on this server\n\nAnyone may run these here, with no account, over MCP at ${publicUrl}/public/mcp (list_public, run_workflow, call_tool, get_run, get_credit) or plain HTTP.\n\n${runnableSection(runnable, publicUrl).join("\n")}\n`; + } return c.body(text, 200, { "content-type": "text/markdown; charset=utf-8", "x-content-type-options": "nosniff", "cache-control": "public, no-cache" }); }); + } else { + // The public view as ONE markdown page (llmstxt.org, plans/paid-runs.md + // §10): what anyone may run here and its price, how an agent runs and + // pays for it, what else anyone may read. Open to anyone, like + // `/public`; nothing public, nothing here. + app.get("/llms.txt", async (c) => { + const listing = await publicDoors.listing(); + if (!listing.runnable.length && !listing.workflows.length && !listing.apps.length && !listing.concepts.length && !listing.steps.length) { + return c.json({ error: "nothing is public on this strut server" }, 404); + } + const { publicLlmsTxt } = await import("./public-agents.js"); + return c.body(publicLlmsTxt(listing, { publicUrl: publicUrlOf(c) }), 200, { + "content-type": "text/markdown; charset=utf-8", + "x-content-type-options": "nosniff", + "cache-control": "public, no-cache", + "access-control-allow-origin": "*", + }); + }); } const envPoll = Number(bootEnv()["STRUT_DISTRO_POLL_MINUTES"] ?? 10); @@ -8501,9 +8526,18 @@ export async function createStrut( viewer: `visitor ${who}`, }, }); - const instructions = - "A public app's one tool, step_run, for a visitor's page: reads held to this strut server's public view, and run/launch of a workflow anyone may run that the app grants — a priced one answers { payment_required } until its host pays."; - return createMcpDoor(() => toolsetServer({ tools, instructions, meta: APP_TOOL_META })).handle(c.req.raw); + // An AGENT with no account (plans/paid-runs.md §10, work item 8): the + // five tools beside the page's `step_run` — list_public, run_workflow, + // call_tool, get_run, get_credit — over the SAME doors as the HTTP + // routes (`publicDoors`): the address's budgets, the drain, the caps, + // the price and the key are one code path. A priced one with no + // credit answers `{ payment_required }` as its result; the agent pays + // and calls again with `payment`, or this request's own + // `Authorization: L402`. + const { buildPublicAgentTools, publicMcpInstructions } = await import("./public-agents.js"); + const publicUrl = publicUrlOf(c); + const agentTools = buildPublicAgentTools({ doors: publicDoors, who, authorization: c.req.header("authorization"), publicUrl }); + return createMcpDoor(() => toolsetServer({ tools: { ...agentTools, ...tools }, instructions: publicMcpInstructions(publicUrl), meta: APP_TOOL_META })).handle(c.req.raw); }); /** The builder deps both doors spread: public links, and a run that * outlives the wait window goes on detached, tracked like any launch — diff --git a/src/packages/index.test.ts b/src/packages/index.test.ts index 1f11a1da..c947cc4a 100644 --- a/src/packages/index.test.ts +++ b/src/packages/index.test.ts @@ -171,8 +171,9 @@ describe("the package index", () => { // The MCP door is the role's too: without it the gate answers, and with the key there is no route. assert.equal((await plain.app.request("/index/mcp", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" })).status, 401); assert.equal((await plain.app.request("/index/mcp", { method: "POST", headers: { ...auth, "content-type": "application/json", accept: "application/json, text/event-stream" }, body: "{}" })).status, 404); - // So is llms.txt: the role's page, nothing without it. - assert.equal((await plain.app.request("/llms.txt")).status, 401); + // llms.txt is the role's page, or the public view's (plans/paid-runs.md + // §10): open to anyone, and with neither there is nothing to read. + assert.equal((await plain.app.request("/llms.txt")).status, 404); assert.equal((await plain.app.request("/llms.txt", { headers: auth })).status, 404); } finally { await plain.close(); @@ -354,7 +355,7 @@ describe("the package index", () => { const txt = await ui.app.request("/llms.txt"); assert.equal(txt.status, 200); assert.ok((await txt.text()).includes("- [The store page](http://localhost/?store=1)"), "the store page, for a person"); - assert.equal((await plain.app.request("/llms.txt")).status, 401); + assert.equal((await plain.app.request("/llms.txt")).status, 404, "nothing public there: no page"); } finally { await ui.close(); await plain.close(); @@ -413,6 +414,52 @@ describe("a live row's Concept trees, and a read that fails", () => { assert.equal(kept.apps, 1, "the view read before is kept"); assert.equal((await index.live()).length, 2); }); + + it("carries what anyone may RUN there with its price (plans/paid-runs.md §10): beside a public item, an item of its own when only runnable, a stranger's fields checked", async () => { + const served: Record = { + "https://pub.example/public": { + workflows: [{ name: "cve/scan", description: "Scan a package", runs: true }], + steps: [], + types: [], + apps: [], + concepts: [], + runnable: [ + { kind: "workflow", name: "cve/scan", base: 0, markup: 0, capUsd: null, reserve: 0, results: "output", input: { pkg: { type: "string" } }, recent: { days: 30, runs: 12, average: 0 } }, + { kind: "workflow", name: "voxel/character", description: "A rigged character", base: 0.1, markup: 1.5, capUsd: 5, reserve: 7.6, results: "run", recent: { days: 30, runs: 3, average: 2.25 } }, + { kind: "step", name: "cve/lookup", base: 0.02, markup: 0, capUsd: null, reserve: 0.02, results: "output" }, + // A stranger's answer: what the index cannot show is left out. + { kind: "workflow", name: "bad", base: "free", markup: 0, reserve: 0 }, + { kind: "workflow", name: "negative", base: -1, markup: 0, reserve: 0 }, + { kind: "app", name: "not-runnable", base: 0, markup: 0, reserve: 0 }, + "not an item", + ], + }, + }; + const fakeFetch = (async (input: string | URL | Request) => { + const body = served[String(input)]; + return body === undefined ? new Response("missing", { status: 404 }) : new Response(JSON.stringify(body), { status: 200 }); + }) as typeof fetch; + const index = createPackageIndex({ secrets: new MemorySecretStore(), dir: join(root, "index"), cache: new PackageCache(join(root, "cache")), fetch: fakeFetch }); + const info = await index.setLive("acme", { url: "https://pub.example" }); + assert.equal(info.liveCrawled[0]!.error, undefined); + assert.equal(info.liveCrawled[0]!.runnable, 3); + const items = await index.live(); + const byName = Object.fromEntries(items.map((i) => [`${i.kind}:${i.name}`, i])); + assert.deepEqual(Object.keys(byName).sort(), ["step:cve/lookup", "workflow:cve/scan", "workflow:voxel/character"]); + // Public AND runnable: one item, its price beside it. + assert.deepEqual(byName["workflow:cve/scan"]!.run, { base: 0, markup: 0, capUsd: null, reserve: 0, results: "output", recent: { days: 30, runs: 12, average: 0 } }); + assert.equal(byName["workflow:cve/scan"]!.definition, undefined); + assert.equal(byName["workflow:cve/scan"]!.runs, true); + // Runnable alone: an item of its own, its definition not public, no input kept (the store reads it from the publisher). + const voxel = byName["workflow:voxel/character"]!; + assert.equal(voxel.definition, false); + assert.equal(voxel.description, "A rigged character"); + assert.deepEqual(voxel.run, { base: 0.1, markup: 1.5, capUsd: 5, reserve: 7.6, results: "run", recent: { days: 30, runs: 3, average: 2.25 } }); + assert.equal((voxel.run as Record)["input"], undefined); + assert.deepEqual(byName["step:cve/lookup"]!.run, { base: 0.02, markup: 0, capUsd: null, reserve: 0.02, results: "output" }); + // Searchable by its words like any live item. + assert.deepEqual((await index.live("rigged")).map((i) => i.name), ["voxel/character"]); + }); }); describe("the concept tree across the crawled packages", () => { diff --git a/src/packages/index.ts b/src/packages/index.ts index 847e99d0..e1ceab1b 100644 --- a/src/packages/index.ts +++ b/src/packages/index.ts @@ -61,7 +61,7 @@ export interface LiveState { /** What stopped the last read (the listing kept is the one before). */ error?: string; } -export type LiveStateBrief = Omit & { apps?: number; workflows?: number; steps?: number; concepts?: number }; +export type LiveStateBrief = Omit & { apps?: number; workflows?: number; steps?: number; concepts?: number; runnable?: number }; /** `GET /public`, as the index keeps it (src/public-routes.ts). */ export interface LiveListing { @@ -69,6 +69,71 @@ export interface LiveListing { steps: Array<{ type: string; description?: string; effect?: string }>; concepts: Array<{ name: string; description?: string; pages?: number }>; apps: Array<{ name: string; version: string; title: string; description?: string; icon?: string }>; + /** What anyone may RUN there, and for how much (plans/paid-runs.md §2, + * decision 25), as the crawl read it — absent from a view read before. */ + runnable?: LiveRunnable[]; +} + +/** One item a publisher's server lets anyone run, as the index keeps it: + * its price, the credit a run needs to start, and what recent runs were + * charged — read off a stranger's server, so every field is checked + * (`liveRunnableOf`). The input is read from the publisher when it is run. */ +export interface LiveRunnable { + kind: "workflow" | "step"; + name: string; + description?: string; + /** Dollars a run or call. */ + base: number; + /** Times what the run cost. */ + markup: number; + /** The most one run may spend, in dollars; null when the item sets none. */ + capUsd: number | null; + /** The credit a run needs to start, in dollars. */ + reserve: number; + results: "output" | "run"; + recent?: { days: number; runs: number; average: number }; +} + +/** The most runnable items the index keeps of one server's view. */ +export const LIVE_RUNNABLE_MAX = 500; +const LIVE_TEXT_MAX = 2000; + +/** `GET /public`'s `runnable`, checked: only items with a kind, a + * name and prices that are numbers the index can show; strings cut. A + * server's answer is a stranger's, never trusted as it came. */ +export function liveRunnableOf(raw: unknown): LiveRunnable[] { + if (!Array.isArray(raw)) return []; + const money = (v: unknown): number | undefined => (typeof v === "number" && Number.isFinite(v) && v >= 0 && v < 1e9 ? v : undefined); + const text = (v: unknown, max: number): string | undefined => (typeof v === "string" && v.trim() ? v.slice(0, max) : undefined); + const out: LiveRunnable[] = []; + for (const r of raw.slice(0, LIVE_RUNNABLE_MAX)) { + if (!r || typeof r !== "object") continue; + const o = r as Record; + const kind = o["kind"] === "workflow" || o["kind"] === "step" ? o["kind"] : undefined; + const name = text(o["name"], 300); + const base = money(o["base"] ?? 0); + const markup = money(o["markup"] ?? 0); + const reserve = money(o["reserve"] ?? 0); + const cap = o["capUsd"] === null || o["capUsd"] === undefined ? null : money(o["capUsd"]); + if (!kind || !name || base === undefined || markup === undefined || reserve === undefined || cap === undefined) continue; + const description = text(o["description"], LIVE_TEXT_MAX); + const rec = o["recent"] as Record | undefined; + const days = money(rec?.["days"]); + const runs = money(rec?.["runs"]); + const average = money(rec?.["average"]); + out.push({ + kind, + name, + ...(description ? { description } : {}), + base, + markup, + capUsd: cap, + reserve, + results: o["results"] === "run" ? "run" : "output", + ...(days !== undefined && runs !== undefined && average !== undefined ? { recent: { days, runs: Math.floor(runs), average } } : {}), + }); + } + return out; } /** One item a publisher's public view shows, as the store lists it. */ @@ -84,6 +149,12 @@ export interface IndexLiveItem { version?: string; runs?: boolean; pages?: number; + /** Anyone may run it there (plans/paid-runs.md §10): its price, as the + * crawl read it. */ + run?: Omit; + /** Runnable, and its definition is NOT in the public view: the store reads + * its price and input from the server's `/public` listing alone. */ + definition?: false; } export interface CrawlState { @@ -426,7 +497,8 @@ export function createPackageIndex(deps: IndexDeps): PackageIndex { try { const listing = (await get("/public")) as Partial; const tree = listing.concepts?.length ? ((await get("/public/concepts")) as { concepts?: LiveState["concepts"] }).concepts ?? [] : []; - state.listing = { workflows: listing.workflows ?? [], steps: listing.steps ?? [], concepts: listing.concepts ?? [], apps: listing.apps ?? [] }; + const runnable = liveRunnableOf((listing as { runnable?: unknown }).runnable); + state.listing = { workflows: listing.workflows ?? [], steps: listing.steps ?? [], concepts: listing.concepts ?? [], apps: listing.apps ?? [], ...(runnable.length ? { runnable } : {}) }; state.concepts = tree.map((c) => ({ name: c.name, ...(c.description ? { description: c.description } : {}), ...(c.parent ? { parent: c.parent } : {}), root: c.root })); state.readAt = state.checkedAt; delete state.error; @@ -455,7 +527,7 @@ export function createPackageIndex(deps: IndexDeps): PackageIndex { }; const briefOf = ({ listing, concepts, ...rest }: LiveState): LiveStateBrief => ({ ...rest, - ...(listing ? { apps: listing.apps.length, workflows: listing.workflows.length, steps: listing.steps.length } : {}), + ...(listing ? { apps: listing.apps.length, workflows: listing.workflows.length, steps: listing.steps.length, ...(listing.runnable?.length ? { runnable: listing.runnable.length } : {}) } : {}), ...(concepts ? { concepts: concepts.length } : {}), }); @@ -632,9 +704,29 @@ export function createPackageIndex(deps: IndexDeps): PackageIndex { const l = st.listing; if (!l) continue; const at = { publisher: st.prefix, url: st.url }; + // Its price, beside the item when its definition is public too, else + // an item of its own (decision 11: runnable is not readable). + const runOf = (kind: "workflow" | "step", name: string) => { + const r = l.runnable?.find((x) => x.kind === kind && x.name === name); + if (!r) return undefined; + const { kind: _k, name: _n, description: _d, ...run } = r; + return run; + }; for (const a of l.apps) items.push({ ...at, kind: "app", name: a.name, title: a.title, ...(a.description ? { description: a.description } : {}), version: a.version }); - for (const w of l.workflows) items.push({ ...at, kind: "workflow", name: w.name, ...(w.description ? { description: w.description } : {}), runs: !!w.runs }); - for (const t of l.steps) items.push({ ...at, kind: "step", name: t.type, ...(t.description ? { description: t.description } : {}) }); + for (const w of l.workflows) { + const run = runOf("workflow", w.name); + items.push({ ...at, kind: "workflow", name: w.name, ...(w.description ? { description: w.description } : {}), runs: !!w.runs, ...(run ? { run } : {}) }); + } + for (const t of l.steps) { + const run = runOf("step", t.type); + items.push({ ...at, kind: "step", name: t.type, ...(t.description ? { description: t.description } : {}), ...(run ? { run } : {}) }); + } + for (const r of l.runnable ?? []) { + const shown = r.kind === "workflow" ? l.workflows.some((w) => w.name === r.name) : l.steps.some((t) => t.type === r.name); + if (shown) continue; + const { kind, name, description, ...run } = r; + items.push({ ...at, kind, name, ...(description ? { description } : {}), run, definition: false }); + } for (const c of l.concepts) items.push({ ...at, kind: "concept", name: c.name, ...(c.description ? { description: c.description } : {}), ...(c.pages !== undefined ? { pages: c.pages } : {}) }); } const words = q.trim(); diff --git a/src/public-agents.test.ts b/src/public-agents.test.ts new file mode 100644 index 00000000..33e964a6 --- /dev/null +++ b/src/public-agents.test.ts @@ -0,0 +1,398 @@ +/** + * An agent as a buyer (plans/paid-runs.md work item 8, §10 "An agent over + * MCP"): `POST /public/mcp` serves list_public, run_workflow, call_tool, + * get_run and get_credit beside a visitor page's `step_run`, over the SAME + * doors as the HTTP routes. An unpaid priced call's RESULT is `{ + * payment_required }`; the agent pays the fake wallet's invoice and calls + * again with `payment`, or the connection's `Authorization: L402`. A retry of + * the same launch is the same run while it is in flight, never a second + * reserve; a long run answers a handle `get_run` waits on; credit bought on + * one connection is spent on another. And `GET /llms.txt` lists what anyone + * may run here, with its price. + */ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { z } from "zod"; +import { createStrut } from "./createStrut.js"; +import { WorkspaceManager } from "./workspace.js"; +import { MemoryRunStore } from "./store.js"; +import { defineStep, withUsage } from "./core.js"; +import { createRegistry } from "./steps/registry.js"; +import { FakeWallet } from "./payments/fake-wallet.js"; +import { lightningCapability } from "./payments/lightning.js"; +import { STEP_RUN_TOOL } from "./apps/tools.js"; +import { buildPublicAgentTools, defaultLaunchKey, priceWords } from "./public-agents.js"; +import type { PublicDoors } from "./public-routes.js"; +import type { ExchangeRate } from "./payments/rate.js"; + +const KEY = "public-agents-key"; +const auth = { authorization: `Bearer ${KEY}`, "content-type": "application/json" }; +const RATE = 100_000; // dollars per bitcoin: $1 is 1000 sats + +/** Held by a test that keeps a run in flight. */ +let gate: Promise = Promise.resolve(); + +/** Writes a file into the run's artifacts and declares it, spending what + * its config says; waits on the gate when told to. */ +const make = defineStep({ + type: "test/make", + input: z.object({ text: z.string(), cost: z.number().optional(), hold: z.boolean().optional() }), + output: z.any(), + async run(cfg, ctx) { + if (cfg.hold) await gate; + const artifacts = (ctx.services as { artifacts?: { write(runId: string, path: string, content: string): Promise } }).artifacts; + await artifacts?.write(ctx.runId, "out.txt", cfg.text); + return withUsage({ text: cfg.text, artifacts: [{ id: "out", title: "Out", path: "out.txt" }] }, cfg.cost ? { modelCalls: 1, usage: {}, cost: cfg.cost } : undefined); + }, +}); + +/** A tool anyone may call: echoes its note, spending what it is told. */ +const echo = defineStep({ + type: "test/echo", + description: "Echo a note back", + input: z.object({ note: z.string(), cost: z.number().optional() }), + output: z.any(), + async run(cfg) { + return withUsage({ note: cfg.note }, cfg.cost ? { modelCalls: 1, usage: {}, cost: cfg.cost } : undefined); + }, +}); + +const wf = (name: string) => + `name: ${name}\ndescription: Makes ${name}\ninput:\n text: { type: string, description: What to write }\n cost: { type: number, default: 0 }\n hold: { type: boolean, default: false }\nsteps:\n - id: m\n type: test/make\n config:\n text: "{{ input.text }}"\n cost: "{{ input.cost }}"\n hold: "{{ input.hold }}"\n`; + +describe("an agent over MCP buys and runs (plans/paid-runs.md §10)", () => { + let root: string; + let saved: string | undefined; + let strut: Awaited>; + const wallet = new FakeWallet(); + const store = new MemoryRunStore(); + + /** One JSON-RPC request to `/public/mcp`, as an agent's client sends it. */ + const rpc = async (method: string, params: unknown, headers: Record = {}) => { + const res = await strut.app.request("/public/mcp", { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json, text/event-stream", ...headers }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }); + const text = await res.text(); + assert.equal(res.status, 200, text); + const lines = text.split("\n").filter((l) => l.startsWith("data: ")).map((l) => JSON.parse(l.slice(6))); + const body = lines.find((l) => l.id === 1) ?? (lines.length ? lines[lines.length - 1] : JSON.parse(text)); + return { body, notifications: lines.filter((l) => l.method) }; + }; + /** A tool's call: its result as JSON, and whether it was an error. */ + const tool = async (name: string, args: Record, headers: Record = {}) => { + const { body } = await rpc("tools/call", { name, arguments: args }, headers); + const result = body.result as { isError?: boolean; content: Array<{ text: string }> }; + return { isError: !!result.isError, out: JSON.parse(result.content[0]!.text) as Record }; + }; + const ledgerHolds = async () => (await strut.ledger()).list({ kind: "hold" }); + + before(async () => { + saved = process.env["STRUT_API_KEY"]; + process.env["STRUT_API_KEY"] = KEY; + root = await mkdtemp(join(tmpdir(), "strut-public-agents-")); + const workspace = new WorkspaceManager(join(root, "ws")); + strut = await createStrut({ + workspace, store, registry: await createRegistry([make, echo]), + scheduler: false, serveUi: false, views: false, + job: false, orchestrator: false, builder: false, browserWorkflows: false, privacyWorkflows: false, + services: { lightning: lightningCapability(wallet) }, + payments: { rate: async (): Promise => ({ usdPerBtc: RATE, at: new Date().toISOString(), source: "test" }) }, + }); + for (const name of ["free", "priced", "metered"]) { + await workspace.publishWorkflowByContent(name, wf(name)); + await workspace.setWorkflowOwner(name, "owner-1"); + } + const put = await strut.app.request("/public-view", { + method: "PUT", + headers: auth, + body: JSON.stringify({ + runnable: [ + { kind: "workflow", name: "free", dailyCapUsd: 5 }, + { kind: "workflow", name: "priced", base: 0.5 }, + { kind: "workflow", name: "metered", base: 0.1, markup: 2, capUsd: 1, results: "run" }, + { kind: "step", name: "test/echo", base: 0.02 }, + ], + }), + }); + assert.equal(put.status, 200, await put.clone().text()); + }); + after(async () => { + await strut?.close?.().catch(() => {}); + if (saved === undefined) delete process.env["STRUT_API_KEY"]; + else process.env["STRUT_API_KEY"] = saved; + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + }); + + it("tools/list: the five tools for an agent, and the page's step_run beside them, still app-only", async () => { + const { body } = await rpc("tools/list", {}); + const tools = body.result.tools as Array<{ name: string; _meta?: { ui?: { visibility?: string[] } }; inputSchema: Record }>; + const names = tools.map((t) => t.name).sort(); + assert.deepEqual(names, ["call_tool", "get_credit", "get_run", "list_public", "run_workflow", STEP_RUN_TOOL].sort()); + assert.deepEqual(tools.find((t) => t.name === STEP_RUN_TOOL)!._meta, { ui: { visibility: ["app"] } }); + for (const t of tools.filter((x) => x.name !== STEP_RUN_TOOL)) assert.equal(t._meta, undefined, `${t.name} is a model's`); + const { body: init } = await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "agent", version: "0" } }); + assert.match(init.result.instructions, /list_public/); + assert.match(init.result.instructions, /payment_required/); + }); + + it("list_public: what anyone may run, its price, reserve, recent charges and input", async () => { + const { isError, out } = await tool("list_public", {}); + assert.equal(isError, false); + const byName = Object.fromEntries((out["runnable"] as Array>).map((r) => [r["name"], r])); + assert.deepEqual(Object.keys(byName).sort(), ["free", "metered", "priced", "test/echo"]); + assert.equal(byName["priced"]!["base"], 0.5); + assert.equal(byName["priced"]!["reserve"], 0.5); + assert.equal(byName["metered"]!["reserve"], 2.1, "base + markup × capUsd"); + assert.equal(byName["priced"]!["call_with"], "run_workflow"); + assert.equal(byName["test/echo"]!["call_with"], "call_tool"); + assert.deepEqual(byName["free"]!["input"]["text"], { type: "string", description: "What to write" }); + assert.deepEqual(byName["free"]!["recent"], { days: 30, runs: 0, average: 0 }); + assert.deepEqual((await tool("list_public", { query: "echo" })).out["runnable"].map((r: { name: string }) => r.name), ["test/echo"]); + }); + + it("run_workflow, free: launches through the public launcher, waits for the run, and answers its output with links that carry the run token", async () => { + const { isError, out } = await tool("run_workflow", { workflow: "free", input: { text: "hello" }, wait: 10 }); + assert.equal(isError, false, JSON.stringify(out)); + assert.equal(out["run"]["status"], "success"); + assert.deepEqual(out["run"]["output"], { text: "hello", artifacts: [{ id: "out", title: "Out", path: "out.txt" }] }); + assert.equal(out["run"]["charged"], 0, "a free run is charged nothing"); + assert.equal(out["key"], defaultLaunchKey("free", { text: "hello" }), "a key made from the workflow and input"); + assert.equal(out["next"], undefined); + const link = out["run"]["artifacts"][0]["url"] as string; + assert.match(link, /^http:\/\/localhost\/public\/workflows\/free\/runs\/\d+\/files\/out\.txt\?t=/); + const file = await strut.app.request(link.replace("http://localhost", "")); + assert.equal(file.status, 200); + assert.equal(await file.text(), "hello"); + const start = (await store.getRunEvents("free", out["run_id"])).find((e) => e.type === "run.start")!; + assert.equal(start.origin, "public"); + // get_run reads it back by its token; another token reads nothing. + const got = await tool("get_run", { workflow: "free", run_id: out["run_id"], token: out["token"] }); + assert.equal(got.out["run"]["status"], "success"); + const bad = await tool("get_run", { workflow: "free", run_id: out["run_id"], token: "nope" }); + assert.equal(bad.isError, true); + assert.match(bad.out["error"], /no run/); + // A run's input is checked as the HTTP door checks it. + const refused = await tool("run_workflow", { workflow: "free", input: {} }); + assert.equal(refused.isError, true); + assert.match(refused.out["error"], /input: text/); + assert.match((await tool("run_workflow", { workflow: "nope", input: { text: "x" } })).out["error"], /not open to anyone/); + }); + + it("run_workflow, a long run: answers a handle with `next`; get_run waits; the same launch sent again while it is in flight is the same run", async () => { + let open!: () => void; + gate = new Promise((r) => (open = r)); + try { + const first = await tool("run_workflow", { workflow: "free", input: { text: "slow", hold: true }, wait: 0 }); + assert.equal(first.isError, false, JSON.stringify(first.out)); + assert.match(first.out["next"], /get_run/); + assert.equal(first.out["run"]["charged"], undefined, "not ended"); + // A retry — no key named, the same input — returns the run in flight. + const again = await tool("run_workflow", { workflow: "free", input: { text: "slow", hold: true }, wait: 0 }); + assert.equal(again.out["run_id"], first.out["run_id"]); + assert.equal(again.out["existing"], true); + // A distinct key is another run. + const other = await tool("run_workflow", { workflow: "free", input: { text: "slow", hold: true }, key: "second", wait: 0 }); + assert.notEqual(other.out["run_id"], first.out["run_id"]); + open(); + const done = await tool("get_run", { workflow: "free", run_id: first.out["run_id"], token: first.out["token"], wait: 10 }); + assert.equal(done.out["run"]["status"], "success"); + assert.equal(done.out["next"], undefined); + await tool("get_run", { workflow: "free", run_id: other.out["run_id"], token: other.out["token"], wait: 10 }); + } finally { + open?.(); + gate = Promise.resolve(); + } + }); + + it("run_workflow, priced: payment_required as a RESULT; paid with the preimage, the same call launches against the credit, once", async () => { + const invoicesBefore = wallet.calls.filter((c) => c.method === "makeInvoice").length; + const asked = await tool("run_workflow", { workflow: "priced", input: { text: "paid" } }); + assert.equal(asked.isError, false, "an unpaid call is a result, not an error"); + const p = asked.out["payment_required"] as Record; + assert.ok(p["macaroon"] && p["invoice"]); + assert.equal(p["usd"], 0.5); + assert.equal(p["sats"], 500); + assert.match(asked.out["next"], /payment: ":"/); + assert.equal(wallet.calls.filter((c) => c.method === "makeInvoice").length, invoicesBefore + 1); + assert.equal((await store.listRuns("priced")).length, 0, "nothing launched"); + + const credential = `${p["macaroon"]}:${wallet.settle(p["invoice"])}`; + const holds = (await ledgerHolds()).length; + const ran = await tool("run_workflow", { workflow: "priced", input: { text: "paid" }, payment: credential, wait: 10 }); + assert.equal(ran.isError, false, JSON.stringify(ran.out)); + assert.equal(ran.out["run"]["status"], "success"); + assert.equal(ran.out["run"]["charged"], 0.5); + assert.equal(ran.out["credit"]["held_usd"], 0.5); + assert.equal((await ledgerHolds()).length, holds + 1, "one reserve set aside"); + // The balance, read on another connection with the same credential. + const credit = await tool("get_credit", { payment: credential }); + assert.equal(credit.isError, false, JSON.stringify(credit.out)); + assert.equal(credit.out["paid_usd"], 0.5); + assert.equal(credit.out["spent_usd"], 0.5); + assert.equal(credit.out["remaining_usd"], 0); + // Short now: the next run asks for more, carrying what is left. + const more = await tool("run_workflow", { workflow: "priced", input: { text: "again" }, payment: credential }); + assert.equal(more.out["payment_required"]["reason"], "short"); + assert.equal(more.out["payment_required"]["carries"], true); + }); + + it("the connection's own Authorization: L402 header pays, and a payer with no preimage claims with the macaroon alone", async () => { + const asked = await tool("run_workflow", { workflow: "priced", input: { text: "phone" }, key: "phone-1" }); + const p = asked.out["payment_required"] as Record; + assert.equal(asked.out["key"], "phone-1"); + // Not paid yet: the claim says so, and nothing launches. + const pending = await tool("run_workflow", { workflow: "priced", input: { text: "phone" }, key: "phone-1", payment: p["macaroon"] }); + assert.ok(pending.out["payment_pending"], JSON.stringify(pending.out)); + assert.equal((await tool("get_credit", { payment: p["macaroon"] })).out["state"], "pending"); + // A person paid the QR code from a phone: the agent never saw a preimage. + wallet.settle(p["invoice"]); + const claimed = await tool("get_credit", { payment: p["macaroon"] }); + assert.equal(claimed.out["state"], "settled"); + const credential = claimed.out["credential"] as string; + assert.ok(credential.startsWith(`${p["macaroon"]}:`)); + assert.equal(claimed.out["credit"]["remaining_usd"], 0.5); + // On a connection that presents it as its header, no argument needed. + const ran = await tool("run_workflow", { workflow: "priced", input: { text: "phone" }, key: "phone-1", wait: 10 }, { authorization: `L402 ${credential}` }); + assert.equal(ran.isError, false, JSON.stringify(ran.out)); + assert.equal(ran.out["run"]["status"], "success"); + assert.equal((await tool("get_credit", {}, { authorization: `L402 ${credential}` })).out["spent_usd"], 0.5); + // A macaroon alone as payment, once settled, launches too, and hands back the whole credential. + const asked2 = await tool("run_workflow", { workflow: "priced", input: { text: "phone-2" } }); + const p2 = asked2.out["payment_required"] as Record; + wallet.settle(p2["invoice"]); + const ran2 = await tool("run_workflow", { workflow: "priced", input: { text: "phone-2" }, payment: p2["macaroon"], wait: 10 }); + assert.equal(ran2.out["run"]["status"], "success", JSON.stringify(ran2.out)); + assert.ok((ran2.out["credential"] as string).startsWith(`${p2["macaroon"]}:`)); + }); + + it("a paid launch whose answer was lost, sent again with the same credential and key while in flight: the same run, one reserve", async () => { + const asked = await tool("run_workflow", { workflow: "priced", input: { text: "lost", hold: true } }); + const p = asked.out["payment_required"] as Record; + const credential = `${p["macaroon"]}:${wallet.settle(p["invoice"])}`; + let open!: () => void; + gate = new Promise((r) => (open = r)); + try { + const holds = (await ledgerHolds()).length; + const first = await tool("run_workflow", { workflow: "priced", input: { text: "lost", hold: true }, payment: credential, wait: 0 }); + assert.equal(first.isError, false, JSON.stringify(first.out)); + const retry = await tool("run_workflow", { workflow: "priced", input: { text: "lost", hold: true }, payment: credential, wait: 0 }); + assert.equal(retry.out["run_id"], first.out["run_id"]); + assert.equal(retry.out["existing"], true); + assert.equal((await ledgerHolds()).length, holds + 1, "never a second reserve"); + open(); + assert.equal((await tool("get_run", { workflow: "priced", run_id: first.out["run_id"], token: first.out["token"], wait: 10 })).out["run"]["charged"], 0.5); + } finally { + open?.(); + gate = Promise.resolve(); + } + }); + + it("a metered run whose launcher reads the whole run: charged base + markup × cost", async () => { + const asked = await tool("run_workflow", { workflow: "metered", input: { text: "m", cost: 0.25 } }); + const p = asked.out["payment_required"] as Record; + assert.equal(p["usd"], 2.1, "the reserve"); + const credential = `${p["macaroon"]}:${wallet.settle(p["invoice"])}`; + const ran = await tool("run_workflow", { workflow: "metered", input: { text: "m", cost: 0.25 }, payment: credential, wait: 10 }); + assert.equal(ran.out["run"]["charged"], 0.6, "0.1 + 2 × 0.25"); + assert.ok(ran.out["run"]["stepCounts"] !== undefined || ran.out["run"]["status"] === "success"); + assert.equal((await tool("get_credit", { payment: credential })).out["remaining_usd"], 1.5, "what the run did not use stays"); + }); + + it("call_tool: a priced step is a payment_required result, then its output and what it was charged", async () => { + const asked = await tool("call_tool", { type: "test/echo", config: { note: "hi" } }); + assert.equal(asked.isError, false); + const p = asked.out["payment_required"] as Record; + assert.equal(p["usd"], 0.02); + const credential = `${p["macaroon"]}:${wallet.settle(p["invoice"])}`; + const got = await tool("call_tool", { type: "test/echo", config: { note: "hi" }, payment: credential }); + assert.equal(got.isError, false, JSON.stringify(got.out)); + assert.deepEqual(got.out["output"], { note: "hi" }); + assert.equal(got.out["credit"]["charged_usd"], 0.02); + const bad = await tool("call_tool", { type: "test/echo", config: { nope: 1 }, payment: credential }); + assert.equal(bad.isError, true); + assert.match(bad.out["error"], /config/); + assert.match((await tool("call_tool", { type: "test/make", config: {} })).out["error"], /not open to anyone/); + }); + + it("a credential this server never minted is refused by name, never treated as unpaid", async () => { + const bad = await tool("run_workflow", { workflow: "priced", input: { text: "x" }, payment: "AgEEc3RydXQ:00" }); + assert.equal(bad.isError, true); + assert.match(bad.out["error"], /invalid_token/); + const credit = await tool("get_credit", { payment: "AgEEc3RydXQ:00" }); + assert.equal(credit.isError, true); + }); + + it("GET /llms.txt: what anyone may run here and its price, open to anyone, the MCP door named", async () => { + const res = await strut.app.request("/llms.txt"); + assert.equal(res.status, 200); + assert.match(res.headers.get("content-type") ?? "", /text\/markdown/); + const md = await res.text(); + assert.match(md, /^# strut server at localhost/m); + assert.match(md, /claude mcp add --transport http localhost http:\/\/localhost\/public\/mcp/); + assert.match(md, /- \[free\]\(http:\/\/localhost\/public\/workflows\/free\/run\): free; \d+ in the last 30 days, \$0\.00 charged on average; input: `text` \(string\), `cost` \(number, optional\), `hold` \(boolean, optional\)/); + assert.match(md, /- \[priced\]\(http:\/\/localhost\/public\/workflows\/priced\/run\): \$0\.50 a run; needs \$0\.50 of credit to start; \d+ in the last 30 days, \$0\.50 charged on average/); + assert.match(md, /- \[metered\]\(.*\): \$0\.10 \+ 2× what a run costs, at most \$1\.00 of cost; needs \$2\.10 of credit to start/); + assert.match(md, /- \[test\/echo\]\(http:\/\/localhost\/public\/steps\/test\/echo\/run\): Echo a note back — \$0\.02 a call; needs \$0\.02 of credit to start/); + assert.match(md, /## Paying/); + }); +}); + +describe("the agent tools hold no call past a client's patience", () => { + /** Doors whose run is in flight for `reads` reads, then ends. */ + const fakeDoors = (reads: number): PublicDoors => { + let n = 0; + const view = () => (++n > reads ? { runId: "r1", status: "success", output: { ok: true }, charged: 0 } : { runId: "r1", status: "running" }); + return { + launch: async () => ({ status: 202, body: { runId: "r1", token: "tok" } }), + call: async () => { + await new Promise((r) => setTimeout(r, 120)); + return { status: 200, body: { output: { done: true } } }; + }, + run: async ({ token }) => (token === "tok" ? { status: 200, body: view() } : { status: 404, body: { error: "no run" } }), + credit: async () => ({ status: 404, body: { error: "no credit" } }), + claim: async () => ({ status: 404, body: { error: "no credit" } }), + runnable: async () => [], + listing: async () => ({ workflows: [], steps: [], concepts: [], types: [], apps: [], runnable: [] }), + }; + }; + const drain = async (gen: unknown): Promise => { + const out: unknown[] = []; + for await (const y of gen as AsyncIterable) out.push(y); + return out; + }; + + it("run_workflow yields progress while its run goes on, then the result; past `wait`, a handle with `next`", async () => { + const tools = buildPublicAgentTools({ doors: fakeDoors(3), who: "a", publicUrl: "https://s.example/", progressEveryMs: 1 }); + const ys = await drain(await tools["run_workflow"]!.execute!({ workflow: "w", input: {}, wait: 5 }, { toolCallId: "1", messages: [] })); + assert.ok(ys.length >= 2, "progress before the result"); + assert.deepEqual(ys.slice(0, -1).map((y) => (y as { status: string }).status), Array(ys.length - 1).fill("running")); + const last = ys.at(-1) as Record; + assert.equal(last["run"]["status"], "success"); + assert.equal(last["token"], "tok"); + + const held = buildPublicAgentTools({ doors: fakeDoors(1000), who: "a", publicUrl: "https://s.example", progressEveryMs: 1 }); + const handle = (await drain(await held["run_workflow"]!.execute!({ workflow: "w", input: {}, wait: 0 }, { toolCallId: "1", messages: [] }))).at(-1) as Record; + assert.equal(handle["run_id"], "r1"); + assert.match(handle["next"], /get_run/); + }); + + it("call_tool yields progress while the call runs, then its output", async () => { + const tools = buildPublicAgentTools({ doors: fakeDoors(0), who: "a", publicUrl: "https://s.example", progressEveryMs: 20 }); + const ys = await drain(await tools["call_tool"]!.execute!({ type: "t", config: {} }, { toolCallId: "1", messages: [] })); + assert.ok(ys.length >= 2, `beats then the output: ${JSON.stringify(ys)}`); + assert.deepEqual(ys.at(-1), { output: { done: true } }); + }); + + it("priceWords: free, fixed, metered, both — and the reserve and recent charges", () => { + const r = { kind: "workflow" as const, capUsd: null, reserve: 0, recent: { days: 30, runs: 0, average: 0 } }; + assert.equal(priceWords({ ...r, base: 0, markup: 0 }), "free"); + assert.equal(priceWords({ ...r, base: 0.5, markup: 0, reserve: 0.5 }), "$0.50 a run; needs $0.50 of credit to start"); + assert.equal(priceWords({ ...r, base: 0, markup: 1.5, capUsd: 2, reserve: 3, recent: { days: 30, runs: 4, average: 0.75 } }), "1.5× what a run costs, at most $2.00 of cost; needs $3.00 of credit to start; 4 in the last 30 days, $0.75 charged on average"); + assert.equal(priceWords({ ...r, kind: "step", base: 0.005, markup: 0, reserve: 0.005 }), "$0.005 a call; needs $0.005 of credit to start"); + }); +}); diff --git a/src/public-agents.ts b/src/public-agents.ts new file mode 100644 index 00000000..a9bad8e0 --- /dev/null +++ b/src/public-agents.ts @@ -0,0 +1,471 @@ +/** + * AN AGENT AS A BUYER (plans/paid-runs.md §10 "An agent over MCP", work item + * 8): the tools `POST /public/mcp` serves beside a visitor's page's + * `step_run`, for an agent with no account on this strut server — + * + * list_public what anyone may run here: price, reserve, what recent runs + * were charged, and the input to send (`GET /public`'s + * `runnable`) + * run_workflow a launch through the ONE launcher (`PublicDoors.launch`, + * src/public-routes.ts): the address's budget, the drain, the + * body, the caps, the price, the `key` — then waits a while + * for the run, and hands back a handle when it is still going + * call_tool a tool's call through `PublicDoors.call` (the calls' + * budget, then src/public-tools.ts `call`) + * get_run the launcher's view of a run, by its RUN TOKEN, waiting + * while it is in flight + * get_credit a credit token's balance; with the macaroon alone, its + * claim (§4.4) + * + * A priced item with no credit answers a RESULT, never an error: `{ + * payment_required: { macaroon, invoice, usd, sats, rate, description, + * expires, remaining_usd?, reason?, carries? } }`. The agent pays the + * invoice with whatever wallet it has and calls again with `payment: + * ":"` — or the connection's own `Authorization: L402 + * …` header, which every tool reads when the call names no `payment`. + * Nothing is kept between requests: the door is stateless, so the credential + * an agent holds works on any connection, and `payment: ""` with no + * preimage claims an invoice a person paid from a phone. + * + * What keeps an agent from losing a run or paying twice: + * - **A key on every launch.** `run_workflow` sends the agent's `key`, else + * one made from the workflow and the input, so a retry of the same launch + * — after paying, or after a lost answer — returns the run in flight and + * sets nothing more aside. A key matches only a run still in flight; a + * distinct `key` starts another. + * - **No call held past a client's patience.** `run_workflow` waits `wait` + * seconds (25 by default, 55 at most) for the run, sending progress while + * it waits, then answers the run's handle — `run_id` and `token` — for + * `get_run`, which waits again. `call_tool` sends progress while the call + * runs (it answers within 60 s). + * - **A failed or timed-out call is charged its cost**: `call_tool` has no + * key, so its description says not to send a charged call again blindly. + */ +import { createHash } from "node:crypto"; +import { z } from "zod"; +import { tool, type ToolSet } from "ai"; +import { capToolResults } from "./ai/tools.js"; +import type { PublicAnswer, PublicDoors, PublicLaunchOutcome } from "./public-routes.js"; +import type { ListedRunnable } from "./public-runnable.js"; +import { PUBLIC_RUN_WAIT_MAX_S } from "./public-runs.js"; + +/** How long `run_workflow` waits for its run by default, in seconds. */ +export const AGENT_RUN_WAIT_S = 25; +/** Progress this often while a tool holds the call, so a client whose + * request times out unless progress resets it (60 s, the MCP SDK's + * default) keeps waiting. */ +export const AGENT_PROGRESS_EVERY_MS = 10_000; + +export interface PublicAgentOptions { + doors: PublicDoors; + /** The caller's address (`visitorAddress`). */ + who: string; + /** The connection's `Authorization`, when it sent an L402 credential. */ + authorization?: string | undefined; + /** This server's base URL: every link the tools hand out is absolute. */ + publicUrl: string; + /** Progress cadence (tests). */ + progressEveryMs?: number; +} + +/** The fields of a 402 an agent reads (§17.5's challenge). */ +const CHALLENGE_FIELDS = ["macaroon", "invoice", "usd", "sats", "rate", "description", "expires", "remaining_usd", "reason", "carries"] as const; + +/** A challenge as a tool's RESULT: what to pay, and how to call again. */ +export function paymentRequiredResult(body: Record, again: string): Record { + const pick: Record = {}; + for (const k of CHALLENGE_FIELDS) if (body[k] !== undefined) pick[k] = body[k]; + return { + payment_required: pick, + next: + `Pay the invoice (BOLT11, ${String(pick["sats"] ?? "?")} sats) with any Lightning wallet, then ${again} with payment: ":" — the macaroon above and the payment's preimage. ` + + `No preimage from your wallet (a person paid a QR code)? Send payment: "" alone and the server claims it once the invoice settles. ` + + (pick["carries"] ? "The new credential inherits the credit left on the one you sent: use it from now on. " : "") + + "The credential is credit on this server for a year: send it on later calls too, until a call asks for more.", + }; +} + +/** A refusal as a tool's RESULT (`{ error }` is `isError` on the door). */ +function refusal(a: PublicAnswer | PublicLaunchOutcome): Record { + const message = typeof a.body["error"] === "string" ? a.body["error"] : `refused (${a.status})`; + const code = typeof a.body["code"] === "string" ? a.body["code"] : undefined; + const retry = a.headers?.["retry-after"]; + return { + error: code && !message.startsWith(`${code}:`) ? `${code}: ${message}` : message, + status: a.status, + ...(code ? { code } : {}), + ...(retry ? { retry_after_s: Number(retry) } : {}), + ...(a.body["credit"] !== undefined ? { credit: a.body["credit"] } : {}), + }; +} + +/** The key a launch is sent under when the agent names none: the workflow + * and its input, so the same launch sent again — after paying, after a + * lost answer — is the same run while that run is in flight. */ +export function defaultLaunchKey(workflow: string, input: unknown): string { + return `auto:${createHash("sha256").update(JSON.stringify([workflow, input ?? {}])).digest("hex").slice(0, 40)}`; +} + +/** A run's links made absolute, each file carrying the run token (`?t=`): + * the agent fetches them with no other credential. */ +function absoluteRun(view: Record, base: string, token: string): Record { + const abs = (u: unknown) => (typeof u === "string" && u.startsWith("/public/") ? `${base}${u}?t=${encodeURIComponent(token)}` : u); + const artifacts = Array.isArray(view["artifacts"]) + ? (view["artifacts"] as Array>).map((a) => ({ ...a, ...(a["url"] ? { url: abs(a["url"]) } : {}), ...(a["render"] ? { render: abs(a["render"]) } : {}) })) + : undefined; + return { ...view, ...(artifacts ? { artifacts } : {}) }; +} + +const IN_FLIGHT = new Set(["running", "paused", "pending", "cancelling", "pausing", "queued"]); +/** The view says the run is still going: no `charged` yet (written once it + * ended and was charged), or a status of a run in flight. */ +const stillGoing = (view: Record) => view["charged"] === undefined || IN_FLIGHT.has(String(view["status"] ?? "")); + +/** Wait for `work`, yielding `beat(n)` every `everyMs` until it settles; + * the last yield is its value. The timer is cleared each round, so nothing + * outlives the call. */ +async function* withBeats(work: Promise, everyMs: number, beat: (n: number) => unknown): AsyncGenerator { + const done = work.then((v) => ({ v })); + for (let n = 1; ; n++) { + let t: ReturnType | undefined; + const tick = new Promise<"tick">((r) => { + t = setTimeout(() => r("tick"), everyMs); + }); + let got: { v: T } | "tick"; + try { + got = await Promise.race([done, tick]); + } finally { + clearTimeout(t); + } + if (got !== "tick") { + yield got.v; + return; + } + yield beat(n); + } +} + +/** The credential a call presents: its `payment` argument, else the + * connection's L402 header. With a macaroon and no preimage, the claim: a + * settled invoice's whole credential, else the result to answer. */ +async function resolvePayment( + o: PublicAgentOptions, + payment: string | undefined, +): Promise<{ authorization?: string; claimed?: string } | { answer: Record }> { + const raw = payment?.trim().replace(/^(?:L402|LSAT)\s+/i, ""); + if (!raw) return o.authorization && /^\s*(?:L402|LSAT)\s/i.test(o.authorization) ? { authorization: o.authorization } : {}; + if (raw.includes(":") && raw.slice(raw.lastIndexOf(":") + 1)) return { authorization: `L402 ${raw}` }; + const macaroon = raw.replace(/:$/, ""); + const got = await o.doors.claim({ macaroon, who: o.who }); + if (got.status !== 200) return { answer: refusal(got) }; + const state = got.body["state"]; + if (state === "settled" && typeof got.body["credential"] === "string") return { authorization: `L402 ${got.body["credential"]}`, claimed: got.body["credential"] }; + if (state === "expired") return { answer: { error: "payment_expired: that invoice ended unpaid — call again with no payment for a new one", code: "payment_expired" } }; + return { + answer: { + payment_pending: { macaroon, ...(got.body["expires"] ? { expires: got.body["expires"] } : {}) }, + next: "The invoice has not settled yet: pay it, then call again with the same payment (or with : once you have the preimage).", + }, + }; +} + +const PAYING = + "A PRICED item with no credit, or too little, answers a RESULT (not an error): { payment_required: { macaroon, invoice, usd, sats, rate, description, expires, remaining_usd?, reason?, carries? }, next } — pay the BOLT11 `invoice` with any Lightning wallet, then call again with `payment: \":\"` (or send it as this connection's `Authorization: L402 :` header). With no preimage (a person paid the QR), `payment: \"\"` alone claims it once it settles. The credential is CREDIT on this server, in dollars, for a year: send it on later calls; each run or call is charged base + markup × what it cost, never past its reserve, and what it does not use stays. Nothing here needs an account."; + +/** The tools an agent with no account gets on `POST /public/mcp`. */ +export function buildPublicAgentTools(o: PublicAgentOptions): ToolSet { + const base = o.publicUrl.replace(/\/+$/, ""); + const every = o.progressEveryMs ?? AGENT_PROGRESS_EVERY_MS; + + /** Read the run until it ends or `waitS` passes, yielding progress. */ + async function* follow(workflow: string, runId: string, token: string, waitS: number, extra: Record): AsyncGenerator { + const deadline = Date.now() + 1000 * Math.max(0, Math.min(PUBLIC_RUN_WAIT_MAX_S, waitS)); + let n = 0; + for (;;) { + const left = Math.max(0, (deadline - Date.now()) / 1000); + const read = await o.doors.run({ name: workflow, runId, token, wait: Math.min(left, every / 1000) }); + if (read.status !== 200) { + yield { ...refusal(read), workflow, run_id: runId }; + return; + } + const view = absoluteRun(read.body, base, token); + const going = stillGoing(read.body); + if (!going || Date.now() >= deadline) { + yield { + workflow, + run_id: runId, + token, + ...extra, + run: view, + ...(going + ? { next: `Still running: call get_run with { workflow: "${workflow}", run_id: "${runId}", token } (wait up to ${PUBLIC_RUN_WAIT_MAX_S} s a call) until its status is not running.` } + : {}), + }; + return; + } + yield { status: "running", workflow, run_id: runId, progress: ++n }; + } + } + + const tools: ToolSet = { + list_public: tool({ + description: + "What anyone may RUN on this strut server, free or paid in sats over Lightning, with no account: each item { kind: workflow | step, name, description?, base (dollars a run), markup (times what the run cost), capUsd (the most one run may spend), reserve (the credit a run needs to start: base + markup × capUsd), results (what its launcher reads: output, or the whole run), input (a workflow's input: block — field → { type, required?, default?, description? } — or a step's input JSON Schema), recent: { days, runs, average } (what recent runs were charged, in dollars) }. " + + "A workflow runs with run_workflow, a step with call_tool. Free items (base 0, markup 0) need no payment. " + + PAYING, + inputSchema: z.object({ + query: z.string().optional().describe("Words that must each appear in the name or description; omit for everything."), + }), + execute: async ({ query }) => { + const words = (query ?? "").toLowerCase().split(/\s+/).filter(Boolean); + const all = await o.doors.runnable(); + const runnable = all + .filter((r: ListedRunnable) => words.every((w) => r.name.toLowerCase().includes(w) || (r.description ?? "").toLowerCase().includes(w))) + .map((r) => ({ ...r, call_with: r.kind === "workflow" ? "run_workflow" : "call_tool" })); + return { + server: base, + runnable, + ...(all.length === 0 ? { note: "Nothing is open to anyone to run on this server." } : {}), + }; + }, + }), + + run_workflow: tool({ + description: + "Start a run of a workflow anyone may run here (list_public), as its publisher published it, with your `input` (checked against its input: block). Waits up to `wait` seconds (25 by default, 55 at most) for the run to end and answers { workflow, run_id, token, key, existing?, credit?, run: { status, output?, error?, artifacts?, totals?, charged? } } — `charged` (dollars) once it ended and was charged. Still running then: the same with `next`, and get_run { workflow, run_id, token } reads it again (the `token` is the run's receipt: it opens that run and nothing else). " + + "`key` makes a retry safe: the same key while that run is still in flight returns it (`existing: true`) and sets nothing more aside. With no key, one made from the workflow and the input is sent, so the SAME launch sent again — after paying, or after a lost answer — is the same run while it is in flight; pass a distinct `key` to start another run of the same input at once. " + + PAYING, + inputSchema: z.object({ + workflow: z.string().describe("The workflow's name, as list_public names it."), + input: z.record(z.string(), z.unknown()).optional().describe("The run's input: the fields of its input: block."), + key: z.string().min(1).max(200).optional().describe("1 to 200 characters that make a retry the same run while it is in flight."), + payment: z.string().optional().describe('An L402 credential, ":" — or the macaroon alone to claim an invoice paid without a preimage.'), + wait: z.number().min(0).max(PUBLIC_RUN_WAIT_MAX_S).optional().describe(`Seconds to wait for the run to end before answering its handle (default ${AGENT_RUN_WAIT_S}).`), + }), + execute: async function* ({ workflow, input, key, payment, wait }) { + const pay = await resolvePayment(o, payment); + if ("answer" in pay) { + yield pay.answer; + return; + } + const sent = key ?? defaultLaunchKey(workflow, input); + const out = await o.doors.launch({ + name: workflow, + who: o.who, + ...(pay.authorization ? { authorization: pay.authorization } : {}), + readBody: async () => ({ body: { input: input ?? {}, key: sent } }), + }); + const claimed = pay.claimed ? { credential: pay.claimed } : {}; + if (out.status === 402) { + yield { ...paymentRequiredResult(out.body, `call run_workflow again with the same workflow, input and key ("${sent}")`), key: sent, ...claimed }; + return; + } + if (out.status !== 202) { + yield { ...refusal(out), key: sent, ...claimed }; + return; + } + const runId = String(out.body["runId"]); + const token = String(out.body["token"]); + yield* follow(workflow, runId, token, wait ?? AGENT_RUN_WAIT_S, { + key: sent, + ...(out.body["existing"] ? { existing: true } : {}), + ...(out.body["credit"] ? { credit: out.body["credit"] } : {}), + ...claimed, + }); + }, + }), + + call_tool: tool({ + description: + "Call a STEP anyone may call here (list_public, kind step) once, with your `config` (checked against its input schema); it answers within 60 s: { output, credit? } — `credit` on a priced call: { charged_usd, remaining_usd, remaining_sats, expires }. " + + "A priced call that failed, ran out of time or returned too much is still charged its cost (no markup, no base): look at the error before sending it again. There is no key: every call is a call. " + + PAYING, + inputSchema: z.object({ + type: z.string().describe("The step type, as list_public names it."), + config: z.record(z.string(), z.unknown()).optional().describe("The step's config: the fields of its input schema."), + payment: z.string().optional().describe('An L402 credential, ":" — or the macaroon alone to claim an invoice paid without a preimage.'), + }), + execute: async function* ({ type, config, payment }) { + const pay = await resolvePayment(o, payment); + if ("answer" in pay) { + yield pay.answer; + return; + } + const claimed = pay.claimed ? { credential: pay.claimed } : {}; + const work = o.doors.call({ + type, + who: o.who, + ...(pay.authorization ? { authorization: pay.authorization } : {}), + readBody: async () => ({ body: { config: config ?? {} } }), + }); + for await (const y of withBeats(work, every, (n) => ({ status: "running", type, progress: n }))) { + const a = y as PublicAnswer | { status: "running" }; + if (a.status === "running") { + yield a; + continue; + } + const answer = a as PublicAnswer; + if (answer.status === 200) yield { ...answer.body, ...claimed }; + else if (answer.status === 402) yield { ...paymentRequiredResult(answer.body, `call call_tool again with the same type and config`), ...claimed }; + else yield { ...refusal(answer), ...claimed }; + } + }, + }), + + get_run: tool({ + description: + `A run you started with run_workflow, by its RUN TOKEN: { workflow, run_id, token, run: { status, output?, error?, artifacts?, totals?, charged? } }. Waits up to \`wait\` seconds (25 by default, ${PUBLIC_RUN_WAIT_MAX_S} at most) while it is in flight; still running then, \`next\` says to ask again. An artifact's \`url\` carries the token and opens with no other credential. ` + + "What you read is what the item's `results` lets its launcher read: its output and declared files, or the whole run.", + inputSchema: z.object({ + workflow: z.string(), + run_id: z.string(), + token: z.string().describe("The run's token, from run_workflow's answer."), + wait: z.number().min(0).max(PUBLIC_RUN_WAIT_MAX_S).optional(), + }), + execute: async function* ({ workflow, run_id, token, wait }) { + yield* follow(workflow, run_id, token, wait ?? AGENT_RUN_WAIT_S, {}); + }, + }), + + get_credit: tool({ + description: + "A credit token's balance here: { paid_usd, spent_usd, held_usd (set aside for runs in flight), remaining_usd, remaining_sats (at the rate it was bought at), expires, expired, replaced, entries (the newest 50: payment, hold, charge, carry, expiry) }. " + + 'The token is `payment: ":"`, else this connection\'s Authorization: L402 header. With the macaroon alone (no preimage — a person paid the invoice from a phone), it is a CLAIM: { state: pending | expired | settled, credential? } — once settled, `credential` is the whole ":" to send as payment from then on.', + inputSchema: z.object({ + payment: z.string().optional().describe('":", or the macaroon alone to claim it.'), + }), + execute: async ({ payment }) => { + const raw = payment?.trim().replace(/^(?:L402|LSAT)\s+/i, ""); + if (raw && !(raw.includes(":") && raw.slice(raw.lastIndexOf(":") + 1))) { + const claimed = await o.doors.claim({ macaroon: raw.replace(/:$/, ""), who: o.who }); + if (claimed.status !== 200) return refusal(claimed); + if (claimed.body["state"] !== "settled" || typeof claimed.body["credential"] !== "string") return { ...claimed.body }; + const view = await o.doors.credit({ authorization: `L402 ${claimed.body["credential"]}` }); + return { state: "settled", credential: claimed.body["credential"], ...(view.status === 200 ? { credit: view.body } : {}) }; + } + const authorization = raw ? `L402 ${raw}` : o.authorization; + const view = await o.doors.credit({ authorization }); + return view.status === 200 ? view.body : refusal(view); + }, + }), + }; + return capToolResults(tools); +} + +/** `InitializeResult.instructions` for `POST /public/mcp`: what it is, for + * an agent, and the page's tool beside it. */ +export function publicMcpInstructions(publicUrl: string): string { + return ( + `This is a strut server's PUBLIC door at ${publicUrl.replace(/\/+$/, "")} — what its owner opened to anyone, with no account. ` + + "list_public says what you may run, its price and its input; run_workflow starts a workflow (and waits for it a while), call_tool calls a step once, get_run reads a run you started by its token, get_credit reads the credit you bought. " + + PAYING + + " step_run is a public app page's own tool, for the page's host to relay; an agent uses the five above." + ); +} + +// ── GET /llms.txt (llmstxt.org) ─────────────────────────────────────────── + +/** Dollars as an agent reads them: cents, more places under a cent. */ +const usd = (n: number) => (n > 0 && n < 0.01 ? `$${n.toFixed(4).replace(/0+$/, "")}` : `$${n.toFixed(2)}`); + +/** What one item costs, in a line: free, a fixed price, metered, or both; + * the credit a run needs to start; what recent runs were charged. */ +export function priceWords(r: Pick): string { + const unit = r.kind === "step" ? "a call" : "a run"; + const price = + !r.base && !r.markup + ? "free" + : !r.markup + ? `${usd(r.base)} ${unit}` + : `${r.base ? `${usd(r.base)} + ` : ""}${r.markup}× what ${unit} costs${r.capUsd !== null ? `, at most ${usd(r.capUsd)} of cost` : ""}`; + const reserve = r.reserve > 0 ? `; needs ${usd(r.reserve)} of credit to start` : ""; + const recent = r.recent.runs ? `; ${r.recent.runs} in the last ${r.recent.days} days, ${usd(r.recent.average)} charged on average` : ""; + return `${price}${reserve}${recent}`; +} + +/** An `input:` block (or a step's JSON Schema) as one line of fields. */ +function inputWords(r: Pick): string { + const input = r.input as Record | null | undefined; + if (!input || typeof input !== "object") return ""; + if (r.kind === "workflow") { + const fields = Object.entries(input as Record).map( + ([k, f]) => `\`${k}\` (${f?.type ?? "string"}${f?.required === false || f?.default !== undefined ? ", optional" : ""})`, + ); + return fields.length ? `; input: ${fields.join(", ")}` : "; no input"; + } + const props = (input["properties"] ?? {}) as Record; + const required = new Set(Array.isArray(input["required"]) ? (input["required"] as string[]) : []); + const fields = Object.entries(props).map(([k, f]) => `\`${k}\` (${typeof f?.type === "string" ? f.type : "any"}${required.has(k) ? "" : ", optional"})`); + return fields.length ? `; config: ${fields.join(", ")}` : "; no config"; +} + +const oneLine = (s: string) => s.replace(/\s+/g, " ").trim(); + +/** The run section of an `llms.txt`: each runnable item, its price and its + * input, with how to run it. Shared with the index's page when the index + * strut sells too. */ +export function runnableSection(runnable: ListedRunnable[], base: string): string[] { + const out: string[] = []; + const flows = runnable.filter((r) => r.kind === "workflow"); + const steps = runnable.filter((r) => r.kind === "step"); + const enc = (s: string) => s.split("/").map(encodeURIComponent).join("/"); + out.push("## Run", ""); + if (!flows.length) out.push("No workflow is open to anyone to run here."); + for (const r of flows) { + out.push(`- [${r.name}](${base}/public/workflows/${encodeURIComponent(r.name)}/run): ${r.description ? `${oneLine(r.description)} — ` : ""}${priceWords(r)}${inputWords(r)}`); + } + out.push("", "## Tools", ""); + if (!steps.length) out.push("No step is open to anyone to call here."); + for (const r of steps) { + out.push(`- [${r.name}](${base}/public/steps/${enc(r.name)}/run): ${r.description ? `${oneLine(r.description)} — ` : ""}${priceWords(r)}${inputWords(r)}`); + } + return out; +} + +/** `GET /llms.txt` on a strut whose public view has something (llmstxt.org): + * what anyone may run here and its price, how an agent runs it (MCP or + * plain HTTP), how a priced one is paid, and what else is public to read. */ +export function publicLlmsTxt( + listing: { + runnable: ListedRunnable[]; + workflows: Array<{ name: string; description?: string }>; + apps: Array<{ name: string; title?: string; description?: string }>; + concepts: Array<{ name: string; description?: string }>; + }, + opts: { publicUrl: string }, +): string { + const base = opts.publicUrl.replace(/\/+$/, ""); + const host = (() => { + try { + return new URL(base).host; + } catch { + return base; + } + })(); + const out: string[] = [ + `# strut server at ${host}`, + "", + "> A strut server — the workflow engine — whose owner opened some of what it holds to anyone, with no account: workflows and tools anyone may RUN, free or paid in sats over Lightning (L402), and what anyone may READ. Every price is in dollars; a buyer is quoted sats at the rate of the moment.", + "", + `For an agent: this server is an MCP server — \`claude mcp add --transport http ${host} ${base}/public/mcp\` gives you list_public, run_workflow, call_tool, get_run and get_credit, with no credential. The same doors are plain HTTP, below.`, + "", + ...runnableSection(listing.runnable, base), + "", + "## Paying", + "", + `- A priced launch or call with no credit answers \`402 Payment Required\` with \`WWW-Authenticate: L402 macaroon="…", invoice="…"\` and the same in its JSON body (usd, sats, rate, expires). Pay the BOLT11 invoice with any Lightning wallet and send the request again with \`Authorization: L402 :\`. The credential is credit here, in dollars, for a year; each run is charged base + markup × what it cost, never past the credit it set aside, and the rest stays.`, + `- [Your credit](${base}/public/credit): GET with the credential — paid, spent, set aside, remaining`, + `- [Claim](${base}/public/credit/claim): POST \`{ macaroon }\` — for a wallet that gives no preimage; answers the credential once the invoice settles`, + `- A launch: POST \`{ input, key? }\` → \`202 { runId, token }\`; read it at ${base}/public/workflows/{name}/runs/{runId}?t={token}&wait=55 (the token opens that run and nothing else). The same \`key\` while the run is in flight returns it.`, + `- A call: POST \`{ config }\` → \`200 { output, credit? }\`.`, + "", + "## Read", + "", + `- [Everything public](${base}/public): the JSON listing — workflows, steps, Concept roots, apps, and what anyone may run`, + ]; + for (const w of listing.workflows) out.push(`- [${w.name}](${base}/public/workflows/${encodeURIComponent(w.name)}/flow): ${w.description ? oneLine(w.description) : "a workflow"} (its definition)`); + for (const a of listing.apps) out.push(`- [${a.title ?? a.name}](${base}/?app=${encodeURIComponent(a.name)}&solo=1&public=1): ${a.description ? oneLine(a.description) : "an app"} (a page, for a person)`); + for (const c of listing.concepts) out.push(`- [${c.name}](${base}/public/concepts/${encodeURIComponent(c.name)}): ${c.description ? oneLine(c.description) : "a Concept page"}`); + out.push("", "## Optional", "", `- [strut](https://github.com/stakwork/strut): the workflow engine this server runs`); + return out.join("\n") + "\n"; +} diff --git a/src/public-routes.ts b/src/public-routes.ts index ef104bef..03e3329e 100644 --- a/src/public-routes.ts +++ b/src/public-routes.ts @@ -143,10 +143,58 @@ export interface PublicLaunchRequest { visitor?: string; } -/** What `publicRoutes` hands back to the server: the launch, for the - * visitor's door. */ +/** One public tool call (§3's order), whichever door it came through. */ +export interface PublicCallRequest { + type: string; + /** The caller's address: the calls' budget, its share of calls in flight. */ + who: string; + /** `Authorization` as sent: a credit token as `L402 :`. */ + authorization?: string | undefined; + /** The body (`{ config }` and nothing else), read only once the budget, + * the door and the drain have answered; or the refusal reading it came to. */ + readBody: () => Promise<{ body: unknown } | { refused: PublicLaunchOutcome }>; +} + +/** An answer of the doors below, as data: the route's status, body and + * headers. */ +export interface PublicAnswer { + status: number; + body: Record; + headers?: Record; +} + +/** What `publicRoutes` hands back to the server: the doors a second front + * door shares — a visitor's page's `run/launch` and an agent's tools on + * `POST /public/mcp` (plans/paid-runs.md §10) — so admission, budgets, + * caps, price, keys and drain are ONE code path whichever way a caller + * came. */ export interface PublicDoors { launch(r: PublicLaunchRequest): Promise; + /** A tool's call: the calls' per-address budget, the drain, the body, then + * src/public-tools.ts `call`. */ + call(r: PublicCallRequest): Promise; + /** The launcher's view of a public run, with its RUN TOKEN (§3): 404 for a + * token that is not that run's. `wait` holds while it is in flight (s, + * at most `PUBLIC_RUN_WAIT_MAX_S`). */ + run(r: { name: string; runId: string; token: string | undefined; wait?: number }): Promise; + /** A credit token's balance (`GET /public/credit`). */ + credit(r: { authorization: string | undefined }): Promise; + /** A payer with no preimage (`POST /public/credit/claim`): the macaroon alone. */ + claim(r: { macaroon: string; who: string }): Promise; + /** What anyone may run here, and for how much (`GET /public`'s `runnable`). */ + runnable(): Promise; + /** `GET /public` itself: everything the public view shows. */ + listing(): Promise; +} + +/** `GET /public`: what the public view shows, and what anyone may run. */ +export interface PublicListing { + workflows: Array<{ name: string; activeVersion: string; versions: string[]; description?: string; category?: string; runs: boolean }>; + steps: Array<{ type: string; source: string; description?: string; effect?: string }>; + concepts: Array<{ name: string; description?: string; pages: number }>; + types: string[]; + apps: PublicAppInfo[]; + runnable: ListedRunnable[]; } /** A public app as a visitor's host reads it. */ @@ -353,11 +401,11 @@ export function publicRoutes(app: Hono, deps: PublicRouteDeps): PublicDoors // ── The surface ───────────────────────────────────────────────────────── - app.get("/public", async (c) => { + const listing: PublicDoors["listing"] = async () => { const s = await surface.get(); const tree = s.concepts.length ? await surface.conceptTree() : undefined; const roots = tree ? tree.concepts.filter((x) => !x.parent) : []; - return c.json({ + return { workflows: await listedWorkflows(), steps: await listedSteps(), concepts: roots.map((r) => ({ name: r.name, ...(r.description ? { description: r.description } : {}), pages: tree!.concepts.filter((x) => x.root === r.root).length })), @@ -365,8 +413,9 @@ export function publicRoutes(app: Hono, deps: PublicRouteDeps): PublicDoors apps: await listedApps(), // Listed whether or not its definition is public (decision 11). runnable: (await deps.runnable?.()) ?? [], - }); - }); + }; + }; + app.get("/public", async (c) => c.json(await listing())); // ── Apps ──────────────────────────────────────────────────────────────── @@ -484,34 +533,45 @@ export function publicRoutes(app: Hono, deps: PublicRouteDeps): PublicDoors * output, error, what it spent and its declared artifacts with links * here — and, once it ended and was charged, `charged`, in dollars, as * the ledger recorded it. A free run is charged nothing: `charged: 0`, - * its cost (`totals.cost`) the seller's. `?wait=` holds while it is in flight (at most 55 s). With - * `results: "run"`, the summary as a public run shows it. */ - const launcherView = async (c: Context, got: OpenRun) => { + * its cost (`totals.cost`) the seller's. `waitS` holds while it is in + * flight (at most 55 s). With `results: "run"`, the summary as a public + * run shows it. */ + const launcherBody = async (name: string, runId: string, open: OpenRun["open"], waitS: number): Promise> => { const runs = deps.launches!.runs; - const waitS = Number(c.req.query("wait") ?? 0); const deadline = Date.now() + 1000 * Math.min(PUBLIC_RUN_WAIT_MAX_S, Number.isFinite(waitS) && waitS > 0 ? waitS : 0); - let summary = await store.getRunSummary(got.name, got.runId); - while ((!summary || runs.pending(got.name, got.runId)) && Date.now() < deadline) { + let summary = await store.getRunSummary(name, runId); + while ((!summary || runs.pending(name, runId)) && Date.now() < deadline) { await new Promise((r) => setTimeout(r, 250)); - summary = await store.getRunSummary(got.name, got.runId); + summary = await store.getRunSummary(name, runId); } - const ended = !!summary && !runs.pending(got.name, got.runId); - const charged = ended ? await runs.charged(got.name, got.runId).catch(() => undefined) : undefined; + const ended = !!summary && !runs.pending(name, runId); + const charged = ended ? await runs.charged(name, runId).catch(() => undefined) : undefined; let base: Record; if (summary) base = { ...summary }; else { - const events = await store.getRunEvents(got.name, got.runId); - const partial = summarizeFromEvents(got.name, got.runId, events, deps.liveStatus(got.name, got.runId)); - base = partial ? { ...(partial as unknown as Record) } : { runId: got.runId, workflow: got.name, status: deps.liveStatus(got.name, got.runId) }; + const events = await store.getRunEvents(name, runId); + const partial = summarizeFromEvents(name, runId, events, deps.liveStatus(name, runId)); + base = partial ? { ...(partial as unknown as Record) } : { runId, workflow: name, status: deps.liveStatus(name, runId) }; } - const shown = got.open === "all" ? publicSummary(base) : outputOnly(base); - const artifacts = launcherArtifacts(got.name, got.runId, summary?.output); - return c.json({ + const shown = open === "all" ? publicSummary(base) : outputOnly(base); + const artifacts = launcherArtifacts(name, runId, summary?.output); + return { ...shown, ...(artifacts ? { artifacts } : {}), // What the run was charged, from the ledger: a free run nothing. ...(ended ? { charged: microsToUsd(charged ?? 0) } : {}), - }); + }; + }; + const launcherView = async (c: Context, got: OpenRun) => c.json(await launcherBody(got.name, got.runId, got.open, Number(c.req.query("wait") ?? 0))); + + /** A launcher's run, read by its token alone (an agent's `get_run`). */ + const runByToken: PublicDoors["run"] = async ({ name, runId, token, wait }) => { + const missing = { status: 404, body: { error: `no run "${runId}" of "${name}" for that token` } }; + if (!deps.launches || !runId || /[/\\]|\.\./.test(runId) || !isPublicRunToken(name, runId, token)) return missing; + const start = (await store.getRunEvents(name, runId)).find((e) => e.type === "run.start"); + if (start ? start.origin !== "public" : !deps.launches.runs.pending(name, runId)) return missing; + const all = (await surface.workflow(name))?.runs || (await surface.runnable("workflow", name))?.results === "run"; + return { status: 200, body: await launcherBody(name, runId, all ? "all" : "output", wait ?? 0) }; }; app.get("/public/workflows/:name/runs/:runId/events", async (c) => { @@ -725,18 +785,7 @@ export function publicRoutes(app: Hono, deps: PublicRouteDeps): PublicDoors who: visitorAddress(c), authorization: c.req.header("authorization"), // Read only once the cheaper refusals have answered. - readBody: async () => { - const length = Number(c.req.header("content-length") ?? 0); - const tooBig = { refused: { status: 413 as const, body: { error: `the body is over ${PUBLIC_RUN_BODY_MAX_BYTES / 1024} KB` } } }; - if (length > PUBLIC_RUN_BODY_MAX_BYTES) return tooBig; - const raw = await c.req.text(); - if (Buffer.byteLength(raw) > PUBLIC_RUN_BODY_MAX_BYTES) return tooBig; - try { - return { body: raw.trim() ? (JSON.parse(raw) as unknown) : {} }; - } catch { - return { refused: { status: 400 as const, body: { error: "the body is not JSON" } } }; - } - }, + readBody: () => readJsonBody(c), }); return c.json(out.body, out.status, out.headers); }); @@ -772,30 +821,40 @@ export function publicRoutes(app: Hono, deps: PublicRouteDeps): PublicDoors } return ++at.n > callLimit; }; - app.post("/public/steps/:type{.+}/run", async (c) => { - const tools = deps.tools; - const type = c.req.param("type"); - const who = visitorAddress(c); - if (overCallBudget(who)) return c.json({ error: `too many calls: ${callLimit} a minute` }, 429, { "retry-after": "60" }); - if (!tools) return c.json({ error: `step "${type}" is not open to anyone to call on this strut server` }, 404); - const draining = tools.draining(); - if (draining) return c.json({ error: draining }, 503, { "retry-after": "60" }); + /** A JSON body of at most 256 KB, read from the request, or the refusal. */ + const readJsonBody = async (c: Context): Promise<{ body: unknown } | { refused: PublicLaunchOutcome }> => { const length = Number(c.req.header("content-length") ?? 0); - if (length > PUBLIC_RUN_BODY_MAX_BYTES) return c.json({ error: `the body is over ${PUBLIC_RUN_BODY_MAX_BYTES / 1024} KB` }, 413); + const tooBig = { refused: { status: 413 as const, body: { error: `the body is over ${PUBLIC_RUN_BODY_MAX_BYTES / 1024} KB` } } }; + if (length > PUBLIC_RUN_BODY_MAX_BYTES) return tooBig; const raw = await c.req.text(); - if (Buffer.byteLength(raw) > PUBLIC_RUN_BODY_MAX_BYTES) return c.json({ error: `the body is over ${PUBLIC_RUN_BODY_MAX_BYTES / 1024} KB` }, 413); - let body: Record; + if (Buffer.byteLength(raw) > PUBLIC_RUN_BODY_MAX_BYTES) return tooBig; try { - body = raw.trim() ? (JSON.parse(raw) as Record) : {}; + return { body: raw.trim() ? (JSON.parse(raw) as unknown) : {} }; } catch { - return c.json({ error: "the body is not JSON" }, 400); + return { refused: { status: 400 as const, body: { error: "the body is not JSON" } } }; } - if (!body || typeof body !== "object" || Array.isArray(body)) return c.json({ error: "the body is a JSON object: { config }" }, 400); + }; + const callTool: PublicDoors["call"] = async (r) => { + const tools = deps.tools; + const { type, who } = r; + if (overCallBudget(who)) return { status: 429, body: { error: `too many calls: ${callLimit} a minute` }, headers: { "retry-after": "60" } }; + if (!tools) return { status: 404, body: { error: `step "${type}" is not open to anyone to call on this strut server` } }; + const draining = tools.draining(); + if (draining) return { status: 503, body: { error: draining }, headers: { "retry-after": "60" } }; + const read = await r.readBody(); + if ("refused" in read) return read.refused; + const body = read.body as Record; + if (!body || typeof body !== "object" || Array.isArray(body)) return { status: 400, body: { error: "the body is a JSON object: { config }" } }; + if (Buffer.byteLength(JSON.stringify(body)) > PUBLIC_RUN_BODY_MAX_BYTES) return { status: 413, body: { error: `the body is over ${PUBLIC_RUN_BODY_MAX_BYTES / 1024} KB` } }; const extra = Object.keys(body).filter((k) => k !== "config"); - if (extra.length) return c.json({ error: `a public call takes { config } alone — drop ${extra.join(", ")}` }, 400); - const presented = parseAuthorization(c.req.header("authorization")); + if (extra.length) return { status: 400, body: { error: `a public call takes { config } alone — drop ${extra.join(", ")}` } }; + const presented = parseAuthorization(r.authorization); const a = await tools.call({ type, config: body["config"], ...(presented ? { presented } : {}), address: who }); - return c.json(a.body, a.status, a.headers); + return { status: a.status, body: a.body as unknown as Record, ...(a.headers ? { headers: a.headers } : {}) }; + }; + app.post("/public/steps/:type{.+}/run", async (c) => { + const a = await callTool({ type: c.req.param("type"), who: visitorAddress(c), authorization: c.req.header("authorization"), readBody: () => readJsonBody(c) }); + return c.json(a.body, a.status as 200, a.headers); }); // ── Credit (plans/paid-runs.md §3's table, §4) ────────────────────────── @@ -804,14 +863,25 @@ export function publicRoutes(app: Hono, deps: PublicRouteDeps): PublicDoors * and in sats at its own rate, its end, the newest 50 entries. The token * as `Authorization: L402 :`; a token never used * before is used here (the wallet asked once). */ - app.get("/public/credit", async (c) => { - if (!deps.credit || !(await deps.credit.selling())) return c.json({ error: "this strut server has sold no credit" }, 404); - const auth = parseAuthorization(c.req.header("authorization")); - if (!auth) return creditRefusal(c, { status: 401, code: "invalid_token", error: "invalid_token: present the token as Authorization: L402 :" }); + /** A credit refusal as an answer: `creditRefusal`, as data. */ + const refusalAnswer = (r: Pick): PublicAnswer => { + const headers: Record = {}; + if (r.retryAfter !== undefined) headers["retry-after"] = String(r.retryAfter); + if (r.status === 401) headers["www-authenticate"] = `L402 error="${r.code}"`; + return { status: r.status, body: { error: r.error, code: r.code }, headers }; + }; + const creditView: PublicDoors["credit"] = async ({ authorization }) => { + if (!deps.credit || !(await deps.credit.selling())) return { status: 404, body: { error: "this strut server has sold no credit" } }; + const auth = parseAuthorization(authorization); + if (!auth) return refusalAnswer({ status: 401, code: "invalid_token", error: "invalid_token: present the token as Authorization: L402 :" }); const used = await deps.credit.check(auth); - if (!used.ok) return creditRefusal(c, used); + if (!used.ok) return refusalAnswer(used); const view = await deps.credit.view(used.token.tokenId); - return view ? c.json(view) : c.json({ error: "no such token" }, 404); + return view ? { status: 200, body: view as unknown as Record } : { status: 404, body: { error: "no such token" } }; + }; + app.get("/public/credit", async (c) => { + const a = await creditView({ authorization: c.req.header("authorization") }); + return c.json(a.body, a.status as 200, a.headers); }); /** A challenge for more credit than one run's reserve: `{ usd }` or @@ -863,6 +933,13 @@ export function publicRoutes(app: Hono, deps: PublicRouteDeps): PublicDoors /** A payer with no preimage (§4.4): the macaroon alone, as `{ macaroon }` * or `Authorization: L402 `. `pending`, `expired`, or * `settled` with the preimage and the whole credential. */ + const claimCredit: PublicDoors["claim"] = async ({ macaroon, who }) => { + if (!deps.credit || !(await deps.credit.selling())) return { status: 404, body: { error: "this strut server has sold no credit" } }; + const got = await deps.credit.claim({ macaroon }, who); + if (!got.ok) return refusalAnswer(got); + const { ok: _ok, ...out } = got; + return { status: 200, body: out }; + }; app.post("/public/credit/claim", async (c) => { if (!deps.credit || !(await deps.credit.selling())) return c.json({ error: "this strut server has sold no credit" }, 404); let macaroon = parseAuthorization(c.req.header("authorization"))?.macaroon; @@ -877,10 +954,8 @@ export function publicRoutes(app: Hono, deps: PublicRouteDeps): PublicDoors } } if (!macaroon) return c.json({ error: "the body is { macaroon }" }, 400); - const got = await deps.credit.claim({ macaroon }, visitorAddress(c)); - if (!got.ok) return creditRefusal(c, got); - const { ok: _ok, ...out } = got; - return c.json(out); + const a = await claimCredit({ macaroon, who: visitorAddress(c) }); + return c.json(a.body, a.status as 200, a.headers); }); // After the run routes: a version's YAML, the catch-all. @@ -994,5 +1069,5 @@ export function publicRoutes(app: Hono, deps: PublicRouteDeps): PublicDoors return c.json({ ref_id: node.ref_id, node_type: node.node_type ?? null, name: node.name ?? null, title: node.title ?? null, properties: node.properties }); }); - return { launch }; + return { launch, call: callTool, run: runByToken, credit: creditView, claim: claimCredit, runnable: async () => (await deps.runnable?.()) ?? [], listing }; } From 4ffd085234e84589d5d38e405783707f09a245a1 Mon Sep 17 00:00:00 2001 From: Evanfeenstra Date: Tue, 10 Nov 2026 19:53:59 -0800 Subject: [PATCH 2/3] Paid runs 8: the store's Run form; progress from 10 s in; docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live workflow or step anyone may run gets its price card and a Run (Call) form drawn from its input on its store page. The launch goes to the publisher from the visitor's browser with no credential; one key per press, resent on every retry. A 402 shows the PaymentSheet, the paying loop is the visitor's callPaying over the HTTP answers, and the credit and every invoice shown live per publisher in sessionStorage, claimed until settled whether or not the sheet is open. The runs a tab launched are read back by their run tokens until they end. The agent tools yield progress every 5 s: the door sends a yield's progress when the next one arrives, so the first notification lands 10 s in. specs/API.md §17.8, the store paragraph, /index/live's run; AGENTS.md; the plan's status line. --- AGENTS.md | 14 +- plans/paid-runs.md | 1 + specs/API.md | 98 ++++++++++- src/createStrut.ts | 8 +- src/public-agents.ts | 9 +- web/src/api.ts | 46 +++++ web/src/components/Store.tsx | 322 +++++++++++++++++++++++++++++++--- web/src/store-purse.ts | 83 +++++++++ web/src/store-run.test.ts | 141 +++++++++++++++ web/src/store-run.ts | 207 ++++++++++++++++++++++ web/src/styles/components.css | 13 ++ web/src/visitor-pay.ts | 6 +- 12 files changed, 902 insertions(+), 46 deletions(-) create mode 100644 web/src/store-purse.ts create mode 100644 web/src/store-run.test.ts create mode 100644 web/src/store-run.ts diff --git a/AGENTS.md b/AGENTS.md index 428c6d2b..6fd7d2f8 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), `credit` and `claim` (the credit routes as data), `runnable` and `listing` (`GET /public`'s answer), each answering `{ status, body, headers? }`; 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 key the agent names, else `defaultLaunchKey` — the workflow and input hashed — so the same launch sent again after paying or a lost answer is the same run while in flight; waits `wait` s, 25 by default and 55 at most, for the run, then answers its handle `{ run_id, token }` with `next`), `call_tool { type, config?, payment? }` (`PublicDoors.call`; no key: a failed priced call is charged its cost, the description says so), `get_run { workflow, run_id, token, wait? }` (`PublicDoors.run`) and `get_credit { payment? }` (the balance; with a macaroon alone, the CLAIM). 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. The tools that hold a call (`run_workflow`, `get_run`, `call_tool`) are generators that yield progress every `AGENT_PROGRESS_EVERY_MS` (5 s: the door sends a yield's progress when the next arrives, so the first notification reaches a client 10 s in). Run links are absolute, each file carrying the run token. `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, 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 │ ├── 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 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`) ├── 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 `