LiteBot

Session-end hooks

A session-end hook runs a tool automatically when a conversation ends, without asking the model. Use it to log every call in a CRM, send a summary to a webhook or save what was collected, including for callers who hang up mid-conversation. This chapter covers how to declare hooks on a bot, when they fire and when they do not, what the tool receives, and how to read the log. Read Tools first: a hook is an ordinary tool.

Declaring hooks

Add onSessionEnd to the bot. Each entry names a tool, the parameters to pass and, optionally, which end reasons it runs for. In the dashboard, the same list is edited in the bot form's session-end tools section.

{
  "id": "sales-desk",
  "onSessionEnd": [
    {
      "tool": "postCallSummary",
      "params": {
        "transcript": "{{transcript}}",
        "phone": "{{context.callerPhone}}",
        "reason": "{{reason}}"
      },
      "reasons": ["remote_hangup", "llm_hangup", "idle_timeout"]
    }
  ]
}
Field Required Meaning
tool yes Tool name, resolved like any tool call (tenant DB, global DB, disk)
params no Parameters, with {{path}} placeholders filled from the session
reasons no Run only for these end reasons. Omitted or empty runs for all

The hook tool does not need to be in the bot's tools array. Leaving it out keeps the model from calling it mid-conversation.

Hooks run one after another in the order listed. A hook that fails is logged and the next one still runs.

Placeholders

Placeholder Value
{{sessionId}} Conversation id
{{botId}} Bot id
{{userId}} User id sent with the chat requests
{{reason}} End reason, see below
{{messageCount}} Number of user and assistant messages
{{transcript}} Every user and assistant message, one per line as user: ... or assistant: ...
{{context.<key>}} A value from the session context, for example {{context.callerPhone}} or a value a tool saved with updateContext

A parameter that is exactly one placeholder receives the raw value, so "{{messageCount}}" arrives as a number. A placeholder inside other text is turned into a string. A missing value becomes an empty string.

When hooks fire

Reason Fires when
remote_hangup DELETE /api/chat/session/:id?reason=remote_hangup, which the voice gateway sends when the caller hangs up
transfer DELETE /api/chat/session/:id?reason=transfer, sent when the call was handed to a human
api DELETE /api/chat/session/:id without a reason, or an end_session message on the /chat WebSocket
llm_hangup The model ends the call with the hang-up tool. Hooks run in the background so the reply is not delayed
idle_timeout The idle sweep closes a session with no activity for SESSION_IDLE_TIMEOUT_MS
curl -s -X DELETE "http://127.0.0.1:3345/api/chat/session/sess_abc123?reason=remote_hangup"
{ "success": true, "message": "Session ended successfully" }

The DELETE answers at once and runs the hooks in the background, because a summary plus a webhook can take ten seconds. A reason other than the five above is passed to the hooks as given, but reasons in the bot config accepts only those five.

Variable Default Meaning
SESSION_IDLE_TIMEOUT_MS 1800000 (30 minutes) Inactivity after which a session counts as abandoned
SESSION_CLEANUP_INTERVAL_MS 900000 (15 minutes) How often the idle sweep runs

An abandoned session therefore runs its hooks between 30 and 45 minutes after the last message with the defaults.

Hooks run at most once per session: only while the session is still active, and a second end request that arrives while hooks are running is ignored. If the model hangs up and the gateway sends its DELETE a moment later, the hooks run once, with reason llm_hangup.

When hooks do not fire

  • The /chat WebSocket closes without an end_session message. The session stays open and the idle sweep runs the hooks later with idle_timeout.
  • Realtime voice over /voice (Voice). These sessions are marked ended directly when the call stops, and onSessionEnd hooks do not run for them. Voice through the gateway's pipeline mode uses /api/chat and the DELETE above, so hooks do run there.
  • After a restart. The idle sweep runs hooks only for sessions this process has in memory. Sessions left open by a previous process are closed in the database without hooks.
  • The bot has no onSessionEnd, or the end reason is not in reasons.

What the tool receives

params are the resolved parameters, validated against the tool's parameters schema like any call. context carries the usual fields (Tools) plus three for hooks:

Field Content
context.sessionEndReason The end reason
context.transcript The same transcript as {{transcript}}
context.messageCount The same count as {{messageCount}}
context.sessionContext The session context at the moment the session ended
context.updateContext Write back to the session context, for example to record that the summary was sent

Because the transcript and reason are in context, a hook tool can ignore params for them and read context instead.

Declare every parameter, require none

Two validation rules catch hook tools out:

  • Parameters you pass in params that are not declared in the tool's properties are removed before execute runs. Declare every one.
  • If a name in required is missing from the hook's params, validation fails and the tool never runs. A hook has no model to ask for the missing value.

Write hook tools with "required": [] and check values inside execute.

Reading the log

Each hook writes one line:

Session-end hook ran {"sessionId":"sess_abc123","tool":"postCallSummary","reason":"remote_hangup","success":true}

success:true here means the tool ran without throwing and its arguments validated. It does not mean the tool did its job: a tool that returned { success: false, message: 'Webhook refused' } is still logged as success:true. To know whether the summary arrived, log the outcome inside your tool and look for that line, or check the receiving system. Session-end hook failed appears only when the tool could not be run at all.

Example: send a call summary to a webhook

The tool below writes a three-line summary with the bot's own model and posts it, with the raw transcript, to a webhook.

// config/tools/postCallSummary.js
const WEBHOOK_URL = 'https://hooks.example.com/call-summary';

module.exports = {
  name: 'postCallSummary',
  description: 'Session-end hook: summarise the conversation and post it to the CRM webhook. Not for use during a conversation.',
  parameters: {
    type: 'object',
    properties: {
      phone: { type: 'string', description: 'Caller phone number' },
      reason: { type: 'string', description: 'Why the session ended' }
    },
    required: []
  },
  metadata: { category: 'hooks', timeout: 25000 },

  async execute(params, context) {
    const { axios, logger, llm } = context.modules;
    const transcript = context.transcript || '';
    if (!transcript.trim()) {
      return { success: true, message: 'Empty conversation, nothing sent' };
    }
    let summary = '';
    try {
      const reply = await llm.chat([
        { role: 'system', content: 'Summarise this call in three short lines: who called, what they wanted, what was agreed.' },
        { role: 'user', content: transcript }
      ], { maxTokens: 200, temperature: 0.2 });
      summary = (reply && reply.content) || '';
    } catch (error) {
      logger.warn('postCallSummary: summary failed, sending transcript only', { error: error.message });
    }
    if (context.testMode) {
      return { success: true, message: 'Test mode: not posted', summary };
    }
    try {
      const res = await axios.post(WEBHOOK_URL, {
        sessionId: context.sessionId,
        botId: context.botId,
        phone: params.phone || context.userId || '',
        reason: params.reason || context.sessionEndReason,
        messageCount: context.messageCount,
        summary,
        transcript
      }, { timeout: 10000 });
      const ok = res.status === 200 && res.data && res.data.received === true;
      logger.info('postCallSummary result', { sessionId: context.sessionId, ok, status: res.status });
      return ok
        ? { success: true, message: 'Summary posted' }
        : { success: false, message: 'Webhook did not confirm receipt' };
    } catch (error) {
      logger.error('postCallSummary post failed', { sessionId: context.sessionId, error: error.message });
      return { success: false, message: 'Webhook unreachable' };
    }
  }
};

Points to note:

  • llm.chat runs on the provider manager with the bot's tenant key, if any, and no cache. Without a provider option it uses the default provider choice (LLM providers).
  • metadata.timeout of 25 seconds covers the summary and the webhook. Check that CIRCUIT_BREAKER_TIMEOUT is not overriding it (Tools).
  • The tool logs its own result line, which is the line to search for when someone asks whether a summary was sent.
  • Test it with POST /api/tools/postCallSummary/test. The test endpoint does not supply transcript, so pass one in the request's context object.
curl -s -X POST http://127.0.0.1:3345/api/tools/postCallSummary/test \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '{"parameters":{"phone":"9800000000","reason":"api"},"context":{"transcript":"user: I want a demo\nassistant: Booked for Monday"}}'

Where to go next