DEV Community

Edy Cu
Edy Cu

Posted on

I built a phone agent for government counters. It couldn't press 1.

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.

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'],
  },
  // ...
};
Enter fullscreen mode Exit fullscreen mode

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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`;
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

  1. 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.
  2. 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.
  3. 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.
  4. completion_confidence is 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)