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

> Source: <https://dev.to/harishkotra/can-an-agent-buy-from-you-a-one-cent-till-six-refusals-and-twelve-cold-agents-3ehm>
> Published: 2026-11-04 12:11:51+00:00

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:

``` js
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.

``` js
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:

``` js
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](https://8004scan.io/developers) 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:

``` js
// 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](https://www.dailybuild.xyz/project/304-agent-vend)
