Skip to content

The time envelope (Mode A)

The spend policy caps amount and total. It can also bound time — and that is what makes Mode A work: a headless agent runs free inside a budget and a time envelope, and the policy is the consent, with no per-payment approval prompt. It’s a budget that is also a clock.

Two independent leashes, both opt-in. Omit them and behaviour is byte-identical to a time-free policy.

import { PipRailClient } from '@piprail/sdk'
const client = new PipRailClient({
chain: 'base',
wallet: { privateKey: process.env.AGENT_KEY! },
policy: {
maxAmount: '0.10', maxTotal: '5.00', tokens: ['USDC'],
ttlSeconds: 3600, // the whole session expires in 1h
windowTotal: '1.00', windowSeconds: 600, // …and ≤ 1.00 USDC per rolling 10 minutes
},
})

The session deadline — ttlSeconds / expiresAt

Section titled “The session deadline — ttlSeconds / expiresAt”

A session deadline is a hard stop. Past it, every payment is refused — even a zero-amount or under-cap one — because expiry is amount-blind and checked first. It’s a headless agent’s time leash: spend dies when the clock runs out.

policy: { ttlSeconds: 3600 } // dies 1 hour after client construction

The deadline is relative to session start, which is when you construct the client. State is in-memory and resets on restart — the session is the process. For crash-loop-resistant limits, supply a pluggable durable store (the isUsed / markUsed analogue).

expiresAt sets the same deadline absolutely, as epoch milliseconds (matching Date.now()):

policy: { expiresAt: Date.now() + 3_600_000 } // SDK-only; pass milliseconds
FieldUnitsNotes
ttlSecondsseconds, relative to session startPositive safe integer; also the MCP’s PIPRAIL_TTL knob.
expiresAtepoch milliseconds (absolute)SDK-only — no MCP env knob. Not auto-corrected from seconds.

When both are set, the earlier deadline wins.

A payment past the deadline throws PaymentDeclinedError with reasonCode: 'SESSION_EXPIRED', before any funds move. This is terminal — don’t retry: the session is over, and the next payment will be refused too.

import { PipRailClient, PaymentDeclinedError } from '@piprail/sdk'
const client = new PipRailClient({
chain: 'base',
wallet: { privateKey: process.env.AGENT_KEY! },
policy: { ttlSeconds: 3600 },
})
const url = 'https://api.example.com/report'
try {
await client.fetch(url)
} catch (e) {
if (e instanceof PaymentDeclinedError && e.reasonCode === 'SESSION_EXPIRED') {
// terminal — the session's time leash has elapsed; start a fresh client
} else {
throw e
}
}

The rolling window — windowTotal / windowSeconds

Section titled “The rolling window — windowTotal / windowSeconds”

The window is a rate limit layered on top of the lifetime maxTotal: at most windowTotal of a given asset may be spent within any trailing windowSeconds. It’s enforced per (network, asset) — the same per-distinct-asset scoping as maxTotal, because summing across tokens is unit-meaningless without a price oracle (which the SDK deliberately omits).

policy: { windowTotal: '1.00', windowSeconds: 600 } // ≤ 1.00 USDC per rolling 10 min

A payment that would push spend within the last windowSeconds past the cap throws PaymentDeclinedError with reasonCode: 'OUTSIDE_WINDOW'. Unlike a session expiry, this is recoverable — wait for the window to free up (older spend ages out), or raise windowTotal.

Read the leash before paying — client.budget()

Section titled “Read the leash before paying — client.budget()”

budget() returns a read-only SessionBudget: the time envelope plus the per-asset money leash. A Mode A agent reads this to see its remaining runway rather than discovering the limit by hitting a decline.

const b = client.budget()
// → {
// session: { start, expiresAt, secondsRemaining }, // ISO strings; null fields when no deadline
// byAsset: [ /* one SpendRemaining row per (network, asset) the ledger has seen */ ],
// }

client.remaining() returns just the byAsset half — the per-(network, asset) remaining cap, as a SpendRemaining[]. A pair only appears once it’s been spent on (the SDK learns the token’s decimals from the first payment), so a never-spent asset simply isn’t a row, and a fresh client with a maxTotal returns [] until its first payment.

The time envelope also surfaces on every PaymentPlan as plan.session — present only when a deadline is configured — so the same plan you check for funds also shows the clock:

const plan = await client.planPayment(url)
if (plan?.session) {
console.log(plan.session.secondsRemaining) // number | null — seconds left on the session
}

Every payment is evaluated against the whole policy in a pinned, first-failure-wins order, so the refusal is specific. Session expiry runs first because it’s session-global, not asset-scoped — an expired session always reports expiry, never some other gate that happens to also fail.

session expiry → chains → hosts → unknown-token → tokens → maxAmount → maxTotal → window

Both time guards collapse to a single decline reasonCode on the client, distinct from the amount-based codes:

Configured byDecline reasonCodeRetry?
ttlSeconds / expiresAtSESSION_EXPIREDNo — terminal.
windowTotal + windowSecondsOUTSIDE_WINDOWYes — once the window frees, or raise the cap.

See Evaluate policy for the pure decision function behind these checks, and Why payments fail for branching on every reasonCode. The MCP exposes the relative PIPRAIL_TTL knob and a piprail_budget tool — see MCP modes.