The problem
Operational software knows what was supposed to happen. When the plan breaks, it raises an exception and stops — because the information needed to recover is not in any connected system.
A carrier cancels a pickup. The TMS can tell you the shipment is at risk. It cannot tell you which of four other carriers can actually collect it before the warehouse closes at 17:30. That answer exists only in the heads of dispatchers, and the only way to get it is to call them.
So a human starts dialling. Meanwhile the clock runs.
What PHONEOPS does
PHONEOPS turns an exception into a recovery mission with a measurable objective and hard constraints, works out what it does not know, and uses CALL-E to find out.
The loop that matters:
EXCEPTION → OBJECTIVE → MISSING INFORMATION → CALL-E → EVIDENCE
→ CONSTRAINT CHECK → PLAN INVALIDATED → REPLAN → CALL-E AGAIN → RECOVERED
The flagship scenario, end to end:
- Carrier A cancels. Shipment #4821 must leave tonight; the warehouse cutoff is 17:30.
- PHONEOPS plans to use Carrier B, and records three things it cannot know without asking: availability, earliest pickup time, capacity.
- CALL-E calls Carrier B. The answer comes back as structured evidence:
pickup_time = 18:00, confirmed, sourced to call#001. - A deterministic constraint check fails:
18:00 ≤ 17:30is false. The recovery strategy is invalidated — not retried, not escalated. - PHONEOPS replans because of that evidence, and calls Carrier C.
- Carrier C confirms 16:45. Constraints satisfied. Departure protected, with 45 minutes to spare.
The call does not complete a workflow step. The call changes the plan.
Why this is a CALL-E project and not a phone bolt-on
Remove CALL-E and the engine has nothing to reason about. It is not a voice layer on top of a working system — it is the evidence acquisition layer for a system that cannot function without external facts.
PHONEOPS asks CALL-E for exactly the facts the mission is missing, through a
strict result_schema with a *_confirmed flag per fact. That flag matters: a
hedged answer — "we should probably be there around 5:30" — comes back
unconfirmed, and unconfirmed evidence can never satisfy a hard constraint. It
triggers a clarification call instead of a decision.
Three interchangeable modes behind one adapter: the Developer API
(POST /v1/calls, GET /v1/calls/{id}, HMAC webhook), the MCP/CLI surface
(plan_call, run_call, get_call_run), and a deterministic mock for CI and
offline testing. Every call record stores which mode produced it, so a simulated
run can never be mistaken for a real one — in the database, in the API, and on
screen.
How we proved it is not scripted
The obvious objection to any demo like this is that the second call is hard-coded. So there is a script that runs the same mission twice, changing only what the carrier says on the phone:
| Carrier B answers | Strategy 01 | Replans | Second call |
|---|---|---|---|
16:00 |
successful | 0 | never created |
18:00 |
invalidated | 1 | Carrier C |
Both runs recover the shipment. Only one ever creates a second phone operation,
and that operation stores the evidence id, the call id and the violated
constraint that caused it. make counterfactual — it needs no CALL-E key.
The boundary that makes it trustworthy
| An LLM may propose | The system decides |
|---|---|
| interpret the exception | deadline and constraint comparison |
| generate a recovery hypothesis | strategy invalidation |
| extract semantic evidence from a call | state transitions |
| propose an alternative strategy | policy, retry and replan limits |
LLM_ENABLED=false is the default and the entire loop still runs. Models add
semantics, never authority. A model cannot declare a hard constraint satisfied.
Other boundaries the product holds, each with a test:
- A no-answer is not evidence. An unanswered call is retried, then changes the strategy. It is never recorded as "the carrier is unavailable" — and the UI says "could not be reached", never "missed the cutoff".
- Silence is not an answer. A fact the carrier never mentioned produces no evidence record at all.
- A deadline has two ends. A pickup time already in the past cannot satisfy "before 17:30", however true the comparison looks.
- Autonomy is not authority. Gathering information and replanning run autonomously; a cost increase above the threshold stops at a human.
- Escalation is a real outcome. When no candidate satisfies the mandatory constraints, PHONEOPS escalates. It does not invent Carrier E to finish the demo.
How the demo carriers answer
Carrier B and Carrier C are controlled PSTN test endpoints — two Telnyx numbers that answer with a fixed line, so the scenario repeats identically instead of depending on two people by a phone.
The answer still travels the whole way. CALL-E places a real outbound call over the network, hears the spoken reply, transcribes it and extracts the structured result. PHONEOPS receives evidence, not a payload:
| Simulated | Real |
|---|---|
| the carriers' personas | the CALL-E call, and its call id |
| the shipment and its constraints | the telephone network |
| the answers they give | transcription and evidence extraction |
| constraint evaluation and the replan |
The endpoint that plays them is mounted only behind a flag, holds no reference to missions or evidence, and cannot write to the database. A shortcut from there into the mission would be indistinguishable from hard-coding the demo.
How we built it
A modular monolith: FastAPI and async SQLAlchemy over PostgreSQL (17 tables of mission state), a Next.js control room fed by server-sent events, Alembic migrations, Docker Compose, CI.
Roughly 7,900 lines of Python and 187 tests, plus two gates that run before any recording: the counterfactual proof above, and a five-run reliability gate that checks the full causal chain each time.
Challenges
The interesting failures were not in the engine.
Next silently gzipped the event stream. The mission loop worked perfectly
under curl and delivered nothing to a browser — because browsers advertise
gzip and the encoder buffers. Diagnosed by testing the same endpoint with and
without the header: 31 events versus 0.
Events were published before they were committed. Subscribers were told "something changed" about state no other transaction could read, so the UI spent its only signal, re-read the old state and froze there. Now each event commits before it is published — which also means a mission that crashes mid-run keeps the record of the calls it really placed.
The interface said something false about a carrier. On an unanswered call, the strategy diff read "Carrier B — misses the pickup cutoff" for a carrier that never picked up. Exactly the claim the product promises not to make. Four failure paths had been covered by tests but never actually looked at; rendering them found five defects, of which that was the worst.
What we learned
Tests are not enough, and neither is a passing demo. Every defect above passed a green suite. Two habits found them: driving the real interface in a real browser rather than trusting the API, and asking whether the numbers on screen are plausible rather than merely consistent — "time to recovery: 4 s" for a loop that visibly takes seconds is a metric measuring the wrong thing.
What's next
The recovery templates are already generic — the same engine runs supplier
capacity loss and field-service dispatch, with only the constraints changing. The
community contribution, mission-replan-loop, packages the primitive on its own,
with zero dependency on this codebase, so any agent can let a phone answer
invalidate a plan.
The honest gap: PHONEOPS decides who to call from a ranked candidate list. Real deployments would want that ranking informed by history — which carrier actually answers, which one's quoted times hold. That is the next thing worth building.
Built With
- alembic
- call-e
- docker
- fastapi
- next.js
- postgresql
- python
- react
- redis
- server-sent-events
- sqlalchemy
- tailwindcss
- typescript
Log in or sign up for Devpost to join the conversation.