Last updated: 2026-06-29
Status: Architecture decided. Implementation Phase 7.
Context
CE currently gives the LLM one tool: recall_memory. This makes it a talking bot only.
Tool Registry transforms CE into an action platform — the bot can check orders, capture leads, book appointments, submit forms, and call any tenant-configured HTTP endpoint without a CE code change.
Two Types of Tools
Built-in Tools (ship with CE)
| Tool | What it does | Requires |
|---|---|---|
recall_memory |
Fetch CI memories on demand (L3) | ciEnabled + memoryTier >= 3 |
search_knowledge |
Vector search the knowledge base | knowledgeEnabled + sources configured |
collect_lead |
Save name/email/phone → leads table | leadCaptureEnabled |
escalate_to_human |
Trigger SLA alarm + handoff | Always available |
submit_form |
Fill and submit a vlozi Form | formId configured in config |
Custom Tenant Tools (webhook-configured, no code change)
Tenant registers in dashboard:
{
"name": "check_order",
"description": "Look up a customer order by order number. Use when customer asks about their order status.",
"parameters": {
"order_id": { "type": "string", "description": "The order number" }
},
"webhook_url": "https://mystore.com/api/orders/{order_id}",
"auth_header": "Bearer sk-...",
"timeout_ms": 5000
}CE dynamically constructs the LLM tool definition, calls the webhook when invoked, injects the result. No CE deployment per new tool — the tenant owns the configuration.
Tool Call Loop
// Build tool set for this message (intent-gated, session-deduplicated)
const tools = buildToolSet(config, intent, sessionVariables)
// LLM loop — max 5 rounds, prevents runaway loops
let round = 0
while (round < 5) {
const response = await llm.stream(messages, tools)
if (response.type === "text") break // final response, return to contact
if (response.type === "tool_call") {
const result = await executeTool(response.tool, response.args, config)
messages.push(toolResultMessage(response.tool, result))
round++
}
}executeTool() dispatches:
- Built-in name → built-in handler (callCI, leads service, forms service, etc.)
- Custom name → look up
ce_custom_tools→ HTTP webhook call
Tool Permission Gating
Tools are filtered before being offered to the LLM. The LLM never sees a tool it can't use.
| Tool | Condition |
|---|---|
recall_memory |
ciEnabled AND memoryTier >= 3 |
search_knowledge |
knowledgeEnabled AND sources configured |
collect_lead |
leadCaptureEnabled |
escalate_to_human |
Always — never filtered |
submit_form |
formId present in config |
| Custom tools | tool.enabled AND tenant subscription active |
Session deduplication: If collect_lead already succeeded this session, it's removed from the tool set — don't re-collect the same lead. Session variables track completed tool states:
{ "lead_collected": true, "order_id_checked": "12345" }Intent → Tool Mapping
Only offer tools relevant to the detected intent. Giving the LLM 10 tools on every message wastes tokens and causes hallucinated tool calls.
| Intent | Tools offered |
|---|---|
greeting / chitchat |
None |
product_question |
search_knowledge |
order_status |
Custom check_order (if configured) |
purchase_intent |
collect_lead, custom check_availability |
complaint |
escalate_to_human |
escalation_request |
escalate_to_human |
| Tier 3+ any intent | + recall_memory always available as safety net |
Tool Failure Handling
Decision: Acknowledge and continue.
When a tool fails (timeout, 4xx, 5xx, malformed JSON):
-
CE returns error string to LLM:
"Tool 'check_order' failed: upstream timeout. Inform the customer you cannot retrieve their order right now." -
LLM responds naturally:
"I'm having trouble pulling up your order right now. Could you check your confirmation email, or try again in a few minutes?" -
Session continues — bot stays in control
-
Failure logged to
ce_tool_calls+ Analytics Engine
Circuit breaker per tool per session:
Same custom tool fails 3 times in one session → removed from tool set for the remainder of that session. Prevents repeated failures from inflating token cost.
No automatic escalation on tool failure — the bot handles it conversationally.
Cost Impact
Every tool call adds one LLM round (tool result injected → LLM continues):
| Tool calls per message | LLM rounds | Cost multiplier |
|---|---|---|
| 0 | 1 | 1× |
| 1 | 2 | ~1.8× |
| 2 | 3 | ~2.5× |
| 5 (max) | 5 | ~4× |
Mitigations:
- Intent-gated tool set reduces unnecessary tool calls
- Session variable deduplication removes already-completed tools
- Haiku used where tool tasks are mechanical (not emotional/complex)
ce_tool_callsanalytics surfaces which tools are used vs ignored — prune accordingly
Database
ce_custom_tools
CREATE TABLE ce_custom_tools (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
parameters JSONB NOT NULL,
webhook_url TEXT NOT NULL,
auth_header TEXT, -- stored encrypted
timeout_ms INTEGER DEFAULT 5000,
enabled BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE (tenant_id, name)
);Separate table (not JSONB in ce_bot_config) for independent CRUD, per-tool enable/disable, and per-tool analytics.
ce_tool_calls
CREATE TABLE ce_tool_calls (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
message_id TEXT,
tool_name TEXT NOT NULL,
parameters JSONB,
result JSONB,
success BOOLEAN NOT NULL,
latency_ms INTEGER,
error TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);Also written to Analytics Engine for dashboard: tool success rate, latency, most-used tools.
ce_session_variables
CREATE TABLE ce_session_variables (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE (session_id, key)
);Cached in DO during active session, flushed to Neon async. Used for: completed tool deduplication, slot filling state (Phase 10 Conversation Flows), lead data collected.
New Files (Phase 7)
apps/chat-engine/src/
├── lib/
│ ├── tools.ts buildToolSet(), executeTool(), toolResultMessage()
│ └── tool-webhook.ts HTTP webhook executor — timeout, circuit breaker, response parsing
└── routes/
└── tools.route.ts CRUD: POST/GET/PATCH/DELETE /tools (tenant tool management)Modified:
src/lib/orchestrator.ts— replace staticRECALL_MEMORY_TOOLwithbuildToolSet(); wrap LLM call in 5-round loopsrc/db/schema.ts— addce_custom_tools,ce_tool_calls,ce_session_variables
What Doesn't Change
recall_memoryis currently the only tool (llm.ts:43). It already works, and the 2-pass recall loop in the orchestrator (orchestrator.ts:286) is exactly the pattern the 5-round loop generalizes — this is an extension of existing behavior, not a rewrite[[HANDOFF]]persona trigger still works alongsideescalate_to_humantool — both result in escalation- Billing accumulator — tool call LLM rounds billed as normal tokens
- DO hot path — session variables cached in DO, flushed to Neon async