Skip to content

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.

  1. Create a new memory: e.g. "Juno Conversations" (your-org.com:juno-conversations)
  2. 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

  1. Create or update your agent
  2. Set the System Memory ID to your conversation design memory
  3. 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 name and industry in 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:

  1. Call the LLM with the summarization prompt and messages
  2. 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:

  1. sendChatMessage(chatId, userMessage) → compiled prompt + tools.
  2. Call your LLM with stream: true (or whatever your provider's streaming flag is). Render incoming text deltas into the UI as they arrive.
  3. Wait for the stream to complete and assemble the final structured response (tool-call JSON: message, data, next_stage).
  4. 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:

{ "stageOrder": ["understand", "search", "connect"] }

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)