> ## Documentation Index
> Fetch the complete documentation index at: https://docs.poly.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Tutorial: Flow design patterns

> PolyAcademy Level 3 – Master collection, validation, verification patterns and LLM-led vs deterministic control.

export const LessonMeta = ({level, difficulty, time}) => {
  const levelConfig = {
    1: {
      badge: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
      label: 'Level 1'
    },
    2: {
      badge: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
      label: 'Level 2'
    },
    3: {
      badge: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
      label: 'Level 3'
    }
  };
  const difficultyConfig = {
    Beginner: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
    Intermediate: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
    Advanced: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
  };
  const lvl = levelConfig[level] || levelConfig[1];
  const diffColor = difficultyConfig[difficulty] || difficultyConfig['Beginner'];
  return <div className="flex flex-wrap items-center gap-2 my-4 not-prose">
      <span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${lvl.badge}`}>
        {lvl.label}
      </span>
      <span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${diffColor}`}>
        {difficulty}
      </span>
      {time && <span className="inline-flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
          <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
            <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
          </svg>
          {time}
        </span>}
    </div>;
};

export const Quiz = ({questions = []}) => {
  const [selected, setSelected] = useState({});
  const [resetCount, setResetCount] = useState(0);
  const letters = ['A', 'B', 'C', 'D'];
  const handleSelect = (qIdx, optIdx) => {
    if (selected[qIdx] !== undefined) return;
    setSelected(prev => ({
      ...prev,
      [qIdx]: optIdx
    }));
  };
  const handleReset = () => {
    setSelected({});
    setResetCount(c => c + 1);
  };
  if (!questions?.length) return null;
  const getOptionClasses = ({hasAnswered, isThisCorrect, isThisSelected}) => {
    if (!hasAnswered) {
      return {
        btn: 'flex w-full items-center gap-3 py-2.5 px-4 rounded-xl text-sm leading-normal transition-all duration-150 text-left border cursor-pointer border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:bg-gray-50 hover:shadow-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:border-gray-500 dark:hover:bg-gray-700',
        badge: 'w-6 h-6 rounded-full text-xs font-bold flex items-center justify-center shrink-0 leading-none transition-all duration-150 bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-300',
        icon: null
      };
    }
    if (isThisCorrect) {
      return {
        btn: 'flex w-full items-center gap-3 py-2.5 px-4 rounded-xl text-sm leading-normal transition-all duration-150 text-left border cursor-default border-green-400 bg-green-50 text-green-900 font-medium dark:border-green-500 dark:bg-green-950 dark:text-green-100',
        badge: 'w-6 h-6 rounded-full text-xs font-bold flex items-center justify-center shrink-0 leading-none transition-all duration-150 bg-green-500 text-white dark:bg-green-500',
        icon: <svg className="shrink-0 w-4 h-4 text-green-500 dark:text-green-400 ml-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
            <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
          </svg>
      };
    }
    if (isThisSelected) {
      return {
        btn: 'flex w-full items-center gap-3 py-2.5 px-4 rounded-xl text-sm leading-normal transition-all duration-150 text-left border cursor-default border-red-400 bg-red-50 text-red-900 dark:border-red-500 dark:bg-red-950 dark:text-red-100',
        badge: 'w-6 h-6 rounded-full text-xs font-bold flex items-center justify-center shrink-0 leading-none transition-all duration-150 bg-red-500 text-white dark:bg-red-500',
        icon: <svg className="shrink-0 w-4 h-4 text-red-400 dark:text-red-400 ml-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
            <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
          </svg>
      };
    }
    return {
      btn: 'flex w-full items-center gap-3 py-2.5 px-4 rounded-xl text-sm leading-normal transition-all duration-150 text-left border cursor-default border-gray-100 bg-white text-gray-400 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-500',
      badge: 'w-6 h-6 rounded-full text-xs font-bold flex items-center justify-center shrink-0 leading-none transition-all duration-150 bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-500',
      icon: null
    };
  };
  return <div key={resetCount} className="my-6">
      {questions.map((q, qIdx) => {
    const answer = selected[qIdx];
    const hasAnswered = answer !== undefined;
    const isCorrect = answer === q.correct;
    return <div key={String(qIdx)} className="mb-8">
            <p className="flex items-start gap-2.5 font-semibold text-sm mb-3 mt-0 leading-relaxed text-gray-900 dark:text-gray-100">
              <span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-gray-800 dark:bg-gray-200 text-white dark:text-gray-900 text-xs font-bold shrink-0 mt-px leading-none">
                {qIdx + 1}
              </span>
              {q.q}
            </p>

            <div className="flex flex-col gap-2">
              {q.options.map((opt, i) => {
      const isThisCorrect = i === q.correct;
      const isThisSelected = i === answer;
      const {btn, badge, icon} = getOptionClasses({
        hasAnswered,
        isThisCorrect,
        isThisSelected
      });
      return <button key={String(i)} type="button" onClick={() => handleSelect(qIdx, i)} className={btn}>
                    <span className={badge}>{letters[i]}</span>
                    <span className="flex-1">{opt}</span>
                    {icon}
                  </button>;
    })}
            </div>

            {hasAnswered ? <div className={`mt-3 py-3 pl-4 pr-3.5 rounded-r-xl text-sm leading-relaxed border-l-4 ${isCorrect ? 'border-green-500 bg-green-50 dark:bg-green-950 dark:border-green-500' : 'border-red-500 bg-red-50 dark:bg-red-950 dark:border-red-500'}`}>
                <span className={`font-semibold ${isCorrect ? '!text-green-800 dark:!text-green-200' : '!text-red-800 dark:!text-red-200'}`}>
                  {isCorrect ? 'Correct.' : 'Not quite.'}
                </span>{' '}
                <span className="!text-gray-700 dark:!text-gray-300">{q.explanation}</span>
              </div> : null}
          </div>;
  })}

      <button type="button" onClick={handleReset} className="mt-1 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 underline underline-offset-2 cursor-pointer transition-colors duration-150">
        Reset quiz
      </button>
    </div>;
};

export const ProgressTracker = ({lessonNum, totalLessons, level}) => {
  const [checked, setChecked] = useState(false);
  return <div onClick={() => setChecked(prev => !prev)} className={checked ? 'flex items-center gap-3 p-4 rounded-lg border-2 border-green-600 bg-green-50 dark:bg-green-950 cursor-pointer select-none transition-all' : 'flex items-center gap-3 p-4 rounded-lg border-2 border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 cursor-pointer select-none transition-all'}>
      <div className={checked ? 'w-5 h-5 rounded border-2 border-green-600 bg-green-600 flex items-center justify-center shrink-0 transition-all' : 'w-5 h-5 rounded border-2 border-gray-400 dark:border-gray-500 bg-white dark:bg-gray-800 flex items-center justify-center shrink-0 transition-all'}>
        {checked ? <svg width="10" height="8" viewBox="0 0 10 8" fill="none">
            <path d="M1 4L3.5 6.5L9 1" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
          </svg> : null}
      </div>
      <div>
        <div className={checked ? 'font-semibold text-sm text-green-700 dark:text-green-300' : 'font-semibold text-sm text-gray-700 dark:text-gray-200'}>
          {checked ? 'Lesson complete' : 'Mark lesson complete'}
        </div>
        {lessonNum && totalLessons ? <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
            {level ? level + ' - ' : ''}Lesson {lessonNum} of {totalLessons}
          </div> : null}
      </div>
    </div>;
};

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

<LessonMeta level={3} difficulty="Advanced" time="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:

<CardGroup cols={3}>
  <Card title="Collection" icon="inbox">
    Getting a value you don't already have. The user provides a phone number, name, or tracking number.
  </Card>

  <Card title="Validation" icon="check-double">
    Checking the format. Is this a valid tracking number? Does it match the expected pattern (e.g., 3 letters followed by 5 digits)?
  </Card>

  <Card title="Verification" icon="shield-check">
    Checking against known data. Does this tracking number exist in the system? Does this phone number match the account on file?
  </Card>
</CardGroup>

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.

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
# 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.

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
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.

<Warning>
  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?"
</Warning>

## Check your understanding

<Quiz
  questions={[
{
q: "A user gives a tracking number that's correctly formatted but doesn't exist in the system. Which pattern handles this?",
options: [
  "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",
],
correct: 2,
explanation: "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.

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
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:

<Tabs>
  <Tab title="LLM-led">
    Let the model decide how to handle retries and rephrasing.

    ```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
    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.
  </Tab>

  <Tab title="Deterministic">
    Use counters in state to enforce strict retry limits.

    ```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
    def validate_tracking(conv, flow, tracking_number: str) -> dict:
        attempts = conv.state.get("tracking_attempts", 0) + 1
        conv.state["tracking_attempts"] = attempts

        if is_valid(tracking_number):
            flow.goto_step("lookup_order")
            return {"content": "Tracking number valid."}

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

        return {"content": "Ask the user to repeat the tracking number."}
    ```

    **Pros:** Predictable, enforces limits, guarantees escalation after N failures.

    **Cons:** More code, you must handle every scenario yourself.
  </Tab>
</Tabs>

### When to use each approach

| Scenario                                         | Approach      |
| ------------------------------------------------ | ------------- |
| Low-stakes collection (name, preference)         | LLM-led       |
| High-stakes collection (account number, payment) | Deterministic |
| Retry limits required by the client              | Deterministic |
| Natural rephrasing matters                       | LLM-led       |
| Compliance or audit requirements                 | Deterministic |

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

<Quiz
  questions={[
{
q: "You need to collect a date of birth and the client requires exactly 3 attempts before handoff. Which approach should you use?",
options: [
  "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",
],
correct: 1,
explanation: "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.

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
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.

<Warning>
  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.
</Warning>

## Design principles

<CardGroup cols={2}>
  <Card title="Guide explicitly" icon="signs-post">
    Always define what happens after each step. Never assume the model will figure out the next action.
  </Card>

  <Card title="Design for failure" icon="triangle-exclamation">
    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.
  </Card>

  <Card title="Keep steps focused" icon="crosshairs">
    Each step should do one thing: collect one value, confirm one detail, or present one choice.
  </Card>

  <Card title="Balance flexibility and control" icon="scale-balanced">
    Use the model for language tasks. Use code for logic tasks. Combine them for the best results.
  </Card>
</CardGroup>

## Try it yourself

<Steps>
  <Step title="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

    <Accordion title="Hint">
      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.
    </Accordion>

    <Accordion title="Example solution">
      **Step 1 – Collect**

      Prompt: "Ask the user for their tracking number. Once they provide it, call `save_tracking`."

      ```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
      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."

      ```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
      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."

      ```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
      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."
      ```
    </Accordion>
  </Step>
</Steps>

## Check your understanding

<Quiz
  questions={[
{
q: "You want the model to generate a natural goodbye message before handing off, but you also need the handoff to happen reliably. What pattern should you use?",
options: [
  "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",
],
correct: 2,
explanation: "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.",
}
]}
/>

<CardGroup cols={2}>
  <Card title="← Previous: Flow fundamentals" icon="arrow-left" href="/learn/guides/expert/flow-fundamentals">
    Lesson 2 of 5
  </Card>

  <Card title="Next: Writing agent speech →" icon="arrow-right" href="/learn/guides/expert/utterance-design">
    Lesson 4 of 5
  </Card>
</CardGroup>

<ProgressTracker lessonKey="l3-3-flow-patterns" lessonNum={3} totalLessons={5} level="Level 3" />
