Skip to content

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:

  1. Pass accountLogin in 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.
  2. A conversation belongs to an account. Switching the active account means another conversation list, not a continuation of the previous one.
  3. 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 answers AI_AGENT_NOT_FOUND — a backoffice setting, not an interface bug.
  4. 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. AIChatSendMessage returns a runId.
  • 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 accountLogin for an account-owner session in the terminal: the module answers AI_INVALID_DATA naming that field.
  • Showing AI_CONVERSATION_BUSY as an error instead of disabling the input.