9

Agentic Chatbot Mixer
Bringing A.I. models together
Building a UI for agentic AI is a different problem than building a UI for a tool. When multiple models can speak without being asked, relay ideas to each other, and DM you on the side, a single text box doesn't really cut it. So the app uses a messenger metaphor β inbox, threads, typing indicators, read receipts. People already know how that works.
Each model has a solo thread plus the shared group chat. Models will message you without being asked β so the UI has to handle state changing without user input, multiple streams running at once, messages landing in different threads. That's just the reality of an agentic UI.
Component Architecture
The app is split into focused components β ChatWindow, InboxList, AiChat, GroupChat, AiChatBubble, PresetPicker β none of which know about each other. They communicate through a Zustand store. Styles use scoped CSS custom properties: global tokens live in globals.css, overrides scope to the component root. That's how the whole UI can be re-themed in one place, and how it sits inside this blog post without leaking styles either way.
// ChatWindow.tsx β panel transition without unmounting during animation
const [mountedThread, setMountedThread] = useState<string | null>(GROUP_THREAD_ID);
const exitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Keep the thread mounted for 320ms so the CSS exit animation completes
// before React removes it from the tree
const handleBack = () => {
setOpenThread(null);
exitTimerRef.current = setTimeout(() => setMountedThread(null), 320);
};
// Two panels toggled by class β inbox slides out as thread slides in
<div className={[styles.panel, threadOpen ? styles.inboxHidden : styles.inboxVisible].join(' ')}>
<InboxList ... />
</div>
<div className={[styles.panel, threadOpen ? styles.threadVisible : styles.threadHidden].join(' ')}>
{/* key includes presetVersion so preset loads reset AiChat state cleanly */}
{mountedThread && <AiChat key={`${mountedThread}-${presetVersion}`} ... />}
</div>Real-Time UI
Every message opens an SSE stream. The client processes typed events in sequence β start, delta, relay, dm, done β and each maps to something visible: a typing bubble, streaming tokens, an unread badge on another thread. The UI is just reacting to a predictable event shape. Group chat is trickier β multiple models respond in one stream, so the UI has to sequence their reveals independently. scheduleReveal delays each message proportional to its length, so a short reply appears faster than a long one. Without that, everything dumps at once and it doesn't feel like a conversation.
// GroupChat.tsx β chain model reveals with timing proportional to message length
scheduleReveal = (modelId, fullContent) => {
const myIndex = localParticipants.findIndex(x => x.modelId === modelId);
const isEmpty = !fullContent.trim();
window.setTimeout(() => {
// Swap typing bubble for full content
setStreaming(prev => ({ ...prev, [modelId]: { ...prev[modelId], content: fullContent, done: true } }));
onModelComplete?.();
// Chain to next participant β trigger immediately if content is ready,
// otherwise register as pending (content may still be buffering from delta events)
const next = localParticipants[myIndex + 1];
if (next) {
const nextContent = readyToReveal[next.modelId];
if (nextContent !== undefined) {
scheduleReveal(next.modelId, nextContent);
} else {
pendingChainTrigger.add(next.modelId);
}
}
}, isEmpty ? 0 : getResponseDelay(fullContent)); // delay scales with message length
};How a Turn Works
Sending a message is two steps from the client: POST to /agent/turn, get a runId back in milliseconds, then open an SSE stream and react to events. The UI never waits on AI β it hands off and listens. That's what makes group turns possible at all.
Client-side turn flow
Submit
User submits input. The component POSTs { type, modelId?, message } to /agent/turn and immediately clears the input β no waiting.
Route Returns runId
The route loads session state, runs the orchestrator, starts the workflow, and returns { runId } in under 2 seconds. The client is back in control.
SSE Stream Opens
The component opens EventSource(/stream/{runId}). A typing bubble appears when the start event arrives.
Tokens Stream In
delta events append tokens to the bubble in real time. The scroll container tracks the bottom of the message list automatically.
End / DM / Relay
end event stamps a read receipt. For group turns, the next model's typing bubble begins. dm events inject messages into background threads and trigger unread badges.
Stream Closes
done event closes the stream. Final messages are committed to Zustand. The workflow has already written to Blob β a page refresh hydrates cleanly.
// AiChat.tsx β the solo thread component
const handleSubmit = async (e) => {
// 1. Append user message optimistically
setMessages(prev => [...prev, { id, role: 'user', parts: [{ type: 'text', text: input }] }]);
setInput('');
onSendSound?.();
// 2. POST to agent/turn β returns runId immediately
const { runId } = await fetch('/api/chatbot-mixer/agent/turn', {
method: 'POST',
body: JSON.stringify({ type: 'solo', modelId, message: input }),
}).then(r => r.json());
// 3. Open SSE stream β UI reacts to events as they arrive
// Solo stream events: start | delta | relay | dm | done
// (no 'end' β read receipts stamp via useEffect watching message visibility)
const es = new EventSource(`/api/chatbot-mixer/ai-chat/solo/stream/${runId}`);
es.onmessage = (e) => {
const event = JSON.parse(e.data);
if (event.type === 'start') { setStatus('streaming'); }
if (event.type === 'delta') { contentBuffer += event.content; }
if (event.type === 'relay') { /* relay message forwarded to group thread */ }
if (event.type === 'dm') { injectProactiveMessage(event.modelId, event.message); }
if (event.type === 'done') { es.close(); commitMessage(contentBuffer); }
};
};start() launches the workflow without awaiting it β the route returns a runId in milliseconds. The client opens an SSE connection and reacts to events as they arrive. This is what makes multi-model group turns work: the server coordinates several models sequentially while the client updates token by token.
State Management
The Zustand store is just a render cache. Real persistence lives in Vercel Blob β one JSON file per thread, written server-side by the workflow after each turn. On first visit a 30-day sessionId cookie is set; on every subsequent load, hydrateChatStore() reads from Blob and restores the full conversation. Close the tab, come back tomorrow, everything is still there.
// The store is a render cache β Blob is the source of truth
export const useChatStore = create<ChatStore>((set, get) => ({
conversations: {}, // solo thread messages β keyed by modelId
groupConversation: [], // shared group thread
readReceipts: {}, // per-thread read receipt timestamps
proactiveUnread: {}, // DMs that arrived without a user prompt
activePresetId: null, // which preset card is highlighted
// On mount: read from Blob, populate store
hydrateChatStore: async () => {
await fetch('/api/chatbot-mixer/session/init'); // ensure session cookie
const { group, solo, meta } = await fetch('/api/chatbot-mixer/session/state').then(r => r.json());
const isEmpty = group.length === 0 && Object.values(solo).every(msgs => msgs.length === 0);
// Auto-load first preset if the session is fresh
if (isEmpty) { loadFirstPreset(); return; }
set({ groupConversation: group, conversations: solo, ... });
},
// SSE events write here β components re-render automatically
updateGroupConversation: (messages) => set({ groupConversation: messages, activePresetId: null }),
injectProactiveMessage: (modelId, content) => set(/* append to solo thread, mark unread */),
}));The store never writes to Blob β all persistence goes through server-side API routes. The store can be reconstructed from Blob at any time, which is why a proactive DM shows up correctly when you navigate back to its thread.
The Orchestrator
Before anything streams, planTurn() runs server-side β it takes the conversation history, calls generateObject(), and returns a typed TurnPlan specifying who speaks, in what order, and whether any models should DM you on the side. The UI never sees the plan. It just sees the events that come out of it: the right typing bubble appears, tokens arrive from the right model, a badge lights up on another thread.
Everything that affects orchestrator behavior lives in agent-config.ts β session limits, participation style, DM aggression, per-model weights. Adding a model is two entries. Changing the mood of the conversation is a one-line edit.
export const AGENT_CONFIG = {
limits: {
maxParticipantsPerTurn: 4,
maxResponseSlotsPerModel: 2,
maxDMsPerTurn: 2,
maxDMsPerSession: 8,
maxGroupTurns: 20,
maxSoloReplies: 5,
},
behavior: {
participationStyle: 'dynamic', // pick by context, not round-robin
dmAggression: 'selective', // DM only when personality trigger is clearly met
conflictStyle: 'allowed', // models may disagree and argue openly
allowChainedResponses: true,
allowRelays: true,
allowProactiveDMs: true,
},
models: {
'xai/grok-4.3': { participation: 'high', dmLikelihood: 'high', chainPropensity: 'high' },
'openai/gpt-5.5': { participation: 'medium', dmLikelihood: 'low', chainPropensity: 'medium' },
'anthropic/claude-haiku-4.5': { participation: 'medium', dmLikelihood: 'medium', chainPropensity: 'low' },
'meta/llama-4-scout': { participation: 'low', dmLikelihood: 'medium', chainPropensity: 'low' },
},
} satisfies AgentConfig;Most of this is invisible to the user. What they see is a conversation that feels alive β messages arriving unprompted, replies coming in sequence, the right thread lighting up at the right time. The UI work is making that feel natural rather than chaotic.