-
-
CounterCall phones the office before you go. Every filled row is something a clerk said out loud — the Fee row stays empty until one does.
-
The one decision: a field nobody answered stays empty, never a typical value. 233 tests · 11,520 contract cases · 0 values invented.
-
Real captured output: `call.mjs --live` with credentials present, refusing an unsourced number. Exit 3. It never infers a number.
-
Real captured output. The benchmark exits 3 rather than render a table from zero calls. It will not manufacture a statistic.
-
Two refusals before the phone ever rings: an unsourced number, and a schema drifted from the pinned one. Same weight as success.
-
The empty Fee row, labelled in-frame as a schema illustration. An unknown fee costs a question at the window; an invented one costs a trip.
-
The 404 refuses to guess which page you wanted — the project's own thesis applied to its website.
CounterCall places one real CALL-E phone call to a government enquiries line and hands back a validated checklist of what to bring — and leaves the row empty when the clerk did not know.
Live demo: https://countercall.edycu.dev — free, no API key, no sign-up, working through 2026-10-13
For judges (30-second path): https://github.com/edycutjong/countercall/blob/main/JUDGE.md
Architecture (interactive): https://countercall.edycu.dev/architecture/
Pitch deck: https://countercall.edycu.dev/pitch/
Repo: https://github.com/edycutjong/countercall
Pull request (Agent Skills): https://github.com/CALLE-AI/awesome-phone-call-agents/pull/329 → github.com/CALLE-AI/awesome-phone-call-agents
Demo video (under 3 min): <VIDEO_URL>
Proof: 274 tests, no credentials required · 11,520 contract cases verified
exhaustively · p50 <P50>s / p95 <P95>s dial → validated checklist over <CALL_COUNT>
real calls · answer rate <ANSWER_RATE>%, reported including the failures
Built with CALL-E — the live path: calls.create carrying the contract as a
request-scoped result_schema · calls.waitForResult · business-stable Idempotency-Key ·
structuredResult: null mapped to unextractable, not empty · every terminal failure code
routed to a distinct honest outcome
Also implemented and tested, currently unreachable: goals.list · goals.get ·
goals.run · goals.waitForResult and the published_run_spec drift guard — the Goals path
ships and is tested, but no Goal can be published (see Challenges). node scripts/verify_live.mjs
reports which of the two is live, against the real service, without placing a call.
Inspiration
Pak Yanto took the morning off work, rode forty minutes to the Samsat counter, and was turned away because his photocopy was folio instead of A4. He lost a day's income. He still did not know what else was missing for tomorrow.
That is not a rare story in Indonesia. Public-service counters — imigrasi, Samsat, Dukcapil, BPJS — publish requirement lists that are incomplete, outdated, or quietly contradicted at the window. The one reliable source is the office's phone line, and that line is busy, IVR-gated, and answered on the fourth try. So people do not call. They travel, they queue, and they get turned away over one document.
The cost is never the phone call. It is the wasted trip: half a day of lost wages plus transport, repeated until you happen to bring the right thing.
We named it bekal — Indonesian for what you bring with you — because that is precisely what it hands back.
One thing has to be said here rather than buried. We built this for Indonesia, and four days before the deadline CALL-E stopped accepting Indonesian numbers altogether. The shipped office list is now Singapore. The problem is the same one everywhere a counter and a queue exist, and nothing about the mechanism changed — but the story above is the reason this exists, and we are not going to quietly restage it in a country we did not build it for. What happened, and what it cost, is in Challenges.
What it does
You name the procedure and the office. CounterCall places one real phone call to that office's published line, carrying a version-pinned result contract with it. It says plainly at the start that it is an automated assistant and why it is calling, asks a closed set of five questions, treats a refusal as an answer, and waits through hold music rather than pressing. Then it returns a checklist you can screenshot and take with you:
required_documents_text— the actual list, in the clerk's own termstotal_fee_sgdandpayment_method—cash/card/both/unknownappointment_required—yes/no/unknownoriginals_or_copies— the question that sent Pak Yanto homeclerk_certainty—confident/unsure/refusedclerk_quote— the verbatim line the clerk actually said, as the evidence for the rest
When the clerk was unsure, CounterCall says unsure. A field nobody answered renders empty and grey — never filled with what is typical, never quietly dropped so the gap disappears. Somebody may cross a city on the strength of this answer. An unanswered call renders no checklist at all, never a partial one.
An unknown fee costs you one question at the window. An invented one costs you the trip.
What it cannot do, stated plainly. It cannot get past a touch-tone menu. CALL-E has no way
to send DTMF digits — we established this the hard way, on the record, and it is finding 7 in
FEEDBACK.md — so a line that opens with "for English, press 1" is unreachable no matter how
patiently the agent holds. That rules out most national switchboards and points the skill at
lines that are answered by a person: direct extensions, neighbourhood offices, small agencies.
We would rather say this on the submission page than have a judge discover it by dialling.
How we built it
CounterCall ships as an Agent Skill for CALLE-AI/awesome-phone-call-agents: a SKILL.md
consent-and-safety contract, scripts/ for preflight and dialling, references/ for the
result contract and safety boundaries, and a curated offices.json where every number carries
the published source URL it was read from.
The whole product is a schema-valid object. Turning a rambling, code-switching, interrupted
counter conversation into seven typed fields — validated against a version-pinned contract
with additionalProperties: false — is the entire job, and CALL-E does it as a first-class
primitive: the schema the agent spoke against is provably the schema the result is validated
by.
Five design decisions carry the build:
One call per office, per procedure, per day. The Idempotency-Key is
countercall:{office}:{procedure}:{yyyy-mm-dd}:v1 — business-stable, not a random UUID — so a
retry after a crash can never double-dial a public service line staffed by a person with a
queue in front of them.
The sent schema and the validating schema cannot diverge. On the live Calls path, the
result_schema that travels with the request is generated from the same CONTRACT object
that validates the reply — one source, so drift is not merely detected, it is unrepresentable.
On the Goals path, where the schema lives server-side and can move underneath you, a drift guard
reads the live published_run_spec via goals.get before every dial and refuses on mismatch.
Both convert the same silent failure — dialling a real person with a stale schema and getting a
result that looks fine and is quietly wrong — into a loud refusal.
Dry-run by default. skills/countercall/scripts/call.mjs prints the exact request it would send and places
no call. Dialling requires an explicit --live. A skill that dials by default is a skill that
dials by accident.
Never infer a phone number. Not from a directory, not from a similar office, not by
guessing a country code. E.164 is validated against ^\+[1-9]\d{7,14}$ before anything else
happens, and a number enters the flow only when a human read it off the office's own published
page and recorded the date. Calling the wrong number means an automated caller reaches a
stranger, which is the worst thing this skill can do.
The contract is verified exhaustively, not by example. validateResult and the card
renderer are the decision function that must never be wrong — everything downstream is a
person deciding whether to travel across a city. So the test suite walks the entire input
space the contract permits: 1,296 valid results validated, 1,296 rendered cards checked for
invented values, 8,208 single-field corruptions rejected, 720 unexpected-key injections
rejected. 11,520 verified cases. The sweep found a real defect while it was being written:
an empty clerk_quote validated clean, which would have rendered a card that looks sourced and
is not.
Why this needs CALL-E specifically. Take it out and you would need seven separate systems: a telephony provider, a speech-to-text engine, a TTS voice, a dialogue policy that holds a real conversation with an interrupting stranger, an extraction layer, a JSON-schema validator with a repair loop, and a schema registry with version pinning — plus the idempotency and terminal-state semantics that make retries safe against real human beings. You would also have no product, because CounterCall has no scraped-website fallback by design: the answers it returns do not exist in any published document. That is the entire reason the call has to happen.
Challenges we ran into
The Goals API was broken when we started. credential_grant_unavailable — a Goal Run
returns HTTP 201, then fails roughly thirty seconds later before any call is placed. It was
acknowledged by CALL-E staff on the forum on 2026-08-07 and closed on GitHub on 2026-08-17
with no changelog entry. We are Goals-native, so this was existential rather than
inconvenient, and it is why the first thing this repo ever did was scripts/verify_calle.mjs
— a live goals.list against the real service, no mocks, with meaningful exit codes so an
empty catalogue reads as an empty catalogue and not as a failure.
A Goal Run result cannot carry an array. Our contract specified
required_documents: string[] — a document checklist is the product, so an array seemed
obvious. It is not expressible. From the CALL-E OpenAPI spec, a Goal Run result is
additionalProperties: $ref GoalScalar, and GoalScalar is string | number | boolean. No
arrays, no nested objects, no nulls. Notably the one-shot Calls API does accept
simple array.items in its request-scoped result_schema — but Goals does not, and Goals is
what provides the published, reusable procedure catalogue we built the contribution around.
So the checklist travels as a newline-separated string decoded client-side, and the fee field
(then total_fee_idr, now total_fee_sgd) became optional rather than nullable: null is not a permitted scalar,
but an absent key costs nothing and means exactly what null meant. The safety rule survives
intact — a missing fee is missing, never 0, never a typical value.
Two integration defects that would have failed on the first live call. Writing the test
suite against the shipped SDK surfaced both. client.goals.run takes a single RunGoalInput
object with a top-level phone; we were calling it positionally with a target wrapper that
CreateGoalRunRequest explicitly rejects. And the TypeScript SDK camelCases its surface, so
goal.published_run_spec is always undefined — our drift guard was reading a key that never
exists, reporting "no spec" against a perfectly healthy Goal, and refusing every dial.
Both were invisible until something actually exercised them.
Goals are owner-scoped and can only be authored in CALL-E Chat. Cross-owner reads return
404, and there is no POST /v1/goals. So a published Goal cannot ship as a runnable community
artifact. We changed the shape of the contribution rather than pretend otherwise: the reusable
unit is the Goal specification — prompt, input_schema, result_schema — that another
developer republishes into their own account in about two minutes, plus the client that runs
it.
Four days before the deadline, the platform stopped calling our country. On 2026-09-10 we
placed the first live call and it was refused before dialling: "recognized as an Indonesia
number with Indonesian language, which is not currently supported for outbound calling." We
assumed a language problem and retried the same number in English. Also refused. Malaysia:
refused. The Philippines: refused. Singapore: ready_to_run: true.
The capability summary we had crawled in July lists ID, MY and PH among the supported regions and annotates Indonesia as "ID / Indonesia (English)". That line is what the entire project was premised on. There is no endpoint that reports supported region/language pairs, so there was no way to learn this except by being refused at dial time, on the day we needed the call. Buying a dedicated number — the documented advice for a degraded shared pool — does not help, because the check is on the recipient's region.
So the map changed and the engine did not: offices.json became two Singapore agencies each
verified against its own published contact page that day, the task speaks English, region and
locale are now sent explicitly rather than inferred, total_fee_idr became total_fee_sgd
across 55 references, and CONTRACT.version went 1 → 2 because this is a real contract change
and the drift guard should see it. 274 tests still pass. Total elapsed: under three hours.
Then the second wall, which is the more interesting one: CALL-E cannot press keypad digits. We dialled ICA Singapore's published main line. It answered. Then:
10s callee For English, press 1.
26s agent Okay.
32s callee We did not receive your entry. For English, press 1.
48s agent No rush.
64s agent No rush, I'll hold.
75s callee You have exceeded the maximum number of tries...
193s callee You have exceeded the maximum number of tries. Thank you for your call. Goodbye.
193 seconds, status: completed, structured_result: null. The agent answered a touch-tone
menu conversationally four times because speech is the only channel it has — dtmf, keypad
and tone appear nowhere in the OpenAPI specification or the documentation. CALL-E's own
summary of the call is accurate and damning: "The assistant did not make the required keypad
selections."
This is the single most consequential thing we learned, and we think it is worth more to CALL-E
than a working demo would have been: a platform that cannot send digits cannot reach any
organisation large enough to have a switchboard, which is most of the institutions its use
case names. It is finding 7 of eight in FEEDBACK.md.
A smaller one fell out of the same call: it returned completion_confidence: {score: 0.9,
label: "high"} having achieved nothing at all, which makes that field unusable for deciding
whether to retry. We gate on structured_result and our own validator instead.
<CHALLENGE_ANSWER_RATE> — the honest answer-rate finding, to be written after the benchmark
runs against human-answered lines. This is the project's largest open risk and the paragraph
must report the real number including failures.
Accomplishments that we're proud of
The refusals. Every terminal outcome — a valid result, no_answer, declined,
result_invalid, timed_out, and the rest of the eight published GoalRunError codes — and
not one of them renders a partial checklist. The drift guard would rather refuse to dial
than dial with a stale contract. clerk_certainty is a first-class field, not a footnote, so
a hedge is shown as a hedge.
That is the difference between this and a call transcript. A half-parsed checklist is worse than no checklist, because the user acts on it.
274 tests, none of which need a credential, plus live integration tests that hit the real CALL-E API and skip loudly rather than passing quietly when no key is present. The two counts are stated separately and never added together: a suite that goes green with the network unplugged proves nothing about the integration it exists to defend.
A permission-boundary suite that drives the shipped entry points as subprocesses and
attempts to make each of them dial something it must refuse — nine shapes of unusable number,
each attempted with credentials present and --live requested. Most projects configure a
safety boundary. This one tests that it holds.
<ACCOMPLISHMENT_MEASURED> — fill from real output: real calls placed, p50/p95, answer rate,
named external users.
What we learned
That the schema is the product. We came in expecting the hard part to be the phone call, and the phone call is the part CALL-E handles. The hard part is the contract around it — pinning it, diffing it before every run, refusing gracefully when it moves, and being disciplined about the gap between what a clerk said and what is true.
That a constraint you discover late is worth more than a design you assumed early. Finding out
that Goal Run results are scalars-only forced a better contract than the one we specified: an
absent key genuinely means "nobody said this", where a null in an array-shaped schema had
been quietly doing two jobs.
That a documented capability is a snapshot, not a contract. The single line in a capability summary that said our country was supported was true when it was written and false when we needed it, and nothing in between told us. The lesson we are taking is not "read the docs more carefully" — it is spike the one capability the whole project rests on, against the live service, on day one, and re-run that probe on a schedule. Our preflight now proves the dial path before it dials; it should have proved the region in July.
And to design for the callee rather than the caller. Every meaningful constraint in this project — one call per day, opening hours only, no callbacks, state you are automated and stop if asked, never infer a number — exists to protect a person who did not ask to be phoned by software.
What's next
- Widen the seeded office set beyond the current two, each with a published-source citation, weighted toward lines that are answered by a person rather than a menu, and measure answer rates per office and time of day rather than in aggregate
- Publish the Goal specification as a template other developers republish for their own jurisdiction — the honest form of community reuse while Goals stay owner-scoped
- Eight findings filed to CALL-E in
FEEDBACK.mdat the repo root, each with a reproduction. The three we would action first: DTMF support, even a single digit-sending action the model can emit mid-call, which would open up every IVR-gated institution at once; aGET /v1/regionsendpoint reporting live supported region/language pairs, so a preflight can fail in July instead of at dial time in September; and an importable Goal manifest (orPOST /v1/goals) soawesome-phone-call-agentscontributions can be genuinely one-click instead of a specification a human retypes into Chat
Honest limitations
It cannot navigate a touch-tone menu, because CALL-E cannot send DTMF digits. Any line that opens with "press 1 for…" is out of reach, which excludes most national hotlines and main switchboards; the skill is aimed at lines answered by a person. This is a platform limitation we documented rather than designed around, and it is the first thing we would fix.
Coverage is limited to offices in a curated seed file — CounterCall never infers a phone number. A clerk's spoken answer is informational, not legally binding, and every rendered card says so; requirements change and individual counters apply discretion. Answer rates on public service lines vary by office and time of day, and a large share of calls will not be answered — that unreliability is the reason this skill exists, and we report it rather than hide it. The result is one clerk's answer on one day. It is evidence, not a guarantee.
Built With
- agent-skills
- call-e
- call-e/calle
- e.164
- eslint
- github
- github-actions
- indonesia
- javascript
- json-schema
- node.js
Log in or sign up for Devpost to join the conversation.