How to Use Pure Function Calling with Caller Handling
This tutorial demonstrates how to implement "pure" function calling in ChatBotKit, where functions are defined without handlers or static results, giving the caller complete control over function execution.
Learning Objectives
By the end of this tutorial, you will be able to:
- Define pure functions without handlers or result configurations
- Implement a manual conversation loop for full control
- Handle the
activityend reason to detect function calls - Construct response activity messages manually
- Build complex workflows with external function execution
Prerequisites
- Node.js 18+ installed
- A ChatBotKit account with an API secret
- Understanding of async/await and conversation flows
Estimated time: 25 minutes
Understanding Pure Functions
ChatBotKit supports three approaches to function results:
| Approach | Configuration | Execution |
|---|---|---|
| Static | result.data | Server returns data immediately |
| Channel | result.channel | Caller publishes via channel |
| Pure | No result property | Caller handles everything |
With pure functions, when the AI calls a function, the conversation ends with end.reason: 'activity'. You then:
- Inspect the function call
- Execute the function externally
- Construct a response activity message
- Continue the conversation
This pattern is ideal for:
- Complex orchestration or approval workflows
- Human-in-the-loop scenarios
- Persisting function calls to external systems
- Running functions in different processes or services
Step 1: Set Up Your Project
Create a new Node.js project:
mkdir pure-function-example
cd pure-function-example
npm init -y
npm install @chatbotkit/sdk dotenv
Create a .env file:
CHATBOTKIT_API_SECRET=your_api_secret_here
Step 2: Define Pure Functions
Create index.js and define functions without a result property:
import * as dotenv from 'dotenv'
import { ConversationClient } from '@chatbotkit/sdk/conversation/index.js'
dotenv.config()
const functions = [
{
name: 'search_products',
description: 'Search for products in the catalog',
parameters: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'The search query',
},
category: {
type: 'string',
description: 'Optional product category filter',
},
},
required: ['query'],
},
// No result property - this is a pure function
},
{
name: 'add_to_cart',
description: 'Add a product to the shopping cart',
parameters: {
type: 'object',
properties: {
productId: {
type: 'string',
description: 'The product ID to add',
},
quantity: {
type: 'number',
description: 'Quantity to add',
},
},
required: ['productId'],
},
// No result property - caller handles this
},
]
Step 3: Set Up the Conversation
Initialize the client and messages:
async function main() {
const client = new ConversationClient({
secret: process.env.CHATBOTKIT_API_SECRET,
})
const messages = [
{
type: 'user',
text: 'I want to find some wireless headphones and add the best one to my cart.',
},
]
console.log('User:', messages[0].text)
}
main().catch(console.error)
Step 4: Implement the Manual Conversation Loop
Create a loop that handles function calls manually:
let continueLoop = true
let iteration = 0
const maxIterations = 10
while (continueLoop && iteration < maxIterations) {
iteration++
let response
// Run the completion
for await (const item of client
.complete(null, {
model: 'claude-4.5-sonnet',
backstory: 'You are a helpful shopping assistant.',
messages,
functions,
})
.stream()) {
if (item.type === 'message') {
messages.push(item.data)
} else if (item.type === 'result') {
response = item.data
}
}
console.log(`Iteration ${iteration}: End reason = ${response.end.reason}`)
// Handle based on end reason
if (response.end.reason === 'activity') {
// Function call detected - handle it
await handleFunctionCall(messages)
} else if (response.end.reason === 'stop') {
// Natural completion
console.log(`Bot: ${response.text}`)
continueLoop = false
} else {
// Error or unexpected state
continueLoop = false
}
}
Step 5: Handle Function Calls
When the conversation ends with activity, extract and execute the function:
async function handleFunctionCall(messages) {
// Find the last activity message
const lastMessage = messages[messages.length - 1]
if (lastMessage.type !== 'activity') {
return
}
const activity = lastMessage.meta?.activity
if (activity?.type !== 'request') {
return
}
const functionName = activity.function?.name
const functionArgs = activity.function?.arguments
console.log(`Function call: ${functionName}`)
console.log(`Arguments: ${JSON.stringify(functionArgs, null, 2)}`)
// Execute the function
const result = await executeFunction(functionName, functionArgs)
console.log(`Result: ${JSON.stringify(result)}`)
// Add the response activity message
messages.push({
type: 'activity',
text: 'response',
meta: {
activity: {
type: 'response',
function: {
name: functionName,
arguments: functionArgs,
result: JSON.stringify(result),
},
},
},
})
}
Step 6: Implement Function Execution
Create the function that executes your business logic:
async function executeFunction(functionName, args) {
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 300))
switch (functionName) {
case 'search_products':
return {
products: [
{
id: 'WH-1000XM5',
name: 'Sony WH-1000XM5 Wireless Headphones',
price: 349.99,
rating: 4.8,
},
{
id: 'AirPods-Max',
name: 'Apple AirPods Max',
price: 549.00,
rating: 4.7,
},
{
id: 'QC45',
name: 'Bose QuietComfort 45',
price: 279.00,
rating: 4.6,
},
],
query: args?.query,
}
case 'add_to_cart':
return {
success: true,
productId: args?.productId,
quantity: args?.quantity || 1,
message: `Added ${args?.quantity || 1}x ${args?.productId} to cart`,
}
default:
return { error: `Unknown function: ${functionName}` }
}
}
Complete Example
Here's the complete working example:
import * as dotenv from 'dotenv'
import { ConversationClient } from '@chatbotkit/sdk/conversation/index.js'
dotenv.config()
const functions = [
{
name: 'search_products',
description: 'Search for products in the catalog',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'The search query' },
category: { type: 'string', description: 'Category filter' },
},
required: ['query'],
},
},
{
name: 'add_to_cart',
description: 'Add a product to the shopping cart',
parameters: {
type: 'object',
properties: {
productId: { type: 'string', description: 'The product ID' },
quantity: { type: 'number', description: 'Quantity to add' },
},
required: ['productId'],
},
},
]
async function main() {
const client = new ConversationClient({
secret: process.env.CHATBOTKIT_API_SECRET,
})
const messages = [
{
type: 'user',
text: 'Find wireless headphones and add the best one to my cart.',
},
]
console.log('User:', messages[0].text)
console.log('---')
let continueLoop = true
let iteration = 0
while (continueLoop && iteration < 10) {
iteration++
let response
for await (const item of client
.complete(null, {
model: 'claude-4.5-sonnet',
backstory: 'You are a helpful shopping assistant.',
messages,
functions,
})
.stream()) {
if (item.type === 'message') {
messages.push(item.data)
} else if (item.type === 'result') {
response = item.data
}
}
console.log(`[${iteration}] End reason: ${response.end.reason}`)
if (response.end.reason === 'activity') {
const lastMsg = messages[messages.length - 1]
const activity = lastMsg.meta?.activity
if (activity?.type === 'request') {
const fn = activity.function
console.log(`Executing: ${fn.name}`)
const result = await executeFunction(fn.name, fn.arguments)
messages.push({
type: 'activity',
text: 'response',
meta: {
activity: {
type: 'response',
function: {
name: fn.name,
arguments: fn.arguments,
result: JSON.stringify(result),
},
},
},
})
}
} else if (response.end.reason === 'stop') {
console.log(`\nBot: ${response.text}`)
continueLoop = false
} else {
continueLoop = false
}
}
console.log('---\nConversation complete')
}
async function executeFunction(name, args) {
await new Promise((r) => setTimeout(r, 300))
if (name === 'search_products') {
return {
products: [
{ id: 'WH-1000XM5', name: 'Sony WH-1000XM5', price: 349.99 },
{ id: 'AirPods-Max', name: 'Apple AirPods Max', price: 549.00 },
],
}
}
if (name === 'add_to_cart') {
return { success: true, productId: args?.productId }
}
return { error: 'Unknown function' }
}
main().catch(console.error)
Activity Message Structure
The response activity message must follow this structure:
{
type: 'activity',
text: 'response',
meta: {
activity: {
type: 'response',
function: {
name: 'function_name',
arguments: { /* original arguments */ },
result: '{"json": "stringified result"}',
},
},
},
}
Important: The
resultfield must be a JSON string, not an object.
Use Cases for Pure Functions
Human-in-the-Loop Approval
if (functionName === 'submit_order') {
const approved = await promptUserForApproval(functionArgs)
if (!approved) {
result = { error: 'Order rejected by user' }
}
}
External Service Execution
if (functionName === 'process_payment') {
// Send to payment service
result = await paymentService.process(functionArgs)
}
Audit Logging
// Log before execution
await auditLog.record('function_called', { functionName, functionArgs })
// Execute
const result = await executeFunction(functionName, functionArgs)
// Log after execution
await auditLog.record('function_completed', { functionName, result })
Troubleshooting
Infinite Loop
Always set a maxIterations limit and check the end reason to break out of the loop appropriately.
Missing Activity Data
Ensure you check lastMessage.type === 'activity' and activity.type === 'request' before accessing function data.
Result Not a String
The result field in the response activity must be a JSON string. Use JSON.stringify().
Next Steps
- Learn about channel-based function calling
- Explore background processing with dispatch
- Build multi-agent systems