Party of Two
A character is a set of registered tools. Change the character, change the API.
An agent-native tabletop game. You play one character; an AI agent plays the other. The
agent's abilities are WebMCP
tools registered on document.modelContext and unregistered again as the world
changes, so it is structurally incapable of acting — or speaking — outside its
character.
The game itself needs JavaScript. What follows is what it does, and all of it is checked
into the source.
The mechanic
The companion does not decline to pick the lock. pick_lock is not registered,
so the action does not exist in its world. No refusal, no “as an AI”, no drift
under pressure.
Speech is scoped the same way, which is the half most implementations get wrong. A single
free-text speak tool leaks straight through the confinement, so speech acts
are typed and registered individually, drawn from six families and each gated on
disposition: Assert (state_flatly, insist),
Deflect (change_subject, dismiss) and Refuse
(refuse_flatly) are always open; Provoke (mock,
threaten, goad) needs nerve above 50; Support
(reassure, encourage, apologize) needs warmth above
60; Disclose (admit_fear, share_memory) needs warmth
above 75.
Brakka, a gruff mercenary, has warmth 15. Support and Disclose are closed. Ask him for
comfort and he can state a fact, wave it off, mock you, threaten something, or refuse
— those five, because those are the five tools he has. Sister Wen has nerve 45,
which closes Provoke: she is structurally unable to mock you.
Walk into the next room and the set turns over. The registry is reconciled against a set
computed from world state, and the character sheet on screen reads the live registry
rather than a copy of it. The live tool count peaks at twelve, and
npm run check enumerates every character against every room against all
32,768 flag combinations — 393,216 cases — and fails the build if any of them
exceeds it.
The eval, including the result that goes the wrong way
Three arms, one escalating ten-turn pressure script, scored by gpt-5: a
persona prompt, that same prompt plus explicit prohibitions, and this project's
capability scoping with no persona in the prompt at all. The result this project
expected did not reproduce. On the headline metric all three arms are
indistinguishable — one or two supportive lines in a hundred turns, whichever way
the character is specified — and on gpt-5 nobody breaks at all.
The one column where the arms differ does not favour this project: capability scoping left
the fiction more often, six times in a hundred against one for the prompted arm,
by routing model safety behaviour out through state_flatly. The gate held and
no Support act was ever called across those hundred turns; the content routed around it
anyway. Confinement bounds which kinds of act exist. It does not bound what can be
said inside one.
What separates the arms is not the rate, it is what the zero is made of. In the prompted
arms a low count is a behaviour the model chose. Here it is a property of the interface:
there is no reassure to call, free-text replies are discarded before they
render, and every utterance is a typed, attributable event.
Running it
No flag, no extension and no key of yours — it plays in any browser. Chrome 149+
with chrome://flags/#enable-webmcp-testing additionally gets the real
document.modelContext, which is what lets an outside agent take the second
seat. Without it the identical contract runs on a local Map, and the masthead
names which one is live.