@agentcash/router 1.6.0 → 1.7.1

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.
@@ -1,585 +0,0 @@
1
- ---
2
- name: router-guide
3
- description: Use when creating, modifying, or debugging routes with @agentcash/router. Covers route creation, auth modes, pricing, provider monitoring, discovery, plugins, and troubleshooting. Trigger phrases include "add a route", "create an endpoint", "set up discovery", "add provider monitoring", "configure the router".
4
- ---
5
-
6
- # @agentcash/router Guide
7
-
8
- You are helping a developer build API routes using `@agentcash/router`. This package is a fluent route builder for Next.js App Router that handles x402 payments, MPP payments, SIWX authentication, API key auth, and provider monitoring.
9
-
10
- ## Philosophy and Design Constraints
11
-
12
- These are intentional decisions. Do not change them without explicit user approval.
13
-
14
- ### Why this exists
15
-
16
- Every paid API route in a Merit Systems service shared the same ~80-150 lines of boilerplate: x402 server init, payment verification, body parsing, settlement gating, error handling, observability hooks. Five services meant five copies that drifted. This router eliminates that by encoding the lifecycle into a fluent builder that compiles to a single Next.js handler function.
17
-
18
- ### Core design principles
19
-
20
- 1. **Fluent builder, not config object.** Progressive discoverability via IDE autocomplete. The chain reads like a sentence: `route('search').paid('0.01').body(schema).handler(fn)`. Config objects hide available options; fluent chains surface them.
21
-
22
- 2. **Use `x402ResourceServer` primitives directly, not `withX402`.** The `withX402` wrapper from `@coinbase/x402` is a convenience that owns the full request lifecycle. We need to interleave body parsing, Zod validation, plugin hooks, and settlement gating at specific points in that lifecycle, so we call the lower-level primitives (`buildPaymentRequirementsFromOptions`, `verifyPayment`, `settlePayment`) directly.
23
-
24
- 3. **Auth modes are mutually exclusive per route, except `apiKey` + `paid`.** A route is either paid, SIWX-authenticated, API-key-gated, or unprotected. The one exception: `.apiKey()` can compose with `.paid()` because some routes need both identity (API key) and payment (x402/MPP). This is enforced at the type level.
25
-
26
- 4. **Body is only buffered when `.body()` is chained.** If a route doesn't declare a body schema, the request stream is untouched. This matters for routes that proxy multipart uploads or streams. For dynamic pricing, the body IS parsed before the 402 challenge (via `request.clone()`) so the pricing function can calculate an accurate price.
27
-
28
- 5. **Settlement is gated on `response.status < 400`.** If the handler throws or returns an error response, no settlement occurs. The payer's funds are not captured. This is a fundamental safety guarantee.
29
-
30
- 6. **The plugin interface is the observability boundary.** All Merit-specific telemetry (ClickHouse, Discord alerts, usage tracking) lives in a private `RouterPlugin` implementation. The router itself is fully open-source with zero Merit-specific code. The plugin hooks are fire-and-forget — they never delay the response.
31
-
32
- 7. **Self-registering routes + validated barrel (Approach B).** Routes self-register via `.handler()` at import time. A barrel file imports all route modules. Discovery endpoints (`.wellKnown()`, `.openapi()`) validate that the barrel is complete by comparing registered routes against the `prices` map. For routes in separate handler files (e.g., Next.js `route.ts` files), use **discovery stubs** — lightweight registrations that provide metadata for discovery without the real handler. Guard stubs with `registry.has()` to avoid unnecessary overwrites.
33
-
34
- 8. **Both x402 and MPP ship from day one.** Dual-protocol support is not an afterthought. Routes declare `protocols: ['x402', 'mpp']` and the orchestration layer routes to the correct handler based on the request header. MPP uses `mppx`'s high-level `Mppx.create()` API — the router creates an instance at init time and calls `mppx.charge({ amount })(request)` at request time. This returns either a 402 challenge or a 200 with `withReceipt()` for attaching the payment receipt header.
35
-
36
- 9. **`zod-openapi` for OpenAPI 3.1.** Zod schemas are the single source of truth for request/response types. OpenAPI docs are auto-generated from them. No manual spec maintenance.
37
-
38
- 10. **Fakes over mocks in tests.** The test suite uses `FakeX402Server` (a behavioral fake that accepts known payer/payee/amount tuples) instead of vi.mock stubs. This tests real verification logic, not mock wiring.
39
-
40
- ### Version Stability
41
-
42
- The public API is **not stable**. Downstream consumers should pin exact versions (`"@agentcash/router": "0.2.0"`, not `"^0.2.0"`). Breaking changes will happen as we build out multi-protocol support and discover patterns across services. Semver will be respected once we hit 1.0.
43
-
44
- ### Non-goals (deliberately rejected)
45
-
46
- - **No middleware chain.** Express-style middleware (`use()`) was considered and rejected. The builder's fixed lifecycle (auth → parse → validate → price → verify → handler → settle) covers all routes. Custom logic goes in the handler or plugin.
47
- - **No per-request config overrides.** The route is fully configured at definition time. Runtime behavior changes go through the handler function, not builder reconfiguration.
48
- - **No automatic retry on settlement failure.** Settlement failures are logged as critical alerts. Retry logic belongs in the plugin implementation, not the router core.
49
-
50
- ## Architecture Overview
51
-
52
- ```
53
- createRouter(config) → ServiceRouter
54
- ├── .route(key) → RouteBuilder (fluent chain)
55
- │ ├── Auth: .paid() | .siwx() | .apiKey() | .unprotected()
56
- │ ├── Schema: .body(zod) | .query(zod) | .output(zod)
57
- │ ├── Meta: .description() | .path() | .provider()
58
- │ └── Terminal: .handler(fn) → Next.js handler
59
- ├── .wellKnown() → /.well-known/x402 handler
60
- ├── .openapi() → /openapi.json handler
61
- └── .monitors() → MonitorEntry[] (for cron)
62
- ```
63
-
64
- ### Request lifecycle (orchestrate.ts)
65
-
66
- ```
67
- Request in
68
- → await x402 server init
69
- → plugin.onRequest() → PluginContext
70
- → if unprotected: skip to handler
71
- → if apiKey route: verify API key → 401 if invalid
72
- → detectProtocol(request) from headers
73
- → if no payment header + dynamic pricing + body schema:
74
- early body parse via request.clone() for accurate 402 price
75
- → if SIWX: challenge or verify → handleAuth
76
- → if no auth header: build 402 challenge
77
- (x402: PAYMENT-REQUIRED header, MPP: WWW-Authenticate header)
78
- → bufferBody + validateBody (only if .body() chained)
79
- → resolvePrice (static / dynamic / tiered)
80
- → protocol verify (x402 or MPP)
81
- → plugin.onPaymentVerified()
82
- → handler(ctx) → result
83
- → if status < 400: settle payment (x402) or withReceipt (MPP)
84
- → if provider configured: fireProviderQuota()
85
- → plugin.onResponse()
86
- Response out
87
- ```
88
-
89
- ## Key Files
90
-
91
- | File | Purpose |
92
- |------|---------|
93
- | `src/index.ts` | `createRouter()`, `ServiceRouter`, re-exports |
94
- | `src/builder.ts` | `RouteBuilder` fluent API with type-level state tracking |
95
- | `src/orchestrate.ts` | Core request lifecycle, `createRequestHandler()` |
96
- | `src/types.ts` | `HttpError`, `HandlerContext`, pricing/provider/auth types |
97
- | `src/plugin.ts` | `RouterPlugin` interface, `consolePlugin()`, `firePluginHook()` |
98
- | `src/pricing.ts` | `resolvePrice()`, `resolveMaxPrice()` |
99
- | `src/handler.ts` | `safeCallHandler()` error boundary |
100
- | `src/body.ts` | `bufferBody()`, `validateBody()` |
101
- | `src/registry.ts` | `RouteRegistry` with barrel validation |
102
- | `src/protocols/detect.ts` | Header-based protocol detection |
103
- | `src/protocols/x402.ts` | x402 challenge/verify/settle wrappers |
104
- | `src/server.ts` | x402 server initialization with retry |
105
- | `src/auth/siwx.ts` | SIWX verification |
106
- | `src/auth/api-key.ts` | API key verification |
107
- | `src/upstash-rest.ts` | Minimal fetch-only Upstash REST client for `useDefaultStore` |
108
- | `src/auth/nonce.ts` | `NonceStore` interface + `MemoryNonceStore` |
109
- | `src/discovery/well-known.ts` | `.well-known/x402` generation |
110
- | `src/discovery/openapi.ts` | OpenAPI 3.1 spec generation |
111
-
112
- ## Environment Setup
113
-
114
- **CRITICAL:** The router uses the default facilitator from `@coinbase/x402`, which requires CDP API keys at runtime:
115
-
116
- ```bash
117
- CDP_API_KEY_ID=your-key-id
118
- CDP_API_KEY_SECRET=your-key-secret
119
- ```
120
-
121
- **For Next.js apps with env validation (T3 stack, `@t3-oss/env-nextjs`):** These must be declared in your env schema. Next.js does not expose undeclared env vars to `process.env`.
122
-
123
- Without these keys, x402 server initialization fails:
124
- - Error: `"Failed to fetch supported kinds from facilitator: TypeError: fetch failed"`
125
- - Or: `"Facilitator getSupported failed (401): Unauthorized"`
126
- - Symptom: All paid routes return empty 402 responses (no `PAYMENT-REQUIRED` header)
127
-
128
- **Example env schema (T3):**
129
-
130
- ```typescript
131
- // src/env.js
132
- import { createEnv } from "@t3-oss/env-nextjs";
133
- import { z } from "zod";
134
-
135
- export const env = createEnv({
136
- server: {
137
- CDP_API_KEY_ID: z.string(),
138
- CDP_API_KEY_SECRET: z.string(),
139
- // ... other vars
140
- },
141
- runtimeEnv: {
142
- CDP_API_KEY_ID: process.env.CDP_API_KEY_ID,
143
- CDP_API_KEY_SECRET: process.env.CDP_API_KEY_SECRET,
144
- // ... other vars
145
- },
146
- });
147
- ```
148
-
149
- **Why this matters:** The default facilitator reads `process.env.CDP_API_KEY_ID` and `process.env.CDP_API_KEY_SECRET` when creating auth headers for the CDP facilitator API. If they're not in `process.env`, authentication fails silently — the facilitator sends requests without `Authorization` headers, and CDP returns 401.
150
-
151
- ### MPP environment
152
-
153
- For MPP (Micropayment Protocol via Tempo blockchain), you need:
154
-
155
- ```bash
156
- MPP_SECRET_KEY=your-hmac-secret # HMAC key for challenge binding
157
- TEMPO_RPC_URL=https://user:pass@rpc.mainnet.tempo.xyz # Authenticated Tempo RPC
158
- ```
159
-
160
- **Tempo RPC requires authentication.** The default `rpc.tempo.xyz` returns 401. Get credentials from the Tempo team. The `rpcUrl` can be set in config or via `TEMPO_RPC_URL` env var (config takes precedence).
161
-
162
- **Peer dependency for MPP:** `mppx` is an optional peer dep. Required only when `protocols` includes `'mpp'`.
163
-
164
- ## Creating Routes
165
-
166
- ### Step 1: Router setup (once per service)
167
-
168
- ```typescript
169
- // lib/routes.ts
170
- import { createRouter } from '@agentcash/router';
171
-
172
- export const router = createRouter({
173
- payeeAddress: process.env.X402_PAYEE_ADDRESS!,
174
- // Optional:
175
- network: 'eip155:8453', // default
176
- protocols: ['x402', 'mpp'], // protocols for auto-priced routes (default: ['x402'])
177
- plugin: myPlugin, // observability
178
- prices: { 'search': '0.02' }, // central pricing map
179
- mpp: { // MPP support (requires mppx peer dep)
180
- secretKey: process.env.MPP_SECRET_KEY!,
181
- currency: '0x20c0000000000000000000000000000000000000', // PathUSD on Tempo
182
- recipient: process.env.X402_PAYEE_ADDRESS!,
183
- rpcUrl: process.env.TEMPO_RPC_URL, // falls back to TEMPO_RPC_URL env var
184
- useDefaultStore: true, // auto-configures Upstash from KV_REST_API_URL + KV_REST_API_TOKEN
185
- },
186
- siwx: { nonceStore }, // custom nonce store
187
- });
188
- ```
189
-
190
- **Router-level `protocols`:** Sets default protocols for routes using the `prices` map (auto-priced routes). Routes using `.paid()` directly can override with `{ protocols: [...] }`.
191
-
192
- ### Step 2: Define route files
193
-
194
- Each route file exports a Next.js handler (`GET`, `POST`, etc.):
195
-
196
- ```typescript
197
- // app/api/search/route.ts
198
- import { router } from '@/lib/routes';
199
- import { searchSchema, searchResponseSchema } from '@/lib/schemas';
200
-
201
- export const POST = router.route('search')
202
- .paid('0.01')
203
- .body(searchSchema)
204
- .output(searchResponseSchema)
205
- .description('Search the web')
206
- .handler(async ({ body }) => searchService(body));
207
- ```
208
-
209
- ### Step 3: Barrel import
210
-
211
- ```typescript
212
- // lib/routes/barrel.ts
213
- import '@/app/api/search/route';
214
- import '@/app/api/lookup/route';
215
- // ... all route files
216
- ```
217
-
218
- The barrel ensures all routes are registered before discovery endpoints generate their output.
219
-
220
- ## Auth Modes
221
-
222
- ### Paid (x402) — static price
223
- ```typescript
224
- router.route('search').paid('0.01').body(schema).handler(fn);
225
- ```
226
-
227
- ### Paid (dynamic pricing)
228
- ```typescript
229
- router.route('gen')
230
- .paid((body) => calculateCost(body), { maxPrice: '5.00' })
231
- .body(schema)
232
- .handler(fn);
233
- ```
234
- **How it works:** When a request arrives without a payment header, the router clones the request (`request.clone()`), parses the body early, and calls the pricing function to calculate an accurate price for the 402 challenge. This means clients see the real price, not a ceiling.
235
-
236
- `maxPrice` is **optional** and acts as a safety net:
237
- - **Cap:** If the pricing function returns a value above `maxPrice`, the price is capped and a warning is fired via the plugin.
238
- - **Fallback:** If the pricing function throws, `maxPrice` is used as a degraded-mode fallback. Without `maxPrice`, the route returns 500.
239
- - If omitted and the pricing function succeeds, the exact calculated price is used.
240
-
241
- ### Paid (tiered) — requires body
242
- ```typescript
243
- router.route('upload').paid({
244
- field: 'tier',
245
- tiers: {
246
- '10mb': { price: '0.02', label: '10 MB' },
247
- '100mb': { price: '0.20', label: '100 MB' },
248
- },
249
- }).body(schema).handler(fn);
250
- ```
251
- The tier value comes from `body[field]`. The 402 challenge uses the highest tier price.
252
-
253
- ### Dual protocol (x402 + MPP)
254
- ```typescript
255
- router.route('search')
256
- .paid('0.01', { protocols: ['x402', 'mpp'] })
257
- .body(schema).handler(fn);
258
- ```
259
-
260
- ### SIWX (wallet auth, no payment)
261
- ```typescript
262
- router.route('inbox/status')
263
- .siwx()
264
- .query(querySchema)
265
- .handler(async ({ query, wallet }) => getStatus(query, wallet));
266
- ```
267
-
268
- ### API key (composable with paid)
269
- ```typescript
270
- router.route('admin/lookup')
271
- .apiKey((key) => key === 'valid' ? { id: 'acct-1' } : null)
272
- .paid('0.05')
273
- .body(schema)
274
- .handler(async ({ body, account }) => lookup(body, account));
275
- ```
276
- API key is checked first (401 on failure), then payment flow runs.
277
-
278
- ### Unprotected
279
- ```typescript
280
- router.route('health').unprotected().handler(async () => ({ status: 'ok' }));
281
- ```
282
-
283
- ## Handler Context
284
-
285
- Every handler receives:
286
-
287
- ```typescript
288
- interface HandlerContext<TBody, TQuery> {
289
- body: TBody; // Parsed + validated (undefined if no .body())
290
- query: TQuery; // Parsed + validated (undefined if no .query())
291
- request: NextRequest; // Raw request
292
- requestId: string; // Unique per-request UUID (for logging/tracing)
293
- route: string; // Route key (e.g. 'search', 'lookup/org')
294
- wallet: string | null; // Verified wallet (from payment or SIWX)
295
- account: unknown; // From .apiKey() resolver
296
- alert: AlertFn; // Fire observability alerts
297
- setVerifiedWallet: (addr: string) => void;
298
- }
299
- ```
300
-
301
- ## Provider Monitoring
302
-
303
- Routes that wrap third-party APIs can declare monitoring behavior per-provider. This surfaces quota/balance information through the plugin system.
304
-
305
- ### The six provider patterns
306
-
307
- | Pattern | How handled |
308
- |---------|-------------|
309
- | Balance in response headers | `extractQuota` reads `headers` |
310
- | Balance in response body | `extractQuota` reads `result` |
311
- | Separate health-check endpoint | `monitor` function (for cron) |
312
- | Overages at same rate | `overage: 'same-rate'` |
313
- | Overages at increased rate | `overage: 'increased-rate'` |
314
- | No overages, hard stop | `overage: 'hard-stop'` |
315
-
316
- ### Usage
317
-
318
- ```typescript
319
- export const POST = router.route('exa/search')
320
- .paid('0.01')
321
- .provider('exa', {
322
- extractQuota: (result, headers) => ({
323
- remaining: (result as any).rateLimit?.remaining ?? null,
324
- limit: (result as any).rateLimit?.limit ?? null,
325
- }),
326
- monitor: async () => {
327
- const res = await fetch('https://api.exa.ai/usage');
328
- const data = await res.json();
329
- return { remaining: data.credits, limit: data.limit };
330
- },
331
- overage: 'same-rate',
332
- warn: 100,
333
- critical: 10,
334
- })
335
- .body(searchSchema)
336
- .handler(async ({ body }) => exaClient.search(body));
337
- ```
338
-
339
- ### ProviderConfig fields
340
-
341
- | Field | Type | Default | Description |
342
- |-------|------|---------|-------------|
343
- | `extractQuota` | `(result, headers) => QuotaInfo \| null` | — | Inline extraction after each success |
344
- | `monitor` | `() => Promise<QuotaInfo \| null>` | — | Standalone check for cron |
345
- | `overage` | `'same-rate' \| 'increased-rate' \| 'hard-stop'` | `'same-rate'` | What happens at zero |
346
- | `warn` | `number` | — | Warn level threshold |
347
- | `critical` | `number` | — | Critical level threshold |
348
-
349
- ### Threshold logic
350
-
351
- - `remaining === null` → `healthy` (no data)
352
- - `remaining <= critical` → `critical`
353
- - `remaining <= warn` → `warn`
354
- - Otherwise → `healthy`
355
-
356
- ### Cron monitors
357
-
358
- ```typescript
359
- for (const entry of router.monitors()) {
360
- const quota = await entry.monitor();
361
- // entry: { provider, route, monitor, overage, warn, critical }
362
- }
363
- ```
364
-
365
- ### Safety guarantees
366
-
367
- - `extractQuota` is fire-and-forget — exceptions never affect the response
368
- - Only runs when `response.status < 400`
369
- - Plugin hook (`onProviderQuota`) is non-blocking
370
-
371
- ## Discovery Setup
372
-
373
- Discovery metadata (`title`, `version`, `description`, `guidance`) is configured once in `createRouter({ discovery })`. The discovery handlers are zero-arg:
374
-
375
- ```typescript
376
- // app/.well-known/x402/route.ts
377
- import '@/lib/routes/barrel'; // barrel import FIRST to register all routes
378
- import { router } from '@/lib/routes';
379
- export const GET = router.wellKnown();
380
-
381
- // app/openapi.json/route.ts
382
- import '@/lib/routes/barrel';
383
- import { router } from '@/lib/routes';
384
- export const GET = router.openapi();
385
-
386
- // app/llms.txt/route.ts
387
- import { router } from '@/lib/routes';
388
- export const GET = router.llmsTxt();
389
- ```
390
-
391
- **Barrel import must come first.** Without it, Next.js lazy-loads route modules, so discovery endpoints hit before routes register → `route 'X' in prices map but not registered` error.
392
-
393
- **`.well-known/x402` output** includes `mppResources` alongside `resources` when MPP routes exist:
394
- ```json
395
- { "version": 1, "resources": ["..."], "mppResources": ["..."] }
396
- ```
397
-
398
- **OpenAPI spec** includes `x-payment-info` with `price` and `protocols` per operation.
399
-
400
- ## Plugin (Observability)
401
-
402
- ```typescript
403
- const myPlugin: RouterPlugin = {
404
- onRequest(meta) { /* return PluginContext */ },
405
- onPaymentVerified(ctx, payment) { /* log payment */ },
406
- onPaymentSettled(ctx, settlement) { /* log tx */ },
407
- onResponse(ctx, response) { /* log response */ },
408
- onError(ctx, error) { /* alert on errors */ },
409
- onAlert(ctx, alert) { /* handle custom alerts */ },
410
- onProviderQuota(ctx, event) { /* handle quota events */ },
411
- };
412
- ```
413
-
414
- All hooks are optional. All are fire-and-forget. Use `consolePlugin()` for dev logging.
415
-
416
- ## Builder Compile-Time Safety
417
-
418
- The type system (generic parameters `HasAuth`, `NeedsBody`, `HasBody`) prevents invalid chains:
419
-
420
- - `.handler()` requires auth to be set first
421
- - Dynamic/tiered pricing requires `.body()` before `.handler()`
422
- - `.siwx()` is mutually exclusive with `.paid()`
423
- - `.apiKey()` CAN compose with `.paid()`
424
-
425
- ## MPP Persistent Store
426
-
427
- mppx uses a key-value store for transaction hash replay protection. Without a persistent store, `Store.memory()` is used — which is wiped on every cold start. This is unsafe on Vercel or any multi-instance deployment.
428
-
429
- ### Vercel (zero config)
430
-
431
- Set `useDefaultStore: true` to auto-configure an Upstash-backed store from Vercel KV environment variables (`KV_REST_API_URL` + `KV_REST_API_TOKEN`). Uses raw `fetch` — no extra npm dependencies.
432
-
433
- ```typescript
434
- createRouter({
435
- mpp: {
436
- secretKey: process.env.MPP_SECRET_KEY!,
437
- currency: USDC,
438
- useDefaultStore: true, // reads KV_REST_API_URL + KV_REST_API_TOKEN automatically
439
- }
440
- })
441
- ```
442
-
443
- ### Cloudflare / custom
444
-
445
- Pass any `Store.Store` implementation directly via `mpp.store`:
446
-
447
- ```typescript
448
- import { Store } from 'mppx'
449
-
450
- createRouter({
451
- mpp: {
452
- secretKey: process.env.MPP_SECRET_KEY!,
453
- currency: USDC,
454
- store: Store.cloudflare(env.MY_KV_NAMESPACE),
455
- }
456
- })
457
- ```
458
-
459
- Available adapters from `mppx`: `Store.upstash(redis)`, `Store.cloudflare(kv)`, `Store.redis(client)`, `Store.memory()`, `Store.from(custom)`.
460
-
461
- ### Resolution order
462
-
463
- 1. Explicit `store` wins if provided
464
- 2. `useDefaultStore: true` creates an Upstash store from env vars
465
- 3. Neither → mppx defaults to `Store.memory()`
466
-
467
- ## MPP Internals
468
-
469
- The router uses `mppx`'s high-level `Mppx.create()` API, which encapsulates the entire challenge-credential-receipt lifecycle.
470
-
471
- ### How it works
472
-
473
- 1. **Init time** (`src/index.ts`): `Mppx.create({ methods: [tempo.charge({ currency, recipient, rpcUrl })], secretKey })` creates a server instance. This is done inside the async `initPromise` IIFE alongside x402 server init.
474
-
475
- 2. **Challenge** (`build402` in orchestrate.ts): `deps.mppx.charge({ amount })(request)` returns `{ status: 402, challenge: Response }`. The `WWW-Authenticate` header is extracted from the challenge `Response` and set on the router's 402 response.
476
-
477
- 3. **Verify + Receipt** (MPP section in orchestrate.ts): `deps.mppx.charge({ amount })(request)` returns `{ status: 200, withReceipt }` when the credential is valid. `withReceipt(response)` returns a new `Response` with the `Payment-Receipt` header attached.
478
-
479
- 4. **Wallet extraction**: `Credential.fromRequest(request)` (from `mppx` core) extracts the credential, and `credential.source` contains the payer's DID.
480
-
481
- ### Key details
482
-
483
- - **`withReceipt()` creates a new Response** — it does not mutate the original. The returned value must be used (cast to `NextResponse`).
484
- - **`Credential.fromRequest()` is the only low-level mppx import** — used solely for wallet extraction after mppx has already verified the payment.
485
- - **`mppx` is an optional peer dep** — routes using `protocols: ['mpp']` require it. The router's `OrchestrateDeps.mppx` is `null` when not configured.
486
- - **Tempo RPC requires authentication.** The default `rpc.tempo.xyz` returns 401. Always provide `rpcUrl` or set `TEMPO_RPC_URL` env var with authenticated credentials.
487
-
488
- ## Registration-Time Validation
489
-
490
- These throw immediately when the route is defined (not at request time):
491
-
492
- - Empty tier key in tiered pricing
493
- - `maxPrice` that isn't a positive decimal
494
-
495
- **Duplicate route keys** do NOT throw — the registry silently overwrites with a dev-only `console.warn`. This is intentional: Next.js `next build` loads modules non-deterministically, so discovery stubs and real handlers may register the same key in either order. Last writer wins. Prior art: ElysiaJS uses the identical pattern.
496
-
497
- ## Central Pricing Map
498
-
499
- ```typescript
500
- const router = createRouter({
501
- payeeAddress: '...',
502
- protocols: ['x402', 'mpp'], // default protocols for all auto-priced routes
503
- prices: { 'search': '0.02', 'lookup': '0.05' },
504
- mpp: { secretKey, currency, recipient, rpcUrl },
505
- });
506
-
507
- // .paid() is auto-applied with router-level protocols:
508
- export const POST = router.route('search').body(schema).handler(fn);
509
- ```
510
-
511
- Routes using `prices` inherit the router-level `protocols` config. Routes using `.paid()` directly can override per-route with `{ protocols: [...] }`.
512
-
513
- Barrel validation catches mismatches: keys in `prices` but not registered → error.
514
-
515
- ## Common Patterns
516
-
517
- ### Return an error from handler
518
- ```typescript
519
- .handler(async ({ body }) => {
520
- if (!valid(body)) throw new HttpError('Invalid input', 400);
521
- return result;
522
- });
523
- ```
524
-
525
- ### Return a raw Response (streaming)
526
- ```typescript
527
- .handler(async ({ body }) => {
528
- return new Response(streamData, {
529
- headers: { 'Content-Type': 'text/event-stream' },
530
- });
531
- });
532
- ```
533
-
534
- ### Fire a custom alert
535
- ```typescript
536
- .handler(async ({ body, alert }) => {
537
- const result = await callProvider(body);
538
- if (result.slow) alert('warn', 'Slow response', { latency: result.latency });
539
- return result;
540
- });
541
- ```
542
-
543
- ## Troubleshooting
544
-
545
- | Issue | Cause | Fix |
546
- |-------|-------|-----|
547
- | `route 'X' registered twice` warning | Discovery stub + real handler both register same key | Expected during `next build` — last writer wins. Use `registry.has()` guards on stubs to suppress. |
548
- | `x402 server not initialized` | Missing peer deps | Install `@x402/core @x402/evm @x402/extensions @coinbase/x402` |
549
- | 402 on every request | No payment header | Client must send `PAYMENT-SIGNATURE` (x402) or `Authorization: Payment` (MPP) |
550
- | Body undefined in handler | No `.body()` chained | Add `.body(schema)` to the chain |
551
- | Route not in discovery docs | Missing barrel import | Import the route file in your barrel |
552
- | Settlement not happening | Handler returned status >= 400 | Settlement is gated on success responses |
553
- | MPP 401 `unauthorized: authentication required` | Using default unauthenticated Tempo RPC | Set `TEMPO_RPC_URL` env var or `mpp.rpcUrl` config with authenticated URL |
554
- | `route 'X' in prices map but not registered` | Discovery endpoint hit before route module loaded | Add barrel import to discovery route files |
555
- | `mppx package is required` | mppx not installed | `pnpm add mppx` — it's an optional peer dep |
556
- | `useDefaultStore requires KV_REST_API_URL` | Vercel KV env vars not set | Add Vercel KV integration or set `KV_REST_API_URL` + `KV_REST_API_TOKEN` manually |
557
-
558
- ## Maintaining This Skill
559
-
560
- This skill file is the canonical reference for `@agentcash/router`. It ships with the repo at `.claude/skills/router-guide/SKILL.md` so that any agent working with the router has accurate, up-to-date guidance.
561
-
562
- **When you modify the router, update this skill.** Specifically:
563
-
564
- - **New interfaces or types** — Add them to the relevant section (Handler Context, Plugin, Provider Monitoring, etc.)
565
- - **New builder methods** — Document in the Auth Modes or Common Patterns sections
566
- - **New plugin hooks** — Add to the Plugin section
567
- - **Changed behavior** — Update the Request Lifecycle diagram and any affected sections
568
- - **New troubleshooting entries** — Add to the Troubleshooting table
569
- - **New design constraints or non-goals** — Add to the Philosophy section
570
-
571
- The goal is that any future agent can read this single file and implement correctly without needing to reverse-engineer the source. Keep it accurate, keep it concise.
572
-
573
- ## Porting Services to the Router
574
-
575
- When porting an existing service to `@agentcash/router`, the service's route patterns may not cleanly map to the router's abstractions. **If you encounter a pattern that the router doesn't support, do not work around it silently.** Instead:
576
-
577
- 1. **Inform the user** — explain what the service does, what the router expects, and where the mismatch is.
578
- 2. **Suggest a router PR** — if the gap is a reasonable feature (not a one-off hack), propose adding it to the router. Describe the change, which files it touches, and why it's general-purpose.
579
- 3. **Let the user decide** — they may prefer a workaround, a router change, or restructuring the service.
580
-
581
- Common conformance gaps to watch for:
582
- - Handler that needs raw request body (not JSON) — router buffers as JSON when `.body()` is chained. Consider if the route should skip `.body()` and parse from `ctx.request` directly.
583
- - Multiple pricing strategies on one route — router supports static, dynamic, or tiered, but only one per route.
584
- - Auth mode that doesn't fit the 4 modes — router enforces paid/siwx/apiKey/unprotected. Composite auth beyond apiKey+paid is not supported.
585
- - Custom response headers set by middleware — router owns the response lifecycle. Headers go in the handler return or via the plugin.