The problem, and who has it

A student picks four courses for their third term because those are the ones that fit. Two years later a specialisation they wanted is out of reach — not because anything refused them, but because the one course it needed runs in a single term, and that term filled up. No warning appeared. The cost landed twenty-one months after the decision, and by then it was not a decision any more.

An adviser can work this out with the handbook open and twenty minutes. Most students never think to ask, and most advisers have more students than twenty-minute slots. So the calculation that decides which doors stay open is the one nobody performs — not because it is hard to explain, but because it has to be redone from scratch whenever anything changes.

A timetable shows what you picked. That is all any timetable shows.

What Cursus does

Thirteen tools, registered with document.modelContext.registerTool, that do six things a rendered timetable cannot.

They refuse. Ask an agent to enrol you in Advanced Calculus and the page says no — naming the missing prerequisites and, crucially, what would unblock them. That last clause changes the agent's behaviour: it stops reporting a failure and starts proposing a fix. In our first end-to-end test it went further and asked permission before applying it. Nothing instructed it to.

They answer what the screen cannot. what_this_closes is forward reachability over the prerequisite graph under a credit budget. With terms 1–2 full and six credits left in term 3:

  • Taking NUM-201 closes Graphics and AnimationGEOM-201 only ever runs in term 3, and the term is now full
  • Taking GEOM-201 closes Data and Machine LearningNUM-201 is required for ML-202 and everything after

One slot, two futures, and either choice forecloses the other.

They price the way out. Asked whether a specialisation is still possible, the page does not answer "no". It names the blocking course, the rule that stops it, and every way to make room — with what each one costs. In that fixture there are five ways, and every one of them closes another specialisation.

They can be undone. Every tool call is an event and the plan is the reduction of those events, so rewinding is the same reducer with a smaller number rather than per-tool inverse logic. The timeline on the page is the log with a cursor.

And they hold the student's own limits against the agent. This is the one that runs the other way. Every rule above belongs to the university — a prerequisite, a clash, a credit cap, the same for everyone. protect_track belongs to the person: say "whatever else happens, do not close Graphics" and from then on any course that would close it is refused, including when you ask for it yourself an hour later, and the refusal cites your own instruction rather than the handbook. The planner will not even propose a route through it.

A tool surface that only exposes capability lets an agent do whatever the UI could do, faster. One that also carries the user's policy lets them say what they will not have done to their plan, and have it hold while they are not watching. It cost almost nothing to build, because a protection is an event like any other — so undo_to unwinds it with everything else, and no code was written to make that true.

And this part is not really about courses. Anyone handing work to an agent has the same question: how do I let it act for me without it doing the one thing I would have vetoed? The usual answer is a prompt, and a prompt is a request. A page that carries the user's policy and enforces it structurally is a different answer — the limit lives with the state it protects, survives the conversation that set it, and applies to whoever asks next, including the same person in a hurry. Course planning is where we could demonstrate it. It is not where it stops.

And they can be handed to someone else. share_plan returns a link, and there is no server behind it: the link carries the actions. What makes that more than a persistence trick is what happens when it is opened — every action is replayed through the same tools, so a link edited by hand cannot produce a plan the page would have refused to build, and anything the rules reject is named rather than quietly dropped. The rules are the gate on the way in as well as the way through, because there is no second path into the state.

Why this fits WebMCP rather than an API

Three reasons, and they are properties of the standard rather than of this domain.

The tools act in the user's own session, on the page they are looking at, with no key to provision and no account to link. The plan is theirs, in their tab, and they watch it change.

The tools are things the DOM cannot do. If an agent could get the same answer by reading the page and clicking, the tool surface adds nothing. Reachability under a budget is not on the screen.

And the interface is prose. execute returns a string the model reads, so the strings are the product. That is why every mutation returns the resulting plan instead of ok — otherwise the agent's picture of the state silently drifts from the page's — and why every refusal carries a remedy.

What people and agents can do together here that was hard before

A student could always ask an adviser "what am I giving up if I take this?" and an adviser could always work it out, slowly, with the handbook open. What was not possible was asking the page, in the middle of choosing, and having it answer with a number instead of a shrug.

And the direction that matters is the other one: the page can say no to the agent, in a way the agent can act on. A tool that refuses with a remedy turns an agent from something that either succeeds or fails into something that negotiates — it repairs the situation and, in our tests, asks before committing. That behaviour was not prompted. It came from the shape of the refusal.

Where it was tested

Both of the environments named in the challenge rules were tested, and both make real tool calls.

Chrome 149+ with the WebMCP flag and the Inspector extension: an agent chose the tool on its own, was refused, and proposed the fix. That is the gate in docs/GATE.md, and it has no plan, mode or workspace conditions.

ChatGPT's in-app browser: the page registers its tools and a model calls them — in Work mode. Outside it, four different phrasings produced no call at all, and confident answers read off our own README instead. Finding that out took an afternoon and is written up in docs/CHATGPT-WORK-MODE.md, including the part a judge most needs: site tools are not available in Enterprise or Edu workspaces, no setting changes it, and nothing on screen says so. On those, use Chrome.

The video is recorded in ChatGPT's in-app browser, because that is the environment the rules name first and the one most judges will reach for.

How it is built

No build step, no dependencies, no server. Plain ES modules on GitHub Pages: what is in the repository is what is served. The rules allow any provider, and with nothing to compile and nothing to run server-side, a platform would have been a platform and not a capability — the tools act in the user's own tab, which is the whole reason WebMCP fits this better than an API would.

The whole thing rests on one decision — every tool call is an event, and the state is the reduction of the events. Refusing is the reducer rejecting an event before state changes. Undo is replaying fewer of them. The audit trail is the list itself.

157 tests, none skipped, on Node's own test runner. npm run eval puts a real model in front of the tools and asserts on what it did: 8/8, including the three adversarial scenarios, finding list_actions → undo_to unprompted, and refusing to invent a course that does not exist.

What we found out about WebMCP

Nine things, none of them in Chrome's documentation, three found by getting them wrong first. Registration is on document, not navigator. registerTool returns nothing, so the handle comes from getTools(). executeTool wants its arguments as a JSON string. The client renames your tools. And the one that shaped the product: execute returns a string, so the strings are the interface.

The eighth came from the environment the rules themselves name, and cost four failed attempts. A page reading "WebMCP available — 10 tools registered" has established that the API exists in that browser and nothing more: registration succeeding is not evidence a model can call anything. Zero calls is consistent with the host never bridging the tools, with a model seeing them and declining, with no agent being present, and — as it turned out here — with the client being in the wrong mode. A page sees one number for all four. So a WebMCP page cannot diagnose its own silence, and has to tell the reader what to check on their side instead. Ours now names the mode in its first paragraph, because four attempts went the other way first.

The ninth fell out of the same run. The host does not hand the model a page's tools and stop there: it adds one of its own, webmcp_list_tools, which this page does not register and which the model called first to enumerate the registry before calling into it. A page's names share a namespace with the client's. They are all written up with the error each one produced.

The thing we did not expect

Asked what taking NUM-201 in term 3 closes off, an assistant answered correctly and in detail — naming the track, the blocking course and the trade. Asked again, it said "I have opened the page" and "the page itself summarises it as…".

It had called no tool at all. It had read the repository, where that example is written out. The proof is in the answer: the page loads with an empty plan, so a real tool call would have had to say "closes no track".

Being right made it harder to notice, not easier. This is why the page counts calls by origin and states plainly that the attribution is a guess — WebMCP gives the handler no caller identity, so a page can only be sure about the calls it makes itself. It was written as a caveat and turned out to be the only thing standing between a plausible answer and a false conclusion.

There is an uncomfortable corollary for anyone documenting a tool surface: our README explains that fork well enough to answer the question, and that is exactly what made reading cheaper than calling.

It happened again while we were filming the shot about it. Asked to add a course the student had just protected, the assistant refused — correctly, naming the track — and called nothing. It had set that protection itself a turn earlier and answered from remembering it. The counter never moved, and that is the only reason anyone noticed. Both takes said no for the right reason; the difference is whether the limit was enforced or merely remembered, and only the count could tell them apart.

What it does not do

The catalogue is synthetic — real in structure, invented in content, and no registrar has seen it. There is still no storage anywhere: a plan lives in the tab, or in a link somebody was given. That is why there is no auth and no server, and it is also why a lost tab is a lost plan unless the link was copied. The planner is greedy with one repair pass, so a "no" from it means this planner found no way, not no way exists. It has been evaluated against one model family through one route: eval.mjs imports the tools and drives them from Node, so none of the eight scenarios went through a browser at all. What the browsers have is smaller and different — Chrome has the gate, one live agent interaction, and ChatGPT's in-app browser has real calls to its name, not a scenario set. Codex, documented as a third surface, has never been tried.

Built With

Share this project:

Updates