Chat API
This chapter shows how a website, app, messaging channel or telephony gateway sends a user's text to a bot and gets the reply. It covers POST /api/chat with and without streaming, how sessions and history work, how to end a session so the bot's onSessionEnd hooks run, and the /chat WebSocket. Read it when you connect anything that speaks text to LiteBot. For audio, see Voice.
Endpoints at a glance
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /api/chat |
None | Send one user message, get the reply. "stream": true streams it |
| GET | /api/chat/session/:sessionId |
None | Session summary: bot, message count, status, context |
| DELETE | /api/chat/session/:sessionId?reason= |
None | End the session and run its onSessionEnd hooks |
| POST | /api/chat/preview |
JWT or API key | Test an inline bot config without saving it (dashboard use) |
| GET | /api/chat/stats, /api/chat/analytics |
JWT or API key | Dashboard figures, tenant-scoped |
| WS | /chat |
None | Streamed chat over a WebSocket |
The public routes take no credentials. The bot is chosen by botId in the body. Anyone who can reach the port can talk to any active bot and read a session by id, so put LiteBot behind a proxy and limit who reaches it (see Security).
Send a message
POST /api/chat takes a JSON body:
| Field | Type | Required | Meaning |
|---|---|---|---|
message |
string | Yes | The user's text. Up to MAX_MESSAGE_LENGTH characters (default 4000) |
botId |
string | No | Bot that answers. Omit it and LiteBot picks one by intent, then falls back to the bot with id default, then DEFAULT_BOT_ID |
sessionId |
string | No | Continue this session. Omit it to start a new one. An id LiteBot has not seen creates a session with that id |
userId |
string | No | Who the user is. Stored on the session and passed to tools |
context |
object | No | Key-value facts merged into the session context before this turn runs |
stream |
boolean | No | true streams the reply (see Streaming). Default false |
Any other field fails validation.
curl -s -X POST http://127.0.0.1:3345/api/chat \
-H 'Content-Type: application/json' \
-d '{"message":"I need an appointment with a dentist","botId":"clinic-reception","userId":"+919876543210","context":{"callerNumber":"+919876543210"}}'
{
"success": true,
"data": {
"response": "Sure. Which day suits you?",
"sessionId": "sess_mh2k8x1qz4f7a9c",
"botId": "clinic-reception",
"shouldEscalate": false,
"shouldHangup": false,
"shouldWaitForMore": false,
"shouldGiveTime": false,
"skipProcessingSound": false,
"finishReason": "stop",
"usage": { "promptTokens": 1450, "completionTokens": 12, "totalTokens": 1462 },
"toolResults": [],
"metrics": { "totalTime": 980, "llmTime": 870, "toolsTime": 0, "dbTime": 30, "iterations": 0 }
}
}
Send sessionId from the first reply with every later message of the same conversation.
Response fields
| Field | Meaning |
|---|---|
response |
The bot's reply text. Empty when shouldWaitForMore or shouldGiveTime is set |
sessionId |
The session this turn belongs to. New sessions get an id of the form sess_... |
botId |
The bot that answered. It can differ from the one you sent if the bot switched (see Sessions and history) |
shouldEscalate |
An escalation rule matched (keyword or maxAttempts), or a tool returned escalated: true |
shouldHangup |
A tool returned hangup: true, for example hangupCall. Say response, then end the call |
shouldWaitForMore |
A tool decided the user is mid-sentence. Keep listening; do not speak |
shouldGiveTime |
A tool decided the user asked for a moment. Do not speak |
skipProcessingSound |
A tool asked the gateway not to play its "please wait" sound |
finishReason |
How the last model call ended: stop, length, content_filter, tool_error or other. length means the reply was cut at the bot's maxTokens |
usage |
Token counts and cost for this turn, summed over every model call in it |
toolResults |
One entry per tool call in this turn, with the tool's result |
metrics |
Timings in milliseconds and the number of tool iterations |
The non-streaming reply does not carry hangupReason; the streaming metadata does. If you need the reason without streaming, read it from the hangup tool's entry in toolResults.
Streaming
With "stream": true the same endpoint answers 200 with Content-Type: text/plain; charset=utf-8 and chunked transfer encoding. The reply text arrives as it is generated. After the last chunk comes a fixed separator line and one JSON object:
curl -N -s -X POST http://127.0.0.1:3345/api/chat \
-H 'Content-Type: application/json' \
-d '{"message":"What are your opening hours?","botId":"clinic-reception","sessionId":"sess_mh2k8x1qz4f7a9c","stream":true}'
We are open 9 am to 6 pm, Monday to Saturday.
--- METADATA ---
{"sessionId":"sess_mh2k8x1qz4f7a9c","botId":"clinic-reception","response":"We are open 9 am to 6 pm, Monday to Saturday.","shouldEscalate":false,"shouldHangup":false,"hangupReason":null,"shouldWaitForMore":false,"shouldGiveTime":false,"skipProcessingSound":false,"finishReason":"stop","usage":{...},"toolResults":[],"metrics":{...}}
Rules for a streaming client:
- Split the body on
\n\n--- METADATA ---\n. Everything before it is display text; the JSON after it is the result. - Treat
responsein the metadata as the final answer. The engine can change the text after the last chunk, for example to add a handoff line or to blank an incomplete turn. - If the body ends with
\nError: <message>and no metadata, the turn failed. The HTTP status is still200, because the headers were already sent. - For a bot with tools, text is held per model call and only the call that ends the turn is streamed, so interim text written next to a tool call never reaches the user.
STREAM_HOLD_TOOL_TURNS=falseturns that off and streams every call token by token: the first byte is faster, but a voice gateway may speak text that the bot then corrects. Bots without tools always stream token by token. - A turn that hits the iteration limit can stream nothing. Use
responsefrom the metadata.
Sessions and history
A session holds the bot, the user id, the message history and a context object. It is stored in MongoDB (conversations).
- New session. Omit
sessionId. The bot's system prompt becomes the first message. - Your own ids. Send an id LiteBot does not know, and it creates the session under that id. A gateway can use its call id this way.
- History sent to the model. The last
LLM_HISTORY_MAX_MESSAGESmessages (default 40), trimmed so the window starts at a user turn. - Context.
contextkeys are merged into the session before the turn runs, so tools see them on the first turn. The model sees them only when the bot hasinjectContext: true; then LiteBot adds aCurrent session context:block ofkey: valuelines. Keys that start with_are not shown to the model. - Prompt placeholders.
{{name}}in a system prompt is filled from the bot'svariableswhen the bot loads, not fromcontextper request. See Bot configuration reference. - Bot switching. From the third message on, LiteBot checks whether another bot fits the message better and can switch the session to it, even when your request named a
botId. The reply'sbotIdshows the switch. Raise the bot's switching threshold if a pinned channel must never switch (see Bots). - Idle close. A session untouched for
SESSION_IDLE_TIMEOUT_MS(default 30 minutes) is closed by a sweep that runs everySESSION_CLEANUP_INTERVAL_MS(default 15 minutes), with reasonidle_timeout.
GET /api/chat/session/:sessionId returns a summary, or 404 with "error": "Session not found":
curl -s http://127.0.0.1:3345/api/chat/session/sess_mh2k8x1qz4f7a9c
{
"success": true,
"data": {
"sessionId": "sess_mh2k8x1qz4f7a9c",
"botId": "clinic-reception",
"botName": "Clinic Reception",
"messageCount": 5,
"status": "active",
"createdAt": "2026-10-24T09:12:03.120Z",
"lastActivity": "2026-10-24T09:13:41.552Z",
"context": { "callerNumber": "+919876543210" },
"analytics": { "totalCost": 0.0004, "totalTokens": 2930, "messageCount": 2, "toolCallCount": 0, "averageResponseTime": 925, "provider": "gemini", "model": "gemini-2.5-flash" }
}
}
The full transcript and per-turn traces are under /api/conversations, which needs a login. See Conversations and analytics.
Ending a session
DELETE /api/chat/session/:sessionId closes the session. The reply comes back at once; the bot's onSessionEnd hooks, the analytics summary and the close run in the background, because hooks such as a summary plus a CRM webhook can take ten seconds or more.
curl -s -X DELETE 'http://127.0.0.1:3345/api/chat/session/sess_mh2k8x1qz4f7a9c?reason=remote_hangup'
{ "success": true, "message": "Session ended successfully" }
reason tells the hooks why the session ended. Hooks can filter on it.
| Reason | Who sends it |
|---|---|
api |
Default, when reason is missing or empty. Also the WebSocket end_session |
remote_hangup |
The VEXYL gateway, when the caller hangs up |
transfer |
The VEXYL gateway, when the call is transferred |
llm_hangup |
LiteBot itself, when a tool returned hangup: true in a /api/chat turn |
idle_timeout |
The idle sweep |
| any other text | Your client. It is passed through unchanged |
Hooks run once per session, only while it is still active, so a gateway DELETE that lands after an llm_hangup does not run them twice. A DELETE for an unknown or already-ended session still answers success: true. The log line Session-end hook ran with success: true means the hook was called; check the tool's own result for the business outcome. Writing hooks is covered in Session-end hooks.
Always send DELETE when your channel knows the conversation is over. Otherwise the session stays active, and the hooks wait, until the idle sweep.
WebSocket chat
ws://127.0.0.1:3345/chat streams replies over one connection. It takes no credentials. Every frame is one JSON object with a type. The largest accepted frame is 1 MB.
On connect the server sends:
{ "type": "connection_established", "connectionId": "3f0c...", "timestamp": "2026-10-24T09:12:00.000Z", "message": "WebSocket connection established successfully" }
Client to server
| Type | Fields | Effect |
|---|---|---|
chat_message |
message (required), sessionId, botId, userId |
Runs one turn, streamed |
init_session |
sessionId, botId, userId |
Attaches the connection to an existing session. An unknown id is not an error; the first chat_message creates it |
end_session |
sessionId |
Ends the session with reason api and runs its hooks |
ping |
none | Server answers pong |
{ "type": "chat_message", "botId": "clinic-reception", "userId": "web-4821", "message": "Do you open on Sunday?" }
chat_message does not read a context field. If you need per-session context, use POST /api/chat.
Server to client
| Type | Fields | When |
|---|---|---|
typing_indicator |
sessionId, status (typing or stopped) |
Before and after each turn |
chat_chunk |
sessionId, content |
Each streamed piece of the reply |
tool_execution |
sessionId, toolCalls |
The model asked for tools |
chat_response |
sessionId, response, botId, shouldEscalate, shouldWaitForMore, shouldGiveTime, skipProcessingSound, usage, toolResults, metrics |
The turn finished. response is the final text |
session_initialized |
sessionId, sessionInfo |
Reply to init_session. sessionInfo is null for an unknown id |
session_ended |
sessionId |
Sent to every connection on that session after end_session |
pong |
timestamp |
Reply to ping |
error |
error.code, error.message |
See below |
On the first message of a new session, chat_chunk frames carry the sessionId you sent (none); take the id from chat_response. chat_response has no shouldHangup; a client that needs it reads the hangup tool's entry in toolResults.
Error codes: INVALID_JSON, INVALID_MESSAGE_FORMAT (missing type, unknown type, or chat_message without message), RATE_LIMIT_EXCEEDED, CHAT_PROCESSING_ERROR, SESSION_INIT_ERROR, SESSION_END_ERROR, MESSAGE_PROCESSING_ERROR.
Limits: 30 messages per minute per connection. Every 5 minutes the server pings each connection, terminates one that does not answer within 5 seconds, and closes one idle for 30 minutes with code 1001. Process shutdown closes connections with 1001.
Errors and rate limits
| Status | Body | Cause |
|---|---|---|
| 400 | {"success":false,"error":"Validation error","details":[...]} |
Missing message, a message over the length limit, a wrong type, or an unknown field |
| 404 | {"success":false,"error":"Session not found"} |
GET /api/chat/session/:id for an unknown id |
| 429 | {"error":"Too many requests from this IP, please try again later.","retryAfter":900,"limit":100} |
The /api rate limit |
| 500 | {"success":false,"error":"<message>"} |
Engine error, for example Bot not found: <id>, Bot is not active: <id>, or no default bot when botId is omitted |
The /api limit is per client IP: 100 requests per 15 minutes when NODE_ENV=production, 500 per 5 minutes otherwise. API_RATE_LIMIT changes the count, not the window. RateLimit-* headers report the state. A gateway or WhatsApp connector sends every user's traffic from one IP, so raise API_RATE_LIMIT to cover its peak, or the whole channel gets 429s. LiteBot trusts one proxy hop (trust proxy is 1), so behind a single reverse proxy the limit applies to the real client address.
Messaging channels such as WhatsApp
A connector that relays WhatsApp or SMS messages calls POST /api/chat once per incoming message.
- Put the sender's phone number in
userId. Tools and session-end hooks read the caller's number fromuserId, because these channels usually send an emptycontext. - Keep one
sessionIdper sender and conversation, for example in the connector's own store. Without it every message starts a new session with no history. - Send
botIdon every request so routing does not depend on intent detection. - End the conversation with
DELETE /api/chat/session/:id?reason=<your-reason>when your channel decides it is over, for example after a quiet period, so the hooks run with the full transcript. - Do not use
/api/chat-simple. It is a legacy demo route with a fixed keyword responder: it ignoresbotId, never calls a model, and keeps sessions in memory only.
Where to go next
- Talk to a bot with audio, or connect the VEXYL gateway: Voice
- Write the hooks that run when a session ends: Session-end hooks
- Read transcripts, traces and costs: Conversations and analytics
- Every other endpoint: HTTP API reference