Inspiration
Pharmacy stock is one of the last pieces of everyday information with no public API. Supermarkets will tell you what's on the shelf. Restaurants take bookings online. But whether the chemist two streets over has your medication in stock is only knowable today by asking a human on the telephone.
So people ring around. Four pharmacies, four calls, the drug name and the dose repeated four times, two of them putting you on hold, and at the end of fifteen minutes you know about four pharmacies, assuming none of them was at lunch. If you're unwell, or working, or caring for someone who is, that fifteen minutes is not cheap.
What struck me about CALL-E is that it doesn't route around this problem; it solves it at the source. The interface really is a phone call. The pharmacist really does have the answer. The only missing piece is someone to make the calls, all at once, and write down what was said.
That's a genuinely useful errand rather than a demo, which is why I wanted it to be the thing I built. It comes with a constraint that most agent projects never have to face: a phone call reaches a real person, so being wrong isn't free, and being wrong twice is worse than being slow.
The problem
A prescription runs out. Four pharmacies are within reach. None of their websites show stock, so you call them one at a time, repeat the drug name and dose, get put on hold, and hang up knowing about one pharmacy.
RxFind makes those calls for you in parallel and hands back a ranked answer: who has it, how much they have, what it costs, and whether they'll hold it under your name.
It is a real errand, and the reason it's still a phone call in 2026 is that pharmacy inventory has no public API. CALL-E is the API.
What it does
You type a medication and a dose. RxFind dials every pharmacy on the list at once, and each call runs the same script: confirm the drug and strength, ask availability, ask quantity, ask price, ask whether they can hold it for pickup. As the calls land, the results table fills in.
PHARMACY STOCK QTY UNIT PRICE HOLD CONFIDENCE Oakhill Pharmacy in stock 40 £8.99 2 hrs verified Brookside Chemist partial 6 £9.40 — verified Lintel Road no stock — — — verified Green Street unknown — — — unverified
Every row shows where it came from. You can open the transcript of any call and read what the pharmacist actually said.
The one decision the whole project rests on
RxFind will not report a stock level from a call that didn't finish.
This sounds obvious. It isn't, because the tempting thing to build is a wrapper that reads whatever the model extracted and prints it. Do that, and your agent will confidently tell a user that a pharmacy has their medication because the call was cut off mid-sentence and the model filled the gap.
So stock fields are gated on the call actually having completed:
reached = completed and status == "COMPLETED"
if not reached:
# no stock, no quantity, no price. "unverified", and that's the answer.
And verification is the primary sort key ahead of stock, ahead of price:
def key(r):
return (0 if r.get("verified") else 1,
_STOCK_ORDER.get(r.get("in_stock"), 3),
r["unit_price"] if r.get("unit_price") is not None else inf,
0 if r.get("can_hold") == "yes" else 1)
A guessed "in stock" must never outrank a verified "we're out". In a medication context, sending someone across town on a hallucination is the worst thing this tool could do, and it's the failure a naive implementation makes by default.
A phone call cannot be un-rung
Most idempotency problems have a compensating transaction. You can refund a charge, delete a row, cancel an order. You cannot un-ring a phone.
That makes run_call a non-idempotent physical action, and it changes what "handle the crash case" means. The retry-on-restart that is correct for an HTTP request is, here, a stranger's phone ringing twice at 9 pm.
RxFind therefore treats at-most-once delivery as a hard requirement, with a crash-safe ledger under flock, atomic os.replace writes, and fsync before the lock drops. Two rules turned out to matter more than the locking:
The terminal result is durable before the entry retires. Write the result first, then clear the claim; never the reverse. Crash in the window, and you lose a fact you could re-read; crash in the other order, and you lose the record that a call already happened, which is how a redial gets placed.
Corruption fails loudly. A ledger that can't be parsed raises. Returning an empty dict on a malformed file is the same as having no ledger at all, and the consequence of "no ledger" is a duplicate call.
And the claim key is deliberately coarse:
@staticmethod
def claim_key(phone: str, goal: str) -> str:
""" Exclusion identity — deliberately free of provider configuration."""
return hashlib.sha256(f"{phone}|{goal}".encode()).hexdigest()[:32]
This was a real bug I introduced and had to back out. I'd namespaced the key by region and account so that resume couldn't read a run belonging to a different credential, correct for reconciliation, and it silently reopened the redial hazard, because a config change now produced a different key for the same human. Exclusion and reconciliation are two different questions at two different granularities, and they need two different keys. Redial protection has to hold across every config change, because the recipient's phone doesn't care which region you dialled from.
What I learned about CALL-E
next_step.action is the best thing in the API and the docs undersell it. Rather than inferring state from a status enum, the driver is a loop that does what the last response told it to do. It makes an async, multi-minute, multi-state physical process feel like a state machine you didn't have to write. I'd put it on the first page.
I also learned not to trust the shape of result. result.extracted carries the request echo, not the extracted data; the conversational fields are in result.summary. And summary itself is not stable: one call returned key=value pairs, the next returned narrative prose, and my parser rendered a perfectly good call as a blank row. The fix is a prose fallback that is deliberately conservative:
def _from_prose(summary: str) -> dict[str, Any]:
"""Recover what can be read from a narrative summary. Deliberately
conservative — inventing a stock level from prose is exactly the
failure the completion gate exists to prevent."""
It reads "they don't have it" and "about 40 capsules". Where it isn't sure, it returns nothing and the row stays unverified. A parser that guesses is worse than a parser that abstains.
Eight findings like these went into FEEDBACK.md in the repo, each with the observed payload.
Privacy
Phone numbers and transcripts are the sensitive data here, so redaction works by shape, not by field name: any run of eight or more digits in any free text on an output path gets scrubbed, including the pharmacist's own notes. Field-name allowlists fail the moment a number appears somewhere you didn't anticipate, which in a transcript is constant. Prices and quantities are short, so they survive.
Real numbers live in a gitignored local CSV. Every fixture committed to the repo uses the reserved fictional +1 555 01xx range.
How I built it
Python throughout. fastmcp over CALL-E's streamable HTTP MCP endpoint, FastAPI for the backend, one static page for the UI, no frontend framework.
The driver (calle_driver.py) is domain-agnostic: E.164 validation, the budget ceiling, the ledger, the poll loop. The pharmacy logic lives in rxfind.py. That split is why the skill contribution below was extractable at all.
Development ran against saved fixtures with RXFIND_REPLAY=1, so the whole interface and every parser path could be built and tested without dialling anyone. Total spend: 10 calls of my 20-call allocation, for the entire project. A hard RXFIND_MAX_CALLS ceiling backstops it under a lock; four processes racing a ceiling of three get exactly three.
Every regression test was verified by reverting the fix and confirming the test fails. The one I'm most attached to:
AssertionError: 'run_call' unexpectedly found in ['run_call', 'get_call_run' : REDIAL: an in-flight recipient was called a second time
Contributing back
I extracted the generic half into a pharmacy-stock-check skill and opened it against CALLE-AI/awesome-phone-call-agents. It went through seven rounds of review with the maintainer and is now merged as PR #49.
The review was the most useful part of the hackathon. It caught that my dry run wasn't actually offline, that duplicate rows in a CSV would dial concurrently within a single process, that run dumps were written with default permissions, and the one that stung: that my own fix to a credential- binding issue had regressed resume entirely, because update() wrote nested attempt fields that the caller still read from the top level. Ledger code is exactly where a silent regression is most expensive, and I'd shipped one.
The skill includes the MCP integration notes, the safety boundaries, 15 worked examples, and 24 tests.
Challenges
Non-idempotency with no compensating action. Covered above; it's the spine of the project.
A parser against an unstable contract. Two summary formats, two separators, and extracted data in a field that doesn't hold it. The answer wasn't a cleverer regex; it was making "I don't know" a first-class result.
Deciding what the agent must refuse to do. RxFind asks about stock and price. It does not give dosage advice, suggest substitutions, or discuss a condition, and it identifies itself as an automated call on behalf of a patient at the top of every conversation. Those are in the skill's safety reference, because the boundary belongs with the reusable artifact, not just in my app.
A platform outage on demo day. The backend returned 429 insufficient_quota mid-recording. Nothing charged, reported in Discord, and it cost me a day which is the most realistic thing that happened all week.
Accomplishments that i'm proud of
A skill merged into the organisers' own repository. The generic half of RxFind became a pharmacy-stock-check skill, submitted to CALLE-AI/awesome-phone-call-agents and merged as PR #49 after seven rounds of maintainer review. Seven rounds is not a number I'm embarrassed by every round found something real, including one round that found a bug had introduced while fixing the previous round's. Surviving that review, and fixing rather than arguing, is the thing I'd point at first.
An agent that refuses to guess. The completion gate, and verification as the primary sort key, mean RxFind will report "I don't know" about a pharmacy rather than report a plausible answer it didn't hear. That is a harder thing to build than it sounds, because the shape of the API quietly invites the opposite, and it's the difference between a tool a person can act on and a tool that eventually sends someone across town for nothing.
At-most-once delivery that I can demonstrate, not just claim. Every safety property has a test, and every test was verified by reverting the fix and watching it fail:
AssertionError: 'run_call' unexpectedly found in
['run_call', 'get_call_run'] : REDIAL: an in-flight recipient was
called a second time
Four processes racing a ceiling of three calls get exactly three. A crash mid-call resumes without redialling. A corrupt ledger stops the program instead of quietly pretending no call has ever been placed.
The whole thing cost ten phone calls. Fixture-and-replay development meant the entire interface, every parser branch, and all the failure handling were built and tested offline. I finished the project having spent 10 of my 20-call allocation on a platform where the scarce resource is calls, and where every wasted call is a real phone ringing for no reason.
Eight documented API findings, written for the next person. FEEDBACK.md records what actually came back on the wire that result.extracted holds the request echo, that summary has two incompatible formats, that the token cache records an endpoint but no account each with the payload that showed it. Whoever integrates CALL-E next shouldn't have to lose the afternoon I lost.
Redaction that works by shape. Phone numbers are scrubbed from any free text on any output path, by digit-run length rather than by field name, because in a transcript the number turns up wherever the pharmacist happened to say it.
What's next for RxFind
Business-hours guarding, a --language flag now that CALL-E has multi-region support, and a callback rather than a poll when the API grows one. The completion gate stays exactly as it is.
Log in or sign up for Devpost to join the conversation.