back to tutorials

How to Use Pure Function Calling with Caller Handling

Learn how to implement pure function calling where the caller has full control over function execution. This tutorial covers manual conversation loops, activity messages, and building complex workflows with external function 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 activity end 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:

ApproachConfigurationExecution
Staticresult.dataServer returns data immediately
Channelresult.channelCaller publishes via channel
PureNo result propertyCaller handles everything

With pure functions, when the AI calls a function, the conversation ends with end.reason: 'activity'. You then:

  1. Inspect the function call
  2. Execute the function externally
  3. Construct a response activity message
  4. 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 result field 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