LiteBot

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 response in 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 still 200, 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=false turns 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 response from 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_MESSAGES messages (default 40), trimmed so the window starts at a user turn.
  • Context. context keys 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 has injectContext: true; then LiteBot adds a Current session context: block of key: value lines. Keys that start with _ are not shown to the model.
  • Prompt placeholders. {{name}} in a system prompt is filled from the bot's variables when the bot loads, not from context per 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's botId shows 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 every SESSION_CLEANUP_INTERVAL_MS (default 15 minutes), with reason idle_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 from userId, because these channels usually send an empty context.
  • Keep one sessionId per sender and conversation, for example in the connector's own store. Without it every message starts a new session with no history.
  • Send botId on 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 ignores botId, never calls a model, and keeps sessions in memory only.

Where to go next