Skip to content
kaleemsajid.com
← All posts
· 9 min read

Building a multi-agent meeting that actually disagrees with you

ai product architecture

Camelot sits a founder at a virtual round table with an AI advisory team, a neutral Orchestrator plus three specialists, and sends them away with a structured spec: a Vision Summary, a Viability Memo, or a Go-to-Market Plan. The pitch sounds like “multi-agent chat,” but the interesting engineering is not the agent prompts. It is the orchestration, a deterministic state machine that decides who speaks, guarantees the founder a window to interrupt, and survives a server restart mid-meeting, while the language model is only ever asked to fill one turn at a time.

This post walks through that machine as it is actually built: why dissent has to be structural rather than prompted, how a meeting template is the contract everything else derives from, how the Orchestrator routes turns without ever joining the argument, how a per-session lock keeps a live meeting from corrupting itself, and how the whole thing degrades gracefully when the model behind it fails.

Dissent is a structural property, not a prompt

Ask a single model to “play a product lead, a tech lead, and a designer, and have them debate,” and you get a performance of a debate. It samples every voice from one distribution, so it tends to agree with itself, smooth over the tension, and converge on a tidy consensus no real room would actually reach. The disagreement is cosmetic, because there is only ever one thing talking.

Camelot makes disagreement a property of the wiring instead. Each advisor is a separate turn with its own system prompt and identity, produced by its own model call, with no shared “assistant” voice stitching them together. A neutral Orchestrator sits above them and never takes a side; its only job is to decide who speaks next given the state of the meeting. Because the specialists are generated independently rather than as three characters in one script, the tech lead’s skepticism is genuinely adversarial to the product lead’s optimism instead of two clauses of the same sentence. Dissent stops being something you request in a prompt and becomes something the topology produces.

That topology lives in a template.

The template is the contract

Everything a meeting is, its cast, its voices, its deliverable, and the topics it should cover, is owned by one immutable object:

# backend/app/templates.py (abridged)
@dataclass(frozen=True)
class MeetingTemplate:
    id: str                       # 'startup_idea'
    display_name: str             # 'Startup Idea Validation'
    role_ids: tuple[str, ...]     # ('orchestrator', 'product', 'tech', 'design')
    artifact_type: str            # 'vision_summary'
    topics: tuple[Topic, ...]     # taxonomy that drives the sidebar coverage chips

Three templates ship today (startup validation, business viability, go-to-market), each pairing a roster of three specialists drawn from a pool of nine with its own closing artifact and its own topic taxonomy. Adding a fourth kind of meeting is a data change, one new MeetingTemplate, not new orchestration code. build_orchestrator_system(template_id) injects that template’s roster into the Orchestrator’s prompt, and the set of roles the Orchestrator is even allowed to nominate next is exactly the template’s non-orchestrator role_ids. The model chooses the next speaker, but only ever from a list the template fixed in advance. This is the same discipline a good API boundary uses: let the model be creative about which voice the room needs, never about what voices exist.

One piece of duplication is worth naming, because it was a deliberate trade. The frontend keeps its own mirror of this registry (templateMeta.ts, roles.ts, sessionTopics.ts) so the public marketing pages can render server-side with no network hop, which means a template change has to land on both sides in lockstep. That is a real maintenance cost, accepted on purpose to keep the top of the funnel fast.

The Orchestrator routes; it never argues

When the room needs to advance, the Orchestrator asks the model one narrow question, who should speak next given the meeting so far, and the answer is constrained to the template’s roles:

# backend/app/llm/service.py: _get_routing_decision (sketch)
decision = await self._get_routing_decision(
    transcript,
    role_options=template.non_orchestrator_roles,   # the only legal answers
)
# decision.next_role is one of role_options, or 'close'

Keeping the Orchestrator out of the debate matters. If the router also had opinions about the idea, its routing would quietly bias toward the view it preferred, and the dissent the architecture works so hard to preserve would leak away through the one component meant to stay neutral. So the Orchestrator only ever moves the floor; it does not hold it.

Routing is not always the model’s decision, either. A pacing layer intercepts first (backend/app/session/pacing.py). If the founder addresses someone directly (“Tech, is any of this actually buildable?”), detect_direct_address hands the floor straight to that role and skips routing entirely. If the founder asks a question, is_founder_question makes sure it gets answered by an agent rather than filed away as commentary. The cheap, deterministic reads happen in plain Python; the model is only consulted when the next move is genuinely ambiguous.

A meeting is a state machine

Underneath the conversation is a small, explicit state machine:

AWAITING_SEED -> AGENT_SPEAKING -> PAUSE_WINDOW -> FOUNDER_INPUT
             -> DECISION_GATE -> ... -> CLOSING -> COMPLETE

The reason to make it explicit is concurrency. Two things can try to move a live meeting at the same moment: a founder message arriving over the WebSocket, and an auto-advance timer firing to give the next agent the floor. Let those interleave and you get two speakers nominated at once, or a pause that starts after the turn it was meant to pause. Camelot closes that off by running every transition under a per-session lock:

# backend/app/session/registry.py (abridged)
class ActiveSession:
    async def transition(self):
        return self._lock  # per-session asyncio.Lock

# at every state change:
async with active_session.transition():
    ...   # exactly one transition mutates this meeting at a time

The lock is per session, not global, so one founder’s meeting never waits on another’s. The serialization is only ever within a single round table, which is exactly the scope where ordering has to be exact.

The founder gets to interrupt

What turns this from a monologue into a meeting is that the founder can always cut in. After each agent speaks, the room enters a pause window: a visible countdown during which the founder can redirect, push back, or stay silent and let the meeting roll on. The length of that window is not fixed. compute_pause_duration derives it from the turn’s phase, the length of what was just said, the speaking role, and how close the meeting is to its endgame, then clamps the result between a configurable minimum and maximum so it never feels rushed and never stalls. A dense strategic point earns more thinking time than a one-line aside.

When the founder does start typing, the room freezes: agents hold, and a banner tells the founder the floor is theirs. And at genuine phase boundaries the Orchestrator emits a decision gate, an explicit fork the founder has to resolve before the meeting proceeds, rather than letting the agents quietly pick a direction on the founder’s behalf. The recurring principle is that the human keeps the wheel at every point where the meeting could otherwise drift.

Surviving a restart

A meeting is long-lived and stateful, which makes a mid-session deploy or crash a real hazard. Camelot treats the live meeting as persistent state rather than in-memory session data. On every transition the orchestrator writes the current state, turn count, pause timing, and rolling summary to a session_state row, and deletes that row only when the meeting closes. On restart, _try_restore_session rebuilds the meeting from it, and infer_restored_state independently re-derives the current state from the message history as a defence-in-depth cross-check against the persisted value. If the two ever disagree, the meeting can still recover.

The same persistence path solves the other long-meeting problem, the context window. Left alone, a forty-turn meeting would eventually overflow the model’s context. So past a threshold, compress_rolling_summary folds the older transcript into a running summary and keeps only the most recent turns verbatim, while a summary_cursor records exactly how far that summary reaches so nothing is summarized twice or dropped. The meeting stays coherent from turn one to turn forty without ever paying for the full history on every call.

Keeping the room alive when the model fails

The model is a dependency, and dependencies fail. If every provider hiccup threw an exception, one bad response would kill a meeting the founder had spent twenty turns building. A circuit breaker wraps the provider instead:

# backend/app/llm/provider.py: CircuitBreaker (abridged)
if self._consecutive_failures >= self.threshold:   # default 5
    if not self._cooldown_elapsed():               # default 60s
        return FALLBACK_LINE   # a graceful "let's keep moving" instead of raising

After a run of failures the breaker opens, and for a cooldown the meeting gets a graceful fallback line rather than a stack trace. Crucially, founder input still works while the breaker is open, so the human can keep steering even when the advisors have briefly gone quiet. The same defensive instinct runs through the rest of the system: outbound email goes through a DB-backed queue with idempotent claims and a dead-letter path rather than a direct send, and a startup schema guard refuses to boot against a drifted database instead of failing later, mid-request.

The payoff is an artifact, not a transcript

A chat log is not a deliverable. The entire point of the meeting is what the founder leaves holding, so closing the session is its own deterministic step. _close_session generates the artifact whose type the template declared, writes it once, and tears down the live state:

# backend/app/session/orchestrator.py: _close_session (abridged)
artifact = await llm.generate_artifact(
    messages, artifact_type=template.artifact_type,   # vision_summary | viability_memo | gtm_plan
)
await upsert_artifact(session_id, artifact)           # ON CONFLICT (session_id) DO UPDATE
await set_session_title(session_id, artifact.title)   # the model's proposed title, written back
await drop_session_state(session_id)                  # live meeting state is no longer needed

The upsert is keyed on the session, so re-running a close can never produce two artifacts for one meeting. What the founder receives is not the conversation; it is a structured document distilled from it, with a title, key decisions, and open questions, ready to rate one to five and archive. The messy round table becomes one clean spec.

Why the orchestration is the product

Strip Camelot down and the language model does exactly one small thing: it fills a single turn when asked, from a role and a shortlist the surrounding system chose for it. Everything that makes the experience feel like a real, opinionated advisory meeting lives in deterministic code around that turn. Dissent comes from running the advisors as independent voices under a neutral router. Coherence comes from a locked state machine and a rolling summary. Founder control comes from pause windows and decision gates. Durability comes from persisted state and a circuit breaker. The deliverable comes from a template-declared artifact step.

You cannot prompt a single chat model into genuinely disagreeing with itself, pausing for you, and surviving its own crash. You have to build the room. The model supplies the sentences; the architecture supplies the meeting.