Blog · 2026-10-06

Cloning [part of] the world wide web for faster agent testing

TL;DR

  • We build replicas of real SaaS sites (a CRM, a to-do app, ad managers, a spreadsheet, a calendar, a shop admin, a help desk) and score each one at three levels, pixel, DOM and API, against recordings of the real site.
  • Our CRM replica replays 107/107 recorded API scenarios cleanly in both directions (775 steps), and the recordings catch 46/46 bugs we planted on purpose.
  • Our to-do replica replays 38/38 API scenarios, catches 26/26 planted bugs, and reproduces 76.4% of the real page’s DOM hooks at the exact same path, with 95.4% of computed styles matching.
  • The obvious pixel metric is useless: a blank white page scored higher than our clone. We score “ink agreement” instead, which a blank page cannot beat.
  • The replicas fail on purpose (rate limits, lost saves, expired sessions), and tests grade the agent by what actually changed on the site, not by what the agent says it did.

Some of the websites we created:

Pixel-perfect. DOM-perfect. API-perfect.

We test web agents on replicas of real websites: a CRM, a to-do app, ad managers, a spreadsheet, a calendar, a shop admin, a help desk. A pass on the replica should mean a pass on the real site. That holds only if the replica is faithful where the agent looks, and different agents look at different layers. So we score every replica at three levels, each against a recording of the real site:

Level Who reads it What has to match How we score it
Pixel-perfect Vision agents (screenshots in, coordinates out) What the screen looks like at a fixed viewport Ink agreement against a screenshot of the real page
DOM-perfect (our term) DOM and ARIA agents (snapshots, refs, accessible names) Element ids, test ids, roles, accessible names, structure and computed style Hook-path coverage against a DOM signature of the real page
API-perfect Agents and tools that call the backend, and any UI rendered from it Response shapes, error bodies, paging, rate limits and ordering Structural replay of recorded real API traffic, in both directions

The headline numbers, measured offline from recordings:

  • hubspot-sim: 107/107 API scenarios clean in both directions, over 775 request/response steps, and 46/46 deliberate bugs caught by the recordings.1
  • todoist-sim: 38/38 API scenarios clean both ways (165 steps), 26/26 deliberate bugs caught, and 76.4% DOM hook coverage (559 of 732 hooks on 7 real screens) at 95.4% computed-style match.23
  • Pixel: 12.5% ink agreement on todoist-sim. That is low, and the section on pixels explains why, and why the obvious pixel metric would have reported 93% instead.

This post is about the machinery behind those numbers: a recorder and a structural differ for APIs, a DOM signature for markup, a pixel metric that a blank page cannot beat, and a fault-injection surface so the replicas can fail the way real sites do. Where a score cannot be measured yet, we say so. At the end we compare the approach with WebArena, REAL, OSWorld and Mind2Web-Live.

Every number below was measured offline on 2026-10-06, from recordings and captures we already held. No call went to the real HubSpot, Todoist, Meta or X, and the spend was $0.4

The real sites behind these replicas (Todoist, HubSpot, X Ads, Meta Ads Manager and the others) are market leaders in their categories. We took inspiration from them because they are the sites agents are actually asked to use.

The simulators

Sim Modelled on Has real recordings? Public
hubspot-sim (“the-best-crm-app”) HubSpot CRM v3/v4 API Yes: API, from a real portal, 2026-09-23 the-best-crm-app.cc
todoist-sim Todoist Sync API v1 and web UI Yes: API, plus 9 UI screens of HTML and CSS, 2026-09-235 the-best-todo-list-app.cc
xads-mirror X Ads Manager, Ads API v11 shape No the-best-twitter-ads-bench-simulator.cc
Graph bench (adsim) and Meta ads bench Meta Marketing (Graph) API and Ads Manager No HTTP goldens. Behaviour comes from Meta’s docs and from rules learned the hard way6 the-best-meta-ads-bench-simulator.cc
spreadsheet-sim The Sheets API v4 reference, “spreadsheets” resource No. Built from public docs, with fictional data7 the-best-spreadsheet-app.trade
calendar-sim Calendar API v3 and the reference app’s UI structure No7 the-best-calendar-app.trade
shopadmin-sim Common commerce-admin REST conventions; AngularJS 1.8 UI No7 the-best-shop-admin-app.trade
helpdesk-sim Common support-ticket REST conventions; Angular plus Material UI No7 the-best-helpdesk-app.trade
google-sim Google OAuth sign-in, for our own login flow No. It is a real open-source auth server talking to a mock OIDC server8 No. Private network only

What some of them look like (all data is fictional):

The CRM replica: a contacts list with lifecycle stages

The to-do replica: the Today view with overdue tasks and projects

The calendar replica: a week view with recurring and one-off events

The help desk replica: an agent’s unsolved-tickets view

The shop admin replica: the home dashboard with recent orders and low stock

How we score each level

API-perfect: replay of real recordings

Our recorder runs each scenario against the real API and writes every request and response to a “golden” file. Replay then runs the same requests against the clone and diffs the two responses structurally.

Before diffing, ids are renamed in order of first sight (for example <ID3:digits:12>), and timestamps become <TSn:ms>. So two ids only have to be distinct and in the same places, not equal.

A reverse replay diffs in the other direction. It catches fields the clone sends that the real API never does.

The score is the scenarios that are clean in both directions, out of all recorded scenarios. We also give the number of request/response steps behind it.

The score has a limit: it only covers what was recorded. A clone can score 100% and still lack a whole feature nobody recorded. So every API score below comes with a “not mimicked” list.

DOM-perfect: the hook signature

Our DOM signature tool walks a rendered page and records every hooked element: one with an id, data-testid, aria-label or role. It identifies each one by its path of hooks, and records its bounding box and a fixed list of computed styles. A differ then compares the real signature with the clone’s. It reports:

  • Coverage: the share of the real page’s hook paths that exist in the clone at exactly the same path. This is the headline number. An agent that says “click [data-testid=task-list-item] > [aria-label=Complete]” works only if that path exists.
  • Loose coverage: the same, matched by the single strongest key (test id, then id, then aria-label, then role) instead of the full path.
  • Style match: over hooks matched by path, the share of compared computed-style properties that are equal after normalisation (colours to rgba, px rounded).
  • Bounding-box deltas: the median absolute x/y/width/height difference over matched hooks.
  • Visible only: the same numbers, ignoring elements that are not visible in either page.

Pixel-perfect: ink agreement, not raw pixel match

Our first pixel metric was the obvious one: the share of pixels that are equal, or within 16 per channel (“tol16”). That metric turned out to be useless.

Most of a web app screen is white. So we also scored a flat white image against the real screenshot, as a floor. On the Todoist inbox screen, the blank image scored 97.6% tol16, and our clone scored 93.2%.9 A blank page “beat” the clone.

So the pixel score we report is ink agreement:

  1. Take the real screenshot’s modal colour as the background.
  2. A pixel is ink if it differs from that background by more than 16 on any channel, in either image.
  3. The score is the share of ink pixels on which the two images agree within 16.

A flat image scores 0 by construction. We also report a best-shift variant: the best vertical offset between 0 and 80 px is applied before scoring. This separates “everything is 51 px lower” from “everything is wrong”.10

The scores

Sim Pixel-perfect DOM-perfect API-perfect
hubspot-sim not measured (no UI capture; the UI is our own design) not measured (same reason) 107/107 scenarios clean both ways (47 hand-written plus 60 explored; 775 steps)1
todoist-sim 12.5% ink agreement; 17.3% best-shift (7 screens, approximate reference, see below)11 76.4% coverage (559/732), 82.4% loose, 95.4% style match (7 screens)3 38/38 scenarios clean both ways (18 hand-written plus 20 explored; 165 steps)2
xads-mirror not measured not measured not measured
Graph bench and Meta ads bench not measured not measured not measured
spreadsheet-sim not measured not measured not measured
calendar-sim not measured not measured not measured
shopadmin-sim not measured not measured not measured
helpdesk-sim not measured not measured not measured
google-sim not applicable (our own login page) not applicable not measured against Google. 16/16 contract tests pass, which is mock evidence only12

“Not measured” means no recording of the real site exists for that level. It does not mean we believe the score is low or high. We have not estimated any of them. Recording an API needs a real account per vendor; a DOM or pixel score needs a capture of the real screens in a logged-in browser. Today only hubspot-sim and todoist-sim have the first, and only todoist-sim has the second.

Fixture results versus live-site results

Everything in the table is a fixture result: the clone is compared with a recording, not with today’s real site. The recordings date from 2026-09-23. If HubSpot or Todoist changed after that, the replay cannot see it.

Live-site results exist only from the recording rounds. Those are in the next section and are labelled as live.

hubspot-sim

Replay, measured 2026-10-06:

Run Clean Steps
Core scenarios, forward replay 47/47 406
Core scenarios, reverse replay 47/47 406
Explored scenarios, forward replay 60/60 369
Explored scenarios, reverse replay 60/60 369
pytest (whole sim) 307 passed, 11 skipped —

Every skip is a deployment test that runs only in the deployment environment.1

Live-site work, from the recording rounds (2026-09-23; we did not repeat it):

  • The recordings came from a real portal.
  • 46/46 mutations killed. Each mutation is a deliberate bug put into the clone; it counts as killed if at least one golden goes red.
  • 675 live requests in the explorer and walk rounds.
  • 484 live requests in the verification round. Run 7 was the only clean pass, and it covered one walk of 40 steps.
  • A census was taken before and after each live recording: 6 contacts and 12 companies throughout. The recordings left the real portal unchanged.

What the real API taught us that the docs did not:

  • A contact has one primary company.
  • A v4 association PUT sets labels rather than adding to them.
  • Empty values sort lowest.
  • Search lags writes. We measured an index lag of 12.4 s once.
  • Merges enrich records asynchronously.

Proven scope. Only contacts and companies are proven. Deals, tickets, products, line items and projects are unproven: both our tokens get 403 on them.

Not mimicked: rate limits as the real portal sends them, async search indexing, auto-association by email domain, id allocation order, and scopes.

todoist-sim

API. Replay, measured 2026-10-06:2

Run Clean Steps
Core and explored scenarios, forward replay 38/38 165
Core and explored scenarios, reverse replay 38/38 165
pytest (whole sim) 301 passed —

Live-site work, from the recording rounds (2026-09-23):

  • 26/26 mutations killed.
  • A catalogue of 244 generated edge cases.
  • The first live pass found 172 disagreements in 49 classes, over 438 ledgered requests.
  • The real API allows 100 full syncs per 15 minutes and answers more with a LIMITS_REACHED 429. The clone does not reproduce that limit.

DOM. The real side is the 9 screens captured on 2026-09-23 in a logged-in browser. The capture is HTML, CSS and sync logs. We keep it out of version control because it holds account data.5 Seven of those screens are used for scoring.

For each screen, the captured HTML was re-rendered offline with scripts stripped, and the DOM signature tool was run on the result. The clone side was captured on 2026-10-06 from a local copy of the clone, logged in as a throwaway workspace. The capture made 0 requests to anything but the local clone.13

Clone version Coverage Loose Style match
2026-09-23 73.6% (539/732) 77.2% 95.5%
2026-10-06 76.4% (559/732) 82.4% 95.4%
2026-10-06, visible only 72.8% (452/621) 81.3% 96.1%

Coverage per screen, 2026-10-06 clone:

inbox today upcoming filters project task detail quick add
75.4% 78.0% 71.9% 81.9% 75.9% 76.3% 76.1%

The largest missing hooks are the onboarding cards in the sidebar, such as “Get Todoist on mobile”. The real account showed them and the clone’s seed does not.3

The clone uses the vendor’s DOM ids, test ids and aria labels, because those are what a DOM agent keys on.

Pixels. Mean over the same 7 screens at 1440x900:14

Comparison Exact tol16 Mean abs. diff Ink agreement Best-shift ink
Real vs 2026-09-23 clone 85.3% 91.9% 6.17 12.4% —
Real vs 2026-10-06 clone 82.7% 91.4% 6.50 12.5% 17.3%
Real vs flat white 78.0% 90.4% — 0% by construction —

Three things to read from this:

  1. Raw pixel match is nearly the same as the blank page’s. It cannot rank clones.
  2. Ink agreement is about 2% on six of the seven screens. The task-detail screen scores about 75%, because a dark modal overlay covers most of it and both images have it.
  3. DOM and pixels moved in opposite directions. Coverage went up 2.8 points between the two clone versions, and raw pixel match went down 2.6 points. A clone tuned for one level is not tuned for the other.

Why the pixel score is low. We looked at the PNGs side by side:

  • The clone adds a fixture banner and a workspace bar, about 51 px tall, so everything sits lower. The best shifts are 23 to 64 px.
  • The real reference is a re-render with scripts stripped. Todoist virtualises its task rows, so the rows never drew. The real side shows an empty list plus “Finish your setup” and “Get Todoist on mobile” cards.
  • The seed data differs from the account that was captured.

So 12.5% is a lower bound measured against an approximate reference. It is not a fair pixel score yet. A fair one needs the real pages captured with scripts running, from an account seeded to match the clone, and the clone captured with its fixture banner hidden.

Faults on purpose

A clone that only ever succeeds tests the happy path. The real site rate-limits you, loses a save, logs you out mid-edit, and covers the page with a consent dialog. So the clones can do those things on command.

Every sim has the same control surface: /_sim/reset, /_sim/seed, /_sim/state, /_sim/fault and /_sim/clock. These sit behind an admin header. A test arms a fault, runs the agent, then grades by reading /_sim/state — what actually changed — rather than what the agent said changed.

Sim Fault What the client sees Recorded or from the docs?
hubspot-sim rate_limit 429, RATE_LIMIT, policy TEN_SECONDLY_ROLLING, in the documented example’s keys, no Retry-After Docs. The code says: “Neither shape is recorded from a real portal: these are the docs’ word.”
hubspot-sim drop (flaky save) 200 and a normal body. The write went to a deep copy and is lost Our invention, to test read-after-write
hubspot-sim inline The save works only through inline edit (an x-sim-inline-only header) Our invention
hubspot-sim expire The session ends as the save arrives Our invention
hubspot-sim server_error 503 Docs
todoist-sim — No fault for the real 100-full-syncs-per-15-minutes limit Gap
spreadsheet-sim rate_limited 429 RESOURCE_EXHAUSTED, Retry-After: 30 Docs
spreadsheet-sim dropped_write 200 with kept: false Our invention
spreadsheet-sim unavailable, session_expired, refused_field, protected_range 503, 401, a field-level refusal, a protected-range error Docs
calendar-sim rate_limited 429 rateLimitExceeded, Retry-After: 5. Count -1 makes it sticky Docs
shopadmin-sim rate limit 429 “Exceeded 2 calls per second”, Retry-After: 2.0. Faults take times (at most 100) and are written to fault_log Docs
helpdesk-sim rate_limited, refused_field The next N calls get 429; a field gets 422 and aria-invalid in the UI Docs
Graph bench expired token, (#17), (#80004), 500 Meta’s codes, all sent as HTTP 400 except the 500 Docs, plus rules learned from Meta
google-sim wrong-aud, wrong-iss, expired, bad-signature, token-500, pkce-500, pkce-reset Each breaks one step of the OIDC and PKCE exchange Our invention, against the spec8

Stale refs are not injected by a sim. Our fake browser executor mints refs for each page generation. If the agent uses a ref from before the page changed, it refuses with STALE_REF: “That ref is from before the page changed. Take a new snapshot.” So stale refs are tested at the executor contract, one layer above the sites.

Consent banners are not injected by any sim either. We searched every sim for one and found none. Consent handling is tested in the browser executor against snapshots of real banners. One example is a Funding Choices dialog captured from brainyquote on 2026-10-01. Tested this way, banners are answered only when the banner is on an allowlist. No end-to-end run yet shows an agent getting past a consent banner on a site we control.

Clocks. Every .trade sim pins a clock. The default is 2026-10-05 09:00, and calendar-sim pins Monday 2026-10-05 09:00 in Asia/Jerusalem. helpdesk-sim advances the clock 1 s per write. Without a pinned clock, “next Tuesday” is a different date each run.

Per-visitor state. On hubspot-sim and todoist-sim, a cookie gives each browser a private copy of the seed, forked lazily, with at most 500 copies kept (least recently used). Named workspaces at /u/<name>/ are isolated from each other.

The four .trade sims do not have this yet. Their landing pages send every visitor to the same demo workspace, so one visitor’s edits show to the next. Per-visitor isolation for them is planned and not shipped as of 2026-10-06.

Four stories

Each of these is from our own notes, re-checked against the code for this post.

1. The versionless Graph path. Our first Graph bench refused requests without an API version. Meta does not: it serves a versionless path with 200, at the oldest supported version. The only clues are a facebook-api-version response header and old spellings like CONVERSIONS instead of the ODAX objective names.

Our clone was stricter than production, so it could never catch an agent that forgot the version. The bench now does what Meta does. Its specification opens: “It must not be lenient.”6

2. limit=100 meant no paging. The bench seeds 18 campaigns. A test asked for limit=100, so the response had no paging.next. A mutation that stripped paging could never turn it red.

With limit=2, the lock fires. The bench also puts the access token into paging.next, as Meta does. A test that drops it is caught.6

3. Twelve green proofs over the wrong error. Navigation tests in a fake reported NAVIGATION_FAILED where the real executor reports ORIGIN_NOT_ALLOWED. Twelve proofs and two mutation matrices passed, because they all agreed with the fake.

A fake proves your model of the other side, not the other side. This is why every API score above comes from a recording.15

4. Mirror the producer, not a namesake. A grader pinned a string produced by a helper function with the right-sounding name. On the page that test reads, that function never produces the string. The grader passed while checking nothing.

The rule we took from it: a clone’s assertion must point at the code that produces the text on screen.15

Prior art

Benchmark What it matches What it skips
WebArena (812 tasks)16 Real open-source apps, self-hosted (a shop, GitLab, a forum, a CMS, a map). So the DOM and API are real for those apps. GPT-4 scored 14.41% and humans 78.24%. WebArena-Verified makes grading deterministic, from HAR files and backend state17 Not the sites agents meet in practice. No rate limits, no injected faults, no consent dialogs
REAL (11 clones, 112 tasks)18 Hand-built, deterministic clones of familiar sites, such as “Omnizon” and “DashDish”. Best score 41.07% Fidelity to the real site is not measured. We found no mention of rate limits or faults
OSWorld (369 tasks)19 Real desktop VMs and real apps. Humans 72.36%, best model at launch 12.24%. Used to report OpenAI’s CUA (38.1%)20 and Anthropic’s models (Claude 3.5 Sonnet 14.9%/22.0%; Sonnet 4.5 61.4% against Sonnet 4’s 42.2%)21 A desktop, not a website clone. The web is live and not controlled
Mind2Web (2,350 tasks, 137 sites)22 Real sites, captured as offline snapshots Snapshots: no backend, so nothing can be graded by state
Mind2Web-Live / WebCanvas (542 tasks)23 The real live sites, graded on 2,439 “key nodes”. 23.1% task success, 48.8% completion The live site drifts. No control of faults, clock or state
WebVoyager (15 live sites)24 Live sites, graded by a model that agreed with humans 85.3% of the time. 59.1% success Same drift problem
WorkArena (33 tasks)25 A real ServiceNow instance One vendor

Where this work differs. None of these scores a clone’s fidelity to the real site, level by level. None of the ones we read injects rate limits, lost saves or consent banners on purpose.

The self-hosted benchmarks (WebArena, WorkArena) get fidelity for free, because the app is the real app. They do not cover proprietary SaaS. The live benchmarks (WebVoyager, Mind2Web-Live) are faithful but cannot be controlled or graded by state. REAL is controlled, but does not claim fidelity.

We want both: clones of the proprietary sites agents are actually asked to use, scored against recordings of those sites.

Footnotes

  1. Measured 2026-10-06: replay of the 47 core and 60 explored scenarios against the clone, forward and reverse. Every run exited cleanly. pytest: 307 passed, 11 skipped. Steps counted from the stored goldens (406 core, 369 explored). The 46/46 mutation result is from the 2026-09-23 recording rounds. ↩ ↩2 ↩3

  2. Measured 2026-10-06: replay of all 38 scenarios (18 core plus 20 explored) against the clone, forward and reverse: 38/38 clean. 165 steps in the goldens. pytest: 301 passed. The 26/26 mutation result is from the 2026-09-23 recording rounds. ↩ ↩2 ↩3

  3. Measured 2026-10-06 over 7 real/clone pairs, with and without the visible-only filter. The 2026-09-23 row reproduces the 73.6% and 95.5% recorded at the time, from the stored signatures. ↩ ↩2 ↩3

  4. All measurements for this post were offline replays, local unit and contract test runs, a headless render against a local copy of todoist-sim, and image scoring. No paid model calls and no live vendor calls. Spend: $0. ↩

  5. Capture of 2026-09-23 in a logged-in browser: 9 screens of HTML and CSS, plus sync logs. The real-side DOM signatures and PNGs are re-renders of that HTML at 1440x900 with scripts stripped. ↩ ↩2

  6. Bench behaviour: a deprecated API version returns Meta’s (#2635) error; the versionless path serves the oldest supported version with a facebook-api-version header and pre-ODAX spellings; paging.next carries the access token; the default page size is 25; 18 campaigns are seeded. ↩ ↩2 ↩3

  7. Built from public API references with fictional data. Unit and integration tests (pytest), measured 2026-10-06:

    • spreadsheet: 137 passed, 5 skipped (client not built).
    • calendar: 87 passed, 8 skipped (client not built).
    • shopadmin: 91 passed, 2 failed, 1 skipped. Both failures are 503s because the client build was absent in the test environment, so they are environmental.
    • helpdesk: 63 passed, 9 skipped (browser binary missing).
    ↩ ↩2 ↩3 ↩4
  8. 5 personas and 7 faults. Our own documentation calls the result “e2e-mock evidence. It never proves Google’s hosted consent”. ↩ ↩2

  9. Measured 2026-10-06. Flat image in the real screenshot’s modal colour (white on all 7 screens). On the inbox it scored 97.6% tol16, against the clone’s 93.2%. The mean over 7 screens was 78.0% exact and 90.4% tol16. ↩

  10. Settings: background is the real image’s modal colour, the ink threshold is 16 per channel, the agreement tolerance is 16, and the shift search runs over 0–80 px vertically. ↩

  11. As 14. The 2026-10-06 clone scored 12.5% ink agreement and 17.3% with the best shift. Best shifts were 23–64 px. ↩

  12. Contract test suite, measured 2026-10-06: 16 pass, 0 fail. ↩

  13. Measured 2026-10-06. Clone captured headless at 1440x900, with a request guard that aborted anything not local; 0 requests were blocked. ↩

  14. Measured 2026-10-06. Per screen, ink agreement for the 2026-10-06 clone was 2.0–2.3% on six screens and 74.5% on task detail. ↩ ↩2

  15. From our internal engineering notes, re-checked against the code. ↩ ↩2

  16. Zhou et al., “WebArena”, https://arxiv.org/abs/2307.13854 ↩

  17. WebArena-Verified, https://github.com/ServiceNow/webarena-verified ↩

  18. “REAL”, https://arxiv.org/abs/2504.11543 and https://github.com/agi-inc/REAL ↩

  19. Xie et al., “OSWorld”, https://arxiv.org/abs/2404.07972 ↩

  20. OpenAI, “Computer-Using Agent”, https://openai.com/index/computer-using-agent/ (OSWorld 38.1%, WebArena 58.1%, WebVoyager 87%). ↩

  21. https://www.anthropic.com/news/3-5-models-and-computer-use and https://www.anthropic.com/news/claude-sonnet-4-5 ↩

  22. Deng et al., “Mind2Web”, https://arxiv.org/abs/2306.06070 ↩

  23. Pan et al., “WebCanvas” (Mind2Web-Live), https://arxiv.org/abs/2406.12373 ↩

  24. He et al., “WebVoyager”, https://arxiv.org/abs/2401.13919 ↩

  25. Drouin et al., “WorkArena”, https://arxiv.org/abs/2403.07718 ↩