Skip to content

Instantly share code, notes, and snippets.

@timrourke
Created April 16, 2026 14:57
Show Gist options
  • Select an option

  • Save timrourke/90f48eb11e82cacaed1ebf47b5ef7693 to your computer and use it in GitHub Desktop.

Select an option

Save timrourke/90f48eb11e82cacaed1ebf47b5ef7693 to your computer and use it in GitHub Desktop.
Freshpaint AI Chat Architecture - Interactive Reveal.js Presentation
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Freshpaint AI Chat: A Love Story Between Go and React</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/theme/black.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/plugin/highlight/monokai.css">
<style>
:root {
--r-background-color: #0d1117;
--r-main-color: #e6edf3;
--r-heading-color: #58a6ff;
--r-link-color: #58a6ff;
}
.reveal {
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
}
.reveal h1, .reveal h2, .reveal h3 {
text-transform: none;
font-weight: 700;
}
.reveal h1 { font-size: 2.0em; }
.reveal h2 { font-size: 1.5em; }
.reveal h3 { font-size: 1.2em; color: #7ee787; }
.reveal code {
font-family: 'SF Mono', 'Fira Code', monospace;
}
.reveal pre {
width: 100%;
font-size: 0.45em;
box-shadow: none;
}
.reveal pre code {
max-height: 520px;
padding: 16px;
border-radius: 8px;
background: #161b22;
}
.mermaid-container {
background: #161b22;
border-radius: 12px;
padding: 20px;
margin: 10px auto;
}
.mermaid {
font-size: 14px;
}
.file-ref {
color: #7ee787;
font-size: 0.5em;
font-style: italic;
}
.emoji-huge {
font-size: 3em;
display: block;
margin: 20px 0;
}
.box {
background: #161b22;
border: 1px solid #30363d;
border-radius: 12px;
padding: 20px;
margin: 10px 0;
}
.box-green { border-color: #238636; }
.box-blue { border-color: #1f6feb; }
.box-orange { border-color: #d29922; }
.box-red { border-color: #da3633; }
.box-purple { border-color: #8b5cf6; }
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
text-align: left;
}
.three-col {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
text-align: left;
}
.reveal ul { font-size: 0.75em; text-align: left; }
.reveal li { margin: 6px 0; }
.reveal p { font-size: 0.8em; }
.reveal .small { font-size: 0.6em; color: #8b949e; }
.reveal .tiny { font-size: 0.45em; color: #8b949e; }
.highlight-green { color: #7ee787; }
.highlight-blue { color: #58a6ff; }
.highlight-orange { color: #d29922; }
.highlight-red { color: #f85149; }
.highlight-purple { color: #d2a8ff; }
.reveal table {
font-size: 0.55em;
margin: 0 auto;
}
.reveal table th {
background: #1f6feb;
color: white;
padding: 8px 12px;
}
.reveal table td {
padding: 6px 12px;
border-bottom: 1px solid #30363d;
}
.reveal table tr:nth-child(even) td {
background: #161b22;
}
.arrow-flow {
font-size: 1.2em;
color: #58a6ff;
letter-spacing: 4px;
}
.phase-label {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.6em;
font-weight: bold;
}
.phase-1 { background: #238636; color: white; }
.phase-2 { background: #1f6feb; color: white; }
.phase-3 { background: #d29922; color: black; }
.slide-number { color: #8b949e; }
.chapter-title {
font-size: 0.6em;
color: #8b949e;
text-transform: uppercase;
letter-spacing: 4px;
margin-bottom: 20px;
}
.vs-badge {
display: inline-block;
background: #da3633;
color: white;
padding: 2px 12px;
border-radius: 20px;
font-size: 0.6em;
font-weight: bold;
}
/* Scale-down classes for slides that overflow 720px configured height */
.reveal section.shrink-mild { font-size: 0.88em; }
.reveal section.shrink-moderate { font-size: 0.78em; }
.reveal section.shrink-heavy { font-size: 0.62em; }
.reveal section.shrink-mild .mermaid-container { padding: 8px; margin: 4px auto; transform: scale(0.90); transform-origin: top center; height: auto; }
.reveal section.shrink-moderate .mermaid-container { padding: 4px; margin: 2px auto; transform: scale(0.72); transform-origin: top center; margin-bottom: -60px; }
.reveal section.shrink-heavy .mermaid-container { padding: 2px; margin: 0 auto; transform: scale(0.55); transform-origin: top center; margin-bottom: -120px; }
.reveal section.shrink-mild .box { padding: 12px; margin: 6px 0; }
.reveal section.shrink-moderate .box { padding: 8px; margin: 4px 0; }
.reveal section.shrink-heavy .box { padding: 6px; margin: 2px 0; }
.reveal section.shrink-moderate pre { font-size: 0.42em; }
.reveal section.shrink-heavy pre { font-size: 0.38em; }
.reveal section.shrink-moderate table { font-size: 0.42em; }
.reveal section.shrink-heavy table { font-size: 0.38em; }
</style>
</head>
<body>
<div class="reveal">
<div class="slides">
<!-- ===================== TITLE ===================== -->
<section>
<h1>The Freshpaint AI Chat</h1>
<h3>A Love Story Between Go and React<br>(It's Complicated)</h3>
<span class="emoji-huge">🤯</span>
<p class="small">An absurdly thorough deep-dive into every single layer<br>of the most over-engineered chat system you'll ever love</p>
<p class="tiny">Press <kbd>→</kbd> to begin your journey. Press <kbd>↓</kbd> for details within each chapter.<br>Press <kbd>Esc</kbd> for the overview map. Estimated runtime: however long it takes you to lose your mind.</p>
</section>
<!-- ===================== TABLE OF CONTENTS ===================== -->
<section>
<h2>The Journey Ahead</h2>
<div class="three-col" style="font-size: 0.6em;">
<div class="box box-green">
<h3>Act I: The Setup</h3>
<ol>
<li>30,000 ft Overview</li>
<li>The Database Tables</li>
<li>The Type Zoo</li>
</ol>
</div>
<div class="box box-blue">
<h3>Act II: The Backend</h3>
<ol start="4">
<li>Router & Auth</li>
<li>The Agent Loop</li>
<li>The Tool System</li>
<li>The SSE Machinery</li>
</ol>
</div>
<div class="box box-orange">
<h3>Act III: The Frontend</h3>
<ol start="8">
<li>SSE Client</li>
<li>The Stream Hook</li>
<li>Block Parsing</li>
<li>Component Tree</li>
</ol>
</div>
</div>
<div class="box box-purple" style="font-size: 0.6em; margin-top: 10px;">
<h3>Grand Finale: Full Data Flow Walkthrough</h3>
<p>The complete journey of a message, from keypress to pixels</p>
</div>
</section>
<!-- ===================== ACT I: THE SETUP ===================== -->
<section>
<section>
<div class="chapter-title">Act I</div>
<h1>The 30,000 Foot View</h1>
<h3>(Where we pretend this is simple)</h3>
<span class="emoji-huge">🦅</span>
</section>
<section>
<h2>What Even Is This Thing?</h2>
<p>Freshpaint AI Chat is a <span class="highlight-green">conversational analytics assistant</span> that:</p>
<div class="two-col">
<div class="box box-green">
<ul>
<li>Talks to Claude (Sonnet 4.6)</li>
<li>Queries Snowflake via MCP</li>
<li>Introspects your Freshpaint account</li>
<li>Runs funnel queries</li>
</ul>
</div>
<div class="box box-blue">
<ul>
<li>Renders charts, tables, KPIs</li>
<li>Proposes surveys</li>
<li>Suggests follow-up questions</li>
<li>Does weekly summaries</li>
</ul>
</div>
</div>
<p class="small">Think of it as "ChatGPT but it can see all your analytics data and has strong opinions about your funnels"</p>
</section>
<section>
<h2>The Architecture (Squint Version)</h2>
<div class="mermaid-container">
<pre class="mermaid">
flowchart LR
U["👤 User\n(Browser)"] -->|"POST /message"| B["🏗️ Go Backend\n(conversation_handler)"]
B -->|"Creates stream"| R["📦 Registry\n(in-memory)"]
B -->|"Calls Claude"| C["🤖 Claude API\n(Sonnet 4.6)"]
C -->|"Tool calls"| T["🔧 Tools\n(Snowflake, Account, Emit)"]
T -->|"Results"| C
C -->|"Streaming tokens"| B
B -->|"SSE events"| R
R -->|"Fan-out"| S["📡 SSE Stream\n(GET /stream)"]
S -->|"text_delta, block, done"| U
B -->|"Persist"| DB["💾 PostgreSQL\n(messages table)"]
style U fill:#238636,color:#fff
style B fill:#1f6feb,color:#fff
style R fill:#d29922,color:#000
style C fill:#8b5cf6,color:#fff
style T fill:#da3633,color:#fff
style S fill:#238636,color:#fff
style DB fill:#30363d,color:#fff
</pre>
</div>
<p class="small">"It's just a chat app, how complicated could it be?" — Famous last words</p>
</section>
<section>
<h2>The Two API Patterns (Because Why Not Both)</h2>
<div class="two-col">
<div class="box box-green">
<h3>Old Router Pattern</h3>
<p class="small">POST with <code>Kind</code> string</p>
<pre><code class="language-json">{
"Kind": "ai_chat_list_conversations",
"Token": "...",
"EnvID": "...",
"Body": {}
}</code></pre>
<p class="tiny">Used for: list, save, approval</p>
</div>
<div class="box box-blue">
<h3>New HTTP Endpoints</h3>
<p class="small">RESTful-ish paths</p>
<pre><code class="language-text">GET /ai-chat/conversations/{id}/stream
POST /ai-chat/conversations/{id}/message
POST /ai-chat/conversations/{id}/cancel</code></pre>
<p class="tiny">Used for: streaming, messaging, cancel</p>
</div>
</div>
<p class="small">The streaming endpoints needed real HTTP semantics, so they got their own routes.<br>The CRUD stuff? Still living in 2024's router pattern. Coexistence is beautiful. 🤝</p>
</section>
</section>
<!-- ===================== DATABASE ===================== -->
<section>
<section>
<div class="chapter-title">Act I, Scene 2</div>
<h1>The Database Tables</h1>
<h3>(Where data goes to live forever)</h3>
<span class="emoji-huge">💾</span>
</section>
<section>
<h2>Four Tables to Rule Them All</h2>
<div class="two-col" style="font-size: 0.5em; gap: 12px;">
<div class="box box-green" style="padding: 12px;">
<h3 style="margin: 0 0 8px;">cro_chat_conversations</h3>
<code>uuid id PK</code><br>
<code>uuid env_id FK</code>, <code>text uid FK</code><br>
<code>text title</code>, <code>text kind</code> <span class="tiny">(standard | weekly_summary)</span><br>
<code>enum processing_status</code> <span class="tiny">(idle | processing | error)</span><br>
<code>timestamptz last_message_at</code>
</div>
<div class="box box-blue" style="padding: 12px;">
<h3 style="margin: 0 0 8px;">cro_chat_messages</h3>
<code>uuid id PK</code><br>
<code>uuid conversation_id FK</code> → conversations<br>
<code>text role</code> <span class="tiny">(user | assistant | system)</span><br>
<code>jsonb content</code> <span class="tiny highlight-orange">← the big one</span><br>
<code>int ordinal</code> <span class="tiny highlight-orange">← ordering!</span><br>
<code>enum status</code>, <code>bool visible_to_user</code>
</div>
</div>
<div class="two-col" style="font-size: 0.5em; gap: 12px; margin-top: 8px;">
<div class="box" style="padding: 12px;">
<h3 style="margin: 0 0 8px;">ai_chat_approvals</h3>
<code>uuid id PK</code>, <code>uuid account_id FK</code>, <code>text consent_version</code>
</div>
<div class="box" style="padding: 12px;">
<h3 style="margin: 0 0 8px;">ai_snowflake_credentials</h3>
<code>uuid account_id PK</code>, <code>text snowflake_pat</code> <span class="tiny">(encrypted!)</span>
</div>
</div>
<p class="tiny">Full schema also has timestamps, uid, env_id on each table. File: db/migrations/perfalytics/20260305120000_cro_chat_conversations_and_messages.sql</p>
</section>
<section>
<h2>The <code>content</code> JSONB Field</h2>
<p>This single JSONB field stores EVERYTHING — here be dragons 🐉</p>
<div class="two-col">
<div class="box box-green">
<h3>User Message</h3>
<pre><code class="language-json">{
"blocks": [
{
"type": "text",
"text": "Show me conversion rates"
}
]
}</code></pre>
</div>
<div class="box box-blue">
<h3>Assistant Message</h3>
<pre><code class="language-json">{
"blocks": [
{"type": "text", "text": "Let me analyze..."},
{"type": "tool_use", "name": "SQL_Execution_Tool",
"id": "abc", "input": {"sql": "SELECT..."}},
{"type": "tool_use", "name": "emit_chart",
"id": "def", "input": {"chartType": "bar", ...}},
{"type": "text", "text": "Here are the results!"}
]
}</code></pre>
</div>
</div>
<p class="small">The assistant content is the raw Anthropic SDK format — <code>text</code> blocks mixed with <code>tool_use</code> blocks.<br>Frontend never sees the raw SQL tool calls. Only <code>emit_*</code> tools are visible. More on this later!</p>
</section>
<section>
<h2>Message Ordering: Why Ordinals Matter</h2>
<p class="small"><strong class="highlight-orange">Problem:</strong> Batch-inserted messages get the same <code>created_at</code> — can't sort by time. 🤦<br>
<strong class="highlight-green">Solution:</strong> <code>ordinal</code> — a monotonically increasing integer assigned during streaming.</p>
<pre><code class="language-text">ordinal 1: user "Show me conversion rates"
ordinal 2: assistant [text + SQL tool_use + emit_chart tool_use]
ordinal 3: user [tool_result for SQL] ← hidden!
ordinal 4: assistant [more text + emit_kpis tool_use]
ordinal 5: user [tool_result for emit_kpis] ← hidden!
ordinal 6: assistant "Here's your analysis!" ← final</code></pre>
<p class="tiny">parent_message_id exists for provenance tracking but isn't used for ordering anymore</p>
</section>
<section>
<h2>Processing Status: The Conversation Lock</h2>
<div class="mermaid-container">
<pre class="mermaid">
stateDiagram-v2
direction LR
[*] --> idle: Created
idle --> processing: POST /message
processing --> idle: Complete / Cancel
processing --> error: Stream fails
error --> processing: User retries
</pre>
</div>
<p class="small">Registry.Create() rejects if already processing — only ONE active stream per conversation.<br>The Registry also enforces this at the in-memory level. Belt AND suspenders. 🩳</p>
</section>
</section>
<!-- ===================== TYPE ZOO ===================== -->
<section>
<section>
<div class="chapter-title">Act I, Scene 3</div>
<h1>The Type Zoo</h1>
<h3>(Backend vs Frontend: a tale of two type systems)</h3>
<span class="emoji-huge">🦒🐘🦩</span>
</section>
<section>
<h2>Backend Types (Go)</h2>
<p class="file-ref">back/aichat/types/types.go</p>
<div class="two-col">
<div class="box box-green">
<h3>Typed IDs</h3>
<pre><code class="language-go">type ChatConversationID struct {
uuid.UUID
}
type ChatMessageID struct {
uuid.UUID
}
// No bare UUIDs! No strings!
// Compiler-enforced safety!</code></pre>
</div>
<div class="box box-blue">
<h3>Message Context</h3>
<pre><code class="language-go">type MessageContext struct {
MessageID string `json:"message_id"`
Ordinal int `json:"ordinal"`
}
// Embedded in EVERY SSE event
// Frontend uses this to correlate
// events to messages</code></pre>
</div>
</div>
<div class="two-col" style="margin-top: 10px;">
<div class="box box-orange">
<pre><code class="language-go">// "idle" | "processing" | "error"
type ProcessingStatus string</code></pre>
</div>
<div class="box box-purple">
<pre><code class="language-go">// "standard" | "weekly_summary"
type ConversationKind string</code></pre>
</div>
</div>
</section>
<section>
<h2>Frontend Types (TypeScript)</h2>
<p class="file-ref">front/src/ai-chat/useConversationStream.ts + front/src/api/ai-chat.ts</p>
<div class="two-col">
<div class="box box-green">
<h3>API Type (wire format)</h3>
<pre><code class="language-typescript">type AiChatMessage = {
id: string;
conversation_id: string;
role: string;
content: unknown; // 👀 !!!
visible_to_user: boolean;
};</code></pre>
<p class="tiny"><code>content: unknown</code> — could be a string, could be <code>{"blocks": [...]}</code>, could be your hopes and dreams.</p>
</div>
<div class="box box-blue">
<h3>UI Type (display format)</h3>
<pre><code class="language-typescript">type ChatMessage = {
id: string;
ordinal: number;
role: "user" | "assistant";
content: string;
blocks?: ParsedBlock[];
isStreaming?: boolean;
isWeeklySummary?: boolean;
};</code></pre>
<p class="tiny">ChatMessage ≠ AiChatMessage. One is the API model, one is the UI model. On purpose. (Mostly.)</p>
</div>
</div>
</section>
<section>
<h2>The Block Type Rosetta Stone</h2>
<table style="font-size: 0.45em;">
<thead>
<tr>
<th>Backend BlockKind</th>
<th>SSE Event</th>
<th>emit_* Tool</th>
<th>Frontend ParsedBlock</th>
<th>React Component</th>
</tr>
</thead>
<tbody>
<tr><td class="highlight-green">text</td><td>text_delta / text_done</td><td>emit_text</td><td><code>{ kind: "text" }</code></td><td>MarkdownContent</td></tr>
<tr><td class="highlight-green">table</td><td>block</td><td>emit_table</td><td><code>{ kind: "table" }</code></td><td>TableResponse</td></tr>
<tr><td class="highlight-green">chart</td><td>block</td><td>emit_chart</td><td><code>{ kind: "chart" }</code></td><td>ChartResponse</td></tr>
<tr><td class="highlight-green">funnel</td><td>block</td><td>emit_funnel</td><td><code>{ kind: "funnel" }</code></td><td>FunnelChart</td></tr>
<tr><td class="highlight-green">kpis</td><td>block</td><td>emit_kpis</td><td><code>{ kind: "kpis" }</code></td><td>KpiResponse</td></tr>
<tr><td class="highlight-green">suggestions</td><td>block</td><td>emit_suggestions</td><td><code>{ kind: "suggestions" }</code></td><td>SuggestionsResponse</td></tr>
<tr><td class="highlight-green">thinking</td><td>block</td><td>emit_thinking</td><td><code>{ kind: "thinking" }</code></td><td>ThinkingBlock</td></tr>
<tr><td class="highlight-green">survey</td><td>block</td><td>emit_survey</td><td><code>{ kind: "survey" }</code></td><td>SurveyProposalCard</td></tr>
<tr><td class="highlight-green">survey_card</td><td>block</td><td>emit_survey_card</td><td><code>{ kind: "survey_card" }</code></td><td>SurveyCardResponse</td></tr>
</tbody>
</table>
<p class="small">Every block type flows through the exact same pipeline: Claude calls an <code>emit_*</code> tool → backend emits SSE <code>block</code> event → frontend parses into <code>ParsedBlock</code> → React renders the component. Symmetry! 🎯</p>
</section>
</section>
<!-- ===================== ACT II: THE BACKEND ===================== -->
<section>
<section>
<div class="chapter-title">Act II</div>
<h1>Router & Auth</h1>
<h3>(The bouncer at the door 🚪)</h3>
<span class="emoji-huge">🔐</span>
</section>
<section>
<h2>Route Registration</h2>
<p class="file-ref">back/apiv2/server/router/router.go</p>
<pre><code class="language-go">// The new hotness: direct HTTP routes
aiChat := r.PathPrefix("/ai-chat").Subrouter()
aiChat.Use(firebaseAuth) // Firebase JWT validation
aiChat.HandleFunc(
"/conversations/{conversationID}/stream",
aichat.HandleConversationStream,
).Methods("GET")
aiChat.HandleFunc(
"/conversations/{conversationID}/message",
aichat.HandleSendMessage,
).Methods("POST")
aiChat.HandleFunc(
"/conversations/{conversationID}/cancel",
aichat.HandleCancelConversation,
).Methods("POST")</code></pre>
<p class="small">The streaming endpoints bypass the old router pattern entirely — standard Go HTTP handlers with <code>gorilla/mux</code> path params.</p>
</section>
<section>
<h2>The Triple Auth Gate</h2>
<p class="file-ref">back/app/aichat/auth.go</p>
<div class="mermaid-container">
<pre class="mermaid">
flowchart LR
R["Request"] --> A{"FP Employee\nor Admin?"}
A -->|No| D1["❌ 403"]
A -->|Yes| B{"Feature flag\nenabled?"}
B -->|No| D2["❌ 404\n(hidden)"]
B -->|Yes| C{"Account\napproved?"}
C -->|No| D3["❌ 403"]
C -->|Yes| E["✅ Proceed"]
style D1 fill:#da3633,color:#fff
style D2 fill:#da3633,color:#fff
style D3 fill:#da3633,color:#fff
style E fill:#238636,color:#fff
</pre>
</div>
<p class="small">Three checks, three ways to fail. And if the feature flag is off, it returns 404 (not 403) so you don't even know the feature exists. Sneaky! 🕵️</p>
</section>
</section>
<!-- ===================== THE AGENT LOOP ===================== -->
<section>
<section>
<div class="chapter-title">Act II, Scene 2</div>
<h1>The Agent Loop</h1>
<h3>(Where the real magic happens 🎩✨)</h3>
<span class="emoji-huge">🔄</span>
<p class="file-ref">back/aichat/agentloop/streaming.go</p>
</section>
<section>
<h2>The Loop Structure</h2>
<p>This is the beating heart of the system. Up to <span class="highlight-red">100 iterations</span> of Claude calls:</p>
<div class="mermaid-container">
<pre class="mermaid">
flowchart LR
S["Start:\nagentloop.Run()"] --> B["Build tools"]
B --> L{"Iter < 100?"}
L -->|Yes| API["Call Claude\n(streaming)"]
API --> EMIT["Emit SSE:\ntext_delta\nblock events"]
EMIT --> STOP{"Stop\nReason?"}
STOP -->|"tool_use"| TOOLS["Process\ntool calls"]
TOOLS --> L
STOP -->|"end_turn"| DONE["✅ Done:\nbuild final\ncontent"]
L -->|"No"| DONE
style S fill:#8b5cf6,color:#fff
style API fill:#1f6feb,color:#fff
style TOOLS fill:#da3633,color:#fff
style DONE fill:#238636,color:#fff
</pre>
</div>
</section>
<section>
<h2>Streaming Event Processing</h2>
<p class="file-ref">back/aichat/agentloop/streaming.go — the blockTracker</p>
<pre><code class="language-go">// The block tracker accumulates streaming tokens
type blockTracker struct {
blockTypes map[int64]string // index → "text" or "tool_use"
toolNames map[int64]string // index → tool name
toolIDs map[int64]string // index → tool call ID
partialJSON map[int64]*strings.Builder // index → accumulated JSON
textAccum *strings.Builder // accumulated text
}
// Anthropic SDK streaming events map to actions:
// "content_block_start" → record block type (text vs tool_use)
// "content_block_delta" → accumulate text or JSON
// → text: emit text_delta SSE event IMMEDIATELY
// → json: buffer silently (can't emit partial JSON)
// "content_block_stop" → finalize block
// → text: emit text_done SSE event
// → tool_use: parse complete JSON, process tool call</code></pre>
<p class="small"><strong class="highlight-orange">Key:</strong> Text tokens stream in real time. Tool JSON is accumulated silently until complete — can't parse half a JSON object. That's why text is smooth but charts pop in all at once.</p>
</section>
<section>
<h2>What Happens During a Tool Call</h2>
<div class="mermaid-container">
<pre class="mermaid">
sequenceDiagram
participant C as Claude API
participant AL as Agent Loop
participant MCP as Snowflake MCP
participant DB as Account DB
participant E as SSE Emitter
C->>AL: content_block_stop (tool_use: SQL_Execution_Tool)
AL->>MCP: CallTool("SQL_Execution_Tool", sql)
MCP-->>AL: Query results (≤ 8KB)
Note over AL: Add tool_result to message history
C->>AL: content_block_stop (tool_use: emit_chart)
AL->>E: SendBlock("chart", chartJSON)
Note over E: SSE: event: block\ndata: {"kind":"chart",...}
Note over AL: Return empty tool_result\n(response tools = fire-and-forget)
C->>AL: content_block_stop (tool_use: Freshpaint_Get_Account_Overview)
AL->>DB: handleGetAccountOverview(accountCtx)
DB-->>AL: Account info JSON
Note over AL: Add tool_result to message history
</pre>
</div>
<p class="small">Three kinds of tools, three different behaviors. SQL and account tools feed data back to Claude. Response tools (emit_*) push data directly to the user and give Claude an empty acknowledgement.</p>
</section>
<section>
<h2>The Final Content Assembly</h2>
<p class="file-ref">back/aichat/agentloop/streaming.go — buildFinalContent()</p>
<p>After ALL loop iterations, the accumulated blocks get compiled into one JSON blob:</p>
<pre><code class="language-json">{
"blocks": [
{ "dataType": "thinking", "summary": "Analyzing conversion data..." },
{ "dataType": "text", "content": "Here's what I found..." },
{ "dataType": "chart", "chartType": "line", "title": "CVR Over Time",
"xAxisLabel": "Week", "series": [{"name": "CVR", "data": [...]}] },
{ "dataType": "kpis", "items": [
{"label": "Total Sessions", "value": "14,230", "trend": "+12%"},
{"label": "CVR", "value": "3.2%", "trend": "-0.5%"}
]},
{ "dataType": "suggestions", "suggestions": [
"What's driving the CVR decline?",
"Show me the top converting segments"
]}
]
}</code></pre>
<p class="small">This JSON becomes the <code>Content</code> field in the persisted CROChatMessage and is also sent in the final <code>done</code> SSE event.</p>
</section>
</section>
<!-- ===================== TOOL SYSTEM ===================== -->
<section>
<section>
<div class="chapter-title">Act II, Scene 3</div>
<h1>The Tool System</h1>
<h3>(Claude's superpowers 🦸)</h3>
<span class="emoji-huge">🔧</span>
</section>
<section>
<h2>Three Tool Categories</h2>
<div class="three-col">
<div class="box box-blue">
<h3>🔍 Query Tools</h3>
<p style="font-size: 0.6em;">Feed data TO Claude</p>
<ul style="font-size: 0.55em;">
<li><code>SQL_Execution_Tool</code><br>→ Snowflake MCP</li>
<li><code>query_funnel</code><br>→ Backend funnel engine</li>
</ul>
<p style="font-size: 0.5em;">Results go back into message history for Claude to analyze</p>
</div>
<div class="box box-green">
<h3>📋 Account Tools</h3>
<p style="font-size: 0.6em;">Introspect Freshpaint</p>
<ul style="font-size: 0.55em;">
<li><code>Get_Account_Overview</code></li>
<li><code>List_Destinations</code></li>
<li><code>Get_Destination_Detail</code></li>
<li><code>List_Event_Definitions</code></li>
<li><code>Get/List_Surveys</code></li>
</ul>
<p style="font-size: 0.5em;">Results also go back to Claude</p>
</div>
<div class="box box-orange">
<h3>📺 Response Tools</h3>
<p style="font-size: 0.6em;">Push UI TO the user</p>
<p style="font-size: 0.5em;"><code>emit_text</code>, <code>emit_table</code>, <code>emit_chart</code>,<br><code>emit_funnel</code>, <code>emit_kpis</code>, <code>emit_suggestions</code>,<br><code>emit_thinking</code>, <code>emit_survey[_card]</code></p>
<p style="font-size: 0.5em;">Results go to user's screen via SSE. Claude gets an empty ACK.</p>
</div>
</div>
</section>
<section>
<h2>The Snowflake MCP Connection</h2>
<p class="file-ref">back/aichat/snowflake/mcp.go</p>
<div class="mermaid-container">
<pre class="mermaid">
sequenceDiagram
participant AL as Agent Loop
participant MCP as MCPClient
participant SF as Snowflake MCP
AL->>MCP: CallTool("SQL_Execution_Tool", sql)
MCP->>MCP: LookupCredentials (pgp_sym_decrypt)
MCP->>SF: POST JSON-RPC 2.0 tools/call
SF-->>MCP: Query results
MCP->>MCP: Truncate to 8KB if needed
MCP-->>AL: Tool result content
</pre>
</div>
<p class="small">Credentials encrypted with <code>pgp_sym_encrypt</code> in Postgres. Results hard-capped at 8KB — Claude gets a "try LIMIT" hint if truncated.</p>
</section>
<section>
<h2>Response Tools: The "emit_" Pattern</h2>
<p class="file-ref">back/aichat/tools/response.go</p>
<pre><code class="language-go">// Response tools are defined from embedded JSON schemas
//go:embed schema/tool_emit_chart.schema.json
var emitChartSchemaJSON []byte
// The magic: IsResponseTool checks for the "emit_" prefix
func IsResponseTool(name string) bool {
return strings.HasPrefix(name, "emit_")
}
// When Claude calls an emit_* tool, the backend:
// 1. Immediately emits an SSE "block" event to the user
// 2. Returns an empty tool result to Claude
// 3. Adds the block to the final content accumulator
// It's tools all the way down 🐢</code></pre>
<p class="small"><strong>Key insight:</strong> Claude doesn't generate HTML — it calls typed tools. The frontend renders each block type. The 64KB system prompt teaches Claude when to use each emit tool.</p>
</section>
</section>
<!-- ===================== SSE MACHINERY ===================== -->
<section>
<section>
<div class="chapter-title">Act II, Scene 4</div>
<h1>The SSE Machinery</h1>
<h3>(The plumbing that makes it all flow 🚿)</h3>
<span class="emoji-huge">📡</span>
</section>
<section>
<h2>The Three-Phase GET Handler</h2>
<p class="file-ref">back/app/aichat/conversation_handler.go — HandleConversationStream()</p>
<div class="three-col" style="font-size: 0.55em;">
<div class="box box-green">
<h3>Phase 1: Replay</h3>
<ol>
<li>Load persisted messages from DB</li>
<li>Filter for user visibility</li>
<li>Emit: <code>conversation_info</code></li>
<li>Emit: <code>message</code> x N</li>
<li>Emit: <code>replay_done</code></li>
</ol>
</div>
<div class="box box-blue">
<h3>Phase 2: Active Stream</h3>
<ul>
<li><code>Registry.Get(id)</code></li>
<li>If stream exists + done → return</li>
<li>If stream exists + active → <code>drainStream()</code></li>
<li>If no stream → Phase 3</li>
</ul>
</div>
<div class="box box-orange">
<h3>Phase 3: Poll</h3>
<ul>
<li>Fast poll: <strong>500ms</strong> — check registry for new stream</li>
<li>Slow keepalive: <strong>30s</strong> — prevent timeout</li>
<li>When stream appears → <code>drainStream()</code></li>
</ul>
</div>
</div>
</section>
<section>
<h2>The Stream Registry</h2>
<p class="file-ref">back/aichat/registry/registry.go</p>
<p>The in-memory event bus that connects POST (producer) to GET (consumer):</p>
<div class="mermaid-container">
<pre class="mermaid">
flowchart LR
subgraph POST["POST /message (Producer)"]
AGENT["Agent Loop"] -->|"Emit(event)"| STREAM["ConversationStream\n• events[] buffer\n• subscribers map\n• done bool"]
end
subgraph GETS["GET /stream (Consumers)"]
STREAM -->|"Subscribe()"| S1["Subscriber 1\n(original tab)"]
STREAM -->|"Subscribe()"| S2["Subscriber 2\n(reconnected tab)"]
STREAM -->|"Subscribe()"| S3["Subscriber 3\n(new tab)"]
end
STREAM -->|"Complete()"| REAPER["Reaper\n(cleanup after 5 min)"]
style POST fill:#1f6feb,color:#fff
style GETS fill:#238636,color:#fff
style REAPER fill:#da3633,color:#fff
</pre>
</div>
<p class="small"><strong>Emit()</strong> appends to buffer + fans out. <strong>Subscribe()</strong> returns ALL buffered events + channel. <strong>Complete()</strong> closes channels. <strong>Reaper</strong> cleans up after 5 min TTL.</p>
</section>
<section>
<h2>The POST → GET Race Condition</h2>
<p>What happens when GET arrives before the POST's goroutine creates the stream?</p>
<div class="mermaid-container">
<pre class="mermaid">
sequenceDiagram
participant FE as Frontend
participant POST as POST Handler
participant GET as GET Handler
participant REG as Registry
FE->>POST: POST /message
POST-->>FE: 200 OK {message_id, ordinal}
FE->>GET: GET /stream (reconnect)
GET->>REG: Registry.Get(id)
REG-->>GET: nil (not created yet!)
Note over GET: Phase 3: poll every 500ms
POST->>REG: Registry.Create(id)
GET->>REG: Registry.Get(id) [500ms later]
REG-->>GET: ConversationStream ✅
GET->>REG: Subscribe()
</pre>
</div>
<p class="small">The 500ms fast poll in Phase 3 exists specifically to handle this race.<br>Without it, the GET would miss the stream entirely. 🏎️💨</p>
</section>
<section>
<h2>SSE Wire Format</h2>
<p class="file-ref">back/aichat/emitter/internal/sse/sse.go</p>
<p>What actually goes over the wire:</p>
<pre><code class="language-text">event: conversation_info
data: {"kind":"standard"}
event: message
data: {"message_id":"abc-123","ordinal":1,"role":"user","content":"{\"blocks\":[...]}"}
event: message
data: {"message_id":"def-456","ordinal":2,"role":"assistant","content":"{\"blocks\":[...]}"}
event: replay_done
data: {"kind":"standard"}
event: text_delta
data: {"message_id":"ghi-789","ordinal":3,"delta":"Here"}
event: text_delta
data: {"message_id":"ghi-789","ordinal":3,"delta":"'s what"}
event: text_delta
data: {"message_id":"ghi-789","ordinal":3,"delta":" I found..."}
event: block
data: {"message_id":"ghi-789","ordinal":3,"kind":"chart","data":{"chartType":"bar",...}}
event: done
data: {"message_id":"ghi-789","ordinal":3,"content":"{\"blocks\":[...]}"}
event: keepalive
data: {}</code></pre>
</section>
</section>
<!-- ===================== ACT III: THE FRONTEND ===================== -->
<section>
<section>
<div class="chapter-title">Act III</div>
<h1>The SSE Client</h1>
<h3>(Parsing bytes into meaning 📖)</h3>
<span class="emoji-huge">🌐</span>
<p class="file-ref">front/src/ai-chat/sseClient.ts — 310 lines</p>
</section>
<section>
<h2>Connection Lifecycle</h2>
<pre><code class="language-typescript">// openConversationStream uses Fetch API + ReadableStream (NOT EventSource!)
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal: abortController.signal,
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let remainder = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const { events, remainder: rest } = parseSSEChunk(remainder + chunk);
remainder = rest;
for (const event of events) {
dispatchSSEEvent(event, handlers); // Route to the right handler
}
}</code></pre>
<p class="small"><strong class="highlight-orange">Why Fetch instead of EventSource?</strong> EventSource doesn't support custom headers (no <code>Authorization: Bearer</code>). So we use Fetch + ReadableStream and parse SSE manually. Classic.</p>
</section>
<section>
<h2>SSE Parsing & Dispatch</h2>
<pre><code class="language-typescript">// parseSSEChunk: Splits raw text into event objects
// Input: "event: text_delta\ndata: {\"delta\":\"Hello\"}\n\n"
// Output: [{ event: "text_delta", data: "{\"delta\":\"Hello\"}" }]
// dispatchSSEEvent: Routes events to handlers
function dispatchSSEEvent(event, handlers) {
const data = JSON.parse(event.data);
switch (event.event) {
case "conversation_info": return handlers.onConversationInfo(data);
case "message": return handlers.onMessage(data);
case "replay_done": return handlers.onReplayDone(data);
case "text_delta": return handlers.onTextDelta(data);
case "text_done": return handlers.onTextDone(data);
case "block": return handlers.onBlock(data);
case "done": return handlers.onDone(data);
case "status": return handlers.onStatus(data);
case "error": return handlers.onError(data);
case "keepalive": break; // 🥱
}
}</code></pre>
</section>
<section>
<h2>Reconnection Strategy</h2>
<div class="mermaid-container">
<pre class="mermaid">
flowchart LR
DISC["Connection\nLost"] --> TERM{"Terminal\nevent?"}
TERM -->|"yes"| STOP["❌ Stop"]
TERM -->|"no"| RETRY{"Retries\n< 5?"}
RETRY -->|"no"| GIVEUP["❌ Give up"]
RETRY -->|"yes"| WAIT["Backoff:\n1s→2s→4s→30s"]
WAIT --> RECONNECT["🔄 Reconnect"]
RECONNECT --> DATA{"Data?"}
DATA -->|"yes"| RESET["Reset\ncounters"]
DATA -->|"no"| RETRY
style STOP fill:#da3633,color:#fff
style GIVEUP fill:#da3633,color:#fff
style RECONNECT fill:#238636,color:#fff
style RESET fill:#238636,color:#fff
</pre>
</div>
<p class="small">Exponential backoff: 1s → 2s → 4s → 8s → 16s → 30s (capped).<br>Max 5 retries without receiving any data. Resets on success. Elegant! 🎩</p>
</section>
</section>
<!-- ===================== THE STREAM HOOK ===================== -->
<section>
<section>
<div class="chapter-title">Act III, Scene 2</div>
<h1>useConversationStream</h1>
<h3>(The 537-line hook that does everything 😅)</h3>
<span class="emoji-huge">🪝</span>
<p class="file-ref">front/src/ai-chat/useConversationStream.ts</p>
</section>
<section>
<h2>Hook State Machine</h2>
<div class="mermaid-container">
<pre class="mermaid">
stateDiagram-v2
direction LR
[*] --> Loading: Mount
Loading --> Replaying: SSE opens
Replaying --> Replaying: onMessage
Replaying --> Ready: onReplayDone
Ready --> Streaming: sendPrompt()
Streaming --> Streaming: onTextDelta / onBlock
Streaming --> Ready: onDone / onError / cancel
</pre>
</div>
</section>
<section>
<h2>The Send Flow</h2>
<pre><code class="language-typescript">async function sendPrompt(prompt: string, kind: ConversationKind) {
// 1. Create optimistic user message
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
ordinal: Infinity, // Will be replaced by server ordinal
role: "user",
content: prompt,
};
// 2. Create streaming placeholder for assistant
const placeholder: ChatMessage = {
id: crypto.randomUUID(),
ordinal: Infinity,
role: "assistant",
content: "",
blocks: [],
isStreaming: true, // Shows pulsing dots
};
// 3. Add both to messages immediately (optimistic UI!)
setMessages(prev => [...prev, userMsg, placeholder]);
// 4. POST to server — get back the real message_id + ordinal
const ack = await sendMessage(conversationId, envId, { ... });
// 5. Reconnect SSE to pick up the new stream
conn.reconnect();
}</code></pre>
</section>
<section>
<h2>Text Streaming Accumulation</h2>
<pre><code class="language-typescript">// The textAccumRef — tracks streaming text state
const textAccumRef = useRef({
messageId: "",
content: "", // Growing string of text
hasActiveTextBlock: false,
});
// onTextDelta: Called for every token from Claude
function onTextDelta(data: { message_id, ordinal, delta }) {
const accum = textAccumRef.current;
accum.content += data.delta; // Append the new token
if (!accum.hasActiveTextBlock) {
// First delta for this block — create a new text block
appendBlockToMessage(data.message_id, {
kind: "text",
content: accum.content,
});
accum.hasActiveTextBlock = true;
} else {
// Update the LAST block's content (in-place mutation for perf)
updateLastBlock(data.message_id, (block) => ({
...block,
content: accum.content,
}));
}
}
// onTextDone: Reset the accumulator
function onTextDone(data) {
textAccumRef.current = { messageId: "", content: "", hasActiveTextBlock: false };
}</code></pre>
<p class="small">This is why text streams smoothly — each token appends to the accumulator, and the last text block is updated in place. React re-renders with the growing string.</p>
</section>
<section>
<h2>Block Event Handling</h2>
<pre><code class="language-typescript">// onBlock: Structured data blocks arrive all at once
function onBlock(data: { message_id, ordinal, kind, data: unknown }) {
const parsed = parseSSEBlock(data.kind, data.data);
// ↑ "chart" ↑ {chartType: "bar", ...}
if (!parsed) return; // Invalid block, skip
// Append the parsed block to the streaming message
const idx = findOrAssignMessageIndex(data.message_id, data.ordinal);
setMessages(prev => {
const updated = [...prev];
const msg = { ...updated[idx] };
msg.blocks = [...(msg.blocks || []), parsed];
updated[idx] = msg;
return updated;
});
}
// onDone: Stream complete — set final content, stop streaming indicator
function onDone(data: { message_id, ordinal, content }) {
const finalBlocks = parseStructuredResponse(data.content);
// Replace streaming blocks with the canonical final version
setMessages(prev => /* update message with final blocks, isStreaming=false */);
}</code></pre>
<div class="box box-green" style="font-size: 0.6em;">
<p><strong>Key detail:</strong> When <code>onDone</code> fires, the message gets its FINAL content from the server, which may differ slightly from the incrementally-built streaming version. This ensures consistency between what's displayed and what's persisted.</p>
</div>
</section>
<section>
<h2>The Message Merge Magic</h2>
<p>During replay, consecutive assistant messages get merged:</p>
<pre><code class="language-typescript">// Why merge? Because multi-turn tool calls create this in the DB:
// ordinal 2: assistant [text + SQL tool_use]
// ordinal 3: user [tool_result] ← hidden
// ordinal 4: assistant [text + emit_chart tool_use]
// ordinal 5: user [tool_result] ← hidden
// ordinal 6: assistant [final text]
//
// After filtering out hidden messages:
// ordinal 2: assistant [text]
// ordinal 4: assistant [chart]
// ordinal 6: assistant [text]
//
// These LOOK like 3 separate messages but the user saw ONE response!
// mergeConsecutiveAssistantMessages() combines them:
// → assistant [text + chart + text] ← one message, all blocks
function mergeConsecutiveAssistantMessages(msgs: ChatMessage[]): ChatMessage[] {
// Walk through messages, combine consecutive assistant msgs
// Keep the FIRST message's id, accumulate all blocks
}</code></pre>
<p class="small">This is called in <code>onReplayDone</code> after all historical messages are buffered.<br>Without it, reloading the page would show a confusing split conversation. 🧩</p>
</section>
</section>
<!-- ===================== BLOCK PARSING ===================== -->
<section>
<section>
<div class="chapter-title">Act III, Scene 3</div>
<h1>Block Parsing</h1>
<h3>(Turning JSON into React components 🧪)</h3>
<span class="emoji-huge">🧬</span>
</section>
<section>
<h2>Two Parsing Paths</h2>
<p class="file-ref">front/src/ai-chat/parseStructuredResponse.ts</p>
<div class="two-col">
<div class="box box-green">
<h3>Streaming: parseSSEBlock()</h3>
<pre><code class="language-typescript">parseSSEBlock(kind, data)
// kind from SSE event name
// Zod schema WITHOUT dataType
// → ParsedBlock | null</code></pre>
</div>
<div class="box box-blue">
<h3>Replay: parseStructuredResponse()</h3>
<pre><code class="language-typescript">parseStructuredResponse(raw)
// raw: '{"blocks":[...]}'
// Zod schema WITH dataType
// → ParsedBlock[] | null</code></pre>
</div>
</div>
<p class="small"><strong class="highlight-orange">Why two paths?</strong> During streaming, <code>kind</code> comes from the SSE event name (no discriminator needed). During replay, blocks are in a JSON array and need <code>dataType</code> to tell them apart. Same data, different packaging. 📦</p>
</section>
<section>
<h2>Zod Schema Validation</h2>
<p class="file-ref">front/src/ai-chat/schema/assistantResponse.ts</p>
<pre><code class="language-typescript">// Every block type has a Zod schema with runtime validation
const ChartBlockSchema = z.object({
dataType: z.literal("chart"),
chartType: z.enum(["bar", "line", "area"]),
title: z.string(),
xAxisLabel: z.string(),
yAxisLabel: z.string().optional(),
series: z.array(z.object({
name: z.string(),
data: z.array(z.union([z.number(), z.null()])),
})),
categories: z.array(z.string()).optional(),
dataSource: z.string().optional(),
});
// Response types strip the discriminator for component props:
type ChartResponseBlock = Omit&lt;ChartBlock, "dataType"&gt;
// The full schema is a discriminated union:
const BlockSchema = z.discriminatedUnion("dataType", [
TextBlockSchema, TableBlockSchema, ChartBlockSchema,
FunnelBlockSchema, KpiBlockSchema, SuggestionsBlockSchema,
ThinkingBlockSchema, SurveyBlockSchema, SurveyCardBlockSchema,
]);</code></pre>
<p class="small">Zod catches malformed blocks at runtime before they crash a React component.<br>If parsing fails, the block is silently dropped. Better no chart than a broken chart. 📊💥</p>
</section>
</section>
<!-- ===================== COMPONENT TREE ===================== -->
<section>
<section>
<div class="chapter-title">Act III, Scene 4</div>
<h1>The Component Tree</h1>
<h3>(Where blocks become pixels 🖼️)</h3>
<span class="emoji-huge">🌳</span>
</section>
<section>
<h2>Component Hierarchy</h2>
<div class="mermaid-container">
<pre class="mermaid">
flowchart LR
PAGE["AiChatPage"] --> GATE["ApprovalGate"]
GATE --> SIDEBAR["Sidebar"]
GATE --> CHAT["ChatArea"]
CHAT --> LOOP["messages.map()"]
LOOP --> USER["User Bubble"]
LOOP --> ASST["AssistantMessage"]
ASST --> BLOCKS["ThinkingBlock\nMarkdownContent\nTableResponse\nChartResponse\nFunnelChart\nKpiResponse\nSuggestionsResponse\nSurveyProposalCard\nPulsingDots"]
style PAGE fill:#8b5cf6,color:#fff
style CHAT fill:#1f6feb,color:#fff
style ASST fill:#238636,color:#fff
style BLOCKS fill:#d29922,color:#000
</pre>
</div>
</section>
<section>
<h2>AssistantMessage: The Block Router</h2>
<p class="file-ref">front/src/ai-chat/components/AssistantMessage.tsx</p>
<pre><code class="language-typescript">// This component is beautifully simple — it just maps blocks to components
function AssistantMessage({ blocks, isStreaming, onSuggestionSelect, ... }) {
const thinkingBlocks = blocks.filter(b => b.kind === "thinking");
const contentBlocks = blocks.filter(b => b.kind !== "thinking");
return (
&lt;&gt;
{thinkingBlocks.length > 0 && (
&lt;ThinkingBlock steps={thinkingBlocks} isStreaming={isStreaming} /&gt;
)}
{contentBlocks.map((block, i) => {
switch (block.kind) {
case "text": return &lt;MarkdownContent content={block.content} /&gt;;
case "table": return &lt;TableResponse data={block.data} /&gt;;
case "chart": return &lt;ChartResponse data={block.data} /&gt;;
case "funnel": return &lt;FunnelChart data={block.data} /&gt;;
case "kpis": return &lt;KpiResponse data={block.data} /&gt;;
case "suggestions": return &lt;SuggestionsResponse ... /&gt;;
case "survey": return &lt;SurveyProposalCard ... /&gt;;
case "survey_card": return &lt;SurveyCardResponse ... /&gt;;
}
})}
{isStreaming && &lt;PulsingDots /&gt;}
&lt;/&gt;
);
}</code></pre>
<p class="small">Clean switch statement. Each block type gets its own component. No inheritance, no polymorphism, just a good old-fashioned switch. Sometimes simple is best. ✨</p>
</section>
</section>
<!-- ===================== GRAND FINALE ===================== -->
<section>
<section>
<div class="chapter-title">Grand Finale</div>
<h1>The Complete Data Flow</h1>
<h3>(Every step, from keypress to pixels 🎬)</h3>
<span class="emoji-huge">🏁</span>
</section>
<section>
<h2>Step 1: User Types a Question</h2>
<div class="mermaid-container">
<pre class="mermaid">
sequenceDiagram
participant U as User (Browser)
participant CA as ChatArea
participant H as useConversationStream
participant SSE as sseClient
U->>CA: Types "Show me conversion rates"
U->>CA: Presses Enter
CA->>H: sendPrompt("Show me conversion rates", "standard")
Note over H: Create optimistic messages:
Note over H: 1. User msg (ordinal=∞)
Note over H: 2. Assistant placeholder (ordinal=∞, isStreaming=true)
H->>CA: setMessages([...prev, userMsg, placeholder])
Note over CA: React re-renders with user bubble + pulsing dots
H->>SSE: sendMessage(POST /message)
SSE-->>H: { message_id: "abc", ordinal: 3 }
H->>SSE: conn.reconnect()
Note over SSE: Closes old connection, opens new GET /stream
</pre>
</div>
</section>
<section>
<h2>Step 2: Backend Processes the Request</h2>
<div class="mermaid-container">
<pre class="mermaid">
sequenceDiagram
participant FE as Frontend
participant POST as POST Handler
participant DB as PostgreSQL
participant REG as Registry
participant AL as Agent Loop
FE->>POST: POST /message {content, env_id, kind}
POST->>POST: Auth check (triple gate)
POST->>DB: Persist user message (ordinal assigned)
POST->>DB: UpdateProcessingStatus(processing)
POST-->>FE: 200 OK {message_id, ordinal: 3}
Note over POST: Launch background goroutine
POST->>REG: Registry.Create(conversationID)
Note over REG: New ConversationStream created
POST->>AL: agentloop.Run() with registryEmitter
Note over AL: The agent loop begins...
</pre>
</div>
</section>
<section>
<h2>Step 3a: Claude Streams Text & Queries Data</h2>
<div class="mermaid-container">
<pre class="mermaid">
sequenceDiagram
participant AL as Agent Loop
participant C as Claude (Sonnet 4.6)
participant SF as Snowflake MCP
participant REG as Registry → SSE
AL->>C: Messages + Tools + System Prompt (64KB)
C-->>AL: text_delta "Let me "
AL->>REG: emit text_delta
C-->>AL: text_delta "check..."
AL->>REG: emit text_delta
C-->>AL: content_block_stop (text)
AL->>REG: emit text_done
C-->>AL: tool_use: SQL_Execution_Tool
AL->>SF: CallTool(sql)
SF-->>AL: Results (≤ 8KB)
AL->>C: Continue with tool_result
</pre>
</div>
</section>
<section>
<h2>Step 3b: Claude Emits UI Blocks</h2>
<div class="mermaid-container">
<pre class="mermaid">
sequenceDiagram
participant AL as Agent Loop
participant C as Claude (Sonnet 4.6)
participant REG as Registry → SSE
C-->>AL: tool_use: emit_chart
AL->>REG: emit block {kind:"chart"}
C-->>AL: tool_use: emit_kpis
AL->>REG: emit block {kind:"kpis"}
C-->>AL: tool_use: emit_suggestions
AL->>REG: emit block {kind:"suggestions"}
C-->>AL: end_turn
AL->>REG: emit done {content: final JSON}
</pre>
</div>
<p class="small">Query tools feed data back to Claude. Response tools (emit_*) push blocks directly to the user's screen.</p>
</section>
<section>
<h2>Step 4a: Frontend Streams Text</h2>
<div class="mermaid-container">
<pre class="mermaid">
sequenceDiagram
participant SSE as SSE Client
participant H as useConversationStream
participant R as React
SSE->>H: onTextDelta "Let me "
H->>H: textAccumRef += delta
H->>R: MarkdownContent re-renders
SSE->>H: onTextDelta "check..."
H->>R: Update last text block
SSE->>H: onTextDone
H->>H: Reset accumulator
</pre>
</div>
<p class="small">Each text_delta appends to the accumulator. The last text block is updated in-place. React re-renders with the growing string.</p>
</section>
<section>
<h2>Step 4b: Frontend Receives Blocks & Done</h2>
<div class="mermaid-container">
<pre class="mermaid">
sequenceDiagram
participant SSE as SSE Client
participant H as useConversationStream
participant P as parseSSEBlock
participant R as React Components
SSE->>H: onBlock {kind:"chart", data:{...}}
H->>P: parseSSEBlock("chart", data)
P-->>H: ParsedBlock
H->>R: ChartResponse appears! 📊
SSE->>H: onBlock {kind:"kpis", data:{...}}
H->>R: KpiResponse appears! 📈
SSE->>H: onDone {content: finalJSON}
H->>H: Replace streaming blocks with final
H->>H: isStreaming = false
H->>R: PulsingDots disappears ✅
</pre>
</div>
</section>
<section>
<h2>Step 5: Persistence (Backend Cleanup)</h2>
<div class="mermaid-container">
<pre class="mermaid">
sequenceDiagram
participant AL as Agent Loop
participant POST as POST Handler
participant DB as PostgreSQL
participant REG as Registry
AL-->>POST: Returns {content, messages, nil}
POST->>DB: AddMessagesToConversation(messages)
POST->>DB: UpdateProcessingStatus(idle)
POST->>REG: stream.Complete()
Note over REG: Stream buffered 5 min, then reaped
</pre>
</div>
<p class="small">Messages are persisted AFTER the stream completes, not during.<br>If the server crashes mid-stream, you lose the in-progress response. But the next GET /stream will replay whatever was already saved. Tradeoff accepted. 🤷</p>
</section>
</section>
<!-- ===================== CONTENT FILTERING ===================== -->
<section>
<section>
<div class="chapter-title">Bonus</div>
<h1>Content Filtering</h1>
<h3>(What the user sees vs what's really there 👀)</h3>
<span class="emoji-huge">🔍</span>
</section>
<section>
<h2>Backend: contentfilter</h2>
<p class="file-ref">back/aichat/contentfilter/contentfilter.go</p>
<div class="mermaid-container">
<pre class="mermaid">
flowchart LR
subgraph DB["What's in the DB"]
M1["user msg\n'Show me CVR'"]
M2["assistant msg\ntext + SQL_Execution_Tool\n+ emit_chart"]
M3["user msg\n(tool_result for SQL)\nvisible_to_user=false"]
M4["assistant msg\ntext + emit_kpis"]
M5["user msg\n(tool_result for kpis)\nvisible_to_user=false"]
M6["assistant msg\nfinal text"]
M7["system msg\n(turn_complete)\nvisible_to_user=false"]
end
subgraph FILTER["After Filtering"]
F1["user msg\n'Show me CVR'"]
F2["assistant msg\ntext + emit_chart\n(SQL tool stripped!)"]
F4["assistant msg\ntext + emit_kpis"]
F6["assistant msg\nfinal text"]
end
DB --> |"FilterMessagesForUser()"| FILTER
style M3 fill:#da3633,color:#fff
style M5 fill:#da3633,color:#fff
style M7 fill:#da3633,color:#fff
</pre>
</div>
<pre><code class="language-go">// A block is visible if it's text OR an emit_* tool call
func IsUserVisibleBlock(block map[string]any) bool {
blockType, _ := block["type"].(string)
if blockType == "text" { return true }
name, _ := block["name"].(string)
return blockType == "tool_use" && strings.HasPrefix(name, "emit_")
}</code></pre>
</section>
<section>
<h2>Frontend: extractAssistantBlocks()</h2>
<p class="file-ref">front/src/ai-chat/useConversationStream.ts</p>
<pre><code class="language-typescript">// During replay, the frontend also filters content
function extractAssistantBlocks(content: unknown): ParsedBlock[] {
const blocks = (content as { blocks: unknown[] }).blocks;
const result: ParsedBlock[] = [];
for (const block of blocks) {
// Plain text → always show
if (block.type === "text") {
result.push({ kind: "text", content: block.text });
continue;
}
// Tool calls → only show emit_* tools
if (block.type !== "tool_use") continue;
if (!block.name.startsWith("emit_")) continue; // 👈 THE FILTER
// Parse the tool input as a block
const kind = block.name.slice("emit_".length); // "emit_chart" → "chart"
const parsed = parseSSEBlock(kind, block.input);
if (parsed) result.push(parsed);
}
return result;
}</code></pre>
<div class="box box-orange" style="font-size: 0.65em;">
<p><strong>Double filtering:</strong> Backend filters before sending replayed messages, AND the frontend filters when parsing. Belt and suspenders again. 🩳🩳<br>
The frontend filter handles the case where the backend sends the raw persisted content (which includes all tool calls) in the <code>done</code> event's final content.</p>
</div>
</section>
</section>
<!-- ===================== CANCELLATION ===================== -->
<section>
<section>
<div class="chapter-title">Bonus</div>
<h1>Cancellation Flow</h1>
<h3>(When you change your mind mid-thought 🛑)</h3>
<span class="emoji-huge">✋</span>
</section>
<section>
<h2>How Cancellation Works End-to-End</h2>
<div class="mermaid-container">
<pre class="mermaid">
sequenceDiagram
participant U as User
participant FE as Frontend
participant API as POST /cancel
participant REG as Registry
participant AL as Agent Loop
U->>FE: Clicks "Stop"
FE->>FE: isStreaming = false
FE->>API: POST /cancel
API->>REG: stream.Cancel()
REG->>AL: context.Cancel()
AL->>AL: break loop + save cancelled msg
AL-->>REG: Return → stream.Complete()
</pre>
</div>
<p class="small">The frontend updates immediately (optimistic UI), then sends the cancel request.<br>If the cancel request fails, no big deal — the user already sees the response as stopped.<br>The backend cleans up in the background. 🧹</p>
</section>
</section>
<!-- ===================== FILE MAP ===================== -->
<section>
<section>
<div class="chapter-title">Reference</div>
<h1>Complete File Map</h1>
<h3>(Your GPS for the codebase 🗺️)</h3>
<span class="emoji-huge">📂</span>
</section>
<section>
<h2>Backend Files</h2>
<table style="font-size: 0.35em;">
<thead>
<tr><th>File</th><th>What It Does</th><th>Key Functions</th></tr>
</thead>
<tbody>
<tr><td>back/app/aichat/conversation_handler.go</td><td>HTTP handlers (stream, send, cancel)</td><td>HandleConversationStream, HandleSendMessage</td></tr>
<tr><td>back/app/aichat/request_handler.go</td><td>Old router pattern handlers</td><td>handleListConversations, handleSaveConversation</td></tr>
<tr><td>back/app/aichat/auth.go</td><td>Triple auth gate</td><td>RequireAIChatAccess</td></tr>
<tr><td>back/aichat/types/types.go</td><td>Data models</td><td>CROChatConversation, CROChatMessage, MessageContext</td></tr>
<tr><td>back/aichat/db/db.go</td><td>Database operations</td><td>GetConversationWithMessages, AddMessagesToConversation</td></tr>
<tr><td>back/aichat/registry/registry.go</td><td>In-memory event bus</td><td>ConversationStream, StreamRegistry</td></tr>
<tr><td>back/aichat/emitter/emitter.go</td><td>SSE event types & interface</td><td>Emitter interface, BlockKind, EventName</td></tr>
<tr><td>back/aichat/agentloop/streaming.go</td><td>Claude API integration loop</td><td>Run(), blockTracker, buildFinalContent()</td></tr>
<tr><td>back/aichat/agentloop/codec.go</td><td>Message serialization</td><td>MarshalContentBlocks, ReconstructMessageParams</td></tr>
<tr><td>back/aichat/messages/accumulator.go</td><td>Message builder during streaming</td><td>Accumulator, AddAssistantMessageWithID</td></tr>
<tr><td>back/aichat/messages/replay.go</td><td>Replay persisted messages as SSE</td><td>ReplayMessagesAsSSE</td></tr>
<tr><td>back/aichat/tools/tools.go</td><td>Tool definitions + 64KB system prompt</td><td>SQLToolDefinition, AccountToolDefinitions</td></tr>
<tr><td>back/aichat/tools/response.go</td><td>Response tool definitions (emit_*)</td><td>ResponseToolDefinitions, IsResponseTool</td></tr>
<tr><td>back/aichat/tools/account.go</td><td>Account introspection tools</td><td>AccountContext, handleGetAccountOverview</td></tr>
<tr><td>back/aichat/contentfilter/contentfilter.go</td><td>Filter internal tool calls</td><td>FilterMessagesForUser, IsUserVisibleBlock</td></tr>
<tr><td>back/aichat/snowflake/mcp.go</td><td>Snowflake MCP client</td><td>MCPClient.CallTool, ResolveMCPEndpoint</td></tr>
</tbody>
</table>
</section>
<section>
<h2>Frontend Files</h2>
<table style="font-size: 0.45em;">
<thead>
<tr><th>File</th><th>What It Does</th><th>Lines</th></tr>
</thead>
<tbody>
<tr><td>front/src/ai-chat/useConversationStream.ts</td><td>Main state hook (SSE handlers, message mgmt)</td><td>537</td></tr>
<tr><td>front/src/ai-chat/sseClient.ts</td><td>SSE connection, parsing, reconnection</td><td>310</td></tr>
<tr><td>front/src/ai-chat/parseStructuredResponse.ts</td><td>Block parsing (streaming + replay)</td><td>151</td></tr>
<tr><td>front/src/ai-chat/schema/assistantResponse.ts</td><td>Zod schemas for all block types</td><td>298</td></tr>
<tr><td>front/src/ai-chat/AiChatPage.tsx</td><td>Page container</td><td>152</td></tr>
<tr><td>front/src/ai-chat/AiChatPage/ChatArea.tsx</td><td>Message display + input</td><td>~500</td></tr>
<tr><td>front/src/ai-chat/components/AssistantMessage.tsx</td><td>Block → component router</td><td>94</td></tr>
<tr><td>front/src/ai-chat/components/MarkdownContent.tsx</td><td>Markdown text rendering</td><td>~120</td></tr>
<tr><td>front/src/ai-chat/components/ChartResponse.tsx</td><td>Highcharts visualization</td><td>114</td></tr>
<tr><td>front/src/ai-chat/components/TableResponse.tsx</td><td>Data table rendering</td><td>39</td></tr>
<tr><td>front/src/ai-chat/components/FunnelChart.tsx</td><td>Funnel viz + save</td><td>110</td></tr>
<tr><td>front/src/ai-chat/components/KpiResponse.tsx</td><td>KPI metric cards</td><td>44</td></tr>
<tr><td>front/src/ai-chat/components/SuggestionsResponse.tsx</td><td>Follow-up prompt buttons</td><td>37</td></tr>
<tr><td>front/src/ai-chat/components/SurveyProposalCard.tsx</td><td>Survey proposal carousel</td><td>~100</td></tr>
<tr><td>front/src/ai-chat/components/ThinkingBlock.tsx</td><td>Collapsible thinking display</td><td>~60</td></tr>
<tr><td>front/src/api/ai-chat.ts</td><td>API types</td><td>~40</td></tr>
</tbody>
</table>
</section>
</section>
<!-- ===================== SUMMARY ===================== -->
<section>
<section>
<h1>Key Takeaways</h1>
<span class="emoji-huge">🧠</span>
</section>
<section>
<h2>The Big Ideas</h2>
<div class="two-col">
<div>
<div class="box box-green" style="font-size: 0.6em;">
<h3>1. Tools as UI</h3>
<p>Claude doesn't generate HTML. It calls <code>emit_*</code> tools with typed schemas. The frontend renders typed blocks. Clean separation.</p>
</div>
<div class="box box-blue" style="font-size: 0.6em;">
<h3>2. Registry as Event Bus</h3>
<p>The in-memory ConversationStream buffers events and fans out to multiple subscribers. POST produces, GET consumes. Late joiners get replay.</p>
</div>
<div class="box box-orange" style="font-size: 0.6em;">
<h3>3. Optimistic UI</h3>
<p>Frontend creates placeholder messages immediately. Server ordinals arrive later. Messages merge on replay. The user never waits.</p>
</div>
</div>
<div>
<div class="box box-purple" style="font-size: 0.6em;">
<h3>4. Double Content Filtering</h3>
<p>Internal tool calls (SQL, account queries) are stripped server-side AND client-side. Users only see text + emit_* blocks.</p>
</div>
<div class="box box-red" style="font-size: 0.6em;">
<h3>5. Two Parsing Paths</h3>
<p>Streaming blocks come via SSE events (kind from event name). Replayed blocks come via JSON (kind from dataType discriminator). Same destination, different routes.</p>
</div>
<div class="box" style="font-size: 0.6em;">
<h3>6. Deferred Persistence</h3>
<p>Messages stream to the user in real time but aren't saved until the loop completes. The trade-off: crash = lost response, but no partial database state.</p>
</div>
</div>
</div>
</section>
<section>
<h2>The Full Picture (One Last Time)</h2>
<div class="mermaid-container">
<pre class="mermaid">
flowchart LR
subgraph FE["Frontend"]
INPUT["ChatArea"] --> SEND["sendPrompt"]
SSE_IN["SSE Client"] --> HOOK["useConversation\nStream"]
HOOK --> PARSE["parseSSEBlock"]
PARSE --> RENDER["AssistantMessage"]
end
subgraph BE["Backend"]
POST["POST /message"] --> AGENT["agentloop.Run()"]
AGENT --> CLAUDE["Claude 4.6"]
CLAUDE --> TOOLS["Tools"]
TOOLS --> AGENT
AGENT --> REG["Registry"]
GET["GET /stream"] --> REG
end
SEND --> POST
REG --> SSE_IN
style FE fill:#0d1117,stroke:#238636,color:#fff
style BE fill:#0d1117,stroke:#1f6feb,color:#fff
</pre>
</div>
</section>
<section>
<h1>You Made It! 🎉</h1>
<h3>Now go forth and simplify this glorious mess</h3>
<p class="small">Total codebase: ~9,682 lines of Go + ~6,712 lines of TypeScript<br>
Total SSE event types: 10<br>
Total block types: 9<br>
Total tool categories: 3<br>
Total ways to parse a message: at least 4<br>
Total ways to lose your sanity: ∞</p>
<p class="tiny" style="margin-top: 40px;">Built with love, caffeine, and 4 parallel research agents by Claude Opus 4.6<br>
April 2026 — Freshpaint Engineering</p>
</section>
</section>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.js"></script>
<script src="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/plugin/highlight/highlight.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script>
// Initialize Mermaid
mermaid.initialize({
startOnLoad: false,
theme: 'dark',
themeVariables: {
darkMode: true,
background: '#161b22',
primaryColor: '#1f6feb',
primaryTextColor: '#e6edf3',
primaryBorderColor: '#30363d',
lineColor: '#58a6ff',
secondaryColor: '#238636',
tertiaryColor: '#d29922',
fontFamily: 'SF Mono, Fira Code, monospace',
fontSize: '14px',
},
flowchart: { curve: 'basis', padding: 15 },
sequence: { actorMargin: 50, noteMargin: 10 },
});
// Render Mermaid diagrams
async function renderMermaid() {
const elements = document.querySelectorAll('.mermaid');
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
if (el.getAttribute('data-processed')) continue;
const graphDef = el.textContent.trim();
try {
const { svg } = await mermaid.render(`mermaid-${i}`, graphDef);
el.innerHTML = svg;
el.setAttribute('data-processed', 'true');
} catch (e) {
console.warn('Mermaid render failed for diagram', i, e);
}
}
}
// Auto-shrink slides whose content exceeds the configured height
function autoShrinkSlides() {
const configH = 720;
const usableH = configH * 0.92; // 4% margin each side = 662px
const slides = Reveal.getSlides();
slides.forEach(slide => {
// Skip title/chapter slides (only have h1/h2 + emoji)
if (slide.children.length <= 3 && !slide.querySelector('pre, .mermaid-container, table, .box, .two-col, .three-col')) return;
// Measure natural content height including mermaid SVGs
let totalH = 0;
Array.from(slide.children).forEach(child => {
const s = window.getComputedStyle(child);
const mt = parseFloat(s.marginTop) || 0;
const mb = parseFloat(s.marginBottom) || 0;
// For mermaid containers, measure the SVG inside
const svg = child.querySelector('svg');
const childH = svg ? Math.max(child.offsetHeight, svg.getBoundingClientRect().height) : child.offsetHeight;
totalH += childH + mt + mb;
});
const naturalH = Math.max(totalH, slide.scrollHeight);
// Remove any previous shrink class
slide.classList.remove('shrink-mild', 'shrink-moderate', 'shrink-heavy');
if (naturalH > usableH + 5) {
const ratio = usableH / naturalH;
if (ratio < 0.68) slide.classList.add('shrink-heavy');
else if (ratio < 0.85) slide.classList.add('shrink-moderate');
else slide.classList.add('shrink-mild');
}
});
}
// Initialize Reveal.js
Reveal.initialize({
hash: true,
slideNumber: true,
transition: 'slide',
transitionSpeed: 'fast',
backgroundTransition: 'fade',
center: true,
width: 1280,
height: 720,
margin: 0.04,
plugins: [RevealHighlight],
}).then(() => {
renderMermaid();
// Re-render on slide change (for lazy-loaded slides)
Reveal.on('slidechanged', () => {
setTimeout(renderMermaid, 100);
});
// Auto-shrink after mermaid renders (give it time)
setTimeout(autoShrinkSlides, 1500);
// Re-shrink after any mermaid re-renders
Reveal.on('slidechanged', () => {
setTimeout(autoShrinkSlides, 200);
});
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment