Designing a multi-stage conversation¶
A learn-by-doing walkthrough of designing a real conversation from scratch. You will build a meeting-prep assistant with three stages — intake → research → summarize — and end with a runnable design you can chat with.
This tutorial focuses on the design decisions: what each stage is for, what data it extracts, and how it routes to the next. Handling a user who veers off-topic mid-conversation isn't something the chat engine does today; Step 6 says why. For the test mechanics (running chats, verifying results), see Chatbot end-to-end test.
What you'll build¶
A conversation called meeting-prep with:
- Stage 1 —
intake: collect who, what, and why for the meeting. - Stage 2 —
research: pull together talking points and questions. - Stage 3 —
summarize: produce a one-page brief and confirm. - Routing signals: phrases that tell Hadron when this conversation should run.
By the end, a user can say "Help me prep for my 1:1 with Sam tomorrow" and the assistant will guide them through a structured prep session.
Prerequisites¶
- A Hadron agent with a chatbot system memory. If you don't have one yet, follow Getting started or Building a chatbot agent first.
- An LLM provider configured on the agent, for the test run in Step 7. See Configure an LLM provider. Every test turn is a real call to that model, billed by your provider.
- Claude Code with the Hadron MCP server connected, so you can author nodes through conversation. The same instructions can be used through the portal node editor — adapt the wording to "create node X with data Y".
- 30–45 minutes for the design pass; another 15 to test.
Set the active memory to your agent's system memory before you start:
Set the active memory to
<your-system-memory-name>.
Step 1 — Plan the conversation before creating nodes¶
The single best discipline in conversation design is to write the flow on paper (or in a scratchpad) before you create any nodes. Three questions per stage:
- What does this stage need from the user? That answer becomes the
extractionSpec. - What's the prompt asking the LLM to do? That answer becomes the prompt content.
- When does this stage hand off to the next? That answer becomes the
transition condition or the LLM's
next_stageinstruction.
For meeting-prep, the answers are:
| Stage | Needs from user | LLM does | Hands off when |
|---|---|---|---|
intake |
who's attending, what's the meeting about, what's the goal | Asks 2–3 short questions; doesn't try to dig deep yet | Topic + goal are captured |
research |
which talking points matter, what questions to raise | Suggests points based on the topic; lets user accept / edit / add | At least one talking point is locked in |
summarize |
a confirmation that the brief looks right | Renders a tidy brief; offers tweaks | User says it's good (or LLM detects acceptance) |
Notice the pattern: each stage is a focused conversation, not a multi-purpose agent. The LLM stays on rails because the prompt has one job at a time.
Step 2 — Create the conversation node¶
Conversations and stages are system nodes in your agent's system memory.
Create the parent first, with routing metadata so the agent knows when to
pick it.
Create node
conversations:meeting-prep(nodeType: system) with:{ "isSetup": false, "stageOrder": ["intake", "research", "summarize"], "goalDescriptions": [ "Help the user prepare for an upcoming meeting", "Build a structured brief: attendees, goal, talking points, questions" ], "goalSignals": [ "Help me prep for my meeting", "I have a 1:1 tomorrow with Sam — what should I bring up?", "Can you help me get ready for the standup?", "I'm meeting with the board next week, need to think through it" ] }
stageOrder declares the default linear flow: intake → research →
summarize.
goalDescriptions and goalSignals are how the routing engine matches
user messages to this conversation. Be generous with signals — multiple
phrasings of the same intent help the engine match real-world wording. See
Conversation routing
for the deep model.
Step 3 — The intake stage¶
intake is the cheapest stage to design well: it just collects three
things and moves on. Don't over-script it; the LLM is good at small-talk
chained with structured asks.
Create node
conversations:meeting-prep:intake(nodeType: system) with:{ "promptRef": "prompts:meeting-prep:intake", "extractionSpec": [ { "field": "meeting_topic", "description": "One-line topic of the meeting (e.g. 'quarterly OKRs review')", "shape": "string" }, { "field": "meeting_goal", "description": "What the user wants to achieve in the meeting", "shape": "string" }, { "field": "attendees", "description": "Attendee names or roles (comma-separated)", "shape": "string" } ] }
Why the fields have no prefix
An earlier version of this tutorial prefixed fields with {chat}. for
this chat's facts and memory. for durable ones. The chat engine doesn't
route by prefix: every extracted fact is saved under the last segment
of its field, on the data node of the memory the chat lives in, so it
persists beyond this chat. Prompts read a fact back by that name, as
{{meeting_topic}}. Edge conditions are different: they name a
fact with its scope, as memory.meeting_topic (see
Edge conditions). See
Building a chatbot agent,
and hadron-server#910
for the destination work.
Now the prompt. Keep it short and explicit about the tool call:
Create node
prompts:meeting-prep:intake(nodeType: system) with content:The user wants to prep for a meeting. Ask three short questions, one at a time: 1. What's the meeting about? (one line) 2. Who's attending? 3. What does the user want to walk out with? Don't dig into specifics yet — that's the next stage. Once the user has given you a topic AND a goal, set `next_stage` to "research". Use the `respond` tool. Always include any new info in the `data` field; never store it in free-text only.
The intake stage hands off when both meeting_topic and meeting_goal
exist. The cleanest way to enforce this is to instruct the LLM in the
prompt (above) and let it set next_stage itself. For an extra belt
on the suspenders, you could add an edge that holds the stage if the
fields are missing — but for a simple flow, the prompt is enough.
Step 4 — The research stage¶
Research is where the conversation earns its keep. The prompt should ask the LLM to propose talking points based on the topic captured in intake, and let the user accept, edit, or add.
Create node
conversations:meeting-prep:research(nodeType: system) with:{ "promptRef": "prompts:meeting-prep:research", "extractionSpec": [ { "field": "talking_points", "description": "Semicolon-separated list of points the user wants to make", "shape": "string" }, { "field": "questions_to_ask", "description": "Semicolon-separated list of questions for the meeting", "shape": "string" } ] }Create node
prompts:meeting-prep:research(nodeType: system) with content:The user is preparing for a meeting: - Topic: {{meeting_topic}} - Goal: {{meeting_goal}} - Attendees: {{attendees}} Suggest 3 candidate talking points and 2–3 questions worth asking. Frame them as "Here are some I'd suggest — keep, edit, or replace." The user might already have ideas in mind; capture those as you go. When you have at least one talking point the user has confirmed, set `next_stage` to "summarize". Use the `respond` tool. Talking points and questions go in `data` as semicolon-separated strings.
Notice the prompt uses Mustache variables ({{meeting_topic}})
to inject the facts captured in intake. The runtime fills them in from the
facts saved so far when it compiles the prompt — see
Mustache template syntax for the rules.
This is the moment where intake's discipline pays off: the research stage has clean inputs to work with.
Step 5 — The summarize stage¶
The final stage produces the artifact: a one-page brief. The LLM does the formatting; the user just confirms.
Create node
conversations:meeting-prep:summarize(nodeType: system) with:{ "promptRef": "prompts:meeting-prep:summarize", "extractionSpec": [ { "field": "brief_confirmed", "description": "Whether the user accepted the brief (true/false)", "shape": "boolean" } ] }Create node
prompts:meeting-prep:summarize(nodeType: system) with content:Render a tight meeting brief in this exact shape: ## Meeting: {{meeting_topic}} **Goal:** {{meeting_goal}} **Attendees:** {{attendees}} ### Talking points {{talking_points}} ### Questions to ask {{questions_to_ask}} Then ask: "Does this look right, or want me to tweak anything?" If the user accepts, set `data.brief_confirmed` to true and `next_stage` to null. If they want changes, apply the edit, render the brief again, ask again. Use the `respond` tool.
next_stage: null keeps the chat on summarize; nothing marks a chat as
ended today. The chat stays on the user's record, so they can come back to
it or start a new one.
Step 6 — Add an escape edge for off-topic detours¶
Real users don't stay on rails. Half-way through prep, they might say "Wait — actually, can you help me draft an email to Sam first?" The chatbot should gracefully bail to whichever conversation handles email drafting (or a fallback) rather than soldiering through prep.
Skip this step: the escape edge doesn't work as written
This step was written for a design the chat engine doesn't implement. Don't add it:
- Routing rules are graph edges, drawn in the Conversation
Editor, not an
edgeslist in a node's data. The engine doesn't read adata.edgesfield. - The engine only checks edges that leave the current stage, so an edge on the conversation node never fires.
onTrack: falsedoesn't trigger anything today; it is only recorded. An unconditionedalwaysedge on a stage would hand the chat off on every turn where the model doesn't pick a stage.
See What the chat engine acts on today
and hadron-docs#308.
A chat that has changed subject can still reach another conversation:
give that conversation Goal signals, as described in
Building a chatbot agent.
Note that this only takes effect while the chat is in fallback.
For the routing-engine details (what onTrack does and doesn't do, how
behavior: detour differs, the goal stack), see
Conversation routing.
Step 7 — Test the design¶
Run the design as a scripted test in the portal. Open the agent, go to Chatbot Control → Overview, and click Open Tester →. Expand Scripted persona (multi-turn) and fill it in from the script below:
- Persona name: anything, e.g.
okr-review. - Start conversation:
meeting-prep— the bare name. The field's placeholder suggestsconversations:…, but the chat is started by name, and the prefixed form is refused as not found. - Opening message: turn 1's line.
- Follow-up messages: turns 2–4, one per box (Add follow-up for each extra turn).
- Expected conversations:
meeting-prep, also the bare name. The report lists visited conversations by name, so a prefixed entry never matches and the run reports a failure.
Click Run script. The bot replies after each message, and the report shows every turn's stage, so you can check it against the last column. Each run calls the agent's real LLM and creates a real chat in your history.
| Turn | You say | What to watch for |
|---|---|---|
| 1 | "Help me prep for my 1:1 with Sam tomorrow." | Welcome from intake. Topic should be saved as meeting_topic. |
| 2 | "It's a quarterly review of my team's OKRs. I want to walk out with agreement on which two to deprioritize." | Goal extracted. Stage transition to research. |
| 3 | "I'd like to talk about the Q2 sales-enablement OKR — it's not landing. And the ML platform OKR, which is the one I'd keep." | Talking points captured. Stage transition to summarize. |
| 4 | "Looks great. Yes." | brief_confirmed: true, next_stage: null. The chat stays on summarize. |
Watch for:
- Stage transitions at the right turn (intake → research → summarize).
- Extracted facts on the
datanode of the memory the chat lives in, after each turn. - The compiled brief in turn 4 includes the values you gave.
If a transition didn't fire when expected, the most common causes are
(in order): the prompt didn't tell the LLM to set next_stage; the
extractionSpec field shape doesn't match what the LLM emitted; the
stageOrder array is out of sync with the stage node names.
For a more thorough validation pass, build a test persona for this conversation and re-run it whenever you tweak a prompt. See Test personas.
What to do when a stage gets crowded¶
A common failure mode: the design works for the happy path but turns brittle when the user gives surprising answers. Two patterns help.
Split a stage into sub-stages¶
If the prompt for research starts feeling overloaded — "ask about
talking points AND questions AND any prior context AND…" — that's a
signal to split. A research-points stage feeding a research-questions
stage gives each prompt one focused job and makes extraction cleaner.
Guard a stage against a missing value¶
If the user lands on research without having actually given a goal
(possible if they routed in mid-flow from another conversation), the
prompt's Mustache variables will be empty and the LLM will improvise
poorly. The editor offers a prerequisite edge for this — timing:
on_enter and a missing condition on memory.meeting_goal, detouring to
intake — but the chat engine does not act on it today: it evaluates
neither on_enter edges nor edges to a stage. See
What the chat engine acts on today.
Until it does, tell the research prompt what to do when the goal is
missing.
The mechanics are in Conversation routing → Common edge patterns.
Where to go next¶
- Test a chatbot with scripted personas — turn this happy-path test into a scripted run you repeat after every change.
- Conversation routing — the full routing model (topics, edges, the goal stack, hierarchical extraction).
- Building a chatbot agent — agent setup if you skipped that step.
- Chatbot end-to-end test — manual end-to-end test with both Path A (Portal Chat) and Path B (Claude Code + MCP).