exception-resolution-calls · git:20260914.443c116 · 2026-09-14 · sha256 0fa53f2499bcec69
exception-resolution-calls git:20260914.443c116A
Immutable. This exact content is served forever at /api/v1/blob/0fa53f2499bcec69.
---
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.