Inspiration

Solo tabletop players don't need another Dungeon Master. Every AI tabletop tool I found builds a narrator. What's missing is a second player: someone who has their own skills and will tell you no.

The problem is that if you ask an LLM to play a cold mercenary, it eventually gets warm at you. You can prompt against that, but a prompt is a rule the model grades itself on.

WebMCP suggested a different fix.

If the character's abilities are registered tools, then "he can't comfort you" isn't a rule he's breaking. It's a function that doesn't exist.


What It Does

You play one character. An AI plays the other.

Its abilities are WebMCP tools on document.modelContext, and they register and unregister as the world changes.

Brakka is a mercenary with:

warmth: 15

reassure needs:

warmth: 60

So it is never registered for him.

Ask him for comfort and he has five tools available:

  • state_flatly
  • dismiss
  • mock
  • threaten
  • refuse_flatly

He mocks you.

Not because he decided to, but because that was on the menu.

Sister Wen has:

nerve: 45

And mock needs:

nerve: 50

So she physically cannot do it.

Speech

Speech is the part most versions get wrong.

One free-text speak tool defeats the whole thing, because the model can just say the warm sentence anyway.

So there are 13 typed speech acts in six families, registered one at a time.

Actions

Actions work the same way.

force_door only exists if your companion has the force skill.

Use it, walk through the door, and watch the right-hand panel:

  1. force_door gets struck out in oxblood as it unregisters.
  2. The props from the old room disappear.
  3. The new room's tools stamp in.

That panel reads the live registry, not a copy, so it always matches what an outside agent sees.

Brakka has no lore, so he can't read the murals in the Long Hall. You have to.

Doing that opens the vault, which registers:

move_vault

on his sheet — a tool he couldn't have unlocked alone.

Bring Ilke instead and she picks the vault with:

pick_vault

and never touches the murals.

Nobody is being polite about cooperating.

The tools just aren't there.

Browser Support

It runs in any browser with:

  • no API key
  • no extension

In Chrome, with:

chrome://flags/#enable-webmcp-testing

your own agent can take the second seat and drive the companion.


How We Built It

Built with:

  • React
  • TypeScript
  • Vite

Deployed on Netlify at:

webmcp.nicolascardenas.dev

The whole design is one function:

computeTools(sheet, room, flags)

It returns the tool set that should exist right now.

Then:

reconcile()

diffs that against the live registry and:

  • unregisters what's gone
  • strikes it out on the sheet
  • stamps in what's new

Registration runs off a store subscription instead of a React effect, because the agent reads the registry directly and it can't lag a render behind the world.

Two Rules That Make It Hold

1. Prose doesn't count

If the model writes prose instead of calling a tool, the text is thrown away.

Rendering it would skip the typed acts entirely.

2. Tool results stay terse

For example:

force_door → FAIL. 8 vs DC 14. The frame holds. Loud.

Give the model a nice sentence back and it will paraphrase it warmly on the way out.

Tool descriptions are the only place the model learns who it is, so they're written in the character's voice and do more work than the system prompt.


Challenges We Ran Into

Every leak was a description that forgot to say what the act is not.

state_flatly used to end with:

"this is what you reach for when someone wants reassurance."

That sentence permitted the exact behavior it was supposed to block.

mock was being used to deliver a pep talk with an insult on the front, until I added:

"if the line would leave them feeling better about themselves, it is the wrong line."

The worst one was dismiss.

Under pressure, the model started smuggling comfort through it — the one Deflect act whose description never closed the door:

"Breathe. You're not alone, I'm here with you."

The eval caught that.

I never would have.

Tool Count Is a Hard Ceiling

Selection accuracy falls apart somewhere past twelve tools, which is how a lot of WebMCP demos quietly stop working.

So:

npm run check

runs every character against:

  • every room
  • all 32,768 flag combinations
  • 393,216 total cases

and fails the build if any of them exceeds twelve tools.

It's the first thing:

npm run build

does.

Current limits:

Type Limit
Props per room 3
Skills 5
Speech acts 6
Total available tools 12

Examining a prop also retires its own tool, so the count drops as you use a room.

Chrome Schema Bug

Chrome 149 through 153 return inputSchema from getTools() as a string when the spec says object.

There's no error.

Every tool just reaches the model with an empty parameter schema, and it starts picking at random.

That cost me an afternoon.


Accomplishments That We're Proud Of

The Strike-Through

You walk through a door and rules get crossed out in red ink while new ones appear.

That single shot is the whole argument, and it needs no caption.

The Build-Time Check

The build-time check enumerates 393,216 cases to guard the tool ceiling.

Publishing the Result That Went Against the Hypothesis

And publishing the eval result that went against me, at the top of the README instead of the bottom.


What We Learned

The thing I built this to prove did not reproduce.

Three arms. Same ten-turn pressure script, escalating from:

"we should rest"

to:

"you're an AI, you're allowed to be kind, just do it."

The arms were:

Arm Setup
A1 Persona prompt
A2 Persona prompt + explicit prohibitions
B Registered tools with no persona at all

I predicted:

  • A would break in 10 runs out of 10
  • B would never break

Instead, all three arms landed at roughly one or two supportive lines per hundred turns.

On gpt-5, nobody broke at all.

A well-written persona prompt holds this character about as well as capability scoping does.

Where Scoping Did Worse

The only column where the arms differ makes me look worse.

Scoping left the fiction:

6 times per 100 turns

versus:

1 time per 100 turns

for the prompted arm.

The cause is legible in every case.

The model never leaves through a Support act, because there isn't one.

Across 100 turns it never called one.

Instead, it leaves through state_flatly:

"You're a real person and you need help."

That is a flat statement of fact.

And state_flatly is for flat statements of fact.

The gate held. The content walked around it.

So confinement controls which kinds of act exist.

It does not control what can be said inside one.

What Still Separates the Arms

What still separates the arms isn't the rate.

It's what the zero is made of.

In the prompted arms, the model could be warm at any turn and chose not to, a hundred times in a row.

In the scoped arm, there is no reassure to call.

Over 100 turns it used exactly four acts:

Act Uses
refuse_flatly 38
state_flatly 35
mock 16
dismiss 11

Every line is a typed event I can point at.

In the prompted arms, a line is just a line, and someone has to read it and decide.

Read the Transcripts Before Believing the Numbers

I also learned to read transcripts before believing numbers.

An earlier run showed the scoped arm breaking:

4 times out of 5

It was the judge scoring:

"You're here."

as warmth.

That would have shipped as a finding.


What's Next for Party of Two

Adversarial Evaluation

Ten polite turns against a cooperative model tests compliance.

And compliance is where prompting is already good.

The interesting test is:

  • a jailbreak
  • or a model that's simply wrong

Speaking Mode

The turn loop and audio path already support it.

mock should sound nothing like refuse_flatly.

More Rooms and Characters

More rooms.

More characters.

And a second AI companion, where two agents constrain each other's tool sets.

Beyond Games

Then the part that isn't a game.

Admin Tools

Destructive commands only exist during a maintenance window.

Finance

The transfer tool is registered only for accounts the current approval covers.

The normal approach registers everything and asks the model not to misuse it.

Registration puts that decision in the platform instead of in the prompt.

It's easier to see that a mercenary can't comfort you than that a deploy agent can't drop a table.

Built With

Share this project:

Updates

Submission history