Skip to navigation

Tutorial: Flow design patterns

Level 3 – Lesson 3 of 5 – Learn common flow patterns for collecting, validating, and acting on user input.

Lesson
Level 3 · Advanced · 20 min

Most flows follow a small number of repeatable patterns: collect a value, validate its format, then verify it against known data. For each pattern, decide whether to let the model lead (flexible, natural) or write deterministic logic (predictable, reliable).

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 persistent 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

  • Collection – re-ask for the number
  • Validation – the format check failed
  • Verification – the value is valid but doesn’t match known data
  • None – this is an API error

Answer: Verification – the value is valid but doesn’t match known data

Validation checks format. Verification checks against known data. A correctly formatted number that doesn’t exist in the system is a verification failure, not a validation failure.

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

  • LLM-led – the model can count retries itself
  • Deterministic – use a counter in conv.state to enforce the limit
  • Either approach works equally well
  • Neither – use a managed topic instead

Answer: Deterministic – use a counter in conv.state to enforce the limit

When a client requires a strict retry limit, use deterministic control with a counter. The LLM may not count retries accurately or consistently, and you cannot guarantee it will stop at exactly 3 attempts.

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: utterance – The 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. Designing the success path is straightforward – failure handling is where reliability is built.

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 – Collect

Prompt: “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 – Validate

Prompt: “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 – Lookup

Prompt: “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

  • Return content telling the model to say goodbye, then hope it calls the handoff function
  • Hard-code a generic goodbye utterance with handoff: True
  • Pass the utterance as a function argument so the model generates it, then return it with handoff: True
  • Use two separate functions – one for goodbye, one for handoff

Answer: Pass the utterance as a function argument so the model generates it, then return it with handoff: True

By passing the utterance as a function argument, the model generates contextually appropriate phrasing. The function then returns it as a hard-coded utterance with handoff: True, so the transfer happens deterministically.