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/329github.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 terms
  • total_fee_sgd and payment_methodcash / card / both / unknown
  • appointment_requiredyes / no / unknown
  • originals_or_copies — the question that sent Pak Yanto home
  • clerk_certaintyconfident / unsure / refused
  • clerk_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.md at 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; a GET /v1/regions endpoint 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 (or POST /v1/goals) so awesome-phone-call-agents contributions 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

Share this project:

Updates

Submission history