Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions .amazonq/rules/nevermined-router.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Nevermined Router — paying external services

You are writing an agent that must **pay** an external service it has no account with — any x402
agent or MPP merchant — through the Nevermined Router. If you are *receiving* payments or buying a
Nevermined plan, use the `nevermined-payments` rules instead.

Full skill: https://github.com/nevermined-io/docs/tree/main/skills/nevermined-router
Docs: https://nevermined.ai/docs/products/router/overview

## When the Router does NOT apply

It pays a price quoted **on the wire** for one request. A conventional SaaS API (Exa, Firecrawl,
Tavily…) is billed out of band by a monthly plan and a long-lived key, so it never quotes a
per-request price — there is nothing to pay and no address to pay it to.

**A service answering `401`/`403` rather than `402` wants authentication, not payment.** Say so;
don't try to route it.

## Environment

- `NVM_API_URL` — `https://api.sandbox.nevermined.app` or `https://api.live.nevermined.app`
- `NVM_API_KEY` — `sandbox:…` / `live:…`. **Never send it to the merchant**; it authenticates you to
Nevermined only. The merchant's own auth goes in `headers`.
- `NVM_DELEGATION_ID` — the spending Delegation

## 1. Create a Delegation (the budget)

```bash
curl -sX POST "$NVM_API_URL/api/v1/delegation/create" \
-H "Authorization: Bearer $NVM_API_KEY" -H "Content-Type: application/json" \
-d '{"provider":"erc4337","currency":"usdc","spendingLimitCents":500,"durationSecs":604800}'
```

All four fields are required — no defaults. `erc4337` is the crypto-funded Delegation both
stablecoin rails need.

## 2. Fund the buyer wallet

Both rails **pull** from your own custodial wallet: a Delegation authorizes a spend, it does not
supply funds. Read the address from the live Delegation every time —
`GET /api/v1/delegation/{id}` → `providerPaymentMethodId`.

**Never reuse a cached address.** A stale one is the most common cause of `402 BCK.ROUTER.0009`, and
that error deliberately doesn't echo the address it checked.

## 3. Discover (public, no API key)

```bash
curl -s "$NVM_API_URL/api/v1/catalog/services?protocol=x402&search=web+search"
```

- **Only `protocol` of `x402` or `mpp` is routable.** Filter for them.
- **`targetUrl` is the default endpoint's COMPLETE URL, not a base** — it may already contain the
path (`https://host/search` while `endpoints[0].path` is also `/search`), so concatenating yields
`/search/search`. Resolve instead:

```ts
const url = endpoint ? new URL(endpoint.path, service.targetUrl).toString() : service.targetUrl
```

- `offset` is the page **size**, not a skip count, and default ordering is shuffled within each
tier — never assume `services[0]` is stable.

## 4. Pay

```ts
const res = await fetch(`${process.env.NVM_API_URL}/api/v1/router/route`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NVM_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
delegationId: process.env.NVM_DELEGATION_ID,
url: 'https://service.example/api/resource',
method: 'POST',
body: { query: '…' },
requestId: 'search-nevermined-router-v1', // stable per PURCHASE, not per attempt
}),
})
const { status, body, paid, payment } = await res.json()
```

The Router probes, auto-detects the protocol from the 402, pays and relays. `status`/`body` are the
merchant's own; `paid: false` with no `payment` means it was free — handle that. For streaming use
`ALL /api/v1/router/proxy` with `X-Router-Target-Url`, `X-Router-Delegation-Id` and
`X-Router-Request-Id` headers.

### `requestId` is an idempotency key, not a request counter

**A fresh `uuid4()` per HTTP attempt is how an agent double-spends.** Use one stable id per logical
purchase, reused across retries of it: the same id returns the original payment, a fresh id buys
again. Derive it from the work being done.

### Money arithmetic

Budget is debited in **whole cents, rounded up** — 1000 calls at $0.001 costs **$10.00, not $1.00**.
`settlement.approxCents` is what was actually reserved; trust it over any catalog `priceLabel`.

## Guardrails — a refusal is the system working

- `BCK.ROUTER.0003` (402) — Delegation over cap, expired, exhausted or revoked. **Stop.**
- `BCK.ROUTER.0009` (402) — wallet short on the target network; nothing was signed. **Stop.**
- `BCK.ROUTER.0002` (409) — `requestId` already used; the original `paymentId` is in the response.
- `BCK.ROUTER.0001` (400) — bad input / no fundable option / non-allowlisted asset; `details` names it.
- `BCK.ROUTER.0008` (403) — legacy API key; create a new one.
- Only `BCK.ROUTER.0006` (500) and `0007` (429, too many concurrent) are **retryable**. Everything
else is a decision — retrying unchanged gives the same answer.

**Never widen a Delegation, and never create a second one, to get past a refusal.** The cap is the
user's decision, not a runtime obstacle; minting a fresh Delegation to escape an exhausted one
defeats the whole mechanism. Report and stop.

## Accounting

`GET /api/v1/router/payments` (filters `delegationId`, `from`, `to`, `format=csv`) and
`/payments/summary`. `amount` is in the asset's smallest unit, not cents. A record at `Issued` is
**not** an error — the money moved; do not retry it.
127 changes: 127 additions & 0 deletions .clinerules/nevermined-router.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
---
paths:
- "**/*.ts"
- "**/*.tsx"
- "**/*.js"
- "**/*.jsx"
- "**/*.py"
---

# Nevermined Router — paying external services

You are writing an agent that must **pay** an external service it has no account with — any x402
agent or MPP merchant — through the Nevermined Router. If you are *receiving* payments or buying a
Nevermined plan, use the `nevermined-payments` rules instead.

Full skill: https://github.com/nevermined-io/docs/tree/main/skills/nevermined-router
Docs: https://nevermined.ai/docs/products/router/overview

## When the Router does NOT apply

It pays a price quoted **on the wire** for one request. A conventional SaaS API (Exa, Firecrawl,
Tavily…) is billed out of band by a monthly plan and a long-lived key, so it never quotes a
per-request price — there is nothing to pay and no address to pay it to.

**A service answering `401`/`403` rather than `402` wants authentication, not payment.** Say so;
don't try to route it.

## Environment

- `NVM_API_URL` — `https://api.sandbox.nevermined.app` or `https://api.live.nevermined.app`
- `NVM_API_KEY` — `sandbox:…` / `live:…`. **Never send it to the merchant**; it authenticates you to
Nevermined only. The merchant's own auth goes in `headers`.
- `NVM_DELEGATION_ID` — the spending Delegation

## 1. Create a Delegation (the budget)

```bash
curl -sX POST "$NVM_API_URL/api/v1/delegation/create" \
-H "Authorization: Bearer $NVM_API_KEY" -H "Content-Type: application/json" \
-d '{"provider":"erc4337","currency":"usdc","spendingLimitCents":500,"durationSecs":604800}'
```

All four fields are required — no defaults. `erc4337` is the crypto-funded Delegation both
stablecoin rails need.

## 2. Fund the buyer wallet

Both rails **pull** from your own custodial wallet: a Delegation authorizes a spend, it does not
supply funds. Read the address from the live Delegation every time —
`GET /api/v1/delegation/{id}` → `providerPaymentMethodId`.

**Never reuse a cached address.** A stale one is the most common cause of `402 BCK.ROUTER.0009`, and
that error deliberately doesn't echo the address it checked.

## 3. Discover (public, no API key)

```bash
curl -s "$NVM_API_URL/api/v1/catalog/services?protocol=x402&search=web+search"
```

- **Only `protocol` of `x402` or `mpp` is routable.** Filter for them.
- **`targetUrl` is the default endpoint's COMPLETE URL, not a base** — it may already contain the
path (`https://host/search` while `endpoints[0].path` is also `/search`), so concatenating yields
`/search/search`. Resolve instead:

```ts
const url = endpoint ? new URL(endpoint.path, service.targetUrl).toString() : service.targetUrl
```

- `offset` is the page **size**, not a skip count, and default ordering is shuffled within each
tier — never assume `services[0]` is stable.

## 4. Pay

```ts
const res = await fetch(`${process.env.NVM_API_URL}/api/v1/router/route`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NVM_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
delegationId: process.env.NVM_DELEGATION_ID,
url: 'https://service.example/api/resource',
method: 'POST',
body: { query: '…' },
requestId: 'search-nevermined-router-v1', // stable per PURCHASE, not per attempt
}),
})
const { status, body, paid, payment } = await res.json()
```

The Router probes, auto-detects the protocol from the 402, pays and relays. `status`/`body` are the
merchant's own; `paid: false` with no `payment` means it was free — handle that. For streaming use
`ALL /api/v1/router/proxy` with `X-Router-Target-Url`, `X-Router-Delegation-Id` and
`X-Router-Request-Id` headers.

### `requestId` is an idempotency key, not a request counter

**A fresh `uuid4()` per HTTP attempt is how an agent double-spends.** Use one stable id per logical
purchase, reused across retries of it: the same id returns the original payment, a fresh id buys
again. Derive it from the work being done.

### Money arithmetic

Budget is debited in **whole cents, rounded up** — 1000 calls at $0.001 costs **$10.00, not $1.00**.
`settlement.approxCents` is what was actually reserved; trust it over any catalog `priceLabel`.

## Guardrails — a refusal is the system working

- `BCK.ROUTER.0003` (402) — Delegation over cap, expired, exhausted or revoked. **Stop.**
- `BCK.ROUTER.0009` (402) — wallet short on the target network; nothing was signed. **Stop.**
- `BCK.ROUTER.0002` (409) — `requestId` already used; the original `paymentId` is in the response.
- `BCK.ROUTER.0001` (400) — bad input / no fundable option / non-allowlisted asset; `details` names it.
- `BCK.ROUTER.0008` (403) — legacy API key; create a new one.
- Only `BCK.ROUTER.0006` (500) and `0007` (429, too many concurrent) are **retryable**. Everything
else is a decision — retrying unchanged gives the same answer.

**Never widen a Delegation, and never create a second one, to get past a refusal.** The cap is the
user's decision, not a runtime obstacle; minting a fresh Delegation to escape an exhausted one
defeats the whole mechanism. Report and stop.

## Accounting

`GET /api/v1/router/payments` (filters `delegationId`, `from`, `to`, `format=csv`) and
`/payments/summary`. `amount` is in the asset's smallest unit, not cents. A record at `Issued` is
**not** an error — the money moved; do not retry it.
123 changes: 123 additions & 0 deletions .cursor/rules/nevermined-router.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
---
description: Nevermined Router — pay any external x402 or MPP service from a capped Delegation
globs: ["*.ts", "*.tsx", "*.js", "*.jsx", "*.py", "*.mjs"]
---

# Nevermined Router — paying external services

You are writing an agent that must **pay** an external service it has no account with — any x402
agent or MPP merchant — through the Nevermined Router. If you are *receiving* payments or buying a
Nevermined plan, use the `nevermined-payments` rules instead.

Full skill: https://github.com/nevermined-io/docs/tree/main/skills/nevermined-router
Docs: https://nevermined.ai/docs/products/router/overview

## When the Router does NOT apply

It pays a price quoted **on the wire** for one request. A conventional SaaS API (Exa, Firecrawl,
Tavily…) is billed out of band by a monthly plan and a long-lived key, so it never quotes a
per-request price — there is nothing to pay and no address to pay it to.

**A service answering `401`/`403` rather than `402` wants authentication, not payment.** Say so;
don't try to route it.

## Environment

- `NVM_API_URL` — `https://api.sandbox.nevermined.app` or `https://api.live.nevermined.app`
- `NVM_API_KEY` — `sandbox:…` / `live:…`. **Never send it to the merchant**; it authenticates you to
Nevermined only. The merchant's own auth goes in `headers`.
- `NVM_DELEGATION_ID` — the spending Delegation

## 1. Create a Delegation (the budget)

```bash
curl -sX POST "$NVM_API_URL/api/v1/delegation/create" \
-H "Authorization: Bearer $NVM_API_KEY" -H "Content-Type: application/json" \
-d '{"provider":"erc4337","currency":"usdc","spendingLimitCents":500,"durationSecs":604800}'
```

All four fields are required — no defaults. `erc4337` is the crypto-funded Delegation both
stablecoin rails need.

## 2. Fund the buyer wallet

Both rails **pull** from your own custodial wallet: a Delegation authorizes a spend, it does not
supply funds. Read the address from the live Delegation every time —
`GET /api/v1/delegation/{id}` → `providerPaymentMethodId`.

**Never reuse a cached address.** A stale one is the most common cause of `402 BCK.ROUTER.0009`, and
that error deliberately doesn't echo the address it checked.

## 3. Discover (public, no API key)

```bash
curl -s "$NVM_API_URL/api/v1/catalog/services?protocol=x402&search=web+search"
```

- **Only `protocol` of `x402` or `mpp` is routable.** Filter for them.
- **`targetUrl` is the default endpoint's COMPLETE URL, not a base** — it may already contain the
path (`https://host/search` while `endpoints[0].path` is also `/search`), so concatenating yields
`/search/search`. Resolve instead:

```ts
const url = endpoint ? new URL(endpoint.path, service.targetUrl).toString() : service.targetUrl
```

- `offset` is the page **size**, not a skip count, and default ordering is shuffled within each
tier — never assume `services[0]` is stable.

## 4. Pay

```ts
const res = await fetch(`${process.env.NVM_API_URL}/api/v1/router/route`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NVM_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
delegationId: process.env.NVM_DELEGATION_ID,
url: 'https://service.example/api/resource',
method: 'POST',
body: { query: '…' },
requestId: 'search-nevermined-router-v1', // stable per PURCHASE, not per attempt
}),
})
const { status, body, paid, payment } = await res.json()
```

The Router probes, auto-detects the protocol from the 402, pays and relays. `status`/`body` are the
merchant's own; `paid: false` with no `payment` means it was free — handle that. For streaming use
`ALL /api/v1/router/proxy` with `X-Router-Target-Url`, `X-Router-Delegation-Id` and
`X-Router-Request-Id` headers.

### `requestId` is an idempotency key, not a request counter

**A fresh `uuid4()` per HTTP attempt is how an agent double-spends.** Use one stable id per logical
purchase, reused across retries of it: the same id returns the original payment, a fresh id buys
again. Derive it from the work being done.

### Money arithmetic

Budget is debited in **whole cents, rounded up** — 1000 calls at $0.001 costs **$10.00, not $1.00**.
`settlement.approxCents` is what was actually reserved; trust it over any catalog `priceLabel`.

## Guardrails — a refusal is the system working

- `BCK.ROUTER.0003` (402) — Delegation over cap, expired, exhausted or revoked. **Stop.**
- `BCK.ROUTER.0009` (402) — wallet short on the target network; nothing was signed. **Stop.**
- `BCK.ROUTER.0002` (409) — `requestId` already used; the original `paymentId` is in the response.
- `BCK.ROUTER.0001` (400) — bad input / no fundable option / non-allowlisted asset; `details` names it.
- `BCK.ROUTER.0008` (403) — legacy API key; create a new one.
- Only `BCK.ROUTER.0006` (500) and `0007` (429, too many concurrent) are **retryable**. Everything
else is a decision — retrying unchanged gives the same answer.

**Never widen a Delegation, and never create a second one, to get past a refusal.** The cap is the
user's decision, not a runtime obstacle; minting a fresh Delegation to escape an exhausted one
defeats the whole mechanism. Report and stop.

## Accounting

`GET /api/v1/router/payments` (filters `delegationId`, `from`, `to`, `format=csv`) and
`/payments/summary`. `amount` is in the asset's smallest unit, not cents. A record at `Issued` is
**not** an error — the money moved; do not retry it.
4 changes: 0 additions & 4 deletions .github/workflows/publish-skill-clawhub.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,6 @@ jobs:
- path: skills/nevermined-router
slug: nevermined-router
name: Nevermined Router
# First publish of a new slug only — pins it to the frontmatter
# version instead of the CLI's 1.0.0 default. Delete this line once
# it has published once; auto-increment takes over from there.
version: "0.1.0"
env:
SKILL_PATH: ${{ matrix.path }}
OWNER: nevermined-io
Expand Down
9 changes: 7 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Cursor
/.cursor
# Cursor — local editor state, EXCEPT `.cursor/rules/`, which is published
# content: the install page tells users to curl it from `main`, so a rule file
# that silently fails to commit becomes a 404 (and, without `--fail`, writes
# "404: Not Found" into their rules directory). `nevermined-payments.mdc` only
# survived the blanket `/.cursor` because it was tracked before the rule existed.
/.cursor/*
!/.cursor/rules/

# Node
node_modules
Expand Down
Loading