cd /news/ai-agents/can-an-agent-buy-from-you-a-one-cent… Β· home β€Ί topics β€Ί ai-agents β€Ί article
[ARTICLE Β· art-163807] src=dev.to β†— pub= topic=ai-agents verified=true sentiment=↑ positive

Can an agent buy from you? A one-cent till, six refusals, and twelve cold agents

A developer built a single-process Express demo server that tests whether AI agents can actually complete a purchase, answering four questions in a fixed order β€” price (402), input validity (422), contract version (409), and payment verification (402/409) β€” before serving the item with a 200. The server combines llms.txt and .well-known discovery, x402 payments via EIP-3009 TransferWithAuthorization verified with viem on Base Sepolia or Base in USDC, and an append-only sha256-chained JSONL receipt ledger. The work argues that MCP and x402 move bytes and money but not meaning, and that typed failures with a code, hint, fix and retryable flag are what agents need most.

read9 min views2 publishedNov 4, 2026

Most agent-payment demos stop at the happy path: an agent hits an endpoint, gets a 402, signs a payment, gets a 200, the room claps. That demo teaches that a protocol works. It does not teach what breaks, and the thing that breaks is not the payment. It is everything around it.

So this repo does three things:

An agent that wants to buy from your service is not asking "do you support x402". It is asking four questions, in order:

x402 answers the third one well. MCP hands an agent a tool with a name. Neither of them tells the agent what the tool actually promises, what version it is, or how it fails.

A handshake is not an agreement. MCP moves bytes, x402 moves money, nothing in the stack moves meaning. The repo is an argument for putting meaning in the interface, and the demo is how you show it in twenty minutes.

One process. That is the first design decision and it matters more than it sounds.

agent / browser / curl
   β”‚ GET /llms.txt        β”‚ POST /api/vend/:item     β”‚ GET /api/receipts
   β–Ό                      β–Ό                          β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Express                                                        β”‚
β”‚                                                                β”‚
β”‚  discovery:  llms.txt, .well-known/agent.json, schema.json,    β”‚
β”‚              registration.json, /mcp        (generated)        β”‚
β”‚                                                                β”‚
β”‚  funnel:     price β†’ input β†’ version β†’ money β†’ serve           β”‚
β”‚              402     422      409       402      200           β”‚
β”‚                                                                β”‚
β”‚  x402:       EIP-3009 TransferWithAuthorization, verified      β”‚
β”‚              with viem, nonce-checked against the ledger       β”‚
β”‚                                                                β”‚
β”‚  receipts:   append-only JSONL, sha256 chain, re-verifiable    β”‚
β”‚                                                                β”‚
β”‚  demo:       chaos runs, tamper drill, three-doors results     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
   β”‚
   β–Ό  Base Sepolia (eip155:84532) or Base (eip155:8453), USDC
      verify locally, then ask the facilitator to settle

Why one process:

llms.txt says a penny and the endpoint wants two, that is not a documentation bug, it is impossible. src/server/vend.ts is the whole product. It answers the four questions in a fixed order:

/**
 * One call, four questions answered in order:
 *   what does this cost and where do I pay (402) -> is the input even valid (422)
 *   -> is the money real and unused (402/409) -> here is what you bought (200).
 */
export async function vend(itemId: string, input: Record<string, unknown>, opts: VendOptions) {
  const item = getItem(itemId);
  // 1. price and payee, or a 402 that names both
  // 2. schema check, pointer by pointer, or a 422
  // 3. contract version pin, or a 409
  // 4. payment verification: payee, amount, network, window, nonce
  // 5. run the item, append the receipt, return it
}

The ordering is the interesting part.

Every failure is a typed object, not a sentence:

throw fail(409, "contract_version_mismatch",
  `This tool speaks contract ${item.version}; the request pinned ${opts.contractHeader}.`,
  "Read /schema.json, pin that version, retry. Or ask for the current contract.",
  false, { expected: item.version });

A code for the agent, a hint for the retry policy, a fix for the human reading the trace, and a retryable flag so a client can distinguish "adjust and retry" from "stop". This is the part most payment demos skip, and it is the part an agent needs most.

The 402 carries PAYMENT-REQUIRED: base64 JSON with the amount, asset, payee, network, resource and timeout. The buyer signs an EIP-3009 TransferWithAuthorization for exactly that amount and retries the same POST with PAYMENT-SIGNATURE. The server:

const valid = await verifyTypedData({
  address: authorization.from,
  domain: { name: "USD Coin", version: "2", chainId, verifyingContract: asset },
  types: TRANSFER_TYPES,
  primaryType: "TransferWithAuthorization",
  message: authorization,
  signature,
});

Then checks payee, amount, network, window, and a nonce that has never been used in this ledger. The 200 carries PAYMENT-RESPONSE with the settlement. Then the facilitator submits the EIP-3009 transfer and returns the transaction hash, which is what the wall links to. When settlement fails the receipt says failed with the facilitator's reason and no money moved; the room sees the real outcome instead of a green tick that means nothing.

The buyer side is one function in src/server/client.ts: request, read the 402, sign, retry, return. If you only take one file from this repo, take that one.

The receipt wall is the demo that makes people lean forward, because it turns "trust me" into a file you can hash.

export function append(input: ReceiptInput): Receipt {
  const seq = all().length + 1;
  const prevHash = headHash();
  const base = { ...input, seq, prevHash };
  const hash = sha256(stableStringify(base));
  const receipt: Receipt = { ...base, id: `rcpt_${hash.slice(0, 12)}`, hash };
  fs.appendFileSync(FILE, `${JSON.stringify(receipt)}\n`);
  all().push(receipt);
  return receipt;
}

Each line commits to the previous line's hash. verify() walks the file, recomputes each hash and checks the link. Two details that cost me time:

id is derived from the hash, so it must not be inside the hashed body. Otherwise verification is self-referential and always fails.stableStringify sorts them, so a receipt written on my laptop verifies on yours. The tamper drill is a route, not a slide show: edit line one, reload, break, restore, reload, green. Six seconds, and the room now understands what a receipt is for.

The chaos buyer is the part I would keep if I could keep only one. Each row runs over the actual HTTP path against the running server.

200. Without it, the suite proves nothing. 0.9.0, tool speaks 1.0.0, 409 contract_version_mismatch. 422, with a JSON pointer to the offending path. validBefore in the past, 402 authorization_expired. 402 wrong_payee. 409 nonce_replayed. 422 budget_exceeded. Two things this buys you:

409 into a 400 fails CI, which is exactly the kind of change that breaks agent clients in production. The schema validator is deliberately small, about eighty lines, covering only the keywords this contract uses. A real deployment should use a real validator. The point here is the pointer-level error, not the parser.

This is the experiment behind the talk's title. Same service, published four ways:

cold: the domain and nothing else. llms: the domain plus a pointer to /llms.txt. mcp: the domain plus "there is an MCP tool at /mcp". registry: the domain plus "it is registered under ERC-8004". Each agent gets one HTTP tool, a wallet flag, and one sentence: buy something from this site. It decides what to read, whether to check the contract or the identity, and what to pay.

The run in this repo: all twelve bought. Median five steps. Ten read a contract or an identity before paying. Two of the three that came in through the MCP door never opened either. A tool name looks like an answer, and agents behave accordingly.

Caveats, because this is a talk prop and not a benchmark: one model, one afternoon, n=12, and ERC-8004 validation is not production-deployed, so the registry arm reads a registration document rather than a live validation registry. The result is a shape worth arguing about, not a decimal place worth quoting.

/api/three-doors serves the result from .data/three-doors.json. The numbers you quote are the numbers on disk. If the file is missing, the endpoint says ok: false, which is the honest failure mode and also a better story than a fabricated chart.

The first version of this talk had a discovery demo with a JSON file full of fake services. It looked fine

and taught nothing, because the hard part of discovery is not the search box. It is that the world's

inventory is messy, unreachable, and mostly not payable.

ERC-8004 gives you an identity registry. On Base Sepolia that is

0x8004A818BFB912233c491871b3d84c89A494BD9e, and at the time of writing it holds roughly 9,400 agent

identities. There is no enumeration function, so the only way to know what exists is to walk the

Registered logs. Public RPCs reject a two-million block getLogs call, so I chunk it:

const CHUNK = 400_000n;
for (let i = 0; i < MAX_CHUNKS; i++) {
  const from = to > CHUNK ? to - CHUNK : 0n;
  const logs = await client.getLogs({ address: REGISTRY.identity, event: registeredEvent, fromBlock: from, toBlock: to });
  // decode agentId, agentURI, owner; stop after a few empty windows
  to = from - 1n;
}

The registration document itself is behind tokenURI(agentId), and it is either inline base64 or a URL on

somebody's server. Of the newest 240 registrations I warmed, 171 answered with parseable JSON and 69 did

not. That ratio is the lesson, not a bug.

Ranking stays deliberately explainable, because an audience has to be able to argue with it:

if (name.includes(term)) value += 6;
if (services.includes(term)) value += 3;
if (desc.includes(term)) value += 2;
return { score: value, why: ["name: sql", "description: query"] };

Then the agent knocks. One GET to the endpoint the winner advertises, no money, and it reports what came

back: 402 payment required, a 200, or nothing at all. On the live registry, most entries advertising

x402 point at localhost:8001 or a machine that has since gone dark. So the verdicts are the honest ones:

The one service this build can genuinely settle with is its own till, because it holds those keys. That is

why the registry view ends by handing the room back to the till: discovery is somebody else's contract to

honour, settlement is yours.

Walking nine thousand log-derived identities to answer one shout-out is honest and too slow for a stage. So

discovery asks an index first. 8004scan exposes semantic search over the same

registry:

GET https://api.8004scan.io/api/v1/agents/search/semantic?q=<topic>&chain_id=84532&limit=6

It answers in 0.8 to 1.6 seconds with items[] carrying token_id, name, x402_supported and services.

That is a much better search than mine, and it is still somebody else's database. Two things follow, and they

are the whole reason this part exists:

// every row the index hands back is re-read from the contract
const owner = await client.readContract({
  address: REGISTRY.identity,
  abi: ownerOfAbi,
  functionName: "ownerOf",
  args: [BigInt(raw.token_id)],
});
const verified = owner.toLowerCase() === (raw.owner_address ?? "").toLowerCase();

If the contract disagrees, the card is marked unverified rather than presented. And if the index is down, slow, or rate-limited, the run does not fail: it falls back to the log walk and labels itself honestly.

via: "8004scan" | "on-chain"     // the screen says which one answered

The screen also refuses to overstate. matched is how many rows the index returned; chainTotal is how many registrations the chain holds. Telling a room "6 agents" when the registry has 9,388 would be a lie told with a true number.

One more filter earns its place. ERC-8004 service maps include oasf, which is a taxonomy path, not an endpoint:

if (name === "oasf") continue;          // an ontology is not a door
if (!/^https?:\/\//.test(endpoint)) continue;

That leaves only things an agent could actually knock on, which is what makes the next step meaningful: 402 means sellable, 404 or silence means the badge has no door.

Measured over the ten audience chips: 0.9 to 2.1 seconds warm, 0.9 to 3.6 cold, and 0.21 seconds to fail over to the on-chain path when I pointed the index at a dead host on purpose.

npm install
npm run build:ui
npm start          # http://localhost:8787, till; registry at /#registry

Live by default: every paid call settles on Base Sepolia, and npm run live-doctor tells you what is missing before you go on stage. AGENTVEND_MODE=rehearsal is the opt-in offline fallback.

Twenty-two tests, no mocks of the HTTP layer, strict TypeScript, and no CDN anywhere in the UI.

Code & more: https://www.dailybuild.xyz/project/304-agent-vend

── more in #ai-agents 4 stories Β· sorted by recency
── more on @x402 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/can-an-agent-buy-fro…] indexed:0 read:9min 2026-11-04 Β· β€”