---
name: exception-resolution-calls
description: Use when a business workflow is blocked waiting on a person to answer a question by phone — a stale purchase order, an unconfirmed delivery window, a job a technician has not accepted — and the answer must update system state. Turns one CALL-E call task into per-recipient structured evidence, then advances the workflow with deterministic policy instead of letting a model decide.
---

# Resolving blocked workflows by phone

## When to use this

Use it when three things are true at once:

1. a workflow is **stuck on a fact only a person has** ("did you get the order?",
   "will it ship Friday?", "can you take this job?");
2. the answer has to **change system state**, not just get logged;
3. a wrong answer has a **real cost**, so "we think they said yes" is not good
   enough.

Do not use it for open-ended conversations, for anything where the caller must
negotiate or commit the business to something, or where no bounded set of
questions exists. Those are not exception resolution; they are sales, and they
need a human.

## The shape of the problem

The tempting design is: call, ask the model what happened, update the record.
That design fails in production for a reason worth stating plainly.

**Extraction and authority are different jobs.** A model is good at turning a
messy conversation into structured fields. It is not the right thing to decide
whether a purchase order may be closed, because when the evidence is thin it
will still return *something*, and that something becomes a business fact
nobody chose.

So split them:

```
CALL-E                          Your policy code
──────                          ────────────────
holds the conversation          decides what the answer means
extracts strict JSON            re-validates the JSON
reports confidence              can refuse to act on low confidence
never touches state             owns every state transition
```

## Build it in this order

### 1. Model uncertainty before you model success

Write the result schema first, and give every judgment field an `unknown`
value. This is the single highest-leverage decision in the whole design.

```json
{
  "type": "object",
  "additionalProperties": false,
  "required": ["received", "po_status", "needs_human"],
  "properties": {
    "received": {
      "type": "string",
      "enum": ["yes", "no", "unknown"],
      "description": "Use yes ONLY when they clearly confirm receipt. Use unknown when the answer was hedged, second-hand or unclear — for example 'I think someone in logistics has it'."
    },
    "po_status": {
      "type": "string",
      "enum": ["on_time", "delayed", "blocked", "unknown"],
      "description": "Never infer a status from tone or from a polite acknowledgement."
    },
    "needs_human": {
      "type": "string",
      "enum": ["yes", "no", "unknown"],
      "description": "Use yes when they ask for a person, dispute the record, or raise pricing, payment terms or contract changes."
    }
  }
}
```

Three rules that are easy to get wrong:

- **Enums, not booleans**, for anything a call might not settle. A boolean
  forces a guess; an enum lets the call say "I don't know", which is usually
  the true answer.
- **`additionalProperties: false`**, and no `$ref`, `oneOf`, `anyOf`, `allOf`
  or recursion — CALL-E does not support them. A generated schema from a typed
  model will emit `$ref` and silently return `null` results forever.
- **Do not name a recipient field `status`, `summary`, `transcript` or
  `call_id`.** Those are reserved on recipient results. The collision does not
  error; it just returns `null`. Rename yours (`po_status`, `customer_summary`).

### 2. Bound the call before you place it

The task text is a contract with the person who answers. State what may be
disclosed, what may be asked, and what ends the call.

```
YOU MAY DISCLOSE ONLY
- the supplier company name;
- the purchase order number.

TRUTHFULNESS
- Never guess a date, quantity, status or commitment.
- If unclear, ask one clarification question, then report unknown.
- Do not convert a polite acknowledgement into a business commitment.

SCOPE
- Do not negotiate price, discounts, payment terms or contract terms.
- Do not collect passwords, card data or bank credentials.
- If the recipient asks for a human, stop and report needs_human = yes.
```

**Scan the operator-supplied values, not the rendered task.** This bites
everyone once: the task above contains the words "password", "credentials",
"negotiate" and "price", so a scanner pointed at the rendered text flags the
task's own safety rules and blocks every call you try to make. Scan the
untrusted surface — the interpolated names, ids and numbers — and leave the
template alone.

### 3. Batch recipients, keep outcomes separate

`recipients[]` plus `recipient_result_schema` means one call task can cover
many people, each with an independent result. It is cheaper and it demos far
better than a loop of single calls.

```python
call = client.calls.create(
    task=task_text,                                  # says "ask each about THEIR order only"
    recipients=[{"phones": [p], "region": "US"} for p in phones],
    recipient_result_schema=RECIPIENT_SCHEMA,        # per person
    result_schema=ROLLUP_SCHEMA,                     # whole batch
    metadata={"workflow_run_id": run_id},
    idempotency_key=batch_key,                       # header
)
```

Two things to get right:

- The task must **explicitly forbid cross-disclosure** ("never mention another
  supplier's order to anyone"), or a batch call leaks one customer's business
  to another.
- Correlate results back by `recipients[i].id`. If you cannot match a result to
  a workflow, record it as unattributed and decide nothing — guessing which
  record a phone answer belongs to is worse than leaving it.

### 4. Derive the idempotency key from content, never from a clock

A create that times out may already have started a phone call. Retrying with a
fresh key calls a real person twice.

```python
key = "wf:batch:" + sha256("|".join(sorted(f"{run}:{attempt}" for ...))).hexdigest()[:32]
```

Same logical batch → byte-identical key → CALL-E returns the original call.
Persist the key **before** you dispatch, so a crash mid-flight is recoverable.

### 5. Let policy decide, in a pure function

```python
def decide(workflow, call, recipient, settings) -> Decision:
    if workflow.is_terminal:            return NOOP          # late results never overwrite
    if call.status != "completed":     return RECONCILE      # stop ambiguous/in-flight dispatch
    if recipient.status != "completed": return RECONCILE      # never infer no call occurred
    if recipient.structured_result is None:
                                        return RECONCILE      # null is never success
    ev = revalidate(recipient.structured_result)              # check enums again yourself

    if ev.escalation_reason == "asked_not_to_be_called":
                                        suppress(recipient); return HUMAN_REVIEW
    if ev.escalation_reason in ("disputes_po", "commercial_change", "wants_human"):
                                        return HUMAN_REVIEW   # distinct reasons, not one flag
    if ev.needs_human == "yes":         return HUMAN_REVIEW   # outranks any positive answer

    could_close = ev.received == "yes" and ev.po_status in ("on_time", "delayed")
    if could_close and ev.spoke_with not in ("intended_contact", "authorized_representative"):
                                        return HUMAN_REVIEW   # right number, wrong/unknown person
    if could_close and not confident_enough(call, settings):
                                        return HUMAN_REVIEW   # dampener, see below

    if ev.received == "yes" and ev.po_status == "on_time":
                                        return RESOLVE
    return HUMAN_REVIEW                                       # ambiguity is never resolved
```

**Record who you actually reached, and why a human is needed, as fields --
not as one flag and a hope.** Two gaps bite in roughly this order:

- An authorized **destination number** is not the same thing as an
  authorized **person**. A clean "yes, on time" from whoever happens to
  pick up is not evidence, if that person was never the contact. Add a
  `spoke_with` field (self-reported, not authenticated) and gate any
  outcome that would close the workflow on it. See `references/decision-table.md` for the full precedence.
- A single `needs_human` boolean cannot tell an operator whether the
  recipient asked for a person, disputed the record, tried to
  renegotiate, or asked never to be called again. The last one has a
  consequence beyond this call: it must suppress future attempts to this
  contact, not just get logged. Differentiate the reason
  (`escalation_reason`), and act on the ones that need acting on.

**Use confidence as a dampener, in one direction only.** `task_completed` and
`completion_confidence` are reported for the whole call task, not per recipient.
Low confidence may *withhold* an automatic resolution; high confidence must
never *rescue* a recipient whose own result is missing or ambiguous. Getting
this backwards is how a confident-sounding batch closes an order nobody
confirmed.

**Never branch on `failure_code`.** The API documents it as having no published
enum. Store it, show it to a human, and drive retry from `status` instead.

### 6. Say who you are before you ask anything

The recipient did not opt into this call. Before discussing the order:

- state plainly that you are an AI assistant, not a person;
- name the real company you are calling for -- never a placeholder, and
  never the name of your own agent/product;
- confirm you are speaking to the right person *before* disclosing order
  details, not after.

Refuse to dispatch a live call if you cannot fill in a truthful company
name. A placeholder would be spoken aloud to a real person.

### 7. Treat the webhook as a notification, not an answer

```
POST /webhooks/...
  → validate shape
  → dedupe on the event id (a UNIQUE column, not a SELECT)
  → enqueue reconciliation
  → return 200
```

Then fetch the call and decide **there**. Three properties fall out for free:
a duplicate delivery is a no-op; a lost delivery is only a delay, because a
sweep reconciles anything that has been running too long; and you can develop
with no public URL at all.

## Failure modes worth designing for

| Failure | Correct behaviour |
|---|---|
| Create times out | Stop automatic dispatch; reconcile the original call, then ask a human if still unknown. Any explicitly supported manual replay retains the same payload/key |
| Webhook delivered twice | Second is a no-op |
| Webhook never arrives | Reconciliation sweep finishes the call |
| `structured_result: null` | Reconcile, then escalate — never resolve or automatically redial |
| Recipient never answered | No evidence exists, whatever the result object says |
| Right number, wrong person answers | Escalate even on a clean "yes" -- identity was never established |
| Someone resolved it by hand mid-call | Keep the result as history; do not overwrite |
| Attempts exhausted | Escalate to a person, do not keep dialling |
| Caller asks to renegotiate | Escalate; the agent has no authority |
| Caller asks not to be called again | Escalate **and** suppress this workflow record; decide deliberately whether that should also cover other open records for the same number, or it silently won't |

## Cancellation and side effects

A call is a side effect you cannot take back — someone's phone rings. So:

- Nothing dials without passing an eligibility check **and** an attempt budget.
- Gate live calling behind an explicit flag *plus* a key, so no single stray
  environment variable starts calling people.
- Keep an allowlist of numbers during development; a batch containing an
  unlisted number should fall back to a simulator rather than dial.
- Bound attempts (3 is a reasonable default) with a backoff between them, and
  escalate when the budget is gone.
- Distinguish "not due yet" and "quiet hours" from real refusals. Those fix
  themselves, so they must not be escalated — a false alarm on someone's queue
  is how they learn to ignore the queue.

## Where to look next

- Read `references/safety.md` for the boundaries this pattern assumes:
  phone-number handling, consent, credentials, cancellation, and the
  conversations that must escalate rather than continue.
- Read `references/examples.md` for runnable request and response
  shapes, including batch calls and the terminal webhook.
- Consult `references/decision-table.md` for the full branch precedence
  and the retry-versus-escalate rule.
- Use `references/result-schema.json` as a starting point for your own
  strict result schema.
- Run `scripts/idempotency_key.py` to see the content-derived key, and
  to confirm it is stable under reordering.

## Reference implementation

Resolve-E implements all of the above for supplier purchase-order
acknowledgements: one CALL-E call task resolves eight purchase orders
into their distinct outcomes -- including the identity-gate and
escalation-differentiation cases this file just described -- each with
its own policy decision and audit trail, and the failure table above is
a test suite rather than a promise.
