Building a chatbot agent
This guide walks through creating a Hadron agent that drives a conversational AI chatbot — an AI mentor, support agent, research assistant, or any application where the AI has structured conversations with end users.
How It Works¶
Hadron doesn't make LLM calls. Your app does. Hadron provides:
- Conversation designs — stages with prompts and data extraction specs
- Prompt compilation — Mustache templates resolved with live data
- Tool schemas — for LLM structured output (data extraction in one call)
- Memory — user profiles, chat history, extracted facts, summaries
- Orchestration — stage transitions, conversation handoffs, summarization triggers
Your App → Hadron: "User said this"
Hadron → Your App: compiled prompt + tool schema + message history
Your App → LLM: makes the call
LLM → Your App: structured response (message + data)
Your App → Hadron: "LLM responded with this"
Hadron → Your App: display message + stage transition info
Prerequisites¶
- A Hadron account with an organization
- An agent with at least two memories (see Building an Agent):
- A read-only knowledge memory with domain content
- A read-write user memory for storing chat data and user profiles
- A third memory for the system memory (conversation designs)
Step 1: Create the System Memory¶
The system memory holds your conversation designs — stages, prompts, and partials. It's separate from domain knowledge so chatbot designers can iterate on prompts without touching the knowledge base.
- Create a new memory: e.g. "Juno Conversations" (
your-org.com:juno-conversations) - Set visibility to Organization (your team can edit it)
Step 2: Design Your Conversations¶
Conversations are system nodes under conversations/ in the system memory.
Each conversation has stages, and each stage has a prompt.
Create the conversation structure¶
Using MCP tools or the portal node editor:
conversations/
setup/ (nodeType: system)
.data: { isSetup: true, stageOrder: ["onboard"] }
find-mentor/ (nodeType: system)
.data: { stageOrder: ["understand", "search", "connect"] }
Create the stages¶
Each stage is a child node of the conversation:
conversations/setup/onboard/ (nodeType: system)
.data: {
promptRef: "prompts:setup:onboard",
extractionSpec: [
{
field: "memory.name",
description: "The user's name",
shape: "string"
},
{
field: "memory.industry",
description: "The user's business industry",
shape: "string"
}
]
}
The extractionSpec tells Hadron what data to extract from the conversation.
Each field specifies:
- field — where to store the data (
memory.name→ the memory's default data node;{chat}.topic→ the current chat's data) - description — what the LLM should extract
- shape — the expected type (
string,number,string[], etc.)
Create the prompts¶
Prompts live under prompts/ for discoverability:
prompts/
setup/
onboard (nodeType: system)
content: |
You are Juno, an AI mentor for entrepreneurs.
Welcome the user warmly and learn about them.
Ask about:
- Their name
- What industry they're in
- What stage their business is at
Current user data: {{memory.name}} in {{memory.industry}}
{{> prompts:partials:metadata-spec}}
find-mentor/
understand (nodeType: system)
content: |
The user is {{memory.name}}, in {{memory.industry}}.
Help them articulate their biggest challenge.
{{> prompts:partials:metadata-spec}}
partials/
metadata-spec (nodeType: system)
content: |
Always respond using the 'respond' tool. Include:
- message: your response to the user
- data: any new information you learned
- next_stage: null to stay, or the next stage name
Notice:
- Mustache variables (
{{memory.name}}) are resolved from user data - Partials (
{{> prompts:partials:metadata-spec}}) include shared fragments - Prompts are separated from stages — reusable and independently editable
Step 3: Set Up the Agent¶
- Create or update your agent
- Set the System Memory ID to your conversation design memory
- Add your knowledge memory (read-only) and user memory (read-write)
The agent now knows where to find conversation designs AND domain knowledge.
Step 4: Integrate with Your App¶
Start a chat¶
mutation {
startChat(
agentId: "your-agent-id"
userId: "end-user-123"
conversationName: "setup"
) {
chatId
systemMessage
tools
conversationName
stageName
}
}
Returns:
- chatId — reference for subsequent calls
- systemMessage — compiled prompt (Mustache resolved, partials included)
- tools — tool schema built from the stage's extraction spec
- stageName — which stage we're in
Send a user message¶
mutation {
sendChatMessage(
chatId: "chats:202604121000-user-setup"
userMessage: "Hi! I'm Maria, I run a small construction company."
) {
systemMessage
tools
messageHistory
summarizationNeeded { messageCount prompt }
}
}
Returns the compiled prompt for the current stage (may have changed), tool schema, and message history for the LLM call.
Call the LLM¶
Your app calls the LLM with the system message, tools, and message history. Using tool use, the LLM responds with structured JSON:
{
"message": "Welcome Maria! It's great to meet a fellow entrepreneur...",
"data": {
"name": "Maria",
"industry": "construction"
},
"next_stage": null
}
Process the response¶
mutation {
processChatResponse(
chatId: "chats:202604121000-user-setup"
toolResponse: {
message: "Welcome Maria! It's great to meet...",
data: { name: "Maria", industry: "construction" },
next_stage: null
}
) {
displayMessage
stageTransitioned
newStageName
conversationHandoff
chatEnded
}
}
Hadron:
- Saves the assistant message
- Stores
nameandindustryin the user's data node - Evaluates if the stage should transition (are all required fields populated?)
- Returns what to display to the user
Handle stage transitions¶
When stageTransitioned is true, the next sendChatMessage call will return
a different compiled prompt (for the new stage). Your app doesn't need to do
anything special — just keep calling the same two endpoints.
Handle conversation handoffs¶
When conversationHandoff is set, the current conversation is done and a new
one should start. Call startChat again with the new conversation name.
The user's data persists across conversations.
Save summaries¶
When summarizationNeeded is returned, your app should:
- Call the LLM with the summarization prompt and messages
- Save the result:
mutation {
saveChatSummary(
chatId: "chats:202604121000-user-setup"
summary: "Maria runs a construction company. She needs help..."
)
}
Streaming responses¶
A common question: can the assistant message stream into the user's UI token-by-token, instead of arriving all at once?
Hadron's Chat API is non-streaming. processChatResponse takes one
complete tool-call response and stores it. The mutation does not emit
partial-message events, and there is no GraphQL subscription for chat
streaming today.
Your app can still deliver a streaming UX. Your app — not Hadron —
makes the LLM call. Stream the LLM's response into your UI yourself, then
once the full structured response is in hand, pass it to
processChatResponse. From the user's perspective, text appears
token-by-token; from Hadron's perspective, the message arrives as one
finished record.
The minimal pattern:
sendChatMessage(chatId, userMessage)→ compiled prompt + tools.- Call your LLM with
stream: true(or whatever your provider's streaming flag is). Render incoming text deltas into the UI as they arrive. - Wait for the stream to complete and assemble the final structured
response (tool-call JSON:
message,data,next_stage). processChatResponse(chatId, toolResponse)once, with the final response.
If the user clicks Stop mid-stream, decide locally whether to
(a) discard the partial response and not call processChatResponse, or
(b) call processChatResponse with whatever was assembled so far. Hadron
will not see the partial state either way until you call the mutation.
The Hadron portal itself uses this approach in the App-level Hadron Assistant chat (the setup-help chat on the App detail page). That component streams the assistant's response into the bubble, shows an in-bubble cursor while streaming, and offers a Stop button. The streaming behavior is local to the portal — no streaming wire format is exposed through Hadron's GraphQL API.
Step 5: Stage transitions and edges¶
The next stage on each turn is decided by three layered mechanisms:
| Mechanism | Where it lives | When it fires |
|---|---|---|
stageOrder |
The conversation's data.stageOrder array |
Default linear sequence — used when nothing else fires. |
| Edges | data.edges on a stage (or conversation) |
Deterministic — a JSONLogic condition evaluates against memory + chat state, and the edge fires if the condition is true (or always, if the condition is empty). |
LLM next_stage |
The LLM's tool call: respond({ next_stage: "..." }) |
The LLM forces a specific destination. Wins over the other two. |
Priority: LLM next_stage override → matching edges (lowest
priority first) → stageOrder default. If none match, the
conversation stays on the current stage.
Default linear flow¶
stageOrder is an array of stage names on the conversation:
When understand completes, the engine advances to search,
then connect, then ends the conversation. No edges, no LLM
override needed for happy-path linear flows.
Edge schema¶
An edge attaches to a stage (data.edges on the stage) or to a
conversation (catch-all edges that fire from any stage). Each
edge carries:
{
"target": "conversations:profile-building",
"condition": { "missing": ["memory.business_type"] },
"timing": "on_enter",
"behavior": "detour",
"label": "Fill profile if business type is missing",
"priority": 10
}
| Field | Values | Meaning |
|---|---|---|
target |
stage loc or conversation URN |
Where to go. Cross-conversation targets switch the routing context. |
condition |
JSONLogic expression, or null |
The rule that decides if the edge fires. null (or omitted) means always. |
timing |
on_complete, on_enter, always |
When the engine considers the edge — when the source stage finishes, when it's entered, or every turn. |
behavior |
transition, detour |
transition = permanent move. detour = push onto the goal stack and return when the destination completes. |
label |
any string | Human-readable description shown in the editor. |
priority |
number, lower fires first | Tiebreaker when multiple edges qualify on the same turn. |
The condition uses the curated UI subset of JSONLogic in the portal's structured Condition Builder. The runtime evaluator supports the full JSONLogic operator set.
| Operator | Shape | Reads as |
|---|---|---|
== |
{ "==": [{"var": "field"}, value] } |
field equals value |
!= |
{ "!=": [{"var": "field"}, value] } |
field does not equal value |
<, <=, >, >= |
{ ">": [{"var": "field"}, value] } |
numeric / temporal comparison |
in |
{ "in": [{"var": "field"}, ["a", "b"]] } |
field is one of the listed values |
missing |
{ "missing": ["field"] } |
field is undefined or empty |
and, or |
{ "and": [clause, clause] } |
n-ary combinators (engine-only; not exposed in the portal builder yet) |
! |
{ "!": clause } |
unary negation (engine-only; not exposed in the portal builder yet) |
Variables read from a small set of scoped vars: memory.*,
chat.*, agent.*, message.data.*, plus the now() and
today() builtins. See
Edge conditions for the full
model and what's deferred.
Three worked examples¶
1. Trivial always edge — jump out of the setup conversation
When the welcome stage completes, hand off to the main flow no
matter what. Add this edge on conversations:setup:onboard:
{
"target": "conversations:find-mentor",
"timing": "on_complete",
"behavior": "transition",
"label": "After onboarding, start the main flow"
}
No condition — it fires every time the stage completes.
2. Fallback edge driven by onTrack: false
When the LLM signals the conversation has drifted (returns
onTrack: false in respond()), reroute to a stage that gets
the user back on course. Add this on a noisy mid-conversation
stage:
{
"target": "conversations:fallback:no-match",
"timing": "always",
"behavior": "transition",
"label": "Bail out when the conversation goes off track"
}
The engine fires always-timed edges on every turn; this one
wins because the LLM's onTrack: false signal triggers the
fallback evaluation. This is the Fallback preset in the
portal's edge editor.
3. Condition-driven edge — skip a stage when the data is already on file
Skip collect-business-type if memory.business_type is
already set from a prior session. Add this edge on
conversations:onboarding:collect-business-type:
{
"target": "conversations:onboarding:welcome-back",
"condition": { "!": { "missing": ["memory.business_type"] } },
"timing": "on_enter",
"behavior": "transition",
"label": "Skip if business type is already set",
"priority": 5
}
{ "!": { "missing": [...] } } is JSONLogic for "the field is
present" — the portal's Condition Builder serialises the
exists chip to this exact shape. priority: 5 puts the skip
ahead of any default priority: 10 follow-on edge.
For the full step-by-step walk-through of building this in the portal, see Build a conditional conversation flow.
LLM-controlled transitions¶
The LLM can force a transition by returning next_stage in its
respond() tool call:
{
"message": "Got it — let me look that up for you.",
"data": { "topic": "billing" },
"next_stage": "conversations:billing:lookup"
}
This wins over both edges and stageOrder. Use it for
intent-aware routing the engine can't compute deterministically
("the user just confirmed they're done" → jump to wrap-up).
Save edges for the mechanical decisions; let the LLM handle
judgment calls.
For the design rationale of when to reach for which mechanism, see Edge conditions. For the full reference (operators, scopes, presets, runtime order), see Conversation routing.
Step 6: The Setup Conversation¶
The conversation with isSetup: true runs on first use. Use it for:
- Greeting the user
- Collecting essential information (name, preferences)
- Explaining what the chatbot can do
- Routing to the right specialized conversation
After setup, subsequent sessions can start with a router conversation that presents choices based on what the user needs.
Tips¶
Keep prompts focused¶
Each stage should have one clear objective. Don't try to collect everything in one stage — break it into focused steps.
Use partials for shared instructions¶
Common instructions (metadata format, safety disclaimers, tone guidelines) should be partials. Update them once, all stages get the update.
Let the LLM manage tone¶
Don't over-script. Give the LLM the objective and let it manage the natural language. Over-scripted prompts feel robotic.
Test with real conversations¶
The best way to test is to have real conversations. Create a test app (type Workstation or Chatbot), connect it, and chat. Iterate on prompts based on what works.
Summarization matters¶
Configure summarization per conversation. Casual chats: every 20 messages, concise style. Professional/legal chats: every 40 messages, detailed style.
Architecture Reference¶
System Memory (read-only for the chatbot):
conversations/ → conversation designs (system nodes)
prompts/ → prompt templates and partials (system nodes)
Knowledge Memory (read-only):
guides/ → domain knowledge the chatbot references
resources/ → helpful links, tools, etc.
references/ → external sources (papers, legislation)
User Memory (read-write, one per user or shared):
data → user profile and extracted facts
chats/ → conversation history (record nodes)
<chat-name>/
messages/ → individual messages (record nodes)
summary → conversation summary (abstract node)