Blog/Article

The capability was already there. No agent could ask for it.

26 of 81 AgentBoard methods already declared a cheap response format. Exactly one of five code generators honoured it, so no agent could ask.

•September 4, 2026•2 min read

26 of 81 AgentBoard methods already declared a cheap response format. Exactly one of five code generators honoured it, so no agent could ask.

We went looking for a performance problem and found a plumbing problem. This is what the measurement actually said, and what we did about it.

The complaint was about cost

The report that started this read like a cost complaint: reading a board burns too much context. That is true, and it is the less interesting half.

An orchestrator asks questions at four different zoom levels. Which cards moved since my last pass. Which of these five boards has anything in review. What is the full text of this one card. How many cards are blocked. Only the third of those needs the deep answer. Every one of them was served the deep answer.

Measured on a single card: the JSON response is 11,177 bytes, and 81 percent of that sits in two fields. The card has 67 fields. The other 61 are under 40 bytes each. The long tail is free. Two fields are the entire bill.

One page of a task list is 78,893 bytes. A board export, at its default setting, produced 2,120,648 bytes on a board of 322 cards. Not one of those numbers is an estimate.

The part the report did not have

Here is the finding that changed the work.

The API already supported a cheap response format. Twenty-six methods declared it in their schema. We checked which of the five code generators actually read that declaration and emitted a way to request it:

sdk.ts: 0    mcp-tools.ts: 0    runtime.ts: 0    openapi.ts: 0    typescript-plugin.ts: 1

One. So 26 methods advertised a cheap form that no SDK consumer and no MCP consumer could ever receive. The capability was built, tested, documented and deployed, and the wire to it was never connected.

The same shape appeared one layer up. A cross-board endpoint already accepted board filters, status filters, assignee filters and a markdown rendering. It was explicitly excluded from the agent surface as a dashboard feature, so no agent could call it at all. An activity endpoint offered agents exactly one parameter, and that parameter was not one the route actually declared, while the four the route did declare were invisible.

None of this is a performance bug. It is a distribution bug wearing a performance bug's clothes. Four of the seven work sessions this turned into did not build anything. They connected something already finished to the people it was for.

What shipped

The read side gained a detail dial with three settings, defaulting to exactly today's behaviour. Asking for summary drops the two heavy fields and keeps every card. It is lossless in the sense that matters: no card disappears, the fields simply get shorter.

The write side gained receipts. A mutation can now return what it changed, with before and after values, instead of returning the whole object and leaving the caller to diff it. Before this, the only way to know whether a status update took effect was to read the card back.

Cross-board questions became one call. Five fixed axes, all indexable and all tenant-scoped: board ids, column names, status, whether the card has a pull request, and a resumable cursor. We deliberately did not build a query language. A general query DSL is a large surface, hard to prove tenant-safe, impossible to cache, and agents write queries that scan everything.

Column names, rather than column ids, because ids are per-board and a question spanning five boards cannot know them.

Six methods that already existed moved onto the agent surface, taking it from 81 methods to 87.

The index that was doing nothing

The cross-board activity feed got a partial index. To find out whether it earned its place, we turned index scans off to reconstruct the old behaviour on the same data: 595.6 milliseconds, sequentially scanning and then discarding 43,604 rows. With the index: 5.6 to 8.6 milliseconds.

That is roughly 70 to 100 times, measured on production data rather than asserted from a plan.

The same check found something we did not expect. A later migration had added an overlapping index, and the planner prefers that one for both of the shapes the route actually issues. Ours is redundant. It is not a production problem at 5 to 8 milliseconds, but it is a real piece of bookkeeping, and it only surfaced because the verification re-measured instead of confirming.

One correctness bug underneath

A card's position was stored in two places, its column and its status, and the two synchronisation paths could disagree. Silently. That had already caused two incidents before this work started.

A write that names both, and names them inconsistently, is now refused rather than resolved. When two inputs disagree about one fact, every automatic resolution publishes the other one as a lie.

What we would do differently

The generator gap is the lesson. A capability declared in a schema, honoured by the service, and dropped by four of five generators is invisible to every test that checks either end. The service tests passed. The schema was valid. The drift checker compared the schema against the live route and found them in perfect agreement, because the missing piece was in neither.

We now check the surfaces separately, because a method can be correct against the service it calls and absent from the path a caller actually takes.

The dials are live on the REST API and in @opvs-ai/agentboard 1.27.0 and later. Defaults did not move, so nothing you already run changes.

Subscribe to our newsletter for blog updates and original content