Skip to content

Paid runs 8: agents and the store — /public/mcp tools, llms.txt, prices in live items, the store's Run form - #620

Merged
Evanfeenstra merged 3 commits into
mainfrom
feature/paid-runs-8-agents
Nov 11, 2026
Merged

Evanfeenstra merged 3 commits into
mainfrom
feature/paid-runs-8-agents

Conversation

@Evanfeenstra

@Evanfeenstra Evanfeenstra commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Work item 8 of plans/paid-runs.md, "Agents and the store" (§10 "An agent over MCP" and "The store").

What it does

An agent over MCP (src/public-agents.ts). POST /public/mcp now serves five tools beside a public app page's step_run. step_run is unchanged and still app-only.

Tool
list_public What anyone may run here: price, reserve, recent charges, input (GET /public's runnable), plus call_with
run_workflow Launches through PublicDoors.launch, the same function the HTTP route uses, so admission, budgets, caps, price, key and drain are one code path. It waits wait s (25 by default, 45 at most), then answers a handle { run_id, token } with next. The run's first progress note already carries run_id and token
call_tool Goes through a new PublicDoors.call: the calls' per-address budget, the drain, the body, then createPublicTools().call. The HTTP route is now a thin wrapper over it
get_run The launcher's view, read by the run token, waiting while the run is in flight
get_credit The token's balance, plus runs: the runs and calls it paid for, each run with its run token. Given the macaroon alone, it claims the invoice instead
  • An unpaid priced call answers a result, not an error: { payment_required: { macaroon, invoice, usd, sats, rate, description, expires, remaining_usd?, reason?, carries? }, next, key? }. The agent pays and calls again with payment: "<macaroon>:<preimage>". The connection's own Authorization: L402 header works too.
  • The door keeps nothing between requests, so credit bought on one connection is spent on any other.
  • A priced launch always carries a key. It is the agent's own key, or else one the launcher hashes from the input as the workflow's input: block parsed it, keys sorted. A retry while the run is in flight, after paying or after a lost answer, returns that run and never sets aside a second reserve. A free launch carries a key only when the agent passes one, because its scope is the caller's address, which a hosted agent's users share.
  • The way back to a lost answer is get_credit. A key matches only a run in flight (Paid runs: a key dedupes a run in flight only, a charge never passes its reserve #615), so once a run ended, the same call would start and pay for another. get_credit (and GET /public/credit) lists the runs and calls the credential paid for, each run with its run token, and every tool's description says to read it before calling again after a timeout or a dropped answer.
  • Every tool answers within 45 s, under the MCP SDK's 60 s default request timeout, which progress resets only when a client opts in. A launch still starting, a run still going (its handle) or a call still going (still_running, "check get_credit") answers what it has. Progress also goes to a client that passed a progressToken.
  • Nothing paid for is cut. There's no builder cap: a call's output comes back whole (the door's 5 MB). A run's output over 100k characters is left out of the answer and pointed to at the run's url, which carries the token. Every run answer carries that url.
  • A run reads as going by its own status. One that ended with its charge pending says charge_pending, instead of looking like it's still running.

PublicDoors (src/public-routes.ts) gains call, run, credit, claim, runnable and listing. The routes POST /public/steps/:type/run, GET /public/credit, POST /public/credit/claim and GET /public now call them.

GET /llms.txt is open whatever the role.

  • An index strut keeps its store page as before, and appends a # Run on this server section when it also sells.
  • Any other strut serves the public view's page: each runnable item with its price, input and recent charges, how an agent runs it (MCP or HTTP) and pays, and what else is public.
  • With nothing public it answers 404.
  • It's built at most every 30 s per base URL and counted against the address's /public budget. The /public/mcp lines are left out when the MCP doors are off.

Prices in the index's live items (src/packages/index.ts). The live crawl keeps each server's runnable, checked as a stranger's answer (liveRunnableOf: finite numbers ≥ 0, strings cut, at most 500 items). GET /index/live carries an item's run: { base, markup, capUsd, reserve, results, recent? }. A runnable item whose definition isn't public is an item of its own with definition: false.

The store's Run form (web/src/components/Store.tsx, web/src/store-run.ts, web/src/store-purse.ts).

  • Browse shows a price badge on each runnable item.
  • A runnable live workflow (or step) page shows a price card (price, reserve, recent charges) and a Run (or Call) form. The form's fields come from the input: block through bindingsFromInputBlock and ConfigField, or from a step's JSON Schema.
  • The launch goes to the publisher from the visitor's browser with no credential. Each press of Run gets one key, which is sent again on every retry, including after a dropped request. A call is never sent twice.
  • A 402 shows Paid runs 10: a visitor's app page #613's PaymentSheet. The paying loop is Paid runs 10: a visitor's app page #613's callPaying, over the HTTP answers (one new optional launch field).
  • The credit and every invoice shown live per publisher in the store page's sessionStorage, never in a URL. One claim keeper per publisher claims each invoice until it settles or ends, whether or not the sheet is still open.
  • Your credit lives in this tab shows the credential on request, with a Copy button, and takes a copied one pasted back (checked with GET /public/credit). That's how credit survives the tab.
  • A run that ended with its charge pending shows that.

refusesCredential fix, which is also on main since #613. It only matches the door's own refusal: a result's code: invalid_token with status: 401, or else an error that begins with invalid_token:. A step's failure whose text merely mentions it (an OAuth error, already charged) keeps the credential and is never sent again. It used to drop the credential and resend, which meant a second sheet and a second execution if paid.

  • The runs a tab launched are kept and read back by their run tokens until they end, showing status, output, file links and what each was charged.

Departures from the plan

  • Paying with no preimage. payment: "<macaroon>" with no preimage claims the invoice first, on run_workflow, call_tool and get_credit. Once it settles, the answer carries the whole credential. This is so credit a person paid from a phone is never stranded for an agent.
  • The default key covers priced launches only. It is the workflow plus the parsed input with keys sorted, scoped by the credit token. Two priced runs of the same input at once need distinct keys; the tool description says so.
  • Progress runs one beat behind. src/ai/mcp.ts sends a yield's progress only when the next yield arrives (it can't tell which yield is the last), so the beat is 5 s and a launch yields its handle first.
  • The run list lives on GET /public/credit too, not just in get_credit. The credential holder launched those runs, so it may hold their tokens.
  • get_run reads by run token only. It doesn't read a public run that has no token.
  • GET /llms.txt is open on every strut. A strut with nothing public answers 404 where it used to answer 401. index.test.ts and the gate sweeps are updated.
  • The index's live items don't carry input. The store reads it fresh from the publisher's /public. The index's own llms.txt and /index/mcp don't list live items' prices yet; only /index/live and the store do.
  • Steps get the form too. A step's live page gets a Call form as well as the workflow's Run form, since the plan says "a live item's page".

Tests

  • src/public-agents.test.ts (new, 14 tests), against FakeWallet:
    • the tools list, with step_run still app-only;
    • list_public;
    • free runs with absolute file links that carry the run token;
    • a long run answering a handle, with a retry returning the same run;
    • a priced run as payment_required, then paid, then the balance read on another connection;
    • the connection header and a macaroon-only claim;
    • a lost answer resent with the same credential and key → one reserve;
    • metered charges;
    • call_tool;
    • a foreign credential refused;
    • llms.txt;
    • the progress generators.
  • src/packages/index.test.ts: a live row's runnable with prices, and junk from a stranger's server dropped.
  • web/src/store-run.test.ts (new): price words, fields, values, HTTP answers into the paying loop (402 → pay → the same key resent; refused after paying keeps the credit; cancelled), the run list.
  • Review fixes, each with a test:
    • a lost answer after a run ended, recovered from get_credit;
    • reordered input fields joining the run in flight;
    • two keyless free launches at one address running apart;
    • the 45 s deadline on a slow launch, call and run;
    • charge pending;
    • a call's 200k output whole, and a run's large output left out with its url;
    • the default key's canonical form;
    • llms.txt with MCP off;
    • refusesCredential (visitor-pay and store-run).
  • The touched suites pass under Node 22 (node@22 --import tsx --test): 290/290.
  • Full npm test on Node 24: 4234 pass. One failure, index.test.ts "a publisher asks, the operator approves…", was an ENOTEMPTY when a row's removal raced a crawl's page write under full-suite load. It passes 4/4 alone, and that path isn't touched here.

Browser check. I ran a scratch index strut serving the store and a publisher strut with FakeWallet, in the built-in pane. Each of these worked as expected:

  • Browse badges show.
  • A runnable-only workflow's page shows its price and form.
  • Run on a priced item opens the QR sheet.
  • Cancel, then pay: the claim keeper still stored the credential, and the next Run spent it with no new invoice. It was charged $0.31 = 0.25 + 2 × 0.03.
  • After a reload, the runs and the credit are still there.
  • A short top-up paid with the sheet open carried the old credit and launched the same run.
  • The step's Call form spent credit without a sheet.
  • The free item works at phone width.
  • After the review fixes, against a restarted publisher (a new L402 root):
    • the stale credential got the door's 401 and was dropped, and a fresh sheet came up; paid, the run went through;
    • Show my credential then Copy worked; pasting it into a cleared tab restored the $0.94.

Real MCP client. I used the MCP SDK against the same publisher:

  • Credit bought on one connection was spent on two others.
  • A 20 s run held a 12 s-timeout client's call open with three progress notifications.

Not tested

  • No real sats and no real wallet: everything ran against FakeWallet.
  • No MCP client other than the SDK's.
  • A strut behind stakgraph's /lab gate. That gate passes POST /public/mcp, so the agent tools work through it, but it doesn't pass GET /llms.txt, which isn't under /public. That needs a change in stakgraph.

@Evanfeenstra
Evanfeenstra force-pushed the feature/paid-runs-8-agents branch from 077d4dc to edbccc7 Compare November 11, 2026 04:20
…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.
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.
…free default key, the door's own invalid_token only, nothing paid cut, llms.txt cached

- GET /public/credit (and get_credit) lists the runs and calls the token
  paid for, each run with its run token: after a lost answer an agent finds
  the run it started instead of paying for another. Every tool says so; a
  launch's first progress note carries run_id and token.
- A priced launch's default key is made by the launcher from the PARSED
  input, keys sorted (PublicLaunchRequest.defaultKey); a free launch gets a
  key only when the agent passes one (its scope is a shared address).
- Every agent tool answers within 45 s (wait <= 45): a launch still
  starting, a run still going (its handle) or a call still going
  (still_running, check get_credit) answers what it has.
- No builder cap: a call's output comes back whole; a run's output over
  100k chars is left out with the run's url (token on it).
- A run reads as going by its own status; ended with its charge pending
  says charge_pending (the launcher's view waits at most 2 s for the charge).
- refusesCredential: only the door's own refusal (code invalid_token +
  status 401, else an error that begins invalid_token:); a step's failure
  that mentions it keeps the credential and is not sent again.
- GET /llms.txt: built at most every 30 s per base URL, counted against
  the /public budget, no /public/mcp lines when the MCP doors are off.
- The store: charge pending shown; Your credit lives in this tab: show and
  copy the credential, paste one back.
@Evanfeenstra
Evanfeenstra force-pushed the feature/paid-runs-8-agents branch from edbccc7 to c9d7730 Compare November 11, 2026 04:26
@Evanfeenstra
Evanfeenstra merged commit a9f656c into main Nov 11, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant