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.
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
└──────────────────┘
Intents are the possible things a user might want. Start by listing them:
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.
Slots are the pieces of data you need to collect. The SlotFiller has built-in extractors for common types:
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.
The dialogue is a state machine. Each state can have an entry message, required slots, and transitions:
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..."))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()The EscalationManager watches for situations where a human should take over:
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}")Channel adapters translate between platform formats and your bot:
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": [...]}For production, define your bot in YAML instead of Python code:
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.
When collecting critical data, add a confirmation state:
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
},
))Use fallback_state to handle unrecognized inputs without crashing:
flow.add_state(DialogueState(
name="main_menu",
entry_message="Choose: orders, returns, or account",
transitions={...},
fallback_state="didnt_understand",
))Use callback hooks to run logic on state entry:
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,
))Run the included tests to verify everything works:
python -m pytest tests/ -v
# Or without pytest:
python -m unittest discover tests/Write your own test by mocking the classifier:
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"KeywordIntentClassifier for LLMIntentClassifier with your API keyctx.to_dict() to Redis/DynamoDB between turnsTurnResult data to track intent distribution and drop-off pointsSlackChannelAdapter and register a Slack app webhookThis guide covers how to connect your chatbot to different messaging platforms using the channel adapter system.
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.
WebChannelAdapter)The simplest adapter — handles JSON payloads from a web chat widget embedded in your site.
Incoming format:
{
"message": "I need help",
"user_id": "user-123",
"session_id": "sess-abc",
"metadata": {}
}Outgoing format:
{
"response": "I can help! What's your email?",
"session_id": "sess-abc",
"buttons": [
{"label": "Cancel", "value": "cancel"}
]
}Integration with Flask:
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:
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 exampleSlackChannelAdapter)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):
{
"event": {
"type": "message",
"user": "U1234ABCD",
"text": "I need help with my order",
"ts": "1623456789.000100",
"channel": "D1234ABCD"
}
}Integration with Flask:
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:
mrkdwn formatbold → *bold*text → # Heading → *Heading*To add support for a new platform (Microsoft Teams, Discord, WhatsApp, etc.), subclass ChannelAdapter:
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 textWhen building a custom adapter, make sure you handle:
For bots that need to run on multiple platforms simultaneously:
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,
))For production deployments, you need to persist conversation state between server restarts. The ConversationContext class has to_dict() and from_dict() methods for serialization:
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 NoneFor DynamoDB, use the same to_dict() output as the item payload. For PostgreSQL, store the JSON in a jsonb column.
Never skip request verification. Each platform signs its webhook payloads:
| Platform | Verification Method |
|---|---|
| Slack | HMAC-SHA256 of request body with signing secret |
| Discord | Ed25519 signature verification |
| HMAC-SHA256 with app secret | |
| Telegram | Secret token in URL path |
| Microsoft Teams | JWT token validation |
The adapter's parse_incoming() doesn't verify signatures — that's your web framework's job. Always verify before processing.
Log every turn for debugging:
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 operationsFor production monitoring, track these metrics per channel: