Skip to main content
Level 3 — Lesson 3 of 5 — Learn common flow patterns for collecting, validating, and acting on user input.
Most flows follow a small number of repeatable patterns. This lesson covers the patterns you will use most often: value collection, validation, verification, and the trade-offs between letting the model lead and writing deterministic logic.

The three patterns

When a flow collects information from a user, there are three distinct operations:

Collection

Getting a value you don’t already have. The user provides a phone number, name, or tracking number.

Validation

Checking the format. Is this a valid tracking number? Does it match the expected pattern (e.g., 3 letters followed by 5 digits)?

Verification

Checking against known data. Does this tracking number exist in the system? Does this phone number match the account on file?
These are separate concerns. A value can be collected but invalid. A value can be valid but not match any record. Handling them separately makes your flows more predictable.

Collection pattern

The simplest flow pattern: ask for a value, save it, move on.
# Step prompt: "Ask the user for their tracking number.
# Once they provide it, call save_tracking_number."

def save_tracking_number(conv, flow, tracking_number: str) -> str:
    conv.state["tracking_number"] = tracking_number
    flow.goto_step("validate_tracking")
    return "Tracking number received."

Validation pattern

After collecting a value, check whether it matches the expected format before using it.
import re

def validate_tracking(conv, flow, tracking_number: str) -> str:
    pattern = r"^[A-Z]{3}\d{5}$"
    if re.match(pattern, tracking_number):
        flow.goto_step("lookup_order")
        return f"Tracking number {tracking_number} is valid."
    else:
        return "That doesn't look like a valid tracking number. Ask the user to try again."
When validation fails, the step prompt is still active — the model will ask the user again. This is the sticky prompt at work.
In voice, validation failures are often caused by transcription errors, not user mistakes. Avoid phrasing that blames the user.Instead of: “That’s not a valid tracking number.”Prefer: “Sorry, I didn’t quite catch that — could you repeat your tracking number?”

Check your understanding

Verification pattern

After validation passes, check the value against your backend.
def lookup_order(conv, flow) -> str:
    tracking_number = conv.state.get("tracking_number")
    result = call_tracking_api(tracking_number)

    if result.get("found"):
        conv.state["order_status"] = result["status"]
        flow.goto_step("report_status")
        return f"Order found. Status: {result['status']}."
    else:
        return "I couldn't find an order with that tracking number. Ask the user to double-check and try again."

LLM-led vs deterministic control

When something goes wrong in a flow (validation fails, API returns no match), you have two approaches:
Let the model decide how to handle retries and rephrasing.
def validate_tracking(conv, flow, tracking_number: str) -> str:
    if not is_valid(tracking_number):
        return "That doesn't seem right. Ask them to repeat it."
    # ... continue
Pros: Flexible, handles edge cases naturally, less code to write.Cons: Less predictable. The model might retry indefinitely or phrase things inconsistently.

When to use each approach

ScenarioApproach
Low-stakes collection (name, preference)LLM-led
High-stakes collection (account number, payment)Deterministic
Retry limits required by the clientDeterministic
Natural rephrasing mattersLLM-led
Compliance or audit requirementsDeterministic
A good rule: let the model do what it is good at (natural language, extraction, phrasing) and use code where precision matters (validation, retry limits, escalation).

Check your understanding

Handoff from a flow

When the user fails repeatedly or the flow cannot continue, hand off to a live agent.
def escalate(conv, flow, utterance: str) -> dict:
    return {
        "utterance": utterance,
        "handoff": True
    }
Parameter: utteranceThe message to say to the user before transferring By passing the utterance as a function argument, the model generates a contextually appropriate goodbye message, but the handoff is handled deterministically.
If you return a hard-coded utterance with handoff, no second LLM request occurs. The utterance is played and the call is transferred immediately. This is efficient but means the model does not get a chance to add anything — make sure your utterance provides proper closure.

Design principles

Guide explicitly

Always define what happens after each step. Never assume the model will figure out the next action.

Design for failure

Plan what happens when validation fails, APIs are down, or the user gives unexpected input. The happy path is the easy part.

Keep steps focused

Each step should do one thing: collect one value, confirm one detail, or present one choice.

Balance flexibility and control

Use the model for language tasks. Use code for logic tasks. Combine them for the best results.

Try it yourself

1

Challenge: Design a tracking number lookup flow

Design a 3-step flow that:
  1. Collects a tracking number
  2. Validates the format (3 uppercase letters + 5 digits)
  3. Looks up the order and reports the status
Include:
  • What happens if validation fails
  • What happens if the order is not found
  • A retry limit of 3 attempts before handoff
Use conv.state to store the tracking number and an attempt counter. Each step should have a clear prompt and one flow function. The validation step should check the counter before re-prompting.
Step 1 — CollectPrompt: “Ask the user for their tracking number. Once they provide it, call save_tracking.”
def save_tracking(conv, flow, tracking_number: str) -> str:
    conv.state["tracking_number"] = tracking_number
    conv.state["tracking_attempts"] = conv.state.get("tracking_attempts", 0) + 1
    flow.goto_step("validate")
    return "Tracking number received."
Step 2 — ValidatePrompt: “The tracking number has been collected. Call check_format to validate it.”
import re

def check_format(conv, flow) -> str:
    number = conv.state["tracking_number"]
    attempts = conv.state["tracking_attempts"]

    if re.match(r"^[A-Z]{3}\d{5}$", number):
        flow.goto_step("lookup")
        return "Format valid."

    if attempts >= 3:
        return {
            "utterance": "I'm having trouble with that number. Let me transfer you to someone who can help.",
            "handoff": True
        }

    flow.goto_step("collect")
    return "The format doesn't look right. Ask the user to repeat it."
Step 3 — LookupPrompt: “The tracking number is validated. Call lookup_order to check the status.”
def lookup_order(conv, flow) -> str:
    number = conv.state["tracking_number"]
    result = call_tracking_api(number)

    if result.get("found"):
        conv.exit_flow()
        return f"The order status is: {result['status']}."

    flow.goto_step("collect")
    return "No order found with that number. Ask the user to try again."

Check your understanding

Last modified on March 26, 2026