Skip to content
Learn Agentic AI13 min read0 views

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

Share this article
C

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.