> ## 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: Intent-based routing

> Route calls to different destinations based on what the caller says they need.

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

Instead of playing an IVR menu ("Press 1 for billing, press 2 for support"), this recipe lets the caller state their need in natural language and routes them based on what they said. The LLM extracts intent; the function handles the routing deterministically.

## When to use this

Use this pattern when:

* Replacing a traditional IVR or phone tree
* You have multiple specialist queues and want to route callers correctly first time
* You want to capture the caller's intent in `conv.state` to brief the receiving agent

## The complete pattern

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
# Define valid destinations and their queue identifiers
ROUTES = {
    "billing": "BILLING_QUEUE",
    "technical_support": "TECH_QUEUE",
    "cancellation": "RETENTION_QUEUE",
    "general_enquiry": "GENERAL_QUEUE",
}

def route_call(conv, intent: str, reason: str) -> dict:
    """
    Route the caller based on their stated intent.

    Parameters:
    - intent: One of 'billing', 'technical_support', 'cancellation', 'general_enquiry'
    - reason: A brief summary of what the caller said (used to brief the receiving agent)
    """
    queue = ROUTES.get(intent)

    if not queue:
        # Unknown intent — route to general rather than failing
        queue = ROUTES["general_enquiry"]

    # Store context so the receiving agent's screen-pop has it
    conv.state["routing_intent"] = intent
    conv.state["routing_reason"] = reason

    return {
        "utterance": f"I'll connect you with the right team now. Just one moment.",
        "handoff": queue,
    }
```

**Behavior field reference:**

```text theme={"theme":{"light":"github-light","dark":"github-dark"}}
@route_call

When the caller states their reason for calling, call `route_call` with:
- intent: the most appropriate category ('billing', 'technical_support', 'cancellation', 'general_enquiry')
- reason: a one-sentence summary of what they said

Do not ask clarifying questions unless it is genuinely impossible to determine the intent.
```

## Routing flow

```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}}
flowchart TD
    A[Caller states their reason] --> B[LLM extracts intent + reason]
    B --> C[Call route_call]
    C --> D{Intent in ROUTES?}
    D -->|Yes| E[Handoff to specific queue]
    D -->|No / ambiguous| F[Fallback to general_enquiry queue]
    E --> G[Receiving agent sees intent + reason in screen-pop]
    F --> G
```

## Briefing the receiving agent

The `routing_reason` stored in `conv.state` can be passed to your CTI or CRM via a webhook on call transfer. This means the agent answering the call already knows why the caller is calling — reducing "Can you tell me again what you need?" moments.

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
def get_routing_context(conv) -> str:
    """Utility for the receiving-agent screen-pop webhook."""
    intent = conv.state.get("routing_intent", "unknown")
    reason = conv.state.get("routing_reason", "No reason captured.")
    return f"Routing intent: {intent}. Caller said: {reason}"
```

## Handling caller corrections

Callers sometimes change their mind or correct the LLM's initial interpretation. Make the routing function re-callable by not using `flow.goto_step()` — stay in the current context so the LLM can re-route if the caller says "actually, it's a billing question":

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
def route_call(conv, intent: str, reason: str) -> dict:
    # Same function, no flow step — allows correction before transfer
    queue = ROUTES.get(intent, ROUTES["general_enquiry"])
    return {
        "utterance": f"Connecting you to {intent.replace('_', ' ')} now.",
        "handoff": queue,
    }
```

<Tip>
  Add a brief confirmation before transferring if the caller's intent is ambiguous: "Just to confirm — I'm connecting you with billing. Is that right?" The LLM can handle this naturally if you include it as an instruction in the step prompt or Behavior field.
</Tip>

## Key decisions

<AccordionGroup>
  <Accordion title="Why pass `reason` as a parameter?" icon="comment-dots">
    The LLM captures the caller's exact words (paraphrased) in `reason`. This is more useful than just `intent` because it gives the receiving agent context about *why* the caller chose this queue — not just which queue they were sent to.
  </Accordion>

  <Accordion title="Why fall back to general_enquiry instead of failing?" icon="shield-halved">
    An unknown intent should never cause the call to fail or loop. Routing to general ensures the caller always reaches a human who can handle any edge case.
  </Accordion>

  <Accordion title="Why use utterance + handoff together?" icon="code">
    Using `utterance` gives the caller a natural transition message before the transfer. Without it, the call transfers silently, which feels like a dropped call.
  </Accordion>
</AccordionGroup>

## Check your understanding

<Quiz
  questions={[
{
q: "The Behavior field says 'Do not ask clarifying questions unless it is genuinely impossible to determine the intent.' Why?",
options: [
  "Clarifying questions slow down routing and add latency to the call",
  "Callers have already stated their reason — asking again is redundant and frustrating",
  "The LLM cannot process clarifying question responses",
  "Clarifying questions are not supported in routing functions",
],
correct: 1,
explanation: "Most callers state their reason clearly in their opening message. Asking a clarifying question when the intent is already clear adds friction and makes the agent feel less capable than a human receptionist. Only ask when the intent is genuinely ambiguous.",
}
]}
/>

***

<CardGroup cols={2}>
  <Card title="← Caller ID validation" icon="arrow-left" href="/learn/recipes/caller-id-validation">
    Previous recipe
  </Card>

  <Card title="Back to Recipes" icon="flask-conical" href="/learn/recipes/introduction">
    All recipes
  </Card>
</CardGroup>
