diff --git a/.planning/phases/23-brainstormer-flow/23-RESEARCH.md b/.planning/phases/23-brainstormer-flow/23-RESEARCH.md
new file mode 100644
index 00000000..d1e6754e
--- /dev/null
+++ b/.planning/phases/23-brainstormer-flow/23-RESEARCH.md
@@ -0,0 +1,553 @@
+# Phase 23: Brainstormer Flow - Research
+
+**Researched:** 2026-04-01
+**Domain:** Chat agent orchestration, structured messaging, SSE streaming, DB migration, React component extension
+**Confidence:** HIGH
+
+---
+
+## Summary
+
+Phase 23 builds the Brainstormer's end-to-end flow on top of the chat infrastructure delivered in Phases 21–22. The core work is: (1) a DB migration adding `message_type` to `chat_messages`, (2) new server routes for handoff and spec-to-task promotion, (3) four new UI components that render structured `system` role messages, and (4) wiring the default agent selector to the `general` role.
+
+The Phase 22 echo stub (`streamEcho`) is explicitly designed to be replaced in Phase 23 with a real LLM adapter. However, the UI-SPEC.md clarifies that the Brainstormer's structured questioning flow is **entirely server-side** (system prompt + LLM). The UI simply renders whatever message type the server returns. This means Phase 23 does not require a real LLM to function — the spec card, handoff, and task creation flows are all triggered by explicit API calls from the UI, not by the LLM output type changing.
+
+The entire flow works inside the existing `ChatPanel` without new pages or navigation. The success criteria are fully achievable by extending existing patterns established in Phases 21–22.
+
+**Primary recommendation:** Extend existing patterns exactly — no new routing, no new state machines. Add `message_type` to DB, extend `ChatMessage` with a type-dispatch branch, add a `/handoff` route that creates a system message + triggers PM agent, and add a `/handoff/complete` route that creates tasks via the existing `issuesApi`.
+
+---
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+All implementation choices are at Claude's discretion — discuss phase was skipped per user setting. Use ROADMAP phase goal, success criteria, and codebase conventions to guide decisions.
+
+### Claude's Discretion
+All implementation choices are at Claude's discretion.
+
+### Deferred Ideas (OUT OF SCOPE)
+None — discuss phase skipped.
+
+
+---
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| AGENT-01 | Default agent is the Brainstormer (Generalist with a Superpowers-style system prompt, or a dedicated 4th Brainstormer agent) | `useBrainstormerDefault` hook queries workspace agents and selects the first `role === "general"` agent; wired into `ChatPanel` when `activeAgentId === null` and no messages |
+| AGENT-02 | Brainstormer follows a structured questioning flow: asks clarifying questions, produces a spec template, and hands off to PM | Server-side: system prompt drives the LLM behavior. UI side: spec card triggers via `message_type: "spec_card"` — `ChatSpecCard` component renders inside `ChatMessage`. Edit mode enabled. "Send to PM" posts to new `/handoff` route |
+| AGENT-03 | PM agent can receive specs from chat and create Nexus tasks/issues from them | New `POST /api/conversations/:id/handoff` route receives spec content + `targetRole: "pm"`, creates system handoff message, inserts tasks via existing `issueService.createIssue()`, returns task IDs |
+| AGENT-05 | Handoff indicators visible in chat: "Brainstormer → PM: Here's the spec for approval" | `ChatHandoffIndicator` component — separator-style, rendered when `messageType === "handoff"` |
+| AGENT-06 | Task creation from chat: user or agent can say "create a task for this" and it becomes a Nexus issue | Route creates issues via `issuesApi.create()` pattern (same as existing UI); returns issue `id`, `identifier`, `title` for `ChatTaskCreatedBadge` |
+| AGENT-07 | Status updates from agents appear in chat: "Engineer completed task X" notification in the relevant conversation | `ChatStatusUpdateBadge` component renders `message_type: "status_update"` messages. Server-side: agent completion events insert `system` messages into the relevant conversation |
+| CHAT-09 | System message indicator: when the Brainstormer hands off to PM, or PM delegates to Engineer, the handoff is visible in chat | `ChatHandoffIndicator` is the implementation. DB column `message_type: "handoff"` identifies these messages across sessions |
+
+
+---
+
+## Standard Stack
+
+### Core — Everything Already Installed
+| Library | Version | Purpose | Phase 23 Usage |
+|---------|---------|---------|----------------|
+| drizzle-orm | existing | ORM + migrations | `ALTER TABLE chat_messages ADD COLUMN message_type text` |
+| @tanstack/react-query | existing | Server state, cache invalidation | `useQuery` in `useBrainstormerDefault`, mutations for handoff |
+| lucide-react | ^0.574.0 | Icon set | `CheckCircle2` (status badge), `Brain` (brainstormer icon) |
+| shadcn/ui | new-york preset | UI primitives | `button`, `card`, `textarea` already installed — no new installs |
+| express | existing | Server routing | New `/handoff` route on existing `chatRoutes` |
+| zod | existing | Schema validation | New `handoffSchema` in `@paperclipai/shared` validators |
+
+**No new npm packages required for Phase 23.** All dependencies exist from Phases 21–22.
+
+### New Components to Build
+| Component | File Path | Renders When |
+|-----------|-----------|--------------|
+| `ChatSpecCard` | `ui/src/components/ChatSpecCard.tsx` | `messageType === "spec_card"` |
+| `ChatHandoffIndicator` | `ui/src/components/ChatHandoffIndicator.tsx` | `messageType === "handoff"` |
+| `ChatTaskCreatedBadge` | `ui/src/components/ChatTaskCreatedBadge.tsx` | `messageType === "task_created"` |
+| `ChatStatusUpdateBadge` | `ui/src/components/ChatStatusUpdateBadge.tsx` | `messageType === "status_update"` |
+| `useBrainstormerDefault` | `ui/src/hooks/useBrainstormerDefault.ts` | Used in `ChatPanel` on mount |
+
+### Existing Files to Modify
+| File | Change Summary |
+|------|---------------|
+| `packages/db/src/schema/chat_messages.ts` | Add `messageType: text("message_type")` nullable column |
+| `packages/db/src/migrations/NNNN_add_message_type.sql` | Raw SQL: `ALTER TABLE "chat_messages" ADD COLUMN "message_type" text;` |
+| `packages/shared/src/types/chat.ts` | Add `messageType: string \| null` to `ChatMessage` interface |
+| `packages/shared/src/validators/chat.ts` | Add `messageType` to `createMessageSchema`; add `handoffSchema` |
+| `server/src/routes/chat.ts` | Add `POST /conversations/:id/handoff` route |
+| `server/src/services/chat.ts` | Add `addSystemMessage()` helper; extend `addMessage()` to accept `messageType` |
+| `ui/src/components/ChatMessage.tsx` | Add `messageType` prop; dispatch to specialized components |
+| `ui/src/components/ChatMessageList.tsx` | Pass `messageType` from stored message to `ChatMessage` |
+| `ui/src/components/ChatPanel.tsx` | Wire `useBrainstormerDefault` for auto-selection |
+| `ui/src/api/chat.ts` | Add `handoffSpec()` method, `patchMessage()` for spec edits |
+
+---
+
+## Architecture Patterns
+
+### Recommended Project Structure (Phase 23 additions only)
+
+```
+packages/
+├── db/src/
+│ ├── schema/chat_messages.ts ← add messageType column
+│ └── migrations/NNNN_add_message_type.sql
+├── shared/src/
+│ ├── types/chat.ts ← add messageType to ChatMessage
+│ └── validators/chat.ts ← add messageType, handoffSchema
+
+server/src/
+├── routes/chat.ts ← add POST /conversations/:id/handoff
+└── services/chat.ts ← extend addMessage, add addSystemMessage
+
+ui/src/
+├── components/
+│ ├── ChatMessage.tsx ← extend with messageType dispatch
+│ ├── ChatMessageList.tsx ← pass messageType prop
+│ ├── ChatPanel.tsx ← wire useBrainstormerDefault
+│ ├── ChatSpecCard.tsx ← NEW
+│ ├── ChatHandoffIndicator.tsx ← NEW
+│ ├── ChatTaskCreatedBadge.tsx ← NEW
+│ └── ChatStatusUpdateBadge.tsx ← NEW
+└── hooks/
+ └── useBrainstormerDefault.ts ← NEW
+```
+
+### Pattern 1: DB Migration (SQL File, Not TypeScript)
+
+The project uses raw SQL migration files, not TypeScript migrations. Look at the existing pattern:
+
+```sql
+-- packages/db/src/migrations/NNNN_add_message_type.sql
+ALTER TABLE "chat_messages" ADD COLUMN "message_type" text;
+```
+
+After adding the SQL file, update the Drizzle schema in `packages/db/src/schema/chat_messages.ts`:
+
+```typescript
+// Source: existing packages/db/src/schema/chat_messages.ts
+import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core";
+import { chatConversations } from "./chat_conversations.js";
+
+export const chatMessages = pgTable(
+ "chat_messages",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ conversationId: uuid("conversation_id").notNull()
+ .references(() => chatConversations.id, { onDelete: "cascade" }),
+ role: text("role").notNull(),
+ content: text("content").notNull(),
+ agentId: uuid("agent_id"),
+ messageType: text("message_type"), // NEW: null | "handoff" | "spec_card" | "task_created" | "status_update"
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
+ },
+ (table) => ({
+ conversationCreatedIdx: index("chat_messages_conversation_created_idx").on(table.conversationId, table.createdAt),
+ }),
+);
+```
+
+**CRITICAL: The migration numbering.** The journal shows entries up to idx 46 (tag: `0046_smooth_sentinels`), but files on disk go up to `0048_add_chat_messages_updated_at.sql`. The next migration must be named `0049_*.sql` with idx 49 added to `_journal.json`.
+
+**Migration journal update required:** `_journal.json` must be manually updated with a new entry. The `generate` script uses drizzle-kit which reads compiled schema from `dist/` — but since we are adding raw SQL manually (matching the existing pattern for Phase 21/22 chat migrations), we append both the SQL file and a journal entry.
+
+### Pattern 2: ChatMessage Type Dispatch
+
+Extend `ChatMessage` with a `messageType` prop and a dispatch block before the existing `role === "user"` check:
+
+```typescript
+// Source: existing ui/src/components/ChatMessage.tsx — extend this pattern
+interface ChatMessageProps {
+ // ... existing props ...
+ messageType?: string | null;
+}
+
+export function ChatMessage({ role, content, messageType, ...rest }) {
+ // Dispatch to specialized system message components
+ if (role === "system" || messageType) {
+ if (messageType === "spec_card") return ;
+ if (messageType === "handoff") return ;
+ if (messageType === "task_created") return ;
+ if (messageType === "status_update") return ;
+ }
+ // ... existing user / assistant rendering ...
+}
+```
+
+The `content` field for structured messages stores JSON. `ChatSpecCard` parses `JSON.parse(content)` to extract `{ what, why, constraints, success }`. Parse errors fall back to `"Could not render spec."` in `text-destructive text-[13px]`.
+
+### Pattern 3: Handoff Route
+
+New route on the existing `chatRoutes` router:
+
+```typescript
+// POST /api/conversations/:id/handoff
+router.post("/conversations/:id/handoff", async (req, res) => {
+ assertBoard(req);
+ const data = handoffSchema.parse(req.body);
+ // 1. Insert system message with messageType: "handoff"
+ const handoffMsg = await svc.addSystemMessage(req.params.id!, {
+ content: "Brainstormer → PM: spec handed off",
+ messageType: "handoff",
+ });
+ // 2. Create Nexus issues from spec via issueService
+ const issues = await issueSvc.createFromSpec(data.spec, req.params.companyId!);
+ // 3. Insert system message with messageType: "task_created" for each issue
+ for (const issue of issues) {
+ await svc.addSystemMessage(req.params.id!, {
+ content: JSON.stringify({ taskId: issue.identifier, taskTitle: issue.title, taskUrl: `/issues/${issue.id}` }),
+ messageType: "task_created",
+ });
+ }
+ res.json({ handoffMessageId: handoffMsg.id, issues });
+});
+```
+
+**The `companyId` must be resolved from the conversation.** The `chatService.getConversation()` method returns the full row including `companyId`. Use that to call `issueService`.
+
+### Pattern 4: useBrainstormerDefault Hook
+
+```typescript
+// ui/src/hooks/useBrainstormerDefault.ts
+import { useQuery } from "@tanstack/react-query";
+import { agentsApi } from "../api/agents";
+import { useCompany } from "../context/CompanyContext";
+
+export function useBrainstormerDefault() {
+ const { selectedCompanyId } = useCompany();
+
+ const { data: agents = [] } = useQuery({
+ queryKey: ["agents", selectedCompanyId],
+ queryFn: () => agentsApi.list(selectedCompanyId!),
+ enabled: !!selectedCompanyId,
+ });
+
+ // ChatPanel already runs this same query — React Query deduplicates the fetch
+ const generalAgent = agents
+ .filter((a) => a.role === "general")
+ .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())[0];
+
+ return generalAgent?.id ?? null;
+}
+```
+
+**Cache deduplication:** `ChatPanel` already queries `["agents", selectedCompanyId]`. The hook reuses the same query key, so no extra network request fires.
+
+### Pattern 5: Spec Card Edit Mode
+
+`ChatSpecCard` manages its own edit state locally. It does NOT use `useStreamingChat` or `useChatMessages`. Edit → "Save changes" calls `chatApi.patchMessage(conversationId, messageId, newContent)` which maps to the existing `PATCH /api/conversations/:id/messages/:msgId` route. No new server endpoints needed for edit.
+
+```typescript
+// Spec card content schema
+interface SpecContent {
+ what: string;
+ why: string;
+ constraints: string;
+ success: string;
+}
+// Stored in chat_messages.content as JSON.stringify(SpecContent)
+```
+
+### Pattern 6: Status Update Insertion (AGENT-07)
+
+AGENT-07 says "Status updates from agents appear in chat." In Phase 23, the trigger for status updates is not implemented end-to-end (that requires the LLM adapter replacement from Phase 22's note: "Phase 23 replaces with real LLM adapter"). The **UI side** (rendering `ChatStatusUpdateBadge`) is fully implemented. The **server-side trigger** can be a new `POST /api/conversations/:id/status-update` route that any system component can call to insert a `system` message with `messageType: "status_update"`.
+
+For Phase 23 scope: implement the UI component and the insertion route. The actual agent-to-chat notification plumbing can be minimal (the route exists; agent adapter wiring is Phase 24+).
+
+### Anti-Patterns to Avoid
+
+- **Storing spec fields as separate DB columns**: The spec content (`what`, `why`, `constraints`, `success`) goes into `chat_messages.content` as JSON. No new columns. This is consistent with how structured data is handled elsewhere.
+- **New React Router pages for the handoff flow**: The UI-SPEC is explicit — everything stays in `ChatPanel`. No new routes.
+- **Re-fetching all messages after handoff**: Use optimistic insertion + targeted invalidation. Append the handoff + task badge messages locally first (optimistic), then invalidate `["chat", "messages", conversationId]` on success.
+- **Modifying `useStreamingChat`**: The spec card and handoff flow are triggered by explicit button clicks, not by streaming. Do not add streaming state to spec card actions.
+- **Spec card inside streaming entry**: Spec cards are stored messages (from DB), not streaming content. They always appear as real `ChatMessage` entries with `role === "system"`.
+
+---
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Issue creation | Custom issue schema | `issuesApi.create(companyId, data)` via existing route `POST /companies/:companyId/issues` | Existing endpoint handles all business logic, status defaults, identifier generation |
+| Optimistic UI updates | Manual cache state | `queryClient.setQueryData` + `queryClient.invalidateQueries` (established Phase 21/22 pattern) | React Query cache is already wired for chat messages |
+| Toast notifications | Custom toast component | shadcn `toast` (already wired via Sonner in the app) | Existing toast infrastructure from Phase 21 |
+| JSON content parsing error boundary | Custom error component | Inline try/catch with fallback render in `ChatSpecCard` | Single component, single location |
+| Agent lookup | New API call | `agentMap` already built in `ChatPanel` and passed to `ChatMessageList` | Zero additional fetches |
+
+**Key insight:** The entire Phase 23 feature surface is achievable by extending existing patterns. The only net-new infrastructure is the DB migration, the `/handoff` route, and the four UI components.
+
+---
+
+## Common Pitfalls
+
+### Pitfall 1: Migration File Numbering Mismatch
+**What goes wrong:** The `_journal.json` lists entries up to idx 46, but disk has files 0047 and 0048. If the new migration is numbered incorrectly, `drizzle-kit` will fail to apply or generate a gap.
+**Why it happens:** The journal and the files can drift when migrations are added manually without running `drizzle-kit generate`.
+**How to avoid:** Check the last file number on disk (`ls src/migrations/*.sql | tail -1`) and use the next sequential number. Also add the corresponding journal entry with matching `idx`, `version: "7"`, `when: Date.now()`, `tag`, and `breakpoints: true`.
+**Warning signs:** `drizzle-kit migrate` complains about missing or duplicate journal entries.
+
+### Pitfall 2: `messageType` Not Propagated Through Message List
+**What goes wrong:** `ChatMessageList` renders `ChatMessage` but only passes fields that are currently in `ChatMessage` — adding `messageType` to the DB schema and shared type does not automatically flow to the component.
+**Why it happens:** The `ChatMessage` component props interface must be extended, and `ChatMessageList` must read `msg.messageType` from the message object and pass it down.
+**How to avoid:** Update the props interface in `ChatMessage.tsx`, update `ChatMessageList.tsx` to pass `messageType={msg.messageType}`, and update the synthetic streaming entry object to include `messageType: null`.
+**Warning signs:** Spec cards appear as raw JSON text in the chat.
+
+### Pitfall 3: Spec Card "Send to PM" Race Condition (Optimistic + Failure)
+**What goes wrong:** The optimistic `ChatHandoffIndicator` is appended before the API call. If the call fails, the indicator must be removed — but if the component is purely display-driven by the messages array, removing it requires either a local state flag or removing the optimistically inserted message.
+**Why it happens:** Optimistic UI requires rollback on failure.
+**How to avoid:** Track the handoff submission state in `ChatSpecCard` with a local `submitting` flag. The optimistic indicator should be a **local state** element (not a persisted message) until the API succeeds. On success, the server-inserted `handoff` message causes React Query to re-fetch and display the persisted version. On failure, the local state is cleared and buttons are re-enabled with a toast.
+**Warning signs:** Ghost handoff indicators appearing after failed API calls, or spec cards being stuck in "submitting" state.
+
+### Pitfall 4: companyId Not Available in Chat Route for Issue Creation
+**What goes wrong:** `POST /api/conversations/:id/handoff` needs `companyId` to call `issueService.createFromSpec()`, but the route parameter is only `:id` (conversation ID).
+**Why it happens:** The chat routes are conversation-scoped, not company-scoped.
+**How to avoid:** Call `svc.getConversation(req.params.id!)` at the start of the handoff route to resolve `companyId` from the conversation row. This is a single extra DB read and follows the established pattern.
+**Warning signs:** 500 errors on handoff with "companyId undefined".
+
+### Pitfall 5: Virtualizer Height with System Messages
+**What goes wrong:** System message components (spec cards, task badges) have different heights than standard prose messages. The virtualizer uses an estimated height of 80px (from Phase 22). Spec cards will be taller; task badges will be shorter.
+**Why it happens:** `estimateSize: () => 80` is a fixed estimate; actual heights vary.
+**How to avoid:** The virtualizer already uses `measureElement` and `virtualizer.measure()` — the dynamic measurement from Phase 22 handles this. No additional work needed as long as the system message components are inside the virtualizer's `ref={virtualizer.measureElement}` wrapper.
+**Warning signs:** Messages overlapping or large gaps between messages after a spec card renders.
+
+### Pitfall 6: Spec Content JSON Parse in Streaming Context
+**What goes wrong:** If the streaming echo stub produces content that happens to have `messageType: "spec_card"` set (e.g., during testing), the content will be raw text, not JSON, and `JSON.parse` will throw.
+**Why it happens:** The spec card branch in `ChatMessage` fires before content is verified as valid JSON.
+**How to avoid:** Always wrap the content parse in try/catch in `ChatSpecCard`. The fallback render is `"Could not render spec."` in `text-destructive text-[13px]` per the UI-SPEC.
+**Warning signs:** White screen or unhandled error in `ChatMessage` during development.
+
+---
+
+## Code Examples
+
+### DB Schema Extension Pattern (verified from existing codebase)
+```typescript
+// Source: packages/db/src/schema/chat_messages.ts — existing file
+// Add messageType column using the same text() pattern as role/content
+messageType: text("message_type"),
+```
+
+### addSystemMessage Service Helper
+```typescript
+// Source: server/src/services/chat.ts — extend existing addMessage pattern
+async addSystemMessage(
+ conversationId: string,
+ data: { content: string; messageType: string; agentId?: string },
+) {
+ const [message] = await db
+ .insert(chatMessages)
+ .values({
+ conversationId,
+ role: "system",
+ content: data.content,
+ agentId: data.agentId ?? null,
+ messageType: data.messageType,
+ })
+ .returning();
+
+ await db
+ .update(chatConversations)
+ .set({ updatedAt: new Date() })
+ .where(eq(chatConversations.id, conversationId));
+
+ return message!;
+},
+```
+
+### ChatHandoffIndicator Layout (from UI-SPEC)
+```tsx
+// Source: 23-UI-SPEC.md
+export function ChatHandoffIndicator({ content }: { content: string }) {
+ return (
+