Skip to content

Chat API

The Chat API is what a third-party chatbot integration calls to drive a conversation through a Hadron agent. Hadron compiles the system message and tool schema; the caller hosts the LLM, runs the turn, and reports the result back. Hadron handles routing, stage transitions, data extraction and history.

For the conceptual model behind the per-turn fields (topics, conversations, stages, edges, goals), see Conversation routing. This surface has no MCP equivalent — see MCP tools — Chat.

When to use this API vs. the portal

  • Use the portal for manual testing or when the agent is meant to be driven from inside the portal: an App's Chats tab → Agent chat runs a live chat, and the agent's Tester runs a scripted one. See Chatting with an agent and Test a chatbot with scripted personas.
  • Use this API when an external app (a website, a native client, an IVR) hosts the chat surface and the LLM call. The app passes user messages in and renders the assistant's replies; the routing logic stays in Hadron.

Surface

Four GraphQL mutations make up the Chat API:

Operation Purpose
startChat Open a chat. Returns the initial system message and tool schema.
sendChatMessage Save a user message and return a recompiled system message + history for the next LLM turn.
processChatResponse Save the LLM's structured response. Extracts data, evaluates routing edges, returns transitions.
saveChatSummary Persist a summary. Kept for compatibility; nothing requests summaries or uses them as summaries today (#1115).

There is no MCP equivalent. The hadron_chatbot_* MCP tools that mirrored these mutations were removed in hadron-server#1143; drive a chat through GraphQL or the portal.

Authentication

Every call carries an identity in ctx. The caller authenticates as one of:

Identity How What it binds
User (JWT) OAuth bearer token in the Authorization header. ctx.userId. Requires the user to hold at least the READER role on the agent's organization.
App App key in the Authorization header (or your platform's app-key conventions). ctx.appId. Resolves to App.agentId. Personal-class memory is auto-keyed on (app_id, user_id).
Anonymous No identity. ctx.userId = "anonymous". No AgentSubscription is auto-provisioned; reads / writes that require one will fail.

App-keyed identity is the typical shape for third-party chatbot integrations — your service holds the App key, and you pass each end user's ID through to bind their personal memory. See Memory access for how user-vs-app identity flows through the four-gate access chain.

Per-turn loop

A typical session is one startChat plus N user turns. Each user turn is one sendChatMessage followed by one processChatResponse.

sequenceDiagram
    participant App as Caller
    participant Hadron
    participant LLM

    App->>Hadron: startChat(agentId, userId?, conversationName?)
    Hadron-->>App: {chatId, systemMessage, tools, conversationName, stageName}
    App->>LLM: systemMessage + tools (no user msg yet)
    LLM-->>App: respond({message, onTrack, data?, next_stage?})
    App->>Hadron: processChatResponse(chatId, {message, onTrack, data, next_stage})
    Hadron-->>App: {displayMessage, stageTransitioned, ...}
    Note over App: render welcome to user

    loop Per user turn
      App->>Hadron: sendChatMessage(chatId, userMessage)
      Hadron-->>App: {systemMessage, tools, messageHistory}
      App->>LLM: systemMessage + tools + messageHistory + userMessage
      LLM-->>App: respond({message, onTrack, data?, next_stage?})
      App->>Hadron: processChatResponse(chatId, {message, onTrack, data, next_stage})
      Hadron-->>App: {displayMessage, stageTransitioned, newStageName?, ...}
      Note over App: render displayMessage to user
    end

The LLM is always tool-calling the canonical respond tool — the schema for it is included in the tools field that startChat and sendChatMessage return. If your LLM returns plain text instead of a tool call, treat it as a bug in your provider adapter; the Hadron surface assumes structured responses.

Operations

startChat

Open a new chat with an agent. Hadron creates the chat node, provisions per-user memory if needed, and runs the initial routing to pick a starting conversation and stage.

Inputs

Field Type Required Notes
agentId ID yes The agent to start the chat with.
userId ID no The end-user's ID. Defaults to ctx.userId for JWT identities. App-keyed callers should always pass this so personal memory is keyed correctly.
conversationName String no Force-pick a conversation by name. If omitted, the routing engine picks based on the agent's setup conversation or the welcome flow.

Returns

Field Type What it is
chatId ID Pass this to every subsequent call.
systemMessage String Compiled system prompt for the LLM — injected stages, agent persona, extraction spec, route history.
tools JSON The tool schema for the respond tool. Pass as-is to your LLM.
conversationName String The conversation the chat started in.
stageName String The starting stage.

After startChat, run one LLM turn with no user message — the welcome turn — and feed the LLM's respond tool call into processChatResponse. The chat begins from the assistant's side.

sendChatMessage

Save the user's next message and return a refreshed compilation of the system message and history.

Inputs

Field Type Required
chatId ID yes
userMessage String yes

Returns

Field Type What it is
systemMessage String Recompiled — reflects the current stage and any goals on the stack.
tools JSON Same schema as startChat. Resend on every turn — agents can mutate.
messageHistory [Message!] Full chat so far in the order the LLM should see.
summarizationNeeded Object? Always null today: summarization is disarmed until the design in hadron-server#1115 lands. The field stays so existing callers don't break.

processChatResponse

Save the LLM's structured response, save any data fields to the memory the chat lives in (the user's for a private chat, the App's for a shared one), evaluate stage and conversation transitions, and return what the caller should render.

Inputs

Field Type Required Notes
chatId String yes
toolResponse JSON yes The model's respond tool call arguments, as one JSON object. Its fields are below.

Fields of toolResponse

Field Type Required Notes
message String yes The reply text the LLM produced.
data Object no Extracted facts. Each key is merged into the data node of the memory the chat lives in (auto-created if missing): the user's memory for a private chat, the App's memory for a shared one.
next_stage String no A bare stage name in the current conversation to move to. null stays on the current stage; it doesn't end the chat.
onTrack Boolean no Whether the current conversation still fits what the user is asking. The respond tool schema requires it of the model; processChatResponse treats a missing value as true.
offTrackReason String no The model's explanation when onTrack is false.

Returns

Field Type What it is
displayMessage String The text to render to the user. Usually equal to message; may be rewritten if a transition produced a hand-off.
stageTransitioned Boolean True if the next turn will start in a different stage than this turn ended in.
newStageName String? The new stage if stageTransitioned.
conversationHandoff String? The new conversation if an edge fired (rare; today fires on conversation-end edges).
onTrack Boolean False when the LLM signalled onTrack: false in the tool call — the conversation has drifted. Today this is only recorded on the chat (with the reason); nothing re-routes on it.
offTrackReason String? The LLM's explanation when onTrack is false.
chatEnded Boolean Always false today; nothing marks a chat as ended.

saveChatSummary

Summarization is disarmed

sendChatMessage no longer asks for summaries: summarizationNeeded is always null, and nothing uses a saved summary as a summary. The mutation still works so existing callers don't break, but the saved summary is replayed to the model as an ordinary turn alongside the messages it covers, so calling it makes the history longer, not shorter. Re-arming it needs the design in hadron-server#1115.

Stores a summary for a chat.

Inputs

Field Type Required
chatId ID yes
summary String yes

Returns true. There is no reason to call it today.

Errors

The Chat API throws plain Error instances with text messages — no structured error codes in v1. The most common shapes you'll see at the GraphQL boundary:

Message When
Forbidden The caller's role doesn't satisfy the agent's gate. JWT callers need at least READER on the agent's org. App-keyed callers need an active AgentOrgGrant (cross-org) and active AgentSubscription for the user (personal-class). See Debug PERMISSION_DENIED errors.
Chat <chatId> not found The chat ID is invalid, soft-deleted, or scoped to a different user/app than the caller.
No system memory available The agent doesn't have a chatbot system memory attached. See Building a chatbot agent.
No target memory available for this chat The chat's personal-class memory could not be resolved or auto-provisioned. Usually a configuration issue with the App's Agent.memoryProvisioning.

If you need to disambiguate failure modes programmatically, match on the message prefix. A typed-error contract is on the roadmap but not in v1.

Spec drift

The canonical specification at hadron-concept/hadron-specs/Features/Chat-API.md is older than the implementation and is not authoritative. Treat the resolvers in hadron-server as the source of truth.

Concrete deltas you'll see if you compare:

  • processChatResponse returns more fields than the spec documents — conversationHandoff, chatEnded, onTrack, offTrackReason are all current and not in the spec.
  • data and next_stage are optional on the tool call. The spec implies all three.
  • Fallback rerouting on every turn. If the current conversation is marked isFallback: true and a non-fallback conversation matches via goalSignals, Hadron re-routes there on the next turn. The spec describes routing on session start only.
  • Summarization. The spec describes periodic summaries; the implementation has disarmed them (summarizationNeeded is always null, #1115).
  • No <metadata>JSON</metadata> fallback. The spec mentions it; the implementation only supports tool-call extraction.