## Task
Task #20: Timmy responds to Workshop input bar — make the "Say something
to Timmy…" input bar actually trigger an AI response shown in Timmy's
speech bubble.
## What was built
### Server (artifacts/api-server/src/lib/agent.ts)
- Added `chatReply(userText)` method to AgentService
- Uses claude-haiku (cheaper eval model) with a wizard persona system prompt
- 150-token limit so replies fit in the speech bubble
- Stub mode: returns one of 4 wizard-themed canned replies after 400ms delay
- Real mode: calls Anthropic with wizard persona, truncates to 250 chars
### Server (artifacts/api-server/src/routes/events.ts)
- Imported agentService
- Added per-visitor rate limit system: 3 replies/minute per visitorId (in-memory Map)
- Added broadcastToAll() helper for broadcasting to all WS clients
- Updated visitor_message handler:
1. Broadcasts visitor message to all watchers as before
2. Checks rate limit — if exceeded, sends polite "I need a moment…" reply
3. Fire-and-forget async AI call:
- Broadcasts agent_state: gamma=working (crystal ball pulses)
- Calls agentService.chatReply()
- Broadcasts agent_state: gamma=idle
- Broadcasts chat: agentId=timmy, text=reply to ALL clients
- Logs world event "visitor:reply"
### Frontend (the-matrix/js/websocket.js)
- Updated case 'chat' handler to differentiate message sources:
- agentId === 'timmy': speech bubble + event log entry "Timmy: <text>"
- agentId === 'visitor': event log only (don't hijack speech bubble)
- everything else (delta/alpha/beta payment notifications): speech bubble
## What was already working (no change needed)
- Enter key on input bar (ui.js already had keydown listener)
- Input clearing after send (already in ui.js)
- Speech bubble rendering (setSpeechBubble already existed in agents.js)
- WebSocket sendVisitorMessage already exported from websocket.js
## Tests
- 27/27 testkit PASS (no regressions)
- TypeScript: 0 errors
- Vite build: clean (the-matrix rebuilt)