From d2b30e259e8d343aa1d841c32ee6fc989c4938a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Thu, 4 Jun 2026 12:28:45 +0100 Subject: [PATCH 1/2] Improve 402 html page --- cmd/obol/sell.go | 28 +- cmd/obol/sell_agent.go | 7 +- cmd/obol/sell_test.go | 4 +- internal/embed/skills/buy-x402/SKILL.md | 12 + internal/embed/skills/buy-x402/scripts/buy.py | 26 +- internal/x402/config.go | 12 + internal/x402/paymentrequired.go | 303 +++++++++++++++--- internal/x402/paymentrequired_test.go | 74 +++++ internal/x402/serviceoffer_source.go | 6 +- internal/x402/templates/payment_required.html | 16 +- internal/x402/verifier.go | 23 +- 11 files changed, 432 insertions(+), 79 deletions(-) diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 45292a7b..40097e06 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -195,8 +195,9 @@ Examples: Usage: "Agent name for ERC-8004 registration (defaults to the offer name)", }, &cli.StringFlag{ - Name: "register-description", - Usage: "Agent description for ERC-8004 registration", + Name: "description", + Aliases: []string{"register-description"}, + Usage: "Human-readable description of the service. Surfaced on the 402 payment page, in the storefront catalog, and (when registration is enabled) on the ERC-8004 registration document.", }, &cli.StringFlag{ Name: "register-image", @@ -296,6 +297,18 @@ Examples: if modelFlag == "" { return fmt.Errorf("--model is required (or run interactively to auto-detect)") } + // LiteLLM's `paid/*` wildcard route doesn't match model names + // containing `/` — buyers signing against this seller would see + // requests fall through to the buyer sidecar with a 404 (see + // CLAUDE.md and the obol-agent's recent buy report). Reject + // up-front and suggest a `--` separator that survives the route. + if strings.Contains(modelFlag, "/") { + return fmt.Errorf( + "--model %q contains '/', which breaks LiteLLM's `paid/*` wildcard on the buyer side; "+ + "use `--` (or another non-slash separator) — e.g. `%s` instead of `%s`", + modelFlag, strings.ReplaceAll(modelFlag, "/", "--"), modelFlag, + ) + } teeType := cmd.String("tee") modelHash := cmd.String("model-hash") @@ -345,7 +358,7 @@ Examples: persistedRegistration, _, regErr := buildSellRegistrationConfig(name, sellRegistrationInput{ NoRegister: cmd.Bool("no-register"), Name: cmd.String("register-name"), - Description: cmd.String("register-description"), + Description: cmd.String("description"), Image: cmd.String("register-image"), Skills: cmd.StringSlice("register-skills"), Domains: cmd.StringSlice("register-domains"), @@ -601,8 +614,9 @@ Examples: Usage: "Agent name for ERC-8004 registration", }, &cli.StringFlag{ - Name: "register-description", - Usage: "Agent description for ERC-8004 registration", + Name: "description", + Aliases: []string{"register-description"}, + Usage: "Human-readable description of the service. Surfaced on the 402 payment page, in the storefront catalog, and (when registration is enabled) on the ERC-8004 registration document.", }, &cli.StringFlag{ Name: "register-image", @@ -799,7 +813,7 @@ Examples: NoRegister: cmd.Bool("no-register"), Register: cmd.Bool("register"), Name: cmd.String("register-name"), - Description: cmd.String("register-description"), + Description: cmd.String("description"), Image: cmd.String("register-image"), Skills: cmd.StringSlice("register-skills"), Domains: cmd.StringSlice("register-domains"), @@ -3861,7 +3875,7 @@ func buildResumeGatewayArgs(d *inference.Deployment) []string { args = append(args, "--register-name", v) } if v, _ := d.Registration["description"].(string); v != "" { - args = append(args, "--register-description", v) + args = append(args, "--description", v) } if v, _ := d.Registration["image"].(string); v != "" { args = append(args, "--register-image", v) diff --git a/cmd/obol/sell_agent.go b/cmd/obol/sell_agent.go index e5fb4068..d7062d00 100644 --- a/cmd/obol/sell_agent.go +++ b/cmd/obol/sell_agent.go @@ -75,8 +75,9 @@ Examples: Usage: "Agent name for ERC-8004 registration (defaults to the offer name)", }, &cli.StringFlag{ - Name: "register-description", - Usage: "Agent description for ERC-8004 registration (defaults to the agent's objective)", + Name: "description", + Aliases: []string{"register-description"}, + Usage: "Human-readable description of the service. Surfaced on the 402 payment page, in the storefront catalog, and (when registration is enabled) on the ERC-8004 registration document. Defaults to the agent's objective.", }, }, Action: func(ctx context.Context, cmd *cli.Command) error { @@ -173,7 +174,7 @@ Examples: if regName == "" { regName = name } - regDesc := strings.TrimSpace(cmd.String("register-description")) + regDesc := strings.TrimSpace(cmd.String("description")) if regDesc == "" { regDesc = agent.Objective } diff --git a/cmd/obol/sell_test.go b/cmd/obol/sell_test.go index 4055fa2c..55df0346 100644 --- a/cmd/obol/sell_test.go +++ b/cmd/obol/sell_test.go @@ -1396,7 +1396,7 @@ func TestBuildResumeGatewayArgs(t *testing.T) { "--per-mtok", "23", "--facilitator", "https://x402.gcp.obol.tech", "--register-name", "Qwen3.6-27B AEON Ultimate", - "--register-description", "Uncensored Qwen3.6-27B abliteration", + "--description", "Uncensored Qwen3.6-27B abliteration", "--register-skills", "llm/inference", "--register-skills", "llm/uncensored", "--register-domains", "inference.v1337.org", @@ -1419,7 +1419,7 @@ func TestBuildResumeGatewayArgs(t *testing.T) { }, wantNoSub: []string{ "--register-name", - "--register-description", + "--description", }, }, { diff --git a/internal/embed/skills/buy-x402/SKILL.md b/internal/embed/skills/buy-x402/SKILL.md index b8ebdf87..72cac5e3 100644 --- a/internal/embed/skills/buy-x402/SKILL.md +++ b/internal/embed/skills/buy-x402/SKILL.md @@ -71,6 +71,18 @@ This is one tx, ~46k gas, valid forever (unless the user later revokes). EIP-300 storefront publishes machine-readable metadata at `/api/services.json` with full asset, EIP-712 signing domain, transfer method, and atomic-unit price for every offered service. +- **`pay` timeout defaults to ~100 s.** This is the Cloudflare free-tier + tunnel cap — longer requests get killed by the edge before our client + ever sees a response. Reasoning models, long generations, or large + batches need `--timeout ` set explicitly, and the seller's own + upstream/edge limit still applies. +- **Avoid `/` in remote model identifiers.** LiteLLM's `paid/*` wildcard + route only matches a single segment; a remote `vendor/model` would + resolve to `paid/vendor/model` and miss the wildcard, so the request + falls through to the buyer sidecar and 404s. Sellers should use a non- + slash separator (e.g. `vendor--model`); buyers signing against a + legacy slashed name need the controller to insert an explicit LiteLLM + entry for the alias (`addLiteLLMModelEntry`). ## When to Use diff --git a/internal/embed/skills/buy-x402/scripts/buy.py b/internal/embed/skills/buy-x402/scripts/buy.py index 7c482983..831dbdfb 100644 --- a/internal/embed/skills/buy-x402/scripts/buy.py +++ b/internal/embed/skills/buy-x402/scripts/buy.py @@ -1698,7 +1698,7 @@ def cmd_balance(chain=None): # Pay (single-shot HTTP/x402 purchase) # --------------------------------------------------------------------------- -def cmd_pay(url, method="GET", data=None, kind="http", network=None): +def cmd_pay(url, method="GET", data=None, kind="http", network=None, timeout=None): """Single-shot paid HTTP request: probe → pre-sign one auth → send with X-PAYMENT. Stateless. Does not create a PurchaseRequest, does not touch the buyer @@ -1708,7 +1708,17 @@ def cmd_pay(url, method="GET", data=None, kind="http", network=None): `network` is an optional safety guard: when set, the seller's advertised chain must match it or `pay` aborts before signing. + + `timeout` is the seconds to wait for the seller's response. Defaults to + ~100s (Cloudflare's free-tier tunnel cap — longer requests are killed by + the edge before our client sees a response anyway). Override for slower + inference (reasoning models, large batches) up to the seller's own + upstream/edge limit. """ + if timeout is None or float(timeout) <= 0: + timeout = 100.0 + else: + timeout = float(timeout) method = (method or "GET").upper() print(f"Probing {url} ...") @@ -1807,7 +1817,7 @@ def cmd_pay(url, method="GET", data=None, kind="http", network=None): print(f"Sending paid {method} {target_url} ...") req = urllib.request.Request(target_url, data=request_data, method=method, headers=headers) try: - with urllib.request.urlopen(req, timeout=60) as resp: + with urllib.request.urlopen(req, timeout=timeout) as resp: body = resp.read().decode(errors="replace") print(f"HTTP {resp.status}") settle = resp.headers.get("X-PAYMENT-RESPONSE") @@ -1949,7 +1959,7 @@ def usage(): print("Commands:") print(" probe [--model ] [--type http|inference] [--method GET|POST]") print(" Probe x402 pricing (default --type inference)") - print(" pay [--type http|inference] [--method GET|POST] [--data ''] [--network ]") + print(" pay [--type http|inference] [--method GET|POST] [--data ''] [--network ] [--timeout ]") print(" Single-shot paid request (sign 1 auth, attach X-PAYMENT)") print(" --network is a guard: aborts if seller is on a different chain") print(" buy --endpoint --model Pre-sign + configure paid/") @@ -1988,18 +1998,26 @@ def usage(): elif cmd == "pay": positional, opts = parse_flags(rest) if not positional: - print("Usage: pay [--type http|inference] [--method GET|POST] [--data ''] [--network ]", file=sys.stderr) + print("Usage: pay [--type http|inference] [--method GET|POST] [--data ''] [--network ] [--timeout ]", file=sys.stderr) sys.exit(1) kind = opts.get("type", "http") if kind not in ("http", "inference"): print(f"Error: --type must be 'http' or 'inference', got '{kind}'", file=sys.stderr) sys.exit(1) + timeout = opts.get("timeout") + if timeout is not None: + try: + timeout = float(timeout) + except ValueError: + print(f"Error: --timeout must be a number of seconds, got '{timeout}'", file=sys.stderr) + sys.exit(1) cmd_pay( positional[0], method=opts.get("method", "GET"), data=opts.get("data"), kind=kind, network=opts.get("network"), + timeout=timeout, ) elif cmd == "buy": diff --git a/internal/x402/config.go b/internal/x402/config.go index cd57265a..545b4d42 100644 --- a/internal/x402/config.go +++ b/internal/x402/config.go @@ -116,6 +116,18 @@ type RouteRule struct { // AgentRuntime is the runtime backing the agent ("hermes", etc). // Surfaced as `accepts[].extra.agentRuntime`. AgentRuntime string `yaml:"agentRuntime,omitempty"` + + // OfferType records the originating ServiceOffer.spec.type + // (inference, http, agent, fine-tuning). The HTML 402 renderer uses + // this to pick type-appropriate copy and Buy CTAs. + OfferType string `yaml:"offerType,omitempty"` + + // Model is the upstream model identifier for inference and agent + // offers. For type=inference it mirrors ServiceOffer.spec.model.name; + // for type=agent it mirrors AgentResolution.Model. Surfaced in the + // 402 page's primary Buy card so users can copy a fully-formed + // `obol buy inference --model ...` command. + Model string `yaml:"model,omitempty"` } // LoadConfig reads and parses a pricing configuration YAML file. diff --git a/internal/x402/paymentrequired.go b/internal/x402/paymentrequired.go index af16ab43..5a53951c 100644 --- a/internal/x402/paymentrequired.go +++ b/internal/x402/paymentrequired.go @@ -53,6 +53,33 @@ type PaymentDisplay struct { // chain (e.g. https://basescan.org/address/0x...). Empty when the chain // isn't in the explorer registry. ExplorerURL string + + // OfferType is the ServiceOffer.spec.type that produced this route + // (inference, agent, http, fine-tuning). Drives the type-aware lede, + // Buy CTAs, and prompt copy. Empty means "http" semantics for the + // renderer (the safest default — single-shot pay). + OfferType string + + // OfferName is the originating ServiceOffer.metadata.name. The HTML + // template surfaces it on the inference Buy card so users get a + // concrete `obol buy inference ` they can paste. + OfferName string + + // OfferDescription is the operator-supplied service description + // (ServiceOffer.spec.registration.description). The renderer surfaces + // it under the Service card and in the OG/meta description when set. + OfferDescription string + + // AgentModel is the upstream model id (when known) — surfaced on the + // inference Buy card so users can copy a full `obol buy inference` + // command with --model pre-filled. + AgentModel string + + // Model is the user-facing upstream model identifier (mirrors + // ServiceOffer.spec.model.name for inference and AgentResolution.Model + // for agent). Preferred over AgentModel for the inference Buy card + // because it's populated for type=inference too. + Model string } // SendPaymentRequiredFunc is the renderer signature compatible with the @@ -157,41 +184,52 @@ func sendPaymentRequiredHTML(w http.ResponseWriter, r *http.Request, requirement } payToDisplay := truncateAddress(payToFull) - promptObol := buildObolPrompt(siteURL, endpoint, display) - promptOther := buildOtherAgentPrompt(siteURL, endpoint, display) + typeCopy := buildTypeCopy(siteURL, endpoint, display) data := struct { - Title string - Description string - PageURL string - StorefrontURL string - WordmarkURL string - OGImageURL string - Endpoint string - NetworkLabel string - PriceDisplay string - PayToDisplay string - PayToFull string - ExplorerURL string - PromptObol string - PromptOther string - JSONBody string + Title string + Description string + PageURL string + StorefrontURL string + WordmarkURL string + OGImageURL string + Endpoint string + NetworkLabel string + PriceDisplay string + PayToDisplay string + PayToFull string + ExplorerURL string + OfferDescription string + Lede string + PrimaryTitle string + PrimaryLede string + PrimaryIsCode bool + PrimaryPayload string + PromptObol string + PromptOther string + JSONBody string }{ - Title: "Payment required — Obol Stack", - Description: buildMetaDescription(display), - PageURL: pageURL, - StorefrontURL: siteURL, - WordmarkURL: siteURL + "/obol-stack-logo.png", - OGImageURL: siteURL + "/og-payment-required.png", - Endpoint: endpoint, - NetworkLabel: networkLabel, - PriceDisplay: priceDisplay, - PayToDisplay: payToDisplay, - PayToFull: payToFull, - ExplorerURL: display.ExplorerURL, - PromptObol: promptObol, - PromptOther: promptOther, - JSONBody: string(indented), + Title: "Payment required — Obol Stack", + Description: buildMetaDescription(display), + PageURL: pageURL, + StorefrontURL: siteURL, + WordmarkURL: siteURL + "/obol-stack-logo.png", + OGImageURL: siteURL + "/og-payment-required.png", + Endpoint: endpoint, + NetworkLabel: networkLabel, + PriceDisplay: priceDisplay, + PayToDisplay: payToDisplay, + PayToFull: payToFull, + ExplorerURL: display.ExplorerURL, + OfferDescription: display.OfferDescription, + Lede: typeCopy.Lede, + PrimaryTitle: typeCopy.PrimaryTitle, + PrimaryLede: typeCopy.PrimaryLede, + PrimaryIsCode: typeCopy.PrimaryIsCode, + PrimaryPayload: typeCopy.PrimaryPayload, + PromptObol: typeCopy.PromptObol, + PromptOther: typeCopy.PromptOther, + JSONBody: string(indented), } var buf bytes.Buffer @@ -206,20 +244,175 @@ func sendPaymentRequiredHTML(w http.ResponseWriter, r *http.Request, requirement _, _ = w.Write(buf.Bytes()) } -// buildMetaDescription returns the shared og/twitter/description string. Uses -// the dynamic price+asset+network when available; otherwise the static fallback. +// buildMetaDescription returns the shared og/twitter/description string. The +// operator-supplied description wins; otherwise we fall back to the dynamic +// price+asset+network blurb (or a static line when neither is available). func buildMetaDescription(d PaymentDisplay) string { + if d.OfferDescription != "" { + return d.OfferDescription + } if d.PriceDisplay != "" && d.NetworkLabel != "" { return fmt.Sprintf("Unlock this Obol Agent service. Pay %s on %s, settled via x402.", d.PriceDisplay, d.NetworkLabel) } return "Unlock this Obol Agent service. Pay per call in USDC or OBOL, settled via x402." } -// buildObolPrompt generates the natural-language instruction the user sends -// to their own Obol Agent. The agent already has the buy-x402 skill loaded, -// so the prompt only needs to identify the endpoint, price, asset, network. -func buildObolPrompt(siteURL, endpoint string, d PaymentDisplay) string { +// typeCopy is the per-offer-type render payload. The renderer produces one +// of these from the PaymentDisplay so the template can stay branch-free. +type typeCopy struct { + // Lede sits under

Payment required

and explains what the + // service actually does at a high level. + Lede string + + // PrimaryTitle / PrimaryLede / PrimaryPayload drive the first "Buy" + // card. For inference, PrimaryPayload is a shell command and + // PrimaryIsCode is true so the template renders it in
;
+	// for agent + http it's a natural-language prompt rendered as a copy
+	// snippet.
+	PrimaryTitle   string
+	PrimaryLede    string
+	PrimaryIsCode  bool
+	PrimaryPayload string
+
+	// PromptObol / PromptOther are the secondary copy cards (Obol agent
+	// w/ buy-x402 skill, and generic AI agent). They follow the
+	// pre-existing one-liner prompt shape.
+	PromptObol  string
+	PromptOther string
+}
+
+func buildTypeCopy(siteURL, endpoint string, d PaymentDisplay) typeCopy {
 	url := siteURL + endpoint
+	switch normalizeOfferType(d.OfferType) {
+	case "inference":
+		return inferenceCopy(url, d)
+	case "agent":
+		return agentCopy(url, d)
+	default:
+		return httpCopy(url, d)
+	}
+}
+
+// normalizeOfferType collapses the spec.type values into the three render
+// branches. Empty falls back to "inference" historically (the original
+// default), but the storefront defaults new offers to "http" — match that
+// behavior here so unknown/unset types stay on the safest (single-shot pay)
+// CTA.
+func normalizeOfferType(t string) string {
+	switch t {
+	case "inference":
+		return "inference"
+	case "agent":
+		return "agent"
+	default:
+		return "http"
+	}
+}
+
+// inferenceCopy: primary CTA is `obol buy inference`, the CLI command that
+// pre-pays the seller and registers the model as `paid/` in the
+// local LiteLLM gateway. Secondary cards still expose the agent-prompt and
+// raw-JSON paths, but reframed so users understand they're buying remote
+// model time, not an agent with tools/memory.
+func inferenceCopy(url string, d PaymentDisplay) typeCopy {
+	model := strings.TrimSpace(d.Model)
+	if model == "" {
+		model = ""
+	}
+	name := strings.TrimSpace(d.OfferName)
+	if name == "" {
+		name = "remote-inference"
+	}
+
+	cmd := fmt.Sprintf(
+		"obol buy inference %s \\\n  --seller %s \\\n  --model %s \\\n  --budget 1 \\\n  --no-verify-identity",
+		name, url, model,
+	)
+
+	prompt := fmt.Sprintf(
+		"Use the buy-x402 skill's `buy` command to pre-pay %s for the %s model. "+
+			"This is remote inference — once the auths are signed, route requests "+
+			"through LiteLLM as `paid/%s` and report the response.",
+		url, model, model,
+	)
+
+	other := fmt.Sprintf(
+		"Read https://obol.org/llms.txt to learn how Obol's x402 micropayments work. "+
+			"I want to use the remote LLM at %s (model %s) as a paid OpenAI-compatible "+
+			"chat-completions endpoint. Pre-sign a budget of EIP-3009/Permit2 authorisations "+
+			"and POST chat-completions bodies with the X-PAYMENT header attached.",
+		url, model,
+	)
+
+	return typeCopy{
+		Lede: "This is a paid inference endpoint — remote model time gated by x402 micropayments. " +
+			"The cleanest way to consume it is the Obol CLI, which loads the model into your local LiteLLM " +
+			"gateway as `paid/` so your agent and tools can call it like any other OpenAI-compatible model.",
+		PrimaryTitle:   "Buy with the Obol CLI",
+		PrimaryLede:    "Pre-pays the seller through your obol-agent's wallet and exposes the model as `paid/" + model + "` in your local LiteLLM gateway. Adjust `--budget`, drop `--no-verify-identity` once the seller is registered on ERC-8004.",
+		PrimaryIsCode:  true,
+		PrimaryPayload: cmd,
+		PromptObol:     prompt,
+		PromptOther:    other,
+	}
+}
+
+// agentCopy: primary CTA is "send a prompt" — agents have skills + tools
+// + memory, not just inference, so the buyer almost always wants to
+// include an instruction with the purchase. POSTs an OpenAI
+// chat-completions body with X-PAYMENT attached so a buying agent has
+// both the structure and the why.
+func agentCopy(url string, d PaymentDisplay) typeCopy {
+	model := strings.TrimSpace(d.Model)
+	modelClause := ""
+	modelLine := ""
+	if model != "" {
+		modelClause = fmt.Sprintf(`"model": "%s",`, model)
+		modelLine = " (running " + model + ")"
+	}
+
+	body := fmt.Sprintf(`POST %s
+Content-Type: application/json
+X-PAYMENT: 
+
+{
+  %s
+  "messages": [
+    {"role": "user", "content": ""}
+  ]
+}`, url, modelClause)
+
+	prompt := fmt.Sprintf(
+		"Use the buy-x402 skill's `pay` command to call the Obol Agent at %s%s. "+
+			"This is an *agent*, not a raw model — it has its own skills, tools, and memory. "+
+			"Include a clear instruction in the chat-completions body so the agent knows what to do.",
+		url, modelLine,
+	)
+
+	other := fmt.Sprintf(
+		"Read https://obol.org/llms.txt to learn how Obol's x402 micropayments work. "+
+			"Help me call the Obol Agent at %s%s — it's an autonomous agent (tools + skills + memory), "+
+			"not a raw LLM. POST OpenAI-style chat-completions JSON with a real prompt in `messages`, "+
+			"attach a signed EIP-3009/Permit2 authorisation as `X-PAYMENT`, and report what the agent does.",
+		url, modelLine,
+	)
+
+	return typeCopy{
+		Lede: "This is an Obol Agent — tools, skills, and memory, not just a model. " +
+			"You're buying one round of work from another autonomous agent, so plan to send it a real prompt: " +
+			"POST OpenAI chat-completions JSON with your instructions in the `messages` array.",
+		PrimaryTitle:   "Send a prompt (OpenAI chat-completions)",
+		PrimaryLede:    "Agents accept OpenAI-style chat-completions bodies. Include your actual instruction in `messages`; the X-PAYMENT header carries one pre-signed authorisation per request.",
+		PrimaryIsCode:  true,
+		PrimaryPayload: body,
+		PromptObol:     prompt,
+		PromptOther:    other,
+	}
+}
+
+// httpCopy: legacy default. Stateless single-shot pay; no model, no
+// pre-payment, no LiteLLM mounting. Matches the pre-existing copy.
+func httpCopy(url string, d PaymentDisplay) typeCopy {
 	priceClause := ""
 	if d.PriceDisplay != "" {
 		priceClause = " Pay " + d.PriceDisplay + "."
@@ -228,30 +421,36 @@ func buildObolPrompt(siteURL, endpoint string, d PaymentDisplay) string {
 	if d.NetworkLabel != "" {
 		netClause = " Network: " + d.NetworkLabel + "."
 	}
-	return fmt.Sprintf("Use the buy-x402 skill to buy access to %s.%s%s", url, priceClause, netClause)
-}
+	prompt := fmt.Sprintf("Use the buy-x402 skill's `pay` command to call %s once.%s%s", url, priceClause, netClause)
 
-// buildOtherAgentPrompt generates a self-contained instruction for any
-// generic AI agent (Claude, ChatGPT, Gemini, etc.) that does NOT have the
-// Obol skills pre-loaded. It points the agent at obol.org/llms.txt and
-// the public skills repo so it can self-orient before signing the payment.
-func buildOtherAgentPrompt(siteURL, endpoint string, d PaymentDisplay) string {
-	url := siteURL + endpoint
-	priceClause := "the listed price"
+	priceWord := "the listed price"
 	if d.PriceDisplay != "" {
-		priceClause = d.PriceDisplay
+		priceWord = d.PriceDisplay
 	}
-	netClause := ""
+	onNet := ""
 	if d.NetworkLabel != "" {
-		netClause = " on " + d.NetworkLabel
+		onNet = " on " + d.NetworkLabel
 	}
-	return fmt.Sprintf(
+	other := fmt.Sprintf(
 		"Read https://obol.org/llms.txt and skim https://github.com/ObolNetwork/skills "+
 			"to learn how Obol Agents pay for x402 services. Then help me buy access to %s "+
 			"for %s%s. Sign the EIP-3009 or Permit2 authorisation and call the endpoint "+
 			"with the X-PAYMENT header.",
-		url, priceClause, netClause,
+		url, priceWord, onNet,
 	)
+
+	return typeCopy{
+		Lede:          "This is a paid HTTP endpoint gated by x402 micropayments. Each call is a one-shot purchase — no subscription, no pre-payment, no LLM model behind it.",
+		PrimaryTitle:  "Pay with your Obol Agent",
+		PrimaryLede:   "Paste this into your Obol Agent — it has the `buy-x402` skill pre-loaded and will sign one authorisation per request.",
+		PrimaryIsCode: false,
+		// PrimaryPayload doubles as PromptObol for http; keep both
+		// populated so the template can stay symmetrical and the
+		// "Pay with another AI agent" card still renders.
+		PrimaryPayload: prompt,
+		PromptObol:     prompt,
+		PromptOther:    other,
+	}
 }
 
 // truncateAddress shortens a hex address for display: 0xa1b2c3...f9c0.
diff --git a/internal/x402/paymentrequired_test.go b/internal/x402/paymentrequired_test.go
index e5e3302d..78a6ea95 100644
--- a/internal/x402/paymentrequired_test.go
+++ b/internal/x402/paymentrequired_test.go
@@ -177,6 +177,80 @@ func TestHTMLAware_DegradeWithoutDisplay(t *testing.T) {
 	mustContain(t, body, "1000 (atomic units)") // price falls back to atomic units
 }
 
+// Inference offers should surface the canonical `obol buy inference` CLI
+// command as the primary Buy card with the seller URL and model id
+// pre-filled, plus the operator-supplied description on the Service card.
+func TestHTMLAware_InferenceShowsCLIPrimaryAndDescription(t *testing.T) {
+	d := sampleDisplay()
+	d.OfferType = "inference"
+	d.OfferName = "aeon7"
+	d.Model = "AEON-7"
+	d.OfferDescription = "Remote 35B reasoning model with 32k context."
+
+	render := NewHTMLAwarePaymentRequired(d)
+	r := httptest.NewRequest("GET", "/services/aeon7", nil)
+	r.Header.Set("Accept", "text/html")
+	r.Header.Set("X-Forwarded-Host", "agent.example.tunnel.dev")
+	r.Header.Set("X-Forwarded-Proto", "https")
+	w := httptest.NewRecorder()
+	render(w, r, []x402types.PaymentRequirements{sampleRequirement()}, nil)
+
+	body := w.Body.String()
+	mustContain(t, body, "Buy with the Obol CLI")
+	mustContain(t, body, "obol buy inference aeon7")
+	mustContain(t, body, "--model AEON-7")
+	mustContain(t, body, "https://agent.example.tunnel.dev/services/agent-quant")
+	mustContain(t, body, "paid/AEON-7")
+	// Operator description bubbles into Service card + OG.
+	mustContain(t, body, "Remote 35B reasoning model with 32k context.")
+	// Lede explains that you're paying for remote inference, not an agent.
+	mustContain(t, body, "remote model time")
+}
+
+// Agent offers should explain that the buyer needs a prompt and POST a
+// chat-completions body, not just attach a payment header.
+func TestHTMLAware_AgentShowsChatCompletionsPrimary(t *testing.T) {
+	d := sampleDisplay()
+	d.OfferType = "agent"
+	d.OfferName = "agent-quant"
+	d.Model = "qwen3.5:9b"
+
+	render := NewHTMLAwarePaymentRequired(d)
+	r := httptest.NewRequest("GET", "/services/agent-quant", nil)
+	r.Header.Set("Accept", "text/html")
+	w := httptest.NewRecorder()
+	render(w, r, []x402types.PaymentRequirements{sampleRequirement()}, nil)
+
+	body := w.Body.String()
+	mustContain(t, body, "Send a prompt (OpenAI chat-completions)")
+	// JSON snippet sits inside 
; html/template escapes quotes.
+	mustContain(t, body, `"model": "qwen3.5:9b"`)
+	mustContain(t, body, `"messages":`)
+	mustContain(t, body, "X-PAYMENT")
+	// Lede frames the offer as an agent (tools/skills/memory), not a model.
+	mustContain(t, body, "tools, skills, and memory")
+}
+
+// HTTP offers (the default) keep the existing single-prompt Pay-with-Obol
+// CTA — no CLI command, no chat-completions body.
+func TestHTMLAware_HTTPKeepsLegacyCopy(t *testing.T) {
+	d := sampleDisplay()
+	d.OfferType = "http"
+
+	render := NewHTMLAwarePaymentRequired(d)
+	r := httptest.NewRequest("GET", "/services/agent-quant", nil)
+	r.Header.Set("Accept", "text/html")
+	w := httptest.NewRecorder()
+	render(w, r, []x402types.PaymentRequirements{sampleRequirement()}, nil)
+
+	body := w.Body.String()
+	mustContain(t, body, "Pay with your Obol Agent")
+	mustContain(t, body, "buy-x402 skill")
+	if strings.Contains(body, "obol buy inference") {
+		t.Errorf("http-type 402 page should NOT show the inference CLI primary card")
+	}
+}
+
 func TestFormatAmount(t *testing.T) {
 	cases := []struct {
 		atomic   string
diff --git a/internal/x402/serviceoffer_source.go b/internal/x402/serviceoffer_source.go
index 54fb1066..b822e2c2 100644
--- a/internal/x402/serviceoffer_source.go
+++ b/internal/x402/serviceoffer_source.go
@@ -160,7 +160,8 @@ func routeRuleFromOffer(offer *monetizeapi.ServiceOffer, upstreamAuth string) (R
 	rule := RouteRule{
 		Pattern:                strings.TrimSuffix(offer.EffectivePath(), "/") + "/*",
 		Price:                  price,
-		Description:            fmt.Sprintf("ServiceOffer %s", offer.Name),
+		Description:            offer.Spec.Registration.Description,
+		OfferType:              offer.Spec.Type,
 		PayTo:                  offer.Spec.Payment.PayTo,
 		Network:                NormalizeNetworkID(offer.Spec.Payment.Network),
 		AssetAddress:           offer.Spec.Payment.Asset.Address,
@@ -184,6 +185,9 @@ func routeRuleFromOffer(offer *monetizeapi.ServiceOffer, upstreamAuth string) (R
 		rule.AgentModel = res.Model
 		rule.AgentSkills = append([]string(nil), res.Skills...)
 		rule.AgentRuntime = res.Runtime
+		rule.Model = res.Model
+	} else {
+		rule.Model = offer.Spec.Model.Name
 	}
 
 	return rule, nil
diff --git a/internal/x402/templates/payment_required.html b/internal/x402/templates/payment_required.html
index a7a4b102..914bf565 100644
--- a/internal/x402/templates/payment_required.html
+++ b/internal/x402/templates/payment_required.html
@@ -151,10 +151,11 @@
       
 
       

Payment required

-

This Obol Agent service requires a payment to access.

+

{{.Lede}}

Service

+ {{if .OfferDescription}}

{{.OfferDescription}}

{{end}}
Endpoint
{{.Endpoint}}
Network
{{.NetworkLabel}}
@@ -176,6 +177,19 @@

Service

+
+

{{.PrimaryTitle}}

+

{{.PrimaryLede}}

+
+ {{if .PrimaryIsCode}} +
{{.PrimaryPayload}}
+ {{else}} +
{{.PrimaryPayload}}
+ {{end}} + +
+
+

Pay with your Obol Agent

Send this to your Obol Agent (it has the buy-x402 skill pre-loaded):

diff --git a/internal/x402/verifier.go b/internal/x402/verifier.go index ab75fe18..54ede2d6 100644 --- a/internal/x402/verifier.go +++ b/internal/x402/verifier.go @@ -403,15 +403,20 @@ func mergeAgentExtras(req *x402types.PaymentRequirements, rule *RouteRule) { // (1 OBOL → 0.000000000000000001 OBOL). func buildPaymentDisplay(rule *RouteRule, chain ChainInfo, asset AssetInfo, payTo, atomicAmount string) PaymentDisplay { return PaymentDisplay{ - Endpoint: rule.Pattern, - Network: chain.Name, - NetworkLabel: humanizeNetwork(chain.Name), - AssetSymbol: asset.Symbol, - AssetAddress: asset.Address, - PriceDisplay: FormatPriceDisplay(atomicAmount, asset.Decimals, asset.Symbol), - PriceAtomic: atomicAmount, - PayToFull: payTo, - ExplorerURL: explorerAddressURL(chain.Name, payTo), + Endpoint: rule.Pattern, + Network: chain.Name, + NetworkLabel: humanizeNetwork(chain.Name), + AssetSymbol: asset.Symbol, + AssetAddress: asset.Address, + PriceDisplay: FormatPriceDisplay(atomicAmount, asset.Decimals, asset.Symbol), + PriceAtomic: atomicAmount, + PayToFull: payTo, + ExplorerURL: explorerAddressURL(chain.Name, payTo), + OfferType: rule.OfferType, + OfferName: rule.OfferName, + OfferDescription: rule.Description, + AgentModel: rule.AgentModel, + Model: rule.Model, } } From afdd495d4c6282831f597de117fa7bccb8d5baea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Thu, 4 Jun 2026 13:55:31 +0100 Subject: [PATCH 2/2] Update storefronts --- cmd/obol/sell.go | 2 +- cmd/obol/sell_agent.go | 10 + internal/schemas/service-catalog.schema.json | 8 + internal/schemas/service_catalog.go | 8 +- internal/serviceoffercontroller/render.go | 32 +++- internal/x402/paymentrequired.go | 171 +++++++++++------- internal/x402/paymentrequired_test.go | 42 ++++- internal/x402/templates/payment_required.html | 28 +++ internal/x402/verifier.go | 1 + plans/openapi-402-followups.md | 157 ++++++++++++++++ .../src/components/ServiceCard.tsx | 139 +++++++++++++- web/public-storefront/src/types.ts | 6 + web/public-storefront/tsconfig.tsbuildinfo | 2 +- 13 files changed, 517 insertions(+), 89 deletions(-) create mode 100644 plans/openapi-402-followups.md diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 40097e06..9bd923a7 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -1376,7 +1376,7 @@ var demoTypes = map[string]demoSpec{ "quant": { Type: "quant", Price: "10", - Description: "Agent-backed chain analyst (Agent CRD + ServiceOffer of type=agent)", + Description: "A simple example agent that can analyse Ethereum and Base for you", NeedsERPC: true, DefaultChain: "ethereum", DefaultToken: "OBOL", diff --git a/cmd/obol/sell_agent.go b/cmd/obol/sell_agent.go index d7062d00..f185f043 100644 --- a/cmd/obol/sell_agent.go +++ b/cmd/obol/sell_agent.go @@ -419,6 +419,16 @@ func runAgentBackedDemo( "metadata": map[string]any{ "name": name, "namespace": offerNs, + // Agent-backed demos can't live in the legacy "demo" + // namespace today (the controller's confused-deputy guard at + // agent_resolver.go forces spec.agent.ref.namespace == + // offer.namespace), so the catalog renderer can't infer + // "demo" from offer.namespace alone. The obol.org/demo + // label is the explicit signal — keep it set here so quant + // and friends show up under "Demo services" on the + // storefront. Drop this once we relax the cross-namespace + // guard (see plans/openapi-402-followups.md). + "labels": map[string]any{"obol.org/demo": "true"}, }, "spec": specMap, } diff --git a/internal/schemas/service-catalog.schema.json b/internal/schemas/service-catalog.schema.json index fb02742a..5a9808d2 100644 --- a/internal/schemas/service-catalog.schema.json +++ b/internal/schemas/service-catalog.schema.json @@ -147,6 +147,14 @@ "type": "string", "minLength": 1 }, + "skills": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "OASF skills or buy-x402 skills the agent advertises. For type=agent offers this mirrors the resolved Agent.spec.skills allow-list; for non-agent offers it mirrors spec.registration.skills." + }, "isDemo": { "type": "boolean" }, diff --git a/internal/schemas/service_catalog.go b/internal/schemas/service_catalog.go index 6f839135..6423b31d 100644 --- a/internal/schemas/service_catalog.go +++ b/internal/schemas/service_catalog.go @@ -30,7 +30,13 @@ type ServiceCatalogEntry struct { ChainID int64 `json:"chainId,omitempty"` Asset *ServiceCatalogAsset `json:"asset,omitempty"` Description string `json:"description"` - IsDemo bool `json:"isDemo"` + // Skills are the OASF / buy-x402 skill names this offer advertises. + // For type=agent offers it mirrors AgentResolution.Skills (the + // resolved allow-list from the linked Agent CR); for non-agent + // offers it mirrors spec.registration.skills. Surfaced as pills on + // the storefront ServiceCard. + Skills []string `json:"skills,omitempty"` + IsDemo bool `json:"isDemo"` // RegistrationPending is true when the offer is operationally ready // (route published, payment gate active, upstream healthy) but its diff --git a/internal/serviceoffercontroller/render.go b/internal/serviceoffercontroller/render.go index b892daa2..b55b6027 100644 --- a/internal/serviceoffercontroller/render.go +++ b/internal/serviceoffercontroller/render.go @@ -912,6 +912,24 @@ func offerOperationallyReady(offer *monetizeapi.ServiceOffer) bool { // ready but has its on-chain ERC-8004 registration still pending. Used to // flip ServiceCatalogEntry.RegistrationPending so storefront UIs can show // a "registration pending" badge alongside the usable offer. +// isDemoOffer reports whether an offer should be rendered under the +// storefront's "Demo services" group. The legacy demo path puts offers +// directly in the "demo" namespace, but the agent-backed demo path +// (`obol sell demo quant`) lands the offer in agent- because the +// controller's confused-deputy guard requires the ServiceOffer and the +// referenced Agent CR to share a namespace. To keep both paths grouping +// together on the storefront, the CLI sets obol.org/demo=true on +// agent-backed demos and we honour either signal. +func isDemoOffer(offer *monetizeapi.ServiceOffer) bool { + if offer == nil { + return false + } + if offer.Namespace == "demo" { + return true + } + return offer.Labels["obol.org/demo"] == "true" +} + func offerAwaitingRegistration(offer *monetizeapi.ServiceOffer) bool { if offer == nil { return false @@ -978,6 +996,17 @@ func buildServiceCatalogJSON(offers []*monetizeapi.ServiceOffer, baseURL string) drainEndsAt = offer.DrainEndsAt().UTC().Format(time.RFC3339) } + // Skills source matches the 402 renderer: for type=agent the + // resolved Agent allow-list wins (controller-populated), with a + // fallback to spec.registration.skills for non-agent offers + // that still want to surface skill tags on discovery. + var skills []string + if offer.IsAgent() && offer.Status.AgentResolution != nil && len(offer.Status.AgentResolution.Skills) > 0 { + skills = append([]string(nil), offer.Status.AgentResolution.Skills...) + } else if len(offer.Spec.Registration.Skills) > 0 { + skills = append([]string(nil), offer.Spec.Registration.Skills...) + } + svc := schemas.ServiceCatalogEntry{ Name: offer.Name, Namespace: offer.Namespace, @@ -988,7 +1017,8 @@ func buildServiceCatalogJSON(offers []*monetizeapi.ServiceOffer, baseURL string) PayTo: offer.Spec.Payment.PayTo, Network: offer.Spec.Payment.Network, Description: desc, - IsDemo: offer.Namespace == "demo", + Skills: skills, + IsDemo: isDemoOffer(offer), RegistrationPending: offerAwaitingRegistration(offer), DrainEndsAt: drainEndsAt, } diff --git a/internal/x402/paymentrequired.go b/internal/x402/paymentrequired.go index 5a53951c..3455a274 100644 --- a/internal/x402/paymentrequired.go +++ b/internal/x402/paymentrequired.go @@ -80,6 +80,12 @@ type PaymentDisplay struct { // for agent). Preferred over AgentModel for the inference Buy card // because it's populated for type=inference too. Model string + + // AgentSkills is the resolved skill allow-list for an agent-type + // offer. Surfaced as pills under the description in the Service + // card. Empty for non-agent offers and for agents that haven't yet + // resolved their skill list. + AgentSkills []string } // SendPaymentRequiredFunc is the renderer signature compatible with the @@ -187,49 +193,57 @@ func sendPaymentRequiredHTML(w http.ResponseWriter, r *http.Request, requirement typeCopy := buildTypeCopy(siteURL, endpoint, display) data := struct { - Title string - Description string - PageURL string - StorefrontURL string - WordmarkURL string - OGImageURL string - Endpoint string - NetworkLabel string - PriceDisplay string - PayToDisplay string - PayToFull string - ExplorerURL string - OfferDescription string - Lede string - PrimaryTitle string - PrimaryLede string - PrimaryIsCode bool - PrimaryPayload string - PromptObol string - PromptOther string - JSONBody string + Title string + Description string + PageURL string + StorefrontURL string + WordmarkURL string + OGImageURL string + Endpoint string + NetworkLabel string + PriceDisplay string + PayToDisplay string + PayToFull string + ExplorerURL string + OfferDescription string + Skills []string + Lede template.HTML + ShowPrimary bool + PrimaryTitle string + PrimaryLede string + PrimaryIsCode bool + PrimaryPayload string + PromptObol string + PromptOther string + JSONBody string + ChatCompletionsNote string + ChatCompletionsBody string }{ - Title: "Payment required — Obol Stack", - Description: buildMetaDescription(display), - PageURL: pageURL, - StorefrontURL: siteURL, - WordmarkURL: siteURL + "/obol-stack-logo.png", - OGImageURL: siteURL + "/og-payment-required.png", - Endpoint: endpoint, - NetworkLabel: networkLabel, - PriceDisplay: priceDisplay, - PayToDisplay: payToDisplay, - PayToFull: payToFull, - ExplorerURL: display.ExplorerURL, - OfferDescription: display.OfferDescription, - Lede: typeCopy.Lede, - PrimaryTitle: typeCopy.PrimaryTitle, - PrimaryLede: typeCopy.PrimaryLede, - PrimaryIsCode: typeCopy.PrimaryIsCode, - PrimaryPayload: typeCopy.PrimaryPayload, - PromptObol: typeCopy.PromptObol, - PromptOther: typeCopy.PromptOther, - JSONBody: string(indented), + Title: "Payment required — Obol Stack", + Description: buildMetaDescription(display), + PageURL: pageURL, + StorefrontURL: siteURL, + WordmarkURL: siteURL + "/obol-stack-logo.png", + OGImageURL: siteURL + "/og-payment-required.png", + Endpoint: endpoint, + NetworkLabel: networkLabel, + PriceDisplay: priceDisplay, + PayToDisplay: payToDisplay, + PayToFull: payToFull, + ExplorerURL: display.ExplorerURL, + OfferDescription: display.OfferDescription, + Skills: display.AgentSkills, + Lede: typeCopy.Lede, + ShowPrimary: typeCopy.ShowPrimary, + PrimaryTitle: typeCopy.PrimaryTitle, + PrimaryLede: typeCopy.PrimaryLede, + PrimaryIsCode: typeCopy.PrimaryIsCode, + PrimaryPayload: typeCopy.PrimaryPayload, + PromptObol: typeCopy.PromptObol, + PromptOther: typeCopy.PromptOther, + JSONBody: string(indented), + ChatCompletionsNote: typeCopy.ChatCompletionsNote, + ChatCompletionsBody: typeCopy.ChatCompletionsBody, } var buf bytes.Buffer @@ -261,14 +275,24 @@ func buildMetaDescription(d PaymentDisplay) string { // of these from the PaymentDisplay so the template can stay branch-free. type typeCopy struct { // Lede sits under

Payment required

and explains what the - // service actually does at a high level. - Lede string + // service actually does at a high level. Typed as template.HTML so + // branches can include trusted anchor tags (e.g. a docs link) — all + // interpolated values must be either constant or first passed + // through html/template's standard escaping; no PaymentDisplay + // strings are interpolated raw. + Lede template.HTML + + // ShowPrimary toggles the first "Buy" card. Inference and http + // surface a primary CTA card (a copy-able CLI command or Obol-agent + // prompt); agent type omits it because the actionable example lives + // in the Pay-manually card alongside the raw 402 JSON. + ShowPrimary bool // PrimaryTitle / PrimaryLede / PrimaryPayload drive the first "Buy" - // card. For inference, PrimaryPayload is a shell command and - // PrimaryIsCode is true so the template renders it in
;
-	// for agent + http it's a natural-language prompt rendered as a copy
-	// snippet.
+	// card when ShowPrimary is true. For inference, PrimaryPayload is a
+	// shell command and PrimaryIsCode is true so the template renders it
+	// in 
; for http it's a natural-language prompt rendered
+	// as a copy snippet.
 	PrimaryTitle   string
 	PrimaryLede    string
 	PrimaryIsCode  bool
@@ -279,6 +303,14 @@ type typeCopy struct {
 	// pre-existing one-liner prompt shape.
 	PromptObol  string
 	PromptOther string
+
+	// ChatCompletionsNote / ChatCompletionsBody render inside the "Pay
+	// manually (raw HTTP 402)" card *after* the x402 wire JSON, and are
+	// only populated for agent-type offers. The note is the explanatory
+	// sentence; the body is the literal example request that buyers can
+	// copy and adapt. Empty values mean "don't render the block".
+	ChatCompletionsNote string
+	ChatCompletionsBody string
 }
 
 func buildTypeCopy(siteURL, endpoint string, d PaymentDisplay) typeCopy {
@@ -345,9 +377,10 @@ func inferenceCopy(url string, d PaymentDisplay) typeCopy {
 	)
 
 	return typeCopy{
-		Lede: "This is a paid inference endpoint — remote model time gated by x402 micropayments. " +
+		Lede: template.HTML("This is a paid inference endpoint — remote model time gated by x402 micropayments. " +
 			"The cleanest way to consume it is the Obol CLI, which loads the model into your local LiteLLM " +
-			"gateway as `paid/` so your agent and tools can call it like any other OpenAI-compatible model.",
+			"gateway as paid/<model> so your agent and tools can call it like any other OpenAI-compatible model."),
+		ShowPrimary:    true,
 		PrimaryTitle:   "Buy with the Obol CLI",
 		PrimaryLede:    "Pre-pays the seller through your obol-agent's wallet and exposes the model as `paid/" + model + "` in your local LiteLLM gateway. Adjust `--budget`, drop `--no-verify-identity` once the seller is registered on ERC-8004.",
 		PrimaryIsCode:  true,
@@ -357,11 +390,12 @@ func inferenceCopy(url string, d PaymentDisplay) typeCopy {
 	}
 }
 
-// agentCopy: primary CTA is "send a prompt" — agents have skills + tools
-// + memory, not just inference, so the buyer almost always wants to
-// include an instruction with the purchase. POSTs an OpenAI
-// chat-completions body with X-PAYMENT attached so a buying agent has
-// both the structure and the why.
+// agentCopy: agents have skills + tools + memory, not just inference, so
+// the buyer almost always wants to include an instruction with the
+// purchase. The primary "Buy" card is suppressed; the Obol-Agent and
+// Other-AI-Agent prompt cards drive the action, and a chat-completions
+// example sits next to the raw x402 JSON in the Pay-manually card to
+// make the wire shape obvious to readers walking the spec by hand.
 func agentCopy(url string, d PaymentDisplay) typeCopy {
 	model := strings.TrimSpace(d.Model)
 	modelClause := ""
@@ -398,15 +432,21 @@ X-PAYMENT: 
 	)
 
 	return typeCopy{
-		Lede: "This is an Obol Agent — tools, skills, and memory, not just a model. " +
-			"You're buying one round of work from another autonomous agent, so plan to send it a real prompt: " +
-			"POST OpenAI chat-completions JSON with your instructions in the `messages` array.",
-		PrimaryTitle:   "Send a prompt (OpenAI chat-completions)",
-		PrimaryLede:    "Agents accept OpenAI-style chat-completions bodies. Include your actual instruction in `messages`; the X-PAYMENT header carries one pre-signed authorisation per request.",
-		PrimaryIsCode:  true,
-		PrimaryPayload: body,
-		PromptObol:     prompt,
-		PromptOther:    other,
+		Lede: template.HTML(
+			"This is a payment gate for an Obol Agent — it has tools, skills, and memory, it's " +
+				"not just a model. You're buying one round of work from it, so plan to send it " +
+				"a real prompt. The description below tells you what the agent specialises in. " +
+				"The future of agentic commerce is agents paying for specialist work from " +
+				`specialised agents. Learn more at docs.obol.org/obol-stack.`,
+		),
+		// Primary card is suppressed for agents — the actionable
+		// example lives next to the raw x402 JSON instead.
+		ShowPrimary:         false,
+		PromptObol:          prompt,
+		PromptOther:         other,
+		ChatCompletionsNote: "Obol Agents accept OpenAI-style chat-completions bodies. A request like the following will get you an answer:",
+		ChatCompletionsBody: body,
 	}
 }
 
@@ -440,7 +480,8 @@ func httpCopy(url string, d PaymentDisplay) typeCopy {
 	)
 
 	return typeCopy{
-		Lede:          "This is a paid HTTP endpoint gated by x402 micropayments. Each call is a one-shot purchase — no subscription, no pre-payment, no LLM model behind it.",
+		Lede:          template.HTML("This is a paid HTTP endpoint gated by x402 micropayments. Each call is a one-shot purchase — no subscription, no pre-payment, no LLM model behind it."),
+		ShowPrimary:   true,
 		PrimaryTitle:  "Pay with your Obol Agent",
 		PrimaryLede:   "Paste this into your Obol Agent — it has the `buy-x402` skill pre-loaded and will sign one authorisation per request.",
 		PrimaryIsCode: false,
diff --git a/internal/x402/paymentrequired_test.go b/internal/x402/paymentrequired_test.go
index 78a6ea95..6aa6e6cd 100644
--- a/internal/x402/paymentrequired_test.go
+++ b/internal/x402/paymentrequired_test.go
@@ -207,28 +207,50 @@ func TestHTMLAware_InferenceShowsCLIPrimaryAndDescription(t *testing.T) {
 	mustContain(t, body, "remote model time")
 }
 
-// Agent offers should explain that the buyer needs a prompt and POST a
-// chat-completions body, not just attach a payment header.
-func TestHTMLAware_AgentShowsChatCompletionsPrimary(t *testing.T) {
+// Agent offers should explain that the buyer is paying an autonomous
+// agent (not a model), surface its skills as pills under the description,
+// and append a chat-completions example next to the raw x402 JSON in the
+// "Pay manually" card rather than a separate primary CTA.
+func TestHTMLAware_AgentShowsChatCompletionsInPayManually(t *testing.T) {
 	d := sampleDisplay()
 	d.OfferType = "agent"
-	d.OfferName = "agent-quant"
+	d.OfferName = "quant"
 	d.Model = "qwen3.5:9b"
+	d.OfferDescription = "A simple example agent that can analyse Ethereum and Base for you"
+	d.AgentSkills = []string{"ethereum-networks", "gas", "addresses"}
 
 	render := NewHTMLAwarePaymentRequired(d)
-	r := httptest.NewRequest("GET", "/services/agent-quant", nil)
+	r := httptest.NewRequest("GET", "/services/quant", nil)
 	r.Header.Set("Accept", "text/html")
 	w := httptest.NewRecorder()
 	render(w, r, []x402types.PaymentRequirements{sampleRequirement()}, nil)
 
 	body := w.Body.String()
-	mustContain(t, body, "Send a prompt (OpenAI chat-completions)")
-	// JSON snippet sits inside 
; html/template escapes quotes.
+
+	// Primary "Send a prompt" card is gone — the example body lives in
+	// the Pay-manually card instead, after the raw x402 JSON.
+	if strings.Contains(body, "Send a prompt (OpenAI chat-completions)") {
+		t.Errorf("agent-type 402 page should NOT render a primary 'Send a prompt' card")
+	}
+	mustContain(t, body, "Pay manually (raw HTTP 402)")
+	mustContain(t, body, "Obol Agents accept OpenAI-style chat-completions bodies")
+	// Example chat-completions body (JSON snippet inside 
; html/template
+	// escapes the quotes).
 	mustContain(t, body, `"model": "qwen3.5:9b"`)
 	mustContain(t, body, `"messages":`)
-	mustContain(t, body, "X-PAYMENT")
-	// Lede frames the offer as an agent (tools/skills/memory), not a model.
-	mustContain(t, body, "tools, skills, and memory")
+
+	// Lede uses the operator-facing copy and links to docs.obol.org.
+	mustContain(t, body, "payment gate for an Obol Agent")
+	mustContain(t, body, "future of agentic commerce")
+	mustContain(t, body, `href="https://docs.obol.org/obol-stack"`)
+
+	// Description renders in the Service card.
+	mustContain(t, body, "A simple example agent that can analyse Ethereum and Base for you")
+
+	// Skills render as pills under the description.
+	mustContain(t, body, `ethereum-networks`)
+	mustContain(t, body, `gas`)
+	mustContain(t, body, `addresses`)
 }
 
 // HTTP offers (the default) keep the existing single-prompt Pay-with-Obol
diff --git a/internal/x402/templates/payment_required.html b/internal/x402/templates/payment_required.html
index 914bf565..c3df858f 100644
--- a/internal/x402/templates/payment_required.html
+++ b/internal/x402/templates/payment_required.html
@@ -107,6 +107,22 @@
         word-break: break-word;
       }
       .prompt + .prompt { margin-top: 12px; }
+      .skill-pills { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 4px; }
+      .skill-pill {
+        display: inline-block;
+        background: var(--bg03);
+        border: 1px solid var(--stroke);
+        color: var(--body);
+        font: 500 12px/1 "DM Sans", system-ui, sans-serif;
+        padding: 4px 10px;
+        border-radius: 999px;
+      }
+      .chat-completions {
+        margin-top: 16px;
+        padding-top: 14px;
+        border-top: 1px dashed var(--stroke);
+      }
+      .chat-completions p { margin: 0 0 10px; color: var(--body); font-size: 14px; }
       pre.json {
         margin: 0;
         background: var(--bg01);
@@ -156,6 +172,7 @@ 

Payment required

Service

{{if .OfferDescription}}

{{.OfferDescription}}

{{end}} + {{if .Skills}}
{{range .Skills}}{{.}}{{end}}
{{end}}
Endpoint
{{.Endpoint}}
Network
{{.NetworkLabel}}
@@ -177,6 +194,7 @@

Service

+ {{if .ShowPrimary}}

{{.PrimaryTitle}}

{{.PrimaryLede}}

@@ -189,6 +207,7 @@

{{.PrimaryTitle}}

+ {{end}}

Pay with your Obol Agent

@@ -215,6 +234,15 @@

Pay manually (raw HTTP 402)

{{.JSONBody}}
+ {{if .ChatCompletionsBody}} +
+ {{if .ChatCompletionsNote}}

{{.ChatCompletionsNote}}

{{end}} +
+
{{.ChatCompletionsBody}}
+ +
+
+ {{end}}