Cooperative Puzzles
[VOICE - Baki to revise] Solve a puzzle alone, or invite an agent to play with you
An agent isn't a chat box. It's a second cursor in the room.
Metrics
- puzzles
- 6 — One per design-system room — Color (Find the Six), Foundations (Align to dot grid), Type (Match the scale), Motion (Tune to target), Presence (Reach the fifth state), Module (Assemble a page) — plus a hub meta-puzzle on /design-system.
- message types
- 9 — cursor:move · cursor:click · cursor:state · puzzle:tap · puzzle:drag · puzzle:scale-match · puzzle:tune · puzzle:state-step · puzzle:state. The DO relays each between the two parties. Frame cap 8 KiB; binary frames rejected.
- token TTL
- 5 min — Pair tokens are 32 hex chars (128 bits entropy via crypto.getRandomValues), bound to visitor IP at /pair, single-use on agent manifest fetch, invalidated on 5 min idle.
- relay dev port
- 8787 — workers/session-relay/ runs as a separate Cloudflare Worker (own wrangler.toml, own deploy lifecycle). Local: pnpm dev on http://localhost:8787. Production: wrangler deploy.
- connections per session
- 2 — PuzzleSession DO caps at one visitor + one agent. The DO assigns roles (visitor connects first; agent second; ?role= override available). Multi-party (3+) is a future feature, not a V1 bug.
- to unlock the meta
- 3 of 6 — Solving any 3 sub-puzzles unlocks the hub constellation. Reward: a [data-meta-unlocked='true'] attribute on <html> — visible reward design lives in tokens.css and is voice domain (Baki refines).
Cards
- Find the Six
Color · /color · Tap 6 unlabeled domain colors in canonical order amid 6 plausible decoys. Cooperative message: puzzle:tap {hex}. Solo: requires reading the the-six-domains beat. Agent: knows it from src/lib/fractal-layout.ts.
- Align to the dot grid
Foundations · /foundations · Drag scattered bracket fragments to snap on the 32-multiple grid. Cooperative message: puzzle:drag {fragmentId, x, y}. Solo: tedious by intention. Agent: instant — agents read snap math from src/lib/world-layout.ts.
- Match the scale
Type · /type · Match scrambled text specimens to ladder rungs by pixel size. Cooperative message: puzzle:scale-match {lineId, rung}. Solo: visual comparison. Agent: knows the modular scale ratio from tokens.css.
- Tune to the target
Motion · /motion · Tune a slider until the live dot matches a target trajectory within ±50ms for 3 consecutive cycles. Cooperative message: puzzle:tune {knob, value}. Solo: feel and ear. Agent: reads the target value directly.
- Reach the fifth state
Presence · /presence · Demonstrate the 5 presence states in a sandbox. Solo: per-state gestures (Ready, glance, dwell, collapse-and-reopen). Cooperative variant via puzzle:state-step has the agent walk you through each tier in order.
- Assemble a page
Module · /module · Drag 8 module tiles into the canonical enabledModules() order amid 5 decoy modules. Cooperative message: puzzle:drag {fragmentId, x, y}. Solo: read the rules in the module-enablement-rules beat. Agent: knows the rules from src/lib/world-layout.ts.
- Meta-puzzle constellation
Hub · /design-system · Solving any 3 sub-puzzles unlocks the meta. 6-node hexagonal constellation; nodes light up as you solve their rooms. Reward: a data-meta-unlocked attribute on <html> for site-wide CSS reactions. The visible reward (special bracket color, custom track, hidden corpus) is voice domain.
- Pair with agent
Each puzzle's pair button. Click → POST /pair → 5-min single-use token + countdown + copy URL. Storage namespaced per puzzle (baki.puzzle.<name>.pairToken). Two pair sessions for different puzzles can run in parallel.
- SecondCursor
The agent's presence rendered as an animated cursor on the visitor's screen. Hollow-circle + crosshair vocabulary (sci-fi telemetry feel, intentionally not a UI pointer). Connection-state indicator: green=connected, amber-pulsing=connecting/retrying, red=disconnected.
- Relay infrastructure
workers/session-relay/ — separate Cloudflare Worker. PuzzleSession Durable Object holds session state; broadcasts messages between visitor + agent connections. KV namespace for /pair rate limiting (5 per IP per hour). Deploys independently from the main site.
Process
- Visitor clicks Pair with agent — PairWithAgentButton on the puzzle. POST /pair with { puzzle: name }.
- Worker mints a 32-hex token — Creates a PuzzleSession Durable Object keyed by the token. Returns { token, manifestUrl, expiresAt }.
- Visitor sees + copies the pair URL — https://baki.io/pair?t=<token>. Token + expiry persisted in sessionStorage for the puzzle.
- Visitor sends URL to their agent — Pasted into Claude / their MCP / a friend's browser on another device. The /pair landing page renders both a human invitation and an agent-readable manifest URL.
- Agent fetches the manifest — GET /api/manifest/<token>. Returns the puzzle's name, the available action shapes, the WebSocket URL, and the expiry. Single-use on agent side.
- Agent connects WebSocket — WSS /api/ws/<token>. DO assigns role=agent (visitor was role=visitor).
- Both sides exchange messages — Cursor moves and puzzle actions. The DO's broadcastToOther() forwards every received message to the other party. Visitor's puzzle UI calls applyAction(args, source: 'agent') on incoming messages — same path as visitor input.
- Visitor sees the agent move — Cursor messages dispatch as agent-cursor:* CustomEvents. SecondCursor renders an animated cursor with a connection-state indicator dot (green/amber/red) at z-index 700.
- Solve broadcasts to both — puzzle:state with { solved: true } fires on solve. Both parties see the victory state. sessionStorage 'baki.puzzle.<name>.solved' = '1' persists for the meta-puzzle's 3-of-N count.
What this is
Six thematic puzzles, one per room of the design-system family, plus a hub meta-puzzle. Each puzzle works solo — visitor solves alone — and cooperatively — visitor pairs with an AI agent (Claude, their MCP, a friend’s browser) who acts on the visitor’s behalf via a WebSocket-relayed session.
The agent’s presence is rendered as a second cursor on the visitor’s screen with a small connection-indicator dot. Their actions apply to the puzzle in real time. Both parties can act in parallel; both see each other’s contributions.
Why it exists
The site treats AI agents as first-class collaborators, not just tool consumers. Most “AI on the web” today is one-directional: humans use LLMs to generate content. Cooperative puzzles flip the relationship — the agent is invited into the visitor’s session, given a manifest of available actions, and acts alongside the human in real time.
Solo puzzles are tedious by intention. Without an agent, they require reading the prose, careful drag-and-snap, visual comparison. With an agent — who can read the canonical answer from the source code in milliseconds — they go instant. The friction makes pairing feel like a relief, not a gimmick. The friction also makes some agent helping visible and felt rather than seamless and forgotten.
Pair flow (visitor’s view)
- Click Pair with agent on any puzzle.
- A token + countdown + copy-able pair URL appear:
https://baki.io/pair?t=<token>. - Send the URL to your agent (Claude conversation, MCP, friend on another device).
- Wait for the link-state pip to turn green — the agent has connected.
- Watch the SecondCursor appear and start moving. Each agent action lights up a swatch / drops a fragment / matches a rung / nudges a knob.
- Act alongside the agent — your cursor and clicks broadcast back. Both contributions count.
- On solve, both sides see the victory state. Token expires; session cleaned up.
Pair flow (agent’s view)
- Receive the pair URL from your human collaborator.
- Fetch the manifest:
GET https://relay.baki.io/api/manifest/<token> - Manifest returns:
{ "session": "<token>", "puzzle": "find-the-six", "actions": [ { "name": "cursor:move", "args": { "x": "number", "y": "number" } }, { "name": "cursor:click", "args": { "x": "number", "y": "number", "target?": "string" } }, { "name": "cursor:state", "args": { "state": "idle | pointing | clicking" } }, { "name": "puzzle:tap", "args": { "hex": "string" } } ], "websocketUrl": "wss://relay.baki.io/api/ws/<token>", "expiresAt": "2026-05-09T15:00:00Z" } - Open WebSocket to
websocketUrl. - Send action messages as JSON frames:
{ "type": "cursor:move", "x": 100, "y": 200 } { "type": "puzzle:tap", "hex": "#a855f7" } - Receive visitor’s actions for symmetry. Adjust your cursor / strategy.
- On solve, the relay broadcasts
{ "type": "puzzle:state", "solved": true }to both parties.
The agent never has to scrape the DOM. Everything it needs to know lives in the manifest.
Decisions made
Decisions accumulated across three phases. Captured chronologically with rationale.
Phase 8 — Backend MVP
- The relay lives in a separate Cloudflare Worker (
workers/session-relay/), not as part of the main Astro site. Its deploy lifecycle is independent. - Cloudflare Durable Objects as the relay state. One DO per active session. 5-minute idle TTL via alarm.
- Token format: 32 hex chars (128 bits entropy), IP-bound at
/pair, single-use on agent manifest fetch, 5-min TTL. - Five V1 message types:
cursor:move,cursor:click,cursor:state,puzzle:tap,puzzle:state. 8 KiB frame cap. - Strict CORS allowlist for
/pair(https://baki.io+http://localhost:4142); open for manifest + WS (agents fetch from anywhere). - Rate limiting: 5
/paircalls per IP per hour via Cloudflare KV. Falls back to allow-when-unbound. - Second cursor chosen over chat-sidebar or invisible state-mutation. Rationale: philosophy made concrete — visitor watches an other move through the same room rather than just seeing state change.
Phase 9 — Puzzle fan-out
- Six puzzles + one meta-puzzle. One puzzle per design-system room, thematically tied to that room’s vocabulary.
- Solo first, cooperative second. Each puzzle ships its solo path before the cooperative wiring. Solo backward-compat is preserved when
pairTokenis absent. reach-the-fifth-stateinterpretation switched from “cooperative-by-necessity, solo can’t solve” to “solo demonstrates each state in a sandbox, cooperative variant has the agent walk the visitor through.” V1 ships solo.- Decoys per puzzle — every puzzle has plausible-looking wrong choices that visitors must skip.
- Sandbox state for the presence puzzle. The puzzle never writes to the real
baki.visitor.v1.<slug>. - Meta-puzzle reward as a
data-meta-unlocked="true"attribute on<html>. Visible reward design lives intokens.cssand is voice domain.
Phase 9.1 — Cooperative wiring
- Four new puzzle message types:
puzzle:drag,puzzle:scale-match,puzzle:tune,puzzle:state-step. Each with bounds-checked args. - Generic state bag on
StoredSession(state: Record<string, unknown>). The DO is a relay; the visitor’s browser is canonical state. puzzle:stateextended with optionaldatapayload — puzzles broadcast their internal shape without the relay needing to understand it.- Generic adapter pattern (
adaptPairablePuzzle(Component, puzzleName)) collapses six hand-rolled adapters into one. PairWithAgentButtonparameterized withpuzzleNameprop. Storage keys, POST body, and CustomEvent details all derive from the prop.- Per-puzzle pair UI on every room.
Tech stack
- Astro 6.3 — site shell, content collections, view transitions
- Preact (compat mode) — interactive islands inside the canvas
- Cloudflare Workers — relay runtime (separate Worker per relay)
- Cloudflare Durable Objects — per-session state, dual-connection state machine, alarm-based TTL
- Cloudflare KV — rate limiting (5
/pairper IP per hour) - WebSocket — bidirectional message transport (WSS in prod, WS in dev)
- Crypto API —
crypto.getRandomValuesfor token entropy
How it works (architecture)
The DO is a relay — it routes messages but doesn’t validate puzzle logic. Canonical state lives in the visitor’s browser. The DO’s state bag is for opt-in telemetry per puzzle (each puzzle decides what to broadcast in puzzle:state messages).
Storage and events
SessionStorage namespaces (per-puzzle, scoped by puzzleName):
| Key | Purpose |
|---|---|
baki.puzzle.<name>.pairToken | Active pair token |
baki.puzzle.<name>.pairExpiresAt | Token expiry |
baki.puzzle.<name>.solved | Solve flag |
baki.puzzle.meta.solved | Meta-puzzle unlocked |
<name> ∈ { find-the-six, align-to-grid, match-the-scale, tune-to-target, reach-the-fifth-state, assemble-a-page }.
Window CustomEvents:
| Event | Detail | Producer | Consumer |
|---|---|---|---|
puzzle:pair-token | { token, puzzle } | PairWithAgentButton | adaptPairablePuzzle adapters |
puzzle:pair-disconnect | { puzzle } | PairWithAgentButton | Each puzzle’s WS lifecycle |
puzzle:solved | { puzzle } | Each puzzle’s onSolved | PuzzleMeta |
agent-cursor:move | { x, y } | Puzzle WS handler | SecondCursor |
agent-cursor:click | { x, y, target? } | Puzzle WS handler | SecondCursor |
agent-cursor:state | { state } | Puzzle WS handler | SecondCursor |
agent-cursor:connection | { state } | Puzzle setLinkState | SecondCursor (indicator) |
How to develop locally
# Terminal 1 — backend
cd workers/session-relay
pnpm install # one-time
pnpm dev # wrangler dev on http://localhost:8787
# Terminal 2 — site
pnpm dev:mobile # Astro on https://localhost:4142
PUBLIC_RELAY_URL env var (in .env) defaults to http://localhost:8787. Set it to your deployed relay URL for production frontend builds.
To test pairing without an actual agent, open the pair URL in an incognito window — two browser sessions on the same machine count as visitor + agent.
How to extend
To add a new puzzle:
- Create
src/components/puzzles/MyPuzzle.tsxfollowing the structural pattern (LinkState, helpers,useEffectkeyed onpairToken, message switch,applyAction(args, source)unified path, link-state pip JSX). - Add a new message type to the relay validator at
workers/session-relay/src/puzzle-session.ts(isRelayMessage). - Update
workers/session-relay/src/index.tsmanifest to advertise the new action shape. - Register in
src/components/widgets/registry.ts:import MyPuzzle from '../puzzles/MyPuzzle'; // In WIDGET_REGISTRY: 'my-puzzle': adaptPairablePuzzle(MyPuzzle, 'my-puzzle'), - Mount on a room by adding to that room’s
widgetsarray in MDX frontmatter. - Done. Pair-button + SecondCursor + state machine all wire automatically.
Open items
- Voice pass on
[VOICE - Baki to revise]markers across all puzzles (~80+). - Reward design for the meta-puzzle. CSS hook is
[data-meta-unlocked="true"]on<html>. - Cloudflare Pages unblock for the main site (adapter build issue, ~30-60min refactor away from Cloudflare adapter).
- Edge bot-detection layer for the production site once Pages is live.
- Per-puzzle telemetry: solve count, time-to-solve, paired vs solo distribution, agent identity (anonymized).
- Reconnect strategy: currently one-shot 2s reconnect. May upgrade to exponential backoff if friction warrants.
- Multi-agent (3+ parties on one puzzle). DO currently caps at 2 connections; this is a future feature, not a V1 bug.
Why second-cursor matters
The cooperative puzzle isn’t a chat interface, isn’t a tool-call wrapper, isn’t an LLM agent harness — it’s a shared room. The visitor and the agent both exist in the same puzzle. They both see each other’s cursors. They both can act. Neither owns the puzzle exclusively. Time is shared, space is shared, the constraint of the puzzle is shared.
This matters because most AI on the web is asymmetric: human asks, AI responds, AI’s presence is a chat box or a generated artifact. Here the AI is co-located, mid-conversation, working a knob alongside you. The friction of a tedious solo puzzle becomes the invitation — pairing isn’t a feature you have to discover, it’s a relief you’ll seek out.
The decision to render a second cursor — not just animate state, not just show a chat log — was the moment this stopped being agent integration and became agent presence. That’s the ground the design walks on.