Skip to content
AdStack Engineering Docs
Esc
navigateopen⌘Jpreview
On this page

The chat loop

How the host LLM runs a real agentic tool loop, pauses for the user, and streams results back to the page.

The chat host runs a true agentic loop, the same shape as a coding agent. It is given tools and tool_choice: auto, it calls them, it sees every result, and it keeps going until it answers with text.

Everything lives in lib/chat-v2/. The route is POST /api/chats/$chatId/loop; the frontend hook is hooks/use-chat-loop.ts.

This is the only chat path. The earlier architecture in lib/chat/ (a forced single route tool call with a blind presentation step), the VITE_CHAT_V2 flag, and the entire v1 client were deleted in 2026-08. The chat-v2 name is now just where the code lives - there is no v1 to contrast it with, and no fallback to fall back to.

Flow

Maximum 10 iterations. Hitting the cap triggers one final wrap-up call with tool_choice: none so the user always gets a sentence rather than a dead stream.

Tool categories

Category Examples Behaviour
Agent one per entry in AGENT_DEFINITIONS, dashes become underscores Runs an agent, result feeds back
Action generate_image, generate_video, create_storyboard, generate_character_portrait, save_character, propose_cast_list, generate_element_sheet, start_campaign, link_character_persona Does something, result feeds back
UI select_offer, select_persona, select_ad_format, configure_image, configure_video, collect_campaign_briefing, ask_user Pauses the loop for the user
Read list_offers, get_offer, list_personas, get_persona, list_visual_assets, get_chat_outputs, list_offer_outputs, get_output, get_campaign_status, list_image_presets, list_generation_models, read_attachment Instant lookup, hidden from the UI

Pause and resume

UI tools stop the loop. The assistant message is persisted with metadata.v2.awaiting set to the tool use id, and a toolCall SSE frame renders the card. The frontend resumes by posting { toolResponse: { toolUseId, result } } to the same route.

If the user types plain text while a card is awaiting, the card is auto-dismissed with { dismissed: true, user_message } so the conversation never deadlocks. History rebuilding repairs any stray tool_use block with a synthetic dismissed result.

Persistence

Conversation state is reconstructed from the database on every turn, not held in memory.

  • metadata.v2.blocks stores the Anthropic content blocks per message.
  • metadata.v2.hidden marks read-tool results so they never render in the UI.
  • Agent output rows carry content: "" and render from metadata.agentOutput. They deliberately have no v2 blocks: the hidden tool result row already carries the output for the model, and duplicating it would double the history.
  • History is capped at 40 messages, merging same-role neighbours.

Cross-offer referencing

list_offer_outputs(offer_id) returns saved outputs for any of the user’s offers, and get_output(output_id) returns one in full. This is what makes “take the quiz from that other offer and adapt it for this one” work: the host lists, fetches, then calls the quiz agent with reference instructions.

Shared guidelines

Rules that apply to every agent (no em dashes, no horizontal rules, output language, JSON output shape) live in one admin-editable module, shared-guidelines. The agent runner appends them at runtime with per-rule deduplication, so an agent whose prompt already states a rule does not pay for it twice. stripCodeFences() removes stray ```json wrappers server side as a safety net.

Streaming and the timeline

Agents stream, but their tokens are suppressed from the chat body. They surface as agentToken frames feeding the expandable code viewer in the run timeline, which shows: resolving context, writing [agent], presenting results. Streaming also keeps the SSE connection alive through long generations.

A single turn can produce several host messages (announce, run, present). The server emits a hostTurn frame when a tool-calling turn’s text completes; the client collects them as live segments, renders them above the timeline, and promotes them to real messages when the turn is done.

Rows are server-shaped

The client renders only rows the server persisted. It does not synthesize a message row from an SSE frame and hope the fetched copy matches later.

Every message carries a per-session sequence, allocated through one central insert path (insertChatMessageRow, max+1 in-statement with a unique-index retry). Every writer funnels through it; readers order by sequence with NULLs last. The client keeps a sequence-keyed store and merges fetched rows with rows pushed over SSE.

The payoff is that interactive cards survive a reload. Under the old client, a card was a local object with no durable identity, so refreshing mid-conversation lost it.

One design for question cards

components/chat/questionnaire-card.tsx is the single design for anything that asks the user something - single-select tool cards and the multi-step campaign briefing alike. OptionsSelector, OptionsListSelector and CampaignBriefingWizard were deleted.

Two visual variants of the same element will drift, and id-keyed decoration maps break the moment ids come from an LLM. To decorate a card, add a data field to the option payload; never add an id-keyed lookup in the component.

Last updated on August 6, 2026

Was this page helpful?