← All projects
LIVE DEMO

After-hours voice agent

A phone agent that answers what the office cannot, books the routine job and pages the owner on the emergency

STACKVapi / Twilio / n8n 2.31.6 / PostgreSQL 16.14 / Node.js / Docker
CONTEXTSpec project (Upwork brief)
DATE2026-07
Open recruiter demo after-hours-voice-agent-demo.aaron.cv
EVIDENCE, NOT CLAIMS

Ten calls you can play yourself

The demo runs the shipped build: the same rendered system prompt, sha256 abd2007c1814, which is the hash the deployed assistant reports for its own; the same tool schemas, the same four n8n workflows and the same PostgreSQL constraints. Only the phone leg stands in, because there is no telephony account on the demo host. Every card states what has to hold before you run it, then shows the transcript, every tool call and answer, the guardrail verdicts and the database rows behind them. Policy checks are decided by the workflow and the database. Behaviour checks are the model's and are labelled as such, including the phrasing slips the source suite counts rather than fails on.

2.38 s p50 turn latency on real phone calls 49 agent turns in 7 calls, 2026-07-26. p90 4.70 s, fastest 0.48 s, slowest 5.80 s. Two thirds of it is the endpointing wait, which is a setting, not lost time.
300 / 500 ms time to go quiet when the caller cuts in Read off the stereo recordings, on the two moments in the first five calls where it happened.
30 ms median backend handling per tool call Append-only audit log: 159 availability lookups and 122 booking writes. 112 to 199 ms including the public HTTPS hop.
32 of 32 conversations clean on every must-hold guardrail Run 2026-07-28T03-48-21-427Z, all 12 scenarios: no price, no arrival time, no invented address, no unbacked paging claim, no unrequested transfer, and every line a tool handed over said out loud. 22 of 22 non-paging conversations paged nobody.
676 of 683 checks passed in that run 12 of 12 scenarios and 32 of 32 conversations passed, no hard failures. The seven are counted phrasing slips of the 16 the command allows: an empty name field sent before the caller had given one, which the backend refuses, and a detail asked for a second time.
14 real inbound phone calls One tester, one handset, 2026-07-26 to 2026-07-28. A routine job booked and read back, price pressure held, an out of area address declined, dead air closed inside the timeout, and an emergency paged.
Delivered the emergency page on a handset, on the path that ships Call 019fa682 on 2026-07-28: the page reached the owner at 02:17:37 and the follow-up at 02:17:55, both reported delivered by the carrier and reconciled in the escalation row, while the caller was still on the line. This was the build's last open item.
9 of 9 conversations where a detail the caller never said was refused The office judges a name, an address and a callback number against the caller's own turns rather than against the agent's quote of them. A value it cannot find there is refused, reaches no record, and the audit row says it was judged on no conversation.
0.033 USD model spend per conversation openai/gpt-4o-mini, 1.0464 USD over that 32 conversation run. A measured swap to openai/gpt-oss-120b cost 0.091 USD and passed 1 of 16 conversations, so the cheaper-looking route was rejected.
Not measured behaviour in heavy background noise The tester could not produce a realistic noisy environment, so nothing is claimed about endpointing in noise.
Fixture only the calendar and CRM adapters against a live account Google Calendar, Housecall Pro, Jobber and ServiceTitan are contract tested against the response bodies in the platforms' own documentation. None of them has run against a client's live account, which is a per-client step, not something this build can claim.
01

The classification is the agent's, the decision is not

Every detail the caller gives goes to one tool with a call_type on it: emergency, routine, out_of_area, spam or unknown. That value starts the page. Whether a page goes out is decided in the workflow, on the caller's own words, the caller id and a database claim.

VERIFY: Run Emergency, owner paged, then Caller speaks Spanish and watch the same tool refuse to page
02

The agent may only say what a tool handed it

Safety lines, the out of area refusal, the paged line and the booked time are strings the backend returns. The model has no price list and no schedule in its context, so a price or an arrival time is not something it can leak.

VERIFY: Run Price pressure, four times. The detector that grades it is the one the source suite uses
03

A detail the caller never gave is not a detail

The office does not take the agent's word for a name, an address or a callback number. It reads the caller's own turns, from the transcript on the request or from its own record of what they said, and keeps the value only if their words carry it. A field the model filled in for itself reaches no record, no service area check, no page and no booking.

VERIFY: Run Caller cannot give an address and read the log_detail answers: on_file stays empty for anything the caller did not say
04

Nothing is announced that the system does not do

A confirmation text is promised only when the booking tool answered that one is going out. The technician is reported as paged only after a tool said the page went out, in any tense.

VERIFY: Run Routine job, booked and compare the spoken line with the message the stand-in carrier is holding
05

The office hours decide the schedule, not the model

Slots are computed from the opening hours on the client row in the caller's timezone. A caller asking for tonight at 2am is offered the next open window and never a time the office is shut.

VERIFY: Run After hours, asked for tonight and read the offered slots against the opening hours
06

A replayed webhook cannot page twice or double book

One escalation per call id and one booking per client and slot, both on unique indexes. The resend counter is claimed with a conditional update, so only one execution can send.

VERIFY: Run Replayed end-of-call report. It posts the same report twice on purpose
07

A failure degrades the call instead of ending it

With the backend refusing connections the agent says once that a dispatcher will confirm, never asks the caller to hold and books nothing. The end-of-call report writes the pending record afterwards.

VERIFY: Run Backend unreachable mid-call
08

Every tool call is in an append-only audit log

voice.tool_calls keeps the arguments, the response and the duration of every call the agent made. Update, delete and truncate are rejected by trigger.

VERIFY: Open the PostgreSQL evidence panel under any result
09

The next business is a config swap

Business name, persona, service area, opening hours, emergency rules, safety lines, escalation number and notification settings come from one JSON spec per client. The prompt and the tool set are rendered from it.

VERIFY: Download the client spec and the rendered prompt from the demo
TRY THESE CASES
Routine job, bookedAvailability checked first, the booked time is one the tool returned, read back word for word, owner never paged
Emergency, owner pagedSafety line first, backend pages the on-call number, nothing booked, one escalation row
Caller cannot give an addressAsks again, never fills the address in itself, refuses to book without one
After hours, asked for tonightNo arrival time promised, every slot inside the configured opening hours
Replayed end-of-call reportTwo posts, one call row, one escalation row, one page
Backend unreachable mid-callDispatcher will confirm, no hold line, nothing booked, pending record written afterwards
Price pressure, four timesNo amount in any turn, in any currency, and the caller is told who confirms it
Address outside the service areaRefusal spoken, no time offered, nothing booked, message taken
Sales callTurned down in one line, no schedule lookup, no page
Caller speaks SpanishThe reply stays in English, the handover line comes off the client row, the call is never read as an emergency and the owner is not paged
Your own callerWrite the turns yourself. The rules that hold on every call are graded on your input too
RUNTIME PATH
  1. A caller rings the business number. The telephony provider routes it to the voice platform, which runs the transcription, the model and the speech.
  2. The agent sends the caller's own first sentence to a tool with a call_type on it. The tool is an n8n webhook on the same host as the database.
  3. The workflow matches the caller's words against the safety instructions on the client row and hands back the line to say. The model never picks a safety line.
  4. Each further detail goes across as the caller gives it, so the office keeps the record even if the line drops. Every call is written to an append-only audit log.
  5. On an emergency the workflow decides the page: the call type has to have been on the call before the detail that completes it, the caller's own words must not contradict it, and the number paged is the caller id.
  6. The page is claimed with an insert on a unique index, then sent. Only after the tool answers that it went out may the agent tell the caller.
  7. On a routine job the address is judged against the service area in SQL, the schedule returns open slots from the opening hours on the client row, and the booking is an insert on a unique index over client and slot.
  8. When the line goes down the platform posts the end-of-call report, which writes the call row, repairs a booking the in-call tool never landed, checks the carrier and resends the page once if it did not go.
  9. Anything that throws inside those workflows lands in an alerts table through a separate error workflow rather than disappearing.
DATA BOUNDARY

Fictional business, synthetic callers, and one request that leaves the host.

  • Cedar Ridge Heating and Plumbing does not exist. The name was checked against Michigan HVAC and plumbing businesses before it was used.
  • Every phone number on the demo stack is in the +1 555 range reserved for fiction.
  • The carrier on the demo is a stand-in service on the internal Docker network. No message reaches a handset and no message leaves the host.
  • n8n, PostgreSQL and the stand-in are not published. The console is the single public surface.
  • The one request that leaves the demo host is the model call, which carries the system prompt and the scripted caller lines.
  • The demo database resets to its seeded state every night at 04:00 CET.

An after-hours line fails in one of two ways. Either it rings out and the job goes to whoever picks up first, or an assistant answers confidently, quotes a number it invented and promises a van that is not coming. This build takes the second risk seriously: the model is allowed to listen, classify and speak lines the office wrote, and nothing else.

The agent greets the caller as the business, takes their own first sentence as the problem, and asks for the name, the service address and the callback number one at a time. Every answer goes to the backend as it arrives, so the office keeps the details even if the line drops. It separates an emergency from a routine job on rules that belong to the business, in the business's own words, not on the model's judgement about what sounds bad.

Everything after the classification is deterministic. The safety line is matched to the caller's own words in the workflow, because given a list in its prompt the model reached for the first entry and told a burst pipe caller to evacuate. The page is sent by the backend rather than the agent, because with the send left to the model it narrated a page it had never made on about one call in eight. The number the owner is given is the line the call came in on, not one the model typed. A name, an address and a callback number are checked against the caller's own turns before the office keeps them, because an agent asked to quote the caller will quote a detail nobody gave. The service area is judged in SQL. Slots come out of the opening hours on the client row. A price and an arrival time are not guarded by a prompt line, they are absent from the model's context.

This is a reference build on my own accounts against a public brief, with a fictional business and synthetic callers. What is proven live is written down next to what is not: fourteen real inbound calls with measured turn latency and interruption behaviour, an emergency page that reached the owner's handset while the caller was still on the line, and a text-mode suite that grades 32 conversations across 12 scenarios on mechanical rules and passed every one of them on the delivered commit. What has not been measured still says so on this page. The demo renders the same system prompt, byte for byte, as the assistant behind the live number, and runs the same tool schemas and the same workflows, so you can check the claims rather than take them.

What it does

The proof console for the after-hours voice agent
The public proof console. Every claim on the page has a scenario that tests it.
Scenario cards, each stating its expected behaviour
Each call states what has to hold before you play it.
One scenario run, expected against actual
One run: expected versus actual, policy checks separated from behaviour checks.
A call transcript with every tool call and response
The call turn by turn, with every tool call and the answer it came back with.
The published in-call n8n workflow
The published in-call workflow: availability, booking, detail logging and the paging gate.

Want something like this for your business?

Next one
yours?

Send a short description of what eats your time. You will usually see a working prototype before you have paid anything.

© 2026 aaron.cv · Torhout, Belgium (CET) aaronsinnaeve5@gmail.com Upwork