What it is
A four-arm signalised junction that a human and an agent operate together. The traffic simulation runs entirely in the tab — no backend, no API, nothing meaningful to scrape off a canvas. The state exists only in the page.
That is the point. A conventional browser agent could screenshot this and learn nothing: it cannot read mean_wait_s out of pixels, and evaluating a candidate timing would cost it a slider drag, a button click and a screenshot round trip. It would manage perhaps five experiments while an agent with site tools runs two hundred. Not faster — categorically different.
The junction exposes seven WebMCP tools. An agent arrives cold, reads the model specification, searches the timing space off the main thread while the traffic keeps flowing on screen, and hands back a scrubbable explanation of what it found. The operator watches, locks parameters mid-search, and decides.
The design argument
The interesting part is not the simulation. It is the shape of the tool surface, and three decisions in particular.
run_batch reports physics. apply_config enforces policy.
run_batch evaluates any timing, including ones that are illegal to deploy, and reports honestly what would happen. It checks nothing. apply_config is the only tool that can commit a timing to the junction, and the only one that enforces the constraints.
Keeping those separate means the agent can discover that the fastest timing is inadmissible rather than being silently steered away from it. Under school-run demand the best-scoring timing is a permissive-left plan at 27.4 s mean wait; it is prohibited, because opposing volume × left-turn volume exceeds the phasing threshold. The best legal timing costs 45.8 s. An agent that only ever saw filtered results would never understand what admissibility cost.
The refusal says so explicitly: a refusal is a fact about what may be deployed, not a scoring signal — a refused timing may well be the fastest one.
Constraints ship with their derivations, and it changed agent behaviour
describe_model publishes every constraint with the arithmetic behind it. Yellow and all-red are not tuning parameters; they are safety intervals derived from the ITE change-interval formulae:
approach speed v = 15 m/s (54 km/h) yellow y = t + v/(2a) → 3.50 s → floor 4 s all-red r = (W + L)/v → 1.33 s → floor 2 s
Two measured effects on a real agent run:
Unprompted, it used 5 seeds and 60-minute runs. run_batch's description warns about ±2 s of simulation noise and recommends averaging at least 3 seeds; the agent went further on its own. It never hit the phasing refusal. Its 50-config refine batch was entirely left_protected: true — it read the cross-product rule, worked out that school-run demand mandates protected phasing, and pre-filtered its search space.
Publishing constraints in full meant a careful agent self-checked instead of learning by rejection.
Locks are a permission boundary with no unlock tool
The operator can pin any parameter. read_state reports which are locked; apply_config refuses to change one and says so. There is deliberately no tool that unlocks a parameter. The agent can read the lock, reason about it, and re-optimise around it — only a human clicking the button releases it.
A permission model expressed through the shape of the surface rather than a confirmation dialog.
Tools Tool Kind Purpose describe_model read Parameters, metrics, constraints with derivations, scenarios read_state read Committed timing, phase, per-arm queues, locks, live metrics run_batch read Evaluate ≤50 timings in a Web Worker apply_config write Commit; enforces constraints and locks set_scenario write Switch demand profile; re-checks the committed plan pin_result write Surface a candidate to the operator with a rationale play_sequence write Narrated, scrubbable explanation
Other choices worth naming:
Tool results are budgeted. run_batch returns full per-arm detail for the top 3 rows and summary metrics for the rest, and does not duplicate the payload into structuredContent. Measured on a 36-config batch: 53,558 → 11,284 characters, a 79% reduction. Context bloat is what degrades an agent over a long session. run_batch runs in a Web Worker. A 50-config batch is ~500 ms; on the main thread that freezes the junction every time the agent thinks, and the premise is that the human watches a live workspace while the agent works. apply_config restarts the live run and says so, along with the ~4 simulated minutes before metrics settle. Otherwise the agent commits, re-reads two seconds later, and reasons about a transient. set_scenario re-checks the committed timing. Switching demand can make the deployed plan illegal. It reports that and leaves the plan committed — the agent asked to change the demand, not to redeploy the signal plan. pin_result re-measures over 3 seeds regardless of what produced the original figure, so two pins are always comparable. The agent's search is drawn in the domain's own notation. Evaluated candidates appear as miniature ring-and-barrier phase diagrams — the diagram a traffic engineer actually reads — with bar width encoding cycle length. A scatter plot would show convergence more precisely and say nothing about what was tried. Verification
npm run agentsim mocks document.modelContext, registers the real tool surface, and drives the exact sequence a competent agent follows: cold discovery, coarse sweep, the constraint refusal, recovery inside the legal region, a lock refusal, malformed input, two pins, a scenario round-trip, a narrated replay, and AbortSignal cleanup. It runs in about four seconds and caught several schema bugs before any browser was involved.
The simulation itself was validated before any UI existed: metric spread of 162 s against 1.6 s of seed noise, an interior optimum in cycle length that moves with demand (57 s under morning rush, 87 s under event egress), and four scenarios with four distinct optima.
Notes on the model
A stochastic cellular automaton (Nagel–Schreckenberg). Arrival rates are representative of a mid-size German town, not measured. Two modelling details matter for the decisions the agent faces:
Opposing traffic is platooned. Gap acceptance uses the opposing flow concentrated into its green window, not the hourly average — the platoon is released by the same green in which a permissive left-turner is trying to cross. Without this, permissive lefts are far too easy and left_protected becomes a parameter with one always-correct answer. The left-turn bay is 60 m. When it fills, left-turners spill back and block the through lane behind them. That is why protected phasing is ever worth its cycle time, and why sizing the protected phase wrong is worse than not having one: at school-run demand, protected at 8 s scores 214 s against permissive's 74 s, while protected at 16 s is the best timing available. Running it bash npm install npm run agentsim # headless tool-surface harness npm run dev # then open in a WebMCP-capable browser
Node 22+. WebMCP is secure-context only; localhost qualifies.
Try: "Optimise this junction for mean wait under the school run scenario, then pin the two options you think I should choose between."
Built With
- canvas
- react
- typescript
- vite
- web-workers
- webmcp
Log in or sign up for Devpost to join the conversation.