Building a multi-agent meeting that actually disagrees with you
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.