← All engineering notes

Selling tickets to agents

What we built so an autonomous agent can find a gig, read the price and buy a ticket without a human touching a browser.

A person buying a ticket opens a page, reads it, and clicks. An agent cannot do any of that reliably. It needs the same three answers in a form it can parse: what is on, what does it cost, and how do I pay.

We spent the summer making those answers machine-readable. This is what we built, and the bug that showed us we had not finished.

  agent arrives
        |
        v
  llms.txt ............. 25 lines: what this is, where to look
        |
        v
  /gig/<slug>/ ......... JSON-LD MusicEvent
        |               Offer.sku = "<eventId>:<ticketTypeId>"
        |
        |  the same string, no lookup, no mapping table
        v
  POST /api/checkout_sessions ..... items[].id = that sku
        |                           Idempotency-Key required
        v
  POST .../complete  ->  a real order, the same path a human takes

Four surfaces, one set of facts

An agent arriving at Gotobeat has four ways in.

JSON-LD on every gig page. Each event page carries a schema.org MusicEvent block with one Offer per ticket tier. Price as a decimal string with a currency. Availability as InStock or SoldOut. A cancelled event reads EventCancelled and SoldOut together, so an agent does not have to infer it.

A read-only JSON API for events, artists and venues, described by an OpenAPI 3.1 file at /openapi/agents.json.

An ACP checkout. The Agentic Commerce Protocol gives an agent a session it can create, update, complete or cancel over HTTP, with an idempotency key on every write.

An MCP server at mcp.gotobeat.com, OAuth-protected, with an anonymous read-only tier at /public for agents that only want to look.

Three files orient an agent before it calls anything: llms.txt, the OpenAPI spec, and a prose page at /docs/agents/. The first is 25 lines. That is deliberate. An agent reading orientation is spending context it would rather spend on the task.

One id, two surfaces

The part that matters most is also the smallest. A ticket tier has one id, and two surfaces carry the same string:

  • Offer.sku in the JSON-LD on the gig page
  • items[].id in an ACP checkout session

The format is <eventId>:<ticketTypeId>. An agent reads the sku off the page it just parsed and posts it straight back to checkout. No lookup, no mapping table, no second call to resolve what it already has.

The parser splits on the first colon. That is a choice, and it holds because of two things elsewhere in the codebase. An event id comes from generateSlug, which emits [a-z0-9] runs joined by hyphens plus a nanoid suffix, so it can never contain a colon. A ticket type id comes from crypto.randomUUID(). If a future tier id ever holds a colon, it stays inside the second half and the parser is still right.

One rule we settled early: a tier row with no ticketTypeId gets no sku at all. A broken id is worse for an agent than a missing one, because a missing id makes it ask, and a broken id makes it confidently check out against a tier that does not exist.

robots.txt has an ordering rule most people never hit

Our robots.txt disallows /api/, because most of it is internal. The public agent endpoints need to be reachable anyway.

RFC 9309 says the most specific rule wins, and it measures specificity in octets. Allow: /api/events is longer than Disallow: /api/, so the Allow rule wins. We also emit every Allow line before the Disallow list, because an older first-match parser reads top to bottom and we would rather be right for both kinds of reader.

The non-prod stanza still says Disallow: / and nothing else. A staging environment full of test events is not something we want an agent to find.

Five places, one test

Adding a public endpoint means updating five things: the OpenAPI spec, the /docs/agents page, llms.txt, the apiAllows list in robots.txt, and the expected-paths list in the test.

Five hand-maintained lists is four too many. We could not collapse them, because they are genuinely different artifacts for different readers. So we wrote the consistency test instead. It reads the OpenAPI paths and the production robots output and fails when an Allow line does not prefix-cover a documented path. An endpoint that reaches the spec but never reaches robots.txt now fails the build.

That test has caught two mistakes so far. Both were mine.

The bug that produced zero orders

Here is the part worth reading.

The ACP surface shipped. The tests were green. Session create, update, complete and cancel all had unit coverage, and the coverage passed. The surface produced no orders at all.

entity.patch({ key }).set(payload) throws in ElectroDB when the payload still carries the key attribute. The error is Attribute "checkoutId" is Read-Only and cannot be updated. Our checkoutSessionsDB.update passed the whole record into .set(), and the whole record still had checkoutId on it. So every update, every complete and every cancel had failed since the day it deployed.

It threw only against the real table. A Jest mock of an ElectroDB entity accepts any payload you hand it, so every test stayed green while the deployed path had never once worked.

Two things came out of that. The fix is small: destructure the key out at the database module boundary, before .set() ever sees it. The lesson is not small. Mocking the thing whose constraints you depend on tests your own assumptions back at you. We now run an API-level smoke spec against a deployed stage for this surface, because that is the only thing that would have caught it.

What we are doing next

The MCP server now exposes a read-only tier that needs no account, and a wider set of discovery tools to authenticated staff connectors. Deciding what belongs in each tier is an ongoing argument. Read-only and public sounds safe until you notice that a fast enough agent can use a search endpoint to enumerate a corpus you did not intend to publish, so the public tier is rate limited and its tool list is defined server-side. A client cannot widen its own surface.

The bigger open question is trust. An agent that buys a ticket is spending someone's money on their behalf. ACP gives us the shape of the transaction. It does not tell us whether the agent had permission, and that is the part the whole industry is still working out.

We would rather be early and careful than late. If any of this is your kind of problem, we are hiring.

All engineering notes
region eu-west-2read 5m