Contents

Chapter 1

Building a Chatbot from Scratch

This guide walks you through building a complete chatbot using the Conversational AI Templates framework. By the end, you'll have a working bot with intent classification, slot filling, multi-turn dialogue, and escalation — running without any API keys.

Prerequisites

  • Python 3.10+
  • This template package (unzipped)
  • For LLM-backed features: an OpenAI or Anthropic API key (optional)

Architecture Overview

User Message
     │
     ▼
┌──────────────────┐
│  Channel Adapter  │  ← Parses platform-specific payload (web JSON, Slack event)
└────────┬─────────┘
         │ IncomingMessage
         ▼
┌──────────────────┐
│  DialogueManager  │  ← Central orchestrator
│  ┌──────────────┐ │
│  │ Intent        │ │  ← Step 1: What does the user want?
│  │ Classifier    │ │
│  └──────────────┘ │
│  ┌──────────────┐ │
│  │ Slot Filler   │ │  ← Step 2: Extract structured data
│  └──────────────┘ │
│  ┌──────────────┐ │
│  │ State Machine │ │  ← Step 3: Determine next state
│  └──────────────┘ │
│  ┌──────────────┐ │
│  │ Response Gen  │ │  ← Step 4: Generate reply
│  └──────────────┘ │
│  ┌──────────────┐ │
│  │ Escalation    │ │  ← Step 5: Check if human needed
│  │ Manager       │ │
│  └──────────────┘ │
└────────┬─────────┘
         │ TurnResult
         ▼
┌──────────────────┐
│ ConversationCtx   │  ← Persistent state across turns
└──────────────────┘

Step 1: Define Your Intents

Intents are the possible things a user might want. Start by listing them:

python
from src.intent import IntentDefinition, KeywordIntentClassifier

classifier = KeywordIntentClassifier()

# Each intent has keywords (fast matching) and optional regex patterns
# (higher precision). Priority breaks ties between equally-matched intents.
classifier.add_intent(IntentDefinition(
    name="order_status",
    description="User wants to check their order status",
    keywords=["order", "status", "track", "where is", "delivery"],
    patterns=[r"(?:where|when).+(?:order|package|delivery)"],
    priority=1,
))

classifier.add_intent(IntentDefinition(
    name="return_request",
    description="User wants to return a product",
    keywords=["return", "refund", "send back", "exchange"],
    patterns=[r"(?:want|need|like)\s+(?:to\s+)?(?:return|exchange|refund)"],
    priority=1,
))

classifier.add_intent(IntentDefinition(
    name="greeting",
    description="User says hello",
    keywords=["hello", "hi", "hey"],
    priority=0,
))

Tip: Start with 5-10 intents. Add more as you discover what users actually ask. The keyword classifier is fast and needs no API key — switch to LLMIntentClassifier later for production accuracy.

Step 2: Define Your Slots

Slots are the pieces of data you need to collect. The SlotFiller has built-in extractors for common types:

python
from src.slots import SlotFiller, SlotDefinition

filler = SlotFiller([
    SlotDefinition(
        name="order_id",
        description="Order tracking number",
        slot_type="text",
        required=True,
        prompt="What's your order number?",
        validation_pattern=r"^[A-Z]{2}-\d{6}$",
    ),
    SlotDefinition(
        name="email",
        description="Customer email for lookup",
        slot_type="email",
        required=True,
        prompt="What email address is your account under?",
    ),
    SlotDefinition(
        name="reason",
        description="Reason for return",
        slot_type="choice",
        required=True,
        prompt="What's the reason for the return?",
        choices=["defective", "wrong item", "changed mind", "other"],
    ),
])

# Extract slots from a message
results = filler.extract_from_message(
    "My order is OR-123456 and my email is buyer@example.com"
)
for r in results:
    if r.value:
        print(f"  {r.slot_name}: {r.value} (confidence: {r.confidence:.2f})")

Supported slot types: text, email, phone, date, time, number, choice, free_text.

Step 3: Build the Dialogue Flow

The dialogue is a state machine. Each state can have an entry message, required slots, and transitions:

python
from src.dialogue import DialogueFlow, DialogueState, DialogueManager

flow = DialogueFlow(
    name="Order Support Bot",
    initial_state="greeting",
    # Global intents work from any state
    global_intents={
        "cancel": "greeting",
        "escalate": "escalated",
    },
)

flow.add_state(DialogueState(
    name="greeting",
    entry_message="Hi! I can help with order status and returns. What do you need?",
    transitions={
        "order_status": "collect_order_id",
        "return_request": "collect_return_info",
    },
))

flow.add_state(DialogueState(
    name="collect_order_id",
    entry_message="Sure! What's your order number? (Format: XX-123456)",
    required_slots=["order_id"],
    transitions={"confirm": "show_status"},
    max_turns=3,
))

flow.add_state(DialogueState(
    name="show_status",
    entry_message="Order {order_id} is currently in transit. Estimated delivery: 2 business days. Anything else?",
    transitions={"goodbye": "farewell"},
))

flow.add_state(DialogueState(
    name="collect_return_info",
    entry_message="I can help with that. What's the email on your account?",
    required_slots=["email", "reason"],
    transitions={"confirm": "return_submitted"},
    max_turns=5,
))

flow.add_state(DialogueState(
    name="return_submitted",
    entry_message="Return request submitted for {email}. Reason: {reason}. You'll get a shipping label by email within 24 hours.",
    transitions={"goodbye": "farewell"},
))

flow.add_state(DialogueState(name="farewell", entry_message="Thanks! Have a great day."))
flow.add_state(DialogueState(name="escalated", entry_message="Connecting you with an agent..."))

Step 4: Wire It Together

python
manager = DialogueManager(
    flow=flow,
    # Plug in your classifier as the intent_classifier
    intent_classifier=lambda msg, ctx: classifier.classify(msg).intent_confidence_tuple(),
)

# Start a conversation
ctx, greeting = manager.start_session(user_id="cust-42", channel="web")
print(f"Bot: {greeting}")

# Process user turns
messages = [
    "I want to track my order",
    "It's OR-789012",
    "Thanks, bye!",
]

for msg in messages:
    print(f"User: {msg}")
    result = manager.process_turn(msg, ctx)
    print(f"Bot: {result.response}")
    print(f"  [state={result.new_state}, intent={result.intent}]")
    print()

Step 5: Add Escalation

The EscalationManager watches for situations where a human should take over:

python
from src.escalation import EscalationManager, EscalationRule, EscalationReason

esc_manager = EscalationManager()
esc_manager.add_default_rules()  # Too many turns, low confidence, etc.

# Add a custom rule
esc_manager.add_rule(EscalationRule(
    name="angry_user",
    reason=EscalationReason.NEGATIVE_SENTIMENT,
    condition=lambda ctx: any(
        word in ctx.history[-1].content.lower()
        for word in ["furious", "terrible", "worst", "lawyer"]
    ) if ctx.history else False,
    priority="high",
    target_queue="senior-support",
    message="I can see you're frustrated. Let me connect you with a senior agent.",
))

# Check after each turn
ticket = esc_manager.check_and_escalate(ctx)
if ticket:
    print(f"Escalation: {ticket.ticket_id} → {ticket.assigned_to}")
    print(f"Summary: {ticket.summary}")

Step 6: Add a Channel Adapter

Channel adapters translate between platform formats and your bot:

python
from src.channels.web import WebChannelAdapter
from src.channels.base import OutgoingMessage

web = WebChannelAdapter()

# Parse an incoming web request
incoming = web.parse_incoming({
    "message": "I need help with an order",
    "user_id": "web-user-42",
    "session_id": "sess-abc",
})

# Process with dialogue manager
result = manager.process_turn(incoming.text, ctx)

# Format the response for the web widget
outgoing = OutgoingMessage(
    text=result.response,
    buttons=[
        {"label": "Order Status", "value": "order_status"},
        {"label": "Returns", "value": "return_request"},
    ],
)
response_json = web.format_response(outgoing, session_id="sess-abc")
# → {"response": "...", "session_id": "sess-abc", "buttons": [...]}

Step 7: Load from YAML Config

For production, define your bot in YAML instead of Python code:

python
import yaml
from src.dialogue import build_flow_from_config
from src.intent import build_classifier_from_config

with open("templates/support_bot.yaml") as f:
    config = yaml.safe_load(f)

flow = build_flow_from_config(config["dialogue_flow"])
classifier = build_classifier_from_config(config["intent_classifier"])

# The rest is the same — create a DialogueManager and process turns
manager = DialogueManager(
    flow=flow,
    intent_classifier=lambda msg, ctx: (
        classifier.classify(msg).intent,
        classifier.classify(msg).confidence,
    ),
)

See the templates/ directory for three ready-to-use configs: support_bot.yaml, faq_bot.yaml, and booking_bot.yaml.

Common Patterns

Pattern: Confirmation Loop

When collecting critical data, add a confirmation state:

python
flow.add_state(DialogueState(
    name="confirm_details",
    entry_message="To confirm: your order is {order_id}, email is {email}. Correct?",
    transitions={
        "confirm": "process_request",
        "deny": "collect_order_id",  # Start over
    },
))

Pattern: Fallback State

Use fallback_state to handle unrecognized inputs without crashing:

python
flow.add_state(DialogueState(
    name="main_menu",
    entry_message="Choose: orders, returns, or account",
    transitions={...},
    fallback_state="didnt_understand",
))

Pattern: Context-Aware Responses

Use callback hooks to run logic on state entry:

python
def on_enter_vip_check(ctx):
    if ctx.metadata.get("user_tier") == "premium":
        ctx.metadata["skip_queue"] = True

flow.add_state(DialogueState(
    name="support_queue",
    entry_message="Please hold...",
    on_enter=on_enter_vip_check,
))

Testing Your Bot

Run the included tests to verify everything works:

bash
python -m pytest tests/ -v
# Or without pytest:
python -m unittest discover tests/

Write your own test by mocking the classifier:

python
def test_my_flow():
    flow = build_my_flow()
    forced = {"intent": "greeting", "conf": 0.9}

    manager = DialogueManager(
        flow,
        intent_classifier=lambda msg, ctx: (forced["intent"], forced["conf"]),
    )
    ctx, greeting = manager.start_session()

    forced["intent"] = "order_status"
    result = manager.process_turn("track my order", ctx)
    assert result.new_state == "collect_order_id"

Next Steps

  • Add LLM classification: Swap KeywordIntentClassifier for LLMIntentClassifier with your API key
  • Add persistence: Serialize ctx.to_dict() to Redis/DynamoDB between turns
  • Add analytics: Log TurnResult data to track intent distribution and drop-off points
  • Add Slack: Use SlackChannelAdapter and register a Slack app webhook
  • Customize responses: Replace the default response generator with an LLM-backed one for natural language
Chapter 2

Channel Integration Guide

This guide covers how to connect your chatbot to different messaging platforms using the channel adapter system.

How Channel Adapters Work

Channel adapters sit between the messaging platform and your dialogue manager. They handle two jobs:

1. Inbound: Parse the platform's webhook payload into a standardized IncomingMessage

2. Outbound: Format your bot's response into the platform's expected format

Platform Webhook → ChannelAdapter.parse_incoming() → IncomingMessage
                                                         │
                                                         ▼
                                                   DialogueManager
                                                         │
                                                         ▼
Platform API    ← ChannelAdapter.send()            ← OutgoingMessage

Every adapter implements the same ChannelAdapter interface, so your dialogue logic doesn't change between platforms. You write your bot once and deploy it everywhere.

Built-In Adapters

Web Chat (WebChannelAdapter)

The simplest adapter — handles JSON payloads from a web chat widget embedded in your site.

Incoming format:

json
{
    "message": "I need help",
    "user_id": "user-123",
    "session_id": "sess-abc",
    "metadata": {}
}

Outgoing format:

json
{
    "response": "I can help! What's your email?",
    "session_id": "sess-abc",
    "buttons": [
        {"label": "Cancel", "value": "cancel"}
    ]
}

Integration with Flask:

python
from flask import Flask, request, jsonify
from src.channels.web import WebChannelAdapter
from src.channels.base import OutgoingMessage
from src.dialogue import DialogueManager

app = Flask(__name__)
web_adapter = WebChannelAdapter()
manager = build_your_manager()  # Your configured DialogueManager

# Store contexts by session ID (use Redis in production)
sessions: dict[str, ConversationContext] = {}

@app.route("/chat", methods=["POST"])
def chat():
    payload = request.json
    incoming = web_adapter.parse_incoming(payload)

    session_id = payload.get("session_id", "")

    # Get or create session
    if session_id not in sessions:
        ctx, greeting = manager.start_session(
            user_id=incoming.user_id,
            channel="web",
        )
        sessions[ctx.session_id] = ctx
        session_id = ctx.session_id
        # Return greeting as first response
        outgoing = OutgoingMessage(text=greeting)
        return jsonify(web_adapter.format_response(outgoing, session_id))

    ctx = sessions[session_id]
    result = manager.process_turn(incoming.text, ctx)

    outgoing = OutgoingMessage(text=result.response)
    return jsonify(web_adapter.format_response(outgoing, session_id))

Integration with FastAPI:

python
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class ChatRequest(BaseModel):
    message: str
    user_id: str = "anonymous"
    session_id: str = ""

@app.post("/chat")
async def chat(req: ChatRequest):
    incoming = web_adapter.parse_incoming(req.dict())
    # ... same logic as Flask example

Slack (SlackChannelAdapter)

Handles Slack's Events API payloads and formats responses using Slack's mrkdwn syntax and Block Kit.

Setup requirements:

1. Create a Slack app at https://api.slack.com/apps

2. Enable Event Subscriptions

3. Subscribe to message.im events (for DMs) or message.channels (for channels)

4. Set your Request URL to https://your-server.example.com/slack/events

5. Install the app to your workspace

Incoming Slack event payload (simplified):

json
{
    "event": {
        "type": "message",
        "user": "U1234ABCD",
        "text": "I need help with my order",
        "ts": "1623456789.000100",
        "channel": "D1234ABCD"
    }
}

Integration with Flask:

python
import hmac
import hashlib
from flask import Flask, request, jsonify
from src.channels.slack import SlackChannelAdapter

app = Flask(__name__)
slack_adapter = SlackChannelAdapter(
    bot_token="xoxb-YOUR-BOT-TOKEN-HERE",
    signing_secret="YOUR-SIGNING-SECRET-HERE",
)

@app.route("/slack/events", methods=["POST"])
def slack_events():
    payload = request.json

    # Slack sends a challenge for URL verification
    if payload.get("type") == "url_verification":
        return jsonify({"challenge": payload["challenge"]})

    # Verify the request signature (critical for security)
    if not verify_slack_signature(request):
        return "Unauthorized", 401

    # Skip bot's own messages to prevent infinite loops
    event = payload.get("event", {})
    if event.get("bot_id"):
        return "OK", 200

    incoming = slack_adapter.parse_incoming(payload)

    # Get or create session (keyed by Slack user+channel)
    session_key = f"{incoming.user_id}:{incoming.channel_id}"
    if session_key not in sessions:
        ctx, greeting = manager.start_session(
            user_id=incoming.user_id,
            channel="slack",
        )
        sessions[session_key] = ctx
        slack_adapter.send(OutgoingMessage(
            text=greeting,
            channel_id=incoming.channel_id,
        ))
        return "OK", 200

    ctx = sessions[session_key]
    result = manager.process_turn(incoming.text, ctx)

    slack_adapter.send(OutgoingMessage(
        text=result.response,
        channel_id=incoming.channel_id,
    ))
    return "OK", 200


def verify_slack_signature(req) -> bool:
    """Verify that the request actually came from Slack."""
    timestamp = req.headers.get("X-Slack-Request-Timestamp", "")
    signature = req.headers.get("X-Slack-Signature", "")
    body = req.get_data(as_text=True)

    sig_basestring = f"v0:{timestamp}:{body}"
    computed = "v0=" + hmac.new(
        slack_adapter._signing_secret.encode(),
        sig_basestring.encode(),
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(computed, signature)

Slack formatting notes:

  • The adapter automatically converts markdown to Slack's mrkdwn format
  • bold*bold*
  • text
  • # Heading*Heading*
  • Newlines are preserved

Building a Custom Adapter

To add support for a new platform (Microsoft Teams, Discord, WhatsApp, etc.), subclass ChannelAdapter:

python
from src.channels.base import ChannelAdapter, IncomingMessage, OutgoingMessage

class DiscordChannelAdapter(ChannelAdapter):
    """Adapter for Discord bot interactions."""

    def __init__(self, bot_token: str) -> None:
        self._token = bot_token

    @property
    def channel_name(self) -> str:
        return "discord"

    def parse_incoming(self, raw_payload: dict) -> IncomingMessage:
        """Parse a Discord message event."""
        return IncomingMessage(
            text=raw_payload.get("content", ""),
            user_id=raw_payload.get("author", {}).get("id", ""),
            channel_id=raw_payload.get("channel_id", ""),
            timestamp=raw_payload.get("timestamp", ""),
            raw=raw_payload,
        )

    def send(self, message: OutgoingMessage) -> bool:
        """Send a message via Discord's REST API."""
        import urllib.request
        import json

        url = f"https://discord.com/api/v10/channels/{message.channel_id}/messages"
        data = json.dumps({"content": message.text}).encode()
        req = urllib.request.Request(
            url,
            data=data,
            headers={
                "Authorization": f"Bot {self._token}",
                "Content-Type": "application/json",
            },
        )
        try:
            urllib.request.urlopen(req)
            return True
        except Exception:
            return False

    def format_text(self, text: str) -> str:
        """Discord uses standard markdown, so pass through."""
        return text

Adapter Checklist

When building a custom adapter, make sure you handle:

  • [ ] Message parsing: Extract text, user ID, channel/room ID, timestamp
  • [ ] Attachments: Parse file uploads if the platform supports them
  • [ ] Bot message filtering: Skip the bot's own messages to prevent loops
  • [ ] Request verification: Validate webhook signatures (security-critical)
  • [ ] Rate limiting: Respect the platform's API rate limits on outbound messages
  • [ ] Rich formatting: Convert between markdown and platform-native formatting
  • [ ] Interactive elements: Buttons, dropdowns, or quick replies if supported
  • [ ] Error handling: Graceful degradation when the platform API is down

Multi-Channel Architecture

For bots that need to run on multiple platforms simultaneously:

python
from src.channels.web import WebChannelAdapter
from src.channels.slack import SlackChannelAdapter

# One dialogue manager, multiple adapters
adapters = {
    "web": WebChannelAdapter(),
    "slack": SlackChannelAdapter(
        bot_token="xoxb-YOUR-BOT-TOKEN-HERE",
        signing_secret="YOUR-SIGNING-SECRET-HERE",
    ),
}

def handle_message(channel: str, payload: dict):
    """Route incoming messages to the right adapter."""
    adapter = adapters.get(channel)
    if not adapter:
        raise ValueError(f"Unknown channel: {channel}")

    incoming = adapter.parse_incoming(payload)

    # Session keys include channel to keep conversations separate
    session_key = f"{channel}:{incoming.user_id}"
    ctx = get_or_create_session(session_key, channel)

    result = manager.process_turn(incoming.text, ctx)
    adapter.send(OutgoingMessage(
        text=result.response,
        channel_id=incoming.channel_id,
    ))

Session Persistence

For production deployments, you need to persist conversation state between server restarts. The ConversationContext class has to_dict() and from_dict() methods for serialization:

python
import json

# Save to Redis
def save_session(session_key: str, ctx: ConversationContext):
    redis_client.set(
        f"chat:session:{session_key}",
        json.dumps(ctx.to_dict()),
        ex=3600,  # Expire after 1 hour of inactivity
    )

# Load from Redis
def load_session(session_key: str) -> ConversationContext | None:
    data = redis_client.get(f"chat:session:{session_key}")
    if data:
        return ConversationContext.from_dict(json.loads(data))
    return None

For DynamoDB, use the same to_dict() output as the item payload. For PostgreSQL, store the JSON in a jsonb column.

Webhook Security

Never skip request verification. Each platform signs its webhook payloads:

PlatformVerification Method
SlackHMAC-SHA256 of request body with signing secret
DiscordEd25519 signature verification
WhatsAppHMAC-SHA256 with app secret
TelegramSecret token in URL path
Microsoft TeamsJWT token validation

The adapter's parse_incoming() doesn't verify signatures — that's your web framework's job. Always verify before processing.

Monitoring and Debugging

Log every turn for debugging:

python
import logging
logging.basicConfig(level=logging.DEBUG)

# The framework logs at DEBUG level:
#   - Intent classification results
#   - Slot extraction results
#   - State transitions
#   - Escalation triggers
#   - Message queue operations

For production monitoring, track these metrics per channel:

  • Messages received per minute
  • Average response time
  • Intent distribution (which intents are most common)
  • Escalation rate (what percentage of conversations need a human)
  • Conversation length (turns per session)
  • Drop-off rate (conversations abandoned without resolution)
Conversational AI Templates v1.0.0 — Free Preview