Spokes — Devpost submission
Live: https://spokes.alainschlesser.com/
Inspiration
Groups are bad at picking somewhere to go, and the reason is structural rather than social. The needs that decide the answer are scattered across people and arrive piecemeal: a dietary restriction here, a budget there, a veto that lands after everyone else has settled, and at least one reason nobody wants to say out loud. A search results page optimises a single query. A group chat collects messages. Neither keeps a shared model of what the group has actually asked for, and neither lets a sensitive constraint count toward the decision without being explained to everybody first.
That last part is what made this worth building for WebMCP specifically. A personal agent already knows things about its user that the user would rather not broadcast to five friends. Until now the only way to put that knowledge to work was to hand it to a shared service. I wanted to find out whether an agent could advocate for someone inside a live page, as that person and within an authority they granted, while the reason itself never left their client.
What it does
Three people open one planning room from a link. They see the same live map and the same brief of what the group has asked for so far.

Each person states what matters in a single composer bar and chooses who may see it before they speak. A need can be shared, private, or agent-only. The server evaluates a private need and peers see only its effect on the count. An agent-only need stays with your agent and never reaches the server at all. The app suggests the next thing worth asking about from the data itself, never from a built-in list of categories.

Every need becomes a row you can set aside. Press and hold a row and the map shows the room without that need, live. The cost of a need becomes visible instead of argued about. The room recomputes which places still work after every move, and it counts what is merely unverified separately from what actually failed. Missing data is drawn as its own state rather than quietly dropping a place.

When nothing satisfies everyone, Spokes does not name who is blocking. It privately offers each person a quantified way out. The organizer sees "widen the area from 800 m to 1.2 km, brings back 4 places". The person who stated a need sees "let this need be nice-to-have, +12 places". Consent is explicit and confirmed on the page. Once the group agrees, the composer gives way to arrival: a travel mode and a one-tap handoff to the map app everyone already has.

Alongside all of it, your personal ChatGPT can act in the same session through WebMCP. It can catch up on what changed while you were away, inspect places, put one forward, rule one out, or grant an adjustment. Your agent can assist you, or negotiate for you outright.
How I built it
A TypeScript monorepo (pnpm). Fastify and a Postgres event log sit behind a WebSocket realtime channel. The front end is React and Vite with MapLibre GL over keyless OpenFreeMap tiles. TypeBox contracts are shared by both sides behind a hashed contract-manifest gate. Place data is a one-time OpenStreetMap extract of Berlin Mitte.
The WebMCP surface is 22 tools registered imperatively on
document.modelContext at page load, 10 for negotiation and 12 spatial. Each
is a narrow function with closed enum schemas, readOnlyHint and
untrustedContentHint annotations, and a capped result budget. The page works
fully without WebMCP. The tools are an additional way in.

Four decisions did most of the work.
Two protocols over the WebMCP substrate. WebMCP itself only carries tool
names and schemas. So the first sync_session call returns a capability
manifest that teaches the agent a domain-independent negotiation protocol
(identity, revision sync, privacy tiers, requirements, stances, adjustments,
consent, agreement) and a spatial-destination domain layered on top.
Server-owned identity and per-viewer projections. Actor identity comes only from the bearer token. No tool argument accepts an actor id. Every event is stored once and projected per viewer, so unauthorised fields never appear in another participant's HTTP body or WebSocket frame. The test suite asserts this at the wire, not at the function boundary.
Revision discipline instead of pretend realtime. Every mutation carries the
agent's last-seen revision. A stale one returns a structured sync_required
with a delta instead of acting on old state. That fits an agent that participates
in bursts far better than a subscription it never really holds.
Consent an agent cannot forge. An agent may recommend a grant, but a change beyond what the user delegated only stages. Staging mints a single-use code delivered on the participant's own realtime channel. The applying command must carry it back, and the code never appears in any tool result.
Eligibility, minimal-conflict-set detection and the impasse counterfactuals are all deterministic. No model is allowed to invent a feasibility fact.
364 automated tests across three lanes: 222 unit, 128 three-user API, and 14 Playwright specs driving three isolated browser contexts.
Challenges I ran into
Privacy that is neither a lie nor a hole. The rule I settled on is that a private need's effect is always public and its content never leaves its owner. Both halves matter. Hiding the effect would make the count inexplicable, and leaking the content is the whole thing I was avoiding. It took a real redesign to stop routing private predicates through shared state "just for rendering", and a wire-level test suite to prove I had.

Proving a human consented, honestly. My first cut let a bearer token commit
an agreement over plain HTTP without ever touching the page. The realtime-only
nonce closes that. It still is not proof a human clicked, because the realtime
channel authenticates with the same token. Rather than overclaim, I wrote the
residual threat model down in KNOWN-LIMITATIONS.md.
Keeping the interface domain-agnostic. It is hard not to type a domain word into a heading. What saved it was a rule on the server side. The server returns the facets present across the current result set, each with a mandatory unknown count, and the client renders whatever it gets. There is no domain branch anywhere in the front end, which is also what lets a human and an agent reason over the same vocabulary.
Deployment, on the last evening. I had prepared a Coolify deployment and abandoned it at the gate. Its Traefik has documented WebSocket failures on compose stacks, and the room is a WebSocket. The January 2026 disclosure put eleven critical CVEs in a control plane that runs as root. Its baseline would have claimed a quarter of a 4 GB box before Postgres started. The existing compose file was already a correct production stack. It needed twenty lines of Caddy in front, not a PaaS. That deploy went out with nineteen minutes to spare, after a self-inflicted detour in which a redeploy overwrote the host's generated Postgres password and stranded the database behind credentials nothing had written down.
Two rendering bugs that looked like design problems. Vite's dependency
optimiser broke MapLibre's worker, so the basemap never drew. A CSS animation
setting transform silently overwrote a positioning translate and threw pins
at their anchors. Both cost hours before either was understood.
Accomplishments that I'm proud of
An agent that can advocate for a constraint it never discloses. The agent screens places locally and returns per-place verdicts. The council folds them into eligibility, and peers see the aggregate effect with no route back to the cause. That is the capability I most wanted WebMCP to make possible, and it works.
Press and hold. Holding a need shows the map without it, live, and releasing restores it. It turns an argument about whether a need is reasonable into a number you can see, and it is the one gesture everybody understood without being told.
An impasse resolver that refuses to assign blame. When nothing satisfies everyone, the room offers each person a private, quantified route out, phrased as a consequence rather than an instruction.
Honest unverified state. Missing data renders as a hollow pin and a separate count. A place is never silently dropped for lacking a value, and unknown is never drawn as failure.
Two independent adversarial reviews: a protocol-invariant audit and a cross-family model review of the server, then two further reviews of the rebuilt client. The privacy and agreement-integrity findings were fixed and are covered by tests. I published the deferred items instead of hiding them.
What I learned
An agent is not a fast user, and modelling it as one produces bugs. It
participates in bursts, disappears, and returns against state that moved.
Revision-stamped mutations with a structured sync_required and a replayable
delta fit that reality. A pretend realtime subscription did not.
Privacy has to be a projection, not a filter. Every attempt I made to redact at the edge leaked somewhere. Storing each event once, projecting it per viewer, and then asserting the absence of fields on the wire was the only version that held up under review.
Tool design is interface design. Broad tools with open-ended arguments produced confident nonsense. Narrow single-purpose functions with closed enums and capped result budgets produced agents that behaved. The constraint helped the model.
Determinism belongs under the model. Every feasibility fact, from eligibility to conflict sets to counterfactual deltas, is computed rather than generated. The model routes language and advocates. It never gets to decide what is true.
Choose infrastructure by its failure mode, not its convenience. The platform that would have been faster to click through was the one whose known failure mode was exactly my critical path.
What's next for Spokes
Extract a domain-independent negotiation-core (identity, per-viewer
projections, consent, revision sync and the WebMCP helpers) so the same
privacy-preserving negotiation layer drives other shared decisions by swapping
only the domain adapter. Scheduling across private calendars is the obvious next
one. It has the same shape and the same privacy problem, with different nouns.
Inside the spatial domain: the disclosure ladder beyond agent-private screening, so an agent can negotiate progressive partial disclosure rather than an all-or-nothing verdict; transit routing, meeting points and time-window eligibility; and commutative rebase so two people editing needs at once merge instead of racing.
For anything past a demo room: a real participant join and leave lifecycle with
expiring guest tokens, and realtime fan-out that survives more than one process.
Both are named in KNOWN-LIMITATIONS.md with the reasoning for deferring them.
As a product: find out whether a company or a VC would fund this with a dedicated data layer. The structure is already there. It augments existing place data in ways that change the answer, and the confirmation mechanisms give it a way to crowdsource high-reliability data from the people who were actually there.
Built With
- caddy
- dataforseo
- docker
- docker-compose
- fastify
- gpt-5.6
- maplibre-gl
- model-context-protocol
- node.js
- openai
- openfreemap
- openrouter
- openstreetmap
- parallel-ai
- playwright
- pnpm
- postgresql
- react
- typebox
- typescript
- vite
- vitest
- webmcp
- websockets


Log in or sign up for Devpost to join the conversation.