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:
processChatResponsereturns more fields than the spec documents —conversationHandoff,chatEnded,onTrack,offTrackReasonare all current and not in the spec.dataandnext_stageare optional on the tool call. The spec implies all three.- Fallback rerouting on every turn. If the current
conversation is marked
isFallback: trueand a non-fallback conversation matches viagoalSignals, 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 (
summarizationNeededis alwaysnull, #1115). - No
<metadata>JSON</metadata>fallback. The spec mentions it; the implementation only supports tool-call extraction.
Related¶
- Conversation routing — model behind
the per-turn fields (
onTrack,next_stage, edges, goals). - Building a chatbot agent — the agent setup this API assumes.
- Memory access — how
userId/appIdflow through the four-gate access chain. - Debug PERMISSION_DENIED errors —
decision tree for the
Forbiddenerror.