Frontend-orchestrated AI agents — one function call instead of many
Every subsequent tool call re-reads all prior results. Cost compounds.
searchAvailability
createReservation
getReservation
book({ date, time, partySize, name })
| Approach | Stability | Token cost | Agent burden | HTTP calls / action |
|---|---|---|---|---|
| Browser automation (Playwright) | Breaks on UI change | Very high (DOM) | Must map UI → intent | Many |
| Raw MCP tool calls | Stable | High — 3+ / action | Knows domain logic | 3+ |
| AgentBridge / WebMCP | Stable — API contract | Low — 1 call | Calls one function | 1 |
All 47 operations are reachable over MCP Streamable HTTP (server) via progressive disclosure. The browser deliberately exposes a smaller surface — composite tools only (book, seatGuest) — so agent-facing UI stays a curated, high-leverage set rather than a mirror of the full registry.
explore — navigate module treesearch — glob over op pathsdescribe_tool — get input schemainvoke — call any op directlyload_tools — promote to nativeunload_toolsgetCapabilities — version hashgetContext — current session/roleexplore("reservation.*") → subtreeload_tools(["book"]) → session statebook(…) as a native toolTool state persists per-token, 24h TTL. Agent sees only what it loaded.
invoke("book", {…})One-off calls without session overhead. Reads dispatched concurrently; writes sequentially.
For 50 ops: dump all = ~5 000 tokens; progressive invoke stays flat at ~200.
Key finding: book-op MCP (server-side in-process dispatch) is 3× faster than WebMCP because internal sub-calls skip HTTP hops. WebMCP still saves 85.7% of tokens vs. the baseline.
Figures above from benchmark.mjs (Node-side simulation over /api/call). The 98 ms WebMCP figure was estimated, not measured in a browser — see the next slide for the real-transport numbers.
book(), measured live
scripts/bench.mjs replaces the estimate on the previous slide with numbers from the real MCP Streamable HTTP transport and a real headless browser — no simulation. Booking flow, 5 runs/lane, production build.
| Lane | Transport | Calls | Avg wall-clock | Tool-schema tax | Agent input | Agent output |
|---|---|---|---|---|---|---|
| 3-call MCP (load + native) | Real MCP HTTP | 3 | 47 ms | 80 tok | 62 tok | 304 tok |
| 3-call MCP (invoke, no load) | Real MCP HTTP | 3 | 33 ms | 0 tok | 58 tok | 360 tok |
| book() composite | Real MCP HTTP | 1 | 14 ms | 41 tok | 26 tok | 65 tok |
| book() composite | Real browser (WebMCP) | 1 | 26 ms | — | 19 tok | 56 tok |
| Playwright UI automation | Real browser (clicks) | 7 steps | 613 ms | see next slide — DOM re-reads dwarf every other lane | ||
"3-call MCP" isn't a single choice: raw MCP only ever exposes 8 always-on meta-tools, so an agent must either load_tools the 3 business ops first (schema tax, then 3 native calls) or call invoke three times with no load step. Both are measured above — neither is a strawman.
A function-call agent reads a JSON result. A browser-automation agent needs a fresh accessibility snapshot before every interaction — fill date, fill party size, click Search, click Book, fill name, click Confirm. That snapshot cost compounds across steps the same way the 3-call baseline's context did on slide 4.
~63× more tokens to observe the page than to call the function — and that's before counting the 7 separate tool calls (click, fill, click, fill, click…) an automation agent also has to make, each with its own round-trip.
seatGuest call.
A receptionist doesn't do one thing at a time: take a phone booking, then seat the walk-in already waiting. This flow drives both in one continuous browser session — no reload between them. Not "seat the guest just booked": book() and seatGuest() run on two disjoint stores with no shared id (a booking has no table concept; front-desk seating only works on the 3 seeded reservations). Two real, independent actions.
| Lane | Transport | Calls | Avg wall-clock | Tool-schema tax | Agent input | Agent output |
|---|---|---|---|---|---|---|
| 6-call MCP (load + native) | Real MCP HTTP | 6 | 95 ms | 132 tok | 106 tok | 684 tok |
| 6-call MCP (invoke, no load) | Real MCP HTTP | 6 | 88 ms | 0 tok | 98 tok | 800 tok |
| book() + seatGuest() — 2 composites | Real MCP HTTP | 2 | 29 ms | 57 tok | 41 tok | 113 tok |
| book() + seatGuest() — 2 composites | Real browser (WebMCP) | 2 | 62 ms | — | 26 tok | 100 tok |
| Playwright UI automation | Real browser (clicks) | 10 steps | 863 ms | 11,495 tok cumulative aria-snapshot — nearly 2× the single-flow booking form | ||
The composite lanes scale linearly with the number of real actions (1 call → 2 calls). The Playwright lane's DOM-read cost scales with the number of UI steps, which is why it's the metric that compounds fastest as scope grows.
Slide 4 made the cumulative-context case with 3 calls. Here's the same shape at 7 calls across two domains — CRM and front desk — hosting a VIP guest's visit: read their preferences, read their loyalty tier, confirm a table, seat them, validate, award points, log the visit.
getGuestPreferences → 2. getLoyaltyStatus → 3. getOccupancycheckInGuest → 5. getCheckinStatusaddLoyaltyPoints → 7. logCommunication| Lane | Transport | Calls | Avg wall-clock | Tool-schema tax | Agent input | Agent output |
|---|---|---|---|---|---|---|
| 7-call MCP (load + native) | Real MCP HTTP | 7 | 108 ms | 151 tok | 131 tok | 677 tok |
| 7-call MCP (invoke, no load) | Real MCP HTTP | 7 | 102 ms | 0 tok | 124 tok | 797 tok |
| hostVipGuest() composite | Real MCP HTTP | 1 | 20 ms | 43 tok | 33 tok | 225 tok |
| hostVipGuest() composite | Real browser (WebMCP) | 1 | 51 ms | — | 24 tok | 173 tok |
No Playwright row here — the CRM domain has no screens in this app, so there's nothing to click. That's the honest limit of the demo, not a hidden number: only 10 of 47 business operations have any UI at all today; the rest, including every CRM op used above, are agent-only.
A different audience, a different question: how long does it take to test the business logic itself? scripts/speedbase.mjs runs the same scenario two ways — a Playwright script driving the browser, and a script calling runOne() (lib/operations/dispatch.ts) directly, in-process, with zero HTTP and no MCP client at all. Same Zod validation, same RBAC, same handler code — no transport.
Bars scaled per-pair (book vs. book, seatGuest vs. seatGuest) — the absolute gap is 2–3 orders of magnitude, which would make the function-call bar invisible on a shared linear scale.
| Scenario | Function call | Playwright | Speedup |
|---|---|---|---|
| book() | 1.15 ms | 416 ms | 363× |
| seatGuest() | 0.43 ms | 488 ms | 1127× |
| hostVipGuest() | 0.27 ms | no UI exists — function-call only, same honesty rule as the CRM slide | |
book()/seatGuest() operations your E2E suite drives through a browser can be tested directly, in milliseconds, through the exact validation and RBAC path the browser path goes through.
Every op carries a validated schema (Zod), RBAC roles, a module path, and a result envelope.
Role is checked at register time and on every call. Destructive ops carry requiresConfirmation: true — UI shows a dialog; agent must pass confirm: true.
| Control | Implementation |
|---|---|
| Browser auth | Session cookie |
| MCP HTTP auth | RFC 8707 Bearer, 8h TTL |
| Input validation | Zod schema, every call |
| Audit log | Last 100 calls, SSE-streamed to UI |
| Capability versioning | DJB2 hash over op fingerprints |
explore → load_tools or invoke) to keep context budgets flat regardless of registry size.makeDispatch(ctx)) for the maximum latency win — 3× faster than HTTP sub-calls.