Pharmacy stock check
Finding a medication that is actually in stock means calling pharmacies one at a time and asking the same four questions. This skill does the calling and returns a ranked answer.
It is a good fit for CALL-E's design: low-frequency, personal, high-stakes phone work that was never worth automating with a traditional call platform.
Before you start
This skill places real phone calls to real businesses. Confirm with the user:
- the medication, dosage and quantity
- which pharmacies to call, as E.164 numbers
- that they want calls placed now
Never infer a phone number. Never call a number the user did not supply or approve.
Safety boundaries
This skill gathers availability and pricing information only.
- It does not give medical advice, recommend a medication, or suggest a substitute. If a pharmacist offers an alternative, report it verbatim as something the pharmacist said — never as a recommendation.
- It does not place an order, reserve stock, or commit to a purchase on the user's behalf.
- It is not for emergencies. If someone needs medication urgently, they should contact emergency services or an urgent care provider, not wait on an agent.
- Prescription requirements are reported, never worked around.
- Report the pharmacist's answer and the confidence score. Do not present a low-confidence result as fact — the user may travel somewhere while unwell on the strength of it.
Workflow
1. Collect the request
Required: medication name, dosage, quantity, recipient region, and the list of pharmacy phone numbers in E.164 format.
Validate every number before anything else happens. E.164 is a plus, a
non-zero country code digit, then 7–14 more digits — ^\+[1-9]\d{7,14}$.
Reject anything else and ask the user; never guess a country code. A local
number silently reaching the planner is how the wrong person gets called.
2. Build the goal
CALL-E's MCP surface has no result_schema parameter, so the fields you want
must be named in the goal text. CALL-E honours them reliably and emits them in
result.summary.
You are calling a pharmacy on behalf of a patient looking for a medication.
Identify yourself as an automated assistant immediately, and keep the call
brief and polite.
Find out whether they currently have {drug} {dosage} in stock. The patient
needs {quantity}.
Capture these fields in the structured result, using exactly these key names:
- in_stock: yes, no, partial, or unknown. Use partial if they have some but
fewer than the patient needs.
- form_available: brand, generic, both, or unknown
- quantity_available: integer units they have
- unit_price: number, price per unit
- currency: three-letter currency code
- requires_prescription: yes, no, or unknown
- can_hold: yes, no, or unknown
- hold_duration_hours: number of hours they will hold it
- alternative_suggested: any alternative or other branch they mention
- pharmacist_notes: anything else useful, one short sentence
If they do not have it, still ask about alternatives or another branch. Do not
place an order or commit to anything on the patient's behalf. Thank them and
end the call. If nobody answers, report that the pharmacy could not be reached;
do not treat that as a successful stock check.
Identifying as an automated assistant in the first line is not optional. It is the difference between a pharmacist answering and a pharmacist hanging up.
3. One run per pharmacy
to_phones accepts an array, but batching returns an aggregated summary for
the whole batch. This workflow needs per-pharmacy attribution — which pharmacy
has 10 units at £5 — so place one run per pharmacy and run them concurrently.
The call cost is identical.
4. Execute
plan_call → ready_to_run + confirm_token (free, places no call)
run_call → run_id (spends len(to_phones))
get_call_run → poll until terminal, following next_step.action
plan_call is free. Iterate on the goal text as much as you like before
spending anything.
5. Read the result — but only if the call actually completed
Check result.outcome.task_completed and the run status first. A call that
failed, went to voicemail or was cut off can still carry a partially filled
summary. Treating that as a stock check is how a patient gets sent somewhere on
the strength of a sentence nobody finished.
If the run did not reach COMPLETED with task_completed: true, report "could
not be reached" and emit no stock fields at all. Do not scrape what's there.
Only when the call completed, parse the extracted fields from result.summary
as key=value pairs — not from result.extracted, see
references/calle-mcp-integration.md. Values contain commas and the separator
is not stable (both , and ; observed), so split on key= boundaries.
result.outcome also gives a confidence score and specific evidence strings.
6. Report
Verification outranks stock status. Rank verified results first — those that completed with confidence at or above 0.6 — then by stock, then price, then hold. A low-confidence "yes" must never outrank a high-confidence "no". Someone may travel while unwell on the strength of this, which makes an unreliable positive worse than a reliable negative.
Mark anything unverified as such, visibly.
Mask phone numbers in summaries — show the pharmacy name and the last four digits.
Masking covers derived text too, not just the number you dialled. The
provider's activity feed quotes the call as it happens, and fields like
pharmacist_notes and alternative_suggested are lifted straight out of the
conversation. A pharmacist reading out a branch number ends up in both. Printing
either verbatim puts a third party's words into terminal scrollback, CI logs and
screen recordings.
So: scrub anything number-shaped from free text before it is logged or printed, and withhold realtime speech events entirely unless the operator explicitly asks for them. Prices, quantities and hold durations must survive — redact runs of eight or more digits, not every number.
The stored record keeps the original. It is the evidence, and it is protected by being 0600 and out of the working directory.
Offer the transcript. Every claim should be checkable against what was said.
Cost and safety controls
Count phones, not calls.
to_phonesis an array, so one plan can spend N calls. Chargelen(to_phones)against any budget before dialling.Reserve atomically, under the same lock as the ledger read. A check-then-write budget is not a ceiling: two processes read the same
spent, both pass the check, and both dial. The up-front whole-batch check is a convenience so a batch fails before the first call rather than halfway; the per-call atomic reserve is what actually enforces the limit.Deduplicate recipients before dispatch. The same number listed twice is a data-entry mistake, not a request to ring someone twice — and rows are dispatched concurrently, so nothing downstream spaces them out. A file lock won't catch this: both rows run in one process and share a pid, so the in-flight set has to be tracked in-process as well.
Store credentials and transcripts owner-only (0600), and ignore them. The ledger holds
confirm_tokens that authorise placing a call; run dumps hold a third party's recorded voice. Default umask leaves both readable by every account on the machine, and file permissions are no protection at all against being committed. Write them to a dedicated directory and add that directory to.gitignore— this skill ships one covering its own runtime state.Bind the auth token to the endpoint you are calling, and refuse if you can't. A bearer token is issued for one origin; sending it elsewhere hands a working credential to a party it was never meant for. That's a disclosure, not a routing mistake.
Ask the CLI which endpoint it authenticated against (
calle auth statusreportsserver_urlandcache_path) rather than inferring it from the cache directory hash. Refuse on mismatch.A single cache with no endpoint recorded is not acceptable either. "Only one exists" is not evidence that it belongs to this provider. If nothing binds the credential, stop and make the operator name it explicitly.
Keep the exclusion claim separate from the reconciliation identity. These are two different questions and they need different granularity.
"Is there an unresolved call to this person about this thing?" must be answered on recipient and purpose alone. Fold endpoint, account or region into that key and changing any of them makes a live call look absent — which permits a second dial to someone already being rung.
"Can I resume this particular plan?" needs the full configuration. So nest it: a coarse claim keyed on
sha256(phone|goal), with the attempt's endpoint, principal, region,plan_idandrun_idrecorded underneath.On a configuration mismatch, do neither. Don't resume — a
plan_idfrom one endpoint or account is meaningless under another. Don't redial — the earlier call may still be live. Surface it and let a human resolve it.Bind unfinished state to a stable principal, or fail closed. Don't namespace on the credential cache directory: CALL-E derives that from the server URL, so re-authenticating as a different account on the same endpoint reuses it — and an account-A run could be resumed with account-B credentials.
Derive the principal from the account itself (an id recorded in the cache, or the
subclaim of a JWT). If it can't be established, new calls may proceed, but an unresolved call can no longer be confirmed as yours — so refuse to reconcile it rather than guessing.CALLE_ACCOUNT_IDoverrides.Version the ledger. When claim keys change, old entries won't match anything you compute, so unfinished ones must be refused rather than read as absent.
Persist
plan_idandrun_idto disk as soon as you receive them.plan_idis CALL-E's idempotency key, but it only protects you if it survives a crash. On restart, resume the stored run instead of re-planning.The ledger is the thing standing between a crash and a second call to a real person, so it has to be written like one:
- Lock it. Read-modify-write under an exclusive file lock. Without it, two processes both see "no entry" and both dial.
- Write atomically. Temp file, fsync, then rename. A partial write must never replace a good ledger.
- Fail loudly on corruption. An unreadable ledger means quarantine it and stop. Substituting an empty one presents every in-flight run as new and redials the lot.
- Record the terminal result before retiring the entry. Clearing first opens a window where a crash loses both the result and the claim.
- Treat an ambiguous create as un-retryable. If
run_callwas sent and the response was lost, whether the phone rang is unknown. Mark that state before the call, and require a human decision to clear it — never retry automatically.
Never re-dial to recover data you already paid for.
No recurring schedules. This is a one-shot workflow. If a user wants repeat checks, create them explicitly and tell the user how to cancel.
Dry run
scripts/pharmacy_search.py is dry-run by default, and the dry run is fully
offline: it reads no credentials, opens no socket, and sends nothing anywhere.
It validates every number and prints the exact payload it would send.
That matters here. A dry run that still transmits the recipient's phone number and the medication being sought — by calling the planning endpoint — is not a dry run. In a medical-adjacent workflow it leaks who is looking for what.
Numbers are masked in the printed payload too. Masking covers anything printed or logged, not just what reaches the user: terminal output ends up in scrollback, CI logs and screen recordings.
python3 scripts/pharmacy_search.py --pharmacies pharmacies.csv # offline
python3 scripts/pharmacy_search.py --pharmacies pharmacies.csv --live # calls
Tests
python3 scripts/test_pharmacy_search.py
Standard library only. No network, no credentials, no calls placed.
The suite exists because the same invariant has broken twice in ways that were
silent — the code planned and submitted a second call to a real person, and
nothing in the output said so. test_resume_does_not_redial asserts on which
MCP tools were invoked rather than on return values, because whether run_call
was called is the only thing that distinguishes "resumed" from "dialled again".
Verifying end to end without a real pharmacy
Use a standards-reserved fictional number to exercise the offline planning path. The example cannot be dialed and must not be used for live verification.
printf 'name,phone,address\nFictional Pharmacy,+14155550132,Reserved offline example\n' \
> pharmacies.hotline.csv
python3 scripts/pharmacy_search.py --pharmacies pharmacies.hotline.csv
This checks planning without network access. Use the offline test suite to verify durable-run and duplicate-call behavior.
Example
Request: amoxicillin 500mg, 21 capsules, three pharmacies in GB.
Result from one pharmacy:
{
"pharmacy": "Test Pharmacy (…0100)",
"in_stock": "partial",
"quantity_available": 10,
"unit_price": 5.0,
"currency": "GBP",
"requires_prescription": "yes",
"can_hold": "yes",
"hold_duration_hours": 48,
"pharmacist_notes": "They have only 10 units available, less than the requested 21 capsules.",
"confidence": 0.82
}
Worth noting what CALL-E got right there: the pharmacist said "10 units" against
a request for 21, and the result is partial rather than yes. It also
converted "just two days" into hold_duration_hours: 48.
Known limitations
Stated rather than discovered, because each one is a boundary on a guarantee this skill otherwise makes.
The exclusion lock is single-host. It uses flock on a sidecar file, which
coordinates processes on one machine. It does not coordinate across machines,
and its behaviour on NFS and some network filesystems is unreliable. Two
operators running this against a shared drive get no mutual exclusion, and the
duplicate-dial protection is only as good as the lock. A multi-host deployment
needs a real lease — a database row, or a lock service.
Principal resolution currently returns nothing on CALL-E, so resume needs
CALLE_ACCOUNT_ID. Identity is looked for in an id recorded alongside the
credential, then in the sub claim of a JWT. As of writing, the CALL-E CLI
cache records server_url, auth_base_url, issued_at, expires_at and
token — no account identifier — and the token is opaque rather than a JWT.
So no principal can be established, and unfinished state fails closed rather than risk resuming one account's run under another's credentials. That is the safe behaviour, but it means cross-process resume does not work out of the box:
export CALLE_ACCOUNT_ID=me@example.com # any stable value you control
Any consistent string works — it only has to change when the authenticated
account changes. Endpoint binding is unaffected: server_url is recorded, so
credentials are still bound to the right origin automatically.
This resolves itself if the provider exposes an account identifier in the credential cache or issues a token with a subject claim.
Test coverage is behavioural, not exhaustive. scripts/test_pharmacy_search.py
covers the invariants that have actually regressed — crash-and-resume,
exclusion across configuration changes, budget ceilings, validation — with no
network, no credentials and no calls placed:
python3 scripts/test_pharmacy_search.py
It does not cover the live provider surface. Reaching the real API still means the hotline path above.
The budget ceiling is local. It counts what this installation has spent. It does not know the provider-side balance, so it protects against a runaway loop, not against a quota consumed elsewhere.
References
Read references/calle-mcp-integration.md for MCP surface behaviour, including
where extraction actually lands.
Read references/safety.md for boundaries on medical-adjacent phone workflows.
See references/examples.md for worked conversations, including when to decline
to call.