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
/chatWebSocket closes without anend_sessionmessage. The session stays open and the idle sweep runs the hooks later withidle_timeout. - Realtime voice over
/voice(Voice). These sessions are marked ended directly when the call stops, andonSessionEndhooks do not run for them. Voice through the gateway's pipeline mode uses/api/chatand 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 inreasons.
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
paramsthat are not declared in the tool'spropertiesare removed beforeexecuteruns. Declare every one. - If a name in
requiredis missing from the hook'sparams, 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.chatruns on the provider manager with the bot's tenant key, if any, and no cache. Without aprovideroption it uses the default provider choice (LLM providers).metadata.timeoutof 25 seconds covers the summary and the webhook. Check thatCIRCUIT_BREAKER_TIMEOUTis 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 supplytranscript, so pass one in the request'scontextobject.
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
- Write the hook tool: Tools
- Where
onSessionEndsits in the bot file: Bot configuration reference - How the gateway ends sessions over the chat API: Chat API
- Find the ended conversation and its transcript: Conversations and analytics
- Hooks not running: Troubleshooting