Inspiration

Every time I asked an AI to design a system, I got a picture.

It would produce a tidy Mermaid diagram, tell me the design "scales well," and move on. But the diagram was a dead artifact the moment it appeared. If I changed my mind, the conversation and the diagram drifted apart. And the model had no way to check its own claim, it asserted that the architecture held up, because asserting is all a language model can do about a system it cannot run.

WebMCP changes what is possible, because the page stops being a canvas the model draws on and becomes a participant. The page can hold the state. The page can do arithmetic the model cannot fake. And critically, I can reach into that same state with a mouse while the agent is working.

So I built the thing I actually wanted: a whiteboard that pushes back. The agent proposes an architecture, the browser computes whether it survives contact with load, and when I break something by hand, the agent has to notice.

What it does

NexusCanvas is a system-architecture canvas that ChatGPT operates directly through 14 base WebMCP tools plus 3 contextual ones. It runs entirely client-side, meaning no backend, no API keys, no account.

The agent can build a whole system in one apply_blueprint call, audit it, model load against it, inject outages, and compile it to Docker Compose. But the point is not that the agent draws. The point is what the page does back:

  • It computes what the model cannot fake. An audit engine finds single points of failure, missing caches, insecure protocols and orphaned queues, and returns a letter grade. Two independent load engines report utilisation, error rate and latency percentiles. A Compose generator emits real YAML from the graph.
  • It reads my edits. I take Redis offline by hand in the Inspector. The agent calls get_architecture, sees the outage nobody told it about, reruns the same load, and reports that Postgres went from 36% to 125% utilisation as reads that were being absorbed by the cache fall through to the database.
  • It asks before destroying anything. The host I tested never prompts on destructiveHint, so the page prompts instead, the tool call blocks on a real modal and the agent waits for my click. Clicking Cancel returns a declined result the agent reports honestly.

The demo arc is a design review: the agent builds a 17-component e-commerce system, the audit grades it D (51), we fix it together to A (94), and then I break the cache to prove the improvement was real.

How I built it

Static React 18 + TypeScript, React Flow for the canvas, Zustand for shared state, Dagre for layout. Deterministic TypeScript engines for everything that produces a number. Deployed as static files.

I wrote the specification before the code, a blueprint, numbered requirements with acceptance criteria, and a tool contract document fixing every tool's description, JSON Schema, annotations and example result. Tool descriptions were then copied into the implementation verbatim, because a tool description is a prompt: it is the only thing the model on the other side reads before deciding whether to call you.

The closed-form capacity model walks the graph in topological order, excluding back-edges. For each component,

$$C_i = \text{capacityRps}_i \times \text{replicas}_i \times s_i, \qquad s_i = \begin{cases} 0.5 & \text{degraded} \ 1 & \text{otherwise} \end{cases}$$

$$u_i = \frac{\lambda_i}{C_i}, \qquad \text{served}_i = \min(\lambda_i, C_i), \qquad \text{dropped}_i = \lambda_i - \text{served}_i$$

Routers split their served traffic by normalised edge weight; services fan out $\text{served}i \times w{ij}$ per edge, so a weight of $0.2$ reads as a 20% cache-miss rate. Latency stretches as a component saturates, capped so the number stays finite:

$$\ell_i(u) = \begin{cases} \ell_i & u < 0.5 \[4pt] \dfrac{\ell_i}{1 - \min(u,\,0.9)} & u \geq 0.5 \end{cases}$$

The discrete-event simulator exists because the closed-form model cannot show cascades. It runs a simulated clock over a binary min-heap event queue with Poisson arrivals, exponential interarrival times drawn as $\Delta t = -\ln(U)/\lambda$ , plus per-component concurrency limits, bounded queues, hop timeouts, bounded retries, and upstream workers held while awaiting downstream calls, so connection-pool exhaustion emerges instead of being asserted. It reports true $p_{50}$, $p_{95}$ and $p_{99}$ from completed-request samples.

Randomness is seeded by an FNV-1a hash of the graph and scenario, driving a mulberry32 generator threaded explicitly through the run. Same graph, same seed, bit-identical output, which is what makes it testable, and what makes the agent's claims reproducible.

57 tests cover the engines, tool input validation, the WebMCP adapter against host stand-ins, and the demo sequence.

Challenges we ran into

I did not know what ChatGPT's browser actually did. So before writing a line of app code, I shipped a probe page registering seven deliberately awkward tools and ran it inside the ChatGPT desktop app. It answered questions no specification could: the host exposes document.modelContext but not navigator.modelContext, it accepts content-array results, it picks up tools registered seconds after load, and it waits on a pending promise. That one hour saved the project.

The host has no unregisterTool. Calling it throws a TypeError. My whole contextual-tool design assumed tools could appear and disappear with UI state. Rather than pretend, the adapter keeps one stable dispatcher per registration and marks it dormant when its context is gone, excluded from active counts and from get_architecture, and returning a guarded {ok: false, error, hint} if the retained callback is invoked anyway. The Tools panel shows dormant rows honestly instead of claiming a removal that did not happen.

My audit was confidently wrong. The first run graded the reference architecture F, flagging the load balancer, API gateway and message queue as single points of failure, all of which ran multiple replicas. A structural chokepoint is not a SPOF if it is replicated. Adding a replicas == 1 precondition took one line and turned an audit a judge would distrust into one that survives scrutiny.

Killing the cache made the database less busy. The model routed reads catalog → redis → postgres at a 20% miss rate, so marking Redis down removed 80% of the database's load, the exact opposite of what a cache outage does. The fix is a rule the spec had never stated: a downed cache drops nothing and forwards 100% of its inbound traffic downstream. Postgres went from 110% to 217%, and the demo's most important beat became true.

Accomplishments that we're proud of

The page disagrees with the model, and the page is right. That is the whole thesis, and it holds: the agent cannot talk its way past an audit grade or a utilisation figure it did not compute.

Two independent engines, a one-pass closed-form model and a seeded discrete-event simulator, reach compatible conclusions about the same graph from completely different mathematics.

And the honesty of the submission itself. Where the host's behaviour is unverified, the documentation says so rather than rounding up. Where the engines are comparisons under stated assumptions rather than predictions of production, the UI says that too.

What we learned

A tool description is a prompt. Adding six words to add_component , noting that stateful types default to one replica, was the difference between the agent adding a redundant cache and adding a new single point of failure on camera.

Tool granularity decides whether the demo is watchable. Fine-grained tools mean forty round-trips to draw one system. One coarse apply_blueprint call draws it in a single beat.

Registration lifecycle is a product concern, not plumbing. Tools that linger after their UI is gone are tools the agent will call when they cannot work.

Determinism is a feature, not an implementation detail. It is what lets a person re-run what the agent claimed and get the same answer.

A cost estimator, and it is the feature I most want. Right now the page tells you whether a design survives load. It should also tell you what that design costs, because in practice the argument about an architecture is never purely about correctness. It is about whether three database replicas are worth the money.

The mechanism already fits. Every component on the canvas carries replicas, capacity and traffic, which is most of what a bill is made of. Mapping component types to published list prices gives a monthly estimate that recomputes on every edit:

$$C_{\text{month}} = \underbrace{\sum_{i} r_i \, p_i \, h}{\text{compute}} \;+\; \underbrace{\sum{i} s_i \, q_i}{\text{storage}} \;+\; \underbrace{\sum{(i,j) \in E} \lambda_{ij} \, \bar{b} \, g_{ij}}_{\text{egress}}$$

with $r_i$ replicas at hourly price $p_i$ over $h = 730$ hours, $s_i$ provisioned GB at rate $q_i$, and edge throughput $\lambda_{ij}$ carrying mean payload $\bar{b}$ at egress rate $g_{ij}$.

That turns every existing interaction into a trade-off instead of a verdict. The audit says "your database is a single point of failure"; the cost line says the fix is +\$140 a month. You run 50,000 users and see that the design holds, and that holding costs \$2,300. The agent gets a estimate_cost tool and has to defend its proposals on both axes, against numbers it did not choose. Same principle as everything else here: the page computes it, so the model cannot hand-wave it.

The honesty rules would apply unchanged. Published list prices, stated region, no reserved-instance or committed-use discounts, no free tiers, a comparison between two designs you are considering, not a quote for the one you ship.

After that: Kubernetes manifests alongside Docker Compose, shareable architectures encoded in the URL, a broader audit ruleset covering retry storms, missing timeouts and unbounded queues, and importing real service metrics so both the capacity and the cost numbers start from measured baselines instead of catalogue defaults.

Built With

Share this project:

Updates

Submission history