AI Chat Integration¶
This page is for the interface. It describes the whole flow of one question, what arrives while the answer is being written, and what to do when something goes wrong.
The flow¶
POST ai/chat/conversation -> { id, agentId, ... }
POST ai/chat/message -> { runId, messageId, state: "queued", idempotent }
... ai.run.* events ... -> progress, fragments, preview
GET ai/chat/run?runId=... -> { state, result: { content, sources, actions, tools }, usage }
The send method does not wait for the model. It returns a runId and finishes. An
answer takes tens of seconds, and the platform closes a call after 5 seconds, so a
synchronous chat is not possible by design — not a temporary limitation.
A conversation must exist before the first message: create it, keep the id, then send.
Streaming¶
While the answer is being written, the module sends messages through the platform
delivery channel. The interface receives them
as module:event:
{
"event": "module:event",
"type": "ai.run.delta",
"event_id": "evt-…",
"schema_version": 1,
"created_at": 1789080000,
"data": {
"runId": "run_55b…",
"conversationId": "cnv_8f3a…",
"sequence": 7,
"delta": "…text fragment…"
}
}
type |
When | data beyond runId, conversationId, sequence |
|---|---|---|
ai.run.queued |
the request is accepted | state |
ai.run.started |
generation began | state |
ai.run.delta |
a fragment of the answer | delta |
ai.run.tool.started |
the assistant is reading platform data | tool, round |
ai.run.tool.completed |
the tool answered | tool, state, durationMs |
ai.run.completed |
the answer is ready | messageId, preview (≤ 280 chars), truncated, sources, actions, usage |
ai.run.cancelled |
the run was cancelled | state |
ai.run.failed |
the run failed | errorCode, errorMessage |
Events go to the session that asked: a manager receives them on the manager channel, a client on the account channel. Nothing has to be subscribed per conversation.
Three rules that keep the interface honest¶
Delivery is not guaranteed. There is no acknowledgement, no storage, no redelivery and
no deduplication. sequence grows monotonically inside one run — a gap means a message was
lost, and the interface should stop assembling text and fetch the result instead.
The final text does not arrive through the channel. ai.run.completed carries a
preview of up to 280 characters. The full answer is read with
AIChatGetRun — the same call that recovers a chat after a
reconnect.
Fragments are grouped, not per token. A delta is flushed after 200 characters or 150 ms, whichever comes first. The handler on the platform side runs inline in the receive loop, so a message per token would load the bus for nothing.
Without streaming¶
The chat works fully without module:event support: poll
AIChatGetRun once per second until state leaves queued and
running. The only thing missing is the typing effect. This is a reasonable first
iteration.
Recipes¶
Sending a message¶
Generate a requestId on the interface side and keep it for the retry of the same user
action. A repeated requestId returns the existing run with idempotent: true and does
not pay for a second answer — it protects against a double click, a network retry and a
page reload during send.
Pass route with the current screen (/backoffice/groups). A knowledge document attached
to that screen is ranked higher, and the answer becomes specific to what the user is
looking at.
While an answer is running¶
One active run per conversation. A second question is refused with
AI_CONVERSATION_BUSY — disable the input instead of catching the error.
After a reload or a reconnect¶
AIChatGetConversation returns activeRun:
{ "conversation": { }, "messages": [ ], "count": 42, "activeRun": { "id": "run_55b…", "state": "running" } }
If it is not null, show the indicator and poll that run. Nothing else has to be
reconstructed.
Cancelling¶
AIChatCancelRun stops the generation and marks the
conversation. It makes sense only in queued and running.
Rating an answer¶
AIFeedbackAdd takes rating of 1 or -1 and an optional
comment. Negative ratings are the main material for improving prompts, so show the comment
field immediately rather than behind a second click.
What to render from an answer¶
{
"runId": "run_55b…",
"state": "completed",
"result": {
"content": "Margin level is 109%…",
"sources": [
{ "id": "trading.margin-call", "title": "Margin call and stop out", "section": "Trading", "scope": "global", "score": 12.4 }
],
"actions": [
{ "type": "navigate", "route": "/backoffice/groups", "label": "Open groups" }
],
"tools": [ { "tool": "get_account_balance", "state": "ok" } ],
"messageId": "msg_1c7…"
},
"usage": { "inputTokens": 1840, "outputTokens": 120, "cost": 0.0042 }
}
sources— the documents the answer was built from. Showing them is the only way a manager can check the assistant without leaving the chat.actions— buttons. They come from the knowledge document, not from the model, so the assistant cannot offer a screen that does not exist or anything executable.tools— what platform data was read. A collapsed line is enough: "looked up the balance of account 2000067".usage— cost of the request. Belongs to administration, not to the chat.
Answers are bilingual: the assistant replies in the language of the question and the knowledge base has both. No language switch is needed in the interface.
Client chat in the terminal¶
The same methods, with four differences:
- Pass
accountLoginin every chat call if the client session belongs to an account owner (type: 9). A single-account session (type: 0) carries the login in the token and needs nothing. Sending the terminal's active account every time is the simplest rule. - A conversation belongs to an account. Switching the active account means another conversation list, not a continuation of the previous one.
- The agent is chosen by the module. Do not send
agentId; AIGetAgents returns only client-facing agents. If the brand has none configured, the chat answersAI_AGENT_NOT_FOUND— a backoffice setting, not an interface bug. - Events arrive on the account channel. Same frame, same types.
A client session never sees other accounts, employee tools or internal documents. These limits are enforced by the module, so the interface does not need switches for them.
Errors¶
| Code | HTTP | What happened | What to show |
|---|---|---|---|
AI_CONVERSATION_BUSY |
409 | a run is already active in this conversation | block the input, do not show an error |
AI_QUEUE_FULL |
503 | the service queue is full | "busy, try again in a minute" |
AI_RATE_LIMITED |
429 | too many requests | the same |
AI_BUDGET_EXCEEDED |
402 | the agent's daily budget is spent | "the limit for today is reached" — not a failure |
AI_MODEL_UNAVAILABLE |
503 | no model or no key | "the assistant is temporarily unavailable" |
AI_OPENROUTER_TIMEOUT |
504 | the model did not answer in time | offer to retry |
AI_TOOL_FAILED |
502 | the platform refused a data request | the answer still arrives, without that data |
AI_ACCESS_DENIED |
403 | the session is not allowed | a normal refusal |
AI_RUN_NOT_FOUND |
404 | someone else's or an expired runId |
reload the conversation |
AI_AGENT_NOT_FOUND |
404 | no agent is configured for this brand and audience | a backoffice setting |
AI_KNOWLEDGE_NOT_READY |
503 | the index is still building | "the service is starting" |
AI_SERVICE_NOT_READY |
503 | a platform dependency is missing | technical message; the reason is in the text |
A run with state: "failed" is not a transport error — it is an unsuccessful answer with
the same errorCode. Show it inside the conversation, not in a popup: the user must see
that their question did not vanish.
Limits¶
| What | Value |
|---|---|
| Message length | 8000 characters |
| Active runs per conversation | 1 |
| Suggested polling interval | 1 second |
| Delta flush | 200 characters or 150 ms |
| Model timeout | 90 seconds |
| A run is considered stuck after | 300 seconds |
| Message page | up to 200 |
Common integration mistakes¶
- Waiting for the answer synchronously.
AIChatSendMessagereturns arunId. - Assembling the final text from deltas. Deltas are for the typing effect; the answer
comes from
AIChatGetRun. One lost frame otherwise leaves a hole in the text. - Skipping
requestId. A double click pays for two answers. - Forgetting
accountLoginfor an account-owner session in the terminal: the module answersAI_INVALID_DATAnaming that field. - Showing
AI_CONVERSATION_BUSYas an error instead of disabling the input.