From daa5a43ddf3268d09a5257f7a164a8efb91e2d81 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 05:38:21 +0000 Subject: [PATCH 1/5] Recheck six standards whose specs changed; move their pins The weekly spec drift watch found changes in ARD, ai-catalog, WebMCP, Web Bot Auth, Poppy and x402. Each page was rechecked against its live sources and last_verified moved to 2026-10-30. No status changes. - Poppy: version 0.2 published October 24, draft 0.3 in progress (scopes, transitional mediated sign-in, operations v2); guide.json note updated. - Web Bot Auth: WG draft -01 (October 29): Signature-Agent now names the key set's address directly, and sites can ask for a signature. Signers now link primary sources; DuckDuckGo dropped (no primary evidence). - WebMCP: draft of October 27, the draft W3C Agentic Web WG charter, the trial passing 0.5% of Chrome page loads, Hydrogen on by default. - ai-catalog: multi-party Trust Manifests; Cloudflare's catalog and the working group's list of 26. - x402: October 22 disclosure of library flaws (fixed in August); the CoinDesk figure is volume, not real payments; Foundation source linked. - ARD: only a non-normative observability section upstream; Hugging Face serves only ai-catalog.json too. Closes #16, closes #17, closes #18, closes #19. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NWyUpDcNT2Fd6FNzeuy2Bn --- content/guide.json | 2 +- content/standards/ai-catalog.md | 8 +++++--- content/standards/ard.md | 6 +++--- content/standards/poppy.md | 24 ++++++++++++------------ content/standards/web-bot-auth.md | 12 ++++++------ content/standards/webmcp.md | 14 +++++++------- content/standards/x402.md | 10 ++++++---- spec-pins.json | 16 ++++++++-------- 8 files changed, 48 insertions(+), 44 deletions(-) diff --git a/content/guide.json b/content/guide.json index 4754278..817bec1 100644 --- a/content/guide.json +++ b/content/guide.json @@ -7,7 +7,7 @@ { "situation": "You have lots of structured content people and agents want to query", "standards": ["nlweb"] }, { "situation": "Your bot visits other sites, or you need to verify bots on yours", "standards": ["web-bot-auth"] }, { "situation": "You want agents to create accounts on your service without a sign-up form", "standards": ["auth-md"] }, - { "situation": "Customers' own agents should sign in and act on their accounts", "standards": ["poppy"], "note": "Track it; Draft 0.1 is from October 9, 2026, and 0.2 is in progress." }, + { "situation": "Customers' own agents should sign in and act on their accounts", "standards": ["poppy"], "note": "Track it; Draft 0.2 is from October 24, 2026, and 0.3 is in progress." }, { "situation": "You sell goods and want any AI assistant to browse, cart and check out", "standards": ["ucp"] }, { "situation": "You want your products shown in ChatGPT", "standards": ["acp"] }, { "situation": "An agent pays and you need proof a person approved those exact terms", "standards": ["ap2"] }, diff --git a/content/standards/ai-catalog.md b/content/standards/ai-catalog.md index b69e06b..74d9571 100644 --- a/content/standards/ai-catalog.md +++ b/content/standards/ai-catalog.md @@ -11,7 +11,7 @@ analogy: Your site's table of contents for AI. looks_like: /.well-known/ai-catalog.json when_to_use: When you publish that same list for MCP Server Cards and older tools. Serve one file at both addresses. backers: Linux Foundation working group (MCP and A2A communities) -reality_check: Early, with live catalogs at GitHub, Buildkite and Hugging Face. ARD tools look for `ard.json` first and may skip the old address, so serve the same file at both `ai-catalog.json` and `ard.json`. +reality_check: Early, with live catalogs at GitHub, Buildkite, Hugging Face and Cloudflare's developer docs. [The working group's own list](https://github.com/Agent-Card/awesome-ai-catalogs) has 26, mostly small sites. ARD tools look for `ard.json` first and may skip the old address, so serve the same file at both `ai-catalog.json` and `ard.json`. links: - label: ai-catalog.io url: https://ai-catalog.io/ @@ -24,12 +24,12 @@ related: note: ARD calls ai-catalog its predecessor. ai-catalog sees itself as the lightweight publishing format; it was split off, not retired. - id: nlweb note: A catalog can list an NLWeb endpoint. -last_verified: "2026-10-19" +last_verified: "2026-10-30" spec_sources: - kind: git-path repo: Agent-Card/ai-catalog path: specification - pin: "e020b2db26f8f13e904a95e0e457457dd3da9efd" + pin: "7ee97d9655a13e1f6442ab505982c03db17134ea" --- ## How it works @@ -38,6 +38,8 @@ Like a table of contents, it doesn't contain the chapters; it tells you where ea A restaurant that works with a booking provider and a jobs provider can list both AI tools, so any assistant that only knows the restaurant's web address can find the booking tool. Catalogs can also point to other catalogs, so a big platform can list many sub-catalogs. +An entry can also carry optional Trust Manifests: claims about the tool, such as who published it or which audits it passed, each of which can be signed. Since October 6, 2026, the [spec](https://ai-catalog.io/spec/) lets several parties, say the publisher and an auditor, each add their own. + ## Who's behind it It's developed in a [Linux Foundation working repository](https://github.com/Agent-Card/ai-catalog) (started October 2025) with members from the MCP and A2A communities, and published at [ai-catalog.io](https://ai-catalog.io/). Even the group's name is inconsistent: ai-catalog.io calls it the "Agent Card Working Group," while Google thanks the "AI Catalog Working Group." diff --git a/content/standards/ard.md b/content/standards/ard.md index 039ac04..db81b5f 100644 --- a/content/standards/ard.md +++ b/content/standards/ard.md @@ -11,7 +11,7 @@ analogy: A search engine for agent tools. looks_like: /.well-known/ard.json when_to_use: When you offer APIs, MCP servers or agents and want agent search registries to find them. backers: Google-led authors (Google, Hugging Face, R.V. Guha) -reality_check: Early. [Hugging Face runs a public registry](https://huggingface.co/blog/agentic-resource-discovery-launch), and Hugging Face and GitHub now publish catalogs, though GitHub's is only at the older `ai-catalog.json` address. According to [an independent sweep in September 2026](https://github.com/Agent-Card/ai-catalog/issues/115), only 24 of 102 publishers could be found at `ard.json`; the other 78 used only `ai-catalog.json`. +reality_check: Early. [Hugging Face runs a public registry](https://huggingface.co/blog/agentic-resource-discovery-launch), and Hugging Face and GitHub now publish catalogs, though both only at the older [`ai-catalog.json`](https://huggingface.co/.well-known/ai-catalog.json) address. According to [an independent sweep in September 2026](https://github.com/Agent-Card/ai-catalog/issues/115), only 24 of 102 publishers could be found at `ard.json`; the other 78 used only `ai-catalog.json`. links: - label: Specification (v0.91) url: https://agenticresourcediscovery.org/spec/ @@ -22,12 +22,12 @@ links: related: - id: ai-catalog note: Its predecessor, now the publishing format. ARD is the discovery format. Serve the same file at both addresses. -last_verified: "2026-10-19" +last_verified: "2026-10-30" spec_sources: - kind: git-path repo: ards-project/ard-spec path: spec - pin: "40151d4a76af2e266723f78d9142f54a89fb5dcf" + pin: "eca2857d0c9b7b27aa2fb72fe3bb69fcd0d18ea9" --- ## How it works diff --git a/content/standards/poppy.md b/content/standards/poppy.md index 8a02469..40ce5f0 100644 --- a/content/standards/poppy.md +++ b/content/standards/poppy.md @@ -4,7 +4,7 @@ full_name: Personal Agent Protocol (PAP) job: identity order: 2 status: early -status_note: Draft 0.1 (Oct 9, 2026), 0.2 in progress; a few small deployments +status_note: Draft 0.2 (Oct 24, 2026), 0.3 in progress; a few small deployments tagline: acts as you phrase: Your agent signs in to a company as you analogy: A valet key for your personal agent. @@ -13,9 +13,9 @@ when_to_use: When customers' own agents should sign in and act on their accounts backers: Sierra + Meta; 7 launch and 35 design partners reality_check: A draft first published October 9, 2026. A few small companies serve a `poppy.json`, including [Salespeak](https://salespeak.ai/agent-endpoint/), [Lorikeet](https://www.lorikeetcx.ai/blog/personal-agent-protocol), [Ticket Fairy](https://www.ticketfairy.com/.well-known/poppy.json) and [Alpic](https://alpic.ai/.well-known/poppy.json), most for signed-out chats or plain website browsing only. None of the launch partners does yet. links: - - label: Specification (Draft 0.1) - url: https://personalagentprotocol.org/docs/0.1/spec - - label: Draft 0.2 (in progress) + - label: Specification (Draft 0.2) + url: https://personalagentprotocol.org/docs/0.2/spec + - label: Draft 0.3 (in progress) url: https://personalagentprotocol.org/docs/spec - label: Path to 1.0 (open topics) url: https://personalagentprotocol.org/docs/path-to-1 @@ -34,29 +34,29 @@ related: note: Poppy leaves shopping to UCP and ACP, and may overlap with UCP on sign-in. - id: ap2 note: Payments are not covered yet. Until they are, payment consent needs another protocol, such as AP2. -last_verified: "2026-10-19" +last_verified: "2026-10-30" spec_sources: - kind: page url: https://personalagentprotocol.org/docs/spec - pin: "3b7a81416cfc5f98d4ce1d6f2cb0e359cbf68a46d5a5317b5a4a856855eb50e3" + pin: "24d8da14fd16c944adeb9969221e6d6a6d9ba089f7299ce8c6a8df8a860001f3" - kind: page url: https://personalagentprotocol.org/docs/extensions/operations - pin: "6f94925f7306c2d19cb5548e9655c8d414709301ba8ef37c6e1a436969614b7f" + pin: "ec14d1af5ac4a65e0f691e49fbdf57ede32f32552d86ed88865e85bf8eedb169" - kind: page url: https://personalagentprotocol.org/docs/guides - pin: "f2b0a17c0f420732a721f2843717015cb3f8277fc365a6b829af5e36bd67ea49" + pin: "708fc192545d0a65b596c8dbee038338fe559f21f4d3c203023304a1168420d0" --- ## How it works It lets the agent act for you at a company, with only the permissions you gave it, without opening the boot or the glovebox. Right now an AI agent acting for you has two bad options: pretend to be you in a browser, or not get in at all. -Poppy gives the company a file at `/.well-known/poppy.json` that says how agents can start a session, how you sign in, and which web pages, APIs and company agent are available. Sign-in uses OAuth, the familiar "Sign in with..." approval screen: you approve on the company's own page, on another device with a code, or (if the company allows it) the agent signs in for you. An agent can also start signed out, as a guest. +Poppy gives the company a file at `/.well-known/poppy.json` that says how agents can start a session, how you sign in, and which web pages, APIs and company agent are available. Sign-in uses OAuth, the familiar "Sign in with..." approval screen: you approve on the company's own page, on another device with a code, or (if the company allows it) the agent signs in for you. [Draft 0.3](https://personalagentprotocol.org/docs/guides) calls that last way temporary, with an end date to be set at version 1.0. An agent can also start signed out, as a guest. -Permissions are simple scopes like "read" and "write," plus custom ones such as "manage saved addresses." You also get a different anonymous ID at each company, so companies can't match you with each other. In the launch demo, Meta's Muse agent found Rocket Mortgage's agent and, with the user's permission, completed a mortgage pre-approval. +Permissions are scopes. Version 0.2 has two shared ones, "read" and "write," and companies can add their own, such as "manage saved addresses." [Draft 0.3](https://personalagentprotocol.org/docs/changelog) drops the shared ones: each company names its own and says which make changes that can't be undone. For an action such as exchanging an item, the company can also [ask you to approve the exact terms](https://personalagentprotocol.org/docs/extensions/operations) before it acts. You also get a different anonymous ID at each company, so companies can't match you with each other. In the launch demo, Meta's Muse agent found Rocket Mortgage's agent and, with the user's permission, completed a mortgage pre-approval. ## Who's behind it -[Sierra](https://sierra.ai/blog) (led by Bret Taylor and Clay Bavor) and Meta announced it on October 6, 2026. [Sierra published Draft 0.1](https://sierra.ai/blog/poppy) on October 9 with seven launch partners (Genesys, Instinct, NiCE, Rocket, Shopify, Stripe and Walmart) and 35 more design partners, including OpenAI, Visa, Mastercard and PayPal. [Draft 0.2](https://personalagentprotocol.org/docs/changelog) is in progress, with no publication date yet. +[Sierra](https://sierra.ai/blog) (led by Bret Taylor and Clay Bavor) and Meta announced it on October 6, 2026. [Sierra published Draft 0.1](https://sierra.ai/blog/poppy) on October 9 with seven launch partners (Genesys, Instinct, NiCE, Rocket, Shopify, Stripe and Walmart) and 35 more design partners, including OpenAI, Visa, Mastercard and PayPal. [Version 0.2](https://personalagentprotocol.org/docs/0.2/spec) followed on October 24, and [draft 0.3](https://personalagentprotocol.org/docs/changelog) is in progress. -The spec warns that any part can change, even in ways that break compatibility, and [lists payments as not yet covered](https://personalagentprotocol.org/docs/path-to-1). A reference implementation was promised within about a month of October 9 and isn't out yet. An implementation kit for companies was added on October 17: a skill that has a coding agent design and build a company's side, and a validator that acts as a test agent against a deployment. It reuses OAuth, OpenAPI and MCP rather than replacing them. +The spec warns that any part can change, even in ways that break compatibility, and [lists payments as not yet covered](https://personalagentprotocol.org/docs/path-to-1). A reference implementation was promised within about a month of October 9 and isn't out yet. An implementation kit for companies was added on October 17: a skill that has a coding agent design and build a company's side, and a validator that acts as a test agent against a deployment. [The kit](https://personalagentprotocol.org/kit/company) now follows draft 0.3, not the published 0.2. Poppy reuses OAuth, OpenAPI and MCP rather than replacing them. diff --git a/content/standards/web-bot-auth.md b/content/standards/web-bot-auth.md index f4c972f..6cb2e2e 100644 --- a/content/standards/web-bot-auth.md +++ b/content/standards/web-bot-auth.md @@ -3,7 +3,7 @@ name: Web Bot Auth job: identity order: 1 status: production -status_note: WG draft -00 (Sept 1, 2026); live signers +status_note: WG draft -01 (Oct 29, 2026); live signers tagline: which bot is it phrase: Cryptographic ID for bots analogy: A tamper-proof ID badge for bots, instead of a name tag anyone could write. @@ -23,18 +23,18 @@ related: note: auth.md signs up a new user for the agent; Web Bot Auth only proves which bot is asking. - id: x402 note: Cloudflare plans to use them together for pay-per-crawl. -last_verified: "2026-10-19" +last_verified: "2026-10-30" spec_sources: - kind: ietf-draft name: draft-ietf-webbotauth-httpsig-protocol - pin: "00" + pin: "01" --- ## How it works -Today websites identify bots by a label (the "user agent") that anyone can fake, or by IP address, which breaks easily. With Web Bot Auth, the company running the bot publishes a public key at a well-known address and signs every request with the matching private key, using an existing IETF standard for signing web requests (RFC 9421). +Today websites identify bots by a label (the "user agent") that anyone can fake, or by IP address, which breaks easily. With Web Bot Auth, the company running the bot publishes its public keys at a web address, names that address in each request, and signs the request with the matching private key, using an existing IETF standard for signing web requests (RFC 9421). -The website, or its CDN such as Cloudflare, checks the signature and knows for certain "this really is OpenAI's agent" before deciding to let it in, block it or charge it. A news site can let a verified shopping agent through while stopping an anonymous scraper wearing the same label. +The website, or its CDN such as Cloudflare, checks the signature and knows for certain "this really is OpenAI's agent" before deciding to let it in, block it or charge it. A site can also ask an unsigned bot to sign. A news site can let a verified shopping agent through while stopping an anonymous scraper wearing the same label. It answers *which agent* is visiting, not *whom it represents*; that's Poppy's and OAuth's job. @@ -42,4 +42,4 @@ It answers *which agent* is visiting, not *whom it represents*; that's Poppy's a Cloudflare proposed it in May 2025. On September 1, 2026 the IETF's webbotauth working group [adopted it as an official working-group draft](https://datatracker.ietf.org/doc/draft-ietf-webbotauth-httpsig-protocol/), written by authors from Cloudflare and Google; it's the group's only adopted document. It's still a draft, not an RFC: adoption isn't approval. -[Visa's Trusted Agent Protocol and Mastercard's Agent Pay build on it](https://blog.cloudflare.com/secure-agentic-commerce/), adding a tag that says whether the agent is browsing or buying, and observers report OpenAI, Cloudflare and DuckDuckGo signing requests today. The working-group draft has already changed how a request points to its key directory, but [Cloudflare's own checker](https://developers.cloudflare.com/bots/reference/bot-verification/web-bot-auth/) still expects the older format (as of October 8, 2026), so early implementations may need updates. +[Visa's Trusted Agent Protocol and Mastercard's Agent Pay build on it](https://blog.cloudflare.com/secure-agentic-commerce/), adding a tag that says whether the agent is browsing or buying. [OpenAI's ChatGPT agent](https://help.openai.com/en/articles/11845367), [Cloudflare's Browser Run](https://developers.cloudflare.com/browser-run/reference/automatic-request-headers/) and, as an experiment, [some Google-Agent traffic](https://developers.google.com/crawling/docs/crawlers-fetchers/web-bot-auth) sign requests today. [Revision -01](https://datatracker.ietf.org/doc/html/draft-ietf-webbotauth-httpsig-protocol-01) (October 29, 2026) changed how a request points to its keys: it now gives the exact address of its key set. [Cloudflare's own checker](https://developers.cloudflare.com/bots/reference/bot-verification/web-bot-auth/) still expects the older format (as of October 8, 2026), so early implementations will need updates. diff --git a/content/standards/webmcp.md b/content/standards/webmcp.md index 4280b2b..e2ac87f 100644 --- a/content/standards/webmcp.md +++ b/content/standards/webmcp.md @@ -3,7 +3,7 @@ name: WebMCP job: act order: 1 status: early -status_note: Draft (Oct 16, 2026); Chrome and Edge trials; live in ChatGPT desktop and Shopify +status_note: Draft (Oct 27, 2026); Chrome and Edge trials; live in ChatGPT desktop and Shopify tagline: page tools phrase: A page hands typed tools to the agent in your browser analogy: A labeled menu of actions for the agent in your browser. @@ -12,7 +12,7 @@ when_to_use: When agents use your site in people's browsers. Add tools to your k backers: W3C Web ML community group; Microsoft and Google editors reality_check: Early use with two big real-world users, ChatGPT's desktop browser and Shopify, while Chrome and Edge are still in trials. links: - - label: Draft (Oct 16, 2026) + - label: Draft (Oct 27, 2026) url: https://webmachinelearning.github.io/webmcp/ - label: Chrome docs url: https://developer.chrome.com/docs/ai/webmcp @@ -23,12 +23,12 @@ related: note: NLWeb is an endpoint on your server. WebMCP works inside the browser page. - id: ucp note: Shopify puts WebMCP tools on every Liquid storefront, next to its UCP support. -last_verified: "2026-10-19" +last_verified: "2026-10-30" spec_sources: - kind: git-path repo: webmachinelearning/webmcp path: index.bs - pin: "bd81b6d06c4d22bd15c14849030bbeb2fb14d9cd" + pin: "ce879543a9013dae3effe292e6deda0b4323a07d" --- ## How it works @@ -41,11 +41,11 @@ One caveat: a page's tool descriptions are text the AI reads, so a malicious sit ## Who's behind it -Microsoft and Google proposed it together in August 2025; the W3C's Web Machine Learning community group took it up that September, with editors from both companies. The current text is a [draft dated October 16, 2026](https://webmachinelearning.github.io/webmcp/) that says plainly it is "not a W3C Standard." +Microsoft and Google proposed it together in August 2025; the W3C's Web Machine Learning community group took it up that September, with editors from both companies. The current text is a [draft dated October 27, 2026](https://webmachinelearning.github.io/webmcp/) that says plainly it is "not a W3C Standard." A [draft W3C charter](https://w3c.github.io/charter-drafts/2026/agenticweb-wg.html) for a new Agentic Web Working Group would take WebMCP as its starting text, but that group does not exist yet. -[Chrome has been testing it](https://developer.chrome.com/docs/ai/webmcp) in a time-limited "origin trial" (Chrome 149-156) since May 2026; on September 28 [Google asked to extend the trial](http://www.mail-archive.com/blink-dev@chromium.org/msg17568.html) to Chrome 162 because feedback is still changing the API, so there's no ship date. [Edge runs its own trial](https://developer.microsoft.com/en-us/microsoft-edge/origin-trials/trials/0b76fe60-b266-458e-a285-04e375c0c31a) (through March 30, 2027) and [calls it "ready for testing."](https://blogs.windows.com/msedgedev/2026/09/21/new-in-edge-for-developers-create-better-components-and-make-your-site-agent-ready/) +[Chrome has been testing it](https://developer.chrome.com/docs/ai/webmcp) in a time-limited "origin trial" (Chrome 149-156) since May 2026; on September 28 [Google asked to extend the trial](http://www.mail-archive.com/blink-dev@chromium.org/msg17568.html) to Chrome 162 because feedback is still changing the API, so there's no ship date. On October 28, Google said [pages using the trial had passed 0.5% of all Chrome page loads](http://www.mail-archive.com/blink-dev@chromium.org/msg17895.html), though agents call the tools far less often. [Edge runs its own trial](https://developer.microsoft.com/en-us/microsoft-edge/origin-trials/trials/0b76fe60-b266-458e-a285-04e375c0c31a) (through March 30, 2027) and [calls it "ready for testing."](https://blogs.windows.com/msedgedev/2026/09/21/new-in-edge-for-developers-create-better-components-and-make-your-site-agent-ready/) -[ChatGPT's desktop browser calls these "site tools"](https://help.openai.com/en/articles/20001423-using-site-tools-in-the-chatgpt-desktop-app) automatically, and [Shopify puts WebMCP tools on every Liquid storefront](https://shopify.dev/docs/api/web-mcp) (Hydrogen stores can turn them on), including a [checkout tool that agents may only call after the buyer approves the order and total](https://shopify.dev/docs/agents/carts-and-checkout/checkout-webmcp). [Safari's team opposes it](https://github.com/WebKit/standards-positions/issues/670) and [Firefox is neutral](https://github.com/mozilla/standards-positions/issues/1412). +[ChatGPT's desktop browser calls these "site tools"](https://help.openai.com/en/articles/20001423-using-site-tools-in-the-chatgpt-desktop-app) automatically, and [Shopify puts WebMCP tools on every Liquid storefront](https://shopify.dev/docs/api/web-mcp) (and on Hydrogen stores from the 2026-10 release, unless turned off), including a [checkout tool that agents may only call after the buyer approves the order and total](https://shopify.dev/docs/agents/carts-and-checkout/checkout-webmcp). [Safari's team opposes it](https://github.com/WebKit/standards-positions/issues/670) and [Firefox is neutral](https://github.com/mozilla/standards-positions/issues/1412). ## Neighbours diff --git a/content/standards/x402.md b/content/standards/x402.md index 2411a02..cff03e7 100644 --- a/content/standards/x402.md +++ b/content/standards/x402.md @@ -10,7 +10,7 @@ analogy: A parking meter on every web page. Drop in a few cents and the gate ope looks_like: 402 Payment Required when_to_use: When you charge per API request in stablecoins, with no accounts or sign-up. backers: Coinbase, now the x402 Foundation (Linux Foundation) -reality_check: In production, but small. [TRM Labs (September 9, 2026)](https://www.trmlabs.com/trm-tech-blog/whos-actually-paying-measuring-ai-agent-payments-onchain) counted 198.9 million settlements worth $52.7 million since May 2025. After removing self-payments and similar flows, about $25.6 million looked like real commerce, and only 0.6% to 7.5% of that seemed to come from AI agents. Earlier, [CoinDesk (March 11, 2026)](https://www.coindesk.com/markets/2026/03/11/coinbase-backed-ai-payments-protocol-wants-to-fix-micropayment-but-demand-is-just-not-there-yet) found about $28,000 a day of real payments, averaging about 20 cents. [Cloudflare's "billion 402 responses a day"](https://blog.cloudflare.com/x402/) counts payment *requests*, not payments. Count dollars, not transactions. +reality_check: In production, but small. [TRM Labs (September 9, 2026)](https://www.trmlabs.com/trm-tech-blog/whos-actually-paying-measuring-ai-agent-payments-onchain) counted 198.9 million settlements worth $52.7 million since May 2025. After removing self-payments and similar flows, about $25.6 million looked like real commerce, and only 0.6% to 7.5% of that seemed to come from AI agents. Earlier, [CoinDesk (March 11, 2026)](https://www.coindesk.com/markets/2026/03/11/coinbase-backed-ai-payments-protocol-wants-to-fix-micropayment-but-demand-is-just-not-there-yet) found only about $28,000 a day in volume, averaging about 20 cents, much of it testing or "gamed" transactions. [Cloudflare's "billion 402 responses a day"](https://blog.cloudflare.com/x402/) counts payment *requests*, not payments. Count dollars, not transactions. links: - label: Specification (V2) url: https://github.com/x402-foundation/x402/blob/main/specs/x402-specification-v2.md @@ -25,12 +25,12 @@ related: note: AP2 can settle through x402. - id: web-bot-auth note: Cloudflare plans to use them together for pay-per-crawl. -last_verified: "2026-10-19" +last_verified: "2026-10-30" spec_sources: - kind: git-path repo: x402-foundation/x402 path: specs - pin: "fd92aa2f26e0b20985479ef43ab54cb28b555cf2" + pin: "11f02ce95e15d78e246efcbd5253ecbeefc2718d" --- ## How it works @@ -39,6 +39,8 @@ The internet never had a built-in way to charge tiny amounts, so content is eith Picture an agent paying a weather API a cent per lookup, or a crawler paying a publisher per page instead of being blocked. +Keep the official code libraries up to date. On October 22, 2026 a security firm [reported three critical flaws](https://v12.sh/blog/x402) in them, including no default spending cap, so a hostile server could ask for a wallet's whole balance. Fixes shipped in August. + ## Who's behind it -Coinbase created it, and [version 2 shipped on December 11, 2025](https://x402.org/x402-v2-launch/). The Linux Foundation announced an x402 Foundation on April 2, 2026 and [made it operational on July 14](https://www.linuxfoundation.org/press/linux-foundation-announces-operational-launch-of-x402-foundation-to-standardize-internet-native-payments-for-ai-agents-and-applications) with 40 members. It now [lists 57](https://www.x402.org/members); the 19 premier members include AWS, Google, Visa, Mastercard, American Express, PayPal, Stripe, Shopify, Cloudflare, Coinbase and Circle, the broadest coalition of any agent-payment standard. +Coinbase created it, and [version 2 shipped on December 11, 2025](https://x402.org/x402-v2-launch/). The Linux Foundation [announced an x402 Foundation on April 2, 2026](https://www.linuxfoundation.org/press/linux-foundation-is-launching-the-x402-foundation-and-welcoming-the-contribution-of-the-x402-protocol) and [made it operational on July 14](https://www.linuxfoundation.org/press/linux-foundation-announces-operational-launch-of-x402-foundation-to-standardize-internet-native-payments-for-ai-agents-and-applications) with 40 members. It now [lists 57](https://www.x402.org/members); the 19 premier members include AWS, Google, Visa, Mastercard, American Express, PayPal, Stripe, Shopify, Cloudflare, Coinbase and Circle. diff --git a/spec-pins.json b/spec-pins.json index 243d2e1..4b1102f 100644 --- a/spec-pins.json +++ b/spec-pins.json @@ -16,7 +16,7 @@ "kind": "git-path", "repo": "ards-project/ard-spec", "path": "spec", - "pin": "40151d4a76af2e266723f78d9142f54a89fb5dcf" + "pin": "eca2857d0c9b7b27aa2fb72fe3bb69fcd0d18ea9" } ] }, @@ -29,7 +29,7 @@ "kind": "git-path", "repo": "Agent-Card/ai-catalog", "path": "specification", - "pin": "e020b2db26f8f13e904a95e0e457457dd3da9efd" + "pin": "7ee97d9655a13e1f6442ab505982c03db17134ea" } ] }, @@ -42,7 +42,7 @@ "kind": "git-path", "repo": "webmachinelearning/webmcp", "path": "index.bs", - "pin": "bd81b6d06c4d22bd15c14849030bbeb2fb14d9cd" + "pin": "ce879543a9013dae3effe292e6deda0b4323a07d" } ] }, @@ -67,7 +67,7 @@ { "kind": "ietf-draft", "name": "draft-ietf-webbotauth-httpsig-protocol", - "pin": "00" + "pin": "01" } ] }, @@ -79,17 +79,17 @@ { "kind": "page", "url": "https://personalagentprotocol.org/docs/spec", - "pin": "3b7a81416cfc5f98d4ce1d6f2cb0e359cbf68a46d5a5317b5a4a856855eb50e3" + "pin": "24d8da14fd16c944adeb9969221e6d6a6d9ba089f7299ce8c6a8df8a860001f3" }, { "kind": "page", "url": "https://personalagentprotocol.org/docs/extensions/operations", - "pin": "6f94925f7306c2d19cb5548e9655c8d414709301ba8ef37c6e1a436969614b7f" + "pin": "ec14d1af5ac4a65e0f691e49fbdf57ede32f32552d86ed88865e85bf8eedb169" }, { "kind": "page", "url": "https://personalagentprotocol.org/docs/guides", - "pin": "f2b0a17c0f420732a721f2843717015cb3f8277fc365a6b829af5e36bd67ea49" + "pin": "708fc192545d0a65b596c8dbee038338fe559f21f4d3c203023304a1168420d0" } ] }, @@ -171,7 +171,7 @@ "kind": "git-path", "repo": "x402-foundation/x402", "path": "specs", - "pin": "fd92aa2f26e0b20985479ef43ab54cb28b555cf2" + "pin": "11f02ce95e15d78e246efcbd5253ecbeefc2718d" } ] } From 8047b60d6c75321cdda6bf23f686ac2e83667efe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 10:40:55 +0000 Subject: [PATCH 2/5] Add the ax.guide quiz at /play: an open deck, an expert deck and a leaderboard A card game on the agentic-web standards and the web plumbing under them, built to be shared. - Quick run: 12 cards from the open deck, no sign-up, played in the browser. The deck is content/quiz/open.json (48 hand-written cards) plus one "Real or hype?" card per standard, written from its page, so status cards change the day a page does. - Expert run: 18 cards from a 60-card deck that lives in Supabase, never in this repository, so the leaderboard can't be answered from GitHub. Signed-in players only (GitHub, Google or an emailed link). The server deals each card, keeps time with its own clock, and guards every step: one answer per card, no restarting a card's clock, 20 runs a day. - Scoring: 100 a right answer, up to 50 for speed, up to 50 for a streak; 20 seconds a card. - Sharing: an emoji grid and a share image per result. Open runs encode the score in the address (/play/s/); expert runs link a verified result (/play/r/). Leaderboard: each player's best run, all time or this week, by GitHub handle or first name and initial, never email. - Without Supabase keys the site still builds and the open quiz still plays; the expert deck and leaderboard show as "soon". The schema, setup steps and card-writing rules are in content/quiz/README.md; the database is supabase/migrations. Tests cover both decks, scoring, dealing, share codes, the sign-in return path and the expert run's guards (against an in-memory stand-in for Supabase); the SQL was run against Postgres 16. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NWyUpDcNT2Fd6FNzeuy2Bn --- .env.example | 10 + .gitignore | 3 + README.md | 6 +- app/api/play/me/route.ts | 10 + app/api/play/runs/[id]/route.ts | 21 + app/api/play/runs/route.ts | 7 + app/api/play/signout/route.ts | 7 + app/auth/callback/route.ts | 14 + app/home.css | 7 +- app/play/leaderboard/page.tsx | 78 +++ app/play/page.tsx | 25 + app/play/play.css | 485 ++++++++++++++++++ app/play/r/[id]/opengraph-image.tsx | 18 + app/play/r/[id]/page.tsx | 24 + app/play/s/[code]/opengraph-image.tsx | 18 + app/play/s/[code]/page.tsx | 26 + app/sitemap.ts | 2 +- components/quiz/dealers.ts | 128 +++++ components/quiz/quiz-game.tsx | 363 ++++++++++++++ components/quiz/results.tsx | 125 +++++ components/quiz/share-body.tsx | 44 ++ components/quiz/sign-in.tsx | 82 ++++ content/home.json | 5 + content/quiz/README.md | 57 +++ content/quiz/open.json | 513 ++++++++++++++++++++ content/site.json | 4 + lib/og-quiz.tsx | 48 ++ lib/play-route.ts | 24 + lib/quiz-expert.ts | 273 +++++++++++ lib/quiz-open.ts | 58 +++ lib/quiz-schema.ts | 50 ++ lib/quiz.ts | 186 +++++++ lib/supabase-env.ts | 10 + lib/supabase-server.ts | 69 +++ next.config.ts | 6 + package-lock.json | 128 +++++ package.json | 5 +- supabase/migrations/20261030000000_quiz.sql | 130 +++++ tests/quiz-expert.test.ts | 225 +++++++++ tests/quiz.test.ts | 157 ++++++ 40 files changed, 3447 insertions(+), 4 deletions(-) create mode 100644 .env.example create mode 100644 app/api/play/me/route.ts create mode 100644 app/api/play/runs/[id]/route.ts create mode 100644 app/api/play/runs/route.ts create mode 100644 app/api/play/signout/route.ts create mode 100644 app/auth/callback/route.ts create mode 100644 app/play/leaderboard/page.tsx create mode 100644 app/play/page.tsx create mode 100644 app/play/play.css create mode 100644 app/play/r/[id]/opengraph-image.tsx create mode 100644 app/play/r/[id]/page.tsx create mode 100644 app/play/s/[code]/opengraph-image.tsx create mode 100644 app/play/s/[code]/page.tsx create mode 100644 components/quiz/dealers.ts create mode 100644 components/quiz/quiz-game.tsx create mode 100644 components/quiz/results.tsx create mode 100644 components/quiz/share-body.tsx create mode 100644 components/quiz/sign-in.tsx create mode 100644 content/quiz/README.md create mode 100644 content/quiz/open.json create mode 100644 lib/og-quiz.tsx create mode 100644 lib/play-route.ts create mode 100644 lib/quiz-expert.ts create mode 100644 lib/quiz-open.ts create mode 100644 lib/quiz-schema.ts create mode 100644 lib/quiz.ts create mode 100644 lib/supabase-env.ts create mode 100644 lib/supabase-server.ts create mode 100644 supabase/migrations/20261030000000_quiz.sql create mode 100644 tests/quiz-expert.test.ts create mode 100644 tests/quiz.test.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9e1b12d --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# The quiz's expert deck, sign-in and leaderboard (/play). Without these the +# site still builds, and the open quiz still plays; the rest stays off. +# See content/quiz/README.md. + +# Supabase project settings: Project Settings > API. Public by design. +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= + +# Server only. Never prefix it with NEXT_PUBLIC_. +SUPABASE_SERVICE_ROLE_KEY= diff --git a/.gitignore b/.gitignore index e272c39..23e4cfe 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ next-env.d.ts # misc .DS_Store npm-debug.log* + +# the expert deck: never in the public repo (see content/quiz/README.md) +/private/ diff --git a/README.md b/README.md index c1242a9..b6a4867 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Please read [CONTRIBUTING.md](CONTRIBUTING.md) first: it covers the status rules ## How it's built -A small [Next.js](https://nextjs.org) app with no backend, no database and no tracking. Every page is static, built from the files in [`content/`](content/). +A small [Next.js](https://nextjs.org) app with no tracking. Every page of the guide is static, built from the files in [`content/`](content/). The one exception is the [quiz](https://ax.guide/play): its open deck plays in the browser, while sign-in, the expert deck and the leaderboard run on [Supabase](https://supabase.com). Without Supabase keys the site still builds, and the open quiz still plays. See [`content/quiz/README.md`](content/quiz/README.md). ``` content/ @@ -74,9 +74,12 @@ content/ pairs.json the pairs people mix up (/compare) guide.json which one do I need (/which) home.json, site.json page text: titles, nav, footer, status labels + quiz/open.json the quiz's open deck (the expert deck is kept out of the repo) contribute.md the /contribute page lib/schema.ts the rules every standard's front matter must follow lib/standards.ts reads, checks and numbers the standards +lib/quiz*.ts the quiz: cards, scoring, dealing, expert runs +supabase/migrations/ the quiz's database: players, the expert deck, runs app/ pages and share images (Next.js App Router) components/ small presentational components tests/ checks every content file and this README's table @@ -101,6 +104,7 @@ npm run dev # http://localhost:3000 | `npm run lint` | ESLint | | `npm run typecheck` | TypeScript | | `npm run readme` | Regenerate the table of standards in this README | +| `npm run quiz:sql` | Check `private/quiz-expert.json` and write the SQL that loads it into Supabase | ### Deploy diff --git a/app/api/play/me/route.ts b/app/api/play/me/route.ts new file mode 100644 index 0000000..707d560 --- /dev/null +++ b/app/api/play/me/route.ts @@ -0,0 +1,10 @@ +import { json } from "@/lib/play-route"; +import { playerName } from "@/lib/quiz-expert"; +import { currentUserId, playBackendEnabled } from "@/lib/supabase-server"; + +/** Whether expert runs are on, and who is signed in. */ +export async function GET() { + if (!playBackendEnabled()) return json({ enabled: false, player: null }); + const userId = await currentUserId(); + return json({ enabled: true, player: userId ? { name: (await playerName(userId)) ?? "player" } : null }); +} diff --git a/app/api/play/runs/[id]/route.ts b/app/api/play/runs/[id]/route.ts new file mode 100644 index 0000000..04dade0 --- /dev/null +++ b/app/api/play/runs/[id]/route.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { withPlayer } from "@/lib/play-route"; +import { answerCard, nextCard, PlayError } from "@/lib/quiz-expert"; + +const step = z.discriminatedUnion("action", [ + z.strictObject({ action: z.literal("answer"), position: z.int().min(0), choice: z.int().min(0).max(3).nullable() }), + z.strictObject({ action: z.literal("next"), position: z.int().min(0) }), +]); + +/** One step of an expert run: answer the open card, or deal the next one. */ +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withPlayer(async (userId) => { + const parsed = step.safeParse(await request.json().catch(() => undefined)); + if (!parsed.success) throw new PlayError(400, "Malformed step."); + const body = parsed.data; + return body.action === "answer" + ? answerCard(userId, id, body.position, body.choice) + : nextCard(userId, id, body.position); + }); +} diff --git a/app/api/play/runs/route.ts b/app/api/play/runs/route.ts new file mode 100644 index 0000000..f7e1d43 --- /dev/null +++ b/app/api/play/runs/route.ts @@ -0,0 +1,7 @@ +import { withPlayer } from "@/lib/play-route"; +import { startRun } from "@/lib/quiz-expert"; + +/** Start an expert run: deals its first card. */ +export async function POST() { + return withPlayer((userId) => startRun(userId)); +} diff --git a/app/api/play/signout/route.ts b/app/api/play/signout/route.ts new file mode 100644 index 0000000..1e97421 --- /dev/null +++ b/app/api/play/signout/route.ts @@ -0,0 +1,7 @@ +import { json } from "@/lib/play-route"; +import { signOut } from "@/lib/supabase-server"; + +export async function POST() { + await signOut(); + return json({ ok: true }); +} diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts new file mode 100644 index 0000000..f575434 --- /dev/null +++ b/app/auth/callback/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { safeNext } from "@/lib/quiz"; +import { exchangeAuthCode } from "@/lib/supabase-server"; + +/** Where GitHub, Google and email sign-in links land: set the session, go back to the game. */ +export async function GET(request: Request) { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const next = safeNext(url.searchParams.get("next")); + const ok = code ? await exchangeAuthCode(code) : false; + const target = new URL(next, url.origin); + if (!ok) target.searchParams.set("signin", "failed"); + return NextResponse.redirect(target); +} diff --git a/app/home.css b/app/home.css index a624173..04fee6f 100644 --- a/app/home.css +++ b/app/home.css @@ -266,7 +266,12 @@ @media (min-width: 48rem) { .next__list { - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (min-width: 72rem) { + .next__list { + grid-template-columns: repeat(4, minmax(0, 1fr)); } } diff --git a/app/play/leaderboard/page.tsx b/app/play/leaderboard/page.tsx new file mode 100644 index 0000000..4314c0c --- /dev/null +++ b/app/play/leaderboard/page.tsx @@ -0,0 +1,78 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { leaderboard } from "@/lib/quiz-expert"; +import "../play.css"; + +export const metadata: Metadata = { + title: "Quiz leaderboard", + description: "The best expert runs on the ax.guide agentic web quiz.", + alternates: { canonical: "/play/leaderboard" }, +}; + +// Read fresh at most once a minute. +export const revalidate = 60; + +type Props = { searchParams: Promise<{ period?: string }> }; + +/** Each player's best expert run: the expert deck is server-side, so these scores were earned. */ +export default async function LeaderboardPage({ searchParams }: Props) { + const period = (await searchParams).period === "week" ? "week" : "all"; + const rows = await leaderboard(period); + return ( +
+
+

The ax.guide quiz

+

Leaderboard

+

+ Each player's best expert run: 18 cards from a deck whose answers aren't public. Equal scores share a + place, and the earlier run is listed first. +

+
+ + {rows === undefined ? ( +

The leaderboard opens with the expert deck, soon.

+ ) : rows.length === 0 ? ( +

No expert runs yet. The top spot is open.

+ ) : ( + + + + + + + + + + + {rows.map((row) => ( + + + + + + + ))} + +
#Player + Right + + Points +
{row.rank} + {row.name} + + {row.correct}/{row.total} + {row.score.toLocaleString("en-US")}
+ )} + + Play + +
+ ); +} diff --git a/app/play/page.tsx b/app/play/page.tsx new file mode 100644 index 0000000..4242421 --- /dev/null +++ b/app/play/page.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from "next"; +import { QuizGame } from "@/components/quiz/quiz-game"; +import { getOpenDeck } from "@/lib/quiz-open"; +import { getStandards } from "@/lib/standards"; +import "./play.css"; + +export const metadata: Metadata = { + title: "The agentic web quiz", + description: + "How well do you know the standards that let AI agents find, use, sign in to, shop on and pay websites? Twelve cards, twenty seconds each.", + alternates: { canonical: "/play" }, +}; + +/** + * The quiz. Static: the open deck ships with the page and an open run plays in + * the browser. Expert runs talk to /api/play. + */ +export default function PlayPage() { + const standardNames = Object.fromEntries(getStandards().map((s) => [s.id, s.name])); + return ( +
+ +
+ ); +} diff --git a/app/play/play.css b/app/play/play.css new file mode 100644 index 0000000..e4d54e2 --- /dev/null +++ b/app/play/play.css @@ -0,0 +1,485 @@ +/* + * The quiz. Same poster as the guide: thick rules, hard shadows, and the + * accent still means one thing, right. Wrong is hatched, like a draft. + */ + +.play { + padding-block: var(--space-12) var(--space-24); +} + +.play mark { + background: var(--accent); + color: var(--on-accent); + padding: 0 0.12em; +} + +.play-link { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + font-weight: 700; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 3px; + cursor: pointer; +} +.play-link:hover { + text-decoration-color: var(--accent); +} + +.play-notice { + max-width: var(--measure); + margin-block: var(--space-6) 0; + padding: var(--space-3) var(--space-4); + border: var(--border-thin); + background: var(--color-card); +} +.play-notice--error { + border-width: 3px; +} + +/* Intro */ + +.play-intro__title { + max-width: 14ch; +} + +.play-decks { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr)); + gap: var(--space-8); + margin-top: var(--space-10); +} + +.play-deck { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-6); + border: var(--border); + background: var(--color-card); + box-shadow: var(--shadow); +} +.play-deck--expert { + background-image: repeating-linear-gradient(-45deg, var(--color-hatch) 0 2px, transparent 2px 10px); +} +.play-deck p { + margin: 0; + max-width: 34rem; +} +.play-deck__count { + font-family: var(--font-mono); + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.06em; +} +.play-deck__name { + margin: 0; + font-size: clamp(1.75rem, 1.2rem + 2vw, 2.5rem); + font-weight: 900; + font-stretch: 112%; + text-transform: uppercase; + line-height: 1; +} +.play-deck__go { + align-self: flex-start; + margin-top: auto; +} +.play-deck__soon, +.play-deck__who, +.play-deck__unlock { + font-size: var(--text-sm); +} +.play-deck__unlock { + font-weight: 800; +} + +.play-intro__foot { + margin-top: var(--space-8); + color: var(--color-ink-muted); + font-size: var(--text-sm); +} +.play-intro__foot a { + color: var(--color-ink); + font-weight: 700; +} + +/* Sign in */ + +.signin { + display: flex; + flex-direction: column; + gap: var(--space-3); +} +.signin__providers, +.signin__email { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); +} +.signin__email input { + flex: 1 1 12rem; + min-height: 3rem; + padding: var(--space-2) var(--space-3); + border: var(--border); + background: var(--color-paper); + color: var(--color-ink); + font: inherit; +} +.signin__email input:focus-visible { + outline: 3px solid var(--accent); + outline-offset: 2px; +} +.signin__note, +.signin__error { + color: var(--color-ink-muted); + font-size: var(--text-xs); +} +.signin__error { + color: var(--color-ink); + font-weight: 700; +} + +/* The board */ + +.play-board { + max-width: 46rem; + margin-inline: auto; +} + +.play-hud { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + margin-bottom: var(--space-5); +} +.play-hud__score { + display: flex; + align-items: baseline; + gap: var(--space-3); + margin: 0; + font-family: var(--font-mono); + font-size: var(--text-sm); +} +.play-hud__points strong { + font-size: 1.5em; +} +.play-hud__streak { + padding: 0 var(--space-2); + background: var(--accent); + color: var(--on-accent); + font-weight: 700; +} + +/* One square a card, filled like the share grid. */ +.play-progress { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; +} +.play-progress li { + width: 14px; + height: 14px; + border: 2px solid var(--color-rule); +} +.play-progress li[data-state="right"] { + background: var(--accent); +} +.play-progress li[data-state="wrong"] { + background: var(--color-ink); +} +.play-progress li[data-state="now"] { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.play-progress--big { + gap: 6px; + margin-block: var(--space-6); +} +.play-progress--big li { + width: clamp(16px, 5.8vw, 28px); + height: clamp(16px, 5.8vw, 28px); + border-width: 3px; +} + +.play-card { + position: relative; + padding: var(--space-6); + border: var(--border); + background: var(--color-card); + box-shadow: var(--shadow); +} +@media (min-width: 40rem) { + .play-card { + padding: var(--space-8) var(--space-10); + } +} +.play-card__kind { + display: flex; + justify-content: space-between; + margin: 0 0 var(--space-3); + font-family: var(--font-mono); + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.06em; +} +.play-card__prompt { + margin: var(--space-4) 0 var(--space-6); + font-size: clamp(1.25rem, 1.05rem + 0.9vw, 1.75rem); + font-weight: 800; + line-height: 1.2; + text-wrap: pretty; +} +.play-card__prompt:focus { + outline: none; +} + +/* The clock: a bar that empties over the card's time. */ +.play-clock { + height: 8px; + background: var(--accent); + border: 2px solid var(--color-rule); + transform-origin: left; + animation: play-clock linear forwards; +} +@keyframes play-clock { + from { + transform: scaleX(1); + } + to { + transform: scaleX(0); + } +} + +.play-options { + display: grid; + gap: var(--space-3); + margin: 0; + padding: 0; + list-style: none; +} +.play-option { + display: flex; + align-items: center; + gap: var(--space-4); + width: 100%; + min-height: 3.5rem; + padding: var(--space-3) var(--space-4); + border: var(--border); + background: var(--color-paper); + color: var(--color-ink); + font: inherit; + font-size: var(--text-base); + font-weight: 700; + text-align: left; + cursor: pointer; + transition: transform 120ms ease, box-shadow 120ms ease; +} +@media (hover: hover) { + .play-option:not(:disabled):hover { + transform: translate(-2px, -2px); + box-shadow: var(--shadow-sm); + } +} +.play-option:not(:disabled):active { + transform: translate(1px, 1px); + box-shadow: none; +} +.play-option:focus-visible { + outline: 3px solid var(--accent); + outline-offset: 3px; +} +.play-option:disabled { + cursor: default; +} +.play-option__key { + flex: none; + display: grid; + place-items: center; + width: 2rem; + height: 2rem; + border: var(--border-thin); + font-family: var(--font-mono); + font-size: var(--text-sm); +} +.play-option[data-state="answer"] { + background: var(--accent); + color: var(--on-accent); + border-color: var(--on-accent); +} +.play-option[data-state="answer"] .play-option__key { + border-color: var(--on-accent); +} +.play-option[data-state="chosen"] { + background-image: repeating-linear-gradient(-45deg, var(--color-hatch) 0 2px, transparent 2px 8px); + text-decoration: line-through; + text-decoration-thickness: 2px; +} +.play-option[data-state="other"] { + opacity: 0.45; +} + +.play-reveal { + margin-top: var(--space-6); + padding-top: var(--space-5); + border-top: var(--border); + animation: play-in 200ms ease-out; +} +.play-reveal__verdict { + margin: 0; + font-size: var(--text-lede); + font-weight: 900; + text-transform: uppercase; + font-stretch: 112%; +} +.play-card[data-state="right"] .play-reveal__verdict { + display: inline-block; + padding: 0 var(--space-2); + background: var(--accent); + color: var(--on-accent); +} +.play-reveal__explain { + margin: var(--space-3) 0 0; + max-width: var(--measure); +} +.play-reveal__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + margin-top: var(--space-5); +} +.play-reveal__actions .play-link { + font-size: var(--text-sm); +} + +@keyframes play-in { + from { + opacity: 0; + transform: translateY(6px); + } +} + +/* Results */ + +.play-results { + max-width: 46rem; + margin-inline: auto; +} +.play-results__score { + margin: var(--space-2) 0 0; + font-size: var(--text-poster); + font-weight: 900; + font-stretch: 125%; + line-height: 0.9; + letter-spacing: -0.03em; +} +.play-results__pts { + font-size: 0.3em; + letter-spacing: 0; +} +.play-results__line { + margin: var(--space-4) 0 0; + font-size: var(--text-lede); +} +.play-results__rank { + margin: var(--space-2) 0 0; +} +.play-results__share { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); +} +.play-results__misses, +.play-results__next { + margin-top: var(--space-12); +} +.play-results__misses ul { + display: flex; + flex-wrap: wrap; + gap: var(--space-2) var(--space-5); + margin: var(--space-3) 0 0; + padding: 0; + list-style: none; +} +.play-results__misses a { + color: inherit; + font-weight: 700; +} +.play-results__next { + padding: var(--space-6); + border: var(--border); + background-image: repeating-linear-gradient(-45deg, var(--color-hatch) 0 2px, transparent 2px 10px); +} +.play-results__next p { + max-width: var(--measure); +} + +/* Leaderboard and share pages */ + +.play-board-table { + width: 100%; + max-width: 46rem; + margin-top: var(--space-6); + border-collapse: collapse; + font-variant-numeric: tabular-nums; +} +.play-board-table th, +.play-board-table td { + padding: var(--space-3) var(--space-2); + border-bottom: var(--border-thin); + text-align: left; +} +.play-board-table th { + font-family: var(--font-mono); + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.06em; +} +.play-board-table td:first-child { + width: 3.5rem; + font-weight: 900; +} +.play-board-table tr:nth-child(-n + 3) td:first-child { + color: var(--accent); +} +.play-board-table .num { + text-align: right; +} +.play-board-table a { + color: inherit; +} + +.play-tabs { + display: flex; + gap: var(--space-3); + margin-top: var(--space-6); +} +.play-tabs a[aria-current="page"] { + background: var(--color-ink); + color: var(--color-paper); +} + +.play-share { + max-width: 46rem; +} +.play-share .button { + margin-top: var(--space-8); +} + +@media (prefers-reduced-motion: reduce) { + .play-clock { + animation-timing-function: steps(20, end); + } + .play-reveal { + animation: none; + } +} diff --git a/app/play/r/[id]/opengraph-image.tsx b/app/play/r/[id]/opengraph-image.tsx new file mode 100644 index 0000000..eb64086 --- /dev/null +++ b/app/play/r/[id]/opengraph-image.tsx @@ -0,0 +1,18 @@ +import { OG_SIZE } from "@/lib/og"; +import { quizShareImage } from "@/lib/og-quiz"; +import { finishedRun } from "@/lib/quiz-expert"; + +export const size = OG_SIZE; +export const contentType = "image/png"; +export const alt = "A verified expert run on the ax.guide agentic web quiz"; + +export default async function Image({ params }: { params: Promise<{ id: string }> }) { + const run = await finishedRun((await params).id); + return quizShareImage({ + kicker: run ? `${run.name} · expert run` : "ax.guide quiz · expert run", + score: run?.score ?? 0, + correct: run?.correct ?? 0, + total: run?.total ?? 0, + results: run?.results ?? [], + }); +} diff --git a/app/play/r/[id]/page.tsx b/app/play/r/[id]/page.tsx new file mode 100644 index 0000000..cf41ac7 --- /dev/null +++ b/app/play/r/[id]/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { finishedRun } from "@/lib/quiz-expert"; +import { ShareBody } from "@/components/quiz/share-body"; +import "../../play.css"; + +type Props = { params: Promise<{ id: string }> }; + +export async function generateMetadata({ params }: Props): Promise { + const run = await finishedRun((await params).id); + if (!run) return {}; + return { + title: `${run.name}: ${run.correct}/${run.total} on the expert run`, + description: `${run.score.toLocaleString("en-US")} points on the ax.guide expert deck, ranked. Can you beat it?`, + robots: { index: false }, + }; +} + +/** A finished expert run, read from the database: a score the server counted. */ +export default async function ExpertRun({ params }: Props) { + const run = await finishedRun((await params).id); + if (!run) notFound(); + return ; +} diff --git a/app/play/s/[code]/opengraph-image.tsx b/app/play/s/[code]/opengraph-image.tsx new file mode 100644 index 0000000..01e5f1f --- /dev/null +++ b/app/play/s/[code]/opengraph-image.tsx @@ -0,0 +1,18 @@ +import { OG_SIZE } from "@/lib/og"; +import { quizShareImage } from "@/lib/og-quiz"; +import { decodeShare } from "@/lib/quiz"; + +export const size = OG_SIZE; +export const contentType = "image/png"; +export const alt = "A score on the ax.guide agentic web quiz"; + +export default async function Image({ params }: { params: Promise<{ code: string }> }) { + const shared = decodeShare((await params).code) ?? { score: 0, results: [] }; + return quizShareImage({ + kicker: "ax.guide quiz · quick run", + score: shared.score, + correct: shared.results.filter(Boolean).length, + total: shared.results.length, + results: shared.results, + }); +} diff --git a/app/play/s/[code]/page.tsx b/app/play/s/[code]/page.tsx new file mode 100644 index 0000000..d834f39 --- /dev/null +++ b/app/play/s/[code]/page.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { ShareBody } from "@/components/quiz/share-body"; +import { decodeShare } from "@/lib/quiz"; +import "../../play.css"; + +type Props = { params: Promise<{ code: string }> }; + +export async function generateMetadata({ params }: Props): Promise { + const shared = decodeShare((await params).code); + if (!shared) return {}; + const correct = shared.results.filter(Boolean).length; + return { + title: `${correct}/${shared.results.length} on the agentic web quiz`, + description: `${shared.score.toLocaleString("en-US")} points. Twelve cards on the standards that let AI agents use the web. Can you beat it?`, + robots: { index: false }, + }; +} + +/** A shared open-run score. The score lives in the address, so this page needs no database. */ +export default async function SharedScore({ params }: Props) { + const shared = decodeShare((await params).code); + if (!shared) notFound(); + const correct = shared.results.filter(Boolean).length; + return ; +} diff --git a/app/sitemap.ts b/app/sitemap.ts index 306e7b3..8509331 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -6,7 +6,7 @@ export default function sitemap(): MetadataRoute.Sitemap { const base = currentSiteUrl(); const at = (path: string) => new URL(path, base).toString(); return [ - ...["/", "/compare", "/which", "/contribute"].map((path) => ({ url: at(path) })), + ...["/", "/compare", "/which", "/play", "/play/leaderboard", "/contribute"].map((path) => ({ url: at(path) })), ...getStandards().map((s) => ({ url: at(`/standards/${s.id}`), lastModified: s.last_verified })), ]; } diff --git a/components/quiz/dealers.ts b/components/quiz/dealers.ts new file mode 100644 index 0000000..2cd2fc4 --- /dev/null +++ b/components/quiz/dealers.ts @@ -0,0 +1,128 @@ +import { + CARD_SECONDS, + type CardResult, + cardPoints, + type DealtCard, + type Deck, + dealCard, + learnMore, + openGroup, + optionOrder, + pickCards, + RUN_CAPS, + RUN_LENGTH, +} from "@/lib/quiz"; +import type { QuizQuestion } from "@/lib/quiz-schema"; + +/** The end of a run, whichever deck it was. */ +export interface Summary { + deck: Deck; + score: number; + correct: number; + total: number; + results: boolean[]; + /** Expert runs only: the run's share page, and where it placed. */ + runId?: string; + rank?: number; + players?: number; +} + +/** + * Deals one run. The game screen doesn't know whether the cards come from the + * page (open runs) or the server (expert runs, which the server times). + */ +export interface Dealer { + deck: Deck; + total: number; + first(): Promise; + answer(position: number, choice: number | null, ms: number): Promise<{ result: CardResult; summary?: Summary }>; + next(position: number): Promise; +} + +/** An open run, played entirely in the browser from the public deck. */ +export function openDealer(deck: QuizQuestion[], standardNames: Record): Dealer { + const picked = pickCards(deck, RUN_LENGTH.open, RUN_CAPS.open, openGroup); + const orders = picked.map((q) => optionOrder(q)); + const results: boolean[] = []; + let score = 0; + let streak = 0; + const card = (i: number) => dealCard(picked[i] as QuizQuestion, orders[i] ?? []); + return { + deck: "open", + total: picked.length, + first: async () => card(0), + next: async (position) => card(position), + async answer(position, choice, ms) { + const question = picked[position] as QuizQuestion; + const shown = (orders[position] ?? []).indexOf(question.answer); + const correct = choice === shown && ms <= CARD_SECONDS * 1000; + streak = correct ? streak + 1 : 0; + const points = cardPoints(correct, ms, streak); + score += points; + results.push(correct); + const result: CardResult = { + correct, + answer: shown, + explain: question.explain, + learnMore: learnMore(question, question.standard ? standardNames[question.standard] : undefined), + points, + score, + streak, + }; + if (results.length < picked.length) return { result }; + return { + result, + summary: { deck: "open", score, correct: results.filter(Boolean).length, total: picked.length, results: [...results] }, + }; + }, + }; +} + +class PlayRequestError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + } +} + +async function post(path: string, body?: unknown): Promise { + const response = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const data = (await response.json().catch(() => ({}))) as T & { error?: string }; + if (!response.ok) throw new PlayRequestError(response.status, data.error ?? "Something went wrong. Try again."); + return data; +} + +interface RunState { + runId: string; + total: number; + position: number; + card: DealtCard; +} + +/** + * An expert run, dealt and scored by the server. Starting it deals the first + * card, so `first` only hands that card over. + */ +export async function expertDealer(): Promise { + const start = await post("/api/play/runs"); + const url = `/api/play/runs/${start.runId}`; + return { + deck: "expert", + total: start.total, + first: async () => start.card, + next: async (position) => (await post(url, { action: "next", position })).card, + async answer(position, choice) { + const { result, summary } = await post<{ + result: CardResult; + summary?: Omit; + }>(url, { action: "answer", position, choice }); + return { result, summary: summary && { ...summary, deck: "expert" } }; + }, + }; +} diff --git a/components/quiz/quiz-game.tsx b/components/quiz/quiz-game.tsx new file mode 100644 index 0000000..98fcc1e --- /dev/null +++ b/components/quiz/quiz-game.tsx @@ -0,0 +1,363 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { CARD_SECONDS, type CardResult, type DealtCard, encodeShare, KIND_LABELS, RUN_LENGTH } from "@/lib/quiz"; +import type { QuizQuestion } from "@/lib/quiz-schema"; +import { type Dealer, expertDealer, openDealer, type Summary } from "./dealers"; +import { Results } from "./results"; +import { SignIn } from "./sign-in"; + +type Phase = + | { name: "intro" } + | { name: "loading" } + | { name: "card"; card: DealtCard; position: number; dealtAt: number } + | { name: "revealed"; card: DealtCard; position: number; choice: number | null; result: CardResult; summary?: Summary } + | { name: "done"; summary: Summary; misses: CardResult["learnMore"][] }; + +interface Me { + enabled: boolean; + player: { name: string } | null; +} + +const LETTERS = ["A", "B", "C", "D"]; + +export function QuizGame({ deck, standardNames }: { deck: QuizQuestion[]; standardNames: Record }) { + const [phase, setPhase] = useState({ name: "intro" }); + const [me, setMe] = useState(); + const [error, setError] = useState(); + const [notice, setNotice] = useState(); + const [results, setResults] = useState([]); + const [score, setScore] = useState(0); + const [total, setTotal] = useState(0); + const dealer = useRef(undefined); + const misses = useRef([]); + const busy = useRef(false); + const promptRef = useRef(null); + const nextRef = useRef(null); + const expertRef = useRef(null); + + useEffect(() => { + fetch("/api/play/me") + .then((r) => (r.ok ? (r.json() as Promise) : { enabled: false, player: null })) + .catch(() => ({ enabled: false, player: null })) + .then((found) => { + setMe(found); + if (new URLSearchParams(window.location.search).get("signin") === "failed") { + setNotice("That sign-in link didn't work. It may have expired; try again."); + } + }); + }, []); + + // Back from signing in for the expert deck: put the start button in reach. + useEffect(() => { + if (me?.player && new URLSearchParams(window.location.search).get("deck") === "expert") expertRef.current?.focus(); + }, [me]); + + async function begin(make: () => Promise) { + if (busy.current) return; + busy.current = true; + setError(undefined); + setPhase({ name: "loading" }); + try { + const d = await make(); + dealer.current = d; + misses.current = []; + setResults([]); + setScore(0); + setTotal(d.total); + const card = await d.first(); + setPhase({ name: "card", card, position: 0, dealtAt: performance.now() }); + } catch (e) { + setError(e instanceof Error ? e.message : "The run could not start."); + setPhase({ name: "intro" }); + } finally { + busy.current = false; + } + } + + const answer = useCallback( + async (choice: number | null) => { + if (phase.name !== "card" || !dealer.current || busy.current) return; + busy.current = true; + const ms = performance.now() - phase.dealtAt; + try { + const { result, summary } = await dealer.current.answer(phase.position, choice, ms); + if (!result.correct) misses.current.push(result.learnMore); + setResults((r) => [...r, result.correct]); + setScore(result.score); + setPhase({ name: "revealed", card: phase.card, position: phase.position, choice, result, summary }); + } catch (e) { + setError(e instanceof Error ? e.message : "That answer didn't go through."); + } finally { + busy.current = false; + } + }, + [phase], + ); + + const advance = useCallback(async () => { + if (phase.name !== "revealed" || !dealer.current || busy.current) return; + if (phase.summary) { + const unique = [...new Map(misses.current.map((m) => [m.href, m])).values()]; + setPhase({ name: "done", summary: phase.summary, misses: unique }); + return; + } + busy.current = true; + try { + const position = phase.position + 1; + const card = await dealer.current.next(position); + setPhase({ name: "card", card, position, dealtAt: performance.now() }); + } catch (e) { + setError(e instanceof Error ? e.message : "The next card didn't come."); + } finally { + busy.current = false; + } + }, [phase]); + + // The clock: running out counts as a wrong answer. + useEffect(() => { + if (phase.name !== "card") return; + const timer = window.setTimeout(() => answer(null), CARD_SECONDS * 1000); + return () => window.clearTimeout(timer); + }, [phase, answer]); + + // Focus follows the game, so it plays from the keyboard and reads well aloud. + useEffect(() => { + if (phase.name === "card") promptRef.current?.focus(); + if (phase.name === "revealed") nextRef.current?.focus(); + }, [phase]); + + // 1-4 or A-D answer; Enter or the right arrow moves on. + useEffect(() => { + function onKey(event: KeyboardEvent) { + if (event.metaKey || event.ctrlKey || event.altKey) return; + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return; + if (phase.name === "card") { + const key = event.key.toUpperCase(); + const index = /^[1-4]$/.test(key) ? Number(key) - 1 : LETTERS.indexOf(key); + if (index >= 0 && index < phase.card.options.length) { + event.preventDefault(); + answer(index); + } + } else if (phase.name === "revealed" && event.key === "ArrowRight") { + event.preventDefault(); + advance(); + } + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [phase, answer, advance]); + + async function signOut() { + await fetch("/api/play/signout", { method: "POST" }); + setMe((m) => (m ? { ...m, player: null } : m)); + } + + const playOpen = () => begin(async () => openDealer(deck, standardNames)); + const playExpert = () => begin(expertDealer); + + if (phase.name === "intro" || phase.name === "loading") { + const loading = phase.name === "loading"; + return ( +
+
+

The ax.guide quiz

+

+ How well do you know the agentic web? +

+

+ {RUN_LENGTH.open} cards on the standards that let AI agents find, use, sign in to, shop on and pay websites, + and the web plumbing under them. {CARD_SECONDS} seconds a card. Fast answers score more, and so does a streak. +

+
+ + {notice && ( +

+ {notice} +

+ )} + {error && ( +

+ {error} +

+ )} + +
+
+

{RUN_LENGTH.open} cards

+

+ Quick run +

+

No sign-up. A random dozen from the open deck, then a score to share.

+ +
+ +
+

{RUN_LENGTH.expert} cards · ranked

+

+ Expert run +

+

+ A second deck of 60 harder cards: headers, handshakes and the pairs everyone mixes up. Its answers aren't + on GitHub, so the leaderboard means something. +

+ {me === undefined ? null : !me.enabled ? ( +

The expert deck and leaderboard open soon.

+ ) : me.player ? ( + <> + +

+ Playing as {me.player.name}.{" "} + +

+ + ) : ( + <> +

Sign in to unlock it:

+ + + )} +
+
+ +

+ {me?.enabled && ( + <> + See the leaderboard ·{" "} + + )} + Every answer links to the page that explains it. +

+
+ ); + } + + if (phase.name === "done") { + return ( + + Try the expert deck + + ) : ( + + ) + ) : undefined + } + /> + ); + } + + const revealed = phase.name === "revealed" ? phase : undefined; + return ( +
+
+
    + {Array.from({ length: total }, (_, i) => ( +
  1. + ))} +
+

+ + {score.toLocaleString("en-US")} pts + + {revealed && revealed.result.streak > 1 && {revealed.result.streak} in a row} +

+
+ +
+

+ {KIND_LABELS[phase.card.kind]} + + {phase.position + 1}/{total} + +

+ {!revealed &&
+ + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/components/quiz/results.tsx b/components/quiz/results.tsx new file mode 100644 index 0000000..438e481 --- /dev/null +++ b/components/quiz/results.tsx @@ -0,0 +1,125 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { shareGrid, verdict } from "@/lib/quiz"; +import type { Summary } from "./dealers"; + +export function Results({ + summary, + shareCode, + misses, + onAgain, + expert, +}: { + summary: Summary; + /** Open runs: the code in /play/s/. Expert runs share /play/r/. */ + shareCode?: string; + misses: { href: string; label: string }[]; + onAgain: () => void; + /** The way into the expert deck, after an open run. */ + expert?: React.ReactNode; +}) { + const [copied, setCopied] = useState(false); + const path = summary.runId ? `/play/r/${summary.runId}` : `/play/s/${shareCode ?? ""}`; + const url = typeof window === "undefined" ? path : `${window.location.origin}${path}`; + const deckName = summary.deck === "expert" ? "Expert run" : "Agentic web quiz"; + const text = `${deckName} on ax.guide: ${summary.correct}/${summary.total}, ${summary.score.toLocaleString("en-US")} pts\n${shareGrid(summary.results)}`; + + async function share() { + if (navigator.share) { + try { + await navigator.share({ text, url }); + return; + } catch { + // Cancelled, or not allowed here: copy instead. + } + } + await navigator.clipboard.writeText(`${text}\n${url}`); + setCopied(true); + window.setTimeout(() => setCopied(false), 2500); + } + + const encoded = encodeURIComponent(`${text}\n${url}`); + return ( +
+

{deckName}

+

+ Score: + {summary.score.toLocaleString("en-US")} + pts +

+

+ + {summary.correct} of {summary.total} + {" "} + right. {verdict(summary.correct, summary.total)} +

+ {summary.rank !== undefined && ( +

+ That places you #{summary.rank} of {summary.players} on the{" "} + leaderboard (each player's best run counts). +

+ )} +
    + {summary.results.map((right, i) => ( +
  1. + + Card {i + 1}: {right ? "right" : "wrong"} + +
  2. + ))} +
+ +
+ + + Post on X + + + LinkedIn + + +
+ + {misses.length > 0 && ( +
+

+ Brush up on +

+ +
+ )} + + {expert && ( +
+

+ Ready for harder? +

+

+ The expert deck is 60 tougher cards, 18 a run, ranked on the leaderboard. Its answers aren't public, so + the top of the board is earned. +

+ {expert} +
+ )} +
+ ); +} diff --git a/components/quiz/share-body.tsx b/components/quiz/share-body.tsx new file mode 100644 index 0000000..3361689 --- /dev/null +++ b/components/quiz/share-body.tsx @@ -0,0 +1,44 @@ +import Link from "next/link"; +import { verdict } from "@/lib/quiz"; + +/** A shared score: the result, the grid, and a dare to beat it. */ +export function ShareBody({ + kicker, + score, + correct, + results, +}: { + kicker: string; + score: number; + correct: number; + results: boolean[]; +}) { + return ( +
+

{kicker}

+

+ {score.toLocaleString("en-US")} + pts +

+

+ + {correct} of {results.length} + {" "} + right. {verdict(correct, results.length)} +

+
    + {results.map((right, i) => ( +
  1. + + Card {i + 1}: {right ? "right" : "wrong"} + +
  2. + ))} +
+

How well do you know the standards that let AI agents find, use, sign in to, shop on and pay websites?

+ + Beat this score + +
+ ); +} diff --git a/components/quiz/sign-in.tsx b/components/quiz/sign-in.tsx new file mode 100644 index 0000000..22a8297 --- /dev/null +++ b/components/quiz/sign-in.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { createBrowserClient } from "@supabase/ssr"; +import { useState } from "react"; +import { supabasePublicEnv } from "@/lib/supabase-env"; + +function browserClient() { + const env = supabasePublicEnv(); + return env ? createBrowserClient(env.url, env.anonKey) : undefined; +} + +function callbackUrl(): string { + return `${window.location.origin}/auth/callback?next=${encodeURIComponent("/play?deck=expert")}`; +} + +/** GitHub, Google or an emailed link. Each comes back through /auth/callback. */ +export function SignIn() { + const [email, setEmail] = useState(""); + const [state, setState] = useState<"idle" | "sending" | "sent" | "error">("idle"); + + async function withProvider(provider: "github" | "google") { + const client = browserClient(); + if (!client) return setState("error"); + const { error } = await client.auth.signInWithOAuth({ provider, options: { redirectTo: callbackUrl() } }); + if (error) setState("error"); + } + + async function withEmail(event: React.FormEvent) { + event.preventDefault(); + const client = browserClient(); + if (!client) return setState("error"); + setState("sending"); + const { error } = await client.auth.signInWithOtp({ email, options: { emailRedirectTo: callbackUrl() } }); + setState(error ? "error" : "sent"); + } + + if (state === "sent") { + return ( +

+ Check {email} for a sign-in link. It brings you straight back here. +

+ ); + } + + return ( +
+
+ + +
+
+ + setEmail(e.target.value)} + /> + +
+ {state === "error" && ( +

+ Sign-in didn't start. Try again, or another way. +

+ )} +

+ Only your GitHub handle, or your first name and an initial, appear on the leaderboard. Never your email. +

+
+ ); +} diff --git a/content/home.json b/content/home.json index 159379a..1c2b56f 100644 --- a/content/home.json +++ b/content/home.json @@ -40,6 +40,11 @@ "body": "Start from what you're building, not from the acronym.", "href": "/which" }, + { + "title": "Test yourself", + "body": "Twelve cards, twenty seconds each. Then share your score.", + "href": "/play" + }, { "title": "Fix or add a standard", "body": "Every page is a file. Open a pull request.", diff --git a/content/quiz/README.md b/content/quiz/README.md new file mode 100644 index 0000000..59faa0a --- /dev/null +++ b/content/quiz/README.md @@ -0,0 +1,57 @@ +# The quiz + +[ax.guide/play](https://ax.guide/play) is a card game about the agentic web: the twelve standards on the map and the web plumbing they build on. It has two decks. + +| Deck | Where it lives | A run | Who can play | +| --- | --- | --- | --- | +| **Open** | [`open.json`](open.json), plus one "Real or hype?" card per standard, written from its page | 12 cards, played in the browser | Anyone, no sign-up | +| **Expert** | Supabase, never this repository | 18 cards, dealt and scored by the server | Signed-in players; ranked on the leaderboard | + +The expert deck is kept out of the repository on purpose. If its answers were on GitHub, the leaderboard would mean nothing. + +## Cards + +Each card is checked against [`lib/quiz-schema.ts`](../../lib/quiz-schema.ts), and `npm test` fails on a broken card. A card has: + +- `id`: lowercase words and hyphens, starting with `o-` in the open deck and `x-` in the expert deck. +- `kind`: one of these four. + - `quick`: a plain question. + - `which`: "Which standard am I?" + - `fake`: "Spot the fake". `answer` points at the made-up option. + - `real`: "Real or hype?" +- `prompt`, `options` (two to four) and `answer` (the index of the right option). +- `explain`: one or two sentences shown after the answer. Make the player smarter even when they got it right. +- `standard`: the standard the card is about, if any. "Read more" links to its page. +- `source`: the primary source for the fact. +- `checked`: the day someone last checked the card. + +Options are shuffled when a card is dealt, so an option can never depend on its position ("both of the above"). The exceptions are `real` cards and True/False cards, which keep their written order. + +**Write cards that stay true.** Ask how a standard works, who made it, what it is for, and what it gets confused with. Avoid version numbers, deployment counts and "latest draft" dates: they change monthly. A standard's status is already asked by its generated "Real or hype?" card, whose answer changes the day its page does. The house style of [CONTRIBUTING.md](../../CONTRIBUTING.md) applies: plain words, no em dashes. + +## Scoring + +A right answer earns 100 points. Answering inside 3 seconds adds up to 50 more, sliding to nothing at 20 seconds. Each right answer in an unbroken streak adds 10 more, up to 50. A wrong answer, or running out of time, earns nothing and breaks the streak. The rules are in [`lib/quiz.ts`](../../lib/quiz.ts). + +On an expert run the server deals each card and keeps time with its own clock. A card's clock stops when it is answered and starts again only when the next card is dealt, so reading an explanation costs nothing. A card can't be answered twice, and asking for it again doesn't restart its clock. + +## Setting up the expert deck, sign-in and the leaderboard + +The open quiz needs none of this. Without it, `/play` shows the expert deck and leaderboard as "soon". + +1. **Create a Supabase project.** Open its SQL editor and run [`supabase/migrations/20261030000000_quiz.sql`](../../supabase/migrations/20261030000000_quiz.sql), or use `supabase db push` with the Supabase CLI. Every table has row level security and no policies, so only the site's server can read them. +2. **Turn on sign-in** (Authentication > Sign In / Providers): GitHub, Google and Email. + - **GitHub:** create an OAuth app at github.com/settings/developers whose callback URL is `https://.supabase.co/auth/v1/callback`. + - **Google:** create an OAuth client in Google Cloud with the same callback. + - **Email:** Supabase sends the magic links itself. Its built-in sender is rate-limited, so add your own SMTP (for example Resend) before launch. +3. **Set the redirect URLs** (Authentication > URL Configuration): + - **Site URL:** `https://ax.guide` + - **Redirect URLs:** `https://ax.guide/auth/callback`, `https://*-.vercel.app/auth/callback` for previews, and `http://localhost:3000/auth/callback` +4. **Add the three variables** from [`.env.example`](../../.env.example) in Vercel (Project > Settings > Environment Variables). The service role key is a secret: keep it server-only. +5. **Load the expert deck.** Keep it in `private/quiz-expert.json`; the `private/` folder is gitignored. Run `npm run quiz:sql`: it checks the deck with the same tests as the open deck, then writes `private/quiz-expert.sql`. Run that file in the SQL editor. Re-running it updates changed cards, adds new ones and switches off cards you removed. Runs already in progress keep working. + +Store the master copy of `quiz-expert.json` somewhere private, such as a private repository, a password manager or a private gist. The database holds a copy, but not a convenient one to edit. + +## Players' data + +A player's leaderboard name is their GitHub handle, or their first name and last initial, or `player-` and six characters. It is never their email. Runs are kept to rank them. Deleting a user in Supabase Auth deletes their profile and runs. Open runs never leave the browser. diff --git a/content/quiz/open.json b/content/quiz/open.json new file mode 100644 index 0000000..6bf0dbc --- /dev/null +++ b/content/quiz/open.json @@ -0,0 +1,513 @@ +[ + { + "id": "o-ard-which", + "kind": "which", + "prompt": "Your travel agent needs a weather tool it has never heard of, so it asks a registry that searches sites' tool lists. Which standard is at work?", + "options": ["NLWeb", "WebMCP", "ARD", "Poppy"], + "answer": 2, + "explain": "ARD is a search engine for agent tools: sites publish /.well-known/ard.json and registries collect those files to answer plain-language searches. A high ranking still isn't proof a tool is trustworthy.", + "standard": "ard", + "source": "https://agenticresourcediscovery.org/spec/", + "checked": "2026-10-30" + }, + { + "id": "o-ard-vs-ai-catalog", + "kind": "quick", + "prompt": "ARD and ai-catalog agreed to stay loosely linked. How do they split the work?", + "options": ["ARD publishes, ai-catalog discovers", "ai-catalog publishes, ARD discovers", "ARD replaced ai-catalog outright", "They merged into one shared file"], + "answer": 1, + "explain": "ai-catalog is the publishing format, your site's list of its AI offerings; ARD is the discovery format that registries crawl. The practical tip: serve the same file at both ard.json and ai-catalog.json.", + "standard": "ard", + "source": "https://github.com/ards-project/ard-spec/blob/main/adr/0010-ard-and-ai-catalog-loose-coupling.md", + "checked": "2026-10-30" + }, + { + "id": "o-ai-catalog-which", + "kind": "which", + "prompt": "A restaurant lists its booking tool and its jobs tool in one JSON file, like a table of contents for AI. Which standard?", + "options": ["ai-catalog", "NLWeb", "UCP", "auth.md"], + "answer": 0, + "explain": "ai-catalog is one JSON list at /.well-known/ai-catalog.json. Like a table of contents, it doesn't hold the tools themselves; each entry says what kind of thing it is and where to find its full description.", + "standard": "ai-catalog", + "source": "https://ai-catalog.io/", + "checked": "2026-10-30" + }, + { + "id": "o-ai-catalog-still-serve", + "kind": "real", + "prompt": "ARD calls ai-catalog its predecessor. True or false: it still pays to serve ai-catalog.json.", + "options": ["True", "False"], + "answer": 0, + "explain": "ai-catalog was split off, not retired. MCP's Server Cards point discovery at ai-catalog.json and many publishers only use that address, so serve one file at both ai-catalog.json and ard.json.", + "standard": "ai-catalog", + "source": "https://ax.guide/standards/ai-catalog", + "checked": "2026-10-30" + }, + { + "id": "o-nlweb-which", + "kind": "which", + "prompt": "Your recipe site answers \"vegetarian dinners under 30 minutes, no nuts\" at /ask for people and /mcp for agents. Which standard?", + "options": ["WebMCP", "ARD", "UCP", "NLWeb"], + "answer": 3, + "explain": "NLWeb indexes the structured data a site already has, like Schema.org tags and RSS, and answers only from that site. Every NLWeb site is also an MCP server, so agents can ask the same questions.", + "standard": "nlweb", + "source": "https://developers.cloudflare.com/ai-search/how-to/nlweb", + "checked": "2026-10-30" + }, + { + "id": "o-nlweb-w3c-group", + "kind": "quick", + "prompt": "NLWeb has a W3C community group. What does that make NLWeb?", + "options": ["A W3C Recommendation", "Still not a W3C standard", "A W3C Candidate Recommendation", "An official W3C working draft"], + "answer": 1, + "explain": "Community groups are open forums that anyone can propose, and they aren't a standards track. W3C standards come out of working groups, so NLWeb is an open spec with a W3C discussion space.", + "standard": "nlweb", + "source": "https://www.w3.org/community/nlweb", + "checked": "2026-10-30" + }, + { + "id": "o-guha-two-standards", + "kind": "quick", + "prompt": "R.V. Guha helped create RSS and Schema.org. Which two agentic-web standards also carry his name?", + "options": ["UCP and ACP", "x402 and MPP", "ARD and NLWeb", "WebMCP and Poppy"], + "answer": 2, + "explain": "Guha conceived NLWeb at Microsoft and co-authored ARD with Google and Hugging Face. NLWeb even reuses his earlier work: it indexes a site's Schema.org data.", + "standard": "nlweb", + "source": "https://ax.guide/standards/nlweb", + "checked": "2026-10-30" + }, + { + "id": "o-webmcp-which", + "kind": "which", + "prompt": "A salon's page calls document.modelContext.registerTool() so the agent in your browser can book_appointment. Which standard?", + "options": ["NLWeb", "WebMCP", "Poppy", "UCP"], + "answer": 1, + "explain": "WebMCP lets a page hand typed tools, with names, descriptions and fields, to the agent in your browser. The agent stops hunting for the calendar widget and just calls the tool.", + "standard": "webmcp", + "source": "https://webmachinelearning.github.io/webmcp/", + "checked": "2026-10-30" + }, + { + "id": "o-webmcp-where-it-runs", + "kind": "quick", + "prompt": "When an agent calls a WebMCP tool, where does the work happen?", + "options": ["In your own signed-in browser tab", "On a separate MCP server", "Inside an agent search registry", "On the card network's servers"], + "answer": 0, + "explain": "WebMCP tools are JavaScript functions on the page, running in your tab and your session, so you can watch and step in. A regular MCP server serves agents that never load your page.", + "standard": "webmcp", + "source": "https://developer.chrome.com/docs/ai/webmcp", + "checked": "2026-10-30" + }, + { + "id": "o-webmcp-tool-text-risk", + "kind": "quick", + "prompt": "WebMCP tools come with plain-English descriptions the AI reads. What's the catch?", + "options": ["Descriptions slow pages down", "Only Chrome can read them", "They must be translated", "A shady site can trick the agent"], + "answer": 3, + "explain": "Tool descriptions are text the model reads, so a malicious page can try prompt injection. Chrome says agents must defend against this and gives sites ways to flag untrusted text.", + "standard": "webmcp", + "source": "https://developer.chrome.com/docs/ai/webmcp/secure-tools", + "checked": "2026-10-30" + }, + { + "id": "o-web-bot-auth-which", + "kind": "which", + "prompt": "A news site lets a verified shopping agent in but blocks a scraper wearing the exact same user-agent label. Which standard tells them apart?", + "options": ["auth.md", "Poppy", "Web Bot Auth", "ARD"], + "answer": 2, + "explain": "With Web Bot Auth, the bot's operator publishes public keys and signs every request. Anyone can copy a label, but nobody can fake a valid signature without the private key.", + "standard": "web-bot-auth", + "source": "https://datatracker.ietf.org/doc/draft-ietf-webbotauth-httpsig-protocol/", + "checked": "2026-10-30" + }, + { + "id": "o-web-bot-auth-rfc", + "kind": "quick", + "prompt": "Web Bot Auth didn't invent its own crypto. Which existing IETF standard does it sign requests with?", + "options": ["JSON Web Tokens", "HTTP Message Signatures", "TLS client certificates", "OAuth device flow"], + "answer": 1, + "explain": "It reuses RFC 9421, HTTP Message Signatures, which signs chosen parts of a request. Web Bot Auth adds where a bot's keys live and how a site finds them.", + "standard": "web-bot-auth", + "source": "https://datatracker.ietf.org/doc/draft-ietf-webbotauth-httpsig-protocol/", + "checked": "2026-10-30" + }, + { + "id": "o-card-networks-web-bot-auth", + "kind": "real", + "prompt": "True or false: Visa's Trusted Agent Protocol and Mastercard's Agent Pay build on Web Bot Auth.", + "options": ["True", "False"], + "answer": 0, + "explain": "Both build on Web Bot Auth's signed requests and add a tag saying whether the agent is browsing or buying. The card networks reused the bot ID layer instead of inventing their own.", + "standard": "web-bot-auth", + "source": "https://blog.cloudflare.com/secure-agentic-commerce/", + "checked": "2026-10-30" + }, + { + "id": "o-auth-md-which", + "kind": "which", + "prompt": "Your coding agent calls a scraping API you've never used, gets \"401: who are you?\" and a file explaining how to sign you up. Which standard?", + "options": ["auth.md", "Poppy", "Web Bot Auth", "AP2"], + "answer": 0, + "explain": "auth.md is a sign-up page written for robots, served at yourapp.com/auth.md. However the agent signs you up, it ends with a normal short-lived, revocable OAuth token tied to you.", + "standard": "auth-md", + "source": "https://github.com/workos/auth.md", + "checked": "2026-10-30" + }, + { + "id": "o-auth-md-fake-path", + "kind": "fake", + "prompt": "Spot the fake: which is NOT one of auth.md's ways for an agent to sign you up?", + "options": ["Vouched for by the agent's maker", "Type a code on the service's page", "Pay a $1 fee to prove you're human", "Start anonymous, claim it later"], + "answer": 2, + "explain": "auth.md has three paths: the agent's maker vouches for you, you sign in and type a short code on the service's own page, or the agent starts anonymous and you claim the account later. No fee involved.", + "standard": "auth-md", + "source": "https://github.com/workos/auth.md", + "checked": "2026-10-30" + }, + { + "id": "o-poppy-which", + "kind": "which", + "prompt": "It's a valet key for your personal agent: it can sign in and act for you, but can't open the glovebox. Which standard?", + "options": ["auth.md", "Web Bot Auth", "AP2", "Poppy"], + "answer": 3, + "explain": "Poppy, the Personal Agent Protocol, lets your agent sign in to a company as you, with only the permissions you granted. The company explains how at /.well-known/poppy.json.", + "standard": "poppy", + "source": "https://personalagentprotocol.org/docs/spec", + "checked": "2026-10-30" + }, + { + "id": "o-poppy-design-partners", + "kind": "real", + "prompt": "True or false: OpenAI, Visa and PayPal are Poppy design partners, so they already support it.", + "options": ["True", "False"], + "answer": 1, + "explain": "Design partners help shape a draft; that's not the same as shipping it. Before believing a logo, check whether the company actually serves a poppy.json.", + "standard": "poppy", + "source": "https://sierra.ai/blog/poppy", + "checked": "2026-10-30" + }, + { + "id": "o-poppy-fake-pairing", + "kind": "fake", + "prompt": "Spot the fake: which standard is paired with the wrong launch partners?", + "options": ["Poppy: Google + Visa", "UCP: Google + Shopify", "ACP: OpenAI + Stripe", "MPP: Stripe + Tempo"], + "answer": 0, + "explain": "Poppy came from Sierra and Meta, announced October 6, 2026. The rest are right: Google and Shopify launched UCP, OpenAI and Stripe launched ACP, and Stripe and Tempo launched MPP.", + "standard": "poppy", + "source": "https://sierra.ai/blog/poppy", + "checked": "2026-10-30" + }, + { + "id": "o-ucp-which", + "kind": "which", + "prompt": "A universal power socket for shopping: any assistant plugs into any store, from catalog to cart to checkout. Which standard?", + "options": ["ACP", "UCP", "AP2", "Poppy"], + "answer": 1, + "explain": "UCP defines the common steps of buying and carries them over ordinary web APIs, MCP or A2A. A store describes what it supports at /.well-known/ucp, and the retailer stays the legal seller.", + "standard": "ucp", + "source": "https://ucp.dev/", + "checked": "2026-10-30" + }, + { + "id": "o-ucp-council-seat", + "kind": "real", + "prompt": "True or false: Amazon sits on UCP's technical council, so Amazon sells through UCP.", + "options": ["True", "False"], + "answer": 1, + "explain": "A council seat isn't a storefront. Companies join councils to shape a spec; whether they deploy it is a separate question you check store by store.", + "standard": "ucp", + "source": "https://ucp.dev/documentation/announcements/", + "checked": "2026-10-30" + }, + { + "id": "o-acp-which", + "kind": "which", + "prompt": "You want your products shown in ChatGPT with accurate, current prices, and you already use Stripe. Which standard?", + "options": ["UCP", "NLWeb", "ACP", "ARD"], + "answer": 2, + "explain": "ACP comes from OpenAI and Stripe. Merchants push product feeds and promotions so ChatGPT can show current products, prices and comparisons. UCP is the rival built for any assistant.", + "standard": "acp", + "source": "https://openai.com/index/powering-product-discovery-in-chatgpt/", + "checked": "2026-10-30" + }, + { + "id": "o-acp-main-job", + "kind": "quick", + "prompt": "ACP launched alongside \"Instant Checkout\" in ChatGPT. What is its main job today?", + "options": ["Paying with stablecoins", "Signing users in to stores", "Ranking stores by trust", "Product discovery in ChatGPT"], + "answer": 3, + "explain": "In March 2026 OpenAI expanded ACP to product discovery, while checkout moved back to the merchant's side. The headline feature, buying inside the chat, is the part that was scaled back.", + "standard": "acp", + "source": "https://openai.com/index/powering-product-discovery-in-chatgpt/", + "checked": "2026-10-30" + }, + { + "id": "o-ap2-which", + "kind": "which", + "prompt": "A signed, notarized shopping list with a spending limit the bank can check before paying. Which standard?", + "options": ["AP2", "x402", "MPP", "ACP"], + "answer": 0, + "explain": "AP2 records what you approved as signed \"mandates,\" so the bank and merchant can answer \"did a real person agree to this?\" It authorizes payments; other rails actually move the money.", + "standard": "ap2", + "source": "https://github.com/google-agentic-commerce/AP2", + "checked": "2026-10-30" + }, + { + "id": "o-ap2-human-not-present", + "kind": "quick", + "prompt": "AP2 supports \"Human Not Present\" purchases. What does that mean?", + "options": ["The agent pays with no limits", "You pre-sign rules; it buys later", "The merchant signs on your behalf", "A bot buys with no user at all"], + "answer": 1, + "explain": "You sign the rules up front, say \"concert tickets, under $200,\" and the agent buys later when the conditions are met, even at 3 a.m. The signed limits are what make that safe for the bank.", + "standard": "ap2", + "source": "https://github.com/google-agentic-commerce/AP2", + "checked": "2026-10-30" + }, + { + "id": "o-ap2-fake-mandate", + "kind": "fake", + "prompt": "Spot the fake: AP2 records your consent in signed mandates. Which one is made up?", + "options": ["Open checkout mandate", "Closed checkout mandate", "Refund mandate", "Payment mandate"], + "answer": 2, + "explain": "AP2 has checkout mandates (open while they hold your limits, closed once they lock exact items and price) and payment mandates for how you pay. There's no refund mandate.", + "standard": "ap2", + "source": "https://github.com/google-agentic-commerce/AP2", + "checked": "2026-10-30" + }, + { + "id": "o-mpp-which", + "kind": "which", + "prompt": "A research agent pays a fraction of a cent per article by card through Stripe, then settles the total when it finishes. Which standard?", + "options": ["AP2", "ACP", "x402", "MPP"], + "answer": 3, + "explain": "MPP, from Stripe and Tempo, uses HTTP 402 like x402 but adds cards via Stripe, subscriptions and pay-as-you-go \"sessions.\" x402 is per-request stablecoin payments under a neutral foundation.", + "standard": "mpp", + "source": "https://mpp.dev/", + "checked": "2026-10-30" + }, + { + "id": "o-mpp-individual-draft", + "kind": "quick", + "prompt": "MPP's core spec is an \"individual\" IETF Internet-Draft. What does \"individual\" tell you?", + "options": ["No working group has taken it on", "The IETF has approved it", "Only one company may use it", "It's already a finished RFC"], + "answer": 0, + "explain": "Anyone can post an Internet-Draft. An individual draft has no formal standing until a working group adopts it, so \"it's at the IETF\" isn't the same as \"the IETF is standardizing it.\"", + "standard": "mpp", + "source": "https://datatracker.ietf.org/doc/draft-httpauth-payment/", + "checked": "2026-10-30" + }, + { + "id": "o-x402-which", + "kind": "which", + "prompt": "A parking meter on every web page: drop in a cent of USDC, the gate opens, no account needed. Which standard?", + "options": ["MPP", "x402", "AP2", "ACP"], + "answer": 1, + "explain": "x402, created by Coinbase and now run by a Linux Foundation project, answers with HTTP 402 and a price, and the client attaches a signed stablecoin payment. MPP is the close cousin that also takes cards.", + "standard": "x402", + "source": "https://github.com/x402-foundation/x402/blob/main/specs/x402-specification-v2.md", + "checked": "2026-10-30" + }, + { + "id": "o-x402-billion-402s", + "kind": "real", + "prompt": "True or false: Cloudflare sees about a billion 402 responses a day, so x402 handles a billion payments a day.", + "options": ["True", "False"], + "answer": 1, + "explain": "A 402 is a request for payment, not a payment, and most never get paid. To size x402, count settled dollars, not 402 responses or transaction counts.", + "standard": "x402", + "source": "https://blog.cloudflare.com/x402/", + "checked": "2026-10-30" + }, + { + "id": "o-x402-facilitator", + "kind": "quick", + "prompt": "In x402, the client attaches a signed USDC payment. What does the \"facilitator\" do?", + "options": ["Issues the stablecoin", "Signs the user in", "Settles it on a blockchain", "Ranks paid APIs"], + "answer": 2, + "explain": "The facilitator checks the payment and settles it on-chain in seconds, so the server doesn't run blockchain plumbing itself. Fees are a fraction of a cent, which card networks can't match.", + "standard": "x402", + "source": "https://github.com/x402-foundation/x402/blob/main/specs/x402-specification-v2.md", + "checked": "2026-10-30" + }, + { + "id": "o-http-402-reserved", + "kind": "quick", + "prompt": "HTTP has had a 402 Payment Required code since the 1990s. What does RFC 9110 say it does?", + "options": ["Redirects to a checkout", "Blocks unpaid crawlers", "It's reserved for future use", "Asks for a card number"], + "answer": 2, + "explain": "RFC 9110 just says 402 is \"reserved for future use,\" and it sat unused for decades. x402 and MPP finally give it a job: answer with a price and how to pay.", + "source": "https://www.rfc-editor.org/rfc/rfc9110.html#section-15.5.3", + "checked": "2026-10-30" + }, + { + "id": "o-robots-txt-location", + "kind": "quick", + "prompt": "Where must a site put its robots.txt file?", + "options": ["/robots.txt", "/.well-known/robots.txt", "/meta/robots.txt", "/api/robots.txt"], + "answer": 0, + "explain": "RFC 9309 says the file must be /robots.txt at the top level. It's older than the /.well-known/ folder (RFC 8615) that newer files like ard.json and poppy.json use.", + "source": "https://www.rfc-editor.org/rfc/rfc9309.html", + "checked": "2026-10-30" + }, + { + "id": "o-robots-txt-request", + "kind": "real", + "prompt": "True or false: robots.txt rules are a polite request, not a lock.", + "options": ["True", "False"], + "answer": 0, + "explain": "RFC 9309 says the rules \"are not a form of access authorization.\" Good crawlers follow them; a bot that ignores them isn't stopped, which is why sites want signed bot identity like Web Bot Auth.", + "source": "https://www.rfc-editor.org/rfc/rfc9309.html", + "checked": "2026-10-30" + }, + { + "id": "o-oauth-device-code", + "kind": "quick", + "prompt": "Your TV says \"Go to example.com/device and enter WDJB-MJHT.\" Which OAuth flow is that?", + "options": ["Client credentials grant", "Device authorization grant", "Implicit grant", "Refresh token grant"], + "answer": 1, + "explain": "The device authorization grant (RFC 8628) lets a device with no good keyboard get your approval on another screen. Poppy uses the same pattern when you approve an agent on another device with a code.", + "source": "https://www.rfc-editor.org/rfc/rfc8628.html", + "checked": "2026-10-30" + }, + { + "id": "o-oauth-pkce", + "kind": "quick", + "prompt": "PKCE, pronounced \"pixy,\" is added to OAuth's sign-in code flow. What problem does it solve?", + "options": ["Users forgetting passwords", "Tokens that never expire", "Bots solving CAPTCHAs", "Someone stealing the sign-in code"], + "answer": 3, + "explain": "The app makes a one-time secret, sends a hashed version at the start, and reveals the secret when trading the code for a token. A thief who grabs the code can't use it without that secret.", + "source": "https://www.rfc-editor.org/rfc/rfc7636.html", + "checked": "2026-10-30" + }, + { + "id": "o-mcp-who", + "kind": "quick", + "prompt": "Who introduced the Model Context Protocol (MCP) in November 2024?", + "options": ["Anthropic", "OpenAI", "Google", "Microsoft"], + "answer": 0, + "explain": "Anthropic open-sourced MCP on November 25, 2024 as one standard way to plug AI apps into tools and data. Lots here builds on it: every NLWeb site is an MCP server, and WebMCP borrows its idea of tools.", + "source": "https://www.anthropic.com/news/model-context-protocol", + "checked": "2026-10-30" + }, + { + "id": "o-mcp-json-rpc", + "kind": "quick", + "prompt": "Under the hood, what message format do MCP clients and servers speak?", + "options": ["GraphQL", "SOAP", "JSON-RPC 2.0", "gRPC"], + "answer": 2, + "explain": "MCP messages are JSON-RPC 2.0: small JSON objects with a method, params and an id. That keeps them simple to carry over a local pipe or over HTTP.", + "source": "https://modelcontextprotocol.io/specification/2025-06-18", + "checked": "2026-10-30" + }, + { + "id": "o-mcp-fake-primitive", + "kind": "fake", + "prompt": "Spot the fake: MCP servers offer three core building blocks. Which isn't one?", + "options": ["Tools", "Actions", "Resources", "Prompts"], + "answer": 1, + "explain": "MCP servers expose tools (functions the model can call), resources (data it can read) and prompts (reusable templates). \"Actions\" sounds right but isn't an MCP building block.", + "source": "https://modelcontextprotocol.io/specification/2025-06-18/server", + "checked": "2026-10-30" + }, + { + "id": "o-a2a-agent-card-path", + "kind": "quick", + "prompt": "Google's A2A protocol lets agents talk to agents. Where does an agent publish its Agent Card?", + "options": ["/.well-known/ai-plugin.json", "/agent/card.xml", "/.well-known/agents.txt", "/.well-known/agent-card.json"], + "answer": 3, + "explain": "An A2A Agent Card is a JSON file at /.well-known/agent-card.json saying what the agent can do and how to reach it. Google launched A2A in April 2025 and later gave it to the Linux Foundation.", + "source": "https://a2a-protocol.org/latest/specification/", + "checked": "2026-10-30" + }, + { + "id": "o-mcp-vs-a2a", + "kind": "quick", + "prompt": "MCP and A2A get mixed up a lot. What's the usual split?", + "options": ["MCP links tools, A2A links agents", "MCP links agents, A2A links tools", "MCP handles payments, A2A logins", "MCP runs in browsers, A2A servers"], + "answer": 0, + "explain": "MCP connects an AI app to tools and data; A2A lets independent agents find each other and hand off tasks. Google pitched A2A as a complement to MCP, not a replacement.", + "source": "https://developers.googleblog.com/en/a2a-a-new-era-of-agent-interoperability/", + "checked": "2026-10-30" + }, + { + "id": "o-llms-txt-format", + "kind": "quick", + "prompt": "Jeremy Howard proposed /llms.txt in September 2024. What's inside it?", + "options": ["Crawl rules in robots syntax", "A Markdown guide to the site", "A JSON list of API keys", "Signed bot public keys"], + "answer": 1, + "explain": "llms.txt is a Markdown file with a short summary and links to the pages worth reading, so a model finds the good stuff fast. It guides models; it doesn't set rules the way robots.txt does.", + "source": "https://llmstxt.org/", + "checked": "2026-10-30" + }, + { + "id": "o-user-agent-fakeable", + "kind": "quick", + "prompt": "A request says \"User-Agent: ChatGPT-User\". Why can't a site trust that alone?", + "options": ["It expires after an hour", "Browsers strip it out", "Anyone can type any value", "Only Google can read it"], + "answer": 2, + "explain": "A user-agent string is a self-written label, so a scraper can copy it word for word. IP checks break easily too, which is why Web Bot Auth adds cryptographic signatures.", + "standard": "web-bot-auth", + "source": "https://ax.guide/standards/web-bot-auth", + "checked": "2026-10-30" + }, + { + "id": "o-jwks-contents", + "kind": "quick", + "prompt": "Sign-in servers publish a JWKS so others can check their signatures. What's in a JWKS?", + "options": ["A set of public keys", "A list of allowed bots", "Encrypted user passwords", "Signed access tokens"], + "answer": 0, + "explain": "A JWK Set (RFC 7517) is a JSON object with a \"keys\" array of public keys. Sharing public keys is safe; the private keys stay with the owner and do the signing.", + "source": "https://www.rfc-editor.org/rfc/rfc7517.html", + "checked": "2026-10-30" + }, + { + "id": "o-usdc-issuer", + "kind": "quick", + "prompt": "x402 payments usually move USDC, a dollar stablecoin. Which company issues USDC?", + "options": ["Coinbase", "Tether", "Stripe", "Circle"], + "answer": 3, + "explain": "Circle issues USDC, a token designed to stay worth one US dollar. Coinbase, which created x402, is a major USDC partner, hence the common mix-up.", + "source": "https://www.circle.com/usdc", + "checked": "2026-10-30" + }, + { + "id": "o-rfc9421-fake-header", + "kind": "fake", + "prompt": "Spot the fake: which header is NOT defined by RFC 9421, HTTP Message Signatures?", + "options": ["Signature", "Signature-Input", "Accept-Signature", "Signature-Owner"], + "answer": 3, + "explain": "RFC 9421 defines Signature-Input (what was signed and how), Signature (the signature itself) and Accept-Signature (a server asking for one). Web Bot Auth builds on these.", + "source": "https://www.rfc-editor.org/rfc/rfc9421.html", + "checked": "2026-10-30" + }, + { + "id": "o-cors-fake-header", + "kind": "fake", + "prompt": "Spot the fake CORS header: which one doesn't exist?", + "options": ["Access-Control-Allow-Agents", "Access-Control-Allow-Origin", "Access-Control-Allow-Methods", "Access-Control-Max-Age"], + "answer": 0, + "explain": "CORS headers tell browsers which other sites' scripts may read a response. There's no agent switch: CORS is about browser origins, not bots, so bot identity needs something like Web Bot Auth.", + "source": "https://fetch.spec.whatwg.org/#http-cors-protocol", + "checked": "2026-10-30" + }, + { + "id": "o-oauth-fake-well-known", + "kind": "fake", + "prompt": "Spot the fake: three of these OAuth discovery files are registered under /.well-known/. Which is invented?", + "options": ["openid-configuration", "oauth-agent-login", "oauth-authorization-server", "oauth-protected-resource"], + "answer": 1, + "explain": "openid-configuration and oauth-authorization-server describe a sign-in server; oauth-protected-resource (RFC 9728) tells a client which server guards an API. auth.md and MCP lean on these same files.", + "source": "https://www.iana.org/assignments/well-known-uris/well-known-uris.xhtml", + "checked": "2026-10-30" + }, + { + "id": "o-http-fake-status", + "kind": "fake", + "prompt": "Spot the fake HTTP status code: which one isn't in the official registry?", + "options": ["402 Payment Required", "451 Unavailable For Legal Reasons", "432 Bot Not Verified", "425 Too Early"], + "answer": 2, + "explain": "402, 425 (RFC 8470) and 451 (RFC 7725, a nod to Fahrenheit 451) are all registered. There's no status code for unverified bots; that job falls to signatures, not a new number.", + "source": "https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml", + "checked": "2026-10-30" + } +] diff --git a/content/site.json b/content/site.json index 045ff06..bb9da5a 100644 --- a/content/site.json +++ b/content/site.json @@ -27,6 +27,10 @@ "label": "Which one?", "href": "/which" }, + { + "label": "Quiz", + "href": "/play" + }, { "label": "Contribute", "href": "/contribute" diff --git a/lib/og-quiz.tsx b/lib/og-quiz.tsx new file mode 100644 index 0000000..0dacd89 --- /dev/null +++ b/lib/og-quiz.tsx @@ -0,0 +1,48 @@ +import { BRAND } from "./brand"; +import { OgBody, ogImage } from "./og"; + +/** A quiz result's share image: the score, the grid, and a dare. */ +export function quizShareImage({ + kicker, + score, + correct, + total, + results, +}: { + kicker: string; + score: number; + correct: number; + total: number; + results: boolean[]; +}) { + const square = total > 12 ? 44 : 60; + return ogImage( + +
{kicker}
+
+ {score.toLocaleString("en-US")} + PTS +
+
+ {correct} of {total} right on the agentic web quiz +
+
+ {results.map((right, i) => ( +
+ ))} +
+
+ Can you beat it? + ax.guide/play +
+ , + ); +} diff --git a/lib/play-route.ts b/lib/play-route.ts new file mode 100644 index 0000000..60326f6 --- /dev/null +++ b/lib/play-route.ts @@ -0,0 +1,24 @@ +import "server-only"; +import { NextResponse } from "next/server"; +import { PlayError } from "./quiz-expert"; +import { currentUserId } from "./supabase-server"; + +/** No quiz response is ever cached: each one belongs to one player at one moment. */ +const NO_STORE = { "Cache-Control": "private, no-store" }; + +export function json(body: unknown, status = 200): NextResponse { + return NextResponse.json(body, { status, headers: NO_STORE }); +} + +/** Run a quiz handler for the signed-in player, turning PlayErrors into JSON errors. */ +export async function withPlayer(handler: (userId: string) => Promise): Promise { + try { + const userId = await currentUserId(); + if (!userId) return json({ error: "Sign in to play an expert run." }, 401); + return json(await handler(userId)); + } catch (error) { + if (error instanceof PlayError) return json({ error: error.message }, error.status); + console.error(error); + return json({ error: "Something went wrong. Try again." }, 500); + } +} diff --git a/lib/quiz-expert.ts b/lib/quiz-expert.ts new file mode 100644 index 0000000..b8c6b2b --- /dev/null +++ b/lib/quiz-expert.ts @@ -0,0 +1,273 @@ +import "server-only"; +import { z } from "zod"; +import { + CARD_SECONDS, + type CardResult, + cardPoints, + type DealtCard, + dealCard, + LATENCY_GRACE_MS, + learnMore, + optionOrder, + pickCards, + RUN_CAPS, + RUN_LENGTH, +} from "./quiz"; +import { type QuizQuestion, quizQuestion } from "./quiz-schema"; +import { getStandard } from "./standards"; +import { adminClient } from "./supabase-server"; + +/* + * Expert runs, dealt and scored on the server so the leaderboard can trust + * them. A run moves one way: deal a card (its clock starts), answer it (the + * clock stops, the answer is revealed), deal the next. Every update is + * guarded by the run's position and phase, so a card can't be answered twice + * or have its clock restarted. + */ + +/** Runs a player may start in a day: plenty to improve, too few to farm the deck in an afternoon. */ +const RUNS_PER_DAY = 20; + +const runCards = z.array(z.strictObject({ id: z.string(), order: z.array(z.int()) })); + +interface RunRow { + id: string; + user_id: string; + cards: unknown; + position: number; + served_at: string | null; + score: number; + correct: number; + streak: number; + results: string; + finished_at: string | null; +} + +export class PlayError extends Error { + constructor( + readonly status: 400 | 401 | 404 | 409 | 429 | 503, + message: string, + ) { + super(message); + } +} + +function db() { + const client = adminClient(); + if (!client) throw new PlayError(503, "Expert runs are not switched on here yet."); + return client; +} + +/** Active expert cards, checked against the schema; a broken row is skipped, not dealt. */ +async function loadQuestions(ids?: string[]): Promise> { + let query = db().from("quiz_questions").select("id, data").eq("active", true); + if (ids) query = query.in("id", ids); + const { data, error } = await query; + if (error) throw new PlayError(503, "The expert deck could not be read."); + const out = new Map(); + for (const row of data ?? []) { + const parsed = quizQuestion.safeParse(row.data); + if (parsed.success && parsed.data.id === row.id) out.set(row.id, parsed.data); + else console.error(`quiz_questions row "${row.id}" is not a valid card; skipped`); + } + return out; +} + +export interface RunState { + runId: string; + total: number; + position: number; + card: DealtCard; +} + +export interface RunSummary { + runId: string; + score: number; + correct: number; + total: number; + results: boolean[]; + rank: number; + players: number; +} + +export async function startRun(userId: string): Promise { + const since = new Date(Date.now() - 86_400_000).toISOString(); + const { count } = await db() + .from("quiz_runs") + .select("id", { count: "exact", head: true }) + .eq("user_id", userId) + .gte("started_at", since); + if ((count ?? 0) >= RUNS_PER_DAY) throw new PlayError(429, `That's ${RUNS_PER_DAY} expert runs today. Come back tomorrow.`); + + const deck = [...(await loadQuestions()).values()]; + if (deck.length < RUN_LENGTH.expert) throw new PlayError(503, "The expert deck isn't loaded yet."); + const picked = pickCards(deck, RUN_LENGTH.expert, RUN_CAPS.expert); + const cards = picked.map((q) => ({ id: q.id, order: optionOrder(q) })); + + const { data, error } = await db() + .from("quiz_runs") + .insert({ user_id: userId, cards, served_at: new Date().toISOString() }) + .select("id") + .single(); + if (error || !data) throw new PlayError(503, "The run could not be started."); + const first = picked[0] as QuizQuestion; + return { runId: data.id, total: cards.length, position: 0, card: dealCard(first, cards[0]?.order ?? []) }; +} + +async function loadRun(userId: string, runId: string): Promise }> { + if (!z.uuid().safeParse(runId).success) throw new PlayError(404, "No such run."); + const { data } = await db().from("quiz_runs").select("*").eq("id", runId).eq("user_id", userId).maybeSingle(); + if (!data) throw new PlayError(404, "No such run."); + return { ...data, cardList: runCards.parse(data.cards) }; +} + +/** Deal the card at `position`, after the one before it was answered. */ +export async function nextCard(userId: string, runId: string, position: number): Promise { + const run = await loadRun(userId, runId); + const slot = run.cardList[position]; + if (run.finished_at || run.served_at || run.position !== position || !slot) { + throw new PlayError(409, "That card isn't next."); + } + const question = (await loadQuestions([slot.id])).get(slot.id); + if (!question) throw new PlayError(503, "A card of this run is no longer in the deck."); + const { data } = await db() + .from("quiz_runs") + .update({ served_at: new Date().toISOString() }) + .eq("id", runId) + .eq("position", position) + .is("served_at", null) + .select("id"); + if (!data?.length) throw new PlayError(409, "That card isn't next."); + return { runId, total: run.cardList.length, position, card: dealCard(question, slot.order) }; +} + +/** + * Answer the open card. `choice` is the option's index as shown, or null when + * the clock ran out. The server's own clock decides whether it was in time. + */ +export async function answerCard( + userId: string, + runId: string, + position: number, + choice: number | null, +): Promise<{ result: CardResult; summary?: RunSummary }> { + const run = await loadRun(userId, runId); + const slot = run.cardList[position]; + if (run.finished_at || !run.served_at || run.position !== position || !slot) { + throw new PlayError(409, "That card isn't open."); + } + const question = (await loadQuestions([slot.id])).get(slot.id); + if (!question) throw new PlayError(503, "A card of this run is no longer in the deck."); + + const ms = Date.now() - Date.parse(run.served_at); + const inTime = ms <= CARD_SECONDS * 1000 + LATENCY_GRACE_MS; + const shownAnswer = slot.order.indexOf(question.answer); + const correct = inTime && choice === shownAnswer; + const streak = correct ? run.streak + 1 : 0; + const points = cardPoints(correct, ms, streak); + const done = position + 1 === run.cardList.length; + const now = new Date().toISOString(); + + const update = { + position: position + 1, + served_at: null, + score: run.score + points, + correct: run.correct + (correct ? 1 : 0), + streak, + results: run.results + (correct ? "1" : "0"), + finished_at: done ? now : null, + }; + const { data } = await db() + .from("quiz_runs") + .update(update) + .eq("id", runId) + .eq("position", position) + .not("served_at", "is", null) + .select("id"); + if (!data?.length) throw new PlayError(409, "That card was already answered."); + + const result: CardResult = { + correct, + answer: shownAnswer, + explain: question.explain, + learnMore: learnMore(question, question.standard ? getStandard(question.standard)?.name : undefined), + points, + score: update.score, + streak, + }; + if (!done) return { result }; + const { rank, players } = await rankOf(update.score); + return { + result, + summary: { + runId, + score: update.score, + correct: update.correct, + total: run.cardList.length, + results: [...update.results].map((c) => c === "1"), + rank, + players, + }, + }; +} + +/** Where a score places among every player's best. */ +async function rankOf(score: number): Promise<{ rank: number; players: number }> { + const { data } = await db().rpc("quiz_rank", { for_score: score }).maybeSingle<{ rank: number; players: number }>(); + return { rank: Number(data?.rank ?? 1), players: Number(data?.players ?? 1) }; +} + +export interface LeaderboardRow { + rank: number; + name: string; + score: number; + correct: number; + total: number; + runId: string; +} + +/** Each player's best expert run, all time or in the last seven days. */ +export async function leaderboard(period: "all" | "week", limit = 50): Promise { + const client = adminClient(); + if (!client) return undefined; + const since = period === "week" ? new Date(Date.now() - 7 * 86_400_000).toISOString() : "-infinity"; + const { data, error } = await client.rpc("quiz_leaderboard", { since, max_rows: limit }); + if (error) return undefined; + return (data as { rank: number; name: string; score: number; correct: number; total: number; run_id: string }[]).map((r) => ({ + rank: Number(r.rank), + name: r.name, + score: r.score, + correct: r.correct, + total: r.total, + runId: r.run_id, + })); +} + +/** A finished run, for its share page. Public: name, score and the grid only. */ +export async function finishedRun(runId: string): Promise< + { name: string; score: number; correct: number; total: number; results: boolean[]; finishedAt: string } | undefined +> { + const client = adminClient(); + if (!client || !z.uuid().safeParse(runId).success) return undefined; + const { data } = await client + .from("quiz_runs") + .select("score, correct, results, finished_at, user_id") + .eq("id", runId) + .not("finished_at", "is", null) + .maybeSingle<{ score: number; correct: number; results: string; finished_at: string; user_id: string }>(); + if (!data) return undefined; + const { data: profile } = await client.from("profiles").select("name").eq("id", data.user_id).maybeSingle<{ name: string }>(); + return { + name: profile?.name ?? "A player", + score: data.score, + correct: data.correct, + total: data.results.length, + results: [...data.results].map((c) => c === "1"), + finishedAt: data.finished_at, + }; +} + +export async function playerName(userId: string): Promise { + const { data } = await db().from("profiles").select("name").eq("id", userId).maybeSingle<{ name: string }>(); + return data?.name; +} diff --git a/lib/quiz-open.ts b/lib/quiz-open.ts new file mode 100644 index 0000000..bc91a26 --- /dev/null +++ b/lib/quiz-open.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; +import openJson from "@/content/quiz/open.json"; +import { statusLabel } from "./content"; +import { type QuizQuestion, quizDeck } from "./quiz-schema"; +import { STATUS_IDS } from "./schema"; +import { getStandards, type Standard } from "./standards"; + +/** The status options of a "Real or hype?" card, in map legend order. */ +const STATUS_OPTIONS = STATUS_IDS.map((status) => statusLabel(status)); + +/** Markdown links and code to plain text, for a card's explanation. */ +function plain(markdown: string): string { + return markdown.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/`([^`]+)`/g, "$1"); +} + +/** The reality check's opening, which states the status and why: up to two sentences that fit a card. */ +function realityOpening(markdown: string): string { + const sentences = plain(markdown).match(/[^.!?]+[.!?]+(?=\s|$)/g) ?? [markdown]; + let out = sentences[0]?.trim() ?? ""; + const second = sentences[1]?.trim(); + if (second && out.length + second.length + 1 <= 320) out += ` ${second}`; + return out.slice(0, 320); +} + +/** + * One "Real or hype?" card per standard, written from its page: the answer is + * its status today, so the card changes the day the page does and never goes + * stale on its own. + */ +export function statusCard(s: Standard): QuizQuestion { + return { + id: `o-status-${s.id}`, + kind: "real", + prompt: `${s.full_name ? `${s.name} (${s.full_name})` : s.name}: ${s.phrase.charAt(0).toLowerCase()}${s.phrase.slice(1)}. How real is it today?`, + options: STATUS_OPTIONS, + answer: STATUS_IDS.indexOf(s.status), + explain: realityOpening(s.reality_check), + standard: s.id, + source: `https://ax.guide/standards/${s.id}`, + checked: s.last_verified, + }; +} + +/** The open deck: content/quiz/open.json plus a status card per standard. */ +export function getOpenDeck(): QuizQuestion[] { + return [...openCards(), ...getStandards().map(statusCard)]; +} + +let cache: QuizQuestion[] | undefined; + +/** content/quiz/open.json, checked. */ +export function openCards(): QuizQuestion[] { + if (cache) return cache; + const result = quizDeck.safeParse(openJson); + if (!result.success) throw new Error(`content/quiz/open.json is not valid:\n${z.prettifyError(result.error)}`); + cache = result.data; + return cache; +} diff --git a/lib/quiz-schema.ts b/lib/quiz-schema.ts new file mode 100644 index 0000000..154765b --- /dev/null +++ b/lib/quiz-schema.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; +import { STANDARD_ID } from "./schema"; + +/** + * The kinds of card, each with its own label on the card: + * quick a plain question + * which "Which standard am I?": a description, a file or a header; name the standard + * fake "Spot the fake": one option is made up; `answer` points at it + * real "Real or hype?": how real something is today + */ +export const QUIZ_KINDS = ["quick", "which", "fake", "real"] as const; +export type QuizKind = (typeof QUIZ_KINDS)[number]; + +/** A question id: lowercase words and hyphens, unique across both decks. */ +export const QUESTION_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +const text = z.string().trim().min(1); + +/** + * One card. Options are shuffled when the card is dealt, so no option may + * depend on its position ("both of the above"). + */ +export const quizQuestion = z + .strictObject({ + id: z.string().regex(QUESTION_ID, "lowercase words joined by hyphens"), + kind: z.enum(QUIZ_KINDS), + /** The question, short enough to read on a phone in a few seconds. */ + prompt: text.max(220), + /** Two to four answers, roughly the same length so the longest isn't a tell. */ + options: z.array(text.max(90)).min(2).max(4), + /** Index into `options` of the right answer (for `fake`, of the made-up one). */ + answer: z.int().min(0), + /** Shown after answering: why the answer is right, in one or two sentences. */ + explain: text.max(320), + /** The ax.guide standard the card is about, linked as "Read more". */ + standard: z.string().regex(STANDARD_ID).optional(), + /** The primary source for the fact the card tests. */ + source: z.url({ protocol: /^https$/ }), + /** The day someone last checked the card against its source (YYYY-MM-DD). */ + checked: z.iso.date(), + }) + .refine((q) => q.answer < q.options.length, { message: "answer must point at one of the options", path: ["answer"] }) + .refine((q) => new Set(q.options.map((o) => o.toLowerCase())).size === q.options.length, { + message: "options must differ", + path: ["options"], + }); + +export type QuizQuestion = z.infer; + +export const quizDeck = z.array(quizQuestion).min(1); diff --git a/lib/quiz.ts b/lib/quiz.ts new file mode 100644 index 0000000..c0c8402 --- /dev/null +++ b/lib/quiz.ts @@ -0,0 +1,186 @@ +import type { QuizKind, QuizQuestion } from "./quiz-schema"; + +/* + * The game's rules, shared by the browser (open runs) and the server + * (expert runs), so both score a card the same way. + */ + +/** Cards in a run of each deck. */ +export const RUN_LENGTH = { open: 12, expert: 18 } as const; +export type Deck = keyof typeof RUN_LENGTH; + +/** Seconds a card stays open. Running out counts as a wrong answer. */ +export const CARD_SECONDS = 20; +/** Answers inside this many seconds earn the whole speed bonus. */ +const FULL_BONUS_SECONDS = 3; +/** Extra time the server allows for the network before it calls an answer late. */ +export const LATENCY_GRACE_MS = 2_000; + +export const POINTS = { correct: 100, speed: 50, streakStep: 10, streakMax: 50 } as const; + +/** + * Points for one card. A right answer earns 100, up to 50 more for speed + * (all of it inside 3 seconds, none at 20), and 10 more for each right answer + * before it in an unbroken streak, up to 50. A wrong answer earns nothing. + */ +export function cardPoints(correct: boolean, ms: number, streak: number): number { + if (!correct) return 0; + const seconds = Math.max(0, ms) / 1000; + const speed = Math.min(1, Math.max(0, (CARD_SECONDS - seconds) / (CARD_SECONDS - FULL_BONUS_SECONDS))); + const streakBonus = Math.min(POINTS.streakMax, POINTS.streakStep * Math.max(0, streak - 1)); + return POINTS.correct + Math.round(POINTS.speed * speed) + streakBonus; +} + +/** The most a run can score, for the share code's sanity check. */ +export function maxScore(cards: number): number { + let total = 0; + for (let i = 1; i <= cards; i++) total += cardPoints(true, 0, i); + return total; +} + +/** What a player sees of a card: no answer, options in dealt order. */ +export interface DealtCard { + id: string; + kind: QuizKind; + prompt: string; + options: string[]; +} + +/** What a player learns after answering. */ +export interface CardResult { + correct: boolean; + /** The right option, in dealt order. */ + answer: number; + explain: string; + /** "Read more": the standard's page, or the card's source. */ + learnMore: { href: string; label: string }; + points: number; + score: number; + streak: number; +} + +export const KIND_LABELS: Record = { + quick: "Quick check", + which: "Which standard am I?", + fake: "Spot the fake", + real: "Real or hype?", +}; + +/** A random integer in [0, n), from the platform's secure source on both sides. */ +function randomBelow(n: number): number { + const buf = new Uint32Array(1); + crypto.getRandomValues(buf); + return (buf[0] ?? 0) % n; +} + +/** Fisher-Yates, on a copy. */ +export function shuffle(list: readonly T[]): T[] { + const out = [...list]; + for (let i = out.length - 1; i > 0; i--) { + const j = randomBelow(i + 1); + [out[i], out[j]] = [out[j] as T, out[i] as T]; + } + return out; +} + +/** + * Pick a run's cards at random, with at most `caps[group]` from a group (by + * default a card's kind), so a run never turns into twelve of one kind. When + * the caps leave too few cards, the rest are filled from what was skipped. + */ +export function pickCards( + deck: readonly T[], + count: number, + caps: Record = {}, + groupOf: (card: T) => string = (card) => card.kind, +): T[] { + const picked: T[] = []; + const skipped: T[] = []; + const perGroup = new Map(); + for (const card of shuffle(deck)) { + if (picked.length === count) break; + const group = groupOf(card); + const used = perGroup.get(group) ?? 0; + if (used >= (caps[group] ?? Infinity)) { + skipped.push(card); + continue; + } + perGroup.set(group, used + 1); + picked.push(card); + } + return [...picked, ...skipped].slice(0, count); +} + +/** Caps per run, by group: a status card is its own group in the open deck. */ +export const RUN_CAPS = { + open: { status: 2, real: 2, which: 4, fake: 3 }, + expert: { real: 3, which: 5, fake: 4 }, +} as const; + +/** The group of an open card for RUN_CAPS: status cards are capped on their own. */ +export function openGroup(card: Pick): string { + return card.id.startsWith("o-status-") ? "status" : card.kind; +} + +/** + * The order to show a card's options in: order[shownIndex] = index in the + * question. Shuffled, except where the written order is the natural one: a + * scale ("Real or hype?") or True and False. + */ +export function optionOrder(question: Pick): number[] { + const indexes = question.options.map((_, i) => i); + const trueFalse = question.options.length === 2 && question.options.every((o) => /^(true|false)$/i.test(o)); + return question.kind === "real" || trueFalse ? indexes : shuffle(indexes); +} + +export function dealCard(question: QuizQuestion, order: number[]): DealtCard { + return { + id: question.id, + kind: question.kind, + prompt: question.prompt, + options: order.map((i) => question.options[i] ?? ""), + }; +} + +export function learnMore(question: Pick, standardName?: string): CardResult["learnMore"] { + return question.standard + ? { href: `/standards/${question.standard}`, label: `Read about ${standardName ?? question.standard} on ax.guide` } + : { href: question.source, label: `Read the source: ${new URL(question.source).hostname.replace(/^www\./, "")}` }; +} + +/** + * The share code of an open run, "1840-110111011101": the score, then a 1 or + * 0 per card. It is not proof of anything (open runs are played in the + * browser); expert runs are shared by their run id instead. + */ +export function encodeShare(score: number, results: boolean[]): string { + return `${score}-${results.map((r) => (r ? "1" : "0")).join("")}`; +} + +export function decodeShare(code: string): { score: number; results: boolean[] } | undefined { + const match = /^(\d{1,5})-([01]{1,24})$/.exec(code); + if (!match) return undefined; + const score = Number(match[1]); + const results = [...(match[2] ?? "")].map((c) => c === "1"); + return score <= maxScore(results.length) ? { score, results } : undefined; +} + +/** The emoji row of a share: orange for right, black for wrong. */ +export function shareGrid(results: boolean[]): string { + return results.map((r) => (r ? "🟧" : "⬛")).join(""); +} + +/** A verdict line for a share of right answers, so a score reads like a result. */ +export function verdict(correct: number, total: number): string { + const share = total ? correct / total : 0; + if (share === 1) return "Flawless. You could maintain this guide."; + if (share >= 0.83) return "Agent-ready. You know the map."; + if (share >= 0.6) return "Solid. A couple of standards to brush up on."; + if (share >= 0.4) return "Getting there. The map has a few surprises left."; + return "Plenty to learn. Good news: that's what the guide is for."; +} + +/** A same-site path to return to after signing in; anything else goes to /play. */ +export function safeNext(value: string | null): string { + return value && /^\/(?!\/)[\w\-./?=&%]*$/.test(value) ? value : "/play"; +} diff --git a/lib/supabase-env.ts b/lib/supabase-env.ts new file mode 100644 index 0000000..77eda32 --- /dev/null +++ b/lib/supabase-env.ts @@ -0,0 +1,10 @@ +/** + * Supabase's public settings. Next.js inlines NEXT_PUBLIC_ variables into the + * browser bundle at build time. Without them the site still builds and the + * open game still plays; sign-in, expert runs and the leaderboard stay off. + */ +export function supabasePublicEnv(): { url: string; anonKey: string } | undefined { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + return url && anonKey ? { url, anonKey } : undefined; +} diff --git a/lib/supabase-server.ts b/lib/supabase-server.ts new file mode 100644 index 0000000..41140ba --- /dev/null +++ b/lib/supabase-server.ts @@ -0,0 +1,69 @@ +import "server-only"; +import { createServerClient } from "@supabase/ssr"; +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { cookies } from "next/headers"; +import { supabasePublicEnv } from "./supabase-env"; + +/** True when sign-in, expert runs and the leaderboard can work. */ +export function playBackendEnabled(): boolean { + return supabasePublicEnv() !== undefined && Boolean(process.env.SUPABASE_SERVICE_ROLE_KEY); +} + +/** + * The visitor's session, from their cookies. In a route handler it also + * writes refreshed tokens back; in a server component, where cookies are + * read-only, the browser client refreshes them instead. + */ +async function sessionClient(): Promise { + const env = supabasePublicEnv(); + if (!env) return undefined; + const store = await cookies(); + return createServerClient(env.url, env.anonKey, { + cookies: { + getAll: () => store.getAll(), + setAll: (list) => { + try { + for (const { name, value, options } of list) store.set(name, value, options); + } catch { + // Server components can't set cookies; route handlers can. + } + }, + }, + }); +} + +/** The signed-in user's id, checked with Supabase Auth, or undefined. */ +export async function currentUserId(): Promise { + const client = await sessionClient(); + if (!client) return undefined; + const { data } = await client.auth.getUser(); + return data.user?.id; +} + +/** Trade the code from a sign-in link or OAuth redirect for a session cookie. */ +export async function exchangeAuthCode(code: string): Promise { + const client = await sessionClient(); + if (!client) return false; + const { error } = await client.auth.exchangeCodeForSession(code); + return !error; +} + +export async function signOut(): Promise { + const client = await sessionClient(); + await client?.auth.signOut(); +} + +let admin: SupabaseClient | undefined; + +/** + * The server's own client, with the service role key. Every quiz table has + * row level security and no policies, so only this client can read them. + * Never send its key or its results unfiltered to a browser. + */ +export function adminClient(): SupabaseClient | undefined { + const env = supabasePublicEnv(); + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!env || !key) return undefined; + admin ??= createClient(env.url, key, { auth: { persistSession: false, autoRefreshToken: false } }); + return admin; +} diff --git a/next.config.ts b/next.config.ts index a2056ac..bdcfb6e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -11,6 +11,12 @@ const nextConfig: NextConfig = { poweredByHeader: false, // Do not let `next dev` write AGENTS.md and CLAUDE.md into the repo. agentRules: false, + // The quiz's server routes read standard names and the share image font + // from disk at request time, so ship those files with them. + outputFileTracingIncludes: { + "/api/play/**": ["./content/standards/**"], + "/play/**": ["./content/standards/**", "./assets/fonts/**"], + }, async headers() { return [{ source: "/:path*", headers: SECURITY_HEADERS }]; }, diff --git a/package-lock.json b/package-lock.json index a9c3b21..615ed45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@supabase/ssr": "0.12.7", + "@supabase/supabase-js": "2.118.0", "marked": "18.1.0", "next": "16.3.8", "react": "19.3.0", @@ -1721,6 +1723,110 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.118.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.118.0.tgz", + "integrity": "sha512-xWfPXyhS2KDSfAneMtI6p/tBnbdiz/5vaRKXEqUfzfC9WCniKr2Ds5o5G7RIGig0bom4z6wUY9zaV07WDVTfnQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.118.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.118.0.tgz", + "integrity": "sha512-4ofletPa/noKAU6KHeqsd3FztGzRPUEJRey1MKY0o+PIHaVePqRPShIXtl37gcVyIWaFM+zNg6r9zh0uUSFOSA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.118.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.118.0.tgz", + "integrity": "sha512-JXy+37KPApHsyotaQACovv/Jy9E22rfiWLRuT+2UjoEAcAIelDFIrsF/FY47Dy19mO7lI0Tq+zDCX8oJ2Ozfiw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.118.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.118.0.tgz", + "integrity": "sha512-pzCIEWjqD5/xqEZQerpt+q9rsgYkcrkgCJgf0W2ULqC4fYUeB/p9CFa3t9vn+IVh6dMN9r4ZCE262lZvUC+Bfw==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/ssr": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.12.7.tgz", + "integrity": "sha512-wiBtEie1KkRJi9RrZWY3R2imRhX1JY7qMyUCH2z9AUk15gQebNEplM+urbCKamdxaTJLXUU6LlpkJsaxhojCEg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.114.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.118.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.118.0.tgz", + "integrity": "sha512-5MNkKt2Moy7Jud/sPX7/28BwdDxZMCVHGJT00b8op3KRa9LKSjOAYU5IcfGWIstaasAqY8GkbA6oI3IRDgB1YQ==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.118.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.118.0.tgz", + "integrity": "sha512-FxI8UXMVC2UG7IkeiI8fuQZqq2oI3JeSuN8MPIC2RLVaoEAxKS0nqE6kii9Ewpq8f9gNPM6git1Y8f0vN/QLFw==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.118.0", + "@supabase/functions-js": "2.118.0", + "@supabase/postgrest-js": "2.118.0", + "@supabase/realtime-js": "2.118.0", + "@supabase/storage-js": "2.118.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, "node_modules/@swc/helpers": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -2950,6 +3056,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4345,6 +4464,15 @@ "hermes-estree": "0.25.1" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/package.json b/package.json index ff82130..15a655d 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,12 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "readme": "UPDATE_README=1 vitest run tests/readme.test.ts", - "pins": "UPDATE_PINS=1 vitest run tests/spec-pins.test.ts" + "pins": "UPDATE_PINS=1 vitest run tests/spec-pins.test.ts", + "quiz:sql": "UPDATE_QUIZ_SQL=1 vitest run tests/quiz.test.ts" }, "dependencies": { + "@supabase/ssr": "0.12.7", + "@supabase/supabase-js": "2.118.0", "marked": "18.1.0", "next": "16.3.8", "react": "19.3.0", diff --git a/supabase/migrations/20261030000000_quiz.sql b/supabase/migrations/20261030000000_quiz.sql new file mode 100644 index 0000000..d8a638b --- /dev/null +++ b/supabase/migrations/20261030000000_quiz.sql @@ -0,0 +1,130 @@ +-- The ax.guide quiz (/play): signed-in players, the expert deck, runs and the +-- leaderboard. Open runs never reach the database; they are played in the +-- browser from content/quiz/open.json. +-- +-- Every table has row level security on and no policies: only the site's +-- server, with the service role key, reads or writes them. The browser only +-- ever talks to Supabase Auth. + +-- A player's public name. Filled when they first sign in: a GitHub handle, +-- or a first name and an initial ("Ada L."), or "player-1a2b3c". Never an +-- email address. +create table public.profiles ( + id uuid primary key references auth.users (id) on delete cascade, + name text not null check (char_length(name) between 1 and 40), + created_at timestamptz not null default now() +); +alter table public.profiles enable row level security; + +create or replace function public.quiz_handle_new_user() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +declare + meta jsonb := coalesce(new.raw_user_meta_data, '{}'::jsonb); + full_name text := nullif(trim(coalesce(meta ->> 'full_name', meta ->> 'name', '')), ''); + first_name text := split_part(full_name, ' ', 1); + last_name text := nullif(split_part(full_name, ' ', 2), ''); +begin + insert into public.profiles (id, name) + values ( + new.id, + left(coalesce( + nullif(meta ->> 'user_name', ''), + nullif(first_name || coalesce(' ' || left(last_name, 1) || '.', ''), ''), + 'player-' || left(replace(new.id::text, '-', ''), 6) + ), 40) + ) + on conflict (id) do nothing; + return new; +end; +$$; + +create trigger quiz_on_auth_user_created + after insert on auth.users + for each row execute function public.quiz_handle_new_user(); + +-- The expert deck. `data` is one card in the shape of lib/quiz-schema.ts; +-- load it with the SQL that `npm run quiz:sql` writes. Kept here, not in the +-- public repository, so ranked players can't look the answers up. +create table public.quiz_questions ( + id text primary key, + data jsonb not null, + active boolean not null default true, + updated_at timestamptz not null default now() +); +alter table public.quiz_questions enable row level security; + +-- One expert run. `cards` holds each card's id and the order its options were +-- shown in. While a card is open, `served_at` says when it was dealt; it is +-- null between an answer and the next card, so reading an explanation never +-- counts against the clock. `results` is a 1 or 0 per answered card. +create table public.quiz_runs ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users (id) on delete cascade, + cards jsonb not null, + position int not null default 0, + served_at timestamptz, + score int not null default 0, + correct int not null default 0, + streak int not null default 0, + results text not null default '', + started_at timestamptz not null default now(), + finished_at timestamptz +); +alter table public.quiz_runs enable row level security; +create index quiz_runs_user_started on public.quiz_runs (user_id, started_at desc); +create index quiz_runs_finished_score on public.quiz_runs (finished_at, score desc) where finished_at is not null; + +-- Each player's best finished run since a moment, ranked. Equal scores share +-- a rank; the earlier run is listed first. +create or replace function public.quiz_leaderboard(since timestamptz default '-infinity', max_rows int default 50) +returns table ( + rank bigint, + name text, + score int, + correct int, + total int, + run_id uuid, + finished_at timestamptz +) +language sql +stable +set search_path = '' +as $$ + with best as ( + select distinct on (r.user_id) r.user_id, r.id, r.score, r.correct, length(r.results) as total, r.finished_at + from public.quiz_runs r + where r.finished_at is not null and r.finished_at >= since + order by r.user_id, r.score desc, r.finished_at asc + ) + select rank() over (order by b.score desc), p.name, b.score, b.correct, b.total, b.id, b.finished_at + from best b + join public.profiles p on p.id = b.user_id + order by b.score desc, b.finished_at asc + limit max_rows; +$$; + +-- Where a score would place among players' best scores since a moment, and +-- how many players there are. +create or replace function public.quiz_rank(for_score int, since timestamptz default '-infinity') +returns table (rank bigint, players bigint) +language sql +stable +set search_path = '' +as $$ + with best as ( + select max(r.score) as score + from public.quiz_runs r + where r.finished_at is not null and r.finished_at >= since + group by r.user_id + ) + select count(*) filter (where best.score > for_score) + 1, count(*) + from best; +$$; + +revoke all on function public.quiz_leaderboard(timestamptz, int) from public, anon, authenticated; +revoke all on function public.quiz_rank(int, timestamptz) from public, anon, authenticated; +revoke all on function public.quiz_handle_new_user() from public, anon, authenticated; diff --git a/tests/quiz-expert.test.ts b/tests/quiz-expert.test.ts new file mode 100644 index 0000000..d95263b --- /dev/null +++ b/tests/quiz-expert.test.ts @@ -0,0 +1,225 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RUN_LENGTH } from "@/lib/quiz"; +import type { QuizQuestion } from "@/lib/quiz-schema"; + +/* + * Expert runs against an in-memory stand-in for the few Supabase calls + * lib/quiz-expert.ts makes, so the run's guards (one answer per card, no + * restarting the clock, late answers) are checked without a database. The + * SQL itself lives in supabase/migrations. + */ + +type Row = Record; +type Filter = (row: Row) => boolean; + +const tables: Record = { quiz_questions: [], quiz_runs: [], profiles: [] }; + +class Query implements PromiseLike<{ data: unknown; error: null; count?: number }> { + private filters: Filter[] = []; + private op: "select" | "insert" | "update" = "select"; + private payload: Row = {}; + private head = false; + private one: "single" | "maybe" | undefined; + + constructor(private readonly table: string) {} + + select(_columns?: string, options?: { head?: boolean }) { + this.head = options?.head ?? false; + return this; + } + insert(row: Row) { + this.op = "insert"; + this.payload = row; + return this; + } + update(values: Row) { + this.op = "update"; + this.payload = values; + return this; + } + eq(column: string, value: unknown) { + this.filters.push((row) => row[column] === value); + return this; + } + gte(column: string, value: string) { + this.filters.push((row) => String(row[column]) >= value); + return this; + } + in(column: string, values: unknown[]) { + this.filters.push((row) => values.includes(row[column])); + return this; + } + is(column: string, value: null) { + this.filters.push((row) => row[column] === value); + return this; + } + not(column: string, _op: "is", value: null) { + this.filters.push((row) => row[column] !== value); + return this; + } + single() { + this.one = "single"; + return this; + } + maybeSingle() { + this.one = "maybe"; + return this; + } + + private run(): { data: unknown; error: null; count?: number } { + const rows = tables[this.table] as Row[]; + const matching = rows.filter((row) => this.filters.every((f) => f(row))); + let data: Row[]; + if (this.op === "insert") { + const row = { id: crypto.randomUUID(), position: 0, score: 0, correct: 0, streak: 0, results: "", finished_at: null, started_at: new Date().toISOString(), ...this.payload }; + rows.push(row); + data = [row]; + } else if (this.op === "update") { + for (const row of matching) Object.assign(row, this.payload); + data = matching; + } else { + data = matching; + } + const copies = data.map((row) => structuredClone(row)); + if (this.head) return { data: null, error: null, count: matching.length }; + if (this.one) return { data: copies[0] ?? null, error: null }; + return { data: copies, error: null }; + } + + then(onFulfilled?: (value: { data: unknown; error: null; count?: number }) => A | PromiseLike, onRejected?: (reason: unknown) => B | PromiseLike) { + return Promise.resolve(this.run()).then(onFulfilled, onRejected); + } +} + +const fakeClient = { + from: (table: string) => new Query(table), + rpc: (name: string, args: { for_score: number }) => ({ + maybeSingle: async () => { + expect(name).toBe("quiz_rank"); + const best = new Map(); + for (const run of tables.quiz_runs ?? []) { + if (run.finished_at) best.set(run.user_id, Math.max(best.get(run.user_id) ?? 0, Number(run.score))); + } + const ahead = [...best.values()].filter((s) => s > args.for_score).length; + return { data: { rank: ahead + 1, players: best.size }, error: null }; + }, + }), +}; + +vi.mock("@/lib/supabase-server", () => ({ adminClient: () => fakeClient })); + +const { answerCard, nextCard, PlayError, startRun } = await import("@/lib/quiz-expert"); + +const ALICE = "00000000-0000-4000-8000-00000000000a"; +const BOB = "00000000-0000-4000-8000-00000000000b"; + +function question(i: number): QuizQuestion { + return { + id: `x-test-${i}`, + kind: "quick", + prompt: `Question ${i}?`, + options: ["right", "wrong one", "wrong two", "wrong three"], + answer: 0, + explain: "Because.", + source: "https://example.com/spec", + checked: "2026-10-30", + }; +} + +/** The option to press, as shown, for the card at `position` of a run. */ +function rightChoice(runId: string, position: number): number { + const run = tables.quiz_runs?.find((r) => r.id === runId) as { cards: { id: string; order: number[] }[] }; + const slot = run.cards[position] as { id: string; order: number[] }; + return slot.order.indexOf(0); +} + +async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (error) { + if (error instanceof PlayError) return error.status; + throw error; + } + return undefined; +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-10-30T12:00:00Z")); + tables.quiz_runs = []; + tables.profiles = []; + tables.quiz_questions = Array.from({ length: 30 }, (_, i) => ({ id: `x-test-${i}`, data: question(i), active: true })); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("expert runs", () => { + it("deal cards without their answers", async () => { + const run = await startRun(ALICE); + expect(run.total).toBe(RUN_LENGTH.expert); + expect(run.position).toBe(0); + expect(Object.keys(run.card).sort()).toEqual(["id", "kind", "options", "prompt"]); + expect([...run.card.options].sort()).toEqual(["right", "wrong one", "wrong three", "wrong two"]); + }); + + it("score a fast right answer, then deal the next card only once", async () => { + const run = await startRun(ALICE); + vi.advanceTimersByTime(1500); + const { result } = await answerCard(ALICE, run.runId, 0, rightChoice(run.runId, 0)); + expect(result).toMatchObject({ correct: true, points: 150, score: 150, streak: 1 }); + expect(await rejection(answerCard(ALICE, run.runId, 0, 0))).toBe(409); + + await nextCard(ALICE, run.runId, 1); + // Asking again must not restart the card's clock. + expect(await rejection(nextCard(ALICE, run.runId, 1))).toBe(409); + }); + + it("call an answer late by the server's clock, whatever was chosen", async () => { + const run = await startRun(ALICE); + vi.advanceTimersByTime(23_000); + const { result } = await answerCard(ALICE, run.runId, 0, rightChoice(run.runId, 0)); + expect(result).toMatchObject({ correct: false, points: 0 }); + }); + + it("keep a run to its player", async () => { + const run = await startRun(ALICE); + expect(await rejection(answerCard(BOB, run.runId, 0, 0))).toBe(404); + expect(await rejection(answerCard(ALICE, "not-a-uuid", 0, 0))).toBe(404); + }); + + it("finish with a summary and a rank, then accept nothing more", async () => { + const run = await startRun(ALICE); + let summary; + for (let position = 0; position < run.total; position++) { + if (position > 0) await nextCard(ALICE, run.runId, position); + const choice = position % 3 === 0 ? (rightChoice(run.runId, position) + 1) % 4 : rightChoice(run.runId, position); + ({ summary } = await answerCard(ALICE, run.runId, position, choice)); + } + expect(summary).toMatchObject({ total: run.total, correct: 12, rank: 1, players: 1 }); + expect(summary?.results).toHaveLength(run.total); + expect(await rejection(nextCard(ALICE, run.runId, run.total))).toBe(409); + }); + + it("skip a broken card instead of dealing it", async () => { + tables.quiz_questions?.push({ id: "x-broken", data: { id: "x-broken", prompt: "?" }, active: true }); + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const run = await startRun(ALICE); + const dealt = (tables.quiz_runs?.[0]?.cards as { id: string }[]).map((c) => c.id); + expect(dealt).not.toContain("x-broken"); + expect(run.card.id).not.toBe("x-broken"); + spy.mockRestore(); + }); + + it("limit runs a day", async () => { + for (let i = 0; i < 20; i++) await startRun(ALICE); + expect(await rejection(startRun(ALICE))).toBe(429); + expect(await rejection(startRun(BOB))).toBeUndefined(); + }); + + it("refuse to start without enough cards", async () => { + tables.quiz_questions = tables.quiz_questions?.slice(0, RUN_LENGTH.expert - 1) ?? []; + expect(await rejection(startRun(ALICE))).toBe(503); + }); +}); diff --git a/tests/quiz.test.ts b/tests/quiz.test.ts new file mode 100644 index 0000000..04734bc --- /dev/null +++ b/tests/quiz.test.ts @@ -0,0 +1,157 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + cardPoints, + decodeShare, + encodeShare, + maxScore, + openGroup, + optionOrder, + pickCards, + RUN_CAPS, + RUN_LENGTH, + safeNext, +} from "@/lib/quiz"; +import { getOpenDeck, openCards, statusCard } from "@/lib/quiz-open"; +import { type QuizQuestion, quizDeck } from "@/lib/quiz-schema"; +import { STATUS_IDS } from "@/lib/schema"; +import { getStandards } from "@/lib/standards"; + +const EM_DASH = String.fromCharCode(0x2014); +const standardIds = new Set(getStandards().map((s) => s.id)); + +/** The expert deck is kept out of the repository; check it only where it exists. */ +const EXPERT_PATH = join(process.cwd(), "private", "quiz-expert.json"); +const expert: QuizQuestion[] | undefined = existsSync(EXPERT_PATH) + ? quizDeck.parse(JSON.parse(readFileSync(EXPERT_PATH, "utf8"))) + : undefined; + +/** Problems that span a deck: duplicate ids, unknown standards, house style. */ +function deckProblems(deck: QuizQuestion[], prefix: string): string[] { + const problems: string[] = []; + const seen = new Set(); + for (const q of deck) { + if (seen.has(q.id)) problems.push(`${q.id} appears twice`); + seen.add(q.id); + if (!q.id.startsWith(prefix)) problems.push(`${q.id} should start with "${prefix}"`); + if (q.standard && !standardIds.has(q.standard)) problems.push(`${q.id} names a standard "${q.standard}" that has no page`); + if (JSON.stringify(q).includes(EM_DASH)) problems.push(`${q.id} has an em dash; use a colon, a semicolon or a new sentence`); + if (q.options.some((o) => /\b(all|none|both) of the above\b/i.test(o))) problems.push(`${q.id} has an option that depends on order`); + } + return problems; +} + +describe("content/quiz/open.json", () => { + it("is a valid deck in the house style", () => { + expect(deckProblems(openCards(), "o-")).toEqual([]); + }); + + it("has enough cards for a run, with every cap", () => { + const deck = getOpenDeck(); + expect(deck.length).toBeGreaterThanOrEqual(60); + const run = pickCards(deck, RUN_LENGTH.open, RUN_CAPS.open, openGroup); + expect(run).toHaveLength(RUN_LENGTH.open); + expect(run.filter((q) => openGroup(q) === "status").length).toBeLessThanOrEqual(RUN_CAPS.open.status); + }); + + it("asks one status card per standard, answered by its page", () => { + for (const s of getStandards()) { + const card = statusCard(s); + expect(quizDeck.element.safeParse(card).success, card.id).toBe(true); + expect(card.answer).toBe(STATUS_IDS.indexOf(s.status)); + expect(card.explain).not.toMatch(/\]\(|`/); + } + }); +}); + +describe.runIf(expert)("private/quiz-expert.json", () => { + const deck = expert ?? []; + + it("is a valid deck in the house style", () => { + expect(deckProblems(deck, "x-")).toEqual([]); + }); + + it("has enough cards for a run, and shares no id or question with the open deck", () => { + expect(deck.length).toBeGreaterThanOrEqual(RUN_LENGTH.expert * 3); + const open = openCards(); + const openIds = new Set(open.map((q) => q.id)); + const openPrompts = new Set(open.map((q) => q.prompt.toLowerCase())); + expect(deck.filter((q) => openIds.has(q.id) || openPrompts.has(q.prompt.toLowerCase())).map((q) => q.id)).toEqual([]); + }); + + // `npm run quiz:sql` writes the SQL that loads the deck into Supabase. + it.runIf(process.env.UPDATE_QUIZ_SQL)("writes private/quiz-expert.sql", () => { + const quote = (s: string) => `'${s.replace(/'/g, "''")}'`; + const rows = deck.map((q) => ` (${quote(q.id)}, ${quote(JSON.stringify(q))}::jsonb, true, now())`); + const sql = [ + "-- Generated by `npm run quiz:sql` from private/quiz-expert.json. Run it in the Supabase SQL editor.", + "begin;", + "insert into public.quiz_questions (id, data, active, updated_at) values", + `${rows.join(",\n")}`, + "on conflict (id) do update set data = excluded.data, active = true, updated_at = now();", + `update public.quiz_questions set active = false where id not in (${deck.map((q) => quote(q.id)).join(", ")});`, + "commit;", + "", + ].join("\n"); + writeFileSync(join(process.cwd(), "private", "quiz-expert.sql"), sql); + }); +}); + +describe("scoring", () => { + it("rewards right, fast and in a row, and nothing for wrong", () => { + expect(cardPoints(false, 1000, 0)).toBe(0); + expect(cardPoints(true, 1000, 1)).toBe(150); + expect(cardPoints(true, 3000, 1)).toBe(150); + expect(cardPoints(true, 20_000, 1)).toBe(100); + expect(cardPoints(true, 60_000, 1)).toBe(100); + expect(cardPoints(true, 1000, 3)).toBe(170); + expect(cardPoints(true, 1000, 40)).toBe(200); + }); + + it("caps a run's score", () => { + expect(maxScore(1)).toBe(150); + expect(maxScore(12)).toBeGreaterThan(maxScore(11)); + }); +}); + +describe("share codes", () => { + it("round-trip", () => { + const results = [true, false, true, true]; + expect(decodeShare(encodeShare(420, results))).toEqual({ score: 420, results }); + }); + + it("reject malformed and impossible scores", () => { + expect(decodeShare("abc")).toBeUndefined(); + expect(decodeShare("100-")).toBeUndefined(); + expect(decodeShare("100-012")).toBeUndefined(); + expect(decodeShare(`${maxScore(2) + 1}-11`)).toBeUndefined(); + }); +}); + +describe("dealing", () => { + const card = (id: string, kind: QuizQuestion["kind"]) => ({ id, kind }); + + it("respects caps, and fills from skipped cards when it must", () => { + const deck = [...Array.from({ length: 10 }, (_, i) => card(`f${i}`, "fake")), card("q", "quick")]; + const run = pickCards(deck, 4, { fake: 2 }); + expect(run).toHaveLength(4); + expect(run.slice(0, 3).filter((c) => c.kind === "fake")).toHaveLength(2); + }); + + it("keeps the written order of a scale or True and False, and shuffles the rest", () => { + expect(optionOrder({ kind: "real", options: ["a", "b", "c"] })).toEqual([0, 1, 2]); + expect(optionOrder({ kind: "quick", options: ["True", "False"] })).toEqual([0, 1]); + expect(optionOrder({ kind: "quick", options: ["a", "b", "c", "d"] }).sort()).toEqual([0, 1, 2, 3]); + }); +}); + +describe("sign-in return path", () => { + it("stays on this site", () => { + expect(safeNext("/play?deck=expert")).toBe("/play?deck=expert"); + expect(safeNext("//evil.example")).toBe("/play"); + expect(safeNext("https://evil.example")).toBe("/play"); + expect(safeNext("/\\evil.example")).toBe("/play"); + expect(safeNext(null)).toBe("/play"); + }); +}); From 00887b3ad2b24f07c4c2d29715259e37a50b99a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 10:44:48 +0000 Subject: [PATCH 3/5] Quiz: apply the fact-check of both decks; fix MPP's sessions example A fact-check of all 108 hand-written cards against primary sources found one wrong premise and no wrong marked answers. - o-mpp-which: MPP's pay-as-you-go sessions run on stablecoin rails such as Tempo; cards through Stripe cover one-off charges only (mpp.dev, draft-httpauth-payment-01). The card said sessions ran by card. - content/standards/mpp.md had the same blur in its example; it now says which rails do what. - o-ai-catalog-which: an entry links to its full description or includes it. - Three True/False cards were labelled "Real or hype?" but test facts, not how real something is; they are plain quick cards now. - o-poppy-fake-pairing asks about creators, not "launch partners", which is Poppy's own term for seven other companies. The expert deck (kept out of the repository) had four cards that repeated open-deck facts replaced, two "Real or hype?" cards put in the same Real-then-Hype order as the rest, and two explanations corrected. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NWyUpDcNT2Fd6FNzeuy2Bn --- content/quiz/open.json | 14 +++++++------- content/standards/mpp.md | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/content/quiz/open.json b/content/quiz/open.json index 6bf0dbc..666f1b4 100644 --- a/content/quiz/open.json +++ b/content/quiz/open.json @@ -27,14 +27,14 @@ "prompt": "A restaurant lists its booking tool and its jobs tool in one JSON file, like a table of contents for AI. Which standard?", "options": ["ai-catalog", "NLWeb", "UCP", "auth.md"], "answer": 0, - "explain": "ai-catalog is one JSON list at /.well-known/ai-catalog.json. Like a table of contents, it doesn't hold the tools themselves; each entry says what kind of thing it is and where to find its full description.", + "explain": "ai-catalog is one JSON list at /.well-known/ai-catalog.json. Like a table of contents, it doesn't hold the tools themselves; each entry says what kind of thing it is and links to its full description or includes it.", "standard": "ai-catalog", "source": "https://ai-catalog.io/", "checked": "2026-10-30" }, { "id": "o-ai-catalog-still-serve", - "kind": "real", + "kind": "quick", "prompt": "ARD calls ai-catalog its predecessor. True or false: it still pays to serve ai-catalog.json.", "options": ["True", "False"], "answer": 0, @@ -133,7 +133,7 @@ }, { "id": "o-card-networks-web-bot-auth", - "kind": "real", + "kind": "quick", "prompt": "True or false: Visa's Trusted Agent Protocol and Mastercard's Agent Pay build on Web Bot Auth.", "options": ["True", "False"], "answer": 0, @@ -189,7 +189,7 @@ { "id": "o-poppy-fake-pairing", "kind": "fake", - "prompt": "Spot the fake: which standard is paired with the wrong launch partners?", + "prompt": "Spot the fake: which standard is paired with the wrong creators?", "options": ["Poppy: Google + Visa", "UCP: Google + Shopify", "ACP: OpenAI + Stripe", "MPP: Stripe + Tempo"], "answer": 0, "explain": "Poppy came from Sierra and Meta, announced October 6, 2026. The rest are right: Google and Shopify launched UCP, OpenAI and Stripe launched ACP, and Stripe and Tempo launched MPP.", @@ -277,10 +277,10 @@ { "id": "o-mpp-which", "kind": "which", - "prompt": "A research agent pays a fraction of a cent per article by card through Stripe, then settles the total when it finishes. Which standard?", + "prompt": "A research agent opens a pay-as-you-go \"session\" on Tempo, pays a fraction of a cent per article, and settles the total when it finishes. Which standard?", "options": ["AP2", "ACP", "x402", "MPP"], "answer": 3, - "explain": "MPP, from Stripe and Tempo, uses HTTP 402 like x402 but adds cards via Stripe, subscriptions and pay-as-you-go \"sessions.\" x402 is per-request stablecoin payments under a neutral foundation.", + "explain": "MPP, from Stripe and Tempo, answers with HTTP 402 like x402 but adds pay-as-you-go \"sessions\" and subscriptions. Sessions run on stablecoin rails like Tempo; cards through Stripe cover one-off charges.", "standard": "mpp", "source": "https://mpp.dev/", "checked": "2026-10-30" @@ -351,7 +351,7 @@ }, { "id": "o-robots-txt-request", - "kind": "real", + "kind": "quick", "prompt": "True or false: robots.txt rules are a polite request, not a lock.", "options": ["True", "False"], "answer": 0, diff --git a/content/standards/mpp.md b/content/standards/mpp.md index c88a922..79b4eab 100644 --- a/content/standards/mpp.md +++ b/content/standards/mpp.md @@ -33,7 +33,7 @@ spec_sources: Paying for an online service usually means signing up, getting a key and setting up billing, which an autonomous agent can't easily do. MPP uses the web's long-unused "402 Payment Required" code: the server replies "this costs 2 cents, here's how to pay," the client pays, and the server returns the result plus a receipt. -It supports one-off charges, subscriptions and streaming pay-as-you-go "sessions," with stablecoins on the Tempo blockchain or ordinary cards through Stripe. Picture a research agent paying a fraction of a cent per article from a paywalled archive and settling the total when it finishes. +It supports one-off charges, subscriptions and streaming pay-as-you-go "sessions." One-off charges can use ordinary cards through Stripe; sessions run on stablecoins, such as on the Tempo blockchain. Picture a research agent paying a fraction of a cent per article from a paywalled archive and settling the total when it finishes. ## Who's behind it From 2d6bb0570bd1c1d9f8d81d5dac8f9064471b7a08 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 05:38:21 +0000 Subject: [PATCH 4/5] Recheck six standards whose specs changed; move their pins The weekly spec drift watch found changes in ARD, ai-catalog, WebMCP, Web Bot Auth, Poppy and x402. Each page was rechecked against its live sources and last_verified moved to 2026-10-30. No status changes. - Poppy: version 0.2 published October 24, draft 0.3 in progress (scopes, transitional mediated sign-in, operations v2); guide.json note updated. - Web Bot Auth: WG draft -01 (October 29): Signature-Agent now names the key set's address directly, and sites can ask for a signature. Signers now link primary sources; DuckDuckGo dropped (no primary evidence). - WebMCP: draft of October 27, the draft W3C Agentic Web WG charter, the trial passing 0.5% of Chrome page loads, Hydrogen on by default. - ai-catalog: multi-party Trust Manifests; Cloudflare's catalog and the working group's list of 26. - x402: October 22 disclosure of library flaws (fixed in August); the CoinDesk figure is volume, not real payments; Foundation source linked. - ARD: only a non-normative observability section upstream; Hugging Face serves only ai-catalog.json too. Closes #16, closes #17, closes #18, closes #19. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NWyUpDcNT2Fd6FNzeuy2Bn --- content/guide.json | 2 +- content/standards/ai-catalog.md | 8 +++++--- content/standards/ard.md | 6 +++--- content/standards/poppy.md | 24 ++++++++++++------------ content/standards/web-bot-auth.md | 12 ++++++------ content/standards/webmcp.md | 14 +++++++------- content/standards/x402.md | 10 ++++++---- spec-pins.json | 16 ++++++++-------- 8 files changed, 48 insertions(+), 44 deletions(-) diff --git a/content/guide.json b/content/guide.json index 4754278..817bec1 100644 --- a/content/guide.json +++ b/content/guide.json @@ -7,7 +7,7 @@ { "situation": "You have lots of structured content people and agents want to query", "standards": ["nlweb"] }, { "situation": "Your bot visits other sites, or you need to verify bots on yours", "standards": ["web-bot-auth"] }, { "situation": "You want agents to create accounts on your service without a sign-up form", "standards": ["auth-md"] }, - { "situation": "Customers' own agents should sign in and act on their accounts", "standards": ["poppy"], "note": "Track it; Draft 0.1 is from October 9, 2026, and 0.2 is in progress." }, + { "situation": "Customers' own agents should sign in and act on their accounts", "standards": ["poppy"], "note": "Track it; Draft 0.2 is from October 24, 2026, and 0.3 is in progress." }, { "situation": "You sell goods and want any AI assistant to browse, cart and check out", "standards": ["ucp"] }, { "situation": "You want your products shown in ChatGPT", "standards": ["acp"] }, { "situation": "An agent pays and you need proof a person approved those exact terms", "standards": ["ap2"] }, diff --git a/content/standards/ai-catalog.md b/content/standards/ai-catalog.md index b69e06b..74d9571 100644 --- a/content/standards/ai-catalog.md +++ b/content/standards/ai-catalog.md @@ -11,7 +11,7 @@ analogy: Your site's table of contents for AI. looks_like: /.well-known/ai-catalog.json when_to_use: When you publish that same list for MCP Server Cards and older tools. Serve one file at both addresses. backers: Linux Foundation working group (MCP and A2A communities) -reality_check: Early, with live catalogs at GitHub, Buildkite and Hugging Face. ARD tools look for `ard.json` first and may skip the old address, so serve the same file at both `ai-catalog.json` and `ard.json`. +reality_check: Early, with live catalogs at GitHub, Buildkite, Hugging Face and Cloudflare's developer docs. [The working group's own list](https://github.com/Agent-Card/awesome-ai-catalogs) has 26, mostly small sites. ARD tools look for `ard.json` first and may skip the old address, so serve the same file at both `ai-catalog.json` and `ard.json`. links: - label: ai-catalog.io url: https://ai-catalog.io/ @@ -24,12 +24,12 @@ related: note: ARD calls ai-catalog its predecessor. ai-catalog sees itself as the lightweight publishing format; it was split off, not retired. - id: nlweb note: A catalog can list an NLWeb endpoint. -last_verified: "2026-10-19" +last_verified: "2026-10-30" spec_sources: - kind: git-path repo: Agent-Card/ai-catalog path: specification - pin: "e020b2db26f8f13e904a95e0e457457dd3da9efd" + pin: "7ee97d9655a13e1f6442ab505982c03db17134ea" --- ## How it works @@ -38,6 +38,8 @@ Like a table of contents, it doesn't contain the chapters; it tells you where ea A restaurant that works with a booking provider and a jobs provider can list both AI tools, so any assistant that only knows the restaurant's web address can find the booking tool. Catalogs can also point to other catalogs, so a big platform can list many sub-catalogs. +An entry can also carry optional Trust Manifests: claims about the tool, such as who published it or which audits it passed, each of which can be signed. Since October 6, 2026, the [spec](https://ai-catalog.io/spec/) lets several parties, say the publisher and an auditor, each add their own. + ## Who's behind it It's developed in a [Linux Foundation working repository](https://github.com/Agent-Card/ai-catalog) (started October 2025) with members from the MCP and A2A communities, and published at [ai-catalog.io](https://ai-catalog.io/). Even the group's name is inconsistent: ai-catalog.io calls it the "Agent Card Working Group," while Google thanks the "AI Catalog Working Group." diff --git a/content/standards/ard.md b/content/standards/ard.md index 039ac04..db81b5f 100644 --- a/content/standards/ard.md +++ b/content/standards/ard.md @@ -11,7 +11,7 @@ analogy: A search engine for agent tools. looks_like: /.well-known/ard.json when_to_use: When you offer APIs, MCP servers or agents and want agent search registries to find them. backers: Google-led authors (Google, Hugging Face, R.V. Guha) -reality_check: Early. [Hugging Face runs a public registry](https://huggingface.co/blog/agentic-resource-discovery-launch), and Hugging Face and GitHub now publish catalogs, though GitHub's is only at the older `ai-catalog.json` address. According to [an independent sweep in September 2026](https://github.com/Agent-Card/ai-catalog/issues/115), only 24 of 102 publishers could be found at `ard.json`; the other 78 used only `ai-catalog.json`. +reality_check: Early. [Hugging Face runs a public registry](https://huggingface.co/blog/agentic-resource-discovery-launch), and Hugging Face and GitHub now publish catalogs, though both only at the older [`ai-catalog.json`](https://huggingface.co/.well-known/ai-catalog.json) address. According to [an independent sweep in September 2026](https://github.com/Agent-Card/ai-catalog/issues/115), only 24 of 102 publishers could be found at `ard.json`; the other 78 used only `ai-catalog.json`. links: - label: Specification (v0.91) url: https://agenticresourcediscovery.org/spec/ @@ -22,12 +22,12 @@ links: related: - id: ai-catalog note: Its predecessor, now the publishing format. ARD is the discovery format. Serve the same file at both addresses. -last_verified: "2026-10-19" +last_verified: "2026-10-30" spec_sources: - kind: git-path repo: ards-project/ard-spec path: spec - pin: "40151d4a76af2e266723f78d9142f54a89fb5dcf" + pin: "eca2857d0c9b7b27aa2fb72fe3bb69fcd0d18ea9" --- ## How it works diff --git a/content/standards/poppy.md b/content/standards/poppy.md index 8a02469..40ce5f0 100644 --- a/content/standards/poppy.md +++ b/content/standards/poppy.md @@ -4,7 +4,7 @@ full_name: Personal Agent Protocol (PAP) job: identity order: 2 status: early -status_note: Draft 0.1 (Oct 9, 2026), 0.2 in progress; a few small deployments +status_note: Draft 0.2 (Oct 24, 2026), 0.3 in progress; a few small deployments tagline: acts as you phrase: Your agent signs in to a company as you analogy: A valet key for your personal agent. @@ -13,9 +13,9 @@ when_to_use: When customers' own agents should sign in and act on their accounts backers: Sierra + Meta; 7 launch and 35 design partners reality_check: A draft first published October 9, 2026. A few small companies serve a `poppy.json`, including [Salespeak](https://salespeak.ai/agent-endpoint/), [Lorikeet](https://www.lorikeetcx.ai/blog/personal-agent-protocol), [Ticket Fairy](https://www.ticketfairy.com/.well-known/poppy.json) and [Alpic](https://alpic.ai/.well-known/poppy.json), most for signed-out chats or plain website browsing only. None of the launch partners does yet. links: - - label: Specification (Draft 0.1) - url: https://personalagentprotocol.org/docs/0.1/spec - - label: Draft 0.2 (in progress) + - label: Specification (Draft 0.2) + url: https://personalagentprotocol.org/docs/0.2/spec + - label: Draft 0.3 (in progress) url: https://personalagentprotocol.org/docs/spec - label: Path to 1.0 (open topics) url: https://personalagentprotocol.org/docs/path-to-1 @@ -34,29 +34,29 @@ related: note: Poppy leaves shopping to UCP and ACP, and may overlap with UCP on sign-in. - id: ap2 note: Payments are not covered yet. Until they are, payment consent needs another protocol, such as AP2. -last_verified: "2026-10-19" +last_verified: "2026-10-30" spec_sources: - kind: page url: https://personalagentprotocol.org/docs/spec - pin: "3b7a81416cfc5f98d4ce1d6f2cb0e359cbf68a46d5a5317b5a4a856855eb50e3" + pin: "24d8da14fd16c944adeb9969221e6d6a6d9ba089f7299ce8c6a8df8a860001f3" - kind: page url: https://personalagentprotocol.org/docs/extensions/operations - pin: "6f94925f7306c2d19cb5548e9655c8d414709301ba8ef37c6e1a436969614b7f" + pin: "ec14d1af5ac4a65e0f691e49fbdf57ede32f32552d86ed88865e85bf8eedb169" - kind: page url: https://personalagentprotocol.org/docs/guides - pin: "f2b0a17c0f420732a721f2843717015cb3f8277fc365a6b829af5e36bd67ea49" + pin: "708fc192545d0a65b596c8dbee038338fe559f21f4d3c203023304a1168420d0" --- ## How it works It lets the agent act for you at a company, with only the permissions you gave it, without opening the boot or the glovebox. Right now an AI agent acting for you has two bad options: pretend to be you in a browser, or not get in at all. -Poppy gives the company a file at `/.well-known/poppy.json` that says how agents can start a session, how you sign in, and which web pages, APIs and company agent are available. Sign-in uses OAuth, the familiar "Sign in with..." approval screen: you approve on the company's own page, on another device with a code, or (if the company allows it) the agent signs in for you. An agent can also start signed out, as a guest. +Poppy gives the company a file at `/.well-known/poppy.json` that says how agents can start a session, how you sign in, and which web pages, APIs and company agent are available. Sign-in uses OAuth, the familiar "Sign in with..." approval screen: you approve on the company's own page, on another device with a code, or (if the company allows it) the agent signs in for you. [Draft 0.3](https://personalagentprotocol.org/docs/guides) calls that last way temporary, with an end date to be set at version 1.0. An agent can also start signed out, as a guest. -Permissions are simple scopes like "read" and "write," plus custom ones such as "manage saved addresses." You also get a different anonymous ID at each company, so companies can't match you with each other. In the launch demo, Meta's Muse agent found Rocket Mortgage's agent and, with the user's permission, completed a mortgage pre-approval. +Permissions are scopes. Version 0.2 has two shared ones, "read" and "write," and companies can add their own, such as "manage saved addresses." [Draft 0.3](https://personalagentprotocol.org/docs/changelog) drops the shared ones: each company names its own and says which make changes that can't be undone. For an action such as exchanging an item, the company can also [ask you to approve the exact terms](https://personalagentprotocol.org/docs/extensions/operations) before it acts. You also get a different anonymous ID at each company, so companies can't match you with each other. In the launch demo, Meta's Muse agent found Rocket Mortgage's agent and, with the user's permission, completed a mortgage pre-approval. ## Who's behind it -[Sierra](https://sierra.ai/blog) (led by Bret Taylor and Clay Bavor) and Meta announced it on October 6, 2026. [Sierra published Draft 0.1](https://sierra.ai/blog/poppy) on October 9 with seven launch partners (Genesys, Instinct, NiCE, Rocket, Shopify, Stripe and Walmart) and 35 more design partners, including OpenAI, Visa, Mastercard and PayPal. [Draft 0.2](https://personalagentprotocol.org/docs/changelog) is in progress, with no publication date yet. +[Sierra](https://sierra.ai/blog) (led by Bret Taylor and Clay Bavor) and Meta announced it on October 6, 2026. [Sierra published Draft 0.1](https://sierra.ai/blog/poppy) on October 9 with seven launch partners (Genesys, Instinct, NiCE, Rocket, Shopify, Stripe and Walmart) and 35 more design partners, including OpenAI, Visa, Mastercard and PayPal. [Version 0.2](https://personalagentprotocol.org/docs/0.2/spec) followed on October 24, and [draft 0.3](https://personalagentprotocol.org/docs/changelog) is in progress. -The spec warns that any part can change, even in ways that break compatibility, and [lists payments as not yet covered](https://personalagentprotocol.org/docs/path-to-1). A reference implementation was promised within about a month of October 9 and isn't out yet. An implementation kit for companies was added on October 17: a skill that has a coding agent design and build a company's side, and a validator that acts as a test agent against a deployment. It reuses OAuth, OpenAPI and MCP rather than replacing them. +The spec warns that any part can change, even in ways that break compatibility, and [lists payments as not yet covered](https://personalagentprotocol.org/docs/path-to-1). A reference implementation was promised within about a month of October 9 and isn't out yet. An implementation kit for companies was added on October 17: a skill that has a coding agent design and build a company's side, and a validator that acts as a test agent against a deployment. [The kit](https://personalagentprotocol.org/kit/company) now follows draft 0.3, not the published 0.2. Poppy reuses OAuth, OpenAPI and MCP rather than replacing them. diff --git a/content/standards/web-bot-auth.md b/content/standards/web-bot-auth.md index f4c972f..6cb2e2e 100644 --- a/content/standards/web-bot-auth.md +++ b/content/standards/web-bot-auth.md @@ -3,7 +3,7 @@ name: Web Bot Auth job: identity order: 1 status: production -status_note: WG draft -00 (Sept 1, 2026); live signers +status_note: WG draft -01 (Oct 29, 2026); live signers tagline: which bot is it phrase: Cryptographic ID for bots analogy: A tamper-proof ID badge for bots, instead of a name tag anyone could write. @@ -23,18 +23,18 @@ related: note: auth.md signs up a new user for the agent; Web Bot Auth only proves which bot is asking. - id: x402 note: Cloudflare plans to use them together for pay-per-crawl. -last_verified: "2026-10-19" +last_verified: "2026-10-30" spec_sources: - kind: ietf-draft name: draft-ietf-webbotauth-httpsig-protocol - pin: "00" + pin: "01" --- ## How it works -Today websites identify bots by a label (the "user agent") that anyone can fake, or by IP address, which breaks easily. With Web Bot Auth, the company running the bot publishes a public key at a well-known address and signs every request with the matching private key, using an existing IETF standard for signing web requests (RFC 9421). +Today websites identify bots by a label (the "user agent") that anyone can fake, or by IP address, which breaks easily. With Web Bot Auth, the company running the bot publishes its public keys at a web address, names that address in each request, and signs the request with the matching private key, using an existing IETF standard for signing web requests (RFC 9421). -The website, or its CDN such as Cloudflare, checks the signature and knows for certain "this really is OpenAI's agent" before deciding to let it in, block it or charge it. A news site can let a verified shopping agent through while stopping an anonymous scraper wearing the same label. +The website, or its CDN such as Cloudflare, checks the signature and knows for certain "this really is OpenAI's agent" before deciding to let it in, block it or charge it. A site can also ask an unsigned bot to sign. A news site can let a verified shopping agent through while stopping an anonymous scraper wearing the same label. It answers *which agent* is visiting, not *whom it represents*; that's Poppy's and OAuth's job. @@ -42,4 +42,4 @@ It answers *which agent* is visiting, not *whom it represents*; that's Poppy's a Cloudflare proposed it in May 2025. On September 1, 2026 the IETF's webbotauth working group [adopted it as an official working-group draft](https://datatracker.ietf.org/doc/draft-ietf-webbotauth-httpsig-protocol/), written by authors from Cloudflare and Google; it's the group's only adopted document. It's still a draft, not an RFC: adoption isn't approval. -[Visa's Trusted Agent Protocol and Mastercard's Agent Pay build on it](https://blog.cloudflare.com/secure-agentic-commerce/), adding a tag that says whether the agent is browsing or buying, and observers report OpenAI, Cloudflare and DuckDuckGo signing requests today. The working-group draft has already changed how a request points to its key directory, but [Cloudflare's own checker](https://developers.cloudflare.com/bots/reference/bot-verification/web-bot-auth/) still expects the older format (as of October 8, 2026), so early implementations may need updates. +[Visa's Trusted Agent Protocol and Mastercard's Agent Pay build on it](https://blog.cloudflare.com/secure-agentic-commerce/), adding a tag that says whether the agent is browsing or buying. [OpenAI's ChatGPT agent](https://help.openai.com/en/articles/11845367), [Cloudflare's Browser Run](https://developers.cloudflare.com/browser-run/reference/automatic-request-headers/) and, as an experiment, [some Google-Agent traffic](https://developers.google.com/crawling/docs/crawlers-fetchers/web-bot-auth) sign requests today. [Revision -01](https://datatracker.ietf.org/doc/html/draft-ietf-webbotauth-httpsig-protocol-01) (October 29, 2026) changed how a request points to its keys: it now gives the exact address of its key set. [Cloudflare's own checker](https://developers.cloudflare.com/bots/reference/bot-verification/web-bot-auth/) still expects the older format (as of October 8, 2026), so early implementations will need updates. diff --git a/content/standards/webmcp.md b/content/standards/webmcp.md index 4280b2b..e2ac87f 100644 --- a/content/standards/webmcp.md +++ b/content/standards/webmcp.md @@ -3,7 +3,7 @@ name: WebMCP job: act order: 1 status: early -status_note: Draft (Oct 16, 2026); Chrome and Edge trials; live in ChatGPT desktop and Shopify +status_note: Draft (Oct 27, 2026); Chrome and Edge trials; live in ChatGPT desktop and Shopify tagline: page tools phrase: A page hands typed tools to the agent in your browser analogy: A labeled menu of actions for the agent in your browser. @@ -12,7 +12,7 @@ when_to_use: When agents use your site in people's browsers. Add tools to your k backers: W3C Web ML community group; Microsoft and Google editors reality_check: Early use with two big real-world users, ChatGPT's desktop browser and Shopify, while Chrome and Edge are still in trials. links: - - label: Draft (Oct 16, 2026) + - label: Draft (Oct 27, 2026) url: https://webmachinelearning.github.io/webmcp/ - label: Chrome docs url: https://developer.chrome.com/docs/ai/webmcp @@ -23,12 +23,12 @@ related: note: NLWeb is an endpoint on your server. WebMCP works inside the browser page. - id: ucp note: Shopify puts WebMCP tools on every Liquid storefront, next to its UCP support. -last_verified: "2026-10-19" +last_verified: "2026-10-30" spec_sources: - kind: git-path repo: webmachinelearning/webmcp path: index.bs - pin: "bd81b6d06c4d22bd15c14849030bbeb2fb14d9cd" + pin: "ce879543a9013dae3effe292e6deda0b4323a07d" --- ## How it works @@ -41,11 +41,11 @@ One caveat: a page's tool descriptions are text the AI reads, so a malicious sit ## Who's behind it -Microsoft and Google proposed it together in August 2025; the W3C's Web Machine Learning community group took it up that September, with editors from both companies. The current text is a [draft dated October 16, 2026](https://webmachinelearning.github.io/webmcp/) that says plainly it is "not a W3C Standard." +Microsoft and Google proposed it together in August 2025; the W3C's Web Machine Learning community group took it up that September, with editors from both companies. The current text is a [draft dated October 27, 2026](https://webmachinelearning.github.io/webmcp/) that says plainly it is "not a W3C Standard." A [draft W3C charter](https://w3c.github.io/charter-drafts/2026/agenticweb-wg.html) for a new Agentic Web Working Group would take WebMCP as its starting text, but that group does not exist yet. -[Chrome has been testing it](https://developer.chrome.com/docs/ai/webmcp) in a time-limited "origin trial" (Chrome 149-156) since May 2026; on September 28 [Google asked to extend the trial](http://www.mail-archive.com/blink-dev@chromium.org/msg17568.html) to Chrome 162 because feedback is still changing the API, so there's no ship date. [Edge runs its own trial](https://developer.microsoft.com/en-us/microsoft-edge/origin-trials/trials/0b76fe60-b266-458e-a285-04e375c0c31a) (through March 30, 2027) and [calls it "ready for testing."](https://blogs.windows.com/msedgedev/2026/09/21/new-in-edge-for-developers-create-better-components-and-make-your-site-agent-ready/) +[Chrome has been testing it](https://developer.chrome.com/docs/ai/webmcp) in a time-limited "origin trial" (Chrome 149-156) since May 2026; on September 28 [Google asked to extend the trial](http://www.mail-archive.com/blink-dev@chromium.org/msg17568.html) to Chrome 162 because feedback is still changing the API, so there's no ship date. On October 28, Google said [pages using the trial had passed 0.5% of all Chrome page loads](http://www.mail-archive.com/blink-dev@chromium.org/msg17895.html), though agents call the tools far less often. [Edge runs its own trial](https://developer.microsoft.com/en-us/microsoft-edge/origin-trials/trials/0b76fe60-b266-458e-a285-04e375c0c31a) (through March 30, 2027) and [calls it "ready for testing."](https://blogs.windows.com/msedgedev/2026/09/21/new-in-edge-for-developers-create-better-components-and-make-your-site-agent-ready/) -[ChatGPT's desktop browser calls these "site tools"](https://help.openai.com/en/articles/20001423-using-site-tools-in-the-chatgpt-desktop-app) automatically, and [Shopify puts WebMCP tools on every Liquid storefront](https://shopify.dev/docs/api/web-mcp) (Hydrogen stores can turn them on), including a [checkout tool that agents may only call after the buyer approves the order and total](https://shopify.dev/docs/agents/carts-and-checkout/checkout-webmcp). [Safari's team opposes it](https://github.com/WebKit/standards-positions/issues/670) and [Firefox is neutral](https://github.com/mozilla/standards-positions/issues/1412). +[ChatGPT's desktop browser calls these "site tools"](https://help.openai.com/en/articles/20001423-using-site-tools-in-the-chatgpt-desktop-app) automatically, and [Shopify puts WebMCP tools on every Liquid storefront](https://shopify.dev/docs/api/web-mcp) (and on Hydrogen stores from the 2026-10 release, unless turned off), including a [checkout tool that agents may only call after the buyer approves the order and total](https://shopify.dev/docs/agents/carts-and-checkout/checkout-webmcp). [Safari's team opposes it](https://github.com/WebKit/standards-positions/issues/670) and [Firefox is neutral](https://github.com/mozilla/standards-positions/issues/1412). ## Neighbours diff --git a/content/standards/x402.md b/content/standards/x402.md index 2411a02..cff03e7 100644 --- a/content/standards/x402.md +++ b/content/standards/x402.md @@ -10,7 +10,7 @@ analogy: A parking meter on every web page. Drop in a few cents and the gate ope looks_like: 402 Payment Required when_to_use: When you charge per API request in stablecoins, with no accounts or sign-up. backers: Coinbase, now the x402 Foundation (Linux Foundation) -reality_check: In production, but small. [TRM Labs (September 9, 2026)](https://www.trmlabs.com/trm-tech-blog/whos-actually-paying-measuring-ai-agent-payments-onchain) counted 198.9 million settlements worth $52.7 million since May 2025. After removing self-payments and similar flows, about $25.6 million looked like real commerce, and only 0.6% to 7.5% of that seemed to come from AI agents. Earlier, [CoinDesk (March 11, 2026)](https://www.coindesk.com/markets/2026/03/11/coinbase-backed-ai-payments-protocol-wants-to-fix-micropayment-but-demand-is-just-not-there-yet) found about $28,000 a day of real payments, averaging about 20 cents. [Cloudflare's "billion 402 responses a day"](https://blog.cloudflare.com/x402/) counts payment *requests*, not payments. Count dollars, not transactions. +reality_check: In production, but small. [TRM Labs (September 9, 2026)](https://www.trmlabs.com/trm-tech-blog/whos-actually-paying-measuring-ai-agent-payments-onchain) counted 198.9 million settlements worth $52.7 million since May 2025. After removing self-payments and similar flows, about $25.6 million looked like real commerce, and only 0.6% to 7.5% of that seemed to come from AI agents. Earlier, [CoinDesk (March 11, 2026)](https://www.coindesk.com/markets/2026/03/11/coinbase-backed-ai-payments-protocol-wants-to-fix-micropayment-but-demand-is-just-not-there-yet) found only about $28,000 a day in volume, averaging about 20 cents, much of it testing or "gamed" transactions. [Cloudflare's "billion 402 responses a day"](https://blog.cloudflare.com/x402/) counts payment *requests*, not payments. Count dollars, not transactions. links: - label: Specification (V2) url: https://github.com/x402-foundation/x402/blob/main/specs/x402-specification-v2.md @@ -25,12 +25,12 @@ related: note: AP2 can settle through x402. - id: web-bot-auth note: Cloudflare plans to use them together for pay-per-crawl. -last_verified: "2026-10-19" +last_verified: "2026-10-30" spec_sources: - kind: git-path repo: x402-foundation/x402 path: specs - pin: "fd92aa2f26e0b20985479ef43ab54cb28b555cf2" + pin: "11f02ce95e15d78e246efcbd5253ecbeefc2718d" --- ## How it works @@ -39,6 +39,8 @@ The internet never had a built-in way to charge tiny amounts, so content is eith Picture an agent paying a weather API a cent per lookup, or a crawler paying a publisher per page instead of being blocked. +Keep the official code libraries up to date. On October 22, 2026 a security firm [reported three critical flaws](https://v12.sh/blog/x402) in them, including no default spending cap, so a hostile server could ask for a wallet's whole balance. Fixes shipped in August. + ## Who's behind it -Coinbase created it, and [version 2 shipped on December 11, 2025](https://x402.org/x402-v2-launch/). The Linux Foundation announced an x402 Foundation on April 2, 2026 and [made it operational on July 14](https://www.linuxfoundation.org/press/linux-foundation-announces-operational-launch-of-x402-foundation-to-standardize-internet-native-payments-for-ai-agents-and-applications) with 40 members. It now [lists 57](https://www.x402.org/members); the 19 premier members include AWS, Google, Visa, Mastercard, American Express, PayPal, Stripe, Shopify, Cloudflare, Coinbase and Circle, the broadest coalition of any agent-payment standard. +Coinbase created it, and [version 2 shipped on December 11, 2025](https://x402.org/x402-v2-launch/). The Linux Foundation [announced an x402 Foundation on April 2, 2026](https://www.linuxfoundation.org/press/linux-foundation-is-launching-the-x402-foundation-and-welcoming-the-contribution-of-the-x402-protocol) and [made it operational on July 14](https://www.linuxfoundation.org/press/linux-foundation-announces-operational-launch-of-x402-foundation-to-standardize-internet-native-payments-for-ai-agents-and-applications) with 40 members. It now [lists 57](https://www.x402.org/members); the 19 premier members include AWS, Google, Visa, Mastercard, American Express, PayPal, Stripe, Shopify, Cloudflare, Coinbase and Circle. diff --git a/spec-pins.json b/spec-pins.json index 243d2e1..4b1102f 100644 --- a/spec-pins.json +++ b/spec-pins.json @@ -16,7 +16,7 @@ "kind": "git-path", "repo": "ards-project/ard-spec", "path": "spec", - "pin": "40151d4a76af2e266723f78d9142f54a89fb5dcf" + "pin": "eca2857d0c9b7b27aa2fb72fe3bb69fcd0d18ea9" } ] }, @@ -29,7 +29,7 @@ "kind": "git-path", "repo": "Agent-Card/ai-catalog", "path": "specification", - "pin": "e020b2db26f8f13e904a95e0e457457dd3da9efd" + "pin": "7ee97d9655a13e1f6442ab505982c03db17134ea" } ] }, @@ -42,7 +42,7 @@ "kind": "git-path", "repo": "webmachinelearning/webmcp", "path": "index.bs", - "pin": "bd81b6d06c4d22bd15c14849030bbeb2fb14d9cd" + "pin": "ce879543a9013dae3effe292e6deda0b4323a07d" } ] }, @@ -67,7 +67,7 @@ { "kind": "ietf-draft", "name": "draft-ietf-webbotauth-httpsig-protocol", - "pin": "00" + "pin": "01" } ] }, @@ -79,17 +79,17 @@ { "kind": "page", "url": "https://personalagentprotocol.org/docs/spec", - "pin": "3b7a81416cfc5f98d4ce1d6f2cb0e359cbf68a46d5a5317b5a4a856855eb50e3" + "pin": "24d8da14fd16c944adeb9969221e6d6a6d9ba089f7299ce8c6a8df8a860001f3" }, { "kind": "page", "url": "https://personalagentprotocol.org/docs/extensions/operations", - "pin": "6f94925f7306c2d19cb5548e9655c8d414709301ba8ef37c6e1a436969614b7f" + "pin": "ec14d1af5ac4a65e0f691e49fbdf57ede32f32552d86ed88865e85bf8eedb169" }, { "kind": "page", "url": "https://personalagentprotocol.org/docs/guides", - "pin": "f2b0a17c0f420732a721f2843717015cb3f8277fc365a6b829af5e36bd67ea49" + "pin": "708fc192545d0a65b596c8dbee038338fe559f21f4d3c203023304a1168420d0" } ] }, @@ -171,7 +171,7 @@ "kind": "git-path", "repo": "x402-foundation/x402", "path": "specs", - "pin": "fd92aa2f26e0b20985479ef43ab54cb28b555cf2" + "pin": "11f02ce95e15d78e246efcbd5253ecbeefc2718d" } ] } From bd74f89dc5e17097e833d5506d76dadb345b2015 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 19:54:13 +0000 Subject: [PATCH 5/5] MPP: say which rails carry which payments The example said MPP's pay-as-you-go sessions run with stablecoins or ordinary cards through Stripe. Cards through Stripe cover one-off charges only; sessions run on stablecoin rails such as Tempo (mpp.dev, and the examples in draft-httpauth-payment-01: tempo/session, stripe/charge). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NWyUpDcNT2Fd6FNzeuy2Bn --- content/standards/mpp.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/standards/mpp.md b/content/standards/mpp.md index c88a922..79b4eab 100644 --- a/content/standards/mpp.md +++ b/content/standards/mpp.md @@ -33,7 +33,7 @@ spec_sources: Paying for an online service usually means signing up, getting a key and setting up billing, which an autonomous agent can't easily do. MPP uses the web's long-unused "402 Payment Required" code: the server replies "this costs 2 cents, here's how to pay," the client pays, and the server returns the result plus a receipt. -It supports one-off charges, subscriptions and streaming pay-as-you-go "sessions," with stablecoins on the Tempo blockchain or ordinary cards through Stripe. Picture a research agent paying a fraction of a cent per article from a paywalled archive and settling the total when it finishes. +It supports one-off charges, subscriptions and streaming pay-as-you-go "sessions." One-off charges can use ordinary cards through Stripe; sessions run on stablecoins, such as on the Tempo blockchain. Picture a research agent paying a fraction of a cent per article from a paywalled archive and settling the total when it finishes. ## Who's behind it