By Sagar Shankaran, Founder of CallSphere
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.
Key takeaways
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.
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:
Hear it before you finish reading
Talk to a live CallSphere AI voice agent in your browser — 60 seconds, no signup.
flowchart TD
Q{"What matters most<br/>for your team?"}
DIM1["Time to first<br/>production deploy"]
DIM2["Total cost of<br/>ownership at scale"]
DIM3["Debuggability and<br/>observability"]
DIM4["Ecosystem and<br/>community support"]
PICK{Score the<br/>four axes}
A(["Pick<br/>Chat Agent<br/>Conversation Flows:…"])
B(["Pick<br/>Open-Ended<br/>Interactions"])
Q --> DIM1 --> PICK
Q --> DIM2 --> PICK
Q --> DIM3 --> PICK
Q --> DIM4 --> PICK
PICK -->|Speed and ecosystem| A
PICK -->|Control and TCO| B
style Q fill:#4f46e5,stroke:#4338ca,color:#fff
style PICK fill:#f59e0b,stroke:#d97706,color:#1f2937
style A fill:#0ea5e9,stroke:#0369a1,color:#fff
style B fill:#059669,stroke:#047857,color:#fff
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?"
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 most effective production agents combine both paradigms. Use a router that detects intent and switches between guided flows and open-ended conversation:
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
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;
}
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.
Still reading? Stop comparing — try CallSphere live.
CallSphere ships complete AI voice agents per industry — 14 tools for healthcare, 10 agents for real estate, 4 specialists for salons. See how it actually handles a call before you book a demo.
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.
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

Written by
Sagar Shankaran· Founder, CallSphere
LinkedInSagar Shankaran is the founder of CallSphere, where he builds production AI voice and chat agents deployed across healthcare, hospitality, real estate, and home services. He writes about agentic AI, LLM engineering, and shipping voice agents that handle real calls in production.
See how AI voice agents work for your industry. Live demo available -- no signup required.
A data-led look at how small businesses from Lusaka to Kitwe are losing revenue to missed calls, and how CallSphere AI voice and chat agents answer every enquiry 24/7 in English, Bemba, and Nyanja.
96% of well-designed agents close calls politely; the rest leave callers with the robotic-hangup feeling that undermines the whole flow. We map endCallPhrase tuning, silence-timeout policies, and CallSphere's vertical farewell library.
Burr from DAGWorks treats agents as explicit state machines. The trade-offs against LangGraph and where Burr fits a regulated workload that needs strict control flow.
LangChain v1 + LangGraph v1 in JS, paired with Ollama, gives you a fully local chat agent with tools, memory, and structured output. No OpenAI key required.
Complete LangGraph tutorial covering state machines for agents, conditional edges, human-in-the-loop patterns, checkpointing, and parallel execution with full code examples.
Build robust fallback systems for chat agents that detect out-of-scope questions, provide helpful redirects, escalate to humans intelligently, and learn from failures to continuously improve coverage.
© 2026 CallSphere Inc. All rights reserved.
Made within San Francisco