Voice agents
Voice agent triage in 90 seconds: a Ghent case study
How a 19-person Ghent vastgoedbeheerder routes 1,340 tenant calls a week through a voice agent — and parks gas leaks in a monteur queue inside 90 seconds.

It's 23:14 on a Friday in Ledeberg. A tenant on the Hundelgemsesteenweg is standing on her kitchen counter, holding a bath towel against the ceiling. Water is coming through the light fixture. She calls her vastgoedbeheerder. The office closed at 18:00. The on-call number used to go to voicemail, and then, if she was lucky, to a junior account manager's personal mobile.
That's the situation we walked into last September when a 19-person property manager in Gent — call them VBR Gent — asked us to fix the after-hours queue. They manage roughly 4,800 units across East Flanders. By their own count they were handling 1,340 huurder-meldingen a week, and about 18% of those landed outside business hours. The previous flow was: voicemail, triage spreadsheet on Monday morning, ticket. The 24-hour SLA clock from the Vlaamse Wooncode was already two-thirds spent by the time anyone read the ticket.
This is the story of the voice agent we put between the tenant and the existing systems, and what we learned gluing AI to a 13-year-old document management system that still ships SOAP-on-XML.
The stack we walked into
VBR Gent's stack was not unusual for a mid-size Flemish property manager. Three load-bearing pieces:
- A copy of Square — the document management system, not the payments company — on-premises, version 4.7, installed in 2013. SOAP API returning XML wrapped in CDATA wrapped in JSON when you ask nicely. Unit registry, owners, tenants, leases, and stored PDFs all live here.
- A homegrown PostgreSQL maintenance ticket system written by a senior dev who has since left. Schema is sensible:
units,tenants,tickets,assignments,monteurs,sla_clocks. Triggers fire on insert. There is no API; internal staff use a Vue 2 front end against the DB through a thin Express layer. - A roster of 14 external contractors (electricity, plumbing, gas, glazing, locksmith) reachable by WhatsApp Business and SMS. Three of them have a real 24/7 line. The rest call back within an hour.
Anything we built had to talk to all three, and we were not allowed to touch the Square server. The Postgres DB we could extend with new tables and views. Contractor outreach we could redesign from scratch.
Voice over chat
The 2026 instinct is to put a chat agent on the website. We tested it for six weeks before we made the recommendation. The data was clear: tenants in distress don't open a browser. They pick up a phone. Of 248 emergency-tier reports during the test window, eleven came in through chat. The rest arrived by voice.
WhatsApp was the next candidate. It's the right channel for plenty of property work — confirmations, photo uploads, scheduling — and it ended up doing useful work downstream. But the inbound side of an emergency is voice, because voice is what people instinctively reach for when there is water on their floor. So the main number, and the after-hours fallback from the front office voicemail, both route to the voice agent. The chat widget stayed on the site and now handles non-urgent items: key replacements, address changes, when the cleaning team comes.
The 90-second routing budget
The hard requirement: from the moment the tenant says "I have a leak" to the moment a contractor's phone is ringing, no more than 90 seconds may pass. That number is not arbitrary. The Flemish private-rental framework treats certain failures — heating in winter, gas, water ingress, a primary-entrance lock failure — as dringende herstellingen, and the practical effect is that the manager has 24 hours to start the intervention or carry the damages. If the call comes in at 23:14 on Friday and triage happens Monday, the clock is already inside the warning band.
90 seconds breaks down roughly like this in the deployed agent:
- 0–8s: greeting in Dutch on a custom Flemish voice, tenant lookup by caller ID.
- 8–35s: open-ended question, classifier on the running transcript while the tenant is still talking.
- 35–60s: confirm address out loud, extract details the tenant didn't volunteer (smell of gas, water source, heat available).
- 60–90s: write the ticket, NOTIFY the dispatch channel, place the first outbound contractor call.
The classifier is the part most teams overcook. We started with a multi-label scheme covering 31 categories. After three weeks we collapsed it to seven, because the only thing that actually changes downstream routing is the triage tier and which contractor pool the ticket lands in. Everything else can be filled in when the monteur is on site.
Here is the routing rule, simplified, that runs against the structured output the LLM emits at the 30-second mark:
def route(extracted: dict) -> str:
if extracted["hazard"] in {"gas_leak", "fire_risk"}:
return "p0_gas"
if extracted["hazard"] == "water_active" and extracted["source"] != "appliance":
return "p0_water"
if extracted["service"] == "heating" and is_winter() and extracted["heat_failed"]:
return "p0_heat"
if extracted["service"] == "lock" and extracted["entry_blocked"]:
return "p1_lock"
if extracted["severity"] >= 7:
return "p1_other"
return "p2_business_hours"
The p0_* tiers go to a monteur queue immediately. p1 waits until 07:00 unless it's already daytime. p2 waits for the office. That's it. The voice agent does not try to be clever about anything else.
Talking to a thirteen-year-old DMS
Square is the part that ate the most engineering time. Its SOAP endpoints were stable and well-documented for their era, but the auth flow used a per-session token that expired aggressively, and the WSDL declared optional fields that the server treated as required. We did not get to modify the Square server. We did get a read-only mirror of three core tables — units, tenants, leases — replicated nightly into a Postgres schema we owned. That gave us the lookup we needed.
For writes back into Square (we attach the call recording and transcript as a document on the unit), we run a small worker that holds a single warm session and serialises writes:
class SquareWriter:
def __init__(self, host, user, pw):
self._client = zeep.Client(f"https://{host}/dms?wsdl")
self._user, self._pw = user, pw
self._token = None
self._token_acquired = 0
def _ensure_token(self):
if not self._token or time.time() - self._token_acquired > 540:
self._token = self._client.service.Login(user=self._user, password=self._pw)
self._token_acquired = time.time()
def attach_document(self, unit_id: str, payload: bytes, filename: str):
self._ensure_token()
return self._client.service.AttachDocument(
token=self._token,
unitId=unit_id,
fileName=filename,
fileBytes=base64.b64encode(payload).decode(),
)
Nothing exciting in there. It exists because losing a session token mid-attach was the most common production error in week two. Serialising writes through one process killed the class of bug entirely.
If you wrap a legacy SOAP DMS with a worker like this, set the worker's RestartSec to at least 30 seconds. We had a flapping crash loop that re-logged-in 200 times in a minute and tripped the DMS brute-force lockout for an hour. The read-only mirror is what saved us during that hour.
NOTIFY beats a poll loop
The homegrown ticket system is the source of truth for who is working on what. We added two tables (voice_calls, voice_call_events) and one view (open_tickets_for_dispatch). When the voice agent finishes a call it inserts the ticket, inserts an event, and emits a Postgres NOTIFY on the dispatch channel.
A small Go dispatcher LISTENs on that channel. When a NOTIFY arrives it pulls the row, looks up the on-call contractor for the relevant tier, and either places an outbound call via Twilio or sends a WhatsApp template message, depending on the contractor's preference.
CREATE OR REPLACE FUNCTION notify_dispatch() RETURNS trigger AS $
BEGIN
IF NEW.priority IN ('p0_gas', 'p0_water', 'p0_heat', 'p1_lock') THEN
PERFORM pg_notify('dispatch', NEW.id::text);
END IF;
RETURN NEW;
END;
$ LANGUAGE plpgsql;
CREATE TRIGGER trg_notify_dispatch
AFTER INSERT ON tickets
FOR EACH ROW EXECUTE FUNCTION notify_dispatch();
We polled at 5-second intervals during the first month. Switching to LISTEN/NOTIFY dropped median dispatch latency from 4.2 seconds to 180 milliseconds and stopped lighting up the unit-lookup queries with useless background load.
The SLA clock, in code
Every urgent ticket carries an sla_clock_started_at column and a derived sla_due_at = sla_clock_started_at + interval '24 hours'. The dispatcher refuses to mark a ticket in progress until a contractor has explicitly confirmed pickup. If pickup doesn't happen inside ten minutes the dispatcher escalates to the next contractor on the rota. If the rota is exhausted, the office manager's phone rings. That has happened twice in eight months. Both times it was a Sunday in February.
The clock is visible to the voice agent during the call, which matters less than you would think — the agent does not negotiate timing with the tenant. But the office sees it on the wallboard the next morning, which has changed the team's relationship with the after-hours pile. They walk in on Monday and the urgent work is already closed.
What we got wrong the first time
Two mistakes worth naming.
First, we let the voice agent confirm unit numbers by reading them back to the tenant. In Ghent, unit identifiers like 2B, 2bis, and 2/B all exist in the wild, and the Square DB stores them inconsistently. The agent would confidently say "unit two-bee" and the tenant would say "yes" and we'd write a ticket against the wrong unit. We fixed it by switching to address-first confirmation ("Hundelgemsesteenweg 184, third floor, the door on your left as you come up the stairs — is that right?") and looking up the unit from there. Tenant-facing accuracy went from 91% to 99.4%.
Second, we underestimated the contractor side. The first version of the dispatcher placed an outbound Twilio call and read out the ticket in a synthetic voice. Three contractors flat-out refused to take robocalls at 2am, which is fair. We switched to WhatsApp templates with an audio attachment of the tenant's own voice describing the problem. Pickup time on p0 tickets dropped from a median of 6 minutes to 90 seconds. The contractors said the tenant's own voice told them more in fifteen seconds than any structured form could.
Six-month numbers
- 1,340 weekly calls on average; 1,418 in the highest week (cold snap, January).
- 96.1% of
p0tickets dispatched to a contractor inside 90 seconds, end-to-end. - Median tenant-call duration: 2 minutes 41 seconds.
- Two SLA breaches in eight months, both attributable to contractor rota exhaustion.
- Office-manager after-hours phone interruptions: from ~14 per week to 0.3 per week.
The 14-to-0.3 number is the one the founder talks about. The voice agent's job is not to be impressive in a demo. It is to let a 19-person team behave like a 40-person team without anyone losing their Sundays.
What to do tomorrow morning
If you're sitting on a similar pile — legacy DMS, homegrown ticket DB, contractor roster on WhatsApp — the smallest useful audit is to count two things this week. How many emergency-tier reports come in outside business hours, and what is the median wait before a human looks at one. If that second number is more than two hours, you have a voice problem, not a chatbot problem.
When we built this for VBR Gent, the thing we kept running into was that the value was not in the model — it was in the wiring between three systems that were never meant to talk. That is most of the work in any production voice agent: making the boring connections reliable enough that the clever part can stay simple.
Key takeaway
The value of a production voice agent isn't the model — it's the wiring between the systems that were never designed to talk to each other.
FAQ
Why a voice agent for tenants instead of a chatbot?
Tenants in distress reach for a phone, not a browser. In a six-week test, only 11 of 248 emergency reports came through chat. The rest were calls.
How does it handle the 24-hour SLA from the Vlaamse Wooncode?
Every urgent ticket carries an SLA-due timestamp. If a contractor hasn't confirmed pickup within ten minutes the rota escalates automatically, and the office manager is paged once the rota is exhausted.
Did the contractors accept being dispatched by an AI?
Not at first. Robocalls at 2am were a non-starter for three of them. WhatsApp templates with an audio clip of the tenant's own voice dropped median pickup time from 6 minutes to 90 seconds.
How accurate is unit identification on Flemish addresses?
After we stopped reading unit numbers back to tenants and switched to address-first confirmation, accuracy went from 91% to 99.4%. Ghent unit numbering is too inconsistent to confirm any other way.