Natural-language renderers
Introduction
Section titled “Introduction”An autonomous LLM reasons over text, not JSON. The renderers turn the SDK’s three structured
value objects — a PaymentPlan, a thrown
PipRailError, and a SpendSummary —
into one model-readable English line each, so a model gets a sentence it can act on instead
of a blob it has to interpret.
All three are pure: zero I/O, zero chain libraries. They compose only fields the SDK already shipped — they invent no data and never call the network — so the line you print is always exactly what the structured object already said.
import { summarizePlan, explainDecline, formatSpendReport } from '@piprail/sdk'summarizePlan(plan)
Section titled “summarizePlan(plan)”Render a PaymentPlan (or null) as one line: what’s payable, on which chain, the gas, and how
many other rails aren’t settleable. It accepts null directly, so you can hand it the result of
planPayment() (which returns PaymentPlan | null) without a
guard:
import { PipRailClient, summarizePlan } from '@piprail/sdk'
const client = new PipRailClient({ chain: 'base', wallet: { privateKey: process.env.AGENT_KEY! },})
const url = 'https://api.example.com/report'const plan = await client.planPayment(url) // PaymentPlan | nullconsole.log(summarizePlan(plan))// → "Payable: 0.10 USDC on eip155:8453 (gas ~0.00002 ETH). 2 other rail(s) not settleable."The three branches it renders:
| Plan | Line |
|---|---|
null (URL not gated) | No payment required — the URL is not payment-gated. |
payable with a best rail | Payable: <amount> <symbol> on <network> (gas ~<fee> <feeSymbol>). plus a count of other rails. |
| not payable | NOT payable: <fundingHint> (or no settleable rail on <network>). |
The amount, symbol, network, and gas come straight off plan.best.quote and plan.best.cost;
the trailing N other rail(s) not settleable is plan.options.length - 1. Gas is shown in the
chain’s native coin (ETH/SOL/TRX/…), never in fiat — PipRail has no price oracle. This is the
sentence you hand a model after planPayment() so it can
decide whether to call fetch().
explainDecline(err)
Section titled “explainDecline(err)”Render any caught failure as one line a model can act on. For a PipRailError it switches on the
stable .code; for anything else (including any code not listed below) it
falls back to Payment failed: <message>.
try { await client.fetch(url)} catch (err) { console.log(explainDecline(err)) // → "The wallet cannot cover the payment + gas — top up the payer (token and/or native gas) and retry."}What each code renders:
.code | Rendered guidance |
|---|---|
PAYMENT_DECLINED | The error’s own message (a policy reason, approval decline, or session-expiry text — already human). |
INSUFFICIENT_FUNDS | Top up the payer (token and/or native gas) and retry. |
RECIPIENT_NOT_READY | The message plus that the fix is on the recipient (trustline / registration / opt-in / activation), not your balance. |
NO_COMPATIBLE_ACCEPT / UNSUPPORTED_SCHEME | The thrown message verbatim — it already says whether you’re on the wrong chain or need to enable the exact scheme. |
PAYMENT_TIMEOUT / MAX_RETRIES_EXCEEDED / CONFIRMATION_TIMEOUT | The message plus the never-re-pay rule (below). |
Any other code (e.g. INVALID_ENVELOPE, SETTLEMENT_FAILED) falls through to
Payment failed: <message>, so the model still gets the original message rather than nothing.
The never-re-pay rule
Section titled “The never-re-pay rule”The three broadcast-but-unconfirmed codes — PAYMENT_TIMEOUT, MAX_RETRIES_EXCEEDED,
CONFIRMATION_TIMEOUT — mean the payment may already be on-chain; the SDK just didn’t see it
confirm in time. For all three, the rendered line carries the load-bearing recovery instruction:
… Recover using the proof on .ref (re-verify or re-submit it); never re-pay — a fresh payment would double-spend.import { PaymentTimeoutError, MaxRetriesExceededError } from '@piprail/sdk'
try { await client.fetch(url)} catch (err) { if (err instanceof PaymentTimeoutError || err instanceof MaxRetriesExceededError) { console.log('Recover this proof — never re-pay:', err.ref) // .ref lives on these two classes } console.log(explainDecline(err)) // always safe: one English line for the model}formatSpendReport(summary)
Section titled “formatSpendReport(summary)”Render client.spent() — the in-memory spend ledger — as one line, broken out per
(network, asset). There is no single cross-token total: PipRail has no price oracle, so it
never sums USDC and SOL into one figure.
import { formatSpendReport } from '@piprail/sdk'
console.log(formatSpendReport(client.spent()))// → "0.30 USDC on eip155:8453 (3 payments); 0.05 USDC on solana:mainnet (1 payment)"A ledger with count === 0 renders No payments yet.; otherwise each byAsset row becomes
<total> <symbol> on <network> (<n> payment(s)), joined with ; .
Wiring them into a tool
Section titled “Wiring them into a tool”These three lines are what make a budget-bound client legible across a tool boundary. The
MCP server returns summarizePlan and formatSpendReport output in its tool
responses, and explainDecline on a thrown failure, so the model on the other end reads a
sentence rather than parsing the structured object itself. See the
payment tools for how a self-hosted toolkit does the same.