From 3564050675555090a38aaba2cc926dd3207e83de Mon Sep 17 00:00:00 2001 From: fretchen Date: Tue, 28 Jul 2026 18:37:55 +0200 Subject: [PATCH 01/12] Cleaning the agen-registration page a bit --- website/pages/agent-onboarding/+Page.tsx | 1207 +++++------------ .../pages/agent-onboarding/+description.ts | 2 +- 2 files changed, 314 insertions(+), 895 deletions(-) diff --git a/website/pages/agent-onboarding/+Page.tsx b/website/pages/agent-onboarding/+Page.tsx index 33d682c0b..bb981b10f 100644 --- a/website/pages/agent-onboarding/+Page.tsx +++ b/website/pages/agent-onboarding/+Page.tsx @@ -1,107 +1,65 @@ -import React, { useState } from "react"; +import React from "react"; import { css } from "../../styled-system/css"; import * as styles from "../../layouts/styles"; -import { useToast } from "../../components/Toast"; -// Validate Ethereum address format -const isValidEthAddress = (address: string): boolean => { - return /^0x[a-fA-F0-9]{40}$/.test(address); -}; - -export default function Page() { - // Form state - const [agentName, setAgentName] = useState(""); - const [apiEndpoint, setApiEndpoint] = useState(""); - const [walletAddress, setWalletAddress] = useState(""); - const [generatedJson, setGeneratedJson] = useState(null); - - // Service selection (radio: one at a time) - const [serviceType, setServiceType] = useState<"genimg" | "llm">("genimg"); - - // Collapsible sections - const [showWhitelisting, setShowWhitelisting] = useState(false); - - // Toast for feedback - const { showToast, ToastComponent } = useToast(); - - // Derived validation state - const walletIsEmpty = walletAddress.trim() === ""; - const walletIsValid = walletIsEmpty || isValidEthAddress(walletAddress); - const canGenerate = agentName && apiEndpoint && walletAddress && isValidEthAddress(walletAddress); - - // Generate JSON from form - const generateJson = () => { - if (!agentName || !walletAddress) { - alert("Please fill in agent name and wallet address"); - return; - } - - if (!apiEndpoint) { - alert("Please fill in the API endpoint"); - return; - } - - const serviceName = serviceType === "genimg" ? "Image Generation" : "Chat/LLM"; - - const json = { - type: "https://eips.ethereum.org/EIPS/eip-8004#registration-v1", - name: agentName, - description: `${serviceName} service provided by ${agentName}. Integrated with fretchen.eu for on-chain settlement.`, - image: "", - endpoints: [ - { - name: serviceType, - endpoint: apiEndpoint, - }, - { - name: "agentWallet", - endpoint: `eip155:10:${walletAddress}`, - }, - ], - registrations: [], - supportedTrust: ["reputation"], - }; - - setGeneratedJson(JSON.stringify(json, null, 2)); - }; - - // Generate GitHub issue URL - const getGitHubIssueUrl = () => { - const title = encodeURIComponent(`Agent Registration: ${agentName || "New Agent"}`); - const serviceName = serviceType === "genimg" ? "Image Generation" : "Chat/LLM"; - const body = encodeURIComponent(`## Agent Registration Request - -**Agent Name:** ${agentName || "(please fill in)"} -**Service Type:** ${serviceName} -**API Endpoint:** ${apiEndpoint || "(please fill in)"} -**Wallet Address:** ${walletAddress || "(please fill in)"} - -### Generated JSON -\`\`\`json -${generatedJson || "(please generate JSON first)"} -\`\`\` - -### Checklist -- [ ] JSON is hosted and publicly accessible -- [ ] API endpoint is reachable -- [ ] I understand the on-chain settlement process +const LLM_ORIGIN = "https://llm-agent.fretchen.eu"; +const GH_BLOB = "https://github.com/fretchen/fretchen.github.io/blob/main"; + +// Small presentational helpers ------------------------------------------------ + +const sectionCard = css({ + mb: "10", + p: "6", + bg: "gray.50", + borderRadius: "lg", + border: "1px solid", + borderColor: "gray.200", +}); + +const h2 = css({ fontSize: "xl", fontWeight: "semibold", mb: "4", color: "gray.800" }); +const para = css({ fontSize: "sm", color: "gray.600", mb: "3", lineHeight: "1.6" }); +const codeBlock = css({ + bg: "gray.900", + color: "gray.100", + p: "3", + borderRadius: "md", + overflow: "auto", + fontSize: "xs", + lineHeight: "1.5", + mt: "1", + mb: "3", + whiteSpace: "pre", +}); +const inlineCode = css({ fontFamily: "mono", fontSize: "0.9em", bg: "gray.100", px: "1", borderRadius: "sm" }); +const extLink = css({ color: "indigo.600", textDecoration: "underline", _hover: { color: "indigo.800" } }); + +function Code({ children }: { children: string }) { + return
{children}
; +} -### Additional Notes -(Add any additional information about your service here) -`); - return `https://github.com/fretchen/fretchen.github.io/issues/new?title=${title}&body=${body}&labels=agent-registration`; - }; +function Challenge({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+
{children}
+
+ ); +} +export default function Page() { return (
-
- {/* Alpha Banner */} +
+ {/* Beta banner */} - {/* Hero Section */} -
-

- πŸ› οΈ Can We Build An Open AI Payment Infrastructure Together? + {/* Hero */} +
+

+ πŸ€– Build an llm/v1 Agent

- Join as an early provider and help shape the protocol. + An open, machine-checkable contract for an x402-paid chat endpoint. Any agent that meets it can be used by + the{" "} + + assistant + {" "} + β€” no account, no manual approval.

- {/* Benefits Section - Compact */} -
-
- πŸ”‘ -

Direct payments after whitelisting

-
-
- πŸ› οΈ -

Your infrastructure, your keys

-
-
- πŸ“Š -

Transparent on Optimism L2

-
-
- - {/* SECTION 1: What Your API Needs To Do */} -
-

- πŸ“‘ What Your API Needs To Do + {/* SECTION 1 β€” what llm/v1 is */} +
+

+ πŸ“‘ What llm/v1 is

- -

- We support two service types. Your API receives requests and interacts with our smart contracts. No - authentication needed – the system is prepaid. +

+ An llm/v1 agent is a single HTTP endpoint that speaks the{" "} + OpenAI chat-completions body and is paid per message via{" "} + x402 batch-settlement (a USDC payment channel). The OpenAI shape is there so the + request/response is easy to read and reuse types against β€” it is not a promise that a stock OpenAI + SDK can pay it (it can't; see Known Challenges).

- {/* Two Service Types */} -
- {/* Image Generation */} -
-

- πŸ–ΌοΈ Image Generation -

-
- Request: -
-                  {`{ "prompt": "...", "tokenId": 42 }`}
-                
-
-
- Response: -
-                  {`{ "image_url": "...", "tx": "0x..." }`}
-                
-
-

β†’ Updates NFT metadata on-chain

-
+

Request:

+ {`POST / (no auth header; the 402 challenge drives payment) +{ + "model": "mistral-large-latest", + "messages": [{ "role": "user", "content": "Hello" }] +}`} - {/* Chat/LLM */} -
-

- πŸ’¬ Chat / LLM -

-
- Request: -
-                  {`{ "message": "...", "address": "0x..." }`}
-                
-
-
- Response: -
-                  {`{ "response": "...", "leaf": "0x..." }`}
-                
-
-

β†’ Deducts from user balance

-
+

+ Response β€” a standard OpenAI chat.completion: +

+ {`{ + "id": "chatcmpl-...", + "object": "chat.completion", + "model": "mistral-large-latest", + "choices": [{ "index": 0, "message": { "role": "assistant", "content": "..." }, "finish_reason": "stop" }], + "usage": { "prompt_tokens": 10, "completion_tokens": 9, "total_tokens": 19 } +}`} + +
    +
  • + usage is required β€” the per-message charge is settled + from it. +
  • +
  • + Streaming (stream: true) is not supported β€” + settlement needs the final usage, which needs the whole completion. +
  • +
  • + model is validated against the advertised ids; an unknown model + returns 404 model_not_found. +
  • +
+ + +
- {/* Links */} -
- - πŸ“„ OpenAPI Spec β†’ - + {/* SECTION 2 β€” interop floor + self-check */} +
+

βœ… The contract, and how to self-check it

+

+ Compatibility is objective and automated β€” there is no human approval step. An agent + qualifies when it publishes the right discovery document and advertises the right payment option. These are + exactly the two checks the assistant runs before it will talk to an endpoint. +

+ +

+ Check 1 β€” discovery document +

+

+ Serve GET <origin>/openapi.json returning{" "} + 200 with{" "} + x-service-type: "llm/v1", plus{" "} + x-interop-floor, x-payment-info, and{" "} + x-discovery.ownershipProofs. +

+ {`curl -s https://your-agent.example/openapi.json | jq '."x-service-type"' +# => "llm/v1"`} + +

+ Check 2 β€” payment challenge meets the interop floor +

+

+ A bare unpaid POST must return 402{" "} + with a base64 Payment-Required header whose decoded{" "} + accepts[] has at least one entry with{" "} + + network Base mainnet (eip155:8453) + + ,{" "} + + scheme batch-settlement + + , asset USDC. +

+ {`curl -si https://your-agent.example/ \\ + -X POST -H 'Content-Type: application/json' \\ + -d '{"model":"probe","messages":[]}' | grep -i '^payment-required' +# decode the base64 value β†’ accepts[] must include { network: "eip155:8453", scheme: "batch-settlement" }`} + +

+ Pass both and any llm/v1 client can use you. Ownership proofs are an + EIP-191 signature over your origin; the repo ships a signer you can reuse:{" "} - πŸ–ΌοΈ ImageGen Example β†’ + sign_ownership_proof.ts + . +

+
+ + {/* SECTION 3 β€” what running a provider takes */} +
+

πŸ› οΈ What running a provider takes

+

+ Most of the stack is stock @x402/evm. One piece is currently + bring-your-own. +

+ +

+ Standard / reusable +

+
    +
  • + Build the 402 with the SDK's BatchSettlementEvmScheme +{" "} + enhancePaymentRequirements; verify each message's voucher with{" "} + verifyPayment. +
  • +
  • + Channel storage is stock: InMemoryChannelStorage for dev,{" "} + RedisChannelStorage/ + FileChannelStorage for prod. A custom backend only has to provide + atomic compare-and-swap on updateChannel. +
  • +
  • + Run a recurring claim/settle job. Per-message voucher settlements are local bookkeeping + only β€” funds move on-chain solely through{" "} + scheme.createChannelManager().claimAndSettle(). Skip it and earned + vouchers never become revenue. +
  • +
  • + Config: a receiver wallet, an off-chain receiverAuthorizer signing + key, per-network RPC URLs, your inference key, and pricing / withdraw-delay knobs. +
  • +
+ +

+ The one external dependency: a facilitator +

+

+ You need an x402 batch-settlement facilitator β€” the process that submits the on-chain + deposit / claim / settle transactions. Unlike the exact scheme, only a + handful of public facilitators enable batch-settlement on EVM mainnet today ( + + Solvador + {" "} + is one; this project runs its own). You can point at one of those or run your own β€” but the small pool is a + real constraint, so check{" "} - πŸ’¬ Chat Example β†’ - -

+ the facilitator list + {" "} + for one that advertises batch-settlement on your network. +

- {/* SECTION 2: How The Payment Flow Works */} -
-

- πŸ’° How The Payment Flow Works -

- -
-
-
- 1 -
-

User Pays

-

ETH β†’ Smart Contract

-
- -
-
- 2 -
-

Frontend Calls You

-

POST to your API

-
- -
-
- 3 -
-

You Update Token

-

Write to contract

-
- -
-
- πŸ’° -
-

ETH β†’ Your Wallet

-

Direct payment

-
-
+ {/* SECTION 4 β€” known challenges */} +
+

⚠️ Known challenges (beta)

+

+ We're documenting these openly because llm/v1 is genuinely beta β€” + these are the sharp edges we've actually hit. +

-
-

- Key insight: Payment happens BEFORE your API is called. You only need to do the work and - update the token. No payment handling required on your side. -

-
+ + Batch-settlement is a Coinbase-blessed standard scheme, but most public facilitators (Coinbase's CDP + among them) only enable exact. A handful advertise batch-settlement on + EVM mainnet β€”{" "} + + Solvador + {" "} + and this project β€” so you can point at one or run your own, but the pool is small. Part of the reason: the + abstract scheme is standardized, yet the EVM wire binding is currently defined by the{" "} + @x402/evm code rather than a ratified spec doc, which slows adoption. + + + + The SDK has no client-side register helper for batch-settlement (unlike{" "} + registerExactEvmScheme for exact). A caller must hand-wire the scheme, + channel storage, deposit, and voucher signing. So the OpenAI body shape buys legibility, not drop-in SDK use + β€” a plain Authorization: Bearer request just hits the 402 and stops. + + + + Each channel is serialized: a second concurrent request returns{" "} + channel_busy, and the client SDK does not auto-recover. An interrupted + request (tab close, network drop) orphans the lock for up to the request timeout (~2 min). The fix is + "wait a few seconds and retry." + + + + Optimism mainnet is excluded by a gap in @x402/evm's{" "} + DEFAULT_STABLECOINS registry (it throws while enhancing the 402), even + though the contract is deployed there. The interop floor is Base ( + eip155:8453). + + + + A channel's unilateral-exit withdraw delay has to stay well above your claim/settle job's + interval, or a channel can become withdrawable before you ever claim it β€” and you lose earned revenue. The + reference uses a 24h delay against a 12h job. (Also: cache on-chain channel state briefly, or a user's + first message right after depositing can spuriously fail.) + + + + If client and server disagree on the channel's cumulative total, recovery requires the client signer to + expose readContract and the server to re-emit the 402 via the SDK's{" "} + createPaymentRequiredResponse (a hand-rolled 402 body breaks recovery). + Get either wrong and a recoverable desync becomes a hard failure. +
- {/* SECTION 3: Get Started - Registration Form */} -
-

- πŸš€ Get Started: Register Your Service -

- -

- Generate your registration JSON and open a GitHub issue to request whitelisting. + {/* SECTION 5 β€” what's next / contact */} +

+

🧭 Where this is going

+

+ The contract itself is stable. The open questions are ecosystem-level: client ergonomics (the SDK has no + batch-settlement register helper, so every client hand-wires the scheme) and the still-thin set of + facilitators and agents running it. Until that ecosystem fills in, the realistic pool of third-party{" "} + llm/v1 agents is small β€” which is exactly why the assistant doesn't + yet expose a "pick another agent" box.

- -
- {/* Agent Name */} -
- - setAgentName(e.target.value)} - placeholder="My AI Service" - className={css({ - width: "100%", - px: "3", - py: "2", - border: "1px solid", - borderColor: "gray.300", - borderRadius: "md", - fontSize: "sm", - _focus: { - outline: "none", - borderColor: "blue.500", - boxShadow: "0 0 0 1px var(--colors-blue-500)", - }, - })} - /> -
- - {/* Service Type - Radio */} -
- -
- - -
-
- - {/* API Endpoint */} -
- - setApiEndpoint(e.target.value)} - placeholder={ - serviceType === "genimg" ? "https://api.your-service.com/genimg" : "https://api.your-service.com/llm" - } - className={css({ - width: "100%", - px: "3", - py: "2", - border: "1px solid", - borderColor: "gray.300", - borderRadius: "md", - fontSize: "sm", - _focus: { - outline: "none", - borderColor: "blue.500", - boxShadow: "0 0 0 1px var(--colors-blue-500)", - }, - })} - /> -
- - {/* Payment Wallet Address */} -
- - setWalletAddress(e.target.value)} - placeholder="0x..." - className={css({ - width: "100%", - px: "3", - py: "2", - border: "1px solid", - borderColor: walletIsValid ? "gray.300" : "red.500", - borderRadius: "md", - fontSize: "sm", - fontFamily: "mono", - _focus: { - outline: "none", - borderColor: walletIsValid ? "blue.500" : "red.500", - boxShadow: walletIsValid ? "0 0 0 1px var(--colors-blue-500)" : "0 0 0 1px var(--colors-red-500)", - }, - })} - /> - {!walletIsValid && ( -

- Invalid address format. Must be 0x followed by 40 hexadecimal characters. -

- )} -
-
- - {/* Action Buttons */} -
- - +

+ Building one, or want to be listed when a picker ships? Reach out at{" "} + + fretchen.dev@proton.me + {" "} + or via{" "} - Open GitHub Issue β†’ + GitHub -

- - {/* Generated JSON Preview */} - {generatedJson && ( -
-
- - Generated JSON: - - -
-
-                {generatedJson}
-              
-
- )} -
- - {/* SECTION 4: Whitelisting Process (Collapsible) */} -
-
- - {showWhitelisting && ( -
-
-
-

- Why is whitelisting required? -

-

- During the alpha phase, we manually review providers to ensure quality and prevent spam. This is a - temporary measure. -

-
-
-

- What happens after whitelisting? -

-

- Once your wallet is whitelisted in the smart contract, all payments between users and your service - are fully decentralized. No middleman, no approval needed. -

-
-
-

Long-term vision

-

- Once EIP-8004 Identity Registry is deployed on Optimism, registration will be fully permissionless - and on-chain. -

-
-
-
- )} -
-
- - {/* Live Example */} -

- {ToastComponent}
); } diff --git a/website/pages/agent-onboarding/+description.ts b/website/pages/agent-onboarding/+description.ts index 7a17c7cf5..4ef9a29cc 100644 --- a/website/pages/agent-onboarding/+description.ts +++ b/website/pages/agent-onboarding/+description.ts @@ -1,3 +1,3 @@ export function description() { - return "Learn how to register your AI service as an agent on fretchen.eu. Follow EIP-8004 standards for trustless agent discovery and on-chain verification."; + return "Build an llm/v1 agent: the open, self-checkable contract for an x402 batch-settlement chat endpoint on fretchen.eu β€” wire format, interop floor, and the known challenges."; } From c648d8797ad1f0027d57abdcfb7fa9ab8a488fe7 Mon Sep 17 00:00:00 2001 From: fretchen Date: Wed, 29 Jul 2026 22:14:13 +0200 Subject: [PATCH 02/12] further work --- website/components/AgentChecker.tsx | 129 ++++++++ website/hooks/x402Discovery.ts | 171 ++++++++++ website/pages/agent-onboarding/+Page.tsx | 389 ++++++++++++----------- website/test/x402Discovery.test.ts | 101 +++++- 4 files changed, 605 insertions(+), 185 deletions(-) create mode 100644 website/components/AgentChecker.tsx diff --git a/website/components/AgentChecker.tsx b/website/components/AgentChecker.tsx new file mode 100644 index 000000000..0161e8e88 --- /dev/null +++ b/website/components/AgentChecker.tsx @@ -0,0 +1,129 @@ +/** + * AgentChecker β€” a diagnostic for the "Build Your Own Agent" page. + * + * Paste an endpoint URL β†’ runs the exact llm/v1 compatibility checks the assistant applies + * (via `checkLlmV1Agent`) and shows every step's pass/fail/warn with a fix hint. Purely + * diagnostic: no wallet, no payment, no "use this agent" β€” unlike the payment-focused + * AgentSelector used in the chat sidebar. + */ +import React, { useState } from "react"; +import { css } from "../styled-system/css"; +import { checkLlmV1Agent, type CheckReport, type CheckStatus } from "../hooks/x402Discovery"; + +const ICON: Record = { pass: "βœ…", fail: "❌", warn: "⚠️" }; +const COLOR: Record = { pass: "green.700", fail: "red.600", warn: "amber.700" }; + +const inputStyle = css({ + flex: "1", + minWidth: "0", + fontSize: "sm", + px: "3", + py: "2", + border: "1px solid", + borderColor: "gray.300", + borderRadius: "md", + _focus: { outline: "none", borderColor: "indigo.500" }, +}); + +const buttonStyle = css({ + px: "4", + py: "2", + fontSize: "sm", + fontWeight: "medium", + bg: "indigo.600", + color: "white", + borderRadius: "md", + cursor: "pointer", + _hover: { bg: "indigo.700" }, + _disabled: { opacity: 0.5, cursor: "not-allowed" }, +}); + +export function AgentChecker() { + const [url, setUrl] = useState(""); + const [checking, setChecking] = useState(false); + const [report, setReport] = useState(null); + + const run = async () => { + const trimmed = url.trim(); + if (!trimmed || checking) return; + setChecking(true); + setReport(null); + try { + setReport(await checkLlmV1Agent(trimmed)); + } finally { + setChecking(false); + } + }; + + return ( +
+
+ setUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void run(); + } + }} + disabled={checking} + className={inputStyle} + /> + +
+ + {report && ( +
+
+ {report.ok + ? "βœ… Compatible β€” the assistant could use this endpoint." + : "❌ Not yet compatible β€” see below."} +
+
    + {report.steps.map((step) => ( +
  • + + {ICON[step.status]} + + + {step.label} + + {step.detail} + + +
  • + ))} +
+

+ The checks run from your browser, so a failure can also mean CORS: your endpoint must send{" "} + Access-Control-Allow-Origin and expose the{" "} + Payment-Required header. +

+
+ )} +
+ ); +} + +export default AgentChecker; diff --git a/website/hooks/x402Discovery.ts b/website/hooks/x402Discovery.ts index d391d8ce6..c2d8ff884 100644 --- a/website/hooks/x402Discovery.ts +++ b/website/hooks/x402Discovery.ts @@ -190,3 +190,174 @@ export async function precheckLlmV1Agent(agentUrl: string): Promise { + const steps: CheckStep[] = []; + const push = (id: string, label: string, status: CheckStatus, detail: string) => + steps.push({ id, label, status, detail }); + + // 1. URL parseable. + let origin: string; + try { + origin = agentOrigin(agentUrl); + } catch { + push("url", "Valid URL", "fail", "That does not look like a valid URL (e.g. https://your-agent.example)."); + return { ok: false, steps }; + } + push("url", "Valid URL", "pass", origin); + + // 2 + 3. /openapi.json reachable cross-origin, with x-service-type: llm/v1. + let doc: OpenApiDoc | null = null; + try { + const res = await fetch(`${origin}/openapi.json`, { method: "GET" }); + if (!res.ok) { + push("openapi", "Serves /openapi.json", "fail", `GET ${origin}/openapi.json returned ${res.status}.`); + } else { + doc = (await res.json().catch(() => null)) as OpenApiDoc | null; + if (!doc) { + push("openapi", "Serves /openapi.json", "fail", "The document was reached but is not valid JSON."); + } else { + push("openapi", "Serves /openapi.json", "pass", `Reachable at ${origin}/openapi.json.`); + } + } + } catch { + push( + "openapi", + "Serves /openapi.json", + "fail", + `Could not read ${origin}/openapi.json from the browser β€” likely CORS: ${CORS_HINT}.`, + ); + } + + if (doc) { + if (doc["x-service-type"] === "llm/v1") { + push("service-type", 'Declares x-service-type: "llm/v1"', "pass", "The discovery tag is present."); + } else { + push( + "service-type", + 'Declares x-service-type: "llm/v1"', + "fail", + `Found x-service-type: ${JSON.stringify(doc["x-service-type"]) ?? "(none)"}. Set it to "llm/v1".`, + ); + } + } + + // 4 + 5. Bare POST β†’ 402 with a readable Payment-Required header. + let accepts: AcceptsEntry[] | null = null; + let got402 = false; + try { + const res = await fetch(agentUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "probe", messages: [] }), + }); + if (res.status === 402) { + got402 = true; + push("challenge", "Returns a 402 payment challenge", "pass", "An unpaid request is asked to pay."); + const header = res.headers.get("Payment-Required"); + if (header === null) { + push( + "payment-required", + "Exposes the Payment-Required header", + "fail", + `The 402 has no readable Payment-Required header β€” if the server sets it, it isn't exposed to the browser: ${CORS_HINT}.`, + ); + } else { + accepts = decodePaymentRequired(header); + push( + "payment-required", + "Exposes the Payment-Required header", + accepts ? "pass" : "fail", + accepts + ? "Header present and decodable." + : "Header present but not valid base64 JSON with an accepts[] array.", + ); + } + } else { + push( + "challenge", + "Returns a 402 payment challenge", + "fail", + `An unpaid POST returned ${res.status}, expected 402. Let unauthenticated probes reach the payment challenge.`, + ); + } + } catch { + push( + "challenge", + "Returns a 402 payment challenge", + "fail", + `Could not POST to ${agentUrl} from the browser β€” likely CORS: ${CORS_HINT}.`, + ); + } + + // 6. accepts[] meets the payment requirement (USDC on Base via batch-settlement). + if (got402) { + if (meetsLlmV1Floor(accepts)) { + push( + "floor", + "Offers USDC on Base via batch-settlement", + "pass", + `accepts[] includes ${LLM_V1_FLOOR.scheme} on ${LLM_V1_FLOOR.network}.`, + ); + } else { + push( + "floor", + "Offers USDC on Base via batch-settlement", + "fail", + `accepts[] must include an entry with network ${LLM_V1_FLOOR.network} (Base) and scheme ${LLM_V1_FLOOR.scheme}.`, + ); + } + } + + // 7. Ownership proof (warn-only β€” recommended, not required to be usable). + if (doc) { + const proofs = (doc as { "x-discovery"?: { ownershipProofs?: unknown[] } })["x-discovery"]?.ownershipProofs; + if (Array.isArray(proofs) && proofs.length > 0) { + push("ownership", "Publishes an ownership proof", "pass", "x-discovery.ownershipProofs is present."); + } else { + push( + "ownership", + "Publishes an ownership proof", + "warn", + "Recommended: sign your origin and add it to x-discovery.ownershipProofs so clients can verify you control the payTo address.", + ); + } + } + + const ok = steps.every((s) => s.status !== "fail"); + return { ok, steps }; +} diff --git a/website/pages/agent-onboarding/+Page.tsx b/website/pages/agent-onboarding/+Page.tsx index bb981b10f..66a074a48 100644 --- a/website/pages/agent-onboarding/+Page.tsx +++ b/website/pages/agent-onboarding/+Page.tsx @@ -1,14 +1,16 @@ import React from "react"; import { css } from "../../styled-system/css"; import * as styles from "../../layouts/styles"; +import { AgentChecker } from "../../components/AgentChecker"; const LLM_ORIGIN = "https://llm-agent.fretchen.eu"; const GH_BLOB = "https://github.com/fretchen/fretchen.github.io/blob/main"; +const X402_DOCS = "https://docs.x402.org"; // Small presentational helpers ------------------------------------------------ const sectionCard = css({ - mb: "10", + mb: "8", p: "6", bg: "gray.50", borderRadius: "lg", @@ -16,7 +18,7 @@ const sectionCard = css({ borderColor: "gray.200", }); -const h2 = css({ fontSize: "xl", fontWeight: "semibold", mb: "4", color: "gray.800" }); +const h2 = css({ fontSize: "xl", fontWeight: "semibold", mb: "3", color: "gray.800" }); const para = css({ fontSize: "sm", color: "gray.600", mb: "3", lineHeight: "1.6" }); const codeBlock = css({ bg: "gray.900", @@ -37,6 +39,18 @@ function Code({ children }: { children: string }) { return
{children}
; } +/** One requirement line in the checklist. */ +function Req({ children }: { children: React.ReactNode }) { + return ( +
  • + + ☐ + + {children} +
  • + ); +} + function Challenge({ title, children }: { title: string; children: React.ReactNode }) { return (
    - πŸ§ͺ Beta β€” this documents the{" "} - llm/v1 agent contract. The client-facing contract is stable and - self-checkable; the rough edges are on the ecosystem side (thin tooling, few facilitators) β€” see{" "} + πŸ§ͺ Beta β€” the payment rails + behind this are new. The endpoint contract below is stable and you can verify yours with the checker on this + page, but the surrounding ecosystem is still thin (see{" "} - Known Challenges + Known limitations - . + ).
    {/* Hero */}

    - πŸ€– Build an llm/v1 Agent + πŸ€– Build your own agent

    - An open, machine-checkable contract for an x402-paid chat endpoint. Any agent that meets it can be used by - the{" "} + Build a chat endpoint that earns a small crypto payment per message. If it meets the requirements below, the{" "} assistant {" "} - β€” no account, no manual approval. + can use it β€” no account and no manual approval. Paste your URL into the{" "} + + checker + {" "} + to see if you're there.

    - {/* SECTION 1 β€” what llm/v1 is */} + {/* SECTION 1 β€” how payment works (plain + links out) */} +
    +

    πŸ’Έ How payment works (in one paragraph)

    +

    + When someone calls your endpoint without paying, you answer with HTTP 402 Payment Required{" "} + and a header describing how to pay. Their client pays in USDC (a dollar stablecoin) and + retries. Instead of one on-chain transaction per message β€” too slow and too expensive for chat β€” payment + uses a channel: the user deposits once into an on-chain escrow, then each message is a tiny + signed IOU ("voucher"), and you redeem the accumulated vouchers on-chain later in one batch. This + channel scheme is called x402 batch-settlement. +

    +

    + You don't implement any of this yourself β€” the @x402/evm SDK does + the protocol work. This page only covers what's specific to being usable by the assistant. New to x402? + Start here: +

    + +
    + + {/* SECTION 2 β€” the API */}
    -

    - πŸ“‘ What llm/v1 is -

    +

    πŸ“‘ The API your endpoint exposes

    - An llm/v1 agent is a single HTTP endpoint that speaks the{" "} - OpenAI chat-completions body and is paid per message via{" "} - x402 batch-settlement (a USDC payment channel). The OpenAI shape is there so the - request/response is easy to read and reuse types against β€” it is not a promise that a stock OpenAI - SDK can pay it (it can't; see Known Challenges). + The request and response are the OpenAI chat-completions format β€” so it looks like any + other LLM API and you can reuse existing types.

    -

    Request:

    - {`POST / (no auth header; the 402 challenge drives payment) +

    Request (a plain POST, no auth header):

    + {`POST / { "model": "mistral-large-latest", "messages": [{ "role": "user", "content": "Hello" }] @@ -138,218 +210,167 @@ export default function Page() {
    • - usage is required β€” the per-message charge is settled - from it. + usage is required β€” the per-message charge is + computed from the token counts.
    • - Streaming (stream: true) is not supported β€” - settlement needs the final usage, which needs the whole completion. + Streaming (stream: true) is not supported (settlement + needs the final token count, which needs the whole reply).
    • - model is validated against the advertised ids; an unknown model - returns 404 model_not_found. + Reject unknown model values with{" "} + 404; only serve the model ids you advertise.
    - {/* SECTION 2 β€” interop floor + self-check */} + {/* SECTION 3 β€” requirements checklist */}
    -

    βœ… The contract, and how to self-check it

    -

    - Compatibility is objective and automated β€” there is no human approval step. An agent - qualifies when it publishes the right discovery document and advertises the right payment option. These are - exactly the two checks the assistant runs before it will talk to an endpoint. -

    - -

    - Check 1 β€” discovery document -

    +

    βœ… Requirements

    - Serve GET <origin>/openapi.json returning{" "} - 200 with{" "} - x-service-type: "llm/v1", plus{" "} - x-interop-floor, x-payment-info, and{" "} - x-discovery.ownershipProofs. -

    - {`curl -s https://your-agent.example/openapi.json | jq '."x-service-type"' -# => "llm/v1"`} - -

    - Check 2 β€” payment challenge meets the interop floor -

    -

    - A bare unpaid POST must return 402{" "} - with a base64 Payment-Required header whose decoded{" "} - accepts[] has at least one entry with{" "} - - network Base mainnet (eip155:8453) - - ,{" "} - - scheme batch-settlement - - , asset USDC. -

    - {`curl -si https://your-agent.example/ \\ - -X POST -H 'Content-Type: application/json' \\ - -d '{"model":"probe","messages":[]}' | grep -i '^payment-required' -# decode the base64 value β†’ accepts[] must include { network: "eip155:8453", scheme: "batch-settlement" }`} - -

    - Pass both and any llm/v1 client can use you. Ownership proofs are an - EIP-191 signature over your origin; the repo ships a signer you can reuse:{" "} - - sign_ownership_proof.ts - - . + Everything an endpoint must do to be usable by the assistant. The checker below tests each of these.

    +
      + + Serve the OpenAI-shaped chat endpoint at POST / (above). + + + Serve a discovery document at GET /openapi.json that includes{" "} + "x-service-type": "llm/v1" (the tag that marks it + assistant-compatible), plus x-payment-info and{" "} + x-discovery.ownershipProofs. + + + On an unpaid request, return 402 whose payment options include{" "} + USDC on Base (eip155:8453) via{" "} + batch-settlement. That's the one payment method the assistant's wallet can + currently fulfil. + + + Allow the browser to read your responses: set{" "} + Access-Control-Allow-Origin: * and{" "} + Access-Control-Expose-Headers: Payment-Required. The assistant runs in + a browser, so without this it can't see your 402. + + + Use an x402 facilitator that supports batch-settlement β€” the service that submits the + on-chain deposit/claim/settle transactions. Point at a public one from{" "} + + the facilitator list + {" "} + (e.g.{" "} + + Solvador + + ), or run your own. + + + Publish an ownership proof (recommended) β€” an EIP-191 signature over your origin, so + clients can confirm you control the address they'll pay. Reusable signer:{" "} + + sign_ownership_proof.ts + + . + + + Run a recurring claim job β€” per-message vouchers are just IOUs until you batch-redeem + them on-chain (claimAndSettle()). Skip it and you never actually get + paid. + +
    - {/* SECTION 3 β€” what running a provider takes */} -
    -

    πŸ› οΈ What running a provider takes

    + {/* SECTION 4 β€” the live checker */} +
    +

    πŸ”Ž Check your endpoint

    - Most of the stack is stock @x402/evm. One piece is currently - bring-your-own. -

    - -

    - Standard / reusable -

    -
      -
    • - Build the 402 with the SDK's BatchSettlementEvmScheme +{" "} - enhancePaymentRequirements; verify each message's voucher with{" "} - verifyPayment. -
    • -
    • - Channel storage is stock: InMemoryChannelStorage for dev,{" "} - RedisChannelStorage/ - FileChannelStorage for prod. A custom backend only has to provide - atomic compare-and-swap on updateChannel. -
    • -
    • - Run a recurring claim/settle job. Per-message voucher settlements are local bookkeeping - only β€” funds move on-chain solely through{" "} - scheme.createChannelManager().claimAndSettle(). Skip it and earned - vouchers never become revenue. -
    • -
    • - Config: a receiver wallet, an off-chain receiverAuthorizer signing - key, per-network RPC URLs, your inference key, and pricing / withdraw-delay knobs. -
    • -
    - -

    - The one external dependency: a facilitator -

    -

    - You need an x402 batch-settlement facilitator β€” the process that submits the on-chain - deposit / claim / settle transactions. Unlike the exact scheme, only a - handful of public facilitators enable batch-settlement on EVM mainnet today ( - - Solvador - {" "} - is one; this project runs its own). You can point at one of those or run your own β€” but the small pool is a - real constraint, so check{" "} - - the facilitator list - {" "} - for one that advertises batch-settlement on your network. + Paste your endpoint URL. This runs the exact checks the assistant runs β€” reachability, the discovery tag, + the 402 challenge, and the payment option β€” and reports each one.

    +
    - {/* SECTION 4 β€” known challenges */} + {/* SECTION 5 β€” known limitations */}
    -

    ⚠️ Known challenges (beta)

    +

    ⚠️ Known limitations (beta)

    - We're documenting these openly because llm/v1 is genuinely beta β€” - these are the sharp edges we've actually hit. + We document these openly β€” they're the rough edges of a young ecosystem, not of your code.

    - - Batch-settlement is a Coinbase-blessed standard scheme, but most public facilitators (Coinbase's CDP - among them) only enable exact. A handful advertise batch-settlement on - EVM mainnet β€”{" "} + + Most public facilitators (including Coinbase's) only support the simpler{" "} + exact scheme. A handful run batch-settlement on mainnet β€”{" "} Solvador {" "} - and this project β€” so you can point at one or run your own, but the pool is small. Part of the reason: the - abstract scheme is standardized, yet the EVM wire binding is currently defined by the{" "} - @x402/evm code rather than a ratified spec doc, which slows adoption. + and this project β€” so your facilitator options are limited today. (The scheme is standard; its EVM wire + binding is still defined by the @x402/evm code rather than a ratified + spec, which is why adoption is thin.) - - The SDK has no client-side register helper for batch-settlement (unlike{" "} - registerExactEvmScheme for exact). A caller must hand-wire the scheme, - channel storage, deposit, and voucher signing. So the OpenAI body shape buys legibility, not drop-in SDK use - β€” a plain Authorization: Bearer request just hits the 402 and stops. + + There is no drop-in client helper for batch-settlement yet, so a caller has to hand-wire the payment + (channel storage, deposit, voucher signing). The OpenAI-shaped body keeps it familiar to read, but a plain{" "} + Authorization: Bearer request just hits the 402 and stops. - - Each channel is serialized: a second concurrent request returns{" "} - channel_busy, and the client SDK does not auto-recover. An interrupted - request (tab close, network drop) orphans the lock for up to the request timeout (~2 min). The fix is - "wait a few seconds and retry." + + A channel processes requests one at a time β€” a concurrent second request is rejected until the first + settles, and an interrupted request can hold the lock for up to ~2 minutes. The client handles this by + waiting and retrying. - Optimism mainnet is excluded by a gap in @x402/evm's{" "} - DEFAULT_STABLECOINS registry (it throws while enhancing the 402), even - though the contract is deployed there. The interop floor is Base ( - eip155:8453). - - - - A channel's unilateral-exit withdraw delay has to stay well above your claim/settle job's - interval, or a channel can become withdrawable before you ever claim it β€” and you lose earned revenue. The - reference uses a 24h delay against a 12h job. (Also: cache on-chain channel state briefly, or a user's - first message right after depositing can spuriously fail.) + The payment option must be on Base (eip155:8453). Optimism is currently + blocked by a gap in the @x402/evm stablecoin registry, even though the + contract is deployed there. - - If client and server disagree on the channel's cumulative total, recovery requires the client signer to - expose readContract and the server to re-emit the 402 via the SDK's{" "} - createPaymentRequiredResponse (a hand-rolled 402 body breaks recovery). - Get either wrong and a recoverable desync becomes a hard failure. + + Your channel's withdraw delay must stay well above how often you run the claim job, or a channel can + become withdrawable before you claim it β€” losing you earned revenue. The reference uses a 24h delay against + a 12h job.
    - {/* SECTION 5 β€” what's next / contact */} + {/* SECTION 6 β€” contact */}

    🧭 Where this is going

    - The contract itself is stable. The open questions are ecosystem-level: client ergonomics (the SDK has no - batch-settlement register helper, so every client hand-wires the scheme) and the still-thin set of - facilitators and agents running it. Until that ecosystem fills in, the realistic pool of third-party{" "} - llm/v1 agents is small β€” which is exactly why the assistant doesn't - yet expose a "pick another agent" box. + The endpoint contract is stable; what's still filling in is the ecosystem around it β€” a drop-in client, + more facilitators, more agents. Until then the pool of compatible third-party agents is small, which is why + the assistant doesn't yet let you pick one from a list.

    Building one, or want to be listed when a picker ships? Reach out at{" "} fretchen.dev@proton.me {" "} - or via{" "} + or on{" "} GitHub - . The full design record lives in{" "} + . The reference implementation and full spec live in{" "} scw_js/README.md {" "} diff --git a/website/test/x402Discovery.test.ts b/website/test/x402Discovery.test.ts index 9e00cd39b..c8efddd79 100644 --- a/website/test/x402Discovery.test.ts +++ b/website/test/x402Discovery.test.ts @@ -3,7 +3,14 @@ * check, and the agent pre-check (contract doc + live 402 probe). */ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { decodePaymentRequired, meetsLlmV1Floor, precheckLlmV1Agent, type AcceptsEntry } from "../hooks/x402Discovery"; +import { + decodePaymentRequired, + meetsLlmV1Floor, + precheckLlmV1Agent, + checkLlmV1Agent, + type AcceptsEntry, + type CheckReport, +} from "../hooks/x402Discovery"; const floorEntry: AcceptsEntry = { scheme: "batch-settlement", @@ -109,3 +116,95 @@ describe("precheckLlmV1Agent", () => { expect(res.ok).toBe(false); }); }); + +describe("checkLlmV1Agent (build-your-own-agent diagnostic)", () => { + beforeEach(() => vi.restoreAllMocks()); + + // Like precheckLlmV1Agent's mockFetch but also lets us thread ownershipProofs and + // simulate a fetch that throws (the browser CORS/network signature). + function mockFetch(handlers: { + openapi?: { status: number; body?: unknown; throws?: boolean }; + probe?: { status: number; accepts?: AcceptsEntry[] | null; header?: string | null; throws?: boolean }; + }) { + vi.stubGlobal( + "fetch", + vi.fn(async (input: string) => { + const url = String(input); + if (url.endsWith("/openapi.json")) { + const h = handlers.openapi ?? { status: 200, body: { "x-service-type": "llm/v1" } }; + if (h.throws) throw new TypeError("Failed to fetch"); + return { ok: h.status >= 200 && h.status < 300, status: h.status, json: async () => h.body ?? {} }; + } + const p = handlers.probe ?? { status: 402, accepts: [floorEntry] }; + if (p.throws) throw new TypeError("Failed to fetch"); + const headerValue = p.header !== undefined ? p.header : p.accepts ? paymentRequiredHeader(p.accepts) : null; + return { status: p.status, headers: { get: () => headerValue } }; + }) as unknown as typeof fetch, + ); + } + + const stepStatus = (r: CheckReport, id: string) => r.steps.find((s) => s.id === id)?.status; + + it("passes every step for a well-formed agent (with ownership proof)", async () => { + mockFetch({ + openapi: { + status: 200, + body: { "x-service-type": "llm/v1", "x-discovery": { ownershipProofs: ["0xsig"] } }, + }, + probe: { status: 402, accepts: [floorEntry] }, + }); + const r = await checkLlmV1Agent("https://agent.example"); + expect(r.ok).toBe(true); + expect(r.steps.every((s) => s.status === "pass")).toBe(true); + }); + + it("warns (not fails) when the ownership proof is missing but everything else passes", async () => { + mockFetch({ openapi: { status: 200, body: { "x-service-type": "llm/v1" } } }); + const r = await checkLlmV1Agent("https://agent.example"); + expect(stepStatus(r, "ownership")).toBe("warn"); + expect(r.ok).toBe(true); // warn does not fail the report + }); + + it("fails the service-type step when x-service-type is wrong, but still reports later steps", async () => { + mockFetch({ openapi: { status: 200, body: { "x-service-type": "something-else" } } }); + const r = await checkLlmV1Agent("https://agent.example"); + expect(stepStatus(r, "service-type")).toBe("fail"); + expect(stepStatus(r, "challenge")).toBe("pass"); // proves it didn't short-circuit + expect(r.ok).toBe(false); + }); + + it("flags CORS specifically when the openapi fetch throws", async () => { + mockFetch({ openapi: { status: 200, throws: true } }); + const r = await checkLlmV1Agent("https://agent.example"); + const openapi = r.steps.find((s) => s.id === "openapi"); + expect(openapi?.status).toBe("fail"); + expect(openapi?.detail).toMatch(/CORS/); + }); + + it("fails the challenge step when the probe does not return 402", async () => { + mockFetch({ probe: { status: 200 } }); + const r = await checkLlmV1Agent("https://agent.example"); + expect(stepStatus(r, "challenge")).toBe("fail"); + }); + + it("flags a missing/unexposed Payment-Required header (CORS on the 402)", async () => { + mockFetch({ probe: { status: 402, header: null } }); + const r = await checkLlmV1Agent("https://agent.example"); + const pr = r.steps.find((s) => s.id === "payment-required"); + expect(pr?.status).toBe("fail"); + expect(pr?.detail).toMatch(/CORS/); + }); + + it("fails the floor step when the 402 offers the wrong scheme/network", async () => { + mockFetch({ probe: { status: 402, accepts: [{ scheme: "exact", network: "eip155:8453" }] } }); + const r = await checkLlmV1Agent("https://agent.example"); + expect(stepStatus(r, "floor")).toBe("fail"); + }); + + it("fails cleanly on a malformed URL with a single step", async () => { + const r = await checkLlmV1Agent("not a url"); + expect(r.ok).toBe(false); + expect(r.steps).toHaveLength(1); + expect(r.steps[0].id).toBe("url"); + }); +}); From 0a50b056599570179c51b8b3d6b781bd2c32f7d9 Mon Sep 17 00:00:00 2001 From: fretchen Date: Thu, 30 Jul 2026 01:46:56 +0200 Subject: [PATCH 03/12] Nice. --- website/components/Foldable.tsx | 48 ++ website/components/ParamTable.tsx | 158 ++++++ website/hooks/useOpenApiSpec.ts | 65 +++ website/pages/agent-onboarding/+Page.tsx | 607 ++++++++++++++++------- website/public/agent-registration.json | 2 +- website/public/openapi.json | 368 -------------- website/test/ParamTable.test.tsx | 106 ++++ website/test/useOpenApiSpec.test.ts | 77 +++ 8 files changed, 888 insertions(+), 543 deletions(-) create mode 100644 website/components/Foldable.tsx create mode 100644 website/components/ParamTable.tsx create mode 100644 website/hooks/useOpenApiSpec.ts delete mode 100644 website/public/openapi.json create mode 100644 website/test/ParamTable.test.tsx create mode 100644 website/test/useOpenApiSpec.test.ts diff --git a/website/components/Foldable.tsx b/website/components/Foldable.tsx new file mode 100644 index 000000000..d28c7b5d3 --- /dev/null +++ b/website/components/Foldable.tsx @@ -0,0 +1,48 @@ +/** + * Foldable β€” a plain

    / disclosure. + * + * Used on documentation pages so a long build guide skims as a checklist but expands into + * full code snippets. No JS state: native
    keeps it accessible and SSR-safe. + */ +import React from "react"; +import { css } from "../styled-system/css"; + +const wrapper = css({ + mb: "3", + border: "1px solid token(colors.border, #e5e7eb)", + borderRadius: "md", + bg: "white", + overflow: "hidden", +}); + +const summary = css({ + px: "3", + py: "2", + fontSize: "sm", + fontWeight: "medium", + color: "indigo.700", + cursor: "pointer", + userSelect: "none", + _hover: { bg: "gray.50" }, +}); + +const body = css({ px: "3", pb: "3", pt: "1" }); + +export interface FoldableProps { + /** Summary line, e.g. "Show the code". */ + label: string; + /** Open on first render (use sparingly β€” the point is a skimmable page). */ + defaultOpen?: boolean; + children: React.ReactNode; +} + +export function Foldable({ label, defaultOpen = false, children }: FoldableProps) { + return ( +
    + {label} +
    {children}
    +
    + ); +} + +export default Foldable; diff --git a/website/components/ParamTable.tsx b/website/components/ParamTable.tsx new file mode 100644 index 000000000..ca57df7fb --- /dev/null +++ b/website/components/ParamTable.tsx @@ -0,0 +1,158 @@ +/** + * ParamTable β€” renders an API parameter reference **from an OpenAPI schema**. + * + * Spec-driven by design: there is intentionally no prop for hand-written rows. If you want an + * endpoint documented here, publish a spec for it. That keeps the docs from drifting away + * from the API (the failure mode of hand-maintained tables) and nudges every service toward + * being properly spec-described. + * + * Styling mirrors the local table recipe in `pages/x402/+Page.tsx` so that page can adopt + * this component once its service publishes a spec. + */ +import React from "react"; +import { css } from "../styled-system/css"; +import type { OpenApiSchema } from "../hooks/useOpenApiSpec"; + +const table = css({ + width: "100%", + borderCollapse: "collapse", + marginBottom: "4", + fontSize: "sm", + "& th, & td": { + padding: "8px 12px", + borderBottom: "1px solid token(colors.border, #e5e7eb)", + textAlign: "left", + verticalAlign: "top", + }, + "& th": { + fontWeight: "semibold", + backgroundColor: "token(colors.codeBg, #f9fafb)", + }, + "& tr:last-child td": { borderBottom: "none" }, +}); + +const fieldName = css({ fontFamily: "mono", fontSize: "xs", fontWeight: "medium", color: "gray.800" }); +const typeCell = css({ fontFamily: "mono", fontSize: "xs", color: "indigo.700", whiteSpace: "nowrap" }); +const requiredYes = css({ fontSize: "xs", fontWeight: "medium", color: "red.600" }); +const requiredNo = css({ fontSize: "xs", color: "gray.400" }); +const descCell = css({ fontSize: "xs", color: "gray.600", lineHeight: "1.5" }); +const nestedList = css({ mt: "1", pl: "3", borderLeft: "2px solid token(colors.border, #e5e7eb)" }); +const nestedRow = css({ fontSize: "xs", color: "gray.600", lineHeight: "1.6" }); +const enumValue = css({ fontFamily: "mono", fontSize: "xs", color: "gray.700" }); + +/** Human-readable type for a schema node, e.g. `string`, `object[]`, `integer`. */ +function typeLabel(schema: OpenApiSchema): string { + if (schema.type === "array") { + const inner = schema.items?.type ?? "any"; + return `${inner}[]`; + } + return schema.type ?? "any"; +} + +/** The nested object whose properties we should list under a row, if any. */ +function nestedProperties(schema: OpenApiSchema): { + props: Record; + required: string[]; +} | null { + const target = schema.type === "array" ? schema.items : schema; + if (!target?.properties || Object.keys(target.properties).length === 0) return null; + return { props: target.properties, required: target.required ?? [] }; +} + +function NestedFields({ schema }: { schema: OpenApiSchema }) { + const nested = nestedProperties(schema); + if (!nested) return null; + return ( +
    + {Object.entries(nested.props).map(([name, child]) => ( +
    + {name} {typeLabel(child)} + {nested.required.includes(name) && required} + {child.description ? ` β€” ${child.description}` : null} + {/* One extra level is plenty for readable docs (e.g. choices[].message.content). */} + +
    + ))} +
    + ); +} + +/** Renders one final level of nesting inline, without recursing further. */ +function NestedFieldsLeaf({ schema, required }: { schema: OpenApiSchema; required: string[] }) { + const nested = nestedProperties(schema); + if (!nested) return null; + return ( +
    + {Object.entries(nested.props).map(([name, child]) => ( +
    + {name} {typeLabel(child)} + {(nested.required.includes(name) || required.includes(name)) && ( + required + )} + {child.description ? ` β€” ${child.description}` : null} +
    + ))} +
    + ); +} + +export interface ParamTableProps { + /** An OpenAPI object schema (from `components.schemas.*`). */ + schema: OpenApiSchema | null | undefined; + /** Optional caption rendered above the table. */ + caption?: string; +} + +export function ParamTable({ schema, caption }: ParamTableProps) { + const properties = schema?.properties; + if (!properties || Object.keys(properties).length === 0) return null; + const required = schema?.required ?? []; + + return ( +
    + {caption &&

    {caption}

    } + + + + + + + + + + + {Object.entries(properties).map(([name, prop]) => ( + + + + + + + ))} + +
    FieldTypeRequiredDescription
    {name}{typeLabel(prop)} + {required.includes(name) ? ( + yes + ) : ( + no + )} + + {prop.description} + {prop.enum && prop.enum.length > 0 && ( +
    + One of:{" "} + {prop.enum.map((v, i) => ( + + {i > 0 && ", "} + {JSON.stringify(v)} + + ))} +
    + )} + +
    +
    + ); +} + +export default ParamTable; diff --git a/website/hooks/useOpenApiSpec.ts b/website/hooks/useOpenApiSpec.ts new file mode 100644 index 000000000..218b07abc --- /dev/null +++ b/website/hooks/useOpenApiSpec.ts @@ -0,0 +1,65 @@ +/** + * Fetches an OpenAPI document at runtime so documentation pages can render *from the spec* + * rather than from hand-written tables (see components/ParamTable.tsx). + * + * Runtime rather than build-time on purpose: our services patch live values into the served + * document β€” `sc_llm_x402.ts` rewrites `x-payment-info.price.max` from the current token + * pricing before serving it β€” so a bundled copy would show a stale price. + * + * Deliberately separate from `x402Discovery.ts`: that module is the payment/compatibility + * checker, this is a docs concern. + */ +import { useQuery } from "@tanstack/react-query"; + +/** A minimal JSON-Schema node β€” only the parts our docs rendering reads. */ +export interface OpenApiSchema { + type?: string; + description?: string; + required?: string[]; + enum?: unknown[]; + default?: unknown; + format?: string; + properties?: Record; + items?: OpenApiSchema; +} + +/** The slice of an OpenAPI document the docs pages use. */ +export interface OpenApiDocument { + info?: { title?: string; version?: string; description?: string }; + "x-service-type"?: string; + components?: { schemas?: Record }; + paths?: Record; +} + +export interface UseOpenApiSpecResult { + spec: OpenApiDocument | null; + isLoading: boolean; + error: string | null; +} + +async function fetchSpec(url: string): Promise { + const res = await fetch(url, { method: "GET" }); + if (!res.ok) throw new Error(`The spec at ${url} returned ${res.status}.`); + return (await res.json()) as OpenApiDocument; +} + +/** + * Fetch and cache an OpenAPI document. Never throws β€” a failure surfaces as `error` so the + * caller can degrade (show a link to the spec) instead of rendering an empty table. + */ +export function useOpenApiSpec(url: string): UseOpenApiSpecResult { + const { data, isPending, isError, error } = useQuery({ + queryKey: ["openApiSpec", url], + queryFn: () => fetchSpec(url), + staleTime: Infinity, + retry: false, + }); + + return { + spec: data ?? null, + isLoading: isPending, + error: isError ? (error instanceof Error ? error.message : "Could not load the spec.") : null, + }; +} + +export default useOpenApiSpec; diff --git a/website/pages/agent-onboarding/+Page.tsx b/website/pages/agent-onboarding/+Page.tsx index 66a074a48..f7f4e26fb 100644 --- a/website/pages/agent-onboarding/+Page.tsx +++ b/website/pages/agent-onboarding/+Page.tsx @@ -2,8 +2,12 @@ import React from "react"; import { css } from "../../styled-system/css"; import * as styles from "../../layouts/styles"; import { AgentChecker } from "../../components/AgentChecker"; +import { ParamTable } from "../../components/ParamTable"; +import { Foldable } from "../../components/Foldable"; +import { useOpenApiSpec } from "../../hooks/useOpenApiSpec"; const LLM_ORIGIN = "https://llm-agent.fretchen.eu"; +const SPEC_URL = `${LLM_ORIGIN}/openapi.json`; const GH_BLOB = "https://github.com/fretchen/fretchen.github.io/blob/main"; const X402_DOCS = "https://docs.x402.org"; @@ -19,6 +23,7 @@ const sectionCard = css({ }); const h2 = css({ fontSize: "xl", fontWeight: "semibold", mb: "3", color: "gray.800" }); +const h3 = css({ fontSize: "md", fontWeight: "semibold", mb: "2", color: "gray.800" }); const para = css({ fontSize: "sm", color: "gray.600", mb: "3", lineHeight: "1.6" }); const codeBlock = css({ bg: "gray.900", @@ -29,25 +34,53 @@ const codeBlock = css({ fontSize: "xs", lineHeight: "1.5", mt: "1", - mb: "3", + mb: "2", whiteSpace: "pre", }); const inlineCode = css({ fontFamily: "mono", fontSize: "0.9em", bg: "gray.100", px: "1", borderRadius: "sm" }); const extLink = css({ color: "indigo.600", textDecoration: "underline", _hover: { color: "indigo.800" } }); +const caption = css({ fontSize: "xs", color: "gray.500", mb: "1" }); +const note = css({ + fontSize: "xs", + color: "gray.700", + bg: "amber.50", + border: "1px solid", + borderColor: "amber.200", + borderRadius: "md", + p: "2", + mb: "3", + lineHeight: "1.6", +}); function Code({ children }: { children: string }) { return
    {children}
    ; } -/** One requirement line in the checklist. */ -function Req({ children }: { children: React.ReactNode }) { +/** A numbered build step. */ +function Step({ n, title, children }: { n: number; title: string; children: React.ReactNode }) { return ( -
  • - - ☐ - - {children} -
  • +
    +

    + + {n} + + {title} +

    + {children} +
    ); } @@ -69,10 +102,47 @@ function Challenge({ title, children }: { title: string; children: React.ReactNo ); } +/** Request/response parameter tables, rendered from the live spec. */ +function ApiReference() { + const { spec, isLoading, error } = useOpenApiSpec(SPEC_URL); + const schemas = spec?.components?.schemas; + + if (isLoading) { + return

    Loading the live spec…

    ; + } + + if (error || !schemas) { + return ( +

    + Couldn't load the live spec right now (the service scales to zero, so it may be waking up). Read it + directly at{" "} + + {SPEC_URL} + + . +

    + ); + } + + return ( +
    + + +

    + These tables are generated from the live{" "} + + openapi.json + {" "} + β€” so they can't drift from what the service actually serves. +

    +
    + ); +} + export default function Page() { return (
    -
    +
    {/* Beta banner */}
    - πŸ§ͺ Beta β€” the payment rails - behind this are new. The endpoint contract below is stable and you can verify yours with the checker on this - page, but the surrounding ecosystem is still thin (see{" "} + πŸ§ͺ Beta β€” the payment rails are + new. The endpoint contract is stable and you can verify yours with the{" "} + + checker + + , but the surrounding tooling is still thin (see{" "} Known limitations @@ -96,8 +169,8 @@ export default function Page() {
    - {/* Hero */} -
    + {/* Hero + audience + stack */} +

    πŸ€– Build your own agent

    @@ -105,38 +178,81 @@ export default function Page() { className={css({ fontSize: "lg", color: "gray.600", - maxWidth: "640px", + maxWidth: "660px", margin: "0 auto", lineHeight: "1.6", })} > - Build a chat endpoint that earns a small crypto payment per message. If it meets the requirements below, the{" "} - - assistant - {" "} - can use it β€” no account and no manual approval. Paste your URL into the{" "} - - checker - {" "} - to see if you're there. + By the end of this page you'll have an HTTP endpoint that answers OpenAI-style chat requests and + charges a fraction of a cent in USDC per message β€” no API keys, no accounts, no invoices.

    - {/* SECTION 1 β€” how payment works (plain + links out) */}
    -

    πŸ’Έ How payment works (in one paragraph)

    +

    πŸ‘€ Who this is for

    +

    + A backend developer comfortable with Node and TypeScript who already has (or can put + together) an LLM endpoint. No prior x402 or crypto-payments experience assumed β€” the one + concept you need is explained below, and everything deeper is linked out rather than re-taught here. +

    + +

    + 🧰 What you'll need +

    +
      +
    • + An OpenAI-compatible LLM to proxy β€” Mistral, an OpenAI key, a local model, anything that + speaks /chat/completions. +
    • +
    • + Node + TypeScript, and the two SDK packages{" "} + @x402/core + @x402/evm. +
    • +
    • + An EVM wallet (two keys: one to receive funds, one off-chain signer β€” explained in step + 2). +
    • +
    • + A place to store channel state β€” Redis, or a file for a single instance. The SDK ships + both. +
    • +
    • + An x402 facilitator that supports batch-settlement (a public one, or your own). +
    • +
    • + A scheduled job (cron) β€” this is how you actually collect the money. +
    • +
    +
    + + {/* SECTION β€” how payment works */} +
    +

    πŸ’Έ How the payment works

    - When someone calls your endpoint without paying, you answer with HTTP 402 Payment Required{" "} - and a header describing how to pay. Their client pays in USDC (a dollar stablecoin) and - retries. Instead of one on-chain transaction per message β€” too slow and too expensive for chat β€” payment - uses a channel: the user deposits once into an on-chain escrow, then each message is a tiny - signed IOU ("voucher"), and you redeem the accumulated vouchers on-chain later in one batch. This - channel scheme is called x402 batch-settlement. + When someone calls your endpoint without paying, you reply 402 Payment Required plus a + header describing how to pay. Their client pays in USDC (a dollar stablecoin) and retries. + One blockchain transaction per chat message would be far too slow and expensive, so payment uses a{" "} + channel: the user locks funds once in an on-chain escrow, each message is then just a tiny + signed IOU (a voucher), and you redeem the accumulated vouchers on-chain later in a single batch. + That scheme is called batch-settlement.

    + + {` client your endpoint chain + β”‚ β”‚ β”‚ + β”œβ”€β”€ POST (no payment) ─────────►│ β”‚ + │◄── 402 + how to pay ─────────── β”‚ + β”‚ β”‚ β”‚ + β”œβ”€β”€ deposit (once) ────────────►│───── escrow opened ──────►│ + │◄── 200 + reply ──────────────── β”‚ + β”‚ β”‚ β”‚ + β”œβ”€β”€ voucher + POST ────────────►│ (off-chain, per message) β”‚ + │◄── 200 + reply ──────────────── β”‚ + β”‚ … β”‚ β”‚ + β”‚ your cron job ───── claim ──────►│ πŸ’°`} +

    - You don't implement any of this yourself β€” the @x402/evm SDK does - the protocol work. This page only covers what's specific to being usable by the assistant. New to x402? - Start here: + You don't implement the protocol yourself β€” the @x402/evm SDK does + that. New to x402? These are the canonical docs:

    • @@ -146,7 +262,7 @@ export default function Page() { rel="noopener noreferrer" className={extLink} > - What the 402 challenge is β†’ + The 402 challenge β†’
    • @@ -156,7 +272,7 @@ export default function Page() { rel="noopener noreferrer" className={extLink} > - Quickstart for sellers (accept payments) β†’ + Quickstart for sellers β†’
    • @@ -166,7 +282,7 @@ export default function Page() { rel="noopener noreferrer" className={extLink} > - The batch-settlement channel scheme β†’ + The batch-settlement scheme β†’
    • @@ -176,150 +292,303 @@ export default function Page() { rel="noopener noreferrer" className={extLink} > - What a facilitator is (it submits the on-chain transactions for you) β†’ + What a facilitator does β†’
    - {/* SECTION 2 β€” the API */} + {/* SECTION β€” the API */}
    -

    πŸ“‘ The API your endpoint exposes

    +

    πŸ“‘ The API you expose

    - The request and response are the OpenAI chat-completions format β€” so it looks like any - other LLM API and you can reuse existing types. + One route: POST /, in the OpenAI chat-completions format β€” so it looks + like any other LLM API and existing types just work.

    -

    Request (a plain POST, no auth header):

    + + +
    + Two rules the schema can't express on its own: usage must be in + your response because the charge is computed from it, and{" "} + stream: true must be rejected (settlement needs the final token count, + which needs the whole reply). +
    + +

    Example request:

    {`POST / { "model": "mistral-large-latest", "messages": [{ "role": "user", "content": "Hello" }] }`} -

    - Response β€” a standard OpenAI chat.completion: -

    +

    Example response:

    {`{ "id": "chatcmpl-...", "object": "chat.completion", "model": "mistral-large-latest", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "..." }, "finish_reason": "stop" }], + "choices": [{ "index": 0, "message": { "role": "assistant", "content": "Hi!" }, "finish_reason": "stop" }], "usage": { "prompt_tokens": 10, "completion_tokens": 9, "total_tokens": 19 } }`} -
      -
    • - usage is required β€” the per-message charge is - computed from the token counts. -
    • -
    • - Streaming (stream: true) is not supported (settlement - needs the final token count, which needs the whole reply). -
    • -
    • - Reject unknown model values with{" "} - 404; only serve the model ids you advertise. -
    • -
    - - +

    + Errors use the OpenAI shape β€” {`{ error: { message, type, code } }`}{" "} + with model_not_found for an unadvertised model and{" "} + stream_unsupported for a streaming request. +

    - {/* SECTION 3 β€” requirements checklist */} + {/* SECTION β€” build it */}
    -

    βœ… Requirements

    +

    πŸ› οΈ Build it, step by step

    - Everything an endpoint must do to be usable by the assistant. The checker below tests each of these. + Each step shows the requirement and the real code from our implementation ( + + sc_llm_x402.ts + {" "} + and{" "} + + x402_server.ts + + ), trimmed for readability and with our infrastructure swapped for portable equivalents. +

    + + +

    + If you already proxy an OpenAI-compatible model, you're done with this step β€” just validate the input + and reject streaming. +

    + + {`const body = await req.json(); + +if (body.stream === true) { + return json(400, { + error: { message: "Streaming is not supported.", type: "invalid_request_error", code: "stream_unsupported" }, + }); +} +if (!Array.isArray(body.messages) || body.messages.length === 0) { + return json(400, { error: { message: "'messages' must be a non-empty array.", type: "invalid_request_error" } }); +} +if (!MODELS.includes(body.model)) { + return json(404, { + error: { message: \`Unknown model '\${body.model}'.\`, type: "invalid_request_error", code: "model_not_found" }, + }); +}`} + +
    + + +

    + Create the resource server once at startup and register the batch-settlement scheme for each network you + accept. Two keys are involved: the receiver address that funds go to, and a separate{" "} + authorizer key that signs channel configuration off-chain (it never needs funding). +

    + + {`import { x402ResourceServer, HTTPFacilitatorClient } from "@x402/core/server"; +import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/server"; +import { RedisChannelStorage } from "@x402/evm/batch-settlement/server/redis-storage"; +import { privateKeyToAccount } from "viem/accounts"; + +const NETWORKS = ["eip155:8453"]; // Base mainnet +const facilitator = new HTTPFacilitatorClient({ url: process.env.FACILITATOR_URL }); +const authorizer = privateKeyToAccount(process.env.RECEIVER_AUTHORIZER_PRIVATE_KEY); + +const resourceServer = new x402ResourceServer(facilitator); + +const scheme = new BatchSettlementEvmScheme(process.env.RECEIVER_ADDRESS, { + storage: new RedisChannelStorage({ client: redis }), // or FileChannelStorage for one instance + receiverAuthorizerSigner: { + address: authorizer.address, + signTypedData: (params) => authorizer.signTypedData(params), + }, + onchainStateTtlMs: 5_000, // keep low, or a user's first message after depositing can fail + withdrawDelay: 86_400, // must be >> your claim interval (step 5) +}); + +for (const network of NETWORKS) resourceServer.register(network, scheme);`} +

    Ours: x402_server.ts β†’ createLLMResourceServer (we use S3 for storage).

    +
    +
    + + +

    + Build the payment requirements β€” the "here's how to pay me" description β€” and return them + as a 402. +

    +
    + + Don't skip enhancePaymentRequirements. + {" "} + It injects the receiverAuthorizer and{" "} + withdrawDelay fields the client needs to build a deposit. And you must + reuse the same enhanced object when you verify (step 4) β€” a bare object gets every deposit + rejected. +
    + + {`async function paymentRequirements() { + const accepts = await Promise.all( + NETWORKS.map((network) => { + const usdc = USDC[network]; // address + EIP-712 name/version + const base = { + scheme: "batch-settlement", + network, + amount: MAX_PRICE_PER_MESSAGE, // a ceiling, in USDC atomic units (6 dp) + asset: usdc.address, + payTo: process.env.RECEIVER_ADDRESS, + maxTimeoutSeconds: 120, + extra: { name: usdc.name, version: usdc.version }, + }; + return scheme.enhancePaymentRequirements(base, { + x402Version: 2, + scheme: "batch-settlement", + network, + extra: base.extra, + }, []); + }), + ); + return { x402Version: 2, resource: { url: MY_URL, description: "chat", mimeType: "application/json" }, accepts }; +} + +// in the handler: +const payment = extractPaymentPayload(req.headers); +if (!payment) return respond402(await paymentRequirements());`} +

    Ours: x402_server.ts β†’ createBatchSettlementPaymentRequirements.

    +
    +
    + + +

    + This is the step that turns a plain endpoint into a paid one. Verify the voucher, run your inference, then + settle β€” and note the trick: you verify against a ceiling but{" "} + settle the amount actually used, so a short reply costs the user less. +

    + + {`const requirements = await paymentRequirements(); // the SAME enhanced object as the 402 + +// 1. Verify the client's voucher. +const check = await resourceServer.verifyPayment(payment, requirements); +if (!check.isValid) { + // Re-emit through the SDK so it can attach corrective channel state the client + // needs to resync β€” a hand-rolled 402 body breaks that recovery. + return respond402( + await resourceServer.createPaymentRequiredResponse([requirements], resource, check.invalidReason, + check.payer ? { payer: check.payer } : undefined, undefined, payment), + ); +} + +// 2. Do the actual work. +const completion = await callYourModel(body.messages); + +// 3. Settle what was really used (<= the ceiling verified above). +const settlement = await resourceServer.settlePayment(payment, { + ...requirements, + amount: priceFromUsage(completion.usage), +}); +if (!settlement.success) return json(402, { error: { message: "Settlement failed" } }); + +return json(200, completion, settlementHeaders(settlement));`} +

    Ours: sc_llm_x402.ts (the main handler).

    +
    +
    + + +

    + Per-message settlements are bookkeeping only β€” no funds move. A scheduled job redeems the + accumulated vouchers on-chain. Skip this and you never get paid. It's genuinely this short: +

    + + {`// Run on a schedule (we use every 12h). Must be far more often than +// the withdrawDelay you set in step 2, or a channel can be withdrawn before you claim it. +for (const network of NETWORKS) { + const manager = scheme.createChannelManager(facilitator, network); + const { claims, settle } = await manager.claimAndSettle(); + console.log({ network, claims: claims.length, settled: settle !== undefined }); +}`} +

    Ours: llm_x402_cron.ts (a 12-hourly scheduled function).

    +
    +
    + + +

    + Finally, make yourself findable. Serve an OpenAPI document at{" "} + GET /openapi.json containing{" "} + "x-service-type": "llm/v1", your{" "} + x-payment-info, and an ownership proof. And set CORS β€” the assistant + runs in a browser, so without it your 402 is invisible. +

    + + {`// openapi.json (excerpt) +{ + "openapi": "3.1.0", + "x-service-type": "llm/v1", + "servers": [{ "url": "https://your-agent.example" }], + "x-discovery": { "ownershipProofs": ["0x"] }, + "paths": { "/": { "post": { "x-payment-info": { + "protocols": ["x402"], + "price": { "mode": "dynamic", "currency": "USD", "min": "0", "max": "0.003" } + } } } }, + "components": { "schemas": { "LLMChatRequest": { /* ... */ }, "LLMChatResponse": { /* ... */ } } } +} + +// Required on every response: +"Access-Control-Allow-Origin": "*", +"Access-Control-Expose-Headers": "Payment-Required"`} + + + {`// One-off: sign your bare origin (scheme + host, no path, no trailing slash) +// and paste the signature into x-discovery.ownershipProofs. +import { privateKeyToAccount } from "viem/accounts"; + +const account = privateKeyToAccount(process.env.RECEIVER_PRIVATE_KEY); +const signature = await account.signMessage({ message: "https://your-agent.example" }); +console.log(signature);`} +

    + Ours:{" "} + + scripts/sign_ownership_proof.ts + + . +

    +
    +
    + +

    + Which facilitator? Pick one that advertises batch-settlement from{" "} + + the facilitator list + {" "} + (e.g.{" "} + + Solvador + + ) β€” or run your own.

    -
      - - Serve the OpenAI-shaped chat endpoint at POST / (above). - - - Serve a discovery document at GET /openapi.json that includes{" "} - "x-service-type": "llm/v1" (the tag that marks it - assistant-compatible), plus x-payment-info and{" "} - x-discovery.ownershipProofs. - - - On an unpaid request, return 402 whose payment options include{" "} - USDC on Base (eip155:8453) via{" "} - batch-settlement. That's the one payment method the assistant's wallet can - currently fulfil. - - - Allow the browser to read your responses: set{" "} - Access-Control-Allow-Origin: * and{" "} - Access-Control-Expose-Headers: Payment-Required. The assistant runs in - a browser, so without this it can't see your 402. - - - Use an x402 facilitator that supports batch-settlement β€” the service that submits the - on-chain deposit/claim/settle transactions. Point at a public one from{" "} - - the facilitator list - {" "} - (e.g.{" "} - - Solvador - - ), or run your own. - - - Publish an ownership proof (recommended) β€” an EIP-191 signature over your origin, so - clients can confirm you control the address they'll pay. Reusable signer:{" "} - - sign_ownership_proof.ts - - . - - - Run a recurring claim job β€” per-message vouchers are just IOUs until you batch-redeem - them on-chain (claimAndSettle()). Skip it and you never actually get - paid. - -
    - {/* SECTION 4 β€” the live checker */} + {/* SECTION β€” checker */}

    πŸ”Ž Check your endpoint

    - Paste your endpoint URL. This runs the exact checks the assistant runs β€” reachability, the discovery tag, - the 402 challenge, and the payment option β€” and reports each one. + Paste your URL. This runs exactly the checks the assistant runs before it will talk to an endpoint, and + tells you which ones fail.

    - {/* SECTION 5 β€” known limitations */} + {/* SECTION β€” known limitations */}

    ⚠️ Known limitations (beta)

    -

    - We document these openly β€” they're the rough edges of a young ecosystem, not of your code. -

    +

    Documented openly β€” these are rough edges of a young ecosystem, not of your code.

    Most public facilitators (including Coinbase's) only support the simpler{" "} @@ -327,46 +596,45 @@ export default function Page() { Solvador {" "} - and this project β€” so your facilitator options are limited today. (The scheme is standard; its EVM wire - binding is still defined by the @x402/evm code rather than a ratified - spec, which is why adoption is thin.) + and this project β€” so your options are limited today. The scheme is standard; its EVM wire binding is still + defined by the @x402/evm code rather than a ratified spec, which is why + adoption is thin. - There is no drop-in client helper for batch-settlement yet, so a caller has to hand-wire the payment + There's no drop-in client helper for batch-settlement yet, so callers must hand-wire the payment side (channel storage, deposit, voucher signing). The OpenAI-shaped body keeps it familiar to read, but a plain{" "} Authorization: Bearer request just hits the 402 and stops. - A channel processes requests one at a time β€” a concurrent second request is rejected until the first - settles, and an interrupted request can hold the lock for up to ~2 minutes. The client handles this by - waiting and retrying. + A channel processes requests serially β€” a concurrent second request is rejected until the first settles, and + an interrupted request can hold the lock for up to ~2 minutes. Clients handle it by waiting and retrying. - The payment option must be on Base (eip155:8453). Optimism is currently - blocked by a gap in the @x402/evm stablecoin registry, even though the - contract is deployed there. + Your payment option must be on Base (eip155:8453). Optimism is blocked + by a gap in the @x402/evm stablecoin registry, even though the contract + is deployed there. - - Your channel's withdraw delay must stay well above how often you run the claim job, or a channel can - become withdrawable before you claim it β€” losing you earned revenue. The reference uses a 24h delay against - a 12h job. + + Your withdrawDelay must stay well above how often your claim job runs, + or a channel can become withdrawable before you claim it β€” losing you earned revenue. We use a 24h delay + against a 12h job.
    - {/* SECTION 6 β€” contact */} + {/* SECTION β€” contact */}

    🧭 Where this is going

    - The endpoint contract is stable; what's still filling in is the ecosystem around it β€” a drop-in client, - more facilitators, more agents. Until then the pool of compatible third-party agents is small, which is why - the assistant doesn't yet let you pick one from a list. + The endpoint contract is stable; what's filling in is the ecosystem around it β€” a drop-in client, more + facilitators, more agents. Until then the pool of compatible third-party agents is small, which is why the + assistant doesn't yet let you pick one from a list.

    - Building one, or want to be listed when a picker ships? Reach out at{" "} + Built one, or want to be listed when a picker ships? Reach out at{" "} fretchen.dev@proton.me {" "} @@ -379,18 +647,9 @@ export default function Page() { > GitHub - . The reference implementation and full spec live in{" "} + . The full reference implementation lives in{" "} scw_js/README.md - {" "} - and{" "} - - openapi.llm.json .

    diff --git a/website/public/agent-registration.json b/website/public/agent-registration.json index 206eb539d..7798fa654 100644 --- a/website/public/agent-registration.json +++ b/website/public/agent-registration.json @@ -6,7 +6,7 @@ "endpoints": [ { "name": "OpenAPI", - "endpoint": "/openapi.json", + "endpoint": "https://imagegen-agent.fretchen.eu/openapi.json", "version": "3.1.0" }, { diff --git a/website/public/openapi.json b/website/public/openapi.json deleted file mode 100644 index 50630ac5d..000000000 --- a/website/public/openapi.json +++ /dev/null @@ -1,368 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "title": "Fretchen AI Services", - "description": "AI image generation and LLM services with blockchain integration on Optimism L2", - "version": "1.0.0", - "contact": { - "name": "fretchen", - "url": "https://www.fretchen.eu" - }, - "license": { - "name": "MIT", - "url": "https://opensource.org/licenses/MIT" - } - }, - "servers": [ - { - "url": "https://my-personal-js-cloudfr-parzqjl2-genimgbfl.functions.fnc.fr-par.scw.cloud", - "description": "Image Generation Service (Scaleway)" - }, - { - "url": "https://my-personal-js-cloudfr-parzqjl2-llm.functions.fnc.fr-par.scw.cloud", - "description": "LLM Service (Scaleway)" - } - ], - "paths": { - "/": { - "post": { - "operationId": "generateNftImage", - "summary": "Generate AI Image and Update NFT", - "description": "Generates an AI image using Black Forest Labs (BFL) API, uploads to S3, and updates the NFT token URI on Optimism. Requires the token to exist and not have an image yet (for generate mode).", - "tags": ["Image Generation"], - "x-server": "https://my-personal-js-cloudfr-parzqjl2-genimgbfl.functions.fnc.fr-par.scw.cloud", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ImageGenerationRequest" - }, - "examples": { - "generate": { - "summary": "Generate new image", - "value": { - "prompt": "A beautiful sunset over mountains", - "tokenId": 42, - "mode": "generate", - "size": "1024x1024" - } - }, - "edit": { - "summary": "Edit existing image", - "value": { - "prompt": "Add a castle on the hill", - "tokenId": 42, - "mode": "edit", - "size": "1024x1024", - "referenceImage": "base64-encoded-image-data" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Image generated and NFT updated successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ImageGenerationResponse" - } - } - } - }, - "400": { - "description": "Bad request - missing parameters or image already updated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Token does not exist", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "x-blockchain": { - "chain": "optimism", - "chainId": 10, - "contract": "0x80f95d330417a4acEfEA415FE9eE28db7A0A1Cdb", - "method": "requestImageUpdate" - } - } - }, - "/llm": { - "post": { - "operationId": "chatWithLLM", - "summary": "Chat with LLM using wallet authentication", - "description": "Sends a prompt to the LLM and returns a response. Requires wallet signature for authentication and deducts from user's prepaid balance. Usage is tracked via Merkle-tree for on-chain verification.", - "tags": ["LLM"], - "x-server": "https://my-personal-js-cloudfr-parzqjl2-llm.functions.fnc.fr-par.scw.cloud", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LLMRequest" - }, - "examples": { - "basic": { - "summary": "Basic LLM request", - "value": { - "data": { - "prompt": "What is the capital of France?" - }, - "auth": { - "address": "0x1234567890abcdef1234567890abcdef12345678", - "signature": "0x...", - "message": "Sign this message to authenticate" - } - } - } - } - } - } - }, - "responses": { - "200": { - "description": "LLM response generated successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LLMResponse" - } - } - } - }, - "400": { - "description": "Bad request - missing parameters or invalid signature", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "402": { - "description": "Insufficient balance - user needs to deposit more ETH", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "x-blockchain": { - "chain": "optimism", - "chainId": 10, - "contract": "0x833F39D6e67390324796f861990ce9B7cf9F5dE1", - "method": "updateLeaf" - } - } - } - }, - "components": { - "schemas": { - "ImageGenerationRequest": { - "type": "object", - "required": ["prompt", "tokenId"], - "properties": { - "prompt": { - "type": "string", - "description": "The text prompt for AI image generation", - "example": "A beautiful sunset over mountains" - }, - "tokenId": { - "type": "integer", - "description": "The NFT token ID to update", - "example": 42 - }, - "mode": { - "type": "string", - "enum": ["generate", "edit"], - "default": "generate", - "description": "generate: create new image, edit: modify existing image" - }, - "size": { - "type": "string", - "enum": ["1024x1024", "1792x1024"], - "default": "1024x1024", - "description": "Output image dimensions" - }, - "referenceImage": { - "type": "string", - "format": "byte", - "description": "Base64-encoded reference image (required for edit mode)" - } - } - }, - "ImageGenerationResponse": { - "type": "object", - "properties": { - "metadata_url": { - "type": "string", - "format": "uri", - "description": "URL to the NFT metadata JSON" - }, - "image_url": { - "type": "string", - "format": "uri", - "description": "URL to the generated image" - }, - "size": { - "type": "string", - "description": "Image dimensions used" - }, - "mintPrice": { - "type": "string", - "description": "Mint price in wei" - }, - "message": { - "type": "string", - "description": "Human-readable status message" - }, - "transaction_hash": { - "type": "string", - "pattern": "^0x[a-fA-F0-9]{64}$", - "description": "Blockchain transaction hash" - } - } - }, - "LLMRequest": { - "type": "object", - "required": ["data", "auth"], - "properties": { - "data": { - "type": "object", - "required": ["prompt"], - "properties": { - "prompt": { - "type": "string", - "description": "The prompt to send to the LLM" - }, - "useDummyData": { - "type": "boolean", - "default": false, - "description": "Use dummy data for testing" - } - } - }, - "auth": { - "$ref": "#/components/schemas/WalletAuth" - } - } - }, - "WalletAuth": { - "type": "object", - "required": ["address", "signature", "message"], - "properties": { - "address": { - "type": "string", - "pattern": "^0x[a-fA-F0-9]{40}$", - "description": "Ethereum wallet address" - }, - "signature": { - "type": "string", - "description": "EIP-191 signature of the message" - }, - "message": { - "type": "string", - "description": "The signed message" - } - }, - "description": "Wallet signature authentication for LLM access" - }, - "LLMResponse": { - "type": "object", - "properties": { - "response": { - "type": "string", - "description": "The LLM's response text" - }, - "leaf": { - "type": "string", - "pattern": "^0x[a-fA-F0-9]{64}$", - "description": "Merkle leaf hash for usage verification" - }, - "remainingBalance": { - "type": "string", - "description": "User's remaining balance in wei" - }, - "cost": { - "type": "string", - "description": "Cost of this request in wei" - } - } - }, - "ErrorResponse": { - "type": "object", - "properties": { - "error": { - "type": "string", - "description": "Error message" - }, - "mintPrice": { - "type": "string", - "description": "Mint price in wei (included in some errors)" - } - } - } - }, - "securitySchemes": { - "walletSignature": { - "type": "apiKey", - "in": "header", - "name": "X-Wallet-Signature", - "description": "EIP-191 signed message for authentication" - } - } - }, - "tags": [ - { - "name": "Image Generation", - "description": "AI-powered image generation with NFT minting on Optimism" - }, - { - "name": "LLM", - "description": "Blockchain-authenticated LLM service with Merkle-tree usage tracking" - } - ], - "externalDocs": { - "description": "Full documentation and source code", - "url": "https://github.com/fretchen/fretchen.github.io" - } -} diff --git a/website/test/ParamTable.test.tsx b/website/test/ParamTable.test.tsx new file mode 100644 index 000000000..2b59c9ff7 --- /dev/null +++ b/website/test/ParamTable.test.tsx @@ -0,0 +1,106 @@ +/** + * ParamTable tests β€” the component is spec-driven, so these assert it faithfully reflects an + * OpenAPI schema: one row per property, required-ness from the schema's `required[]`, enum + * values surfaced, nested array/object fields listed, and malformed input rendering nothing + * instead of throwing. + * + * The schemas here mirror the real shapes served at llm-agent.fretchen.eu/openapi.json + * (LLMChatRequest / LLMChatResponse), including the response's *absent* `required` array. + */ +import React from "react"; +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ParamTable } from "../components/ParamTable"; +import type { OpenApiSchema } from "../hooks/useOpenApiSpec"; + +const requestSchema: OpenApiSchema = { + type: "object", + required: ["model", "messages"], + properties: { + model: { + type: "string", + enum: ["mistral-large-latest"], + description: "The model to use.", + }, + messages: { + type: "array", + description: "The conversation so far.", + items: { + type: "object", + required: ["role", "content"], + properties: { + role: { type: "string", enum: ["system", "user", "assistant"], description: "The speaker's role." }, + content: { type: "string", description: "The message text." }, + }, + }, + }, + useDummyData: { type: "boolean", description: "Vendor extension." }, + }, +}; + +describe("ParamTable", () => { + it("renders one row per property with its description", () => { + render(); + expect(screen.getByText("model")).toBeTruthy(); + expect(screen.getByText("messages")).toBeTruthy(); + expect(screen.getByText("useDummyData")).toBeTruthy(); + expect(screen.getByText("The model to use.")).toBeTruthy(); + }); + + it("marks required fields from the schema's required[] and non-required ones as not", () => { + render(); + // model + messages are required, useDummyData is not. + expect(screen.getAllByText("yes")).toHaveLength(2); + expect(screen.getAllByText("no")).toHaveLength(1); + }); + + it("surfaces enum values", () => { + render(); + expect(screen.getByText('"mistral-large-latest"')).toBeTruthy(); + }); + + it("renders an array of objects as `type[]` and lists the nested fields", () => { + render(); + expect(screen.getByText("object[]")).toBeTruthy(); + // Nested item fields appear under the messages row. + expect(screen.getByText("role")).toBeTruthy(); + expect(screen.getByText("content")).toBeTruthy(); + }); + + it("handles a schema with no required array (nothing marked required)", () => { + const responseSchema: OpenApiSchema = { + type: "object", + properties: { + id: { type: "string" }, + usage: { + type: "object", + properties: { + prompt_tokens: { type: "integer", description: "Input tokens." }, + completion_tokens: { type: "integer" }, + }, + }, + }, + }; + render(); + expect(screen.getByText("id")).toBeTruthy(); + expect(screen.queryAllByText("yes")).toHaveLength(0); + // Nested object fields still render. + expect(screen.getByText("prompt_tokens")).toBeTruthy(); + }); + + it("renders the caption when given", () => { + render(); + expect(screen.getByText("Request body")).toBeTruthy(); + }); + + it("renders nothing (no throw) for null, undefined, or an empty schema", () => { + const { container: a } = render(); + expect(a.querySelector("table")).toBeNull(); + + const { container: b } = render(); + expect(b.querySelector("table")).toBeNull(); + + const { container: c } = render(); + expect(c.querySelector("table")).toBeNull(); + }); +}); diff --git a/website/test/useOpenApiSpec.test.ts b/website/test/useOpenApiSpec.test.ts new file mode 100644 index 000000000..57bb4e78a --- /dev/null +++ b/website/test/useOpenApiSpec.test.ts @@ -0,0 +1,77 @@ +/** + * useOpenApiSpec tests β€” the docs pages render from a live spec fetched at runtime, so the + * hook must degrade cleanly: a network failure, an HTTP error, or a non-JSON body all have to + * surface as `error` (never throw), so the page can show a link to the spec instead of an + * empty table. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { waitFor } from "@testing-library/react"; +import { renderHookWithQuery } from "./testUtils"; +import { useOpenApiSpec } from "../hooks/useOpenApiSpec"; + +const SPEC_URL = "https://agent.example/openapi.json"; + +describe("useOpenApiSpec", () => { + beforeEach(() => vi.restoreAllMocks()); + + it("returns the parsed spec on success", async () => { + const doc = { "x-service-type": "llm/v1", components: { schemas: { A: { type: "object" } } } }; + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, status: 200, json: async () => doc })) as unknown as typeof fetch, + ); + + const { result } = renderHookWithQuery(() => useOpenApiSpec(SPEC_URL)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.spec).toEqual(doc); + expect(result.current.error).toBeNull(); + }); + + it("surfaces an error for a non-OK response", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, status: 503, json: async () => ({}) })) as unknown as typeof fetch, + ); + + const { result } = renderHookWithQuery(() => useOpenApiSpec(SPEC_URL)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.spec).toBeNull(); + expect(result.current.error).toMatch(/503/); + }); + + it("surfaces an error when the fetch throws (offline / CORS)", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new TypeError("Failed to fetch"); + }) as unknown as typeof fetch, + ); + + const { result } = renderHookWithQuery(() => useOpenApiSpec(SPEC_URL)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.spec).toBeNull(); + expect(result.current.error).toBeTruthy(); + }); + + it("surfaces an error when the body is not JSON", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => { + throw new SyntaxError("Unexpected token < in JSON"); + }, + })) as unknown as typeof fetch, + ); + + const { result } = renderHookWithQuery(() => useOpenApiSpec(SPEC_URL)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.spec).toBeNull(); + expect(result.current.error).toBeTruthy(); + }); +}); From 163ed170e6724c1d3c5cca323508170dd56af577 Mon Sep 17 00:00:00 2001 From: fretchen Date: Thu, 30 Jul 2026 17:18:20 +0200 Subject: [PATCH 04/12] Better. Much better --- website/components/AssistantChat.tsx | 78 +++++++-- website/pages/agent-onboarding/+Page.tsx | 196 ++++++++++++++++++++--- website/test/AssistantChat.test.tsx | 68 ++++++++ 3 files changed, 308 insertions(+), 34 deletions(-) diff --git a/website/components/AssistantChat.tsx b/website/components/AssistantChat.tsx index 97c1e7a6b..822238ce9 100644 --- a/website/components/AssistantChat.tsx +++ b/website/components/AssistantChat.tsx @@ -7,6 +7,7 @@ import React, { useState, useMemo, useEffect } from "react"; import { AgentInfoPanel } from "./AgentInfoPanel"; +import { AgentSelector } from "./AgentSelector"; import * as styles from "../layouts/styles"; import { useLocale } from "../hooks/useLocale"; import { useUmami } from "../hooks/useUmami"; @@ -14,14 +15,13 @@ import { css } from "../styled-system/css"; import { useWalletConnection } from "../hooks/useWalletConnection"; import { useAutoNetwork } from "../hooks/useAutoNetwork"; import { useX402Chat, DEFAULT_LLM_AGENT_URL } from "../hooks/useX402Chat"; -import { fetchAgentCard, type AgentCard } from "../hooks/x402Discovery"; +import { fetchAgentCard, precheckLlmV1Agent, type AgentCard } from "../hooks/x402Discovery"; import { getViemChain } from "@fretchen/chain-utils"; -// The multi-agent picker / custom-URL escape hatch (AgentSelector) is intentionally NOT -// rendered yet β€” see open_agent_platform_plan.md. Today the only llm/v1-compatible agent is -// ours, so a "use a different agent" box would point at an empty room. AgentSelector.tsx, -// x402Discovery's precheckLlmV1Agent, and useX402Chat's agentUrl param are kept ready for -// when llm/v2 (OpenAI chat shape) makes third-party agents actually compatible. +// The custom-URL escape hatch (AgentSelector) lets the chat pay any llm/v1 agent. It is also +// the only ready-made batch-settlement client there is, so it doubles as the end-to-end test +// for anyone following the build guide at /agent-onboarding. A curated picker (rather than a +// URL box) waits until there are enough compatible agents to be worth listing. interface ChatMessage { role: "user" | "assistant"; @@ -80,7 +80,15 @@ export function AssistantChat() { const { isConnected, connectWallet } = useWalletConnection(); const { network, switchIfNeeded, switchError } = useAutoNetwork(CHAT_NETWORKS); - const { sendMessage: payAndSend, paymentReceipt } = useX402Chat(network); + // A custom agent, once one has been pre-checked and accepted. Null = the default agent. + const [customUrl, setCustomUrl] = useState(null); + const [customCard, setCustomCard] = useState(null); + const [customUrlInput, setCustomUrlInput] = useState(""); + const [checkState, setCheckState] = useState<"idle" | "checking" | "error">("idle"); + const [checkError, setCheckError] = useState(null); + + const agentUrl = customUrl ?? DEFAULT_LLM_AGENT_URL; + const { sendMessage: payAndSend, paymentReceipt } = useX402Chat(network, agentUrl); // Provenance of the agent actually serving this chat (operator + payTo + origin), read // live from its own /openapi.json + 402 so the sidebar can honestly show who the user pays. @@ -95,6 +103,36 @@ export function AssistantChat() { }; }, []); + // The card shown (and paid) is the custom agent's whenever one is selected. + const activeCard = customCard ?? agentCard; + + const tryCustomAgent = async () => { + const url = customUrlInput.trim(); + if (!url) return; + setCheckState("checking"); + setCheckError(null); + const result = await precheckLlmV1Agent(url); + if (!result.ok) { + setCheckState("error"); + setCheckError(result.reason ?? "This agent is not compatible."); + return; + } + setCheckState("idle"); + setCustomUrl(url); + setCustomCard(result.card ?? null); + setMessages([]); + trackEvent("assistant-v2-custom-agent-selected"); + }; + + const useDefaultAgent = () => { + setCustomUrl(null); + setCustomCard(null); + setCustomUrlInput(""); + setCheckState("idle"); + setCheckError(null); + setMessages([]); + }; + const buttonState = useMemo(() => { if (!isConnected) return "connect"; if (isLoading) return "loading"; @@ -207,7 +245,16 @@ export function AssistantChat() { {/* Agent Info Section */}

    Agent

    - + + void tryCustomAgent()} + onUseDefaultAgent={useDefaultAgent} + />
    )} @@ -291,7 +338,20 @@ export function AssistantChat() {
    {/* Agent Info - Mobile Footer */} - {isMobile && } + {isMobile && ( + <> + + void tryCustomAgent()} + onUseDefaultAgent={useDefaultAgent} + /> + + )}
    diff --git a/website/pages/agent-onboarding/+Page.tsx b/website/pages/agent-onboarding/+Page.tsx index f7f4e26fb..1c86e3fe2 100644 --- a/website/pages/agent-onboarding/+Page.tsx +++ b/website/pages/agent-onboarding/+Page.tsx @@ -2,6 +2,7 @@ import React from "react"; import { css } from "../../styled-system/css"; import * as styles from "../../layouts/styles"; import { AgentChecker } from "../../components/AgentChecker"; +import { CommentsSection } from "../../components/CommentsSection"; import { ParamTable } from "../../components/ParamTable"; import { Foldable } from "../../components/Foldable"; import { useOpenApiSpec } from "../../hooks/useOpenApiSpec"; @@ -205,8 +206,10 @@ export default function Page() { speaks /chat/completions.
  • - Node + TypeScript, and the two SDK packages{" "} - @x402/core + @x402/evm. + Node + TypeScript, and the two SDK packages:{" "} + npm install @x402/core @x402/evm. The snippets below target{" "} + v2.19 β€” batch-settlement is young and its APIs still move between minor versions, so pin + what you test against.
  • An EVM wallet (two keys: one to receive funds, one off-chain signer β€” explained in step @@ -383,6 +386,34 @@ if (!MODELS.includes(body.model)) { accept. Two keys are involved: the receiver address that funds go to, and a separate{" "} authorizer key that signs channel configuration off-chain (it never needs funding).

    +

    + For FACILITATOR_URL you can use ours:{" "} + https://facilitator.fretchen.eu β€” it supports batch-settlement on + Optimism and Base, charges a flat 0.01 USDC per settlement, and needs a one-time USDC approval from your + receiver wallet (details and the approval widget are on{" "} + + the x402 page + + ). Alternatives: any facilitator advertising batch-settlement in the{" "} + + facilitator list + {" "} + (e.g.{" "} + + Solvador + + ), or run your own. +

    {`import { x402ResourceServer, HTTPFacilitatorClient } from "@x402/core/server"; import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/server"; @@ -413,8 +444,19 @@ for (const network of NETWORKS) resourceServer.register(network, scheme);`}

    Build the payment requirements β€” the "here's how to pay me" description β€” and return them - as a 402. + as a 402. The SDK verifies and settles for you, but the HTTP transport β€” which headers, + encoded how β€” is yours to write. It's three ~15-line helpers, shown in this step and the next.

    +

    The USDC constants you'll need:

    + {`const USDC = { + "eip155:8453": { address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", name: "USD Coin", version: "2" }, // Base + "eip155:84532": { address: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", name: "USDC", version: "2" }, // Base Sepolia +};`} +
    + The EIP-712 domain name is "USD Coin" on mainnet but{" "} + "USDC" on testnet. Mix them up and payment verification + fails silently, with no useful error. +
    Don't skip enhancePaymentRequirements. @@ -454,6 +496,25 @@ const payment = extractPaymentPayload(req.headers); if (!payment) return respond402(await paymentRequirements());`}

    Ours: x402_server.ts β†’ createBatchSettlementPaymentRequirements.

    + + {`// The 402 body must ALSO go, base64-encoded, into the Payment-Required header β€” +// browser clients read the header, not the body. And that header must be CORS-exposed, +// or a browser client can't see it at all. +function respond402(paymentRequirements) { + return { + statusCode: 402, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Expose-Headers": "Payment-Required", + "Content-Type": "application/json", + "Payment-Required": Buffer.from(JSON.stringify(paymentRequirements)).toString("base64"), + }, + body: JSON.stringify(paymentRequirements), + }; +}`} +

    Ours: x402_server.ts β†’ create402Response.

    +
    @@ -489,6 +550,44 @@ if (!settlement.success) return json(402, { error: { message: "Settlement failed return json(200, completion, settlementHeaders(settlement));`}

    Ours: sc_llm_x402.ts (the main handler).

    + + {`// The payment arrives base64-encoded in the PAYMENT-SIGNATURE header. +function extractPaymentPayload(headers) { + const header = headers["payment-signature"] ?? headers["Payment-Signature"]; + if (!header) return null; + try { + return JSON.parse(Buffer.from(header, "base64").toString("utf-8")); + } catch { + return null; + } +} + +// The settlement receipt goes back base64-encoded in the Payment-Response header +// (merge these into your 200 response's headers). +function settlementHeaders(settlement) { + return { "Payment-Response": Buffer.from(JSON.stringify(settlement)).toString("base64") }; +}`} +

    Ours: x402_server.ts β†’ extractPaymentPayload / createSettlementHeaders.

    +
    + + {`// Rates are quoted per 1,000,000 tokens; USDC has 6 decimals β€” the two 1e6 +// factors cancel exactly, so no separate decimals conversion is needed. +// Keep rates as integer fractions (num/den) to stay exact in bigint math. +const INPUT_PER_M = { num: 1n, den: 2n }; // $0.50 per 1M prompt tokens +const OUTPUT_PER_M = { num: 3n, den: 2n }; // $1.50 per 1M completion tokens + +function priceFromUsage(usage) { + const p = BigInt(usage.prompt_tokens); + const c = BigInt(usage.completion_tokens); + const cost = + (p * INPUT_PER_M.num * OUTPUT_PER_M.den + c * OUTPUT_PER_M.num * INPUT_PER_M.den) / + (INPUT_PER_M.den * OUTPUT_PER_M.den); + // Never settle above the ceiling you verified against in the 402. + const max = BigInt(MAX_PRICE_PER_MESSAGE); + return (cost > max ? max : cost).toString(); +}`} +

    Ours: llm_service.ts β†’ convertTokensToUsdcCost.

    +
    @@ -514,7 +613,10 @@ for (const network of NETWORKS) { GET /openapi.json containing{" "} "x-service-type": "llm/v1", your{" "} x-payment-info, and an ownership proof. And set CORS β€” the assistant - runs in a browser, so without it your 402 is invisible. + runs in a browser, so without it your 402 is invisible. One consistency rule: the model{" "} + enum in your published schema must match the{" "} + MODELS array your handler validates against (step 1) β€” the spec is a + promise, the validation enforces it.

    {`// openapi.json (excerpt) @@ -533,6 +635,15 @@ for (const network of NETWORKS) { // Required on every response: "Access-Control-Allow-Origin": "*", "Access-Control-Expose-Headers": "Payment-Required"`} +

    And the route that serves it:

    + {`if (req.method === "GET" && req.path.replace(/^\\/+/, "") === "openapi.json") { + // Patch the live ceiling in rather than let the static file drift from what + // your 402 actually charges. + const spec = structuredClone(openapiSpec); + spec.paths["/"].post["x-payment-info"].price.max = formatUsdcAsDecimal(MAX_PRICE_PER_MESSAGE); + return json(200, spec, { "Access-Control-Allow-Origin": "*" }); +}`} +

    Ours: sc_llm_x402.ts (the openapi.json branch of the handler).

    {`// One-off: sign your bare origin (scheme + host, no path, no trailing slash) @@ -556,22 +667,43 @@ console.log(signature);`}

    +
    -

    - Which facilitator? Pick one that advertises batch-settlement from{" "} - - the facilitator list - {" "} - (e.g.{" "} - - Solvador + {/* SECTION β€” test it */} +

    +

    πŸ§ͺ Test it

    +

    + Start on Base Sepolia (eip155:84532) β€” same code path, + no real money. Fund your test wallet from the{" "} + + Circle faucet - ) β€” or run your own. + , and use the testnet USDC constants from step 3. +

    +

    + While developing, the checker below can point straight at{" "} + http://localhost:3000 β€” browsers treat localhost as a secure context, so + a page on https can still reach it (current Chrome and Firefox). Just remember your CORS headers apply + locally too. +

    +
    + Expect one red step on testnet. The compatibility floor requires a payment option on Base{" "} + mainnet, so the "meets the floor" check stays red until you add{" "} + eip155:8453. Everything above it β€” discovery, service type, the 402 + challenge β€” should already be green. +
    +

    + The end-to-end test: pay yourself. Once the checker passes on mainnet, open the{" "} + + assistant + + , open Use a different agent, paste your URL, and send one real message. That exercises the whole + path β€” deposit, voucher, verify, settle β€” from a real client. Being honest: this is currently the only + ready-made batch-settlement client there is (see{" "} + + Known limitations + + ).

    @@ -579,8 +711,9 @@ console.log(signature);`}

    πŸ”Ž Check your endpoint

    - Paste your URL. This runs exactly the checks the assistant runs before it will talk to an endpoint, and - tells you which ones fail. + Paste your URL. This runs exactly the checks the assistant runs before it will talk to an endpoint β€” down to + reading the base64 Payment-Required header from step 3 β€” and tells you + which ones fail.

    @@ -596,9 +729,13 @@ console.log(signature);`} Solvador {" "} - and this project β€” so your options are limited today. The scheme is standard; its EVM wire binding is still - defined by the @x402/evm code rather than a ratified spec, which is why - adoption is thin. + and{" "} + + ours + {" "} + β€” so your options are limited today. The scheme is standard; its EVM wire binding is still defined by the{" "} + @x402/evm code rather than a ratified spec, which is why adoption is + thin. @@ -630,8 +767,8 @@ console.log(signature);`}

    🧭 Where this is going

    The endpoint contract is stable; what's filling in is the ecosystem around it β€” a drop-in client, more - facilitators, more agents. Until then the pool of compatible third-party agents is small, which is why the - assistant doesn't yet let you pick one from a list. + facilitators, more agents. The assistant already lets you point it at any compatible agent by URL; a curated + picker only makes sense once there are enough of them to list.

    Built one, or want to be listed when a picker ships? Reach out at{" "} @@ -654,6 +791,15 @@ console.log(signature);`} .

    + + {/* SECTION β€” feedback */} +
    +

    πŸ’¬ Feedback

    +

    + Stuck on a step, or built one? Leave a note β€” it helps the next builder as much as it helps us. +

    + +
    ); diff --git a/website/test/AssistantChat.test.tsx b/website/test/AssistantChat.test.tsx index 5b13a598c..5e3512482 100644 --- a/website/test/AssistantChat.test.tsx +++ b/website/test/AssistantChat.test.tsx @@ -29,6 +29,7 @@ vi.mock("../hooks/useX402Chat", () => ({ vi.mock("../hooks/x402Discovery", () => ({ fetchAgentCard: vi.fn(() => Promise.resolve(null)), + precheckLlmV1Agent: vi.fn(() => Promise.resolve({ ok: false, reason: "nope" })), })); vi.mock("../hooks/useWalletConnection", () => ({ @@ -58,6 +59,7 @@ vi.mock("../components/AgentInfoPanel", () => ({ })); import { AssistantChat } from "../components/AssistantChat"; +import { precheckLlmV1Agent } from "../hooks/x402Discovery"; import { useX402Chat } from "../hooks/useX402Chat"; import { useWalletConnection } from "../hooks/useWalletConnection"; import { useAutoNetwork } from "../hooks/useAutoNetwork"; @@ -184,4 +186,70 @@ describe("AssistantChat", () => { expect(screen.queryByRole("link", { name: /assistent\.viewPayment/ })).not.toBeInTheDocument(); }); + + /** + * The custom-agent escape hatch. It is also the only ready-made batch-settlement client, + * so builders following /agent-onboarding use it to pay their own agent end-to-end β€” + * which makes "the pasted URL is what actually gets paid" the load-bearing assertion here. + */ + describe("custom agent selection", () => { + const CUSTOM_URL = "https://another-agent.example"; + const CUSTOM_CARD = { + origin: CUSTOM_URL, + title: "Another agent", + operator: "Someone Else", + contactUrl: null, + payTo: "0xabcdef0123456789abcdef0123456789abcdef01", + network: "eip155:8453", + }; + + function pasteAndTry(url: string) { + fireEvent.change(screen.getByPlaceholderText("https://another-agent.example"), { target: { value: url } }); + fireEvent.click(screen.getByRole("button", { name: "Use this agent" })); + } + + it("renders the selector and pays the default agent until one is chosen", () => { + render(); + + expect(screen.getByPlaceholderText("https://another-agent.example")).toBeInTheDocument(); + expect(useX402Chat).toHaveBeenCalledWith("eip155:8453", "https://llm-agent.fretchen.eu"); + }); + + it("pre-checks a pasted URL and then pays that agent instead", async () => { + vi.mocked(precheckLlmV1Agent).mockResolvedValue({ ok: true, card: CUSTOM_CARD }); + + render(); + pasteAndTry(CUSTOM_URL); + + await waitFor(() => expect(precheckLlmV1Agent).toHaveBeenCalledWith(CUSTOM_URL)); + await waitFor(() => expect(useX402Chat).toHaveBeenLastCalledWith("eip155:8453", CUSTOM_URL)); + // Provenance of who is about to be paid. + expect(screen.getByText("Someone Else")).toBeInTheDocument(); + }); + + it("shows the reason and keeps the default agent when the pre-check fails", async () => { + vi.mocked(precheckLlmV1Agent).mockResolvedValue({ + ok: false, + reason: "Expected a 402 payment challenge, got 200.", + }); + + render(); + pasteAndTry("https://not-an-agent.example"); + + expect(await screen.findByText(/Expected a 402 payment challenge/)).toBeInTheDocument(); + expect(useX402Chat).not.toHaveBeenCalledWith(expect.anything(), "https://not-an-agent.example"); + }); + + it("returns to the default agent", async () => { + vi.mocked(precheckLlmV1Agent).mockResolvedValue({ ok: true, card: CUSTOM_CARD }); + + render(); + pasteAndTry(CUSTOM_URL); + + const back = await screen.findByRole("button", { name: "Back to default agent" }); + fireEvent.click(back); + + await waitFor(() => expect(useX402Chat).toHaveBeenLastCalledWith("eip155:8453", "https://llm-agent.fretchen.eu")); + }); + }); }); From 4903ca2de73caf30c181304cb78da061b0018f3d Mon Sep 17 00:00:00 2001 From: fretchen Date: Thu, 30 Jul 2026 18:17:49 +0200 Subject: [PATCH 05/12] clean it up --- website/components/CodeBlock.tsx | 115 +++++ website/package-lock.json | 10 + website/package.json | 1 + website/pages/agent-onboarding/+Page.tsx | 508 +++++++++++++++-------- 4 files changed, 467 insertions(+), 167 deletions(-) create mode 100644 website/components/CodeBlock.tsx diff --git a/website/components/CodeBlock.tsx b/website/components/CodeBlock.tsx new file mode 100644 index 000000000..1f31dac18 --- /dev/null +++ b/website/components/CodeBlock.tsx @@ -0,0 +1,115 @@ +/** + * CodeBlock β€” the shared docs code block: syntax-highlighted, copyable. + * + * Highlighting runs through highlight.js's *core* build with only the three languages we + * actually use registered. The full `highlight.js` bundle registers ~190 grammars and would + * push a client chunk past the 700 kB ceiling enforced by utils/checkChunkSizes.ts β€” never + * import the default entry point here. + * + * Highlighting is synchronous, so it also runs during SSR: the server-rendered HTML already + * carries the .hljs-* markup. That avoids both a flash of unhighlighted code and a hydration + * mismatch, which is why this is preferred over an async highlighter for these pages. + * + * Visual treatment matches the dark blocks on pages/x402 (#1e1e1e / sm type), so that page + * can adopt this component later without any visual change. + */ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import hljs from "highlight.js/lib/core"; +import typescript from "highlight.js/lib/languages/typescript"; +import json from "highlight.js/lib/languages/json"; +import bash from "highlight.js/lib/languages/bash"; +import "highlight.js/styles/vs2015.css"; +import { css } from "../styled-system/css"; + +hljs.registerLanguage("typescript", typescript); +hljs.registerLanguage("json", json); +hljs.registerLanguage("bash", bash); + +/** `plaintext` opts out of highlighting β€” use it for terminal transcripts and annotated output. */ +export type CodeLang = "typescript" | "json" | "bash" | "plaintext"; + +const wrapper = css({ position: "relative", mt: "1", mb: "2" }); + +const block = css({ + bg: "#1e1e1e", + color: "#d4d4d4", + p: "4", + borderRadius: "8px", + overflowX: "auto", + fontSize: "sm", + lineHeight: "1.5", + whiteSpace: "pre", + // The global `pre` rule in layouts/panda.css sets a light background; win over it here. + "& code": { bg: "transparent", p: "0", fontSize: "inherit", color: "inherit" }, +}); + +const copyButton = css({ + position: "absolute", + top: "2", + right: "2", + px: "2", + py: "1", + fontSize: "xs", + fontFamily: "sans", + color: "#d4d4d4", + bg: "rgba(255,255,255,0.1)", + border: "1px solid rgba(255,255,255,0.2)", + borderRadius: "sm", + cursor: "pointer", + opacity: 0.7, + transition: "opacity 0.15s ease, background 0.15s ease", + _hover: { opacity: 1, bg: "rgba(255,255,255,0.18)" }, + _focusVisible: { opacity: 1, outline: "2px solid", outlineColor: "indigo.400", outlineOffset: "1px" }, +}); + +export interface CodeBlockProps { + children: string; + lang?: CodeLang; +} + +export function CodeBlock({ children, lang = "typescript" }: CodeBlockProps) { + const [copied, setCopied] = useState(false); + // Rendered server-side too, where there is no navigator β€” resolve after mount so the + // button's presence never differs between the SSR markup and the hydrated DOM. + const [canCopy, setCanCopy] = useState(false); + const timer = useRef | null>(null); + + useEffect(() => { + setCanCopy(typeof navigator !== "undefined" && !!navigator.clipboard); + return () => { + if (timer.current) clearTimeout(timer.current); + }; + }, []); + + const copy = useCallback(() => { + void navigator.clipboard.writeText(children).then( + () => { + setCopied(true); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => setCopied(false), 2000); + }, + (err) => console.error("Copy failed", err), + ); + }, [children]); + + const highlighted = lang === "plaintext" ? null : hljs.highlight(children, { language: lang }).value; + + return ( +
    +
    +        {highlighted === null ? (
    +          {children}
    +        ) : (
    +          
    +        )}
    +      
    + {canCopy && ( + + )} +
    + ); +} + +export default CodeBlock; diff --git a/website/package-lock.json b/website/package-lock.json index c01b75179..9da804c89 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -17,6 +17,7 @@ "@x402/fetch": "^2.19.0", "chart.js": "^4.5.1", "chartjs-plugin-annotation": "^3.1.0", + "highlight.js": "^11.11.1", "katex": "^0.18.1", "mermaid": "^11.16.0", "react": "^19.2.8", @@ -10981,6 +10982,15 @@ "hermes-estree": "0.25.1" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/hono": { "version": "4.12.31", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", diff --git a/website/package.json b/website/package.json index 579e94db0..047d7478b 100644 --- a/website/package.json +++ b/website/package.json @@ -27,6 +27,7 @@ "@x402/fetch": "^2.19.0", "chart.js": "^4.5.1", "chartjs-plugin-annotation": "^3.1.0", + "highlight.js": "^11.11.1", "katex": "^0.18.1", "mermaid": "^11.16.0", "react": "^19.2.8", diff --git a/website/pages/agent-onboarding/+Page.tsx b/website/pages/agent-onboarding/+Page.tsx index 1c86e3fe2..2393d64cc 100644 --- a/website/pages/agent-onboarding/+Page.tsx +++ b/website/pages/agent-onboarding/+Page.tsx @@ -5,13 +5,46 @@ import { AgentChecker } from "../../components/AgentChecker"; import { CommentsSection } from "../../components/CommentsSection"; import { ParamTable } from "../../components/ParamTable"; import { Foldable } from "../../components/Foldable"; +import MermaidDiagram from "../../components/MermaidDiagram"; import { useOpenApiSpec } from "../../hooks/useOpenApiSpec"; const LLM_ORIGIN = "https://llm-agent.fretchen.eu"; const SPEC_URL = `${LLM_ORIGIN}/openapi.json`; const GH_BLOB = "https://github.com/fretchen/fretchen.github.io/blob/main"; +// Line-anchored links are pinned to a commit: anchors against `main` silently drift onto +// unrelated code the moment the referenced file changes. GH_BLOB stays unpinned for +// "browse the current version" links. +const GH_PIN = "https://github.com/fretchen/fretchen.github.io/blob/7f517783fcac7c7a2f4c5bc08b967e4164088a7d"; const X402_DOCS = "https://docs.x402.org"; +/** The payment flow, as a sequence β€” same style as the /x402 page's diagrams. */ +const paymentFlowDiagram = ` +sequenceDiagram + participant Client as Client / Wallet + participant Server as Your endpoint + participant Facilitator as Facilitator + participant Chain as Blockchain
    (USDC) + + Client->>Server: POST (no payment) + Server-->>Client: 402 + how to pay + + Note over Client,Chain: First message only β€” open the channel + Client->>Chain: Deposit USDC into escrow + Client->>Server: POST + deposit payload + Server->>Facilitator: verify β†’ settle + Server-->>Client: 200 + reply + + Note over Client,Server: Every later message β€” off-chain, no tx + Client->>Server: POST + signed voucher (IOU) + Server->>Facilitator: verify β†’ settle (bookkeeping only) + Server-->>Client: 200 + reply + + Note over Server,Chain: Your cron job, on a schedule + Server->>Facilitator: claimAndSettle() + Facilitator->>Chain: Redeem accumulated vouchers + Chain-->>Server: πŸ’° Paid +`; + // Small presentational helpers ------------------------------------------------ const sectionCard = css({ @@ -57,6 +90,20 @@ function Code({ children }: { children: string }) { return
    {children}
    ; } +/** + * Deep link into the reference implementation at a pinned commit, e.g. + * β†’ "sc_llm_x402.ts:288-324". + */ +function SrcRef({ path, lines, label }: { path: string; lines?: string; label?: string }) { + const file = path.split("/").pop(); + const anchor = lines ? `#L${lines.replace("-", "-L")}` : ""; + return ( + + {label ?? `${file}${lines ? `:${lines}` : ""}`} + + ); +} + /** A numbered build step. */ function Step({ n, title, children }: { n: number; title: string; children: React.ReactNode }) { return ( @@ -190,7 +237,7 @@ export default function Page() {
    -

    πŸ‘€ Who this is for

    +

    Who this is for

    A backend developer comfortable with Node and TypeScript who already has (or can put together) an LLM endpoint. No prior x402 or crypto-payments experience assumed β€” the one @@ -230,7 +277,7 @@ export default function Page() { {/* SECTION β€” how payment works */}

    -

    πŸ’Έ How the payment works

    +

    How the payment works

    When someone calls your endpoint without paying, you reply 402 Payment Required plus a header describing how to pay. Their client pays in USDC (a dollar stablecoin) and retries. @@ -240,18 +287,7 @@ export default function Page() { That scheme is called batch-settlement.

    - {` client your endpoint chain - β”‚ β”‚ β”‚ - β”œβ”€β”€ POST (no payment) ─────────►│ β”‚ - │◄── 402 + how to pay ─────────── β”‚ - β”‚ β”‚ β”‚ - β”œβ”€β”€ deposit (once) ────────────►│───── escrow opened ──────►│ - │◄── 200 + reply ──────────────── β”‚ - β”‚ β”‚ β”‚ - β”œβ”€β”€ voucher + POST ────────────►│ (off-chain, per message) β”‚ - │◄── 200 + reply ──────────────── β”‚ - β”‚ … β”‚ β”‚ - β”‚ your cron job ───── claim ──────►│ πŸ’°`} +

    You don't implement the protocol yourself β€” the @x402/evm SDK does @@ -303,7 +339,7 @@ export default function Page() { {/* SECTION β€” the API */}

    -

    πŸ“‘ The API you expose

    +

    The API you expose

    One route: POST /, in the OpenAI chat-completions format β€” so it looks like any other LLM API and existing types just work. @@ -318,32 +354,63 @@ export default function Page() { which needs the whole reply).

    -

    Example request:

    - {`POST / +

    Try it right now

    +

    + Send an unpaid request to our live agent. You can run this verbatim β€” it costs nothing, and the 402 it + returns is exactly what your own endpoint has to produce: +

    + {`curl -i -X POST ${LLM_ORIGIN}/ \\ + -H "Content-Type: application/json" \\ + -d '{"model":"mistral-large-latest","messages":[{"role":"user","content":"Hello"}]}'`} +

    + The interesting part of the response β€” note receiverAuthorizer and{" "} + withdrawDelay: those are the fields{" "} + enhancePaymentRequirements injects for you in step 3. +

    + {`HTTP/2 402 +access-control-expose-headers: Payment-Required, X-Payment, PAYMENT-REQUIRED +payment-required: eyJ4NDAyVmVyc2lvbiI6MiwicmVzb3VyY2UiOnsidXJs... ← same JSON, base64 + { - "model": "mistral-large-latest", - "messages": [{ "role": "user", "content": "Hello" }] + "x402Version": 2, + "resource": { "url": "/", "description": "AI Assistant chat message", "mimeType": "application/json" }, + "accepts": [{ + "scheme": "batch-settlement", + "network": "eip155:8453", + "amount": "3000", ← ceiling: 0.003 USDC + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "payTo": "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C", + "maxTimeoutSeconds": 120, + "extra": { + "name": "USD Coin", "version": "2", + "receiverAuthorizer": "0xF9B7...2c93", ← injected by the SDK + "withdrawDelay": 86400 ← injected by the SDK + } + }, { "network": "eip155:84532", "...": "the same, for Base Sepolia" }] }`} -

    Example response:

    - {`{ - "id": "chatcmpl-...", - "object": "chat.completion", - "model": "mistral-large-latest", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "Hi!" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 10, "completion_tokens": 9, "total_tokens": 19 } -}`} +

    + You can't get past this point with curl β€” the next request has to carry a signed payment, which means + opening a channel. For a real, runnable paid round-trip, use the buyer notebook: it drives + a server through deposit β†’ voucher β†’ verify β†’ settle over plain HTTP, on testnet by default ( + USE_MAINNET = false). +

    +

    + +

    - Errors use the OpenAI shape β€” {`{ error: { message, type, code } }`}{" "} - with model_not_found for an unadvertised model and{" "} + After payment your endpoint returns the ordinary OpenAI completion object shown in the response table above + β€” usage included. Errors use the OpenAI shape,{" "} + {`{ error: { message, type, code } }`}, with{" "} + model_not_found for an unadvertised model and{" "} stream_unsupported for a streaming request.

    {/* SECTION β€” build it */}
    -

    πŸ› οΈ Build it, step by step

    +

    Build it, step by step

    Each step shows the requirement and the real code from our implementation ( @@ -353,16 +420,83 @@ export default function Page() { x402_server.ts - ), trimmed for readability and with our infrastructure swapped for portable equivalents. + ), trimmed for readability and with our infrastructure swapped for portable equivalents. Each snippet ends + with a line-anchored link to the original, so you can always diff against something that runs in production. +

    +

    + Snippets use a plain-object handler β€” an event in, a{" "} + {`{ statusCode, headers, body }`} out. That's what serverless + platforms hand you, and it maps to Express or Fetch handlers in a couple of lines.

    +

    Where everything goes

    +

    + This is the whole thing, with a slot for each step. Read it once β€” every later snippet fills exactly one of + these slots, so you always know whether code belongs at module scope (runs once) or inside the handler (runs + per request). +

    + {`// ═══ server.ts ════════════════════════════════════════════ MODULE SCOPE (once) +import { x402ResourceServer, HTTPFacilitatorClient } from "@x402/core/server"; +import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/server"; +import { RedisChannelStorage } from "@x402/evm/batch-settlement/server/redis-storage"; +import { privateKeyToAccount } from "viem/accounts"; +import { createClient } from "redis"; + +const MODELS = ["mistral-large-latest"]; // must match the enum you publish (step 6) +const NETWORKS = ["eip155:8453"]; // Base mainnet; add "eip155:84532" to test +const RESOURCE = { url: "https://your-agent.example/", description: "chat", mimeType: "application/json" }; + +// Your price ceiling per message, in USDC atomic units (6 decimals). Pick it as +// (tokens you expect per message) x (your OUTPUT rate) β€” pricing the whole estimate at the +// dearer output rate guarantees the ceiling is never an underestimate. 3000 = 0.003 USDC. +const MAX_PRICE_PER_MESSAGE = "3000"; + +const redis = createClient({ url: process.env.REDIS_URL }); +await redis.connect(); + +const USDC = { /* ... */ }; // ── step 3 +const { resourceServer, scheme } = setupX402(); // ── step 2 + +export async function handler(event) { + // ── preflight: browsers send OPTIONS before a paid POST (step 3) + if (event.httpMethod === "OPTIONS") return { statusCode: 204, headers: CORS, body: "" }; + + // ── step 1: parse + validate the OpenAI body + // ── step 3: no payment? advertise how to pay, return 402 + // ── step 4: verify β†’ run your model β†’ settle β†’ 200 +} + +// ═══ helpers (module scope) ═══════════════════════════════ +// json() / CORS ── step 1 +// respond402() ── step 3 +// extractPaymentPayload(), settlementHeaders(), priceFromUsage() ── step 4 + +// ═══ two more files ═══════════════════════════════════════ +// cron.ts β€” claim your money on a schedule ── step 5 +// openapi.json β€” discovery doc + the route that serves it ── step 6`} +

    If you already proxy an OpenAI-compatible model, you're done with this step β€” just validate the input - and reject streaming. + and reject streaming. Nothing here is x402-specific yet.

    - - {`const body = await req.json(); + + {`// Every response needs these β€” the assistant is a browser client, and +// Allow-Headers must cover PAYMENT-SIGNATURE or the preflight fails. +const CORS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Content-Type, PAYMENT-SIGNATURE, X-PAYMENT", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Expose-Headers": "Payment-Required", + "Content-Type": "application/json", +}; + +function json(statusCode, payload, extraHeaders = {}) { + return { statusCode, headers: { ...CORS, ...extraHeaders }, body: JSON.stringify(payload) }; +} + +// ── in the handler ── +const body = JSON.parse(event.body); if (body.stream === true) { return json(400, { @@ -377,6 +511,10 @@ if (!MODELS.includes(body.model)) { error: { message: \`Unknown model '\${body.model}'.\`, type: "invalid_request_error", code: "model_not_found" }, }); }`} +

    + Ours: (validation),{" "} + (CORS + error helpers). +

    @@ -387,14 +525,9 @@ if (!MODELS.includes(body.model)) { authorizer key that signs channel configuration off-chain (it never needs funding).

    - For FACILITATOR_URL you can use ours:{" "} - https://facilitator.fretchen.eu β€” it supports batch-settlement on - Optimism and Base, charges a flat 0.01 USDC per settlement, and needs a one-time USDC approval from your - receiver wallet (details and the approval widget are on{" "} - - the x402 page - - ). Alternatives: any facilitator advertising batch-settlement in the{" "} + For FACILITATOR_URL, pick a facilitator that advertises{" "} + batch-settlement on your network β€” check its{" "} + /supported endpoint. Public options are listed in the{" "} Solvador - ), or run your own. + ), or you can run your own.

    - - {`import { x402ResourceServer, HTTPFacilitatorClient } from "@x402/core/server"; -import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/server"; -import { RedisChannelStorage } from "@x402/evm/batch-settlement/server/redis-storage"; -import { privateKeyToAccount } from "viem/accounts"; - -const NETWORKS = ["eip155:8453"]; // Base mainnet -const facilitator = new HTTPFacilitatorClient({ url: process.env.FACILITATOR_URL }); -const authorizer = privateKeyToAccount(process.env.RECEIVER_AUTHORIZER_PRIVATE_KEY); - -const resourceServer = new x402ResourceServer(facilitator); - -const scheme = new BatchSettlementEvmScheme(process.env.RECEIVER_ADDRESS, { - storage: new RedisChannelStorage({ client: redis }), // or FileChannelStorage for one instance - receiverAuthorizerSigner: { - address: authorizer.address, - signTypedData: (params) => authorizer.signTypedData(params), - }, - onchainStateTtlMs: 5_000, // keep low, or a user's first message after depositing can fail - withdrawDelay: 86_400, // must be >> your claim interval (step 5) -}); + + {`function setupX402() { + const facilitator = new HTTPFacilitatorClient({ url: process.env.FACILITATOR_URL }); + const authorizer = privateKeyToAccount(process.env.RECEIVER_AUTHORIZER_PRIVATE_KEY); + + const resourceServer = new x402ResourceServer(facilitator); + + const scheme = new BatchSettlementEvmScheme(process.env.RECEIVER_ADDRESS, { + storage: new RedisChannelStorage({ client: redis }), // or FileChannelStorage for one instance + receiverAuthorizerSigner: { + address: authorizer.address, + signTypedData: (params) => authorizer.signTypedData(params), + }, + onchainStateTtlMs: 5_000, // keep low, or a user's first message after depositing can fail + withdrawDelay: 86_400, // must be >> your claim interval (step 5) + }); -for (const network of NETWORKS) resourceServer.register(network, scheme);`} -

    Ours: x402_server.ts β†’ createLLMResourceServer (we use S3 for storage).

    + for (const network of NETWORKS) resourceServer.register(network, scheme); + return { resourceServer, scheme, facilitator }; +}`}
    +

    + Ours: β€” same thing, with S3 for storage. +

    @@ -462,58 +594,68 @@ for (const network of NETWORKS) resourceServer.register(network, scheme);`}enhancePaymentRequirements
    .
    {" "} It injects the receiverAuthorizer and{" "} - withdrawDelay fields the client needs to build a deposit. And you must - reuse the same enhanced object when you verify (step 4) β€” a bare object gets every deposit - rejected. + withdrawDelay fields the client needs to build a deposit β€” you saw + both in the curl output above. The same enhancement has to be applied again at verify time (step + 4); a bare, un-enhanced object gets every deposit rejected with{" "} + receiver_authorizer_mismatch.
    - - {`async function paymentRequirements() { + + {`// One enhanced accepts[] entry for a single network. Step 4 calls this again for the +// network the client picked β€” which is why it takes \`network\` and \`amount\` as arguments +// instead of hard-coding them. +async function requirementsFor(network, amount) { + const usdc = USDC[network]; // address + EIP-712 name/version + const base = { + scheme: "batch-settlement", + network, + amount, // USDC atomic units (6 decimals) + asset: usdc.address, + payTo: process.env.RECEIVER_ADDRESS, + maxTimeoutSeconds: 120, // must be identical in the 402 and at verify + extra: { name: usdc.name, version: usdc.version }, + }; + return scheme.enhancePaymentRequirements( + base, + { x402Version: 2, scheme: "batch-settlement", network, extra: base.extra }, + [], // x402 extensions β€” none, unless you use them + ); +} + +// The 402 body advertises EVERY network you accept. +async function build402Body() { const accepts = await Promise.all( - NETWORKS.map((network) => { - const usdc = USDC[network]; // address + EIP-712 name/version - const base = { - scheme: "batch-settlement", - network, - amount: MAX_PRICE_PER_MESSAGE, // a ceiling, in USDC atomic units (6 dp) - asset: usdc.address, - payTo: process.env.RECEIVER_ADDRESS, - maxTimeoutSeconds: 120, - extra: { name: usdc.name, version: usdc.version }, - }; - return scheme.enhancePaymentRequirements(base, { - x402Version: 2, - scheme: "batch-settlement", - network, - extra: base.extra, - }, []); - }), + NETWORKS.map((network) => requirementsFor(network, MAX_PRICE_PER_MESSAGE)), ); - return { x402Version: 2, resource: { url: MY_URL, description: "chat", mimeType: "application/json" }, accepts }; + return { x402Version: 2, resource: RESOURCE, accepts }; } -// in the handler: -const payment = extractPaymentPayload(req.headers); -if (!payment) return respond402(await paymentRequirements());`} -

    Ours: x402_server.ts β†’ createBatchSettlementPaymentRequirements.

    +// ── in the handler ── +const payment = extractPaymentPayload(event.headers); +if (!payment) return respond402(await build402Body());`}
    +

    + Ours: (the 402 body),{" "} + (the handler branch). +

    +
    + Two different objects, one confusing name. The 402 body is{" "} + {`{ x402Version, resource, accepts: [...] }`}. What you pass to{" "} + verifyPayment/settlePayment is a{" "} + single entry from that array, for the one network the client chose. Passing the whole 402 + body there is the most common way to get this wrong. +
    {`// The 402 body must ALSO go, base64-encoded, into the Payment-Required header β€” -// browser clients read the header, not the body. And that header must be CORS-exposed, -// or a browser client can't see it at all. -function respond402(paymentRequirements) { - return { - statusCode: 402, - headers: { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "*", - "Access-Control-Expose-Headers": "Payment-Required", - "Content-Type": "application/json", - "Payment-Required": Buffer.from(JSON.stringify(paymentRequirements)).toString("base64"), - }, - body: JSON.stringify(paymentRequirements), - }; +// browser clients read the header, not the body. CORS already exposes it (step 1), +// which is exactly what the checker at the bottom of this page verifies. +function respond402(body402) { + return json(402, body402, { + "Payment-Required": Buffer.from(JSON.stringify(body402)).toString("base64"), + }); }`} -

    Ours: x402_server.ts β†’ create402Response.

    +

    + Ours: . +

    @@ -524,34 +666,57 @@ function respond402(paymentRequirements) { settle the amount actually used, so a short reply costs the user less.

    - {`const requirements = await paymentRequirements(); // the SAME enhanced object as the 402 + {`// 0. Which network did the client choose? It's in the payload β€” you can't build the +// verify requirements without it, and you must reject anything you don't serve. +const network = payment.accepted?.network; +if (!network || !NETWORKS.includes(network)) { + return json(402, { error: { message: "Unsupported network for this payment." } }); +} + +// 1. Rebuild the SAME enhanced requirements β€” for that one network, at the ceiling price. +// (Not the 402 body: one accepts[] entry. See the note in step 3.) +const requirements = await requirementsFor(network, MAX_PRICE_PER_MESSAGE); -// 1. Verify the client's voucher. +// 2. Verify the client's voucher. const check = await resourceServer.verifyPayment(payment, requirements); if (!check.isValid) { - // Re-emit through the SDK so it can attach corrective channel state the client - // needs to resync β€” a hand-rolled 402 body breaks that recovery. - return respond402( - await resourceServer.createPaymentRequiredResponse([requirements], resource, check.invalidReason, - check.payer ? { payer: check.payer } : undefined, undefined, payment), + // Re-emit through the SDK so it can attach corrective channel state the client needs to + // resync (passing the failed payload is what triggers that) β€” a hand-rolled 402 body + // breaks the client's automatic retry. + const corrective = await resourceServer.createPaymentRequiredResponse( + [requirements], + RESOURCE, + check.invalidReason, + check.payer ? { payer: check.payer } : undefined, + undefined, + payment, ); + return respond402(corrective); } -// 2. Do the actual work. +// 3. Do the actual work. const completion = await callYourModel(body.messages); -// 3. Settle what was really used (<= the ceiling verified above). +// 4. Settle the amount actually used β€” same requirements, smaller amount. const settlement = await resourceServer.settlePayment(payment, { ...requirements, amount: priceFromUsage(completion.usage), }); -if (!settlement.success) return json(402, { error: { message: "Settlement failed" } }); +if (!settlement.success) { + return json(402, { error: { message: \`Settlement failed: \${settlement.errorReason ?? "unknown"}\` } }); +} +// 5. 200 + the settlement receipt (json() already merges CORS). return json(200, completion, settlementHeaders(settlement));`} -

    Ours: sc_llm_x402.ts (the main handler).

    +

    + Ours: (network check),{" "} + (verify + corrective 402),{" "} + (settle + response). +

    {`// The payment arrives base64-encoded in the PAYMENT-SIGNATURE header. +// Returns null when there's no payment β€” that's the "send a 402" case in step 3. function extractPaymentPayload(headers) { const header = headers["payment-signature"] ?? headers["Payment-Signature"]; if (!header) return null; @@ -567,7 +732,10 @@ function extractPaymentPayload(headers) { function settlementHeaders(settlement) { return { "Payment-Response": Buffer.from(JSON.stringify(settlement)).toString("base64") }; }`} -

    Ours: x402_server.ts β†’ extractPaymentPayload / createSettlementHeaders.

    +

    + Ours: and{" "} + . +

    {`// Rates are quoted per 1,000,000 tokens; USDC has 6 decimals β€” the two 1e6 @@ -586,7 +754,10 @@ function priceFromUsage(usage) { const max = BigInt(MAX_PRICE_PER_MESSAGE); return (cost > max ? max : cost).toString(); }`} -

    Ours: llm_service.ts β†’ convertTokensToUsdcCost.

    +

    + Ours: (the rate maths) and{" "} + (the ceiling clamp). +

    @@ -595,15 +766,20 @@ function priceFromUsage(usage) { Per-message settlements are bookkeeping only β€” no funds move. A scheduled job redeems the accumulated vouchers on-chain. Skip this and you never get paid. It's genuinely this short:

    - - {`// Run on a schedule (we use every 12h). Must be far more often than -// the withdrawDelay you set in step 2, or a channel can be withdrawn before you claim it. + + {`// Same setupX402() as step 2 β€” this runs in its own process, on a schedule +// (we use every 12h). Must run far more often than the withdrawDelay you set in +// step 2, or a channel can be withdrawn before you claim it. +const { scheme, facilitator } = setupX402(); + for (const network of NETWORKS) { const manager = scheme.createChannelManager(facilitator, network); const { claims, settle } = await manager.claimAndSettle(); console.log({ network, claims: claims.length, settled: settle !== undefined }); }`} -

    Ours: llm_x402_cron.ts (a 12-hourly scheduled function).

    +

    + Ours: (a 12-hourly scheduled function). +

    @@ -612,13 +788,12 @@ for (const network of NETWORKS) { Finally, make yourself findable. Serve an OpenAPI document at{" "} GET /openapi.json containing{" "} "x-service-type": "llm/v1", your{" "} - x-payment-info, and an ownership proof. And set CORS β€” the assistant - runs in a browser, so without it your 402 is invisible. One consistency rule: the model{" "} - enum in your published schema must match the{" "} - MODELS array your handler validates against (step 1) β€” the spec is a - promise, the validation enforces it. + x-payment-info, and an ownership proof. Your CORS setup from step 1 + already covers the browser side. One consistency rule: the model enum{" "} + in your published schema must match the MODELS array your handler + validates against (step 1) β€” the spec is a promise, the validation enforces it.

    - + {`// openapi.json (excerpt) { "openapi": "3.1.0", @@ -629,21 +804,26 @@ for (const network of NETWORKS) { "protocols": ["x402"], "price": { "mode": "dynamic", "currency": "USD", "min": "0", "max": "0.003" } } } } }, - "components": { "schemas": { "LLMChatRequest": { /* ... */ }, "LLMChatResponse": { /* ... */ } } } -} + "components": { "schemas": { + "LLMChatRequest": { /* model enum must match MODELS from step 1 */ }, + "LLMChatResponse": { /* ... */ } + } } +}`} +

    And the route that serves it β€” the first branch of your handler:

    + {`import openapiSpec from "./openapi.json" with { type: "json" }; -// Required on every response: -"Access-Control-Allow-Origin": "*", -"Access-Control-Expose-Headers": "Payment-Required"`} -

    And the route that serves it:

    - {`if (req.method === "GET" && req.path.replace(/^\\/+/, "") === "openapi.json") { - // Patch the live ceiling in rather than let the static file drift from what - // your 402 actually charges. +// ── in the handler, before the POST logic ── +if (event.httpMethod === "GET" && (event.path ?? "").replace(/^\\/+/, "") === "openapi.json") { + // Patch the live ceiling in, so the published price can't drift from what you charge. + // MAX_PRICE_PER_MESSAGE is atomic units ("3000"); the spec wants decimal USD ("0.003"). const spec = structuredClone(openapiSpec); - spec.paths["/"].post["x-payment-info"].price.max = formatUsdcAsDecimal(MAX_PRICE_PER_MESSAGE); - return json(200, spec, { "Access-Control-Allow-Origin": "*" }); + spec.paths["/"].post["x-payment-info"].price.max = (Number(MAX_PRICE_PER_MESSAGE) / 1e6).toString(); + return json(200, spec); }`} -

    Ours: sc_llm_x402.ts (the openapi.json branch of the handler).

    +

    + Ours: (with an exact bigint formatter,{" "} + , instead of the float division above). +

    {`// One-off: sign your bare origin (scheme + host, no path, no trailing slash) @@ -654,16 +834,7 @@ const account = privateKeyToAccount(process.env.RECEIVER_PRIVATE_KEY); const signature = await account.signMessage({ message: "https://your-agent.example" }); console.log(signature);`}

    - Ours:{" "} - - scripts/sign_ownership_proof.ts - - . + Ours: .

    @@ -671,7 +842,7 @@ console.log(signature);`}
    {/* SECTION β€” test it */}
    -

    πŸ§ͺ Test it

    +

    Test it

    Start on Base Sepolia (eip155:84532) β€” same code path, no real money. Fund your test wallet from the{" "} @@ -692,8 +863,15 @@ console.log(signature);`} eip155:8453. Everything above it β€” discovery, service type, the 402 challenge β€” should already be green.

    +

    + The first real payment. Point the{" "} + at your server and run it + top to bottom. It opens a channel, sends a paid message, and prints the settlement β€” the fastest way to see + deposit β†’ voucher β†’ verify β†’ settle actually working, and it stays on testnet unless you set{" "} + USE_MAINNET = true. +

    - The end-to-end test: pay yourself. Once the checker passes on mainnet, open the{" "} + Then: pay yourself from a browser. Once the checker passes on mainnet, open the{" "} assistant @@ -709,7 +887,7 @@ console.log(signature);`} {/* SECTION β€” checker */}

    -

    πŸ”Ž Check your endpoint

    +

    Check your endpoint

    Paste your URL. This runs exactly the checks the assistant runs before it will talk to an endpoint β€” down to reading the base64 Payment-Required header from step 3 β€” and tells you @@ -720,7 +898,7 @@ console.log(signature);`} {/* SECTION β€” known limitations */}

    -

    ⚠️ Known limitations (beta)

    +

    Known limitations (beta)

    Documented openly β€” these are rough edges of a young ecosystem, not of your code.

    @@ -729,13 +907,9 @@ console.log(signature);`} Solvador {" "} - and{" "} - - ours - {" "} - β€” so your options are limited today. The scheme is standard; its EVM wire binding is still defined by the{" "} - @x402/evm code rather than a ratified spec, which is why adoption is - thin. + and this project β€” so your options are limited today. The scheme is standard; its EVM wire binding is still + defined by the @x402/evm code rather than a ratified spec, which is why + adoption is thin. @@ -764,7 +938,7 @@ console.log(signature);`} {/* SECTION β€” contact */}
    -

    🧭 Where this is going

    +

    Where this is going

    The endpoint contract is stable; what's filling in is the ecosystem around it β€” a drop-in client, more facilitators, more agents. The assistant already lets you point it at any compatible agent by URL; a curated @@ -794,7 +968,7 @@ console.log(signature);`} {/* SECTION β€” feedback */}

    -

    πŸ’¬ Feedback

    +

    Feedback

    Stuck on a step, or built one? Leave a note β€” it helps the next builder as much as it helps us.

    From 956363583fe1806f01c5836c83aa24c83daf5e8e Mon Sep 17 00:00:00 2001 From: fretchen Date: Thu, 30 Jul 2026 18:19:24 +0200 Subject: [PATCH 06/12] clean up the design --- website/components/MermaidDiagram.tsx | 40 ++++++++---- website/pages/agent-onboarding/+Page.tsx | 77 ++++++++++------------- website/test/CodeBlock.test.tsx | 78 ++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 57 deletions(-) create mode 100644 website/test/CodeBlock.test.tsx diff --git a/website/components/MermaidDiagram.tsx b/website/components/MermaidDiagram.tsx index c69546f3e..c90e4e61e 100644 --- a/website/components/MermaidDiagram.tsx +++ b/website/components/MermaidDiagram.tsx @@ -1,6 +1,13 @@ -import React, { useRef, useEffect } from "react"; +import React, { useRef, useEffect, useMemo } from "react"; import { css } from "../styled-system/css"; +/** + * Sequence-diagram defaults. `mirrorActors` defaults to true in mermaid, which redraws the + * entire participant row again at the bottom of the diagram β€” on these pages that is ~75px + * of dead whitespace, since the labels are already visible at the top. + */ +const SEQUENCE_DEFAULTS = { mirrorActors: false }; + interface MermaidDiagramProps { /** The mermaid diagram definition string */ definition: string; @@ -12,9 +19,27 @@ interface MermaidDiagramProps { config?: Record; } -const MermaidDiagram: React.FC = ({ definition, title, className, config = {} }) => { +const MermaidDiagram: React.FC = ({ definition, title, className, config }) => { const mermaidRef = useRef(null); + // Callers pass `config` as an inline object literal, which is a fresh reference on every + // render β€” depending on it directly would re-render the diagram on every parent render. + // Key the effect on its serialized form instead. + const configKey = config ? JSON.stringify(config) : ""; + + const resolvedConfig = useMemo(() => { + const caller = (configKey ? (JSON.parse(configKey) as Record) : {}) as Record; + return { + startOnLoad: false, + theme: "default" as const, + securityLevel: "sandbox" as const, + ...caller, + // Merge one level deeper than the spread above: a caller passing any `sequence` key + // (e.g. { sequence: { wrap: true } }) would otherwise silently drop mirrorActors. + sequence: { ...SEQUENCE_DEFAULTS, ...((caller.sequence as Record) ?? {}) }, + }; + }, [configKey]); + useEffect(() => { const renderDiagram = async () => { if (!mermaidRef.current) return; @@ -22,14 +47,7 @@ const MermaidDiagram: React.FC = ({ definition, title, clas try { const mermaid = (await import("mermaid")).default; - // Initialize mermaid with custom config merged with defaults - const defaultConfig = { - startOnLoad: false, - theme: "default" as const, - securityLevel: "sandbox" as const, - }; - - mermaid.initialize({ ...defaultConfig, ...config }); + mermaid.initialize(resolvedConfig); // Generate unique ID for this diagram const id = `mermaid-diagram-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; @@ -50,7 +68,7 @@ const MermaidDiagram: React.FC = ({ definition, title, clas }; void renderDiagram(); - }, [definition, config]); + }, [definition, resolvedConfig]); return (
    {children}; -} - /** * Deep link into the reference implementation at a pinned commit, e.g. * β†’ "sc_llm_x402.ts:288-324". @@ -359,15 +344,15 @@ export default function Page() { Send an unpaid request to our live agent. You can run this verbatim β€” it costs nothing, and the 402 it returns is exactly what your own endpoint has to produce:

    - {`curl -i -X POST ${LLM_ORIGIN}/ \\ + {`curl -i -X POST ${LLM_ORIGIN}/ \\ -H "Content-Type: application/json" \\ - -d '{"model":"mistral-large-latest","messages":[{"role":"user","content":"Hello"}]}'`} + -d '{"model":"mistral-large-latest","messages":[{"role":"user","content":"Hello"}]}'`}

    The interesting part of the response β€” note receiverAuthorizer and{" "} withdrawDelay: those are the fields{" "} enhancePaymentRequirements injects for you in step 3.

    - {`HTTP/2 402 + {`HTTP/2 402 access-control-expose-headers: Payment-Required, X-Payment, PAYMENT-REQUIRED payment-required: eyJ4NDAyVmVyc2lvbiI6MiwicmVzb3VyY2UiOnsidXJs... ← same JSON, base64 @@ -387,7 +372,7 @@ payment-required: eyJ4NDAyVmVyc2lvbiI6MiwicmVzb3VyY2UiOnsidXJs... ← same JSO "withdrawDelay": 86400 ← injected by the SDK } }, { "network": "eip155:84532", "...": "the same, for Base Sepolia" }] -}`} +}`}

    You can't get past this point with curl β€” the next request has to carry a signed payment, which means @@ -435,7 +420,7 @@ payment-required: eyJ4NDAyVmVyc2lvbiI6MiwicmVzb3VyY2UiOnsidXJs... ← same JSO these slots, so you always know whether code belongs at module scope (runs once) or inside the handler (runs per request).

    - {`// ═══ server.ts ════════════════════════════════════════════ MODULE SCOPE (once) + {`// ═══ server.ts ════════════════════════════════════════════ MODULE SCOPE (once) import { x402ResourceServer, HTTPFacilitatorClient } from "@x402/core/server"; import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/server"; import { RedisChannelStorage } from "@x402/evm/batch-settlement/server/redis-storage"; @@ -473,7 +458,7 @@ export async function handler(event) { // ═══ two more files ═══════════════════════════════════════ // cron.ts β€” claim your money on a schedule ── step 5 -// openapi.json β€” discovery doc + the route that serves it ── step 6`} +// openapi.json β€” discovery doc + the route that serves it ── step 6`}

    @@ -481,7 +466,7 @@ export async function handler(event) { and reject streaming. Nothing here is x402-specific yet.

    - {`// Every response needs these β€” the assistant is a browser client, and + {`// Every response needs these β€” the assistant is a browser client, and // Allow-Headers must cover PAYMENT-SIGNATURE or the preflight fails. const CORS = { "Access-Control-Allow-Origin": "*", @@ -510,7 +495,7 @@ if (!MODELS.includes(body.model)) { return json(404, { error: { message: \`Unknown model '\${body.model}'.\`, type: "invalid_request_error", code: "model_not_found" }, }); -}`} +}`}

    Ours: (validation),{" "} (CORS + error helpers). @@ -548,7 +533,7 @@ if (!MODELS.includes(body.model)) { ), or you can run your own.

    - {`function setupX402() { + {`function setupX402() { const facilitator = new HTTPFacilitatorClient({ url: process.env.FACILITATOR_URL }); const authorizer = privateKeyToAccount(process.env.RECEIVER_AUTHORIZER_PRIVATE_KEY); @@ -566,7 +551,7 @@ if (!MODELS.includes(body.model)) { for (const network of NETWORKS) resourceServer.register(network, scheme); return { resourceServer, scheme, facilitator }; -}`} +}`}

    Ours: β€” same thing, with S3 for storage.

    @@ -580,10 +565,10 @@ if (!MODELS.includes(body.model)) { encoded how β€” is yours to write. It's three ~15-line helpers, shown in this step and the next.

    The USDC constants you'll need:

    - {`const USDC = { + {`const USDC = { "eip155:8453": { address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", name: "USD Coin", version: "2" }, // Base "eip155:84532": { address: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", name: "USDC", version: "2" }, // Base Sepolia -};`} +};`}
    The EIP-712 domain name is "USD Coin" on mainnet but{" "} "USDC" on testnet. Mix them up and payment verification @@ -600,7 +585,7 @@ if (!MODELS.includes(body.model)) { receiver_authorizer_mismatch.
    - {`// One enhanced accepts[] entry for a single network. Step 4 calls this again for the + {`// One enhanced accepts[] entry for a single network. Step 4 calls this again for the // network the client picked β€” which is why it takes \`network\` and \`amount\` as arguments // instead of hard-coding them. async function requirementsFor(network, amount) { @@ -631,7 +616,7 @@ async function build402Body() { // ── in the handler ── const payment = extractPaymentPayload(event.headers); -if (!payment) return respond402(await build402Body());`} +if (!payment) return respond402(await build402Body());`}

    Ours: (the 402 body),{" "} (the handler branch). @@ -645,14 +630,14 @@ if (!payment) return respond402(await build402Body());`} body there is the most common way to get this wrong.

    - {`// The 402 body must ALSO go, base64-encoded, into the Payment-Required header β€” + {`// The 402 body must ALSO go, base64-encoded, into the Payment-Required header β€” // browser clients read the header, not the body. CORS already exposes it (step 1), // which is exactly what the checker at the bottom of this page verifies. function respond402(body402) { return json(402, body402, { "Payment-Required": Buffer.from(JSON.stringify(body402)).toString("base64"), }); -}`} +}`}

    Ours: .

    @@ -666,7 +651,7 @@ function respond402(body402) { settle the amount actually used, so a short reply costs the user less.

    - {`// 0. Which network did the client choose? It's in the payload β€” you can't build the + {`// 0. Which network did the client choose? It's in the payload β€” you can't build the // verify requirements without it, and you must reject anything you don't serve. const network = payment.accepted?.network; if (!network || !NETWORKS.includes(network)) { @@ -707,7 +692,7 @@ if (!settlement.success) { } // 5. 200 + the settlement receipt (json() already merges CORS). -return json(200, completion, settlementHeaders(settlement));`} +return json(200, completion, settlementHeaders(settlement));`}

    Ours: (network check),{" "} (verify + corrective 402),{" "} @@ -715,7 +700,7 @@ return json(200, completion, settlementHeaders(settlement));`}

    - {`// The payment arrives base64-encoded in the PAYMENT-SIGNATURE header. + {`// The payment arrives base64-encoded in the PAYMENT-SIGNATURE header. // Returns null when there's no payment β€” that's the "send a 402" case in step 3. function extractPaymentPayload(headers) { const header = headers["payment-signature"] ?? headers["Payment-Signature"]; @@ -731,14 +716,14 @@ function extractPaymentPayload(headers) { // (merge these into your 200 response's headers). function settlementHeaders(settlement) { return { "Payment-Response": Buffer.from(JSON.stringify(settlement)).toString("base64") }; -}`} +}`}

    Ours: and{" "} .

    - {`// Rates are quoted per 1,000,000 tokens; USDC has 6 decimals β€” the two 1e6 + {`// Rates are quoted per 1,000,000 tokens; USDC has 6 decimals β€” the two 1e6 // factors cancel exactly, so no separate decimals conversion is needed. // Keep rates as integer fractions (num/den) to stay exact in bigint math. const INPUT_PER_M = { num: 1n, den: 2n }; // $0.50 per 1M prompt tokens @@ -753,7 +738,7 @@ function priceFromUsage(usage) { // Never settle above the ceiling you verified against in the 402. const max = BigInt(MAX_PRICE_PER_MESSAGE); return (cost > max ? max : cost).toString(); -}`} +}`}

    Ours: (the rate maths) and{" "} (the ceiling clamp). @@ -767,7 +752,7 @@ function priceFromUsage(usage) { accumulated vouchers on-chain. Skip this and you never get paid. It's genuinely this short:

    - {`// Same setupX402() as step 2 β€” this runs in its own process, on a schedule + {`// Same setupX402() as step 2 β€” this runs in its own process, on a schedule // (we use every 12h). Must run far more often than the withdrawDelay you set in // step 2, or a channel can be withdrawn before you claim it. const { scheme, facilitator } = setupX402(); @@ -776,7 +761,7 @@ for (const network of NETWORKS) { const manager = scheme.createChannelManager(facilitator, network); const { claims, settle } = await manager.claimAndSettle(); console.log({ network, claims: claims.length, settled: settle !== undefined }); -}`} +}`}

    Ours: (a 12-hourly scheduled function).

    @@ -794,7 +779,7 @@ for (const network of NETWORKS) { validates against (step 1) β€” the spec is a promise, the validation enforces it.

    - {`// openapi.json (excerpt) + {`// openapi.json (excerpt) { "openapi": "3.1.0", "x-service-type": "llm/v1", @@ -808,9 +793,9 @@ for (const network of NETWORKS) { "LLMChatRequest": { /* model enum must match MODELS from step 1 */ }, "LLMChatResponse": { /* ... */ } } } -}`} +}`}

    And the route that serves it β€” the first branch of your handler:

    - {`import openapiSpec from "./openapi.json" with { type: "json" }; + {`import openapiSpec from "./openapi.json" with { type: "json" }; // ── in the handler, before the POST logic ── if (event.httpMethod === "GET" && (event.path ?? "").replace(/^\\/+/, "") === "openapi.json") { @@ -819,20 +804,20 @@ if (event.httpMethod === "GET" && (event.path ?? "").replace(/^\\/+/, "") === "o const spec = structuredClone(openapiSpec); spec.paths["/"].post["x-payment-info"].price.max = (Number(MAX_PRICE_PER_MESSAGE) / 1e6).toString(); return json(200, spec); -}`} +}`}

    Ours: (with an exact bigint formatter,{" "} , instead of the float division above).

    - {`// One-off: sign your bare origin (scheme + host, no path, no trailing slash) + {`// One-off: sign your bare origin (scheme + host, no path, no trailing slash) // and paste the signature into x-discovery.ownershipProofs. import { privateKeyToAccount } from "viem/accounts"; const account = privateKeyToAccount(process.env.RECEIVER_PRIVATE_KEY); const signature = await account.signMessage({ message: "https://your-agent.example" }); -console.log(signature);`} +console.log(signature);`}

    Ours: .

    diff --git a/website/test/CodeBlock.test.tsx b/website/test/CodeBlock.test.tsx new file mode 100644 index 000000000..e2a95d4ac --- /dev/null +++ b/website/test/CodeBlock.test.tsx @@ -0,0 +1,78 @@ +/** + * CodeBlock tests. + * + * Two properties matter here. First, highlighting must be synchronous so it also runs during + * SSR β€” these tests assert the .hljs-* markup exists on first render, with no waiting, which + * is what rules out a flash of unhighlighted code. Second, the copy button has to hand over + * the *exact* source text: a docs page whose copy button silently mangles a snippet is worse + * than one with no button at all. + */ +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import { CodeBlock } from "../components/CodeBlock"; + +const TS = `const answer = 42; // the meaning`; + +function stubClipboard(writeText = vi.fn(() => Promise.resolve())) { + Object.defineProperty(globalThis.navigator, "clipboard", { + value: { writeText }, + configurable: true, + writable: true, + }); + return writeText; +} + +describe("CodeBlock", () => { + beforeEach(() => stubClipboard()); + afterEach(() => vi.useRealTimers()); + + it("highlights on the first render (so the same markup can come from SSR)", () => { + const { container } = render({TS}); + + // No await: highlighting is synchronous by design. + expect(container.querySelectorAll("[class^='hljs-']").length).toBeGreaterThan(0); + expect(container.querySelector("code")?.textContent).toBe(TS); + }); + + it("leaves plaintext untouched", () => { + const transcript = "HTTP/2 402\npayment-required: eyJ4NDAy... <- base64"; + const { container } = render({transcript}); + + expect(container.querySelectorAll("[class^='hljs-']").length).toBe(0); + expect(container.querySelector("code")?.textContent).toBe(transcript); + }); + + it("copies the exact source text, not the highlighted markup", async () => { + const writeText = stubClipboard(); + render({TS}); + + fireEvent.click(await screen.findByRole("button", { name: /copy code/i })); + + expect(writeText).toHaveBeenCalledWith(TS); + }); + + it("confirms the copy, then reverts", async () => { + // Fake timers must be installed before the click, or the revert timeout is scheduled on + // the real clock and advancing the fake one does nothing. + vi.useFakeTimers(); + render({TS}); + await act(async () => {}); + const button = screen.getByRole("button", { name: /copy code/i }); + expect(button.textContent).toBe("Copy"); + + fireEvent.click(button); + await act(async () => {}); // let the writeText promise settle + expect(button.textContent).toBe("Copied!"); + + act(() => void vi.advanceTimersByTime(2000)); + expect(button.textContent).toBe("Copy"); + }); + + it("hides the button where the clipboard API is unavailable (non-secure contexts)", async () => { + Object.defineProperty(globalThis.navigator, "clipboard", { value: undefined, configurable: true }); + render({TS}); + + await waitFor(() => expect(screen.queryByRole("button", { name: /copy code/i })).toBeNull()); + }); +}); From d69f4815c1a6543d95e601c0a6747b33099e4d33 Mon Sep 17 00:00:00 2001 From: fretchen Date: Thu, 30 Jul 2026 18:22:19 +0200 Subject: [PATCH 07/12] clean up --- website/components/CodeBlock.tsx | 25 ++++++++++++++++--------- website/components/MermaidDiagram.tsx | 2 +- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/website/components/CodeBlock.tsx b/website/components/CodeBlock.tsx index 1f31dac18..740c2237b 100644 --- a/website/components/CodeBlock.tsx +++ b/website/components/CodeBlock.tsx @@ -13,7 +13,7 @@ * Visual treatment matches the dark blocks on pages/x402 (#1e1e1e / sm type), so that page * can adopt this component later without any visual change. */ -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import hljs from "highlight.js/lib/core"; import typescript from "highlight.js/lib/languages/typescript"; import json from "highlight.js/lib/languages/json"; @@ -67,19 +67,26 @@ export interface CodeBlockProps { lang?: CodeLang; } +/** + * Clipboard availability, read the hydration-safe way: the server snapshot is always false + * (no navigator there), so the SSR markup and the first client render agree, and React + * re-renders with the real value straight after hydration. + */ +const subscribeNoop = () => () => {}; +const clipboardAvailable = () => typeof navigator !== "undefined" && !!navigator.clipboard; +const clipboardUnavailableOnServer = () => false; + export function CodeBlock({ children, lang = "typescript" }: CodeBlockProps) { const [copied, setCopied] = useState(false); - // Rendered server-side too, where there is no navigator β€” resolve after mount so the - // button's presence never differs between the SSR markup and the hydrated DOM. - const [canCopy, setCanCopy] = useState(false); + const canCopy = useSyncExternalStore(subscribeNoop, clipboardAvailable, clipboardUnavailableOnServer); const timer = useRef | null>(null); - useEffect(() => { - setCanCopy(typeof navigator !== "undefined" && !!navigator.clipboard); - return () => { + useEffect( + () => () => { if (timer.current) clearTimeout(timer.current); - }; - }, []); + }, + [], + ); const copy = useCallback(() => { void navigator.clipboard.writeText(children).then( diff --git a/website/components/MermaidDiagram.tsx b/website/components/MermaidDiagram.tsx index c90e4e61e..508df6148 100644 --- a/website/components/MermaidDiagram.tsx +++ b/website/components/MermaidDiagram.tsx @@ -28,7 +28,7 @@ const MermaidDiagram: React.FC = ({ definition, title, clas const configKey = config ? JSON.stringify(config) : ""; const resolvedConfig = useMemo(() => { - const caller = (configKey ? (JSON.parse(configKey) as Record) : {}) as Record; + const caller: Record = configKey ? JSON.parse(configKey) : {}; return { startOnLoad: false, theme: "default" as const, From 86ffd3274c72a63a920fc38c7abbb8ccaf52c174 Mon Sep 17 00:00:00 2001 From: fretchen Date: Thu, 30 Jul 2026 18:52:57 +0200 Subject: [PATCH 08/12] Better onboarding --- website/components/AgentSelector.tsx | 13 + website/components/Foldable.tsx | 8 +- website/components/MermaidDiagram.tsx | 13 +- website/pages/agent-onboarding/+Page.tsx | 1543 +++++++++++----------- 4 files changed, 817 insertions(+), 760 deletions(-) diff --git a/website/components/AgentSelector.tsx b/website/components/AgentSelector.tsx index 925bd3c41..85fc6330c 100644 --- a/website/components/AgentSelector.tsx +++ b/website/components/AgentSelector.tsx @@ -26,6 +26,13 @@ export interface AgentSelectorProps { const rowStyle = css({ display: "flex", gap: "2", flexWrap: "wrap", alignItems: "center", mt: "2" }); const monoStyle = css({ fontFamily: "mono", fontSize: "xs", color: "gray.600", wordBreak: "break-all" }); const errorStyle = css({ fontSize: "xs", color: "red.600", mt: "1" }); +const hintStyle = css({ mt: "2" }); +const hintLinkStyle = css({ + fontSize: "xs", + color: "brand", + textDecoration: "none", + _hover: { textDecoration: "underline" }, +}); const inputStyle = css({ width: "100%", fontSize: "xs", @@ -96,6 +103,12 @@ export function AgentSelector({
    {checkState === "error" && checkError &&
    {checkError}
    } + {/* This box is exactly where someone wonders how to get their own agent here. */} +
    )}
    diff --git a/website/components/Foldable.tsx b/website/components/Foldable.tsx index d28c7b5d3..8a160ca3f 100644 --- a/website/components/Foldable.tsx +++ b/website/components/Foldable.tsx @@ -11,19 +11,21 @@ const wrapper = css({ mb: "3", border: "1px solid token(colors.border, #e5e7eb)", borderRadius: "md", - bg: "white", overflow: "hidden", }); +// The closed state is the one that has to read as a control, so tint the summary rather than +// the whole wrapper β€” an expanded snippet then sits on the page ground, not in a second box. const summary = css({ px: "3", py: "2", fontSize: "sm", fontWeight: "medium", - color: "indigo.700", + color: "brand", + bg: "gray.50", cursor: "pointer", userSelect: "none", - _hover: { bg: "gray.50" }, + _hover: { bg: "gray.100" }, }); const body = css({ px: "3", pb: "3", pt: "1" }); diff --git a/website/components/MermaidDiagram.tsx b/website/components/MermaidDiagram.tsx index 508df6148..e91d74c86 100644 --- a/website/components/MermaidDiagram.tsx +++ b/website/components/MermaidDiagram.tsx @@ -28,11 +28,20 @@ const MermaidDiagram: React.FC = ({ definition, title, clas const configKey = config ? JSON.stringify(config) : ""; const resolvedConfig = useMemo(() => { - const caller: Record = configKey ? JSON.parse(configKey) : {}; + const caller = (configKey ? (JSON.parse(configKey) as unknown) : {}) as Record; return { startOnLoad: false, theme: "default" as const, - securityLevel: "sandbox" as const, + // "strict" (mermaid's own default), NOT "sandbox". Under "sandbox" mermaid returns an + //