case studiesjournalprojectsresume
all case studies

case study 01 / 2026

NotionBench: making 890 agent rollouts legible

A results site and live run dashboard for a coding-agent benchmark. Eight agent configurations, 38 tasks, three trials each, every rollout graded by a program and never by a model.

The scores tied. The page had to show where eight nearly identical scores actually differ: consistency, cost, and the part of the platform each agent got stuck on.

role
sole designer and engineer
timeframe
Jul 31 to Aug 5, 2026 (six days, 135 commits)
surface
static HTML, CSS, and JS with hand-rolled SVG; TypeScript runner
users
engineers choosing an agent setup, and me while the run executed

live results site (opens in a new tab)source on GitHub (opens in a new tab)

NotionBench results page showing a leaderboard of eight agent configurations with solve rate meters, reliability, tool error rate, cost, and time columns

01

the problem

Notion shipped a developer platform in May 2026 and said it was built for coding agents. I wanted to know whether that was true, so I wrote 38 tasks across every programmable surface, ran eight agent setups through each task three times, and had a program grade the lot. The benchmark was the easy half. The hard half was the page.

Most eval sites ship a leaderboard sorted by score. On this run the top six configurations finished within five points of each other, and 21 of the 38 tasks were solved by every setup on every trial. Ranking by score would have told a reader almost nothing. The page had to answer four different questions: which setups differ, how consistently, at what cost, and where they got stuck.

There was a second audience with a second problem. The grid is 912 cells and took about three days to run across subscription accounts with usage windows. While it ran, I needed to see at a glance whether a config was working, cooling down, quietly broken, or halted by a bad verifier. So the same page had to work as a private monitoring dashboard before it became a public report.

02

what I learned before designing

I audited the four benchmark sites I was borrowing from: Supabase Evals, Terminal-Bench, MCPMark, and tau-bench. Each one publishes a score column first. Almost none put cost beside score, and none of them separated "can solve it" from "solves it every time". Single-trial scores hide consistency by construction, and that is exactly the dimension where these eight setups turned out to differ.

The first user was me, for three days. I designed the page against seeded mock fixtures before any real data existed, then watched the live version while the run executed. Every change in the live section came from a moment when the dashboard failed to tell me something: a config sitting in a rate-limit cooldown that read as stalled, two real verifier bugs that each silently invalidated every cell that touched the task, and two OpenCode setups that ran short when their account hit a weekly usage limit.

03

key decisions

Two columns for one question: solve rate and reliable

considered
  • one score per config
  • best of three trials
  • solve rate with a reliability column beside it
chose
Solve rate (the share of trials solved, with a 95% Wilson interval) and Reliable (the share of tasks solved in every one of the three trials), as two adjacent columns.
why
Capability and dependability are different questions. DeepSeek V4 Flash solves 86% of trials but only 69% of tasks every time. It never failed a task outright, it just does not land the same task twice. A single number, or a best-of-three, erases that finding.

Tool error rate, never a raw count

considered
  • mean tool errors per trial
  • errors as a share of tool calls
chose
Errors as a share of tool calls, with the mean call count in its own column so the denominator is visible.
why
2.4 errors on 14 calls and 0.1 errors on 20 calls are opposite findings that look similar as counts. The rate exposed the real story: one in five Codex tool calls fails, against under 2% for OpenCode, on the same tasks in the same sandbox.

Partial data stays visible and stays honest

considered
  • drop configs that ran short
  • fill missing cells with zero
  • keep them, mark what is not comparable
chose
Per-trial means and medians stay comparable across rows. Totals for a config with fewer completed cells are muted and starred with a footnote. Reliability shows its denominator, as in 68% (25/38). Cells the usage limit prevented from running are excluded and recorded with their reason, not scored zero.
why
Zero-filling turns "not enough data" into "solved nothing". Dropping the rows would have hidden the two cheapest setups on the board. Muting and starring lets a reader keep the row and know exactly which numbers to trust.

One hue on the scatter, identity carried by labels

considered
  • eight categorical colors and a legend
  • one hue with direct labels and a Pareto step
chose
All dots share one hue. Each dot carries a direct label with a paper-colored halo, overlaps are resolved after render from real bounding boxes, and a gray step line marks the Pareto frontier. The leaderboard sits directly above as the chart's table twin.
why
Seven or eight series colors fail all-pairs color-vision checks, and a legend makes the reader work a lookup table. Labels remove the lookup. The step line turns "up and left is better" into a shape you can see: Luna sits alone on the frontier at $2.18.

One page, two modes, one file that knows the wire shape

considered
  • a separate monitoring app for the run
  • one page that switches mode from the URL
chose
The same page loads a static results file by default, or polls a bearer-protected status endpoint every ten seconds when the URL carries an api parameter. A single schema adapter is the only module that knows the runner's payload, and a test pushes a real payload through it so contract drift fails CI.
why
Two apps would have drifted. The monitoring view is where every design flaw surfaced first, and fixing it there fixed the public page for free. The adapter kept every other module reading one normalized shape.

Refuse to invent data, and distinguish "nobody looked" from "someone looked"

considered
  • a usage-window meter estimated from token counts
  • omit the meter when the CLI does not report it
chose
The usage-window meter renders only when the runner can report it; subscription CLIs cannot, so it is omitted rather than estimated. The watchdog's alerts have three levels: halt, warn, and acknowledged, where acknowledged means a human reviewed the shared failure and recorded why.
why
An estimated meter would put a fiction on the dashboard in exactly the moment I was relying on it. The acknowledged level exists so the dashboard can show that a suspicious failure has been read and explained, instead of folding it into a generic warning.

Notion's visual vocabulary, without counterfeiting it

considered
  • a generic dashboard look
  • a page that reads like a Notion database
chose
View tabs with icons, select-tag pastels for family and stage, a page icon and lede, hairline rules, system fonts, and a full dark scheme. The stylesheet's first line states the rule: evoke a Notion page without counterfeiting one.
why
The audience builds on Notion. A results table that reads like a database view they already know how to scan costs them nothing to learn, and the tag colors do double duty as the categorical encoding for product family.

04

how it changed

  1. Jul 31: seeded mock fixtures and a mock status server, so the page was designed and reviewed before a single real cell existed.
  2. Jul 31: the runner grew a serve command that hosts the page and a status endpoint. Live mode, agent lanes, and the failures feed followed the same day.
  3. Jul 31: folding the docs axis into one row per task turned out to change the meaning of pass^3. I wrote that cost down in the commit and in the page's footnote instead of hiding it.
  4. Aug 1 to 3: the real run. Two verifier bugs presented as several configs failing the same task with the same diagnostic. The deterministic watchdog, the halt and acknowledged alert levels, and the redo flow that retires stale rows to a superseded file came out of those two incidents.
  5. Aug 3: the two OpenCode setups ran short. Starred totals, denominators on reliability, and the excluded-cell rule shipped with the final results.
Live mode of the NotionBench page: a large 532 of 798 cells counter, a progress meter, and per-config lanes with done, running, pending, and blocked status chips
Live mode on the mock fixture: a hero count, an ETA computed from completion rate, and one lane per config with a status chip, its current task in mono, a usage window when the runner can report one, and a red needs-attention line when a lane is blocked.
By agent view listing every task with family and stage tags, three trial dots per task, mean score, and best time
The by-agent view. Three dots per task encode solved, failed, and not run with fill and stroke, not color alone. This view is the seed of the explorer prototype below.

05

what shipped

Score versus API-equivalent cost scatter on a log axis with direct labels and a Pareto step, beside four small bar panels of score by product family
The scatter and the family panels. Luna matches Fable within a point at 46 times less cost; DeepSeek V4 Flash scores 86% for 53 cents.
findingwhat the page shows
price and capability came apartthe Pareto step on the scatter, cost beside score in the table
consistency differs more than scorethe Reliable column with its denominator
tool errors belong to the harnesstool error rate as a share of calls, three harness bands
Workers is where the platform is hardfamily tags and the per-family panels: 23 of 49 failures
The same results page in dark mode with the leaderboard and the same tag and meter treatments
Dark scheme with re-validated chart chrome, categorical slots, and status colors that are never reused as series colors.

890 of the 912 cells were run and scored; the 22 cells a usage limit prevented are recorded with their reason rather than counted as zero. The site is pure static: no build step, no external requests, system fonts, and charts drawn as inline SVG so the page works from a file on disk and deploys with one command.

06

the next step, as a working prototype

The shipped page answers config-level questions well. The question I kept wanting while writing the findings was task-level: on which tasks do two setups disagree, and what did the failing trial actually say? I built the explorer below for this case study, in React, on the real run data. It adds the two patterns that experiment-tracking tools converged on: a differences-only toggle that hides every task all eight setups solved on every trial (20 of the 38), and a compare-two mode. Every cell opens its trials and the verifier's diagnostic, and the matrix is fully keyboard navigable.

interactive prototype / eval results explorer

Real data from run 20260801-123827. Arrow keys move between cells, Enter opens a cell, Escape closes the panel.

I used Claude Code to scaffold the component from a written spec that fixed the state shape, the metric definitions, and the required states first. I wrote the metric functions to match the shipped site's numbers, reviewed every render against the leaderboard, and rejected two of its defaults: zebra striping (rows carry state) and a spinner-only loading state (replaced with a skeleton that holds the layout).

07

what I would fix

  • The mobile leaderboard clips its right-hand columns. The table needs a column picker or a card layout under 640px; the explorer prototype solves this with a sticky first column and horizontal scroll.
  • The scatter's direct labels crowd at the top right where five setups sit within a point of each other. A hover-to-lift or a small inset zoom would help more than smaller type.
  • The page header says 912 runs where the writeup says 890 finished. The grid size and the scored count should both be on the page, in one sentence.
  • Reliability at k=3 is a coarse instrument. Extending to k=5 through the resume flow is already supported by the runner; the column should say which k it is at, which it now does.