Skip to main content

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.

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

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

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.

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

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

Check your understanding


← SMS confirmation

Previous recipe

Caller ID validation →

Next recipe
Last modified on May 7, 2026