@agentcash/router 1.6.0 → 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/AGENTS.md +85 -0
- package/README.md +133 -550
- package/dist/index.cjs +567 -525
- package/dist/index.d.cts +90 -71
- package/dist/index.d.ts +90 -71
- package/dist/index.js +559 -506
- package/package.json +6 -14
- package/.claude/CLAUDE.md +0 -343
- package/.claude/skills/router-guide/SKILL.md +0 -585
package/README.md
CHANGED
|
@@ -1,185 +1,130 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
8
|
+
<h1 align="center">@agentcash/router</h1>
|
|
4
9
|
|
|
5
|
-
|
|
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>
|
|
6
14
|
|
|
7
|
-
|
|
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>
|
|
8
20
|
|
|
9
|
-
|
|
10
|
-
pnpm add @agentcash/router
|
|
11
|
-
```
|
|
21
|
+
---
|
|
12
22
|
|
|
13
|
-
|
|
23
|
+
## Install
|
|
14
24
|
|
|
15
25
|
```bash
|
|
16
|
-
pnpm add
|
|
17
|
-
|
|
18
|
-
pnpm add mppx
|
|
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
|
|
19
29
|
```
|
|
20
30
|
|
|
21
|
-
## Environment
|
|
31
|
+
## Environment
|
|
22
32
|
|
|
23
|
-
The
|
|
33
|
+
The recommended entry point reads its config from `process.env`. A copy-paste `.env.example` lives at the repo root.
|
|
24
34
|
|
|
25
|
-
|
|
26
|
-
CDP_API_KEY_ID=your-key-id
|
|
27
|
-
CDP_API_KEY_SECRET=your-key-secret
|
|
28
|
-
```
|
|
35
|
+
### x402
|
|
29
36
|
|
|
30
|
-
|
|
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. |
|
|
31
41
|
|
|
32
|
-
|
|
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
|
-
```
|
|
42
|
+
### Solana
|
|
48
43
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
missing facilitator, MPP, or store configuration to throw immediately in every environment.
|
|
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`. |
|
|
54
48
|
|
|
55
|
-
###
|
|
49
|
+
### MPP (auto-enabled when `MPP_SECRET_KEY` is set)
|
|
56
50
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
}),
|
|
79
|
-
discovery: {
|
|
80
|
-
title: 'My API',
|
|
81
|
-
version: '1.0.0',
|
|
82
|
-
},
|
|
83
|
-
};
|
|
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`). |
|
|
84
58
|
|
|
85
|
-
|
|
86
|
-
export const router = createRouter(config);
|
|
87
|
-
```
|
|
59
|
+
### Other
|
|
88
60
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
`
|
|
93
|
-
`X402_PAYEE_ADDRESS` can pass `{ payeeEnv: 'X402_PAYEE_ADDRESS' }`.
|
|
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. |
|
|
94
65
|
|
|
95
|
-
|
|
96
|
-
env var is present, the full trio is required: `MPP_SECRET_KEY`, `MPP_CURRENCY`,
|
|
97
|
-
and `TEMPO_RPC_URL`. `MPP_CURRENCY` must be the Tempo currency address; for
|
|
98
|
-
Tempo USDC use `TEMPO_USDC_CURRENCY`. Optional `MPP_FEE_PAYER_KEY` is included
|
|
99
|
-
when present and validated as a 32-byte EVM private key. `mppFromEnv()` only
|
|
100
|
-
builds config; call `validateRouterConfig(config)` before `createRouter(config)`
|
|
101
|
-
to fail fast on misconfiguration.
|
|
66
|
+
## Quick start
|
|
102
67
|
|
|
103
|
-
###
|
|
68
|
+
### 1. Create the router
|
|
104
69
|
|
|
105
|
-
|
|
106
|
-
protection, SIWX entitlement records, and MPP tx-hash replay protection. Each
|
|
107
|
-
consumer gets its own key prefix (`siwx:nonce:`, `siwx:ent:`, `mpp:`) so one
|
|
108
|
-
Redis/Upstash instance serves all three.
|
|
70
|
+
There are two ways to initialize. Pick one.
|
|
109
71
|
|
|
110
|
-
`
|
|
111
|
-
automatically (Vercel KV and Upstash both set these). If both are present, it
|
|
112
|
-
builds an Upstash-compatible REST client and wires it into every store. If
|
|
113
|
-
either is missing, all three stores fall back to in-memory — fine for local
|
|
114
|
-
dev, unsafe in serverless production.
|
|
115
|
-
|
|
116
|
-
To use REST credentials under a different env name, pass them as
|
|
117
|
-
`{ url, token }`:
|
|
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.
|
|
118
73
|
|
|
119
74
|
```typescript
|
|
120
|
-
|
|
75
|
+
// lib/router.ts
|
|
76
|
+
import { createRouterFromEnv } from '@agentcash/router';
|
|
121
77
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
},
|
|
127
|
-
// ...
|
|
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.',
|
|
128
82
|
});
|
|
129
83
|
```
|
|
130
84
|
|
|
131
|
-
|
|
132
|
-
`KvStore` interface and pass your instance as `kvStore`.
|
|
133
|
-
|
|
134
|
-
## Quick Start
|
|
135
|
-
|
|
136
|
-
### 1. Create the router (once per service)
|
|
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.
|
|
137
86
|
|
|
138
87
|
```typescript
|
|
139
|
-
// lib/
|
|
140
|
-
import { createRouter } from '@agentcash/router';
|
|
88
|
+
// lib/router.ts
|
|
89
|
+
import { createRouter, BASE_MAINNET_NETWORK } from '@agentcash/router';
|
|
141
90
|
|
|
142
91
|
export const router = createRouter({
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
92
|
+
baseUrl: 'https://api.example.com',
|
|
93
|
+
payeeAddress: '0x…',
|
|
94
|
+
network: BASE_MAINNET_NETWORK,
|
|
95
|
+
protocols: ['x402'],
|
|
96
|
+
x402: { accepts: [/* … */] },
|
|
146
97
|
discovery: {
|
|
147
98
|
title: 'My API',
|
|
148
99
|
version: '1.0.0',
|
|
149
|
-
description: 'Pay-per-call
|
|
100
|
+
description: 'Pay-per-call search.',
|
|
101
|
+
guidance: 'POST /search with { q: string }. Returns top 10 results.',
|
|
150
102
|
},
|
|
151
103
|
});
|
|
152
104
|
```
|
|
153
105
|
|
|
154
106
|
### 2. Define routes
|
|
155
107
|
|
|
156
|
-
**Paid route (x402)**
|
|
157
|
-
|
|
158
108
|
```typescript
|
|
159
109
|
// app/api/search/route.ts
|
|
160
|
-
import { router } from '@/lib/
|
|
161
|
-
import { searchSchema
|
|
110
|
+
import { router } from '@/lib/router';
|
|
111
|
+
import { searchSchema } from '@/lib/schemas';
|
|
162
112
|
|
|
163
113
|
export const POST = router.route({ path: 'search' })
|
|
164
114
|
.paid('0.01')
|
|
165
115
|
.body(searchSchema)
|
|
166
|
-
.output(searchResponseSchema)
|
|
167
|
-
.description('Search the web')
|
|
168
116
|
.handler(async ({ body }) => search(body));
|
|
169
117
|
```
|
|
170
118
|
|
|
171
|
-
**SIWX-authenticated route**
|
|
172
|
-
|
|
173
119
|
```typescript
|
|
120
|
+
// app/api/inbox/status/route.ts
|
|
174
121
|
export const GET = router.route({ path: 'inbox/status' })
|
|
175
122
|
.siwx()
|
|
176
|
-
.
|
|
177
|
-
.handler(async ({ query, wallet }) => getStatus(query, wallet));
|
|
123
|
+
.handler(async ({ wallet }) => getStatus(wallet));
|
|
178
124
|
```
|
|
179
125
|
|
|
180
|
-
**Unprotected route**
|
|
181
|
-
|
|
182
126
|
```typescript
|
|
127
|
+
// app/api/health/route.ts
|
|
183
128
|
export const GET = router.route({ path: 'health' })
|
|
184
129
|
.unprotected()
|
|
185
130
|
.handler(async () => ({ status: 'ok' }));
|
|
@@ -187,479 +132,117 @@ export const GET = router.route({ path: 'health' })
|
|
|
187
132
|
|
|
188
133
|
### 3. Auto-discovery
|
|
189
134
|
|
|
190
|
-
Discovery metadata (`title`, `version`, `description`, `guidance`) is configured once in `createRouter({ discovery })`. The discovery handlers are zero-arg:
|
|
191
|
-
|
|
192
135
|
```typescript
|
|
193
|
-
// app/.well-known/x402/route.ts
|
|
194
|
-
import { router } from '@/lib/routes';
|
|
195
|
-
import '@/lib/routes/barrel'; // ensures all routes are imported
|
|
196
|
-
export const GET = router.wellKnown();
|
|
197
|
-
|
|
198
136
|
// app/openapi.json/route.ts
|
|
199
|
-
import { router } from '@/lib/
|
|
200
|
-
import '@/lib/routes
|
|
137
|
+
import { router } from '@/lib/router';
|
|
138
|
+
import '@/lib/routes-barrel'; // imports every route module so the registry is populated
|
|
201
139
|
export const GET = router.openapi();
|
|
202
|
-
|
|
203
|
-
// app/llms.txt/route.ts
|
|
204
|
-
import { router } from '@/lib/routes';
|
|
205
|
-
export const GET = router.llmsTxt();
|
|
206
140
|
```
|
|
207
141
|
|
|
208
|
-
|
|
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.
|
|
209
143
|
|
|
210
|
-
|
|
211
|
-
- Auth signaling via `security` + `components.securitySchemes`
|
|
212
|
-
- Optional top-level metadata via `x-discovery` (`ownershipProofs`)
|
|
144
|
+
## Auth modes
|
|
213
145
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
| Option | Type | Default | Description |
|
|
221
|
-
|--------|------|---------|-------------|
|
|
222
|
-
| `payeeAddress` | `string` | — | Wallet address to receive payments |
|
|
223
|
-
| `baseUrl` | `string` | **required** | Service origin used for discovery/OpenAPI/realm |
|
|
224
|
-
| `discovery` | `DiscoveryConfig` | **required** | Title, version, description, guidance for OpenAPI/well-known/llms.txt |
|
|
225
|
-
| `network` | `string` | `'eip155:8453'` | Blockchain network |
|
|
226
|
-
| `plugin` | `RouterPlugin` | `undefined` | Observability plugin |
|
|
227
|
-
| `prices` | `Record<string, string>` | `undefined` | Central pricing map (auto-applied) |
|
|
228
|
-
| `kvStore` | `KvStore \| { url, token }` | auto from `KV_REST_API_URL` + `KV_REST_API_TOKEN`, else memory | Single KV cache for SIWX nonce, SIWX entitlement, and MPP tx-hash replay |
|
|
229
|
-
| `mpp` | `{ secretKey, currency, recipient?, rpcUrl?, feePayerKey?, session? }` | `undefined` | MPP config |
|
|
230
|
-
| `protocols` | `('x402' \| 'mpp')[]` | `['x402']` | Default protocols for paid routes |
|
|
231
|
-
| `strictRoutes` | `boolean` | `false` | Enforce `route({ path })` and prevent key/path divergence |
|
|
232
|
-
|
|
233
|
-
### 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. |
|
|
234
152
|
|
|
235
153
|
```typescript
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
TEMPO_USDC_CURRENCY,
|
|
240
|
-
getRouterConfigIssues,
|
|
241
|
-
mppFromEnv,
|
|
242
|
-
paidOptionsForProtocols,
|
|
243
|
-
validateRouterConfig,
|
|
244
|
-
x402AcceptsFromEnv,
|
|
245
|
-
} from '@agentcash/router';
|
|
246
|
-
```
|
|
247
|
-
|
|
248
|
-
- `validateRouterConfig(config)` throws `RouterConfigError` with structured
|
|
249
|
-
issues. Use it when you want invalid env/config to fail at startup in every
|
|
250
|
-
environment; `createRouter(config)` still performs its own validation and
|
|
251
|
-
keeps deferred protocol init errors in development for request-time feedback.
|
|
252
|
-
- `getRouterConfigIssues(config)` returns the same structured issues without
|
|
253
|
-
throwing.
|
|
254
|
-
- `x402AcceptsFromEnv(env)` builds Base and optional Solana x402 accepts from
|
|
255
|
-
`X402_WALLET_ADDRESS` and `SOLANA_PAYEE_ADDRESS`.
|
|
256
|
-
- `mppFromEnv(env)` builds MPP config only when MPP env is present, and rejects
|
|
257
|
-
partial MPP env.
|
|
258
|
-
- `paidOptionsForProtocols(protocols)` copies a protocol array into a
|
|
259
|
-
route-level `PaidOptions` object.
|
|
260
|
-
- Manual `.paid(price)` routes inherit `createRouter({ protocols })` unless
|
|
261
|
-
the route passes its own `options.protocols`.
|
|
262
|
-
|
|
263
|
-
### Path-First Routing
|
|
264
|
-
|
|
265
|
-
Use path-first route definitions to keep runtime, OpenAPI, and discovery aligned:
|
|
266
|
-
|
|
267
|
-
```typescript
|
|
268
|
-
router.route({ path: 'flightaware/airports/id/flights/arrivals', method: 'GET' })
|
|
269
|
-
```
|
|
154
|
+
router.route({ path: 'admin/users' })
|
|
155
|
+
.apiKey(async (key) => db.admin.findByKey(key)) // null => 401
|
|
156
|
+
.handler(async ({ account }) => db.user.findMany());
|
|
270
157
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
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);
|
|
275
161
|
```
|
|
276
162
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
### Route Builder
|
|
280
|
-
|
|
281
|
-
The fluent builder ensures compile-time safety:
|
|
163
|
+
## Pricing
|
|
282
164
|
|
|
283
|
-
|
|
284
|
-
- `.siwx()` - SIWX wallet auth
|
|
285
|
-
- `.apiKey(resolver)` - API key auth (composable with `.paid()`)
|
|
286
|
-
- `.unprotected()` - No auth
|
|
287
|
-
- `.body(zodSchema, example?)` - Request body validation with optional discovery example
|
|
288
|
-
- `.query(zodSchema, example?)` - Query parameter validation with optional discovery example
|
|
289
|
-
- `.output(zodSchema, example?)` - Response schema with optional discovery example
|
|
290
|
-
- `.inputExample(sample)` / `.outputExample(sample)` - Optional examples when a separate call reads better
|
|
291
|
-
- `.description(text)` - Route description (for OpenAPI)
|
|
292
|
-
- `.provider(name, config?)` - Provider monitoring (see [Provider Monitoring](#provider-monitoring))
|
|
293
|
-
- `.settlement({ beforeSettle, afterSettle, onSettledHandlerError, onSettlementError })` - Payment lifecycle hooks
|
|
294
|
-
- `.handler(fn)` - Terminal method, returns Next.js handler
|
|
295
|
-
|
|
296
|
-
### Pricing Modes
|
|
297
|
-
|
|
298
|
-
**Static** - Fixed price for all requests:
|
|
165
|
+
**Static.**
|
|
299
166
|
```typescript
|
|
300
|
-
|
|
167
|
+
.paid('0.02')
|
|
301
168
|
```
|
|
302
169
|
|
|
303
|
-
**
|
|
170
|
+
**Args-driven.**
|
|
304
171
|
```typescript
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
.body(imageGenSchema)
|
|
308
|
-
.handler(async ({ body }) => generate(body));
|
|
172
|
+
.paid((body) => calculateCost(body), { maxPrice: '5.00' })
|
|
173
|
+
.body(genSchema)
|
|
309
174
|
```
|
|
310
175
|
|
|
311
|
-
|
|
312
|
-
```typescript
|
|
313
|
-
router.route('compute')
|
|
314
|
-
.paid((body) => calculateExpensiveOperation(body), { maxPrice: '10.00' })
|
|
315
|
-
.body(computeSchema)
|
|
316
|
-
.handler(async ({ body }) => compute(body));
|
|
317
|
-
```
|
|
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.
|
|
318
177
|
|
|
319
|
-
**Tiered
|
|
178
|
+
**Tiered.**
|
|
320
179
|
```typescript
|
|
321
|
-
|
|
180
|
+
.paid({
|
|
322
181
|
field: 'tier',
|
|
323
182
|
tiers: {
|
|
324
183
|
'10mb': { price: '0.02', label: '10 MB' },
|
|
325
184
|
'100mb': { price: '0.20', label: '100 MB' },
|
|
326
185
|
},
|
|
327
|
-
})
|
|
186
|
+
})
|
|
187
|
+
.body(uploadSchema)
|
|
328
188
|
```
|
|
329
189
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
`maxPrice` is **optional** for dynamic pricing and acts as a safety net:
|
|
333
|
-
|
|
334
|
-
1. **Capping**: If `calculateCost(body)` returns `"15.00"` but `maxPrice: "10.00"`, the client is charged `$10.00` (capped) and a warning alert fires.
|
|
335
|
-
|
|
336
|
-
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.
|
|
337
|
-
|
|
338
|
-
3. **Trust mode**: No `maxPrice` means full trust in your pricing function (no cap, no fallback).
|
|
339
|
-
|
|
340
|
-
**Best practices:**
|
|
341
|
-
- ✅ Always set `maxPrice` for production routes (safety net)
|
|
342
|
-
- ✅ Use `maxPrice` for routes with external dependencies (pricing APIs)
|
|
343
|
-
- ✅ Monitor alerts for capping events (indicates pricing bug)
|
|
344
|
-
- ⚠️ Skip `maxPrice` only for well-tested, unbounded pricing (e.g., per-GB storage)
|
|
345
|
-
|
|
346
|
-
**Example with safety net:**
|
|
347
|
-
```typescript
|
|
348
|
-
router.route('ai-gen')
|
|
349
|
-
.paid(async (body) => {
|
|
350
|
-
// External pricing API (can fail)
|
|
351
|
-
const res = await fetch('https://pricing.example.com/calculate', {
|
|
352
|
-
method: 'POST',
|
|
353
|
-
body: JSON.stringify(body),
|
|
354
|
-
});
|
|
355
|
-
return res.json().price;
|
|
356
|
-
}, { maxPrice: '5.00' }) // Fallback if API is down
|
|
357
|
-
.body(genSchema)
|
|
358
|
-
.handler(async ({ body }) => generate(body));
|
|
359
|
-
```
|
|
360
|
-
|
|
361
|
-
### Dual Protocol (x402 + MPP)
|
|
362
|
-
|
|
190
|
+
**Handler-driven (request-mode).** Bills exactly `tickCost` per request:
|
|
363
191
|
```typescript
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
.body(schema)
|
|
367
|
-
.handler(fn);
|
|
192
|
+
.paid({ dynamic: true, tickCost: '0.01', unitType: 'request', maxPrice: '0.01' })
|
|
193
|
+
.handler(async ({ body }) => { ... });
|
|
368
194
|
```
|
|
369
195
|
|
|
370
|
-
|
|
371
|
-
|
|
196
|
+
**Streaming (MPP only).** One `charge()` call bills one tick:
|
|
372
197
|
```typescript
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
alert: AlertFn; // Fire observability alerts
|
|
381
|
-
setVerifiedWallet: (addr: string) => void;
|
|
382
|
-
}
|
|
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
|
+
});
|
|
383
205
|
```
|
|
384
206
|
|
|
385
|
-
|
|
386
|
-
paid requests it includes `protocol`, `status`, `payer`, `amount`, `network`,
|
|
387
|
-
and best-effort recipient/transaction/receipt metadata when the protocol
|
|
388
|
-
provides it. x402 handlers currently see `status: 'verified'` because settlement
|
|
389
|
-
happens after a successful handler response.
|
|
390
|
-
|
|
391
|
-
### Settlement Lifecycle
|
|
207
|
+
## Pre-payment validation
|
|
392
208
|
|
|
393
|
-
For
|
|
394
|
-
but before router-controlled settlement:
|
|
209
|
+
For checks that need a DB lookup before quoting a price:
|
|
395
210
|
|
|
396
211
|
```typescript
|
|
397
|
-
router.route('
|
|
398
|
-
.paid('
|
|
399
|
-
.body(
|
|
400
|
-
.
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}
|
|
405
|
-
},
|
|
406
|
-
afterSettle: async ({ payment, result }) => {
|
|
407
|
-
await ledger.record({ tx: payment.transaction, result });
|
|
408
|
-
},
|
|
409
|
-
onSettledHandlerError: async ({ payment, error }) => {
|
|
410
|
-
await compensationQueue.enqueue({ receipt: payment.receipt, error });
|
|
411
|
-
},
|
|
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
|
+
}
|
|
412
219
|
})
|
|
413
|
-
.handler(async ({ body }) =>
|
|
220
|
+
.handler(async ({ body, wallet }) => registerDomain(body.domain, wallet));
|
|
414
221
|
```
|
|
415
222
|
|
|
416
|
-
`
|
|
417
|
-
transaction-payload flows. `afterSettle` is for durable ledgers, analytics, and
|
|
418
|
-
post-settlement bookkeeping. `onSettledHandlerError` covers already-settled
|
|
419
|
-
MPP requests whose handler returns an error response, which is the right place
|
|
420
|
-
to enqueue app-owned refund or compensation work. The router cannot generically
|
|
421
|
-
refund protocol payments because it does not hold merchant signing keys.
|
|
422
|
-
|
|
423
|
-
### RouterPlugin
|
|
223
|
+
Pipeline order: `body parse -> validate -> 402 challenge -> payment -> handler`.
|
|
424
224
|
|
|
425
|
-
|
|
225
|
+
## Plugin Hooks
|
|
426
226
|
|
|
427
227
|
```typescript
|
|
428
|
-
import {
|
|
228
|
+
import { createRouterFromEnv, type RouterPlugin } from '@agentcash/router';
|
|
429
229
|
|
|
430
230
|
const myPlugin: RouterPlugin = {
|
|
431
|
-
onRequest(meta) {
|
|
432
|
-
onPaymentVerified(ctx, payment) {
|
|
433
|
-
onPaymentSettled(ctx, settlement) {
|
|
434
|
-
onResponse(ctx, response) {
|
|
435
|
-
onError(ctx, error) {
|
|
436
|
-
onAlert(ctx, alert) {
|
|
437
|
-
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) {},
|
|
438
237
|
};
|
|
439
238
|
|
|
440
|
-
export const router =
|
|
441
|
-
|
|
442
|
-
|
|
239
|
+
export const router = createRouterFromEnv({
|
|
240
|
+
title: 'My API',
|
|
241
|
+
description: '…',
|
|
242
|
+
guidance: '…',
|
|
443
243
|
plugin: myPlugin,
|
|
444
|
-
discovery: {
|
|
445
|
-
title: 'My API',
|
|
446
|
-
version: '1.0.0',
|
|
447
|
-
},
|
|
448
|
-
});
|
|
449
|
-
```
|
|
450
|
-
|
|
451
|
-
Built-in `consolePlugin()` logs lifecycle events:
|
|
452
|
-
|
|
453
|
-
```typescript
|
|
454
|
-
import { createRouter, consolePlugin } from '@agentcash/router';
|
|
455
|
-
|
|
456
|
-
export const router = createRouter({
|
|
457
|
-
payeeAddress: process.env.X402_WALLET_ADDRESS!,
|
|
458
|
-
baseUrl: process.env.NEXT_PUBLIC_BASE_URL!,
|
|
459
|
-
plugin: consolePlugin(),
|
|
460
|
-
discovery: {
|
|
461
|
-
title: 'My API',
|
|
462
|
-
version: '1.0.0',
|
|
463
|
-
},
|
|
464
|
-
});
|
|
465
|
-
```
|
|
466
|
-
|
|
467
|
-
### Central Pricing Map
|
|
468
|
-
|
|
469
|
-
For services with many static-priced routes:
|
|
470
|
-
|
|
471
|
-
```typescript
|
|
472
|
-
const router = createRouter({
|
|
473
|
-
payeeAddress: process.env.X402_WALLET_ADDRESS!,
|
|
474
|
-
baseUrl: process.env.NEXT_PUBLIC_BASE_URL!,
|
|
475
|
-
discovery: {
|
|
476
|
-
title: 'My API',
|
|
477
|
-
version: '1.0.0',
|
|
478
|
-
},
|
|
479
|
-
prices: {
|
|
480
|
-
'search': '0.02',
|
|
481
|
-
'lookup': '0.05',
|
|
482
|
-
},
|
|
483
244
|
});
|
|
484
|
-
|
|
485
|
-
// Price auto-applied, no .paid() needed
|
|
486
|
-
export const POST = router.route('search')
|
|
487
|
-
.body(schema)
|
|
488
|
-
.handler(fn);
|
|
489
245
|
```
|
|
490
246
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
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.
|
|
494
|
-
|
|
495
|
-
#### Why
|
|
496
|
-
|
|
497
|
-
Upstream providers report remaining quota in different ways:
|
|
498
|
-
|
|
499
|
-
| Pattern | Example | How detected |
|
|
500
|
-
|---------|---------|-------------|
|
|
501
|
-
| Balance in response headers | `X-RateLimit-Remaining: 482` | `extractQuota` reads headers |
|
|
502
|
-
| Balance in response body | `{ rateLimit: { remaining: 50 } }` | `extractQuota` reads result |
|
|
503
|
-
| Separate health-check endpoint | Apollo `/credits` endpoint | `monitor` function (cron) |
|
|
504
|
-
| Overages auto-charged at same rate | Exa, Firecrawl | `overage: 'same-rate'` |
|
|
505
|
-
| Overages at increased rate | Some SaaS APIs | `overage: 'increased-rate'` |
|
|
506
|
-
| No overages, immediate stoppage | Whitepages | `overage: 'hard-stop'` |
|
|
507
|
-
|
|
508
|
-
The `.provider()` method handles all six patterns through a single interface.
|
|
509
|
-
|
|
510
|
-
#### Basic usage
|
|
511
|
-
|
|
512
|
-
```typescript
|
|
513
|
-
export const POST = router.route('search')
|
|
514
|
-
.paid('0.01')
|
|
515
|
-
.provider('exa', {
|
|
516
|
-
extractQuota: (result, headers) => ({
|
|
517
|
-
remaining: (result as any).rateLimit?.remaining ?? null,
|
|
518
|
-
limit: (result as any).rateLimit?.limit ?? null,
|
|
519
|
-
}),
|
|
520
|
-
warn: 100,
|
|
521
|
-
critical: 10,
|
|
522
|
-
})
|
|
523
|
-
.body(searchSchema)
|
|
524
|
-
.handler(async ({ body }) => exaClient.search(body));
|
|
525
|
-
```
|
|
526
|
-
|
|
527
|
-
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.
|
|
528
|
-
|
|
529
|
-
#### ProviderConfig
|
|
530
|
-
|
|
531
|
-
| Field | Type | Default | Description |
|
|
532
|
-
|-------|------|---------|-------------|
|
|
533
|
-
| `extractQuota` | `(result, headers) => QuotaInfo \| null` | — | Inline quota extraction after each request |
|
|
534
|
-
| `monitor` | `() => Promise<QuotaInfo \| null>` | — | Standalone health check (for cron) |
|
|
535
|
-
| `overage` | `'same-rate' \| 'increased-rate' \| 'hard-stop'` | `'same-rate'` | What happens when quota hits zero |
|
|
536
|
-
| `warn` | `number` | — | Fire `warn` level when remaining <= this |
|
|
537
|
-
| `critical` | `number` | — | Fire `critical` level when remaining <= this |
|
|
538
|
-
|
|
539
|
-
#### QuotaInfo
|
|
540
|
-
|
|
541
|
-
```typescript
|
|
542
|
-
interface QuotaInfo {
|
|
543
|
-
remaining: number | null; // Credits/calls remaining
|
|
544
|
-
limit: number | null; // Total quota (null if unknown)
|
|
545
|
-
spend?: number; // Credits consumed this request
|
|
546
|
-
}
|
|
547
|
-
```
|
|
548
|
-
|
|
549
|
-
#### Threshold logic
|
|
550
|
-
|
|
551
|
-
| Condition | Level |
|
|
552
|
-
|-----------|-------|
|
|
553
|
-
| `remaining === null` | `healthy` (no data to compare) |
|
|
554
|
-
| `remaining <= critical` | `critical` |
|
|
555
|
-
| `remaining <= warn` | `warn` |
|
|
556
|
-
| Otherwise | `healthy` |
|
|
557
|
-
|
|
558
|
-
#### Plugin hook
|
|
559
|
-
|
|
560
|
-
```typescript
|
|
561
|
-
interface ProviderQuotaEvent {
|
|
562
|
-
provider: string; // Provider name from .provider()
|
|
563
|
-
route: string; // Route key
|
|
564
|
-
remaining: number | null;
|
|
565
|
-
limit: number | null;
|
|
566
|
-
spend?: number;
|
|
567
|
-
level: 'healthy' | 'warn' | 'critical';
|
|
568
|
-
overage: 'same-rate' | 'increased-rate' | 'hard-stop';
|
|
569
|
-
message: string; // Human-readable summary
|
|
570
|
-
}
|
|
571
|
-
```
|
|
572
|
-
|
|
573
|
-
Handle in your plugin:
|
|
574
|
-
|
|
575
|
-
```typescript
|
|
576
|
-
const myPlugin: RouterPlugin = {
|
|
577
|
-
onProviderQuota(ctx, event) {
|
|
578
|
-
if (event.level === 'critical') {
|
|
579
|
-
discord.alert(`${event.provider}: ${event.remaining} remaining`);
|
|
580
|
-
}
|
|
581
|
-
clickhouse.insert('provider_quota', event);
|
|
582
|
-
},
|
|
583
|
-
};
|
|
584
|
-
```
|
|
585
|
-
|
|
586
|
-
#### Cron monitors
|
|
587
|
-
|
|
588
|
-
For providers that require a separate API call to check balance (not available inline in response), register a `monitor` function:
|
|
589
|
-
|
|
590
|
-
```typescript
|
|
591
|
-
export const POST = router.route('people/search')
|
|
592
|
-
.paid('0.05')
|
|
593
|
-
.provider('apollo', {
|
|
594
|
-
monitor: async () => {
|
|
595
|
-
const res = await fetch('https://api.apollo.io/v1/credits', {
|
|
596
|
-
headers: { 'X-Api-Key': process.env.APOLLO_KEY! },
|
|
597
|
-
});
|
|
598
|
-
const data = await res.json();
|
|
599
|
-
return { remaining: data.credits, limit: null };
|
|
600
|
-
},
|
|
601
|
-
overage: 'hard-stop',
|
|
602
|
-
warn: 500,
|
|
603
|
-
critical: 50,
|
|
604
|
-
})
|
|
605
|
-
.body(searchSchema)
|
|
606
|
-
.handler(fn);
|
|
607
|
-
```
|
|
608
|
-
|
|
609
|
-
Retrieve all registered monitors via `router.monitors()`:
|
|
610
|
-
|
|
611
|
-
```typescript
|
|
612
|
-
// cron.ts — run every 5 minutes
|
|
613
|
-
import { router } from '@/lib/routes';
|
|
614
|
-
import '@/lib/routes/barrel';
|
|
615
|
-
|
|
616
|
-
for (const entry of router.monitors()) {
|
|
617
|
-
const quota = await entry.monitor();
|
|
618
|
-
if (!quota) continue;
|
|
619
|
-
|
|
620
|
-
const level = quota.remaining !== null && quota.remaining <= (entry.critical ?? 0)
|
|
621
|
-
? 'critical'
|
|
622
|
-
: quota.remaining !== null && quota.remaining <= (entry.warn ?? 0)
|
|
623
|
-
? 'warn'
|
|
624
|
-
: 'healthy';
|
|
625
|
-
|
|
626
|
-
if (level !== 'healthy') {
|
|
627
|
-
alert(`${entry.provider} (${entry.route}): ${quota.remaining} remaining [${level}]`);
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
```
|
|
631
|
-
|
|
632
|
-
`monitors()` returns:
|
|
633
|
-
|
|
634
|
-
```typescript
|
|
635
|
-
interface MonitorEntry {
|
|
636
|
-
provider: string;
|
|
637
|
-
route: string;
|
|
638
|
-
monitor: () => Promise<QuotaInfo | null>;
|
|
639
|
-
overage: OveragePolicy;
|
|
640
|
-
warn?: number;
|
|
641
|
-
critical?: number;
|
|
642
|
-
}
|
|
643
|
-
```
|
|
644
|
-
|
|
645
|
-
#### Provider name only
|
|
646
|
-
|
|
647
|
-
If you just want to tag a route with its provider for logging/tracing, pass only the name:
|
|
648
|
-
|
|
649
|
-
```typescript
|
|
650
|
-
router.route('health')
|
|
651
|
-
.unprotected()
|
|
652
|
-
.provider('internal')
|
|
653
|
-
.handler(async () => ({ status: 'ok' }));
|
|
654
|
-
```
|
|
655
|
-
|
|
656
|
-
#### Safety guarantees
|
|
657
|
-
|
|
658
|
-
- `extractQuota` runs fire-and-forget — exceptions are caught and swallowed
|
|
659
|
-
- `extractQuota` only runs when `response.status < 400` (no quota extraction on errors)
|
|
660
|
-
- The plugin hook is non-blocking — it never delays the response to the caller
|
|
661
|
-
- Missing thresholds are fine — without `warn`/`critical`, level is always `healthy`
|
|
662
|
-
|
|
663
|
-
## 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.
|
|
664
248
|
|
|
665
|
-
MIT
|