Skip to content

feat(router): pay the router's quoted fee, capped at $0.003, and route only on the paid route - #534

Merged
vraspar merged 31 commits into
mainfrom
vraspar/router-quoted-fee
Nov 1, 2026
Merged

vraspar merged 31 commits into
mainfrom
vraspar/router-quoted-fee

Conversation

@vraspar

@vraspar vraspar commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

What

The CLI pays the router's quoted fee, capped at $0.003, sends every routing call to the paid route, and deposits about 5 cents at a time. Based on main, and merges on its own, first. It was split out of #532 at review and needs nothing from the tolerant contract.

  • The quoted fee, under a ceiling. The router's 402 is the only price. RoutingPayer pays it at or below ROUTING_FEE_CEILING_ATOMIC ($0.003, what every install agreed to), inside the SDK's per-payment cap, the deposit cap and the spend limits. A quote above it is skipped as fee_above_ceiling, with one notice per session.
    • The ceiling applies only to the configured router's paid route: routeFor(baseUrl) refuses any other origin or path and blocks redirects. Provider 402s go through runPay against their spec price.
    • No payee and no exact price is pinned. A charge below the quote (the server's planned $0.001 charge) is the SDK's to record.
  • Deposits of about 5 cents, never $0.25. A deposit is the router's suggested amount (the 402's extra.minDeposit: $0.03 from today's production, $0.05 once the router's update ships), less if the wallet or the per-call limit is smaller, at least ten fees ($0.03 at the ceiling) and at most the SDK's $0.25 cap (fee.ts routingDepositAtomic). It replaces the rule that signed $0.25 from any wallet holding $0.50. A wallet below the deposit is wallet_low, and tenjin doctor uses the same $0.03 floor.
  • The spend question names the ceiling and the payer address: "…pays a quoted fee, at most $0.003, from funds set aside (the router's suggested amount, up to $0.25 at a time) and names your wallet address…". The install disclosure names the wallet address too.
  • One routing path. requestDecision makes exactly one request, to the paid route. A skipped payment is a skip with the once-per-session notice, never a second request.
    • Deleted: the free-path fallback, freePath, the paid-path-absent memo, the payer's 404 branch, FEE_REQUIRED/isFeeRequired and the tool's ask-twice retry.
    • tenjin hook <kind> keeps a stub that tells the person once per session to ask their agent to set Tenjin up again (updating Claude Code or Codex first if needed).
  • tenjin doctor checks the paid route. One warm POST to /route:
    • a 402 is live, and a quote above the ceiling warns;
    • a 200 (fee off) or a 400 (a server without warm) passes;
    • a 404 or 5xx fails.
      The paywall answers 402 before reading the body, so this works on today's production. A local dev server on tenjin main with the fee off reads as failed until the server change that adds the warm form lands.

Checks

  • Rebuilt on main (9a6e1f7): typecheck, eslint and prettier pass, and vitest run passes on the 25 changed test files plus both suite-wide consistency tests (26 files, 1,368 tests).
  • Deposit sizing (8a7af5f, 27a3ced): vitest run passes on the 25 changed test files (1,364 tests). Putting back the old wallet_low check fails two sizing cases.
  • Today's production 402 carries extra.minDeposit: "30000", so this build's first deposit there is $0.03.
  • Before the rebuild, end to end against a local router with the warm form and the fee off: tenjin doctor passed its paid-route check with one warm POST.
  • CI green (runs now that this targets main)

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; no new blocking issue was established.

Summary

The CLI pays the router’s quoted fee up to $0.003 and sends routing decisions only to the paid route.

  • The payer follows the router’s quote, up to $0.003 per call.
  • Routing decisions use one paid route and never retry for free.
  • Older command-style hooks now ask the person to set Tenjin up again.
  • tenjin doctor checks whether the paid route can answer.
  • Tenjin’s spend and install messages explain the quoted fee and wallet address.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Hook or request tool] --> B[Paid routing route]
  B --> C{Router asks for payment?}
  C -->|No| D[Read decision]
  C -->|Yes| E{Quote within ceiling and limits?}
  E -->|No| F[Skip routing; use native tools]
  E -->|Yes| G[Pay through routing channel]
  G --> D
Loading

Reviews (6) · Last reviewed commit: "Merge main (tolerant contract, CLI side)..." · Reviewed by Greptile

vraspar and others added 19 commits October 29, 2026 17:15
…uires`

The 23 strict answer schemas in decision.ts become tolerant readers: an
unknown field is dropped instead of failing the whole answer, and every
known field keeps its type, bounds and checks. An object naming the keys of
two arms of one union (a job's pollUrl or running rule) is still refused.

An offer may list `requires`, in the `accepts` vocabulary, on the answer or
on any spec. A build without a listed feature skips that offer alone: an
other offer's spec (and any choice that runs it) is dropped, and when the
offer is the answer's own, the answer becomes a `native` with reason code
`requires_unmet` naming the missing features. The hook warns for each
skipped offer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…m text

The over-cap note reads each listed service's price from its spec's
`priceAtomic` only; the regex that read dollar amounts out of an offer's
line, and the quoted-text guard beside it, are gone. An offer without specs
names no listed price.

A spec may set `timeoutS` (whole seconds, 1 to 120, the paid leg's own
ceiling): the provider probe's deadline and the least the paid leg waits,
in place of `--timeout`. It may set `maxBytes` (1 to 200 MiB): the cap on a
binary result and on each linked media file, in place of the 200 MiB every
call had. Both are optional and fall back to today's values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`requestDecision` always calls `POST /api/x402-router/route`. A call the
payer skips (`RouteSkipped`) returns `skipped` at once, with no second
request; with no payer, a 402 is a skip with the `no_payer` notice, and a
router charging nothing (the fee off, in development) answers the call.

Deleted: the free-route fallback in `requestDecision` and `freePath` on its
outcomes, the `paid-path-absent` router memo and the payer's 404 branch,
`PAID_PATH_ABSENT`, and the `FEE_REQUIRED`/`isFeeRequired` handling in
hooks.ts and tool.ts (including the tool's ask-twice retry). `routeFor` is
now synchronous and never null; `DecisionRoute` loses `path`.

The `tenjin hook <kind>` command form routes nothing: it tells the person
once per session to run `tenjin install`, and stays silent in a session the
plugin serves. Browse, `{tool}` and outcome reports keep the free route.

Copy and docs: the MCP instructions, `tenjin hook`/`tenjin mcp` help, the
install disclosure and docs/agent-permissions.md no longer describe a free
routing path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in#1046 step 4)

The router's 402 is the only price. The payer reads the quote before the
wallet is unlocked and pays it when it is at or below
`ROUTING_FEE_CEILING_ATOMIC` (3000 atomic USDC, fee.ts), inside the SDK's
per-payment cap, the deposit cap, the per-call limit and the daily budget.
The per-payment checks run at the quoted amount. A quote above the ceiling
is not paid: the call is skipped with `fee_above_ceiling`, reported as
blocked, and the once-per-session notice names the quoted price.

The payment policy keeps any `batch-settlement` canonical-USDC offer at or
below the ceiling; no payee or exact price is pinned. The payer pays only
the configured router's paid route: `routeFor(baseUrl).send` refuses any
other origin or path unsent, so the ceiling never applies to a provider's
402, which `runPay` holds to that spec's price.

A charge below the quote (batch settlement) is recorded by the SDK and
builds the next voucher; a test pins it. The fake router can now quote and
charge any amount.

No fee amount is left in copy: the deposit line names the quoted fee, the
unpaid line names none, and the install question, doctor, MCP instructions,
README and docs say "a small flat fee". Counts with no quote (calls left in
the channel, the funded-enough checks) use the ceiling and say "at least".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oday

Three fixture files had drifted from tenjin's origin/main
(lib/x402-router/fixtures/wire, 0414118e):

- wire-hook-execute-spec.json carried the Exa `type` enum and default from
  tenjin#988 (`lite`), which is unmerged; the server still sends
  `auto | fast | instant`. Taken from the server.
- wire-lookup-spec-by-id.json lacked the CoinMarketCap `slug` and `symbol`
  descriptions the server added. Taken from the server.
- README.md described ROUTER_VERSION 2026-10-13.1 and one routing route.
  Taken from the server (2026-10-24.1: the paid route, the harness section,
  job specs, `running` reports), then extended: this repo is now the
  canonical set, `harness/` stays client-only, a "Reading an answer" section
  states the tolerant reader and `requires`, the paid-path section states the
  fee ceiling, the spec section names `timeoutS` and `maxBytes`, and the
  client-only `headers` token and its fixture are listed.

wire-hook-execute-headers.json stays: it is this client's fixture for the
`headers` token, which the server sends only once tenjin#988 lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…opening

The one-path change reworded that line of docs/agent-permissions.md to
start "Every routing call goes to the router's paid route."; the
suite-wide skills-text test still looked for the free-path opening.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`tolerant` tells the server this build reads answers as a tolerant reader
and honors `requires`. Released CLIs (alpha.25 to 31 carry most paid
traffic and never auto-update) read strictly, so the server can send
`requires`, `honored`, `timeoutS`, `maxBytes` or any later field only to a
client that lists this token, and today's shape to the rest. Either repo
can then ship first.

Today's server ignores the token: it reads `accepts` only through
`includes()` of the tokens it knows (catalog.ts `clientRuns` and
`routerClient`, discovery `bazaarLane`), logs only known tokens, and bounds
the list at 16 tokens of 1 to 32 characters; this build sends 9.

Listed in the wire README's tokens and docs/agent-permissions.md; a test
pins it in `acceptsFor` with and without the Bazaar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The install consent now reads "Tenjin charges a small flat fee per routing
call, never more than $0.003, ...": the ceiling is the amount the person
approves, as EIP-1559's max fee is. It renders `ROUTING_FEE_CEILING_ATOMIC`,
a CLI constant, so it cannot go stale when the router's price changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erver sends

The server always adds `specs` to an open-Bazaar `discovered` answer
(`discoveredDecision` in app/api/x402-router/route.ts), and every released
CLI takes it as optional; wire-hook-discovered.json and
wire-lookup-discovered.json lacked it. Each now carries the server-generated
spec, one per file, under the answer's own id; the files keep their inline
style.

wire.test.ts expects `specs` on that answer and pins its id to the
answer's. tool.test.ts now covers both: an answer with the spec is shown
and kept under its id, and the specless candidate path keeps its own test
on a copy with `specs` removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- decision.ts: the union-refusal note names only `pollUrl`/`running`; the
  browse row is no longer called strict; `maxBytes` says it does not yet
  cap a job's polled result; `tolerant` gating is the server's once it
  reads the token (R2); `DecisionRoute` names the `unreached` case.
- hooks.ts, gate.ts, tool.ts, mcp.ts: no comment credits `tenjin hook
  <kind>` with routing, free decisions or a signer-less leg; the request
  tool pays a routing fee and one payment per take.
- hook-command.ts: `STDIN_TIMEOUT_MS` is the stub's own and no longer
  exported; the wire test that budgeted stdin plus the gate for a leg
  that no longer exists is gone.
- `tenjin hook --help` describes `alive` as live, and only the six
  routing kinds as left from older setups.
- docs: the free docs lookup pays the routing fee, a keychain that does
  not answer skips the call, safety-model.md drops "deciding costs
  nothing", and a skipped offer is "no provider paid", the answer's own
  offer becoming a named skip (same in the changeset).
- fixtures README: the `jobs` condition on never-inline services, the
  stray blank line, the "no fee" paragraph, this client's job-poll rules,
  the `takes.max`, `requires`, `timeoutS` and `maxBytes` bounds, and three
  fixture rows for files this directory does not have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A `requires_unmet` skip says "no provider was paid": the routing fee
  for that answer was. The footer names it `native tools (offer needs
  newer tenjin)`, not the router choosing native.
- A skip with no-fault reason (`channel_busy`, `busy`, `unlock_timeout`)
  tells the model in plain words that the next call usually routes,
  instead of putting the code in its text.
- The deposit line says each decision costs "at most" the quote: the
  router may charge less.
- Doctor's routing-fee row names the $0.003 ceiling, as install consent
  does.
- A body over a spec's `maxBytes` under 1 MiB names the cap in bytes,
  not "0 MB".
- A compare or multi-take quote probe waits as long as the spec's
  `timeoutS`, as the call's own probe does.
- The hook-command stub marks its once-per-session notice with its own
  marker, so it no longer overwrites the wallet notice that "Tenjin
  wallet funded/unlocked" reads.
- fee.test's list of every skip reason includes `fee_above_ceiling`, so
  the fix-text guard covers it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rom the #532 review

- decision.test's "refuses a hook answer carrying a contract" passed only
  because its fixture hint lacked the id; the hint now names it, and the
  test pins what tolerant reading does: the offer is read, the contract
  dropped.
- Test names and comments no longer call routing free or the call
  "going free" (decision, routing-payer, mcp, tool, wire, fee tests), and
  wire.test names `headers` and `tolerant` as client-side tokens.
- Dead helpers: codex-paid-hooks `connect`'s unused fetch parameter, and
  hooks-fee's `NATIVE` and 200 defaults on its one 402 router.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The paid leg's floor was the spec's deadline when one was set, so a
`timeoutS` below the CLI timeout (30 s in `tenjin mcp`) cut the request
after the signed authorization had left: the seller could settle, and the
result was lost. The floor is now the larger of the two: a spec may
lengthen the paid leg, never shorten it. The probe still takes the spec's
deadline. A test with no advertised bound fails without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… names

`requires` is a backstop: the server sends only tokens a client lists in
`accepts` (S2 filters offers by it), so a client meets an unmet token only
when that rule broke. When the backstop drops anything the answer's line
names (the answer's own offer, a spec whose id the line carries, or any
option of its choice set), the line cannot be shown as written, so the
whole answer becomes the named `requires_unmet` native. A dropped spec
the line does not name is still just dropped. This replaces trimming the
choice set while keeping a line that still offered the trimmed options.

The fixtures README states the contract rule; the permissions doc and the
changeset say the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`tenjin doctor` and `tenjin install` probed the free route, which no
longer routes, so a paid route that 404s or quotes above the ceiling
stopped every routing call while the router row read ok. The probe now
sends one request to `POST /api/x402-router/route` in the `warm` form
(`{schemaVersion: 1, warm: true}`), which runs no gate:

- 402: live and charging. Its quote is read, and one above
  `ROUTING_FEE_CEILING_ATOMIC` warns that every routing call is skipped;
  a 402 with no routing offer this CLI reads warns too.
- 200: live with the fee off. 400: a server with no `warm` form, live.
- 404 or 5xx: a failure, as before.

The x402 paywall answers a request with no payment 402 before the handler
reads the body (`withX402FromHTTPServer` returns the payment error before
calling the route handler), so this holds before tenjin#1050 ships `warm`.

Reading a quote moves to `routing-quote.ts`, shared with the payer, and
the probe loads it only on a 402, keeping x402 out of the chunk install
and the status line share. Doctor ends the routing backoff on any
answered probe, a fee warning included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… path move to their own PR

At the reviewer's and the owner's request, #532 keeps the tolerant
contract alone, and the quoted fee and the one routing path move to a PR
stacked on it (vraspar/router-quoted-fee), which restores exactly what
this commit removes.

#532 keeps: the tolerant answer schemas, `requires` and its fail-safe,
`tolerant` in `accepts`, prices and `timeoutS`/`maxBytes` from spec
fields (with the paid-leg floor and the quote probe's timeout), the
fixture drift fix and the open-Bazaar `specs`, the fixtures README's
contract rules, and the review fixes to those pieces.

Removed here, to return in the stacked PR: the quoted fee under the
`ROUTING_FEE_CEILING_ATOMIC` ceiling, `routeFor`'s route guard,
`fee_above_ceiling`, the install consent naming the ceiling, the one
routing path (tenjin#1026: one paid request, the deleted free-path
fallback, the hook-command stub), the doctor's paid-route probe, their
docs and changesets, and their review fixes. On this branch requests
take the paid path with the free-path fallback, as on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing path

Stacked on the tolerant contract (#532), this restores the part split out
of it in 23a8ad9:

- The quoted fee (tenjin#1046 step 4): the payer pays the router's 402
  quote at or below `ROUTING_FEE_CEILING_ATOMIC` (3000 atomic USDC,
  fee.ts), inside the SDK's per-payment cap, the deposit cap, the per-call
  limit and the daily budget, with the per-payment checks at the quoted
  amount. A quote above it is skipped as `fee_above_ceiling`, named once
  with its price. No payee is pinned; `routeFor` pays only the configured
  router's paid route. A charge below the quote is recorded by the SDK.
- No fee amount in copy: the deposit line names the quote as "at most",
  and install consent and doctor name the ceiling.
- One routing path (tenjin#1026, CLI half): `requestDecision` always calls
  the paid route; a call the payer skips returns `skipped` with no second
  request, and with no payer a 402 is a `no_payer` skip. The free-path
  fallback, the `paid-path-absent` memo and `FEE_REQUIRED` handling are
  gone. `tenjin hook <kind>` routes nothing and tells the person once to
  run `tenjin install`. Browse, `{tool}` and outcomes keep the free route.
- `tenjin doctor` and `install` probe the paid route in the `warm` form,
  read its quote and warn above the ceiling.
- Docs, changesets and the #532 review fixes for these pieces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es your wallet

The owner approved a consent clause: the routing payment from the wallet
names its address, which the router sees with each routed turn. Install
text must not grow, so it is folded into the fee sentence by rewording:
the deposit sizes go in parentheses, "while your balance is under" becomes
"below a ... balance", and "each deposit counts against these limits, and
fees from it are not counted again" becomes "... counts against these
limits once". The full spend question goes from 412 to 410 characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vraspar vraspar added the priority: critical Blocks other PRs; merge-priority label Oct 30, 2026
vraspar added a commit that referenced this pull request Oct 30, 2026
…-topup

#532 was split: the quoted fee, one routing path, the doctor probe and the
wallet-address consent moved to #534, which this PR
now stacks on.

Conflicts, resolved by meaning:
- spend-question.ts and its two pinned tests: kept C2's sizing (no $0.25 /
  $0.03 split) and the fee branch's wallet-address clause and closing
  sentence: "paid from funds set aside, up to $0.25 at a time, in a payment
  naming your wallet address. Unused funds remain yours; each deposit
  counts against these limits once." The whole question is 376 characters,
  the same as C2's before the merge (the fee branch's is 410).
- gate.ts: the fee branch's comment without the command form's stdin wait,
  with C2's top-up wait.
- routing-payer.ts: the quote parsing moved to `routing-quote.ts`, so C2
  keeps its `TopUp` type and `this.quoted`, and drops its own
  `routingOffers`.
- tool.ts: C2's top-up sentences, the fee branch's no-fault fallback, and a
  line of its own for `top_up_unavailable`, which that fallback would
  misdescribe.
- hooks.ts imports; the hook test's deposit line now reads "costs at most";
  one test name taken from the fee branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@A1igator A1igator left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at a6fef25 (layer over #532 at 23a8ad9): 0 blocking, 2 should fix, 0 nits

Should fix

  1. The PR body references the private repositories five times (BackTrackCo/tenjin-notes#43, BackTrackCo/tenjin#1046, #1026, #1050, #1052), which this public repository's rule forbids: each reference publishes a private PR number and creates a cross-repo backlink. Replace them with plain words (the plan this is part of; the server changes that charge under the quote and add the warm form) and keep #532, which is this repository's own. #534
  2. STALE_HOOK_LINE hands a terminal command to the person: it is shown in the harness's systemMessage to whoever is typing, and the operator's rule is that person-facing text never hands a person a command (it says "ask your agent to ..."). Reword it to tell the person to ask their agent to set Tenjin up again, and let the changeset and the tenjin hook help text follow.
    export const STALE_HOOK_LINE =

Nothing else I would hold the code on: a skipped payment makes no second request on any route (the Codex hook log pins one route unpaid line), routeFor refuses any URL off the configured router's origin and paid path before anything is sent so a provider's 402 never meets the ceiling, quoteOf takes the largest routing offer at or below $0.003 and routingPolicy offers the SDK only those, a charge below the quote is left to the SDK and tested, the doctor probe's warm POST maps 402 (quote read, above-ceiling warns), 200, 400 and 429 to live and 404 and 5xx to failed, and the hook chunk graph stays free of the SDK (dist-chunks passes on a fresh build since routing-quote is loaded only on a 402). The current plugins run mcp_tool legs, so the stub only meets a copy from an older build. Typecheck, eslint and the touched test files pass locally.

@aether-agent aether-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Reviewer · Review PR #534: feat(router): pay the quoted routing fee under a $0.003 ceiling,… · claude-code · claude-opus-5-5

Deep review of a6fef25 against base 23a8ad9 (48 files, 1,994 changed lines). The review used a context pack, 12 line auditors plus 2 catch-up auditors, 11 file owners and 6 independent reviewers, then verifiers. A coverage script confirmed that every changed and context line is in a ledger, so no lines were left unaudited.

Verdict. The money paths hold up:

  • quoteOf and routingPolicy agree, so the SDK can only pick an offer at or below the amount feeRefusal checked.
  • The routeFor origin/path guard builds its URL the same way as requestDecision, and the call runs with blockRedirects.
  • A quote above the ceiling is refused before unlock.
  • The SDK's corrective-402 retry reuses the first 402's requirements (@x402/fetch index.mjs:69), so the amount can't be raised mid-call.

The two p2s are about what the person is told:

  • On older Claude Code, the new stale-hook line sends them to tenjin install, which can't fix it there.
  • The install disclosure leaves out the wallet address that the spend question now names.

The rest are nits: comments or messages made untrue by the switch to a floating quote, and a few edge behaviours.

Leads. eslint (repo config) is clean on the changed files. semgrep p/default and p/security-audit had one hit (bidi characters, tool.test.ts:2554). It is not within three lines of a change, so I dropped it.

Single-source, not confirmed (not posted inline)

  • src/router/routing-payer.ts:609-619: the 402 body is left undrained when askFirst refuses. The base had the same pattern for its other refusals; this PR adds the fee_above_ceiling exit. Whether it really keeps sockets out of the pool is unverified.

Refuted or out of scope (dropped)

  • Per-call limit not re-checked on a corrective 402. Refuted: the SDK retries with createPaymentPayload(paymentRequired) from the first 402.
  • docs/safety-model.md:23 "same limits". Refuted: the day's budget binds through the reserved deposit.
  • Fixed $0.03 CHANNEL_DEPOSIT_ATOMIC against a floating hint in eligibility/doctor. Already true at the base.
  • src/router/fixtures/README.md fee_required. It describes the server's free route, which is still accurate.
  • docs/agent-permissions.md:125-126 "five files" and "docs fetch" background process. Stale since before this PR.

Test gaps

  • src/commands/update.test.ts:191: not.toContain('Routing costs') can no longer fail, because ROUTING_FEE_TERMS now starts "Tenjin charges…".
  • hooks-fee.test.ts:
    • The deposit-line check (≈199) uses the default quote, which equals the ceiling, so it can't tell route.fee() from the constant.
    • No hook-leg case for a quote above the ceiling (≈222-281).
    • The not POST /api/x402-router busy-channel assertion (≈216) is vacuous now.
  • tool.test.ts: no case where RouteSkipped(FEE_ABOVE_CEILING, { quotedAtomic }) reaches the agent text.
  • Doctor: a warn probe clearing the unreachable memo (doctor.ts:329) is untested.
  • hooks-backoff.test.ts: the 5xx test's memo assertion can't fail, because a skip returns before the memo code.
  • routing-payer.test.ts:
    • unpaidWhy returns undefined for a failed outcome too.
    • The refused-URL test doesn't assert the reason.
    • The two-offer test doesn't assert route.fee().
  • fee.test.ts: EXPECTED lacks fee_above_ceiling, and the comment at :93 says notices name "no fee amount".
  • No fake router can simulate a server missing /route (404) any more.
  • Stale test names or comments:
    • reachability.test.ts:84 "empty body"
    • hooks.test.ts:808-812
    • codex-paid-hooks.test.ts:192
    • mcp.test.ts:1075

Comment thread src/router/hook-command.ts Resolved
Comment thread src/router/install.ts Resolved
Comment thread src/router/hook-command.ts Resolved
Comment thread src/router/reachability.ts Resolved
Comment thread src/router/spend-question.ts Resolved
Comment thread docs/agent-permissions.md Resolved
Comment thread src/router/gate.ts Resolved
Comment thread src/router/doctor.ts Resolved
Comment thread src/router/reachability.ts Resolved
Comment thread docs/agent-permissions.md Resolved
vraspar and others added 4 commits October 29, 2026 23:17
…ed skip, name the ceiling

- tool.ts: a skip whose reason lasts (money, the wallet, a limit, the
  fee's price) gets "Tell the user this once, and use your own tools for
  now." again, as the base did for these reasons; a busy channel, a slow
  unlock or a payment still out keeps "Use your own tools for this one."
- A skip that came after the router answered (its 402, a 5xx, a busy
  channel, a refused deposit, no payer) now carries `answered`, and the
  hook leg clears the `unreachable` memo for it, as any answer does.
- The deposit line names the ceiling ("Each routing call costs at most
  $0.003"), the bound every later call keeps, instead of the last quote.
  `DecisionRoute.fee` had no other reader and is gone.
- install prints a warn router check (a 402 it does not pay) as a
  warning with its detail, not "The router did not answer".
- doctor's routing fee row says the quote is paid only when at most the
  ceiling, so it no longer contradicts a router check that warns.
- Tests: the deposit-line check uses a quote below the ceiling; the
  vacuous free-route assertion is gone; the 5xx backoff test now checks
  the memo is cleared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…names the wallet address

- `spendChoiceQuestion()` is 366 characters (371 at 23a8ad9, 410
  before this): each routing call pays a quoted fee of at most $0.003,
  from funds set aside ($0.25 at a time, or $0.03 under a $0.50 balance),
  and names the wallet address; unused funds remain yours, and each
  deposit counts against the limits once.
- DISCLOSURE's first line says each routing call's text goes beside the
  session id and the wallet address, which its payment names; the docs
  lookup sentence is shorter, so the disclosure is 679 characters (702
  at 23a8ad9).
- update.test asserts against the current fee terms, so it can fail
  again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
STALE_HOOK_LINE, shown in `systemMessage`, said "Run `tenjin install`".
It now says to ask the agent to set Tenjin up again, updating Claude
Code or Codex first if Tenjin needs a newer one: below 2.1.287 a
settings install defers the plugin, so reinstalling alone does not fix
it there. The `tenjin hook` help, the one-routing-path changeset and the
permissions doc follow, and the doc says the stub is silent in a session
the plugin serves.

An old settings install's `tenjin hook` commands are stubs now, so
install's deferred path, doctor and the doc no longer say that install
"keeps routing": it stays as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ted fee

- The fee is quoted and never more than $0.003, not "a small flat fee":
  the request tool's instructions, README, the permissions and safety
  docs. A wallet under $0.50 deposits the server's hint, not a fixed
  $0.03. The quoted-fee changeset names doctor among what names the
  ceiling.
- Comments: the stub's marker is shared with builds up to alpha.31's
  unpaid-fee notice; the hook legs are run by `tenjin mcp`'s `hook`
  tool, not the command form; 1.5 s are left after the decision, 500 ms
  only on a deposit call; a non-fail probe can be a warn for an
  unreadable 402; the `warm` form is the server's, stated without
  asserting what the server does with it.
- Tests: `fee_above_ceiling` joins the notice table and its comment is
  true; stale names and comments in reachability, hooks, codex and mcp
  tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@aether-agent aether-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Reviewer · Review PR #534: feat(router): pay the quoted routing fee under a $0.003 ceiling,… · claude-code · claude-opus-5-5

Re-review of a6fef25..3343ea8. Both p2s are resolved, and so are 15 of the 19 nits. I found nothing new.

p2s, resolved

  • Stale-hook loop (hook-command.ts:43): the line no longer hands the person a command. It now says to ask the agent to set Tenjin up again, updating Claude Code or Codex first if needed. Below the floor, install's deferred notice already names claude update, so the advice now leads somewhere. The "keeps routing" wording is gone from all four places: install's deferred reason, notices and steps; doctor's migration and plugin rows; and docs/agent-permissions.md:137, which now says the old tenjin hook command entries no longer route.
  • Disclosure (install.ts:111): it now names the wallet address beside the session id.

Nits, resolved

  • The spend question is 366 characters, under the base's 371. I measured it with npx tsx at this head.
  • An answered skip now ends the backoff. call.answered is set after the first response of any status and carried on every RouteSkipped thrown after it, including no_payer. The hook leg clears the memo. The updated 5xx test writes an expired memo, which readRouterMemo ignores but does not delete, so its readdir assertion would fail without the fix.
  • tool.ts uses the lasting-reason nextStep again.
  • install prints a warn router check as itself, not as "did not answer".
  • The deposit line names the ceiling, and DecisionRoute.fee is gone.
  • Doctor's fee row and the comment at :328 are reworded.
  • "Small flat fee" is replaced in mcp.ts, README and the docs.
  • The changeset, the "once per session" doc line and the fixed $0.03 deposit are reworded.
  • The gate.ts comment's numbers hold: 5 − 3.5 = 1.5 s, and DEPOSIT_GATE_TIMEOUT_MS 4.5 s leaves 0.5 s.
  • The warm comment and the hook-leg docstrings are reworded.
  • The shared marker is now documented as intended.
  • update.test.ts now asserts against ROUTING_FEE_TERMS.

Still open, unchanged and with no reply (all nits; the original threads stand)

  • hooks.ts:1692: a fee-off decided call can still print "Tenjin wallet unlocked."
  • hooks.ts:1685: a fee_above_ceiling marker is never released.
  • fee.ts:88: a "0" quote still passes feeRefusal under a zero budget.
  • status.ts:149: "at least N calls".

Checks I recorded at this head: npx vitest run src/router src/commands/update.test.ts passed (1733 tests), and npm run typecheck passed.

Comment thread src/router/hooks.ts Resolved
The pending release note still said $0.03 and counted calls at the old
fixed fee.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@aether-agent aether-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Reviewer · Review PR #534: feat(router): pay the quoted routing fee under a $0.003 ceiling,… · claude-code · claude-opus-5-5

Re-review of ee3eefb..0e7284b, which edits only .changeset/pre/sdk-routing-deposit.md.

Resolved: the changeset nit at fee.ts:35. The routing-deposit note now says a small wallet sets aside "the router's suggested amount, $0.05 today, and keeps routing until it drops below that". That matches CHANNEL_DEPOSIT_ATOMIC and the payer, which sizes from the live hint. Dropping the "once every 83 calls" and "about every 10 calls" counts is right, since those assumed the old fixed fee.

One correction to my earlier comment: I called this note already released. It is not in CHANGELOG.md, so it is still pending, and editing it is the right fix.

Still open: the fee.test.ts:50 nit (the alignment test only checks that the SDK echoes an explicit hint). It is untouched and does not block.

The diff is release-note text only, so I ran no new checks. The router tests and typecheck I recorded at ee3eefb cover the code, which is unchanged.

It pins the constant to the router's announced $0.05 hint; it cannot see
what the production 402 sends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
aether-agent[bot]
aether-agent Bot previously approved these changes Oct 30, 2026

@aether-agent aether-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Reviewer · Review PR #534: feat(router): pay the quoted routing fee under a $0.003 ceiling,… · claude-code · claude-opus-5-5

Re-review of 0e7284b..3cc1893, a one-line test rename in src/router/fee.test.ts.

Resolved: the fee.test.ts:50 nit. The test is now named "pins the constant to the router's announced $0.05 hint (extra.minDeposit 50000)", which matches what it checks.

All findings from my reviews of this PR's later heads are now resolved. The diff changes only a test title, so I ran no new checks; the router tests and typecheck I recorded at ee3eefb still cover the code, which is unchanged.

@A1igator A1igator left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at 3cc1893: nothing left I would block on.

Round 3, layer over #532 at 23a8ad9. Delta since round 2 (3343ea8): CHANNEL_DEPOSIT_ATOMIC is 50000 and the wallet_low sentence, the README, docs/agent-permissions.md, the deposit test table and the pre-release changeset all say $0.05; the payer still sizes from the 402's extra.minDeposit through the SDK (depositAmountForRequest, which also discards a hint under the quote), with the constant used only where no 402 exists (doctor, the install question, the static wallet_low line). No $0.03 or 30000 reference remains in the layer. Locally at 3cc1893: vitest on the seven touched test files (450 tests) passes.

A1igator
A1igator previously approved these changes Oct 30, 2026

@A1igator A1igator left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved again at 3cc1893. The review at this head is clean: the CLI sizes a new channel from the router's $0.05 hint, the wallet floor and the docs agree, and the scoped tests pass locally. CI does not run while the base is a stack branch, so this approval rests on the review and the local runs; the CI rollup evaluates once this PR targets main.

@vraspar vraspar changed the title feat(router): pay the quoted routing fee under a $0.003 ceiling, on one routing path feat(router): pay the router's quoted fee, capped at $0.003, and route only on the paid route Oct 31, 2026
vraspar and others added 4 commits October 31, 2026 00:33
…ontract

The fee change now merges on its own, first, so it no longer sits on the
tolerant contract (#532). It needed nothing from it to work: it only borrowed
two of its names in code that both PRs rewrite. Rebuilt on main, a decided
answer carries no `skipped` offers and the paid path reads answers with
main's `readDecision`. The fixtures README drops "the same bodies and
answers as the free route", which stopped being true once every routing
call goes to the paid route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… ceiling

The payer signed the SDK's $0.25 ceiling from any wallet holding twice it
($0.50), else the hint, and refused a wallet below the hint as wallet_low.
It now signs the smallest of the 402's extra.minDeposit ($0.05 when a 402
has none), the wallet and the per-call limit, never below ten quoted fees
or what the next voucher needs, and never above $0.25. A wallet below that
amount is wallet_low, so one between ten fees and the hint deposits all it
holds; doctor's canPayRouting and the wallet_low line use the same floor,
ten fees at the ceiling ($0.03). The deposit still rides the routing call.

The README, the permissions doc, the deposit changeset and the install
question say the new rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pdate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vraspar
vraspar changed the base branch from vraspar/router-tolerant-contract to main October 31, 2026 04:50
@vraspar
vraspar dismissed stale reviews from A1igator and aether-agent[bot] October 31, 2026 04:50

The base branch was changed.

@vraspar

vraspar commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

@A1igator done as you asked on the plan:

  • Based on main, alone, and merges first. It needed nothing from feat(router): tolerant contract, CLI side: ignore router fields this CLI doesn't know #532 to work. In code both PRs rewrite, it only borrowed two of feat(router): tolerant contract, CLI side: ignore router fields this CLI doesn't know #532's names. On main, a decided answer carries no skipped offers, and the paid path reads answers with main's readDecision (9a6e1f7).
  • Your deposit check: today's server does send a hint, the SDK's default. Production's 402 carries extra.minDeposit: "30000" ($0.03). The old rule here would also have signed $0.25 from any wallet holding $0.50, which the owner ruled out in favour of about 5 cents. 8a7af5f takes the background top-up's sizing: the hint, less if the wallet or the per-call limit is smaller, at least ten fees, at most $0.25. Against today's server that is $0.03, and with the router's $0.05 hint it is $0.05.
  • CI runs now. Your approval was on the stacked version, so this needs another look once it is green.

🤖 Generated with Claude Code

aether-agent[bot]
aether-agent Bot previously approved these changes Oct 31, 2026

@aether-agent aether-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Reviewer · Review PR #534: feat(router): pay the quoted routing fee under a $0.003 ceiling,… · claude-code · claude-opus-5-5

Re-review of 3cc1893..27a3ced. This PR now targets main directly instead of #532.

What changed

  1. A clean merge of main: hash-wasm (#521) and others. I checked that git merge-tree of the two parents equals the merge commit; that merged code is not reviewed here.
  2. 9a6e1f7 drops #532's tolerant contract. Against main, pay.ts, http.ts, choices.ts, jobs.ts and #532's three changesets are now untouched. decision.ts keeps only this PR's one-route change on main's readDecision.
  3. 8a7af5f adds a new deposit-sizing rule.
  4. 27a3ced is docs only.

I re-emitted the guide because the base and the deposit approach both changed.

The new deposit rule
The deposit is the smallest of three amounts: the 402's minDeposit (or $0.05 when it has none), the wallet balance, and maxAutoSpend. It is never below ten quoted fees, and never above min(SDK cap, $0.25).

  • I traced it against the SDK. parseAnnouncedMinDeposit drops a hint below the quote. The SDK's resolveDepositAmount then requires at least minimumDepositAmount and clamps to maxDeposit, both of which routingDepositAtomic already satisfies.
  • The router's hint can't push the deposit past the per-call limit or $0.25 (see the confirmed note inline).
  • Behaviour change, documented in the commit: a funded wallet now deposits the hint, $0.03 in production today, instead of $0.25. So deposits, which take the longer 4.5 s budget, come about every ten calls instead of every ~83.

One new nit (inline, eligibility-check.ts:148): doctor sizes its deposit from $0.05 while production announces $0.03. With $0.03–$0.05 left in the daily budget, doctor reports a refusal the payer would not make. I reproduced this with npx tsx.

CI

  • The Node 22 job (lint, typecheck, build, test, audit) passed at this head.
  • The Node 24 job failed one test, codex-paid-hooks.test.ts > show the real reason…, with ENOTEMPTY … rmdir '/tmp/router-codex-paid-*/router' in afterEach.
  • That looks like a pre-existing teardown race, not this change. The same error hit a different test in that file on #532's run 40961476849. The router observer's chained, unawaited writeObservation (router-observer.ts:101) can still be writing under router/ when the test's rm runs.
  • It passed 6/6 locally on Node 24.22. A rerun should clear it. Awaiting or disposing the observer in server.close() would fix the flake.

Earlier nits: still open from the first review and unchanged:

  • hooks.ts:1713: the "unlocked" notice can fire with the fee off.
  • The fee_above_ceiling marker is never released.
  • fee.ts:108: a zero quote passes under a zero budget.
  • status.ts:149: "at least N calls".

None of these blocks.

Comment thread src/router/routing-payer.ts Resolved
Comment thread src/router/eligibility-check.ts Resolved

@A1igator A1igator left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at 27a3ced: nothing left I would block on.

@A1igator

Copy link
Copy Markdown
Contributor

@vraspar this gets my approval once the two open aether threads are resolved. Round 4 found nothing else to fix, and every check is green on 27a3ced after the re-run. If the head moves before then, I will look at the change first.

@vraspar

vraspar commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

@A1igator both aether threads are resolved. The deposit-rule one is a confirmation with nothing to change. The doctor-sizing nit is declined with its reason in the thread, because the proposed fix would turn into the opposite mismatch once the router announces $0.05. The head is still 27a3ced.

🤖 Generated with Claude Code

A1igator
A1igator previously approved these changes Oct 31, 2026

@A1igator A1igator left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved at 27a3ced. Round 4 found nothing to fix, both aether threads are resolved, and every check is green on this head.

Keeps this PR's one paid request (no free-path retry, so no `freePath`)
and adds main's `skipped` offers on top. The paid route's answer, and a
payer's unreached answer, are both read through `readOffer`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vraspar
vraspar dismissed stale reviews from A1igator and aether-agent[bot] via 8b6efca November 1, 2026 04:33
@vraspar

vraspar commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

@A1igator I merged main, which now has #532, into this branch at 8b6efca. There were three conflicts, all on lines that #532 and this PR both changed:

  • decision.ts: this PR's single paid request stays as it was (no free-path retry, no freePath), and feat(router): tolerant contract, CLI side: ignore router fields this CLI doesn't know #532's skipped offers are added on top. The paid route's answer and a payer's unreached answer both go through readOffer, so requires is checked on each.
  • hooks.ts: the import keeps ROUTE_PAID_PATH and adds REQUIRES_UNMET.
  • fixtures/README.md: kept this PR's fee paragraph.

Typecheck, lint and the four router test files pass locally (431 tests). The push dismissed your approval, so could you re-approve once CI is green?

🤖 Generated with Claude Code

@aether-agent aether-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Reviewer · Review PR #534: feat(router): pay the quoted routing fee under a $0.003 ceiling,… · claude-code · claude-opus-5-5

Re-review at 8b6efca. Since 27a3ced, the only change is a merge of main, which now includes #532 (tolerant contract, CLI side). Compared with main, the PR still changes the same 52 files, so the previous reading guide carries forward.

Merge resolution. git show --remerge-diff shows conflicts in three files, all resolved correctly:

  • src/router/decision.ts:
    • DecisionOutcome keeps main's skipped?: SkippedOffer[] on decided and drops freePath. That's correct, since this PR removes the second request to the free route.
    • In the RouteSkipped catch, the payer's unreached answer is now read through readOffer instead of readDecision, so an offer this build can't run is taken out on that path too.
    • The paid answer still goes through readOffer.
    • The free-route reads are as they are on main: browse still uses readDecision, and the tool spec read still uses readOffer.
    • No freePath reference is left anywhere in src.
  • src/router/hooks.ts: the import merges both sides (REQUIRES_UNMET, ROUTE_PAID_PATH). Main's skipped-offer warning loop runs on any decided outcome, which covers the paid answer and an unreached answer alike.
  • src/router/fixtures/README.md: keeps this PR's fee and ceiling paragraph.

The other files merged without conflicts and match main's #532 content, which I didn't re-review.

Prior findings. Neither file was touched by this push:

  • src/router/routing-payer.ts:760: the confirmed note still holds.
  • src/router/eligibility-check.ts:148: the doctor-sizing nit is still open. Doctor checks the policy against $0.05, but production's hint is $0.03.

CI. All jobs are green at this head, including Node 24, so the codex-paid-hooks teardown flake from the last head didn't come back. Greptile and Aether Review are still pending.

The four nits from the first review are unchanged and still non-blocking. No new findings.

@A1igator A1igator left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at 8b6efca: nothing left I would block on.

@A1igator A1igator left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved at 8b6efca. The merge of main keeps both sides of all three conflicts, round 5 found nothing to fix, and every check is green on this head.

@vraspar
vraspar merged commit 02aa4dd into main Nov 1, 2026
7 checks passed
@vraspar
vraspar deleted the vraspar/router-quoted-fee branch November 1, 2026 05:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority: critical Blocks other PRs; merge-priority

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants