Finding out what to bring to a government counter usually means going there. The web page is stale, the answer that matters — do they take card, do I need the original, do I need an appointment first — lives in a clerk's head, and you learn it after the queue.
So I built a phone agent that calls the office for you and returns a validated checklist. Then I pointed it at a real government line, and it spent 193 seconds saying "okay" to a touch-tone menu before the menu hung up on it.
This post is about that call, why it failed, and what a "no result" should look like when your product is an autonomous phone call.
- Live demo (no sign-up, no key): https://countercall.edycu.dev
- Code: https://github.com/edycutjong/countercall
- The two real calls, unedited: https://github.com/edycutjong/countercall/blob/main/DEMO.md
What it was supposed to do
CounterCall is an Agent Skill for CALL-E, a phone-call API where you hand a task and a result schema to a voice model and it dials. The skill dials an office's published enquiries number, announces itself as an automated assistant, asks five closed factual questions, and returns a card:
export const CONTRACT = {
version: 2,
required: [
'required_documents_text',
'payment_method',
'appointment_required',
'originals_or_copies',
'clerk_certainty',
'clerk_quote',
],
optional: ['total_fee_sgd'],
enums: {
payment_method: ['cash', 'card', 'both', 'unknown'],
appointment_required: ['yes', 'no', 'unknown'],
originals_or_copies: ['originals', 'copies', 'both', 'unknown'],
clerk_certainty: ['confident', 'unsure', 'refused'],
},
// ...
};
Two design choices here cost more thought than the rest of the code combined.
clerk_quote is required, and it may not be empty. It's one verbatim sentence from the clerk — the evidence for every other field on the card. While writing a property-based sweep over the validator I found that an empty string validated clean, which would have rendered a card that looks sourced and isn't. That is now a rejected case:
if ('clerk_quote' in result) {
if (typeof result.clerk_quote !== 'string') {
problems.push('clerk_quote is not a string');
} else if (result.clerk_quote.trim().length === 0) {
// The quote is span grounding — it is the evidence for every other field. An empty
// one renders a card that looks sourced and is not.
problems.push('clerk_quote is empty');
}
}
The fee is optional and absent, never null or 0. If the clerk didn't say a number, the row is empty. No "typical fee", no guess. The whole product is a promise that every value on the card was said out loud by a human.
The same schema object is serialised into the request (resultSchema) and used to validate the reply, so what we asked for and what we accept cannot drift. And every dial carries an idempotency key scoped to office + procedure + day, so a retry never double-dials a public line:
export function idempotencyKey(officeId, procedure, date) {
const slug = procedure.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
return `countercall:${officeId}:${slug}:${date}:v1`;
}
The call
2026-09-10, ICA Singapore's published general-enquiries line, procedure "passport renewal". Here is the transcript_turns array from GET /v1/calls/call_h9t6ZZJ_2kG_fxTJXlOQgw, abridged only by cutting the repeated menu loops:
0s agent I'm an automated assistant calling on behalf of a member of the public
about what to bring for an in-person passport renewal visit.
6s ICA Good afternoon. Thank you for calling Immigration and Checkpoints Authority.
10s ICA For English, press 1.
26s agent Okay.
32s ICA We did not receive your entry. For English, press 1.
48s agent No rush.
64s agent No rush, I'll hold.
75s ICA You have exceeded the maximum number of tries. Please hold while we
connect you to our next available officer.
124s ICA For services for Singapore citizens, press 1. Permanent residents,
press 2. Visit us, press 3. Other ICA services, press 4.
131s agent Okay.
193s ICA Sorry. You have exceeded the maximum number of tries. Goodbye.
Nine turns across two menus, every "press 1" answered in words. The API's own post-call summary was accurate: "The assistant did not make the required keypad selections, so the call ended before reaching an officer."
The cause is simple and I should have checked it on day one: the platform cannot send DTMF tones. dtmf, keypad and tone appear nowhere in the OpenAPI spec or the docs. Speech is the only channel the model has, so it does the only thing it can and talks to the menu.
The wrong mental model, named: I had been treating "can the agent hold a conversation with a clerk" as the hard part, and "can it get to the clerk" as plumbing. For a business's main line it is the other way round. Any organisation big enough to have a switchboard is behind a menu, and an agent that can't press a key cannot reach the one person whose answer matters. It can reach a person who picks up directly, and no one else.
The second call that day, to the Ministry of Manpower, failed at dial with SIP 486 Busy Here. Two real calls, zero checklists.
What "no result" has to look like
CALL-E signals the ICA outcome as status: "completed" with structuredResult: null — the call connected, the evidence didn't satisfy the schema. The tempting thing is to pass that null upward and let the renderer decide. The renderer would then have to decide what a missing checklist means, and the answer is always "render nothing", so that decision lives in one place:
export function normaliseCall(call, created = call) {
const result = call.structuredResult ?? call.structured_result ?? null;
const status = call.status ?? null;
// ...
if (status && status !== 'completed') {
return { result: null, error: { code: failureCode(call, status) }, ...meta };
}
if (result === null) {
return { result: null, error: { code: 'result_unextractable' }, ...meta };
}
return { result, error: null, ...meta };
}
And what the user sees for that call:
$ node skills/countercall/scripts/call.mjs --office ica-sg --procedure "passport renewal" --live
Dialling +6563916100 via the calls transport ...
NO CHECKLIST — result_unextractable
The call connected, but nothing said on it answered the
questions. Nothing is shown.
No partial checklist is ever rendered.
CALL-E run call_h9t6ZZJ_2kG_fxTJXlOQgw
Failed after 310.8s.
$ echo $?
5
No partial card. Not "fee: unknown" on a card that otherwise looks filled. Nothing, plus the call id so anyone can go and check.
The benchmark follows the same rule. npm run bench -- --report recomputes from recorded real calls and refuses to print a table from zero records — there is no seeded mode. Right now it prints:
| Metric | Value |
|---|---|
| Real calls placed | 2 |
| Line answered | 1 (50%) |
| Usable validated checklist | 0 (0%) |
| p50 / p95 dial → checklist | — / — |
The p50 and p95 rows are blank rather than estimated. That table is on the README, on the Devpost page, and in the demo video. It was tempting to leave it off.
What I'd say to anyone building on a voice-agent API
- Place one real call to your actual target class before writing the product. Not a friend's phone — the kind of number you'll ship against. I placed my first real call to a government line on day 22 of a 25-day build. That is the mistake this post exists to save you.
- Read the OpenAPI spec for the thing you assume is there. DTMF is table stakes for telephony; I never checked because it didn't occur to me it could be missing.
- Decide what a failure renders before you decide what a success renders. With a phone agent most outcomes will be failures for a long time — no answer, busy, menu, refusal. If your failure state is "the success card with blanks", every failure looks like a partial success.
-
completion_confidenceis not a success signal. That 193-second call came back with{ score: 0.9, label: "high" }. I now ignore it entirely and gate on schema validation.
Limitations, honestly
- It has never returned a checklist from a real government line. The target has to be a number a person answers, or the platform has to grow a way to send digits. I filed the DTMF finding, with the transcript, as one of eight items in the repo's FEEDBACK.md.
- Coverage is bounded by a seed file where every number was read off the office's own published page by a human, with a date. It will not infer a phone number. That's deliberate, and it's slow.
- A clerk's spoken answer is not binding, and every card says so.
- The tests — 274 of them, 11,520 generated contract cases, no credentials needed — prove the contract and the renderer, not the answer rate. Only calls prove the answer rate, and the answer rate is currently zero.
The repo is at https://github.com/edycutjong/countercall and the live page at https://countercall.edycu.dev. If you know a government or institutional line in your city that a human actually picks up, a one-line addition to the seed file is the whole contribution.
Top comments (0)