9


Agentic Chatbot Mixer

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.

Panel transition β€” stay mounted through exit animation
// 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.

scheduleReveal β€” sequential reveal with proportional timing
// 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.

Scroll down
1Submit
1

Submit

User submits input. The component POSTs { type, modelId?, message } to /agent/turn and immediately clears the input β€” no waiting.

2

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.

3

SSE Stream Opens

The component opens EventSource(/stream/{runId}). A typing bubble appears when the start event arrives.

4

Tokens Stream In

delta events append tokens to the bubble in real time. The scroll container tracks the bottom of the message list automatically.

5

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.

6

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.

Submit β†’ runId β†’ SSE stream
// 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); }
  };
};
Why non-blocking matters

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.

Zustand store β€” render cache pattern
// 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 */),
}));
Store writes vs. Blob writes are intentionally separate

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.

agent-config β€” single control surface
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.