> ## 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.

# Recipe: Retry with handoff

> Deterministic retry counter that escalates to a live agent after a fixed number of failures.

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>;
};

<LessonMeta level={2} difficulty="Intermediate" time="10 min" />

When collection fails repeatedly — wrong format, no match in the database, ambiguous input — you need a reliable escalation path. This recipe uses a counter in `conv.state` to enforce a hard retry limit and hand off after N failures.

## When to use this

Use this pattern when:

* A client SLA specifies maximum retry attempts (e.g., "hand off after 3 failures")
* The flow collects high-stakes data where LLM-led retries are not predictable enough
* Compliance requires that callers always reach a human if automation fails

## The complete pattern

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
MAX_ATTEMPTS = 3

def collect_account_number(conv, flow, account_number: str) -> dict:
    attempts = conv.state.get("account_attempts", 0) + 1
    conv.state["account_attempts"] = attempts

    # Validate format: 8 digits
    if not account_number.isdigit() or len(account_number) != 8:
        if attempts >= MAX_ATTEMPTS:
            return {
                "utterance": "I'm sorry, I'm having trouble with that account number. Let me connect you with someone who can help.",
                "handoff": True,
            }
        # Return content so the LLM re-prompts naturally
        return {
            "content": f"Account number invalid (attempt {attempts} of {MAX_ATTEMPTS}). Ask the user to repeat their 8-digit account number."
        }

    # Validation passed
    conv.state["account_number"] = account_number
    conv.state["account_attempts"] = 0  # Reset for next use
    flow.goto_step("verify_account")
    return {"content": f"Account number {account_number} collected."}
```

## How the counter works

```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}}
flowchart TD
    A[Caller provides input] --> B[Increment attempt counter]
    B --> C{Format valid?}
    C -->|Yes| D[Store value, reset counter, next step]
    C -->|No| E{attempts >= MAX?}
    E -->|Yes| F["Return {utterance, handoff: True}"]
    E -->|No| G[Return content – LLM re-prompts]
    G --> A
```

<Warning>
  The counter is incremented **before** validation, not after. This ensures that even if something unexpected happens, the counter still advances and the caller is never trapped in an infinite loop.
</Warning>

## Resetting the counter

Reset `conv.state["account_attempts"]` to `0` after a successful collection. This matters if the same flow is used for multiple collection steps — you don't want attempt count from Step 1 bleeding into Step 2.

## Separating the utterance from the function

For the handoff message, consider passing the utterance as a **parameter** so the LLM can generate a contextually appropriate goodbye, while the handoff itself is deterministic:

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
def escalate(conv, flow, utterance: str) -> dict:
    """
    Call this when retries are exhausted or the user requests a human.
    The LLM generates the utterance; the handoff is guaranteed.
    """
    return {
        "utterance": utterance,
        "handoff": True,
    }
```

**Step prompt:** "If the user requests a live agent or retries are exhausted, call `escalate` with an appropriate transition message."

## Key decisions

<AccordionGroup>
  <Accordion title="Why use content instead of utterance for re-prompts?" icon="comment">
    Returning `content` lets the LLM re-phrase the request naturally each time. Returning a hard-coded `utterance` would make every re-prompt sound identical, which feels robotic after the first failure.
  </Accordion>

  <Accordion title="Why MAX_ATTEMPTS = 3?" icon="list-ol">
    Three attempts is a common SLA default. Adjust to match your client's requirements. Store it as a constant at the top of the file so it's easy to find and change.
  </Accordion>

  <Accordion title="Why reset the counter on success?" icon="arrow-rotate-left">
    If the same validation function is reused in multiple parts of the flow (or if the flow is called again in the same session), you don't want previous failures counting against new attempts.
  </Accordion>
</AccordionGroup>

## Check your understanding

<Quiz
  questions={[
{
q: "The counter is incremented before validation, not after. Why?",
options: [
  "It makes the math simpler",
  "It ensures the counter always advances, preventing infinite loops even if the validation logic has a bug",
  "It allows the counter to be reset on the next successful attempt",
  "The LLM requires the counter to be set before it can generate a response",
],
correct: 1,
explanation: "Incrementing before validation is a safety measure. If validation throws an unexpected error or the function returns early, the counter still advances. This guarantees the caller can never be trapped in an infinite loop due to a code path the developer didn't anticipate.",
}
]}
/>

***

<CardGroup cols={2}>
  <Card title="← SMS confirmation" icon="arrow-left" href="/learn/recipes/sms-confirmation">
    Previous recipe
  </Card>

  <Card title="Caller ID validation →" icon="arrow-right" href="/learn/recipes/caller-id-validation">
    Next recipe
  </Card>
</CardGroup>
