Skip to content

Natural-language renderers

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'

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 | null
console.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:

PlanLine
null (URL not gated)No payment required — the URL is not payment-gated.
payable with a best railPayable: <amount> <symbol> on <network> (gas ~<fee> <feeSymbol>). plus a count of other rails.
not payableNOT 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().

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:

.codeRendered guidance
PAYMENT_DECLINEDThe error’s own message (a policy reason, approval decline, or session-expiry text — already human).
INSUFFICIENT_FUNDSTop up the payer (token and/or native gas) and retry.
RECIPIENT_NOT_READYThe message plus that the fix is on the recipient (trustline / registration / opt-in / activation), not your balance.
NO_COMPATIBLE_ACCEPT / UNSUPPORTED_SCHEMEThe 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_TIMEOUTThe 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 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
}

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 ; .

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.