Skip to main content
A debt collection agent that processes WhatsApp messages, categorizes conversation outcomes, and tracks collection metrics. This example is based on real production patterns and shows how to handle webhook input transformation, phone-based identification, and intelligent categorization.

Project Structure

collections-agent/
├── main.ts
├── agent.ts
├── tools/
│   ├── index.ts
│   └── finalize-conversation.ts
├── prompts/
│   └── index.ts
├── config/
│   └── settings.ts
├── .runflow/
│   └── rf.json
├── package.json
└── tsconfig.json

Step 1: Configuration

Define your categories, priorities, and constants:
config/settings.ts
export const CONVERSATION_OUTCOMES = [
  'PAYMENT_PROMISED',
  'PAYMENT_PLAN_ACCEPTED',
  'ALREADY_PAID',
  'DISPUTE',
  'WRONG_NUMBER',
  'NO_RESPONSE',
  'REFUSED_TO_PAY',
  'REQUESTED_CALLBACK',
] as const;

export type ConversationOutcome = (typeof CONVERSATION_OUTCOMES)[number];

export const OUTCOME_PRIORITIES: Record<ConversationOutcome, 'high' | 'medium' | 'low'> = {
  PAYMENT_PROMISED: 'high',
  PAYMENT_PLAN_ACCEPTED: 'high',
  ALREADY_PAID: 'medium',
  DISPUTE: 'high',
  WRONG_NUMBER: 'low',
  NO_RESPONSE: 'low',
  REFUSED_TO_PAY: 'medium',
  REQUESTED_CALLBACK: 'medium',
};

export const AGENT_CONFIG = {
  model: 'gpt-4o',
  temperature: 0,
  memoryMaxTurns: 50,
};

Step 2: Prompt

prompts/index.ts
export const collectionsPrompt = `You are a professional and empathetic debt collection agent.

## Behavior
- Always be respectful and never aggressive
- Understand the customer's situation before proposing solutions
- Offer payment plan options when appropriate
- If the customer already paid, acknowledge and thank them
- If it's the wrong number, apologize and end the conversation

## Tools
- Use **finalize-conversation** when the conversation reaches a conclusion:
  - Customer promised to pay
  - Customer accepted a payment plan
  - Customer disputes the debt
  - Customer says it's wrong number
  - Customer explicitly refuses to pay
- Always include a summary of the conversation when finalizing

## Important Rules
- Never threaten the customer
- Never share the debt amount with third parties
- If the customer is upset, acknowledge their feelings
- Maximum 3 attempts to negotiate before offering to schedule a callback`;

Step 3: Categorization Tool

The main tool categorizes conversation outcomes and tracks metrics:
tools/finalize-conversation.ts
import { createTool } from '@runflow-ai/sdk';
import { track } from '@runflow-ai/sdk/observability';
import { z } from 'zod';
import { CONVERSATION_OUTCOMES, OUTCOME_PRIORITIES } from '../config/settings';

export const finalizeConversationTool = createTool({
  id: 'finalize-conversation',
  description: 'Finalize a collection conversation with outcome categorization',
  inputSchema: z.object({
    outcome: z.enum(CONVERSATION_OUTCOMES).describe('The conversation outcome'),
    summary: z.string().describe('Brief summary of the conversation'),
    paymentDate: z.string().optional().describe('Promised payment date, if applicable'),
    amount: z.number().optional().describe('Agreed payment amount, if applicable'),
    notes: z.string().optional().describe('Additional observations'),
  }),
  execute: async ({ context }) => {
    const priority = OUTCOME_PRIORITIES[context.outcome];

    // Track business metrics
    track('collection_finalized', {
      outcome: context.outcome,
      priority,
      hasPaymentDate: !!context.paymentDate,
      amount: context.amount,
    });

    return {
      success: true,
      outcome: context.outcome,
      priority,
      summary: context.summary,
      paymentDate: context.paymentDate,
      nextAction: getNextAction(context.outcome),
    };
  },
});

function getNextAction(outcome: string): string {
  const actions: Record<string, string> = {
    PAYMENT_PROMISED: 'Schedule follow-up for payment date',
    PAYMENT_PLAN_ACCEPTED: 'Send payment plan details via email',
    ALREADY_PAID: 'Verify payment in system and close case',
    DISPUTE: 'Escalate to legal team',
    WRONG_NUMBER: 'Remove number from contact list',
    NO_RESPONSE: 'Schedule retry in 48 hours',
    REFUSED_TO_PAY: 'Escalate to supervisor',
    REQUESTED_CALLBACK: 'Schedule callback at requested time',
  };
  return actions[outcome] || 'Review manually';
}
tools/index.ts
export { finalizeConversationTool } from './finalize-conversation';

Step 4: Agent Definition

agent.ts
import { Agent, openai } from '@runflow-ai/sdk';
import { collectionsPrompt } from './prompts';
import { finalizeConversationTool } from './tools';
import { AGENT_CONFIG } from './config/settings';

export const collectionsAgent = new Agent({
  name: 'Collections Agent',
  instructions: collectionsPrompt,
  model: openai(AGENT_CONFIG.model),
  memory: {
    maxTurns: AGENT_CONFIG.memoryMaxTurns,
  },
  tools: {
    finalizeConversation: finalizeConversationTool,
  },
  modelConfig: {
    temperature: AGENT_CONFIG.temperature,
  },
  observability: 'full',
});

Step 5: Main Entry Point with Webhook Parsing

The main.ts handles input transformation from WhatsApp webhooks, user identification by phone, and response formatting:
main.ts
import { identify, track } from '@runflow-ai/sdk/observability';
import { collectionsAgent } from './agent';

// Parse webhook input from WhatsApp/Zenvia/Twilio
function parseWebhookInput(input: any) {
  // Zenvia format
  if (input.message?.from) {
    return {
      phone: input.message.from,
      message: input.message.contents?.[0]?.text || input.message.text || '',
      channel: 'zenvia',
    };
  }

  // Twilio format
  if (input.From && input.Body) {
    return {
      phone: input.From,
      message: input.Body,
      channel: 'twilio',
    };
  }

  // Direct API call
  return {
    phone: input.phone || input.from,
    message: input.message,
    channel: input.channel || 'api',
  };
}

export async function main(input: any) {
  const { phone, message, channel } = parseWebhookInput(input);

  if (!message || !phone) {
    return { error: 'message and phone are required' };
  }

  // Identify by phone number — memory is bound to this number
  identify(phone);

  // Track incoming message
  track('collection_message_received', { channel });

  try {
    const result = await collectionsAgent.process({
      message,
      sessionId: `collection_${phone}`,
    });

    return {
      message: result.message,
      phone,
      metadata: result.metadata,
    };
  } catch (error) {
    console.error('[collections-agent] Error:', error);
    return { error: 'An error occurred processing the message' };
  }
}

Key Patterns

Phone-Based Identification

In WhatsApp/phone integrations, the phone number is the natural user identifier. Memory persists across all conversations with the same number:
identify('+5511999999999');
// All subsequent agent.process() calls use this context
// Memory is bound to this phone number

Webhook Input Transformation

Real integrations receive data in different formats (Zenvia, Twilio, custom APIs). Always normalize the input before processing:
function parseWebhookInput(input: any) {
  // Handle multiple formats
  // Return a consistent { phone, message, channel } object
}

Outcome Categorization

Using TypeScript enums for conversation outcomes keeps your code type-safe and makes it easy to build dashboards:
// Strongly typed outcomes
export const OUTCOMES = ['PAYMENT_PROMISED', 'DISPUTE', ...] as const;
export type Outcome = (typeof OUTCOMES)[number];

// Each outcome maps to a priority and next action
export const PRIORITIES: Record<Outcome, 'high' | 'medium' | 'low'> = { ... };

Next Steps

Customer Support with RAG

Support agent with knowledge base

Context Management

Learn about identify patterns

Observability

Track business metrics

Best Practices

Tips for effective agents