Skip to content

Designing a multi-stage conversation

Portal onlyIntermediate~30 min

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:

  1. What does this stage need from the user? That answer becomes the extractionSpec.
  2. What's the prompt asking the LLM to do? That answer becomes the prompt content.
  3. When does this stage hand off to the next? That answer becomes the transition condition or the LLM's next_stage instruction.

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 edges list in a node's data. The engine doesn't read a data.edges field.
  • The engine only checks edges that leave the current stage, so an edge on the conversation node never fires.
  • onTrack: false doesn't trigger anything today; it is only recorded. An unconditioned always edge 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 suggests conversations:…, 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 data node 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