Inspiration

Procurement teams spend hours on the phone asking three vendors the same three questions: unit price, lead time, minimum order quantity. It is textbook agent work.

It is also work where one over-eager autonomous decision is expensive in a way software bugs usually are not — a call is a real phone ringing on a real supplier's desk, billed to a real account, and it cannot be un-rung. Most agent demos handle that by asking the model to check with a human first. We wanted to know what it looks like when the model cannot do otherwise, no matter how it is prompted.

What it does

A dashboard holds quote requests. An agent can plan a call against one, dial it, cancel it mid-ring, and re-plan it after it fails. The call moves through dialing → connected → wrapping up → done, each transition recorded on the task. Results land on the task as a structured outcome, beside a quote table sorted by price.

The agent cannot approve a call. Between "planned" and "dialing" sits a button only a person can press. If the agent tries to dial anyway, it is refused and told exactly what to do instead. If it tries to write the approval through the generic update path, it is refused. If it calls the approval function directly, bypassing the tool registry altogether, it is refused.

And a single approval is not a standing licence: re-planning a finished call returns the task to "planned", so calling that supplier a second time needs a second human press.

How we built it

Node 20+, Express, React, Jest, ESLint. About 1,250 lines under src/, and 950 lines of tests.

The design rule is that the agent gets no private code path. Every mutation — the dashboard's button clicks and the agent's tool calls alike — goes through one function, invoke(tool, args, actor), over one endpoint, POST /api/invoke. Each registered tool's execute() body is a single delegating line, so a tool physically cannot reach the data store without passing the same guards a button passes. Every invocation, refusals included, is appended to an activity log with the actor's name, which is what makes the run auditable after the fact.

The approval gate is three mechanisms rather than one:

  1. approve_task and reject_task are never added to the tool registry, so tool discovery cannot surface them;
  2. both refuse any actor but the owner even when called directly on invoke();
  3. the generic update_task path is blocked from setting those statuses at all.

Calls go through a small CallProvider adapter. The default FakeCallProvider is deterministic — no network, no real timers, canned outcomes from a JSON file — which is why the whole demo and the whole test suite run with no credentials and place no calls. CallEProvider is the real integration; it takes its fetch by injection, so its request shape and response parsing are unit-tested against fixtures without ever opening a socket.

Challenges we ran into

Cancelling a call that is genuinely stuck. An abort check between steps only cancels a call that was going to progress anyway; a call left ringing forever sails straight past it. The fake provider ended up racing its own pacing delay against the abort signal, so a wait that never resolves on its own is still interrupted — and the test stands in a promise that never settles, so it fails if that race is ever removed.

The subtler one was realising that the approval gate had a back door. update_task took a free-form updates object, so an agent could simply write {status: "approved"} and walk around the whole design. Closing it meant accepting that the generic write path needed a specific exception — and testing for that exception by name.

The most instructive one came last. Our "real" CALL-E integration had passed its unit tests against fixtures for days, and was completely non-functional: it POSTed an invented request body to CALL-E's MCP host, which rejects a static API key because it wants OAuth. Fixtures had only ever confirmed that our code agreed with itself. Rewriting it against the documented Calls API — POST /v1/calls, then polling until terminal — and then actually dialing turned up two more things no fixture could have: a real call takes minutes, not milliseconds, so the poll loop is load-bearing; and cancel_call cannot hang up a real call at all, because the Calls API exposes no client cancellation. The README says so now rather than implying a Cancel button does more than it does.

Accomplishments that we're proud of

The refusals are the demo. Watching an agent get told no three different ways, then watching a person click one button and the same agent immediately succeed, communicates the architecture better than any diagram.

Also: tests/approval-gate.test.js asserts against the actual exported registry rather than reading the source, and checks that no tool matches approve/reject under any spelling — so the guarantee survives someone adding a tool later without reading this paragraph.

We also placed a real, billed outbound call through the live CALL-E API — not just against fixtures — and it returned a schema-valid quote (unit price, lead time, minimum order quantity) end to end.

What we learned

"The model is instructed not to" and "the model cannot" are different products. The second one is barely more work than the first if you decide on it early, and it is the difference between a demo and something a procurement team could actually be handed.

What's next

Persistence (state is in memory today), a real session-backed identity instead of an actor string in the request body, and an approval queue for teams where the approver is not the person who set up the request.

Built With

Share this project:

Updates

Submission history