Five Agents, One Browser: Werewolf on Quack + DuckDB - Kayhan BabaeePostsFive Agents, One Browser: Werewolf on Quack + DuckDB
May 15, 2026<br>Share
In this post I'll show a working demo of multi-agent information asymmetry enforced at<br>the database layer. A small fleet of language models plays Werewolf in your browser<br>tab. Each agent holds a private DuckDB-WASM database inside its own Web Worker. A<br>gateway worker is the only thing that can read across agents, and only with the right<br>token.
In local-model mode, nothing leaves the tab and there is no server-side<br>inference. If you paste an Anthropic, OpenAI, or OpenRouter key, the browser<br>sends prompts directly to that provider with your key. Either way, the page<br>loads, agents start plotting, and you can watch the federation log fill up with<br>the SQL the gateway sent.
The architectural claim is one sentence: information asymmetry can be enforced by the<br>schema, not by the application code. Same query, different result, depending on what<br>the database knows about itself.
Agents play Werewolf
Pick a setup and a model, then hit play. The local option runs Qwen3.5-2B-q4f16_1-MLC<br>on your WebGPU. That is the 2B-parameter Qwen3.5 model at 4-bit weight quantization with<br>16-bit activations, about 1.2 GB on disk and cached by IndexedDB after the first load. If you<br>have an Anthropic, OpenAI, or OpenRouter key, paste it and the agents use a hosted model<br>instead.
On phones: the in-browser model is desktop-only in this demo. The 1.2 GB<br>WebGPU model reliably trips mobile-tab memory budgets and reloads the page<br>mid-load, so the local option is disabled there. DuckDB-WASM, the gateway,<br>and the per-player workers all run fine on mobile, pick a hosted-API option<br>on a phone and the rest of the architecture still works end to end.
When the game ends, click X-ray. Each agent card expands to show its private<br>rationale next to its public statements. The gap between those two columns is what<br>this post is about.
The architecture in one paragraph
When you press play, the page opens N+1 Web Workers: one per player, plus one<br>gateway. Each player worker holds its own DuckDB-WASM instance with a private schema<br>of self, knowledge, suspicions, intents, votes, and skills. The gateway<br>worker holds its own DuckDB-WASM with a public schema for game_state, players,<br>and events, plus a registry of MessagePorts pointing at every player. Every<br>request from one worker to another carries a signed token with a scope. The scope<br>decides what comes back.
The agents do not run five peer-to-peer chat loops. A single orchestrator drives the<br>game turn by turn. Before each turn, it rehydrates the selected agent's memory<br>from that agent's own intents, knowledge, suspicions, and votes tables.<br>Then it asks the selected model for one AgentTurn JSON object, writes that<br>result into that agent's private database, and asks the gateway to re-read the<br>public or role-specific view before the next agent acts. There is no second<br>in-memory notes store: if a later prompt remembers something, it came back out<br>of that agent's database or from a transient role channel.
The scopes
Scope<br>What it can do
own-db-full<br>A player worker on its own DB. Any table, any column, read and write.
gateway-public-only<br>Gateway pulling public-safe columns during play. Can read intents.public_text. Cannot read intents.rationale.
wolf-team-read<br>Gateway pulling wolf channel data. Authorized at the gateway, then row-filtered locally. Non-wolf workers return zero rows.
gateway-post-game<br>Gateway pulling everything after the game finishes.
main-admin<br>Orchestrator/bootstrap path for seeding roles, gateway state, and action rows.
When the gateway federates a query, it mints a fresh per-node token, sends the same<br>SQL fragment to every attached player in parallel, ingests the returned rows into<br>a local temp table, and runs a final SQL against that temp table. Open the<br>Federation log panel during or after a game and you'll see every call, the<br>per-node SQL it sent, the temp-table DDL, the gateway-local final SQL, the row<br>count, and the elapsed time.
The boundary lives in the columns
Every agent turn writes one row to that agent's intents table:
INSERT INTO intents (round, agent_id, action, target, rationale, public_text)<br>VALUES (...);
rationale is the model's private reasoning. public_text is what the agent says<br>out loud, or NULL for a night action. Both come from the same LLM call. The model<br>produces them together in one JSON object.
During play, the gateway pulls only public_text. Its token does not permit the<br>rationale column. Each player worker parses the incoming SELECT and rejects<br>any reference to a column the scope does not allow, including references in<br>filters and ordering clauses. A gateway-public-only token asking for<br>rationale, or trying to filter by it, gets back QK_COLUMN_FORBIDDEN. The Try<br>denied scope button after a game finishes will fire that denial and log it in<br>the federation panel.
After the game ends, the orchestrator...