@agentcash/router 1.5.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,156 +1,130 @@
1
- # @agentcash/router
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://agentcash.dev/logo-dark-striped.svg">
4
+ <img alt="AgentCash" src="https://agentcash.dev/logo-light-striped.svg" width="360">
5
+ </picture>
6
+ </p>
2
7
 
3
- Unified route builder for Next.js App Router APIs with x402 payments, MPP payments, SIWX authentication, and API key auth.
8
+ <h1 align="center">@agentcash/router</h1>
4
9
 
5
- Eliminates ~80-150 lines of boilerplate per route. Routes become 3-6 lines.
10
+ <p align="center">
11
+ <strong>The fastest way to ship an API on x402 and MPP.</strong><br/>
12
+ x402 and MPP payments, compatible discovery, and minimal boilerplate. With @agentcash/router, agents on <a href="https://agentcash.dev">AgentCash</a> and across the agentic commerce ecosystem are compatible and call your endpoints from day one.
13
+ </p>
14
+
15
+ <p align="center">
16
+ <a href="https://www.npmjs.com/package/@agentcash/router"><img alt="npm" src="https://img.shields.io/npm/v/@agentcash/router.svg?color=111&label=npm"></a>
17
+ <a href="https://agentcash.dev/docs"><img alt="docs" src="https://img.shields.io/badge/docs-agentcash.dev-111"></a>
18
+ <a href="#install"><img alt="next.js" src="https://img.shields.io/badge/Next.js-App%20Router-111"></a>
19
+ </p>
20
+
21
+ ---
6
22
 
7
23
  ## Install
8
24
 
9
25
  ```bash
10
26
  pnpm add @agentcash/router
27
+ pnpm add next zod @x402/core @x402/evm @x402/extensions @coinbase/x402 zod-openapi # peer dependencies
28
+ pnpm add mppx # optional, for MPP support
11
29
  ```
12
30
 
13
- Peer dependencies:
31
+ ## Environment
14
32
 
15
- ```bash
16
- pnpm add next zod @x402/core @x402/evm @x402/extensions @coinbase/x402 zod-openapi
17
- # Optional: for MPP support
18
- pnpm add mppx
19
- ```
33
+ The recommended entry point reads its config from `process.env`. A copy-paste `.env.example` lives at the repo root.
20
34
 
21
- ## Environment Setup
35
+ ### x402
22
36
 
23
- The router uses the default facilitator from `@coinbase/x402` for x402 payments, which requires CDP API keys:
37
+ | Var | Required | Purpose |
38
+ |-----|----------|---------|
39
+ | `EVM_PAYEE_ADDRESS` | yes | EVM address that receives x402 and MPP payments (`0x…`, 20 bytes). Canonicalized to lowercase. The zero address is rejected. |
40
+ | `CDP_API_KEY_ID`, `CDP_API_KEY_SECRET` | yes (production) | Coinbase Developer Platform credentials for the default EVM facilitator. T3 / `@t3-oss/env-nextjs` users must declare these in their env schema. |
24
41
 
25
- ```bash
26
- CDP_API_KEY_ID=your-key-id
27
- CDP_API_KEY_SECRET=your-key-secret
28
- ```
42
+ ### Solana
29
43
 
30
- **For Next.js apps with env validation** (T3 stack, `@t3-oss/env-nextjs`): Add these to your env schema — Next.js doesn't expose undeclared env vars to `process.env`.
44
+ | Var | Required | Purpose |
45
+ |-----|----------|---------|
46
+ | `SOLANA_PAYEE_ADDRESS` | no | When set, adds a Solana `exact` accept so the router takes Solana payments. **Dynamic pricing (`upto`) is Base-only** — Solana clients can only pay static-priced routes. |
47
+ | `SOLANA_FACILITATOR_URL` | no | Override the Solana x402 facilitator. Defaults to `DEFAULT_SOLANA_FACILITATOR_URL`. |
31
48
 
32
- ```typescript
33
- // src/env.js
34
- import { createEnv } from "@t3-oss/env-nextjs";
35
- import { z } from "zod";
36
-
37
- export const env = createEnv({
38
- server: {
39
- CDP_API_KEY_ID: z.string(),
40
- CDP_API_KEY_SECRET: z.string(),
41
- },
42
- runtimeEnv: {
43
- CDP_API_KEY_ID: process.env.CDP_API_KEY_ID,
44
- CDP_API_KEY_SECRET: process.env.CDP_API_KEY_SECRET,
45
- },
46
- });
47
- ```
49
+ ### MPP (auto-enabled when `MPP_SECRET_KEY` is set)
48
50
 
49
- Without these keys, x402 routes that use the default EVM facilitator will fail to initialize.
50
- `createRouter(config)` always validates the base URL and protocol list, throws protocol config
51
- errors in production, and keeps protocol-specific init errors as request-time JSON 500s in
52
- development. Call `validateRouterConfig(config)` before `createRouter(config)` when you want
53
- missing facilitator, MPP, or store configuration to throw immediately in every environment.
51
+ | Var | Required | Purpose |
52
+ |-----|----------|---------|
53
+ | `MPP_SECRET_KEY` | when MPP is enabled | Server-side MPP secret. Presence toggles MPP on. |
54
+ | `MPP_CURRENCY` | when MPP is enabled | Tempo currency address. Use `TEMPO_USDC_ADDRESS` for Tempo USDC. |
55
+ | `TEMPO_RPC_URL` | when MPP is enabled | Authenticated Tempo JSON-RPC endpoint. Public `rpc.tempo.xyz` returns 401. |
56
+ | `MPP_OPERATOR_KEY` | no | Signs server-side close/settle. When set, MPP session mode is enabled automatically (required for streaming + `.paid({ dynamic: true })` on MPP). Address must equal the payee. |
57
+ | `MPP_FEE_PAYER_KEY` | no | Sponsors client gas for channel open/topUp. Must resolve to a different address than `MPP_OPERATOR_KEY` (Tempo rejects fee-delegated txs where `sender === feePayer`). |
54
58
 
55
- ### Recommended strict setup
59
+ ### Other
56
60
 
57
- ```typescript
58
- import {
59
- createRouter,
60
- mppFromEnv,
61
- validateRouterConfig,
62
- x402AcceptsFromEnv,
63
- type ProtocolType,
64
- } from '@agentcash/router';
65
-
66
- const payeeAddress = process.env.X402_WALLET_ADDRESS ?? process.env.X402_PAYEE_ADDRESS;
67
- const accepts = x402AcceptsFromEnv(process.env, { payeeAddress });
68
- const protocols: ProtocolType[] = process.env.MPP_SECRET_KEY ? ['x402', 'mpp'] : ['x402'];
69
-
70
- const config = {
71
- payeeAddress: payeeAddress!,
72
- baseUrl: process.env.NEXT_PUBLIC_BASE_URL!,
73
- strictRoutes: true,
74
- protocols,
75
- x402: { accepts },
76
- mpp: mppFromEnv(process.env, {
77
- recipient: payeeAddress,
78
- useDefaultStore: true,
79
- }),
80
- discovery: {
81
- title: 'My API',
82
- version: '1.0.0',
83
- },
84
- };
61
+ | Var | Required | Purpose |
62
+ |-----|----------|---------|
63
+ | `BASE_URL` | yes | Origin URL (`https://api.example.com`). Load-bearing — used as the 402 realm, OpenAPI server URL, and MPP memo prefix. Must match the public domain. |
64
+ | `KV_REST_API_URL`, `KV_REST_API_TOKEN` | no | Upstash / Vercel KV. Backs SIWX nonce, SIWX entitlement, and MPP replay. In-memory fallback is unsafe in serverless production. Providing a Kv Store is highly recommended. |
85
65
 
86
- validateRouterConfig(config);
87
- export const router = createRouter(config);
88
- ```
66
+ ## Quick start
89
67
 
90
- `x402AcceptsFromEnv()` always adds Base (`BASE_NETWORK`) and also adds Solana
91
- mainnet (`SOLANA_MAINNET_NETWORK`) when `SOLANA_PAYEE_ADDRESS` is set. Solana
92
- addresses are case-sensitive and are preserved as-is. It reads
93
- `X402_WALLET_ADDRESS` by default; older services that still use
94
- `X402_PAYEE_ADDRESS` can pass `{ payeeEnv: 'X402_PAYEE_ADDRESS' }`.
68
+ ### 1. Create the router
95
69
 
96
- `mppFromEnv()` returns `undefined` when no MPP env vars are present. If any MPP
97
- env var is present, the full trio is required: `MPP_SECRET_KEY`, `MPP_CURRENCY`,
98
- and `TEMPO_RPC_URL`. `MPP_CURRENCY` must be the Tempo currency address; for
99
- Tempo USDC use `TEMPO_USDC_CURRENCY`. Optional `MPP_FEE_PAYER_KEY` is included
100
- when present and validated as a 32-byte EVM private key. `mppFromEnv()` only
101
- builds config; call `validateRouterConfig(config)` before `createRouter(config)`
102
- to fail fast on `mpp.useDefaultStore` store env (`KV_REST_API_URL` and
103
- `KV_REST_API_TOKEN`) when you use the default store.
70
+ There are two ways to initialize. Pick one.
104
71
 
105
- ## Quick Start
72
+ **Option A — `createRouterFromEnv` (recommended).** Reads `process.env`, validates every value up front, and throws a single `RouterConfigError` with every problem at once. Auto-enables MPP when `MPP_SECRET_KEY` is set, auto-adds a Solana accept when `SOLANA_PAYEE_ADDRESS` is set, auto-enables MPP session mode when `MPP_OPERATOR_KEY` is set.
106
73
 
107
- ### 1. Create the router (once per service)
74
+ ```typescript
75
+ // lib/router.ts
76
+ import { createRouterFromEnv } from '@agentcash/router';
77
+
78
+ export const router = createRouterFromEnv({
79
+ title: 'My API',
80
+ description: 'Pay-per-call search.',
81
+ guidance: 'POST /search with { q: string }. Returns top 10 results.',
82
+ });
83
+ ```
84
+
85
+ **Option B — build a `RouterConfig` and pass it to `createRouter`.** Use this when you need custom networks, multiple payees, non-standard assets, or any setting `createRouterFromEnv` doesn't expose. `createRouter` runs the same validation against the `RouterConfig` shape.
108
86
 
109
87
  ```typescript
110
- // lib/routes.ts
111
- import { createRouter } from '@agentcash/router';
88
+ // lib/router.ts
89
+ import { createRouter, BASE_MAINNET_NETWORK } from '@agentcash/router';
112
90
 
113
91
  export const router = createRouter({
114
- payeeAddress: process.env.X402_WALLET_ADDRESS!,
115
- baseUrl: process.env.NEXT_PUBLIC_BASE_URL!,
116
- strictRoutes: true, // recommended
92
+ baseUrl: 'https://api.example.com',
93
+ payeeAddress: '0x…',
94
+ network: BASE_MAINNET_NETWORK,
95
+ protocols: ['x402'],
96
+ x402: { accepts: [/* … */] },
117
97
  discovery: {
118
98
  title: 'My API',
119
99
  version: '1.0.0',
120
- description: 'Pay-per-call API',
100
+ description: 'Pay-per-call search.',
101
+ guidance: 'POST /search with { q: string }. Returns top 10 results.',
121
102
  },
122
103
  });
123
104
  ```
124
105
 
125
106
  ### 2. Define routes
126
107
 
127
- **Paid route (x402)**
128
-
129
108
  ```typescript
130
109
  // app/api/search/route.ts
131
- import { router } from '@/lib/routes';
132
- import { searchSchema, searchResponseSchema } from '@/lib/schemas';
110
+ import { router } from '@/lib/router';
111
+ import { searchSchema } from '@/lib/schemas';
133
112
 
134
113
  export const POST = router.route({ path: 'search' })
135
114
  .paid('0.01')
136
115
  .body(searchSchema)
137
- .output(searchResponseSchema)
138
- .description('Search the web')
139
116
  .handler(async ({ body }) => search(body));
140
117
  ```
141
118
 
142
- **SIWX-authenticated route**
143
-
144
119
  ```typescript
120
+ // app/api/inbox/status/route.ts
145
121
  export const GET = router.route({ path: 'inbox/status' })
146
122
  .siwx()
147
- .query(statusQuerySchema)
148
- .handler(async ({ query, wallet }) => getStatus(query, wallet));
123
+ .handler(async ({ wallet }) => getStatus(wallet));
149
124
  ```
150
125
 
151
- **Unprotected route**
152
-
153
126
  ```typescript
127
+ // app/api/health/route.ts
154
128
  export const GET = router.route({ path: 'health' })
155
129
  .unprotected()
156
130
  .handler(async () => ({ status: 'ok' }));
@@ -158,479 +132,117 @@ export const GET = router.route({ path: 'health' })
158
132
 
159
133
  ### 3. Auto-discovery
160
134
 
161
- Discovery metadata (`title`, `version`, `description`, `guidance`) is configured once in `createRouter({ discovery })`. The discovery handlers are zero-arg:
162
-
163
135
  ```typescript
164
- // app/.well-known/x402/route.ts
165
- import { router } from '@/lib/routes';
166
- import '@/lib/routes/barrel'; // ensures all routes are imported
167
- export const GET = router.wellKnown();
168
-
169
136
  // app/openapi.json/route.ts
170
- import { router } from '@/lib/routes';
171
- import '@/lib/routes/barrel';
137
+ import { router } from '@/lib/router';
138
+ import '@/lib/routes-barrel'; // imports every route module so the registry is populated
172
139
  export const GET = router.openapi();
173
-
174
- // app/llms.txt/route.ts
175
- import { router } from '@/lib/routes';
176
- export const GET = router.llmsTxt();
177
140
  ```
178
141
 
179
- OpenAPI output follows the discovery contract:
180
-
181
- - Paid signaling via `responses.402` + `x-payment-info`
182
- - Auth signaling via `security` + `components.securitySchemes`
183
- - Optional top-level metadata via `x-discovery` (`ownershipProofs`)
142
+ The barrel forces every route module to load before the discovery handler walks the registry — Next.js otherwise lazy-loads route files on first hit, and unloaded routes don't appear in the spec.
184
143
 
185
- ## API
144
+ ## Auth modes
186
145
 
187
- ### `createRouter(config)`
188
-
189
- Creates a `ServiceRouter` instance.
190
-
191
- | Option | Type | Default | Description |
192
- |--------|------|---------|-------------|
193
- | `payeeAddress` | `string` | — | Wallet address to receive payments |
194
- | `baseUrl` | `string` | **required** | Service origin used for discovery/OpenAPI/realm |
195
- | `discovery` | `DiscoveryConfig` | **required** | Title, version, description, guidance for OpenAPI/well-known/llms.txt |
196
- | `network` | `string` | `'eip155:8453'` | Blockchain network |
197
- | `plugin` | `RouterPlugin` | `undefined` | Observability plugin |
198
- | `prices` | `Record<string, string>` | `undefined` | Central pricing map (auto-applied) |
199
- | `siwx.nonceStore` | `NonceStore` | `MemoryNonceStore` | Custom nonce store |
200
- | `mpp` | `{ secretKey, currency, recipient?, rpcUrl?, feePayerKey?, useDefaultStore? }` | `undefined` | MPP config |
201
- | `protocols` | `('x402' \| 'mpp')[]` | `['x402']` | Default protocols for paid routes |
202
- | `strictRoutes` | `boolean` | `false` | Enforce `route({ path })` and prevent key/path divergence |
203
-
204
- ### Config validation helpers
146
+ | Method | Purpose |
147
+ |--------|---------|
148
+ | `.paid(price)` | Payment required (x402, MPP, or both). |
149
+ | `.siwx()` | Wallet identity, no payment. Returns 402 with a SIWX challenge. |
150
+ | `.apiKey(resolver)` | `X-API-Key` or `Authorization: Bearer <key>`. Composes with `.paid()`. |
151
+ | `.unprotected()` | No auth. |
205
152
 
206
153
  ```typescript
207
- import {
208
- BASE_NETWORK,
209
- SOLANA_MAINNET_NETWORK,
210
- TEMPO_USDC_CURRENCY,
211
- getRouterConfigIssues,
212
- mppFromEnv,
213
- paidOptionsForProtocols,
214
- validateRouterConfig,
215
- x402AcceptsFromEnv,
216
- } from '@agentcash/router';
217
- ```
218
-
219
- - `validateRouterConfig(config)` throws `RouterConfigError` with structured
220
- issues. Use it when you want invalid env/config to fail at startup in every
221
- environment; `createRouter(config)` still performs its own validation and
222
- keeps deferred protocol init errors in development for request-time feedback.
223
- - `getRouterConfigIssues(config)` returns the same structured issues without
224
- throwing.
225
- - `x402AcceptsFromEnv(env)` builds Base and optional Solana x402 accepts from
226
- `X402_WALLET_ADDRESS` and `SOLANA_PAYEE_ADDRESS`.
227
- - `mppFromEnv(env)` builds MPP config only when MPP env is present, and rejects
228
- partial MPP env.
229
- - `paidOptionsForProtocols(protocols)` copies a protocol array into a
230
- route-level `PaidOptions` object.
231
- - Manual `.paid(price)` routes inherit `createRouter({ protocols })` unless
232
- the route passes its own `options.protocols`.
233
-
234
- ### Path-First Routing
235
-
236
- Use path-first route definitions to keep runtime, OpenAPI, and discovery aligned:
237
-
238
- ```typescript
239
- router.route({ path: 'flightaware/airports/id/flights/arrivals', method: 'GET' })
240
- ```
241
-
242
- If you need a custom internal key (legacy pricing map), you can pass:
154
+ router.route({ path: 'admin/users' })
155
+ .apiKey(async (key) => db.admin.findByKey(key)) // null => 401
156
+ .handler(async ({ account }) => db.user.findMany());
243
157
 
244
- ```typescript
245
- router.route({ path: 'public/path', key: 'legacy/key' })
158
+ router.route({ path: 'gated' })
159
+ .apiKey(resolver).paid('0.01') // key AND payment
160
+ .handler(fn);
246
161
  ```
247
162
 
248
- In `strictRoutes` mode, custom keys are rejected to prevent discovery drift.
249
-
250
- ### Route Builder
251
-
252
- The fluent builder ensures compile-time safety:
253
-
254
- - `.paid(price)` / `.paid(fn, { maxPrice })` / `.paid({ field, tiers })` - Payment auth
255
- - `.siwx()` - SIWX wallet auth
256
- - `.apiKey(resolver)` - API key auth (composable with `.paid()`)
257
- - `.unprotected()` - No auth
258
- - `.body(zodSchema, example?)` - Request body validation with optional discovery example
259
- - `.query(zodSchema, example?)` - Query parameter validation with optional discovery example
260
- - `.output(zodSchema, example?)` - Response schema with optional discovery example
261
- - `.inputExample(sample)` / `.outputExample(sample)` - Optional examples when a separate call reads better
262
- - `.description(text)` - Route description (for OpenAPI)
263
- - `.provider(name, config?)` - Provider monitoring (see [Provider Monitoring](#provider-monitoring))
264
- - `.settlement({ beforeSettle, afterSettle, onSettledHandlerError, onSettlementError })` - Payment lifecycle hooks
265
- - `.handler(fn)` - Terminal method, returns Next.js handler
163
+ ## Pricing
266
164
 
267
- ### Pricing Modes
268
-
269
- **Static** - Fixed price for all requests:
165
+ **Static.**
270
166
  ```typescript
271
- router.route('search').paid('0.02')
167
+ .paid('0.02')
272
168
  ```
273
169
 
274
- **Dynamic** - Calculate price based on request body:
170
+ **Args-driven.**
275
171
  ```typescript
276
- router.route('gen')
277
- .paid((body) => calculateCost(body.imageSize, body.quality))
278
- .body(imageGenSchema)
279
- .handler(async ({ body }) => generate(body));
172
+ .paid((body) => calculateCost(body), { maxPrice: '5.00' })
173
+ .body(genSchema)
280
174
  ```
281
175
 
282
- **Dynamic with safety net** - Cap at maxPrice if calculation exceeds, fallback to maxPrice on errors:
283
- ```typescript
284
- router.route('compute')
285
- .paid((body) => calculateExpensiveOperation(body), { maxPrice: '10.00' })
286
- .body(computeSchema)
287
- .handler(async ({ body }) => compute(body));
288
- ```
176
+ `maxPrice` caps the computed amount and acts as a fallback if the pricing function throws. Without `maxPrice`, the route trusts your function fully (no cap, no fallback) and returns 500 on errors.
289
177
 
290
- **Tiered** - Price based on a specific field value:
178
+ **Tiered.**
291
179
  ```typescript
292
- router.route('upload').paid({
180
+ .paid({
293
181
  field: 'tier',
294
182
  tiers: {
295
183
  '10mb': { price: '0.02', label: '10 MB' },
296
184
  '100mb': { price: '0.20', label: '100 MB' },
297
185
  },
298
- }).body(uploadSchema)
186
+ })
187
+ .body(uploadSchema)
299
188
  ```
300
189
 
301
- #### maxPrice Semantics (v0.3.1+)
302
-
303
- `maxPrice` is **optional** for dynamic pricing and acts as a safety net:
304
-
305
- 1. **Capping**: If `calculateCost(body)` returns `"15.00"` but `maxPrice: "10.00"`, the client is charged `$10.00` (capped) and a warning alert fires.
306
-
307
- 2. **Fallback**: If `calculateCost(body)` throws an error and `maxPrice` is set, the route falls back to `maxPrice` (degraded mode) and an alert fires. Without `maxPrice`, the route returns 500.
308
-
309
- 3. **Trust mode**: No `maxPrice` means full trust in your pricing function (no cap, no fallback).
310
-
311
- **Best practices:**
312
- - ✅ Always set `maxPrice` for production routes (safety net)
313
- - ✅ Use `maxPrice` for routes with external dependencies (pricing APIs)
314
- - ✅ Monitor alerts for capping events (indicates pricing bug)
315
- - ⚠️ Skip `maxPrice` only for well-tested, unbounded pricing (e.g., per-GB storage)
316
-
317
- **Example with safety net:**
190
+ **Handler-driven (request-mode).** Bills exactly `tickCost` per request:
318
191
  ```typescript
319
- router.route('ai-gen')
320
- .paid(async (body) => {
321
- // External pricing API (can fail)
322
- const res = await fetch('https://pricing.example.com/calculate', {
323
- method: 'POST',
324
- body: JSON.stringify(body),
325
- });
326
- return res.json().price;
327
- }, { maxPrice: '5.00' }) // Fallback if API is down
328
- .body(genSchema)
329
- .handler(async ({ body }) => generate(body));
330
- ```
331
-
332
- ### Dual Protocol (x402 + MPP)
333
-
334
- ```typescript
335
- router.route('search')
336
- .paid('0.01', { protocols: ['x402', 'mpp'] })
337
- .body(schema)
338
- .handler(fn);
192
+ .paid({ dynamic: true, tickCost: '0.01', unitType: 'request', maxPrice: '0.01' })
193
+ .handler(async ({ body }) => { ... });
339
194
  ```
340
195
 
341
- ### Handler Context
342
-
196
+ **Streaming (MPP only).** One `charge()` call bills one tick:
343
197
  ```typescript
344
- interface HandlerContext<TBody, TQuery> {
345
- body: TBody; // Parsed + validated
346
- query: TQuery; // Parsed + validated
347
- request: NextRequest; // Raw request
348
- wallet: string | null; // Verified wallet address
349
- payment: HandlerPaymentContext | null; // Payment metadata for this request
350
- account: unknown; // From .apiKey() resolver
351
- alert: AlertFn; // Fire observability alerts
352
- setVerifiedWallet: (addr: string) => void;
353
- }
198
+ .paid({ dynamic: true, tickCost: '0.0001', unitType: 'token', maxPrice: '0.05', protocols: ['mpp'] })
199
+ .stream(async function* ({ body, charge }) {
200
+ for await (const token of streamLLM(body.prompt)) {
201
+ await charge();
202
+ yield token;
203
+ }
204
+ });
354
205
  ```
355
206
 
356
- `payment` is `null` for unprotected, API-key-only, and SIWX-only requests. For
357
- paid requests it includes `protocol`, `status`, `payer`, `amount`, `network`,
358
- and best-effort recipient/transaction/receipt metadata when the protocol
359
- provides it. x402 handlers currently see `status: 'verified'` because settlement
360
- happens after a successful handler response.
207
+ ## Pre-payment validation
361
208
 
362
- ### Settlement Lifecycle
363
-
364
- For paid routes, use `.settlement()` when final checks belong after handler work
365
- but before router-controlled settlement:
209
+ For checks that need a DB lookup before quoting a price:
366
210
 
367
211
  ```typescript
368
- router.route('render')
369
- .paid('0.10')
370
- .body(schema, { prompt: 'city at dusk' })
371
- .settlement({
372
- beforeSettle: async ({ result }) => {
373
- if (!isUsableResult(result)) {
374
- throw Object.assign(new Error('Render failed validation'), { status: 502 });
375
- }
376
- },
377
- afterSettle: async ({ payment, result }) => {
378
- await ledger.record({ tx: payment.transaction, result });
379
- },
380
- onSettledHandlerError: async ({ payment, error }) => {
381
- await compensationQueue.enqueue({ receipt: payment.receipt, error });
382
- },
212
+ router.route({ path: 'domain/register' })
213
+ .paid(calculatePrice, { maxPrice: '10.00' })
214
+ .body(RegisterSchema)
215
+ .validate(async (body) => {
216
+ if (await isDomainTaken(body.domain)) {
217
+ throw Object.assign(new Error('Domain taken'), { status: 409 });
218
+ }
383
219
  })
384
- .handler(async ({ body }) => render(body));
220
+ .handler(async ({ body, wallet }) => registerDomain(body.domain, wallet));
385
221
  ```
386
222
 
387
- `beforeSettle` can still prevent the charge for x402 and MPP
388
- transaction-payload flows. `afterSettle` is for durable ledgers, analytics, and
389
- post-settlement bookkeeping. `onSettledHandlerError` covers already-settled
390
- MPP requests whose handler returns an error response, which is the right place
391
- to enqueue app-owned refund or compensation work. The router cannot generically
392
- refund protocol payments because it does not hold merchant signing keys.
223
+ Pipeline order: `body parse -> validate -> 402 challenge -> payment -> handler`.
393
224
 
394
- ### RouterPlugin
395
-
396
- Pluggable observability. All hooks are optional and fire-and-forget.
225
+ ## Plugin Hooks
397
226
 
398
227
  ```typescript
399
- import { createRouter, type RouterPlugin } from '@agentcash/router';
228
+ import { createRouterFromEnv, type RouterPlugin } from '@agentcash/router';
400
229
 
401
230
  const myPlugin: RouterPlugin = {
402
- onRequest(meta) { /* ... */ },
403
- onPaymentVerified(ctx, payment) { /* ... */ },
404
- onPaymentSettled(ctx, settlement) { /* ... */ },
405
- onResponse(ctx, response) { /* ... */ },
406
- onError(ctx, error) { /* ... */ },
407
- onAlert(ctx, alert) { /* ... */ },
408
- onProviderQuota(ctx, event) { /* ... */ },
231
+ onRequest(meta) {},
232
+ onPaymentVerified(ctx, payment) {},
233
+ onPaymentSettled(ctx, settlement) {},
234
+ onResponse(ctx, response) {},
235
+ onError(ctx, error) {},
236
+ onAlert(ctx, alert) {},
409
237
  };
410
238
 
411
- export const router = createRouter({
412
- payeeAddress: process.env.X402_WALLET_ADDRESS!,
413
- baseUrl: process.env.NEXT_PUBLIC_BASE_URL!,
239
+ export const router = createRouterFromEnv({
240
+ title: 'My API',
241
+ description: '…',
242
+ guidance: '…',
414
243
  plugin: myPlugin,
415
- discovery: {
416
- title: 'My API',
417
- version: '1.0.0',
418
- },
419
244
  });
420
245
  ```
421
246
 
422
- Built-in `consolePlugin()` logs lifecycle events:
423
-
424
- ```typescript
425
- import { createRouter, consolePlugin } from '@agentcash/router';
426
-
427
- export const router = createRouter({
428
- payeeAddress: process.env.X402_WALLET_ADDRESS!,
429
- baseUrl: process.env.NEXT_PUBLIC_BASE_URL!,
430
- plugin: consolePlugin(),
431
- discovery: {
432
- title: 'My API',
433
- version: '1.0.0',
434
- },
435
- });
436
- ```
437
-
438
- ### Central Pricing Map
439
-
440
- For services with many static-priced routes:
441
-
442
- ```typescript
443
- const router = createRouter({
444
- payeeAddress: process.env.X402_WALLET_ADDRESS!,
445
- baseUrl: process.env.NEXT_PUBLIC_BASE_URL!,
446
- discovery: {
447
- title: 'My API',
448
- version: '1.0.0',
449
- },
450
- prices: {
451
- 'search': '0.02',
452
- 'lookup': '0.05',
453
- },
454
- });
455
-
456
- // Price auto-applied, no .paid() needed
457
- export const POST = router.route('search')
458
- .body(schema)
459
- .handler(fn);
460
- ```
461
-
462
- ### Provider Monitoring
463
-
464
- Routes that wrap third-party APIs can declare monitoring behavior per-provider. This surfaces quota/balance information through the plugin system and registers cron-checkable monitors.
465
-
466
- #### Why
467
-
468
- Upstream providers report remaining quota in different ways:
469
-
470
- | Pattern | Example | How detected |
471
- |---------|---------|-------------|
472
- | Balance in response headers | `X-RateLimit-Remaining: 482` | `extractQuota` reads headers |
473
- | Balance in response body | `{ rateLimit: { remaining: 50 } }` | `extractQuota` reads result |
474
- | Separate health-check endpoint | Apollo `/credits` endpoint | `monitor` function (cron) |
475
- | Overages auto-charged at same rate | Exa, Firecrawl | `overage: 'same-rate'` |
476
- | Overages at increased rate | Some SaaS APIs | `overage: 'increased-rate'` |
477
- | No overages, immediate stoppage | Whitepages | `overage: 'hard-stop'` |
478
-
479
- The `.provider()` method handles all six patterns through a single interface.
480
-
481
- #### Basic usage
482
-
483
- ```typescript
484
- export const POST = router.route('search')
485
- .paid('0.01')
486
- .provider('exa', {
487
- extractQuota: (result, headers) => ({
488
- remaining: (result as any).rateLimit?.remaining ?? null,
489
- limit: (result as any).rateLimit?.limit ?? null,
490
- }),
491
- warn: 100,
492
- critical: 10,
493
- })
494
- .body(searchSchema)
495
- .handler(async ({ body }) => exaClient.search(body));
496
- ```
497
-
498
- After every successful handler response, `extractQuota` runs with the raw handler result and the response headers. The router computes a level (`healthy`, `warn`, `critical`) based on thresholds and fires `onProviderQuota` on the plugin.
499
-
500
- #### ProviderConfig
501
-
502
- | Field | Type | Default | Description |
503
- |-------|------|---------|-------------|
504
- | `extractQuota` | `(result, headers) => QuotaInfo \| null` | — | Inline quota extraction after each request |
505
- | `monitor` | `() => Promise<QuotaInfo \| null>` | — | Standalone health check (for cron) |
506
- | `overage` | `'same-rate' \| 'increased-rate' \| 'hard-stop'` | `'same-rate'` | What happens when quota hits zero |
507
- | `warn` | `number` | — | Fire `warn` level when remaining <= this |
508
- | `critical` | `number` | — | Fire `critical` level when remaining <= this |
509
-
510
- #### QuotaInfo
511
-
512
- ```typescript
513
- interface QuotaInfo {
514
- remaining: number | null; // Credits/calls remaining
515
- limit: number | null; // Total quota (null if unknown)
516
- spend?: number; // Credits consumed this request
517
- }
518
- ```
519
-
520
- #### Threshold logic
521
-
522
- | Condition | Level |
523
- |-----------|-------|
524
- | `remaining === null` | `healthy` (no data to compare) |
525
- | `remaining <= critical` | `critical` |
526
- | `remaining <= warn` | `warn` |
527
- | Otherwise | `healthy` |
528
-
529
- #### Plugin hook
530
-
531
- ```typescript
532
- interface ProviderQuotaEvent {
533
- provider: string; // Provider name from .provider()
534
- route: string; // Route key
535
- remaining: number | null;
536
- limit: number | null;
537
- spend?: number;
538
- level: 'healthy' | 'warn' | 'critical';
539
- overage: 'same-rate' | 'increased-rate' | 'hard-stop';
540
- message: string; // Human-readable summary
541
- }
542
- ```
543
-
544
- Handle in your plugin:
545
-
546
- ```typescript
547
- const myPlugin: RouterPlugin = {
548
- onProviderQuota(ctx, event) {
549
- if (event.level === 'critical') {
550
- discord.alert(`${event.provider}: ${event.remaining} remaining`);
551
- }
552
- clickhouse.insert('provider_quota', event);
553
- },
554
- };
555
- ```
556
-
557
- #### Cron monitors
558
-
559
- For providers that require a separate API call to check balance (not available inline in response), register a `monitor` function:
560
-
561
- ```typescript
562
- export const POST = router.route('people/search')
563
- .paid('0.05')
564
- .provider('apollo', {
565
- monitor: async () => {
566
- const res = await fetch('https://api.apollo.io/v1/credits', {
567
- headers: { 'X-Api-Key': process.env.APOLLO_KEY! },
568
- });
569
- const data = await res.json();
570
- return { remaining: data.credits, limit: null };
571
- },
572
- overage: 'hard-stop',
573
- warn: 500,
574
- critical: 50,
575
- })
576
- .body(searchSchema)
577
- .handler(fn);
578
- ```
579
-
580
- Retrieve all registered monitors via `router.monitors()`:
581
-
582
- ```typescript
583
- // cron.ts — run every 5 minutes
584
- import { router } from '@/lib/routes';
585
- import '@/lib/routes/barrel';
586
-
587
- for (const entry of router.monitors()) {
588
- const quota = await entry.monitor();
589
- if (!quota) continue;
590
-
591
- const level = quota.remaining !== null && quota.remaining <= (entry.critical ?? 0)
592
- ? 'critical'
593
- : quota.remaining !== null && quota.remaining <= (entry.warn ?? 0)
594
- ? 'warn'
595
- : 'healthy';
596
-
597
- if (level !== 'healthy') {
598
- alert(`${entry.provider} (${entry.route}): ${quota.remaining} remaining [${level}]`);
599
- }
600
- }
601
- ```
602
-
603
- `monitors()` returns:
604
-
605
- ```typescript
606
- interface MonitorEntry {
607
- provider: string;
608
- route: string;
609
- monitor: () => Promise<QuotaInfo | null>;
610
- overage: OveragePolicy;
611
- warn?: number;
612
- critical?: number;
613
- }
614
- ```
615
-
616
- #### Provider name only
617
-
618
- If you just want to tag a route with its provider for logging/tracing, pass only the name:
619
-
620
- ```typescript
621
- router.route('health')
622
- .unprotected()
623
- .provider('internal')
624
- .handler(async () => ({ status: 'ok' }));
625
- ```
626
-
627
- #### Safety guarantees
628
-
629
- - `extractQuota` runs fire-and-forget — exceptions are caught and swallowed
630
- - `extractQuota` only runs when `response.status < 400` (no quota extraction on errors)
631
- - The plugin hook is non-blocking — it never delays the response to the caller
632
- - Missing thresholds are fine — without `warn`/`critical`, level is always `healthy`
633
-
634
- ## License
247
+ All hooks are optional and fire-and-forget; they never delay the response. Use hooks to add additional telemetry or flexibility to your resource's lifecycle.
635
248
 
636
- MIT