
The Deals That Go Quiet
Nothing has gone wrong with the deal. Nobody has said no. It is simply that eleven days ago someone asked the client a question, and the client has not answered, and since then the file has sat exactly where it was.
The failure mode of a lending pipeline is not rejection. It is silence. A deal that is going to fall over usually announces itself; a deal that is dying just stops moving, and it stops moving in a way that looks identical to a deal that is progressing normally, right up until someone opens the board and counts.
So somebody has to keep count. They work through the pipeline, spot the files that have not moved, work out what each one is waiting on, text the client, and remember to check again in a few days. Then they do the same thing tomorrow, because a pipeline does not hold still. It is a real job, it is entirely mechanical, and it is the first thing to slip when volume rises, which is precisely when it matters most.
We built an agent for a home lending practice that does the counting and the chasing. It watches a twenty stage pipeline, works out which files have breached their service window, texts the client a specific question in plain English, understands whatever comes back, and writes the answer into the file. It is called Ralph.
Working Out What "Stalled" Even Means
The first problem was embarrassingly unglamorous: knowing when a deal entered its current stage.
The CRM's calculated "date entered stage" properties did not exist on this account, and its search endpoint cannot return property history at all. So the enumeration runs in two passes. Search for the deals, then batch-read the stage change history for the ones you found.
Once you have the history, picking the right timestamp is not obvious either. Deals leave a stage and come back to it. Taking the first matching entry gives you a date from three weeks ago and an alarming false breach. Taking the last array element assumes an ordering the API never promised. So the rule is the maximum timestamp among entries matching the current stage, tolerating both ISO strings and epoch milliseconds because both turn up.
And when no valid timestamp can be found, the answer is not "not breached". It is "unknown", and it stays unknown all the way through to the decision layer. A system that treats missing history as healthy will report a clean pipeline made partly of files it could not see.
From that timestamp the service window is business days, not calendar days, which is the sort of detail that is trivial to write and impossible to retrofit once every downstream number has been computed the other way.
Splitting the Brain From the Wiring
Roughly half the files in this system come in pairs. There is a module with the logic in it, and a separate module that does the input and output.
That started as a workaround. The test runner could not resolve the path alias the rest of the application imports through, so the pure logic got moved into files that use relative imports only, with no database client, no CRM client, no model SDK and no environment reads. The wiring lives next door and is never imported by a test.
The workaround turned out to be the best decision in the project. Every consequential piece of reasoning, the service window arithmetic, the escalation selection, the duplicate suppression, the message parsing, the phone number normalisation, the rules about what may be written, all of it ended up on the pure side. Three hundred and seven tests run in under half a second, with no mocking infrastructure at all, because there is nothing to mock.
Determinism was enforced the same way. Nothing in a pure module ever asks what time it is. The current time is a parameter, passed in from the edge. That one rule is what makes it possible to ask "what would this system have done last Tuesday" and get a real answer.
Never Texting Twice
An agent that contacts clients has one unforgivable failure, which is contacting them twice.
The obvious fix is a table recording what was sent. We did not add one, because the conversation thread already knows when the agent last spoke. An action is suppressed if the agent has already acted at or after the moment that would have triggered it, and any contact predating the file's entry into its current stage is ignored, so an unrelated text from a month ago cannot silence a fresh question.
The ordering matters more than the rule. The outbound message is written to the conversation before it is sent, not after. If the send fails, the record still stands and the suppression signal still moves forward, so a retry cannot produce a second text. A failed send is logged and never thrown, because a telephony error must not be able to reach back and undo state that has already been recorded.
There is a scar on this one. The scheduled job runs two passes, and the order is deliberate: the per-deal work first, then the escalation pass, so that anything the first pass sent is already on the thread when the second pass decides whether to speak. That comment exists in the code because at one point the schedule was repointed at the first pass alone, and escalation silently stopped happening on every scheduled run. Nothing broke. Nothing errored. The system just quietly stopped doing one of the two things it was for, which is the most expensive kind of bug there is.
The Conversation
The client texts back. Now what?
The first version did this in two steps: compose one question, then extract the answer to that one question. It was tidy and it behaved badly. It re-asked things already sitting in the file. It answered a question the client had not asked while ignoring the one they had. It looped. It invented a phone number. And it was matey in a way the client visibly did not enjoy.
The rewrite collapsed it into a single conversational turn with much more context. The model gets a brief on who the client is and what they are applying for, an explicit list of what is already known and must never be asked again, the outstanding questions with their priority, a rolling summary of the older conversation and the recent messages verbatim. It returns a structured decision: the reply to send, any answers the client just gave, whether they volunteered something nobody asked for, whether to hand off to a human, and a preference worth remembering.
The behavioural rules are as important as the extraction. Never invent a number, a name, an amount, a fee or a policy. Never re-ask something on file. Mirror the client's tone rather than imposing one. Apologise once and then never repeat the behaviour they objected to. Hand off to a person the moment they are upset, ask for a human, or ask something outside finishing their application. There is even a rule against opening every reply with the same interjection, because nothing gives away a bot faster than a verbal tic.
Inbound messages arrive by webhook, which the handler acknowledges immediately and processes afterwards, because the provider disables a webhook after ten consecutive failed responses and the reasoning takes seconds. Two different payload shapes are supported, since the provider has more than one, and they disagree about where the sender's number lives.
What It Is Allowed to Write
Reading a client's text is the easy half. Writing to their file is where an agent can do real damage, so there are four independent gates and they are all pessimistic.
The model must return each answer in a fixed shape with a confidence attached. A write is rejected outright if it targets anything that was not genuinely outstanding, if the value is empty, or if confidence is below the floor. An ambiguous reply therefore leaves the field untouched and the agent asks again, rather than committing a guess and moving on.
Then comes the rule the whole system is built around: the agent may never overwrite something a person put there. Human provenance is checked first, before anything else, and that includes a field a staff member deliberately emptied, because a considered blank is a decision and not a gap. False and zero are treated as real values rather than absences, which is the bug every system of this kind writes once. Where the agent's answer conflicts with what is on file, the conflict is raised for a human and nothing is written.
One refusal is worth spelling out. Told a client's salary, the agent will not create an employment record to hang it on. It could. The synthesised record would be missing a field the file requires, which would inject a new outstanding item into the staff view, and the deal would report as less complete after the agent helped than before. So it declines and flags it instead. An agent that improves its own metric while degrading the actual file is worse than one that does nothing.
Every decision is written to an append-only log per deal: what it saw, what it chose, what it did, and the state of the file at the time. The log is deliberately incurious about content. Deal names are never written to it because they contain client names, and extracted values are stripped before anything is logged.
Shipping It Switched Off
The last design decision was to build the whole thing and then not turn it on.
Every switch defaults to the safe position. The master switch is off. Dry run is on. The list of people who may be contacted is enforced twice, once gracefully so a blocked recipient logs cleanly, and once as a hard assertion at the very edge so an accidental real message is structurally impossible. That list ships empty. Every regulated or browser-driven action is off and gated behind an approval.
Going live is four deliberate environment changes in a specific order, with no code deployment: authenticate the schedule while the passes stay inert, enable the passes while everything still simulates, verify the dry runs against real deals, and only then turn off simulation and turn on the master switch. Between each step you can read exactly what the system would have done.
That is a slower path than shipping it running. It is also the only version of this we were willing to point at real clients, because the failure mode of getting it wrong is not a stack trace. It is a text message to somebody's customer.
By the Numbers
This agent replaces the recurring cost of the pipeline chase:
- A client services officer (~$80,000 AUD/year, SEEK 2026, which puts the role at $70,000 to $85,000) who would otherwise text every client with an outstanding question, re-read the thread to work out what has already been answered, and hand-type each answer into the file.
- A pipeline administrator, roughly half a role (~$70,000 AUD/year, Hays Salary Guide FY26, Sales Support) who would otherwise work through the board looking for files that have stopped moving and decide which ones need escalating.
Operational savings breakdown:
- $115,000 AUD in annual savings, on base figures excluding superannuation.
- 700 dev hours avoided against a traditional build: around 12,700 lines across the agent, the schedule, the messaging integration, the write-safety layer and an operator console, of which roughly 2,900 are tests. This is an estimate of comparable scope, not a measurement.
- 1.5 FTEs replaced.
- Automation type: ai-integration.
The test coverage sits where the risk is. The heaviest concentrations are on the write classifier, the escalation logic, the message parsing and the execution path, which are the four places a wrong answer reaches a real person.
The Part That Is Not the Model
It is tempting to describe a system like this as an AI project, and the language model is genuinely doing something no amount of code could: reading a text message from a stranger and working out what they meant.
But almost none of the engineering is about that. The model occupies one function. Everything around it is unglamorous: deciding what "stalled" means when the history is incomplete, making sure a message cannot be sent twice, working out what a person is allowed to have overwritten, and arranging the switches so the thing cannot act until somebody has watched it not act.
That ratio is the lesson. The interesting part of putting an agent in front of your customers is not the agent. It is everything you build to constrain it, and how much of that you can test without a database, an API or a clock.
