Skip to content

feat(storefront): built-in browser chat widget for agent-type offers#752

Open
bussyjd wants to merge 15 commits into
mainfrom
feat/agent-chat-widget
Open

feat(storefront): built-in browser chat widget for agent-type offers#752
bussyjd wants to merge 15 commits into
mainfrom
feat/agent-chat-widget

Conversation

@bussyjd

@bussyjd bussyjd commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Stacked on #753 (page-surface naming) — review the 12 commits above its 3.

Every agent-type offer automatically gets a built-in browser chat client: a /chat page and an embedded chat card on its landing page, themed per offer through the same resolved storefront profile as the rest of its origin. No signup, no API key: connect an injected wallet, one signature derives a deterministic local session wallet (domain-bound message, key never stored or displayed), fund it once with USDC, and every message pays per-turn via x402 (EIP-3009, gasless) — payment is the authentication.

Highlights beyond the original widget:

  • Streaming: stream:true + line-buffered SSE rendering (JSON fallback kept). Settlement fires at stream-open via the existing header-time interceptor; verified end-to-end with settled paid probes.
  • Per-payment safety ceiling: an x402Client policy refuses any single payment above the current session balance — a mid-session price change can never silently drain more than the user parked.
  • One-click onboarding: connect chains straight into sign-in (same user gesture); the separate sign-in stage remains as the rejection-retry path.
  • Chrome-less embed: framed on the landing page the widget hides its own header (title/price live on the page around it); full-page /chat keeps full context.
  • Verifier hardening: the browser-facing proxy strips Origin/Sec-Fetch-* before re-issuing upstream, so origin-allowlisting upstreams (hermes API server default-deny) work for paid browser traffic without CORS wildcards.
  • Ops correctness: widget assets folded into the catalog content hash (controller upgrades roll the httpd), route-level Cache-Control (no-cache HTML/JSON, immutable hashed vendor), GET-scoped discovery routes so root-priced offers keep their paid POST /.

The vendor bundle (viem + @x402/fetch + @x402/evm, esbuild, pinned + hash-cache-busted) is embedded and served same-origin — no CDN at runtime. Rebuild recipe in assets/README.md.

Tests green (go test ./...); commits signed; running live-verified on a production stack.

https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk

@bussyjd bussyjd marked this pull request as draft July 14, 2026 18:46
@bussyjd

bussyjd commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Two fixes pushed after live browser testing with a real wallet:

  • b612863 — the widget only re-read the session balance at sign-in / after its own Fund button / after each message, so funding the session from any other wallet left the composer disabled on a stale zero. Now polls every 8s once signed in + click-to-refresh on the balance readout.
  • fb5fe5d — hermes's API server CORS middleware 403s any Origin-bearing request against its default-empty allowlist, and browsers attach Origin to every POST (same-origin included) — so the widget's paid requests were rejected upstream after payment verification (fail-closed settlement skip worked; no one was charged). The rendered hermes deployment now sets API_SERVER_CORS_ORIGINS="*" — the API server sits behind the x402 verifier, payment is the gate, no ambient credentials.

Both verified live: paid request with an Origin header now returns 200 + Access-Control-Allow-Origin: * + settlement receipt.

bussyjd added 15 commits July 15, 2026 17:19
skillCatalog* named a busybox httpd after the first file it ever held.
It now serves skill.md, services.json (the storefront's own backend via
SERVICES_URL), openapi.json, the API docs, and one bundle per
hostname-bound offer -- including every offer landing page. "Skill
catalog" described one of those.

Two concepts were sharing the name, so this splits them:

  skillCatalog*          -> staticSite*      (the httpd + its ConfigMap)
  buildSkillCatalogMarkdown -> buildSkillMarkdown   (builds skill.md)
  skillCatalog{HowToPay,TryIt,RouteLines} -> skillMarkdown*  (its sections)
  catalogMu              -> staticSiteMu     (guards the static-site reconcile)

Identifiers only. Every k8s wire name is untouched -- obol-skill-md,
obol-skill-md-route, obol-catalog-headers and namespace x402 are all
byte-identical, verified by diffing the string literals removed against
those re-added (net zero). Renaming the objects would be a live-cluster
migration and they are referenced from internal/tunnel, cmd/obol,
internal/stackbackup, the embedded x402.yaml and next.config.ts; a
comment at the const block now explains the deliberate mismatch.

ServiceCatalog* (the /api/services.json wire types) keeps its name -- that
one is genuinely a catalog.

Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
…emplate

The landing page was a backtick literal buried mid-offerbundle.go while all
three x402 pages (payment_required, siwx_challenge, error_page) follow one
convention: templates/<name>.html + //go:embed + <name>HTMLSrc + <name>Tmpl.
It is the surface users actually hit and it was the only one you could not
open as HTML.

Adopts the existing x402 convention rather than inventing a second one:

  internal/serviceoffercontroller/templates/offer_landing.html
  //go:embed templates/offer_landing.html
  var offerLandingHTMLSrc string

Content is byte-identical to the previous literal (verified by extracting
the literal from origin/main and diffing against the new file), so the
rendered page is unchanged.

Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
… gone

DESIGN.md told the next engineer to hand-mirror the token palette across
three files and gave a drift check to enforce it. Both were stale:
payment_required.html contains zero hex values -- theming moved to
storefront.ResolveTheme(...).CSSVars() via {{.Branding.ThemeCSS}}. The
documented check diffed hex out of that template against globals.css, so
its left side was empty and it compared an empty set forever. Following
the doc would have re-introduced exactly the drift it existed to prevent.

- SS 2: describes the real source (theme.go is the single owner) and says
  not to paste hex back in.
- SS 5: points at the one hand-mirror that does survive -- theme.ts's
  LIGHT_THEME_VARS, a fallback that rots silently because a healthy page
  takes tokens from the feed. The new check compares theme.go's ThemeLight
  against it; verified it passes today (13 pairs each side) and verified it
  FAILS on injected drift, which the old one could not do.
- SS 0: adds the five public surfaces and their data-obol="page-*" markers.
  Nothing else in the repo lists them. Records the two things that are easy
  to get backwards: page-402 renders only on a paid path with Accept:
  text/html (so a root-priced offer's browser visitors get page-landing,
  never page-402), and data-obol="checkout" is a mount div on two pages
  rather than a name for the 402 page.
- Corrects "four surfaces" -> five.

Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
Every hostname-bound offer with spec.type=agent now serves a free,
self-contained chat client at /chat on its dedicated origin, embedded on
the offer landing page via the reserved checkout mount. One shared static
copy (catalog httpd root) serves every agent offer on any stack: the page
derives the agent name from the hostname and price/model/payment
network/asset from the live 402 challenge on POST /v1/chat/completions
(eip155:8453 and eip155:84532 supported).

Sign-in with Ethereum, without a SIWE server: the visitor connects an
injected wallet, signs one fixed domain-bound message whose keccak256
deterministically derives a local session key, funds that address with a
small USDC transfer, and every chat turn is then paid silently via x402
(EIP-3009, gasless for the payer, wrapFetchWithPayment). The key never
leaves the page and re-signing recovers it; the page refuses challenges
above a $0.05 safety cap.

Delivery: chat.html + a single esbuild vendor bundle (viem 2.21.25 +
@x402/fetch + @x402/evm 2.18.0 — the pair validated live against the
verifier with real settlements) are embedded in the controller binary,
projected into the catalog ConfigMap once, and exposed per-agent-offer via
two Exact rules on the so-<name>-host route so they beat the PathPrefix
payment gate exactly like the discovery paths. httpd.conf gains the .js
MIME mapping ES module loading requires. Non-agent offers are unchanged
(route topology pinned by tests).

Refs #671.
Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
Intermediaries cache .js aggressively (Cloudflare's default cacheable
extensions include .js), so a rebuilt vendor bundle behind the same URL
can strand browsers on a stale copy whose exports no longer match
chat.html. Pin the import with a content-derived ?v= query and document
the bump step in the assets README.

Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
… chat

The widget only re-read the session wallet's balance at sign-in, after
its own Fund button, and after each message — funds sent from any other
wallet (or arriving after sign-in) left the composer disabled on a stale
zero. Poll every 8s once signed in, make the balance readout
click-to-refresh, and stop refreshBalance from throwing on transient RPC
errors (keep the last rendered state instead).

Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
Hermes's API server CORS middleware returns 403 for any request carrying
an Origin header whose origin is not allowlisted — and its default
allowlist is empty. Browsers attach Origin to every POST, including
same-origin ones, so the /chat widget's paid requests were rejected by
the upstream after payment verification (settlement correctly skipped —
buyers were not charged, but chat was unusable from any browser).

Set API_SERVER_CORS_ORIGINS="*" on the rendered hermes deployment: the
API server is only reachable through the x402 verifier, payment is the
gate, and no ambient credentials exist, so the wildcard is safe.

Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
The skip-when-unchanged fast path compared only skill.md, the aggregate
docs, and the per-offer bundles — a controller upgrade that changed the
embedded chat widget computed the same hash, skipped the ConfigMap
apply, and pinned the old widget assets forever. Fold chat.html +
chat-vendor.js into both the content hash and the deployed-ConfigMap
match, with a regression test.

Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
…er origin-strip

Unsigned WIP checkpoint — matches the images deployed on silvernuc3
(serviceoffer-controller:chatwidget-compact5, obol-x402-verifier:originstrip1).
To be re-committed signed onto feat/agent-chat-widget for PR #752.

Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
Connect button chains straight into the session-key signature (still
user-triggered — same click gesture), so the two-stage onboarding is one
click; the separate sign-in stage remains only as the rejection-retry
path. Removes the reveal-key affordance: the session key is deterministic
(re-derivable by signing in again with the same wallet), so surfacing a
raw private key bought nothing but risk. Slimmer empty-state copy.

Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
The iframe'd widget duplicated the landing page around it: agent title,
price, network. A head inline script adds .embed when framed (before
first paint, so no header flash) and CSS hides the header; the price
still shows in the composer placeholder. The landing chat card loses its
heading and padding — it is just the widget now — with a small
full-page link below the card. Full-page /chat is unchanged.

Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
The textarea placeholder injected the full registration name, wrapped to
two lines and clipped in the one-row box. Short placeholder (the page
already names the agent), a touch more padding and gap in the strip and
composer, muted placeholder color.

Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
The label span was inline while every sibling row (Endpoint, Model,
Network) breaks after its label, so the card rendered
"API docsAPI docs ↗ · openapi.json ↗". block on the label matches the
siblings.

Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
stream:true with a line-buffered SSE reader appending deltas via
textContent (JSON fallback kept for non-streaming upstreams) — first
tokens at ~3.5s instead of the full ~15s wait, verified end-to-end with
a settled paid probe before the widget change.

Payment policy: the fixed $0.05 MAX_PRICE cap is gone (it also blocked
legitimately pricier agents); instead an x402Client registerPolicy
filter refuses any single payment above the CURRENT session balance.
The session balance is the budget the user explicitly parked, so no
authorization can ever exceed it, and a mid-session price hike beyond
it is refused with a clear message instead of silently paid. The
startup probe price check was advisory only; this enforces on every
payment.

Streaming implemented by grok-4.5 lane from spec; balance-ceiling
follow-up from the forked side-review. Diff reviewed, tests re-run.

Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
@bussyjd bussyjd force-pushed the feat/agent-chat-widget branch from 11c0924 to a585aeb Compare July 15, 2026 13:22
@bussyjd bussyjd marked this pull request as ready for review July 15, 2026 13:25
@bussyjd bussyjd mentioned this pull request Jul 15, 2026
4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant