From add0c39ffa412d474b539a36c8530f67fb629135 Mon Sep 17 00:00:00 2001 From: Rodolphe Marques Date: Mon, 3 Aug 2026 14:51:46 +0200 Subject: [PATCH 1/2] feat(skills): add the nevermined-router agent skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A machine-readable twin of the Router documentation, so an AI agent can discover an external service and buy from it autonomously with nothing but an API key. Companion to `nevermined-payments`, which covers the opposite direction (receiving payments, buying Nevermined plans). SKILL.md carries the six-step buy loop — API key → Delegation → funded wallet → discover → pay → account — plus the guardrails an autonomous buyer must respect. Five reference files cover discovery, paying, bootstrap, errors and the ledger. Everything is verified against the API source and probed against the live sandbox rather than paraphrased, including three things that cost real money if an agent gets them wrong: - `targetUrl` is the default endpoint's COMPLETE URL, not a base. For `superhighway` it is `…/search` while `endpoints[0].path` is also `/search`, so concatenating yields `/search/search`. Resolve with `new URL(endpoint.path, targetUrl)` instead. - `requestId` is an idempotency key, not a request counter. A fresh uuid4 per HTTP attempt — the default reflex — is how an agent double-spends. - Budget is debited in whole cents rounded up, so 1000 calls at $0.001 costs $10.00, not $1.00. The skill is explicit that a refusal is the system working: 0003 and 0009 are stop conditions, and widening or re-minting a Delegation to escape one defeats the whole mechanism. Also: - publish-skill-clawhub.yml becomes a matrix over both skills, with per-skill concurrency and fail-fast disabled so one failing publish can't block the other. Adding a skill is now one matrix entry. - build-using-nvm-skill.mdx explains which of the two skills you want. Refs nevermined-io/nvm-monorepo#2595, epic nevermined-io/nvm-monorepo#2268 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/publish-skill-clawhub.yml | 52 ++-- development-guide/build-using-nvm-skill.mdx | 11 + skills/nevermined-router/SKILL.md | 248 ++++++++++++++++++ .../nevermined-router/references/bootstrap.md | 143 ++++++++++ .../nevermined-router/references/discovery.md | 173 ++++++++++++ skills/nevermined-router/references/errors.md | 153 +++++++++++ skills/nevermined-router/references/ledger.md | 126 +++++++++ skills/nevermined-router/references/paying.md | 240 +++++++++++++++++ 8 files changed, 1130 insertions(+), 16 deletions(-) create mode 100644 skills/nevermined-router/SKILL.md create mode 100644 skills/nevermined-router/references/bootstrap.md create mode 100644 skills/nevermined-router/references/discovery.md create mode 100644 skills/nevermined-router/references/errors.md create mode 100644 skills/nevermined-router/references/ledger.md create mode 100644 skills/nevermined-router/references/paying.md diff --git a/.github/workflows/publish-skill-clawhub.yml b/.github/workflows/publish-skill-clawhub.yml index 9c6cb797..79311c37 100644 --- a/.github/workflows/publish-skill-clawhub.yml +++ b/.github/workflows/publish-skill-clawhub.yml @@ -1,40 +1,60 @@ name: Publish skill to ClawHub -# Publishes the `nevermined-payments` skill to ClawHub under the `nevermined-io` -# publisher — https://clawhub.ai/skills/nevermined — the same org used for the -# OpenClaw plugin. Runs on demand and whenever the skill changes on `main`. +# Publishes this repo's skills to ClawHub under the `nevermined-io` publisher — +# the same org used for the OpenClaw plugin. Runs on demand and whenever a skill +# changes on `main`. +# +# skills/nevermined-payments → https://clawhub.ai/skills/nevermined +# skills/nevermined-router → https://clawhub.ai/skills/nevermined-router # # Required repository secret: # CLAWHUB_TOKEN — a ClawHub API token belonging to a user who owns the # `nevermined-io` publisher (create at https://clawhub.ai, `clawhub token`). # -# Versioning: the CLI auto-increments from the latest published version (the -# push trigger is path-filtered, so it only runs when the skill actually -# changes). To publish an explicit version, add `--version ` below. +# Versioning: the CLI auto-increments from the latest published version. The +# push trigger is path-filtered on `skills/**`, so a change to ONE skill still +# runs the matrix leg for the other — that leg is a no-op, because republishing +# an unchanged version is handled as "already published" below. To publish an +# explicit version, add `--version `. +# +# Adding a skill: add one entry to the matrix. Nothing else needs to change. on: workflow_dispatch: push: branches: [main] paths: - - 'skills/nevermined-payments/**' + - 'skills/**' - '.github/workflows/publish-skill-clawhub.yml' permissions: contents: read -concurrency: - group: clawhub-publish-nevermined - cancel-in-progress: false - jobs: publish: - name: Publish nevermined skill to ClawHub + name: Publish ${{ matrix.slug }} to ClawHub runs-on: ubuntu-latest + # Per-skill so the two legs never queue behind each other, and a rerun of one + # skill can't cancel an in-flight publish of the other. + concurrency: + group: clawhub-publish-${{ matrix.slug }} + cancel-in-progress: false + strategy: + # One skill failing to publish must not stop the other from publishing. + fail-fast: false + matrix: + include: + - path: skills/nevermined-payments + slug: nevermined + name: Nevermined Payments + - path: skills/nevermined-router + slug: nevermined-router + name: Nevermined Router env: - SKILL_PATH: skills/nevermined-payments + SKILL_PATH: ${{ matrix.path }} OWNER: nevermined-io - SLUG: nevermined + SLUG: ${{ matrix.slug }} + SKILL_NAME: ${{ matrix.name }} steps: - uses: actions/checkout@v4 @@ -63,7 +83,7 @@ jobs: SOURCE_REF: ${{ github.ref }} run: | clawhub --no-input skill publish "$SKILL_PATH" \ - --owner "$OWNER" --slug "$SLUG" --name "Nevermined Payments" \ + --owner "$OWNER" --slug "$SLUG" --name "$SKILL_NAME" \ --source-repo "$SOURCE_REPO" --source-commit "$SOURCE_COMMIT" \ --source-ref "$SOURCE_REF" --source-path "$SKILL_PATH" \ --dry-run --json @@ -76,7 +96,7 @@ jobs: run: | set +e OUT=$(clawhub --no-input skill publish "$SKILL_PATH" \ - --owner "$OWNER" --slug "$SLUG" --name "Nevermined Payments" \ + --owner "$OWNER" --slug "$SLUG" --name "$SKILL_NAME" \ --source-repo "$SOURCE_REPO" --source-commit "$SOURCE_COMMIT" \ --source-ref "$SOURCE_REF" --source-path "$SKILL_PATH" \ --json 2>&1) diff --git a/development-guide/build-using-nvm-skill.mdx b/development-guide/build-using-nvm-skill.mdx index acd4527a..89ea7cee 100644 --- a/development-guide/build-using-nvm-skill.mdx +++ b/development-guide/build-using-nvm-skill.mdx @@ -8,6 +8,17 @@ Use the **Nevermined AI Skill** to give your coding assistant deep knowledge of Instead of copy-pasting docs, you import the skill once and your AI assistant knows how to wire up payments for you. +## Two skills, opposite directions + +There are two, and which one you want depends on which side of the payment you're on. + +| Skill | Your agent is… | Covers | +| --- | --- | --- | +| **`nevermined-payments`** | **receiving** money, or buying a Nevermined plan | SDK integration, middleware, plans, credits — the rest of this page | +| **`nevermined-router`** | **spending** money at external services | Discovering services in the catalog, creating a spending Delegation, paying any x402 or MPP endpoint through the [Nevermined Router](/products/router/overview) | + +Every install method below works for either — swap `nevermined-payments` for `nevermined-router` in the paths. Installing both is fine; they don't overlap. + ## What's Included The skill provides your coding assistant with: diff --git a/skills/nevermined-router/SKILL.md b/skills/nevermined-router/SKILL.md new file mode 100644 index 00000000..bf5b25a1 --- /dev/null +++ b/skills/nevermined-router/SKILL.md @@ -0,0 +1,248 @@ +--- +name: nevermined-router +version: "0.1.0" +lastUpdated: "2026-08-03" +description: > + Use when an AI agent needs to PAY an external service it does not have an account + with — any x402 agent or MPP merchant — using the Nevermined Router. Covers + discovering services in the Agent Services Catalog, creating a spending Delegation + from an API key, funding the buyer wallet, making paid calls through + /api/v1/router/route (or the streaming /proxy), reading the payment ledger, and the + guardrails an autonomous buyer must respect. Complements the nevermined-payments + skill, which is about RECEIVING payments and buying Nevermined plans. +metadata: + openclaw: + primaryEnv: NVM_API_KEY + requires: + env: + - NVM_API_KEY + envVars: + - name: NVM_API_KEY + required: true + description: > + Nevermined API key. Format `sandbox:...` for the sandbox environment, + `live:...` for production. Issued from https://nevermined.app under API + Keys. Must be a current-generation key — older keys are refused by the + Router with 403 BCK.ROUTER.0008. +--- + +# Nevermined Router — buy from any x402 or MPP service + +> **Skill version**: 0.1.0 | **Last updated**: 2026-08-03 | **Canonical source (always latest):** https://github.com/nevermined-io/docs/tree/main/skills/nevermined-router +> +> **⚠️ Use the latest version.** If you have a cached copy, check its **Last updated** date against the canonical source and refresh if older. +> +> Human-readable twin of the Router documentation at https://nevermined.ai/docs/products/router/overview. Same facts, same error codes — if the two ever disagree, the docs site is authoritative and this skill has a bug. + +## What this is for + +You are an agent that needs something from a service you have **no account with, no API key for, and no billing relationship with**. The Router lets you pay it per request, from a budget a human capped in advance, and puts every spend on one ledger. + +It works because a growing set of services quote their price **on the wire**: you call them, they answer `402 Payment Required` with what they want, you pay, you get the resource. The Router does the paying. + +| | | +| --- | --- | +| **Use this skill when** | you need to buy a single call from an external x402 / MPP service | +| **Use `nevermined-payments` instead when** | you are *charging* callers, or buying a Nevermined **plan** with credits | + + +**This skill cannot help you with conventional SaaS APIs.** Exa, Firecrawl, Tavily and similar are billed out of band — a monthly plan, a long-lived key. They never quote a price for one call, so there is nothing on the wire for the Router to pay and no address to pay it to. The Router isn't missing a feature; the transaction it performs does not exist for those services. If a service answers `401` or `403` rather than `402`, it wants **authentication**, not payment — stop, and tell the user it needs an account. + +## The buy loop + +Six steps. Steps 1–3 happen once; 4–6 repeat per purchase. + +``` +① API key ──▶ ② Delegation (budget) ──▶ ③ Fund the buyer wallet + │ + ┌─────────────────────────┘ + ▼ + ④ Discover a service ──▶ ⑤ POST /router/route ──▶ ⑥ Read the spend + (catalog) (pays + relays) (ledger) +``` + +Set your environment once: + +```bash +export NVM_API_URL="https://api.sandbox.nevermined.app" # live: https://api.live.nevermined.app +export NVM_API_KEY="" +``` + +Everything is plain HTTP with `Authorization: Bearer $NVM_API_KEY`. There is **no SDK for the Router yet** — that is deliberate here, because it means any agent in any language can drive it with an HTTP client. The one exception is the catalog, which is public and needs no key at all. + +**Never send `NVM_API_KEY` to the service you are paying.** It authenticates you to Nevermined and nothing else. If a merchant needs its own auth, pass it in `headers` (mode B) — see `references/paying.md`. + +--- + +## ① Get an API key — *needs a human once* + +Issued from the Nevermined app. If you were given one, use it. + +A key that predates the Router is refused with **`403 BCK.ROUTER.0008`**. The fix is to create a new key; newly issued keys work. Old keys keep working for credit-based flows, so nothing else needs rotating. + +## ② Create a Delegation — *fully programmatic* + +A **Delegation** is the budget: a hard cap in cents plus an expiry, enforced server-side on every single payment. Create it once, reuse the id. + +```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}' +# → { "delegationId": "5e7481c3-e972-45bd-bdc5-a0b99c4de4a1" } +``` + +That is a $5.00 cap for 7 days. `provider: "erc4337"` is the crypto-funded Delegation both stablecoin rails require — a card-funded Delegation is refused on them. + + +**You may create a Delegation. You must never widen one to get past a refusal.** The cap is the human's decision; a refusal is that decision taking effect. See [Guardrails](#guardrails). + +Full field list, recipient scoping, and reading a Delegation's live state: `references/bootstrap.md`. + +## ③ Fund the buyer wallet — *may need a human* + +Both rails **pull**: the merchant takes funds from your own custodial wallet. The Delegation authorizes the spend; it does not provide the money. Read the wallet address off the Delegation: + +```bash +curl -s "$NVM_API_URL/api/v1/delegation/$NVM_DELEGATION_ID" \ + -H "Authorization: Bearer $NVM_API_KEY" +# → { "providerPaymentMethodId": "0x8F60b3838e6C121FcDBdBc50e7B150F8560a670E", ... } +``` + +`providerPaymentMethodId` is the address to fund, with the payment asset, **on the network you intend to pay on**. + +**Always read this address back from the live Delegation — never from a value you cached.** Funding a stale address is the most common cause of `402 BCK.ROUTER.0009`, and the error deliberately does not echo the address it checked, so it cannot tell you that is what happened. + +If the wallet is empty and you cannot fund it yourself, that is a **stop condition**: report it to the human. Do not retry. + +## ④ Discover a service + +The **Agent Services Catalog** is public and unauthenticated — no API key: + +```bash +curl -s "$NVM_API_URL/api/v1/catalog/services?protocol=x402&search=web+search&offset=5" +``` + +```json +{ + "total": 9, "page": 1, "offset": 5, + "services": [{ + "slug": "superhighway", + "title": "Superhighway — Web Search for Agents", + "protocol": "x402", + "targetUrl": "https://superhighway.walls.sh/search", + "priceLabel": "$0.001", + "network": "Base", + "endpoints": [ + { "path": "/search", "method": "POST", "priceLabel": "$0.001", "description": "Web search" }, + { "path": "/news", "method": "POST", "priceLabel": "$0.001", "description": "Real-time news search" } + ], + "tags": ["search", "web", "news", "markdown"] + }] +} +``` + +Two rules that will otherwise cost you a wasted payment: + +1. **Only `protocol` of `x402` or `mpp` is payable through the Router.** Filter for them. Anything else in the catalog is listed for discovery, not for routing — see [above](#not-for). + +2. **`targetUrl` is the *default endpoint's complete URL*, not a base.** Above it is `…/search`, and `endpoints[0].path` is *also* `/search`. Concatenating gives you `/search/search`. Resolve against the origin instead: + + ```js + const url = endpoint ? new URL(endpoint.path, service.targetUrl).toString() + : service.targetUrl + // '/news' + 'https://superhighway.walls.sh/search' → 'https://superhighway.walls.sh/news' ✓ + ``` + +Filters, the categories endpoint, the per-slug lookup, and the crawlable ARD feed: `references/discovery.md`. + +## ⑤ Make the paid call + +Hand the Router the request you want made. It probes the service, **auto-detects** the protocol from the 402, pays, and relays the answer — one call, and you never see the 402. + +```bash +curl -sX POST "$NVM_API_URL/api/v1/router/route" \ + -H "Authorization: Bearer $NVM_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "delegationId": "'"$NVM_DELEGATION_ID"'", + "url": "https://superhighway.walls.sh/search", + "method": "POST", + "body": { "query": "nevermined router" }, + "requestId": "search-nevermined-router-v1" + }' +``` + +```json +{ + "status": 200, + "body": { "…": "the paid resource" }, + "paid": true, + "payment": { + "paymentId": "b1f9c2e4-…", + "settlement": { "amount": "1000", "asset": "USDC", "network": "base", "approxCents": "1" }, + "txHash": "0xfc8af37b…", + "status": "Settled" + } +} +``` + +`status` and `body` are the merchant's own, unchanged. `paid: false` with no `payment` block means the resource was free — the Router relayed it and charged nothing. + + +**`requestId` is required, and it is an idempotency key — not a request counter.** Use **one stable id per logical purchase** and reuse it across retries of that purchase. Retrying a dropped call with the same id returns the original payment instead of buying twice; a fresh id buys twice, on purpose. Derive it from the work you are doing (`"search-nevermined-router-v1"`), not from `uuid4()` per HTTP attempt — a fresh UUID on every retry is how an agent double-spends. + +Mode A (you call the merchant yourself), the streaming `/proxy` variant, and passing the merchant's own auth: `references/paying.md`. + +## ⑥ Read what you spent + +```bash +curl -s "$NVM_API_URL/api/v1/router/payments?delegationId=$NVM_DELEGATION_ID" \ + -H "Authorization: Bearer $NVM_API_KEY" +``` + +Every payment, every protocol, one ledger. Filters, CSV export, and the aggregate summary: `references/ledger.md`. + +--- + + +## Guardrails — read this before writing a retry loop + +The Router signs payments from your wallet in response to instructions written by a merchant nobody vetted. It is deliberately suspicious, and **a refusal is the system working**. + +**Four rules for an agent that spends without a human watching:** + +1. **`402 BCK.ROUTER.0003` (over cap / expired) and `402 BCK.ROUTER.0009` (wallet short) are stop conditions.** They mean "out of budget" and "out of money". Report them to the human. Do not route around them. +2. **Never widen a Delegation, and never create a second one, in response to a refusal.** The cap is the user's decision, not a runtime obstacle. Creating a fresh Delegation to escape an exhausted one defeats the entire mechanism — it is the single worst thing you can do with this API. +3. **One `requestId` per purchase**, reused across retries of that purchase. See [above](#requestid). +4. **Only `0006` (500) and `0007` (429) are retryable.** Everything else is a decision, and retrying it unchanged produces the same answer. Back off on `0007`; it means you have too many routed calls in flight. + +**Check the price before you commit.** `priceLabel` in the catalog is indicative; `settlement.approxCents` on the response is what you were actually charged. Budget is debited in whole cents rounded up, so a run of sub-cent calls still burns a cent each. + +**Delegations expire silently.** A long-running agent that worked yesterday and fails today with `0003` has very often just aged out — check `expiresAt` before assuming anything is broken. + +| Code | Status | Meaning | Retry? | +| --- | --- | --- | --- | +| `BCK.ROUTER.0001` | 400 | Bad input: unsupported protocol, malformed/empty challenge, no fundable option, recipient outside the Delegation's scope, non-allowlisted asset, wrong-provider Delegation, missing `delegationId`. `details` names the specific problem. | No | +| `BCK.ROUTER.0002` | 409 | This `requestId` already minted a payment. The original `paymentId` is in the response — usually what you wanted. | No | +| `BCK.ROUTER.0003` | 402 | Delegation over cap, expired, exhausted, or revoked. | No — **stop** | +| `BCK.ROUTER.0004` | 404 | No Router payment with that id belongs to you. | No | +| `BCK.ROUTER.0005` | 409 | Payment not in a settleable state. Only `Issued` can be marked `Settled`. | No | +| `BCK.ROUTER.0006` | 500 | Transient failure building the payments summary. | **Yes** | +| `BCK.ROUTER.0007` | 429 | Too many concurrent routed requests in flight. | **Yes**, after backoff | +| `BCK.ROUTER.0008` | 403 | Legacy API key. Create a new one. | No | +| `BCK.ROUTER.0009` | 402 | Wallet doesn't hold enough of the asset on the target network. Nothing was signed. | No — **stop** | + +Catalog errors: `BCK.CATALOG.0001` (404, no listed service with that slug — slugs are case-sensitive), `BCK.CATALOG.0002` (500, transient, retryable), `BCK.CATALOG.0003` (400, `protocol` filter must be one of `x402`, `mpp`, `rest`, `a2a`, `other`). + +What the Router refuses outright — private/loopback/metadata targets, redirects, MPP `splits`, forged `X-Router-*` headers — and the relay limits: `references/errors.md`. + +## Reference files + +| You need… | Read | +| --- | --- | +| Catalog filters, categories, per-slug lookup, the ARD feed | `references/discovery.md` | +| Mode A vs mode B, `/proxy` streaming, merchant auth, full payloads | `references/paying.md` | +| Delegation fields, recipient scoping, wallet funding, networks | `references/bootstrap.md` | +| Every guardrail, every code, what is retryable and why | `references/errors.md` | +| Payment records, filters, CSV export, summary, reconciliation | `references/ledger.md` | diff --git a/skills/nevermined-router/references/bootstrap.md b/skills/nevermined-router/references/bootstrap.md new file mode 100644 index 00000000..1ea52b72 --- /dev/null +++ b/skills/nevermined-router/references/bootstrap.md @@ -0,0 +1,143 @@ +# Bootstrap — API key, Delegation, funded wallet + +Three preconditions before any payment. Do them once and reuse. + +## 1. The API key + +Every Router call carries `Authorization: Bearer $NVM_API_KEY`. Issued from the Nevermined app — +this is the one step that needs a human. + +Keys are environment-scoped: `sandbox:…` for sandbox, `live:…` for production. A key from the wrong +environment fails auth, not the Router's own checks. + +**A key issued before the Router shipped is refused with `403 BCK.ROUTER.0008`.** It is bound to a +previous account model that cannot sign these payments. The fix is to create a new key — newly +issued keys work. Existing keys keep working for credit-based flows, so nothing else needs rotating. +This is not retryable and not a transient error; do not loop on it. + +**Never forward this key to a merchant.** It authenticates you to Nevermined. If the merchant needs +its own credential, pass that separately (`headers` in mode B, `X-Router-Upstream-Authorization` on +`/proxy`) — see `paying.md`. + +## 2. The Delegation + +Your budget. A hard cap in cents plus an expiry, enforced server-side on **every** payment. + +```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}' +# → { "delegationId": "5e7481c3-e972-45bd-bdc5-a0b99c4de4a1" } +``` + +| Field | Required | Notes | +| --- | --- | --- | +| `provider` | **yes** | `erc4337` for both stablecoin rails. No default — omitting it is a 4xx | +| `currency` | **yes** | `usdc` · `eurc` · `usd` · `eur`. No default | +| `spendingLimitCents` | **yes** | Integer ≥ 1. The hard cap, in cents | +| `durationSecs` | **yes** | Integer ≥ 1. `604800` = 7 days | +| `allowedRecipients` | no | Up to 100 `0x…` EVM addresses. **Omit = no restriction** | +| `maxTransactions` | no | Cap on number of charges. Omit = unlimited | + +`provider: "erc4337"` is the crypto-funded Delegation both stablecoin rails require. Card-funded +Delegations use a different provider and are refused on those rails (and vice versa) with +`400 BCK.ROUTER.0001`. + +### Recipient scope is optional, and unset means unrestricted + +If `allowedRecipients` is present, the merchant's pay-to address must be on it — checked *before* +the expensive signing step, so a disallowed recipient costs nothing. If it is **absent, there is no +recipient restriction at all**: the Delegation can pay any merchant the Router can reach, and its +cap and expiry are the only limits. + +Do not assume a Delegation is address-bound unless you deliberately made it so. Card-funded +Delegations are vendor-agnostic by design and never carry one. + +Scoping is worth it when you already know who you are paying — it turns a compromised or confused +agent's blast radius from "anyone" into "these addresses". It is impractical when you are shopping +the catalog, since you do not know the pay-to address until the 402 arrives. + +## 3. Read its live state — and the wallet address + +```bash +curl -s "$NVM_API_URL/api/v1/delegation/$NVM_DELEGATION_ID" \ + -H "Authorization: Bearer $NVM_API_KEY" +``` + +```json +{ + "delegationId": "5e7481c3-…", + "provider": "erc4337", + "providerPaymentMethodId": "0x8F60b3838e6C121FcDBdBc50e7B150F8560a670E", + "status": "Active", + "spendingLimitCents": "500", + "amountSpentCents": "0", + "remainingBudgetCents": "500", + "expiresAt": "2026-08-07T00:00:00Z" +} +``` + +`status` must be `Active`. `Revoked`, `Expired` and `Exhausted` all refuse with +`402 BCK.ROUTER.0003`. + +**Check `expiresAt` before diagnosing anything.** Delegations expire silently — an agent that worked +yesterday and fails today with `0003` has very often just aged out, and that looks identical to a +broken rail until you look. + +You can list your Delegations with `GET /api/v1/delegation`, and a single Delegation's charges with +`GET /api/v1/delegation/{id}/transactions`. + +## 4. Fund the buyer wallet + +**`providerPaymentMethodId` is the custodial wallet address to fund.** Both rails **pull**: the +merchant takes funds from that wallet. The Delegation authorizes the spend; it does not supply the +money. The two are independent — you can be inside your cap and still have an empty wallet. + +Send the payment asset to that address **on the network you intend to pay on**. + +**Always re-read the address from the live Delegation. Never reuse a cached one.** Funding a stale +address is the most common cause of `402 BCK.ROUTER.0009`, and the error deliberately does not echo +the address it checked — so it cannot tell you that is what happened. This one costs more debugging +time than anything else in this API. + +### Which network and asset + +Decided by **what the merchant advertises**, not by which Nevermined environment you point at. + +| Rail | Networks | Assets | +| --- | --- | --- | +| **x402** | `base` (8453, **mainnet — real funds**), `base-sepolia` (84532, testnet) | `USDC`, `EURC` — 6 decimals | +| **MPP** | Tempo mainnet (4217), Tempo Moderato testnet (42431) | Whatever the operator allowlisted for that chain | + +`base` moves real money, and both networks are enabled by default. Read `settlement.network` on the +response if you want certainty about what just happened. + +Amounts are in the asset's smallest unit. For 6-decimal stablecoins: + +``` +1_000_000 atomic units = 1 USDC = 100 cents + 10_000 atomic units = 1 cent +``` + +Your cap is in **cents**, so every payment is converted and **rounded up** to the next whole cent +before being checked. A 5,000-unit (half-cent) payment reserves 1 cent — a long loop of sub-cent +calls burns a full cent of budget each. `settlement.approxCents` tells you what was actually +reserved. + +MPP additionally requires the payment token to be on the operator's per-chain allowlist +(`ROUTER_TEMPO_ASSETS_`), which is **fail-closed**: unset rejects everything on that chain +with `400 BCK.ROUTER.0001`. If MPP fails with that code where x402 works fine, an unconfigured +allowlist is the first thing to check — the rails are configured independently. + +## Preflight checklist + +Before the first payment of a run: + +1. `GET /api/v1/delegation/{id}` → `status: "Active"`, `remainingBudgetCents` covers what you plan + to spend, `expiresAt` is comfortably ahead. +2. `providerPaymentMethodId` read **from that response**, funded on the target network. +3. A `requestId` scheme that is stable per purchase (see `paying.md`). + +If any of these fails and you cannot fix it yourself, stop and report. Do not create a second +Delegation to get around an exhausted one. diff --git a/skills/nevermined-router/references/discovery.md b/skills/nevermined-router/references/discovery.md new file mode 100644 index 00000000..35441ca9 --- /dev/null +++ b/skills/nevermined-router/references/discovery.md @@ -0,0 +1,173 @@ +# Discovery — finding something to buy + +The **Agent Services Catalog** is a Nevermined-curated list of external agent services. It is +**public, unauthenticated, read-only, and cached for 5 minutes**. Send no `Authorization` header; +none is required and none is checked. + +Base: `$NVM_API_URL` — `https://api.sandbox.nevermined.app` or `https://api.live.nevermined.app`. + +## List services + +``` +GET /api/v1/catalog/services +``` + +| Param | Type | Notes | +| --- | --- | --- | +| `search` | string | Free-text over **title and description only** — not tags, not provider | +| `protocol` | enum | `x402` · `mpp` · `rest` · `a2a` · `other`. Anything else → `400 BCK.CATALOG.0003` | +| `category` | string | Exact match. Discover valid values from `/categories` | +| `tag` | string | Exact match against one entry of `tags[]` | +| `page` | int ≥ 1 | Default 1 | +| `offset` | int ≥ 1 | Page **size**, not a skip count. Default 10, capped server-side | +| `sortBy` | enum | Omit for the curated default. Unknown value → 400 | +| `sortOrder` | `asc`/`desc` | | + +A repeated param (`?search=a&search=b`) parses as an array and fails validation with a `400` — send +each filter once. + +**`offset` is a page size.** It is not an offset in the SQL sense. To walk the catalog, hold +`offset` fixed and increment `page`. + +**Default ordering is intentionally not stable.** With no `sortBy`, results come back by curation +tier ascending, then *time-seeded shuffled within each tier* — the rotation changes periodically so +no single service permanently owns the top slot. Never assume `services[0]` is the same service +across two calls; if you need determinism, pass an explicit `sortBy`. + +### Response + +```json +{ "total": 9, "page": 1, "offset": 10, "services": [ /* CatalogService */ ] } +``` + +`total` is the count **matching your filters**, not the catalog size. + +### Fields you will actually use + +| Field | Use | +| --- | --- | +| `slug` | Stable id. Case-sensitive — use it for the per-slug lookup | +| `protocol` | **`x402` or `mpp` = payable through the Router.** See below | +| `targetUrl` | The default endpoint's **complete URL** — see the gotcha below | +| `endpoints[]` | `{ path, method, description, priceLabel, docsUrl }` — the other callable paths | +| `priceLabel` | Human string like `"$0.001"`. **Indicative only** — the wire price governs | +| `network` | Display name (`"Base"`, `"Tempo"`). Not a chain id | +| `tags[]`, `category`, `features[]` | Selection signals | +| `discovery` | Machine-readable pointers: `x402` manifest, `mcp`, `a2a` agent card, `openapi`, `llmsTxt`, `mppRegistry`. `{}` when none | + +`isListed` is always `true` on this API — unlisted rows are never exposed, so you cannot use it to +tell payable from unpayable. + +## Two rules that cost real money if you get them wrong + +### 1. Only `x402` and `mpp` are routable + +**The Router cannot pay a `rest`, `a2a` or `other` service.** Its transaction is *read a price quoted +on the wire for this request, sign a payment settling exactly that*. A conventional SaaS API never +quotes a price for one call — it is billed by a monthly plan and a long-lived key, so at call time +there is nothing to pay and no address to pay it to. + +Measured across every `rest`/`other` service in the curated set: **none returns a 402, none emits +any payment header, none serves a real x402 manifest.** The ones that respond meaningfully return +`401` or `403` — "authenticate", not "pay". + +Curation already protects you from this: those services are deliberately loaded **unlisted**, and +the API only ever exposes listed ones — so in practice today the catalog returns `x402` and `mpp` +only. **Filter with `?protocol=x402` or `?protocol=mpp` anyway.** Listing is a curation decision that +can change, the `protocol` filter accepts values the Router cannot pay, and an explicit filter makes +your agent's assumption visible instead of load-bearing-and-implicit. + +If you ever do hold a non-routable entry, do not call `/route` on it — tell the user that service +needs its own account. + +### 2. `targetUrl` is a full URL, not a base + +This is the single easiest way to waste a payment. + +``` +slug=superhighway + targetUrl = https://superhighway.walls.sh/search ← already includes /search + endpoints = ["/search", "/news", "/images"] + +slug=2s + targetUrl = https://2s.io ← bare origin here + endpoints = ["/api/directory"] +``` + +`targetUrl` is the **default endpoint's complete URL**. Concatenating `targetUrl + endpoint.path` +gives `https://superhighway.walls.sh/search/news`, which 404s — and if the merchant charges before +routing, you paid for it. + +Resolve against the origin instead. Because every `endpoint.path` is absolute (`/…`), plain URL +resolution does exactly the right thing on both shapes above: + +```js +const url = endpoint + ? new URL(endpoint.path, service.targetUrl).toString() + : service.targetUrl // default endpoint — use targetUrl verbatim +``` + +```python +from urllib.parse import urljoin +url = urljoin(service["targetUrl"], endpoint["path"]) if endpoint else service["targetUrl"] +``` + +## Categories + +``` +GET /api/v1/catalog/categories +# → [ { "category": "Search", "count": 3 }, … ] +``` + +Distinct categories over listed services, with counts. Use it to populate a `category` filter rather +than guessing a string. + +## One service by slug + +``` +GET /api/v1/catalog/services/{slug} +``` + +Returns the same object as a list row. Slugs are **case-sensitive**; no match → `404 +BCK.CATALOG.0001`. Unlisted services are not exposed here either. + +## The crawlable feed + +``` +GET /.well-known/agent-services-catalog.json +``` + +A Google **Agentic Resource Discovery (ARD)** document over the same listed services — one entry +each, with the Router pay-through target and the discovery pointers under `x-nevermined-catalog`. +Public and crawlable by any registry. + +```json +{ "specVersion": "1.0", + "host": { "displayName": "Nevermined Agent Services", "identifier": "did:web:…" }, + "entries": [ … ] } +``` + +Note the key is **`entries`**, not `services` — a parser looking for `services` sees an empty feed +and silently concludes the catalog is empty. + +Prefer `/api/v1/catalog/services` when you are choosing something to buy: the feed is a flat dump +with no filtering or pagination. The feed is for registries crawling you, not for you choosing. + +## Choosing well + +1. Filter to `protocol=x402` or `protocol=mpp`. +2. Narrow with `search` (title + description) or `category` / `tag` for precision. +3. Read `endpoints[]` — pick the one whose `description` and `method` match your need, and note its + `priceLabel`. +4. Build the URL per the rule above. +5. Pay with `POST /api/v1/router/route` — see `paying.md`. + +If nothing matches, say so. Do not fall back to a `rest` entry and do not invent a `targetUrl`. + +## Errors + +| Code | Status | Meaning | +| --- | --- | --- | +| `BCK.CATALOG.0001` | 404 | No listed service with that slug. Case-sensitive | +| `BCK.CATALOG.0002` | 500 | Transient read failure — **retryable** | +| `BCK.CATALOG.0003` | 400 | `protocol` must be `x402`, `mpp`, `rest`, `a2a`, or `other` | diff --git a/skills/nevermined-router/references/errors.md b/skills/nevermined-router/references/errors.md new file mode 100644 index 00000000..d1f41c9b --- /dev/null +++ b/skills/nevermined-router/references/errors.md @@ -0,0 +1,153 @@ +# Errors and guardrails + +The Router signs payments from your wallet in response to instructions written by a merchant nobody +vetted. It is deliberately suspicious. + +**A refusal is the system working.** Before you widen a cap or drop an idempotency key to make an +error go away, read what it was protecting you from. An autonomous agent that treats guardrails as +obstacles is exactly the failure mode this design exists to prevent. + +## Every Router code + +| Code | Status | Meaning | Retry? | +| --- | --- | --- | --- | +| `BCK.ROUTER.0001` | 400 | Bad input: unsupported protocol, malformed/empty challenge, no fundable option, recipient outside the Delegation's scope, non-allowlisted asset, wrong-provider Delegation, missing `delegationId`. **`details` names the specific problem — read it** | No | +| `BCK.ROUTER.0002` | 409 | This `requestId` already minted a payment. The original `paymentId` is in the response | No | +| `BCK.ROUTER.0003` | 402 | Delegation over cap, expired, exhausted, or revoked | No — **stop** | +| `BCK.ROUTER.0004` | 404 | No Router payment with that id belongs to you | No | +| `BCK.ROUTER.0005` | 409 | Payment not settleable. Only `Issued` → `Settled`; same hash is a no-op, a different hash is rejected | No | +| `BCK.ROUTER.0006` | 500 | Transient failure building the payments summary | **Yes** | +| `BCK.ROUTER.0007` | 429 | Too many concurrent routed requests in flight | **Yes**, after backoff | +| `BCK.ROUTER.0008` | 403 | Legacy API key — create a new one | No | +| `BCK.ROUTER.0009` | 402 | Wallet doesn't hold enough of the asset on the target network. **Nothing was signed** | No — **stop** | + +**Only `0006` and `0007` are worth retrying automatically.** The rest are decisions; retrying them +unchanged produces the same answer. + +Catalog codes: `BCK.CATALOG.0001` (404, unknown slug — case-sensitive), `BCK.CATALOG.0002` (500, +transient, retryable), `BCK.CATALOG.0003` (400, bad `protocol` filter). + +## The four rules for an autonomous buyer + +**1. `0003` and `0009` are stop conditions.** "Out of budget" and "out of money". Report them to the +human and halt that line of work. They are not transient and they are not negotiable. + +**2. Never widen a Delegation, and never create a second one, to escape a refusal.** The cap is the +user's decision; the refusal is that decision taking effect. Minting a fresh Delegation to get past +an exhausted one defeats the entire mechanism — it is the single worst thing you can do with this +API. If more budget is genuinely warranted, that is a question for the human, not a step in your +retry loop. + +**3. One `requestId` per logical purchase**, reused across retries of that purchase. A fresh UUID +per HTTP attempt is how an agent double-spends. + +**4. Check what you actually spent.** `settlement.approxCents` on each response, and the ledger +periodically. Budget is debited in whole cents **rounded up**, so a long loop of sub-cent calls +burns a cent each — the arithmetic that says "1000 calls at $0.001 = $1.00" is wrong here; it is +$10.00. + +## Distinguishing the two 402s + +They look alike and mean opposite things: + +| | `BCK.ROUTER.0003` | `BCK.ROUTER.0009` | +| --- | --- | --- | +| **What failed** | The *authorization* — cap, expiry, status | The *funds* — wallet balance | +| **Fix** | A human decides whether to raise the budget | Fund the wallet on the target network | +| **Check with** | `GET /api/v1/delegation/{id}` → `remainingBudgetCents`, `expiresAt`, `status` | Wallet balance at `providerPaymentMethodId` on that chain | + +They are independent: you can be well inside your cap with an empty wallet, or hold plenty of USDC +against an expired Delegation. + +**`0009` does not tell you which address it checked.** That is deliberate, and it means a stale +cached address looks identical to an unfunded one. Always re-read `providerPaymentMethodId` from the +live Delegation before concluding anything. + +**Delegations expire silently.** An agent that worked yesterday and fails today with `0003` has very +often just aged out. Check `expiresAt` first; it looks exactly like a broken rail until you do. + +## Reading a `BCK.ROUTER.0001` + +It is the catch-all for "the Router will not pay this", and the **`details`** field names which +check tripped. Common causes, in rough order: + +- **No fundable option in the 402.** Every advertised option was on an unfunded network, in an + unsupported asset, or used a scheme other than `exact`. A mixed-chain 402 is fine as long as *one* + option survives — this only fires when none does. +- **Non-allowlisted MPP asset.** Fail-closed per chain. If MPP fails with `0001` where x402 works, + check this first — the rails are configured independently. +- **Recipient outside the Delegation's scope**, when it carries an `allowedRecipients` list. +- **Wrong Delegation provider** — a card Delegation on a stablecoin rail or vice versa. +- **MPP `splits`** — see below. +- **Missing `delegationId`**, or a missing `X-Router-*` header on `/proxy`. + +Retrying does not help. Either fix the input or pick a different service. + +## What the Router refuses outright + +### Splits + +An MPP `charge` can name a primary recipient *and* extra payout recipients. Only the primary is ever +validated against your Delegation, so honouring splits would move real funds to addresses nobody +checked. **Any split-bearing challenge is rejected outright** — unconditionally, whether or not your +Delegation restricts recipients. The Router refuses the whole thing rather than paying the part it +can vouch for. + +### Internal targets + +The Router makes server-side requests to URLs you supply, so it will not be pointed at +infrastructure you should not reach. Loopback, private (RFC 1918), link-local and cloud-metadata +addresses are blocked — **both literal IPs and public hostnames that resolve to internal +addresses**, so DNS rebinding does not get around it. The connection is then pinned to the address +that was validated, so it cannot be swapped underneath. + +Operators can lift this for local development with `ROUTER_ALLOW_PRIVATE_TARGETS=true`. It should +never be on in a shared environment. + +### Redirects + +**Not followed at all**, and the `location` header is stripped from the relayed response. A merchant +cannot bounce the Router toward an internal target, and cannot hand your client one either. If you +need the redirect target, resolve it yourself and route the final URL. + +### Forged payment signals + +`X-Router-*` headers are stripped in both directions, so an upstream cannot fabricate a payment +header that makes a free response look paid. + +### Signed-vs-approved divergence + +After signing an MPP credential the Router decodes what it actually produced and compares it against +the challenge it validated. On any divergence the credential is discarded and never leaves the +process — a merchant cannot get one thing approved and a different thing signed. + +## Relay limits (mode B) + +| Limit | Default | Env var | +| --- | --- | --- | +| Concurrent routed requests per user | 10 | `ROUTER_MAX_CONCURRENT_PER_USER` | +| Idle time on a streamed response | 30s | `ROUTER_STREAM_IDLE_MS` | +| Total time on a streamed response | 5 min | `ROUTER_STREAM_MAX_MS` | +| Relayed body size | 100 MB | `ROUTER_MAX_RELAY_BYTES` | + +Exceeding concurrency gives `429 BCK.ROUTER.0007`, which **is** retryable — let calls finish and +back off. Do not respond by fanning out harder. + +The idle timer is re-armed by your client draining the response, so a slow-but-healthy large +transfer will not trip it. An abandoned stream will be, and holds a concurrency slot until it is. + +## Error envelope + +Errors carry a structured body — branch on `code`, not on message text: + +```json +{ "code": "BCK.ROUTER.0003", "category": "business", "httpStatus": 402, + "message": "Delegation budget exceeded, expired, or inactive", + "hint": "…", "correlationId": "…" } +``` + +`hint` is written for a human reading a log. `details`, when present, names the specific check that +tripped — that is the field worth logging on a `0001`. + +Error responses always reflect the **current** API shape; they are not version-pinned. Treat them as +latest-shape diagnostics and tolerate the code set growing over time. diff --git a/skills/nevermined-router/references/ledger.md b/skills/nevermined-router/references/ledger.md new file mode 100644 index 00000000..a655b0cb --- /dev/null +++ b/skills/nevermined-router/references/ledger.md @@ -0,0 +1,126 @@ +# Ledger — what you actually spent + +Every Router payment, across every merchant, protocol and Delegation, lands on one record. This is +how an agent audits its own spending, and how a human audits the agent's. + +## List payments + +```bash +curl -s "$NVM_API_URL/api/v1/router/payments?delegationId=$NVM_DELEGATION_ID" \ + -H "Authorization: Bearer $NVM_API_KEY" +``` + +| Param | Notes | +| --- | --- | +| `delegationId` | Only spend against this Delegation | +| `from` / `to` | ISO-8601, **inclusive** on `createdAt`. Invalid → `400 BCK.ROUTER.0001` | +| `format` | `json` (default) or `csv` — a downloadable `router-payments.csv` | + +Returns **newest first, capped at 1000 rows**. The cap is silent: 1000 rows back does not mean there +were exactly 1000. Narrow with `from`/`to` and page through by time, or use the summary endpoint for +totals. + +### A record + +```json +{ + "id": "b1f9c2e4-…", + "createdAt": "2026-07-01T09:00:55.605Z", + "status": "Settled", + "protocol": "x402", + "network": "base", + "asset": "USDC", + "amount": "1000", + "merchantAddress": "0x209693Bc6afc0C5328bA36FaF03C514EF312287C", + "txHash": "0xfc8af37b…", + "delegationId": "5e7481c3-…", + "requestId": "search-nevermined-router-v1", + "resourceUrl": "https://agent.example/resource", + "buyer": "0x8D6A5233…" +} +``` + +| Field | Notes | +| --- | --- | +| `amount` | The asset's **smallest unit** — 6-decimal stablecoins, so `1000` = 0.001 USDC. Not cents | +| `merchantAddress` | Pay-to identifier: a `0x` address on the crypto rails, or a `profile_…` processor id on the card rail | +| `buyer` | Payer identity: the on-chain EOA (`0x…`) on crypto rails, or a `cus_…` customer id on the card rail | +| `txHash` | Settlement reference. **Not always a `0x` hash** — see reconciliation below | +| `requestId` | Your idempotency key, echoed. The join key back to your own records | + +**`amount` is not cents.** The cap is in cents, the ledger is in atomic units. Converting needs the +asset's decimals (6 for USDC/EURC). If you want what was charged against the budget, that is +`settlement.approxCents` on the original response — the ledger does not repeat it. + +Neither `merchantAddress` nor `buyer` is safely a blockchain address: branch on `network` before +rendering either as an explorer link. + +## Aggregate summary + +```bash +curl -s "$NVM_API_URL/api/v1/router/payments/summary?granularity=day" \ + -H "Authorization: Bearer $NVM_API_KEY" +# → { "total": 137, "series": [ { "date": "2026-07-01T00:00:00.000Z", "value": 12 }, … ] } +``` + +`granularity` is `day` (default), `week` or `month`; an unrecognised value **falls back to `day` +rather than erroring**, so a typo silently changes your bucketing. `from`/`to` behave as above. +Buckets are oldest first. `total` is **uncapped**, unlike the 1000-row list. + +**The summary counts payment *requests*, not money.** Use the list endpoint when you need amounts. + +## Statuses + +| Status | Meaning | +| --- | --- | +| `Issued` | Credential minted and budget reserved. Either still in flight, or it succeeded without a usable settlement reference | +| `Settled` | The merchant accepted the credential and returned a settlement reference, stored as `txHash` | +| `Failed` | The merchant rejected the credential — it answered the paid request with another 402 | + +**`Issued` is not an error.** On a paid mode-B call it means you got the resource but the settlement +anchor did not arrive — a missing, oversized or malformed receipt. The Router deliberately will not +fail an already-paid hop over a bad receipt. In mode A it is simply where a record sits until you +report the settlement. + +So: **an agent must not retry a payment because its record says `Issued`.** The money moved. Retrying +with a fresh `requestId` buys it again. + +## Closing a mode-A record + +```bash +curl -sX POST "$NVM_API_URL/api/v1/router/payments/$PAYMENT_ID/settled" \ + -H "Authorization: Bearer $NVM_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"txHash":"0xfc8af37b…"}' +# → { "paymentId": "b1f9c2e4-…", "status": "Settled", "txHash": "0xfc8af37b…" } +``` + +Take the reference from the merchant's `PAYMENT-RESPONSE` / `X-PAYMENT-RESPONSE` (x402) or +`Payment-Receipt` (MPP) header. + +- Only an `Issued` payment can be settled. Re-reporting the **same** hash is a harmless no-op; a + **different** hash, or a non-`Issued` record, is `409 BCK.ROUTER.0005`. +- A payment id that is not yours is `404 BCK.ROUTER.0004`. + +Mode B does this for you — one of the better reasons to prefer it. + +## Reconciling + +The settlement reference is **reported by the merchant and stored unverified**. The Router bounds its +length and character set, but does not confirm on-chain that the transaction exists, paid the +expected recipient, or moved the expected amount. + +For anything that matters — accounting, disputes, anomaly detection — treat `txHash` as an **anchor +to verify**, not as proof. Check it against the chain named in `network`. + +A non-`0x` reference is legitimate: settlement identifiers are protocol-specific, and non-blockchain +rails return processor references rather than transaction hashes. + +## What an agent should do with this + +- **Reconcile against your own intent.** You hold the `requestId`s you generated; the ledger echoes + them. A row whose `requestId` you do not recognise is worth surfacing. +- **Watch the burn rate, not just the cap.** `remainingBudgetCents` on the Delegation tells you where + you are; the summary series tells you how fast you got there. +- **Report, don't self-heal.** If the ledger disagrees with what you think you bought, that is a + human's problem to look at — not a reason to re-issue payments. diff --git a/skills/nevermined-router/references/paying.md b/skills/nevermined-router/references/paying.md new file mode 100644 index 00000000..027ea5e7 --- /dev/null +++ b/skills/nevermined-router/references/paying.md @@ -0,0 +1,240 @@ +# Paying — mode B, the streaming proxy, and mode A + +Three ways to pay. **Default to mode B.** Reach for the others only when its shape does not fit. + +| | Endpoint | Use when | +| --- | --- | --- | +| **Mode B — envelope** | `POST /api/v1/router/route` | Almost always. One call, JSON in, JSON out | +| **Mode B — streaming** | `ALL /api/v1/router/proxy` | Large or streamed responses (SSE, downloads) | +| **Mode A — credential** | `POST /api/v1/router/payments` | You must call the merchant yourself | + +All three need `Authorization: Bearer $NVM_API_KEY` and an `erc4337` `delegationId`. + +--- + +## Mode B — `POST /api/v1/router/route` + +You describe the request; the Router probes the merchant, **auto-detects** the protocol from the +402, pays, and relays the response. You never see the 402 and never handle a credential. + +```json +{ + "delegationId": "5e7481c3-e972-45bd-bdc5-a0b99c4de4a1", + "url": "https://superhighway.walls.sh/search", + "method": "POST", + "headers": { "X-Merchant-Api-Key": "…" }, + "body": { "query": "nevermined router" }, + "requestId": "search-nevermined-router-v1" +} +``` + +| Field | Required | Notes | +| --- | --- | --- | +| `delegationId` | **yes** | UUID. Must be an `erc4337` Delegation | +| `url` | **yes** | Absolute `http(s)` URL | +| `method` | no | `GET` · `POST` · `PUT` · `PATCH` · `DELETE`. Default `GET` | +| `headers` | no | Forwarded to the merchant — put **its** auth here, never your `NVM_API_KEY` | +| `body` | no | JSON, forwarded | +| `protocol` | no | `x402`/`mpp`. **Advisory only** — see below | +| `requestId` | **yes** | Idempotency key. Non-empty, ≤ 256 chars | + +### `protocol` is advisory here, and the detected one wins + +On mode B the Router determines the protocol from the upstream 402 itself — +`WWW-Authenticate: Payment` → `mpp`; `accepts` / `PAYMENT-REQUIRED` → `x402`. **The detected +protocol is authoritative**: a wrong hint does not change what gets paid, and does not cause a +failure. You can omit it entirely and send the same call for both rails. + +### Response + +```json +{ + "status": 200, + "body": { "…": "the paid resource" }, + "paid": true, + "payment": { + "paymentId": "b1f9c2e4-…", + "settlement": { + "recipient": "0x209693Bc…", "amount": "1000", "asset": "USDC", + "network": "base", "approxCents": "1", "scheme": "exact" + }, + "txHash": "0xfc8af37b…", + "status": "Settled" + } +} +``` + +- `status` / `body` are the merchant's own, relayed unchanged. `body` is parsed JSON when the + merchant returned JSON, otherwise a string. +- `paid: false` and **no `payment` block** means the resource was free — the Router relayed it and + charged nothing. Handle this; not every URL you route is actually paid. +- `settlement.approxCents` is what was reserved against your cap. Trust this over any catalog + `priceLabel`. +- `status: "Issued"` (rather than `Settled`) means the hop succeeded and you have your resource, but + the settlement anchor is still pending. Normal, not a failure — see `ledger.md`. + +### `requestId` — the rule that prevents double-spending + +Required on mode B **because the Router pays automatically**: a retry after a dropped connection +must not buy the same thing twice. At most one payment is minted per `(caller, requestId)`; a +duplicate returns `409 BCK.ROUTER.0002` carrying the **original** `paymentId`, which is usually what +you actually wanted. + +**Use one stable id per logical purchase, reused across retries of that purchase.** + +- Same id on retry → returns the original payment. Safe. +- Fresh id on retry → buys again. Also safe, *if that is what you meant*. + +Derive it from the work (`"search-nevermined-router-v1"`, a hash of the query, a task id). **A fresh +`uuid4()` per HTTP attempt is how an agent double-spends** — it is the default reflex and it is +wrong here. + +--- + +## Mode B streaming — `ALL /api/v1/router/proxy` + +Same engine, transparent transport: method, body and headers pass through and the response +**streams** back. Use it for SSE, large downloads, or anything you do not want buffered into a JSON +envelope. It is deliberately absent from the OpenAPI document — it is a raw any-method proxy driven +by headers, with no fixed schema. + +Point your HTTP client at `/api/v1/router/proxy` and drive it with request headers: + +| Request header | Required | Purpose | +| --- | --- | --- | +| `X-Router-Target-Url` | **yes** | Absolute upstream URL | +| `X-Router-Delegation-Id` | **yes** | Your `erc4337` Delegation | +| `X-Router-Request-Id` | **yes** | Idempotency key — same rule as mode B | +| `X-Router-Upstream-Authorization` | no | The **merchant's** auth, forwarded as its `Authorization` | + +```bash +curl -sN -X POST "$NVM_API_URL/api/v1/router/proxy" \ + -H "Authorization: Bearer $NVM_API_KEY" \ + -H "X-Router-Target-Url: https://service.example/stream" \ + -H "X-Router-Delegation-Id: $NVM_DELEGATION_ID" \ + -H "X-Router-Request-Id: stream-job-42" \ + -H "Content-Type: application/json" \ + -d '{"prompt":"…"}' +``` + +Payment metadata comes back in **response headers** rather than the body: + +| Response header | Meaning | +| --- | --- | +| `X-Router-Payment-Id` | The ledger record id | +| `X-Router-Payment-Status` | `Issued` · `Settled` · `Failed` | +| `X-Router-Tx-Hash` | Settlement hash, when the merchant reported one | + +Omitting `X-Router-Target-Url`, `X-Router-Delegation-Id` or `X-Router-Request-Id` is +`400 BCK.ROUTER.0001`. + +`X-Router-*` headers are stripped in **both** directions — yours are not forwarded upstream, and any +the merchant returns are removed before you see them. So a merchant cannot forge a payment signal +that makes a free response look paid. + +**Drain the response.** The idle timer (30s default) is re-armed by your client consuming the +stream, so a slow-but-healthy large transfer will not trip it — but an abandoned one will be killed, +and the connection held open in the meantime counts against your concurrency limit. + +--- + +## Mode A — `POST /api/v1/router/payments` + +The Router mints a signed credential; **you** call the merchant. Use it when the Router cannot be in +the request path — you need the raw connection, an exotic transport, or the merchant rejects a +relayed call. + +It is strictly more work: you make the unpaid call, hand over the challenge, attach the credential, +re-send, and then close the ledger record yourself. + +### 1 · Provoke the 402 + +Call the merchant with no payment. It answers `402` with its requirements: + +- **x402 v1** — in the JSON body: `{ "x402Version": 1, "accepts": [...] }` +- **x402 v2** — base64 in the `PAYMENT-REQUIRED` **response header** (decode it to an object) +- **MPP** — the raw `WWW-Authenticate: Payment …` header value + +### 2 · Mint the credential + +```json +{ + "delegationId": "5e7481c3-…", + "protocol": "x402", + "resourceUrl": "https://agent.example/paid", + "requestId": "order-1234", + "target": { "x402Version": 1, "accepts": [ /* verbatim from the 402 */ ] } +} +``` + +| Field | Required | Notes | +| --- | --- | --- | +| `delegationId` | **yes** | | +| `protocol` | **yes** | `x402` or `mpp`. **Not auto-detected here** — unlike mode B, you must get this right | +| `target` | **yes** | x402 → `{ accepts, x402Version? }` (defaults to **2**; set `1` for x402-express). MPP → `{ challenge }`, the raw header value | +| `resourceUrl` | no | Absolute URL, recorded on the ledger | +| `requestId` | no | **Optional here**, unlike mode B. Still recommended | + +Pass `target` **verbatim** from the 402. Do not normalise, reorder or re-encode it. + +```json +{ + "paymentId": "b1f9c2e4-…", + "protocol": "x402", + "x402Version": 2, + "credential": { "transport": "header", "name": "PAYMENT-SIGNATURE", "value": "eyJ4NDAy…" }, + "settlement": { "recipient": "0x2096…", "amount": "1000", "asset": "USDC", + "network": "base", "approxCents": "1" }, + "status": "Issued" +} +``` + +### 3 · Attach it and re-send + +Set an HTTP header named **`credential.name`** to **`credential.value`** on your original request +and send it again. The value is opaque — attach it verbatim, do not modify it. + +| Protocol | `credential.name` | +| --- | --- | +| x402 v2 | `PAYMENT-SIGNATURE` | +| x402 v1 | `X-PAYMENT` | +| MPP | `Authorization` | + +Read the name off the response rather than hardcoding it — that is why the field exists. + +### 4 · Close the record + +The merchant returns a settlement reference: `PAYMENT-RESPONSE` / `X-PAYMENT-RESPONSE` (x402) or +`Payment-Receipt` (MPP). Report it: + +```bash +curl -sX POST "$NVM_API_URL/api/v1/router/payments/$PAYMENT_ID/settled" \ + -H "Authorization: Bearer $NVM_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"txHash":"0xfc8af37b…"}' +``` + +Idempotent: re-reporting the **same** hash is a no-op. A **different** hash, or a record not in +`Issued`, is rejected with `409 BCK.ROUTER.0005`. Not needed in mode B — `/route` and `/proxy` close +the record themselves. + +### Mode A caveats + +- **Credentials expire.** The signed authorization has a validity window capped by the operator + (one hour by default). Mint it when you are about to use it — if it lapses, the budget stays + reserved and the record stays `Issued`. +- **On MPP, prefer mode B.** The Router is not in the request path, so it never sees the + `Payment-Receipt`; closing the record means decoding the receipt yourself, which needs the MPP + codec you were trying to avoid. + +--- + +## Passing the merchant's own auth + +Some services want payment *and* an account credential. Never put `NVM_API_KEY` in either slot: + +- mode B → `headers: { "Authorization": "Bearer " }` +- `/proxy` → `X-Router-Upstream-Authorization: Bearer ` + +Note that a service which answers **`401`/`403` instead of `402`** wants authentication, not +payment. The Router cannot help — that service needs an account. See `discovery.md`. From 72dd505b5cefec94f967a7b5861497f7800d50bd Mon Sep 17 00:00:00 2001 From: Rodolphe Marques Date: Mon, 3 Aug 2026 15:24:04 +0200 Subject: [PATCH 2/2] fix(skills): pin the router skill's first ClawHub publish to 0.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses aaitor's review note on #267. He flagged that the versioning comment claims the CLI "auto-increments from the latest published version", which cannot hold for a brand-new slug, and asked me to confirm rather than assume. Confirmed against the CLI itself — `clawhub skill publish --help` documents `--version` as "defaults to 1.0.0 or next patch". So the first publish does not fail (his stated worry), but it also does not read the version out of SKILL.md frontmatter: it would land as 1.0.0 while the skill's own frontmatter and its rendered header say 0.1.0, and those would disagree permanently. 0.1.0 is the honest number — the payments skill is still 0.x, and this skill has one acceptance criterion (a real end-to-end paid call) not yet verified. So his suggested remedy is right, for a different reason than either of us gave. Adds an optional per-skill `version` to the matrix, set only on the router leg, to be deleted after the first publish. Threaded in with `if`, not `[ -n … ] && …`: both are equivalent in this position (errexit ignores a failing non-final element of an && list — I asserted the opposite first and my own test disproved it), but the && list's status is 1 when the version is unset, so it would fail the step if ever moved to the last line. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/publish-skill-clawhub.yml | 33 +++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish-skill-clawhub.yml b/.github/workflows/publish-skill-clawhub.yml index 79311c37..7dbbab8e 100644 --- a/.github/workflows/publish-skill-clawhub.yml +++ b/.github/workflows/publish-skill-clawhub.yml @@ -11,11 +11,18 @@ name: Publish skill to ClawHub # CLAWHUB_TOKEN — a ClawHub API token belonging to a user who owns the # `nevermined-io` publisher (create at https://clawhub.ai, `clawhub token`). # -# Versioning: the CLI auto-increments from the latest published version. The -# push trigger is path-filtered on `skills/**`, so a change to ONE skill still -# runs the matrix leg for the other — that leg is a no-op, because republishing -# an unchanged version is handled as "already published" below. To publish an -# explicit version, add `--version `. +# Versioning: on REPUBLISH the CLI auto-increments from the latest published +# version. On a brand-new slug there is nothing to increment from, and the CLI +# does NOT read the version out of SKILL.md frontmatter — `clawhub skill publish +# --help` documents `--version` as "defaults to 1.0.0 or next patch". So a first +# publish would land as 1.0.0 and permanently contradict the `version:` in the +# skill's own frontmatter (which the rendered skill header shows to users). +# Hence the optional per-skill `version` in the matrix below: set it for the +# FIRST publish of a new slug, then delete it and let auto-increment take over. +# +# The push trigger is path-filtered on `skills/**`, so a change to ONE skill +# still runs the matrix leg for the other — that leg is a no-op, because +# republishing an unchanged version is handled as "already published" below. # # Adding a skill: add one entry to the matrix. Nothing else needs to change. @@ -50,11 +57,16 @@ 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 SLUG: ${{ matrix.slug }} SKILL_NAME: ${{ matrix.name }} + SKILL_VERSION: ${{ matrix.version }} steps: - uses: actions/checkout@v4 @@ -82,8 +94,16 @@ jobs: SOURCE_COMMIT: ${{ github.sha }} SOURCE_REF: ${{ github.ref }} run: | + # `if`, not `[ -n … ] && …`. Both behave identically *here*: the default + # shell is `bash -e`, and errexit ignores a failing non-final element of + # an && list. But that list's own status is 1 when SKILL_VERSION is + # unset, so the && form fails the step the moment anyone moves it to the + # last line. `if` has no such edge — verified both ways. + VERSION_ARG=() + if [ -n "${SKILL_VERSION:-}" ]; then VERSION_ARG=(--version "$SKILL_VERSION"); fi clawhub --no-input skill publish "$SKILL_PATH" \ --owner "$OWNER" --slug "$SLUG" --name "$SKILL_NAME" \ + "${VERSION_ARG[@]}" \ --source-repo "$SOURCE_REPO" --source-commit "$SOURCE_COMMIT" \ --source-ref "$SOURCE_REF" --source-path "$SKILL_PATH" \ --dry-run --json @@ -94,9 +114,12 @@ jobs: SOURCE_COMMIT: ${{ github.sha }} SOURCE_REF: ${{ github.ref }} run: | + VERSION_ARG=() + if [ -n "${SKILL_VERSION:-}" ]; then VERSION_ARG=(--version "$SKILL_VERSION"); fi set +e OUT=$(clawhub --no-input skill publish "$SKILL_PATH" \ --owner "$OWNER" --slug "$SLUG" --name "$SKILL_NAME" \ + "${VERSION_ARG[@]}" \ --source-repo "$SOURCE_REPO" --source-commit "$SOURCE_COMMIT" \ --source-ref "$SOURCE_REF" --source-path "$SKILL_PATH" \ --json 2>&1)