Chat Agent Conversation Flows: Designing Guided vs Open-Ended Interactions
Master the design patterns for chat agent conversation flows including state machines for guided flows, free-form handling for open-ended conversations, and hybrid approaches that combine structure with flexibility.
Two Paradigms of Chat
Chat agents operate on a spectrum between fully guided and fully open-ended. A guided flow walks the user through a predefined sequence — like booking an appointment or completing an intake form. An open-ended flow lets the user ask anything in any order — like a knowledge base assistant. Most production agents need both: guided flows for transactional tasks and open-ended handling for informational queries, with smooth transitions between the two.
Understanding when to use each paradigm and how to combine them is fundamental to building agents that feel both helpful and natural.
Guided Flows with State Machines
A state machine is the cleanest way to implement guided flows. Each state represents a step in the process, and transitions define how the conversation moves forward:
from enum import Enum, auto
from dataclasses import dataclass, field
from typing import Callable, Awaitable
class BookingState(Enum):
GREETING = auto()
COLLECT_SERVICE = auto()
COLLECT_DATE = auto()
COLLECT_TIME = auto()
CONFIRM = auto()
COMPLETE = auto()
CANCELLED = auto()
@dataclass
class Transition:
from_state: BookingState
to_state: BookingState
condition: str # Describes when this transition fires
action: Callable | None = None
@dataclass
class FlowContext:
state: BookingState = BookingState.GREETING
data: dict = field(default_factory=dict)
history: list[str] = field(default_factory=list)
class StateMachineFlow:
def __init__(self):
self.handlers: dict[BookingState, Callable] = {
BookingState.GREETING: self.handle_greeting,
BookingState.COLLECT_SERVICE: self.handle_service,
BookingState.COLLECT_DATE: self.handle_date,
BookingState.COLLECT_TIME: self.handle_time,
BookingState.CONFIRM: self.handle_confirm,
}
async def process(self, ctx: FlowContext, user_input: str) -> str:
# Check for cancellation at any point
if any(word in user_input.lower() for word in ("cancel", "nevermind", "stop")):
ctx.state = BookingState.CANCELLED
return "No problem, I've cancelled the booking. Is there anything else I can help with?"
handler = self.handlers.get(ctx.state)
if handler:
return await handler(ctx, user_input)
return "Something went wrong. Let me start over."
async def handle_greeting(self, ctx: FlowContext, user_input: str) -> str:
ctx.state = BookingState.COLLECT_SERVICE
return "I'd be happy to help you book an appointment. What service are you looking for?"
async def handle_service(self, ctx: FlowContext, user_input: str) -> str:
service = await extract_service(user_input)
if service:
ctx.data["service"] = service
ctx.state = BookingState.COLLECT_DATE
return f"Great, {service}. What date works best for you?"
return "I didn't catch the service. We offer: Consultation, Check-up, or Follow-up. Which would you prefer?"
async def handle_date(self, ctx: FlowContext, user_input: str) -> str:
date = await extract_date(user_input)
if date:
available = await check_availability(ctx.data["service"], date)
if available:
ctx.data["date"] = date
ctx.state = BookingState.COLLECT_TIME
slots = await get_time_slots(ctx.data["service"], date)
slot_text = ", ".join(slots[:5])
return f"Available times on {date}: {slot_text}. Which works for you?"
return f"Sorry, no availability on {date}. Would another date work?"
return "I didn't understand the date. Could you give me a specific date like 'March 25' or 'next Tuesday'?"
async def handle_time(self, ctx: FlowContext, user_input: str) -> str:
time = await extract_time(user_input)
if time:
ctx.data["time"] = time
ctx.state = BookingState.CONFIRM
s, d, t = ctx.data["service"], ctx.data["date"], ctx.data["time"]
return f"Let me confirm: {s} on {d} at {t}. Shall I book this?"
return "What time would you prefer? Please specify like '2:00 PM' or 'afternoon'."
async def handle_confirm(self, ctx: FlowContext, user_input: str) -> str:
if any(word in user_input.lower() for word in ("yes", "confirm", "book", "correct")):
booking = await create_booking(ctx.data)
ctx.state = BookingState.COMPLETE
return f"Booked! Your confirmation number is {booking['id']}. You'll receive an email confirmation shortly."
elif any(word in user_input.lower() for word in ("no", "change", "wrong")):
ctx.state = BookingState.COLLECT_SERVICE
ctx.data.clear()
return "No problem. Let's start over. What service would you like?"
return "Just to confirm — would you like me to book this appointment? Yes or no?"
Open-Ended Flows
For informational queries, use the LLM's natural ability to handle any question combined with a retrieval system:
from agents import Agent, function_tool
@function_tool
async def search_knowledge_base(query: str) -> str:
"""Search the knowledge base for relevant information."""
results = await vector_search(query, top_k=3)
return "\n\n".join([r["content"] for r in results])
@function_tool
async def get_product_details(product_name: str) -> str:
"""Look up details for a specific product."""
product = await db.fetch_product(product_name)
if product:
return f"Name: {product['name']}\nPrice: ${product['price']}\nDescription: {product['description']}"
return f"No product found matching '{product_name}'"
open_agent = Agent(
name="Knowledge Assistant",
instructions="""You are a helpful product knowledge assistant.
Answer questions using the available tools. Be concise and accurate.
If you don't know something, say so — don't guess.
If the user wants to take an action (book, buy, subscribe),
tell them you'll transfer them to the appropriate flow.""",
tools=[search_knowledge_base, get_product_details],
)
The Hybrid Approach
The most effective production agents combine both paradigms. Use a router that detects intent and switches between guided flows and open-ended conversation:
See AI Voice Agents Handle Real Calls
Book a free demo or calculate how much you can save with AI voice automation.
class HybridAgent:
def __init__(self):
self.flows: dict[str, StateMachineFlow] = {
"booking": BookingStateMachine(),
"returns": ReturnsFlow(),
"subscription": SubscriptionFlow(),
}
self.active_flow: StateMachineFlow | None = None
self.flow_context: FlowContext | None = None
self.open_agent = open_agent # The RAG-based agent
async def process(self, message: str, session_id: str) -> str:
# If a flow is active, route to it
if self.active_flow:
response = await self.active_flow.process(self.flow_context, message)
# Check if the flow completed or was cancelled
if self.flow_context.state in (BookingState.COMPLETE, BookingState.CANCELLED):
self.active_flow = None
self.flow_context = None
return response
# No active flow — detect if user wants to start one
intent = await detect_intent(message)
if intent in self.flows:
self.active_flow = self.flows[intent]
self.flow_context = FlowContext()
return await self.active_flow.process(self.flow_context, message)
# Fall back to open-ended agent
result = await Runner.run(self.open_agent, message)
return result.final_output
Flow Visualization in TypeScript
For frontend developers building flow editors, a TypeScript representation makes flows debuggable:
interface FlowNode {
id: string;
type: "message" | "question" | "condition" | "action" | "end";
content: string;
next: FlowEdge[];
}
interface FlowEdge {
condition?: string;
targetNodeId: string;
}
const bookingFlow: FlowNode[] = [
{
id: "start",
type: "message",
content: "I'd be happy to help you book an appointment.",
next: [{ targetNodeId: "ask_service" }],
},
{
id: "ask_service",
type: "question",
content: "What service are you looking for?",
next: [
{ condition: "service_recognized", targetNodeId: "ask_date" },
{ condition: "not_recognized", targetNodeId: "clarify_service" },
],
},
{
id: "ask_date",
type: "question",
content: "What date works best?",
next: [
{ condition: "date_available", targetNodeId: "ask_time" },
{ condition: "date_unavailable", targetNodeId: "suggest_dates" },
],
},
];
function getNextNode(flow: FlowNode[], currentId: string, condition: string): FlowNode | null {
const current = flow.find((n) => n.id === currentId);
if (!current) return null;
const edge = current.next.find((e) => !e.condition || e.condition === condition);
if (!edge) return null;
return flow.find((n) => n.id === edge.targetNodeId) || null;
}
FAQ
When should I use a guided flow versus letting the LLM handle it freely?
Use guided flows when the outcome requires specific data in a specific format — bookings, form submissions, payments, account changes. The state machine guarantees every required field is collected and validated. Use open-ended flows for informational queries where the user's question is unpredictable. If you are unsure, start with a guided flow for critical paths and open-ended for everything else.
How do I handle users who go off-script during a guided flow?
At each step of the guided flow, first check if the user's input is relevant to the current question. If it is not, use the LLM to classify whether the user is asking an unrelated question ("What are your hours?") or trying to skip ahead ("Just book me at 3 PM tomorrow"). For tangential questions, answer them briefly and re-ask the current flow question. For skip-ahead attempts, extract whatever data you can and advance the flow accordingly.
How do I test conversation flows before deploying them?
Build a conversation simulator that plays through every path in your flow with synthetic inputs. For each state, test the happy path, the error path, and the edge cases (empty input, irrelevant input, cancellation). Track coverage — how many states and transitions were exercised. Aim for 100% transition coverage before deploying any flow to production.
#ConversationDesign #StateMachine #UXDesign #FlowDesign #ChatAgent #AgenticAI #LearnAI #AIEngineering
CallSphere Team
Expert insights on AI voice agents and customer communication automation.
Try CallSphere AI Voice Agents
See how AI voice agents work for your industry. Live demo available -- no signup required.