Built for Google's All Things Agentic Hackathon (Devpost), August 2026.
I spent three weeks building a fleet of five agents that coordinate home care
admin — reading photographs of pill bottles, voice memos from carers, insurance
letters — and stopping to ask a human before anything irreversible.
The tests passed. The health endpoint was green. Every screen rendered. And for
most of those three weeks, three separate things were broken in ways that
produced no error, no warning, and no visible symptom at all.
They were all hidden by the same thing: a fallback doing exactly what I designed
it to do.
The UI had never once been live
The web interface reads from the deployed API and falls back to a committed
fixture corpus when a request fails. It labels itself honestly — a small badge in
the corner reads sample data when it is showing fixtures, and live when it
is not. I was proud of that badge. A demo that silently shows canned data while
implying it is live is a lie the viewer cannot detect.
The badge worked perfectly. It said "sample data" for the entire life of the
deployment, and I never noticed, because I was testing the API with curl and it
answered every time.
The cause was four characters of shell. gcloud run deploy --source passes
--set-build-env-vars to the builder. A Dockerfile build does not receive them as build args. So NEXT_PUBLIC_VIGIL_KEY was empty in every image ever shipped,
every browser request was unauthenticated, every request failed, and every screen
fell back — correctly, quietly, exactly as specified.
Then there was a second layer. The UI and the API are served from the same
container, so the deploy script deliberately leaves the API base URL empty:
requests are same-origin and need no host. But the code read an empty base URL as
"no API configured" and short-circuited to the fixture before attempting a
single request. The one deployment shape the system was designed for was the one
shape it refused to try.
An agent's proposals never reached the approvals queue
The medication agent can propose a schedule change. Every clinical change goes to
a human — that is the entire safety argument of the project. The tool wrote a
document into a proposals collection.
The API served the approvals screen from a collection called approvals.
Two mechanisms for one idea, running past each other, and nothing in between to
notice. The Approvals screen looked populated and correct the whole time —
because it had sample cards to render.
Voice notes never reached the agent
Uploaded .wav files were stored as application/octet-stream. The upload
handler validated the file extension against an allowlist and then preferred the
content type the client declared over the allowlist it had just consulted. Both
curl and browsers send application/octet-stream for .wav.
Downstream, the pipeline decides whether to attach a binary to the agent's message
by looking at that content type. It saw neither image/ nor audio/, so it
attached nothing. The agent received a prompt with no recording in it and reported
that the audio format was unsupported — which was true of what it had been given,
and false of the file. The screen faithfully reported the agent. Every component
did what it was told.
What exposed all three
One instruction from the person I was building for: delete the sample data.
Every screen shows the deployed system, or says plainly that it cannot reach it.
All three surfaced within an hour.
That is not a coincidence, and it is the part worth keeping. A fallback is a
second, quieter implementation of your feature. When the first one breaks, the
second one carries on and the system looks healthy — which is precisely what you
asked it to do. The more graceful the degradation, the longer the outage hides.
I still think the fallback was a good design. What I would change is that a
fallback should be loud in the environment where it should never happen. In
development, fall back silently. In the deployment where a backend is definitionally
present, falling back is not degradation — it is an incident, and it should say so.
The one that had nothing to do with fallbacks
While fixing those, I found a fourth, and it is my favourite. Five tool signatures took a run_id: str parameter. The agent framework builds a
tool's declaration from its signature, which means every parameter is a field the
model fills in. So the model filled it in — with "run-001", a plausible,
well-formatted identifier belonging to no run that has ever existed.
Every approval created that way was untraceable. The card in the queue named a run
that does not exist. The audit trail could not be correlated. And the idempotency
key was namespaced under the hallucinated id, so two proposals from genuinely
different runs could collide and one would be silently suppressed as a duplicate.
A tool signature should carry what the model needs to decide, and nothing else.
Bookkeeping the model cannot know is bookkeeping the model will invent. The run id
now comes from a context variable, and a test asserts that no tool signature
contains a runtime-only field: RUNTIME_ONLY = {"run_id", "trace_id", "actor", "agent", "step_id"}
def test_no_tool_asks_the_model_for_runtime_bookkeeping():
offenders = {
f.**name**: sorted(RUNTIME_ONLY & set(signature(f).parameters))
for f in ALL_TOOLS
if RUNTIME_ONLY & set(signature(f).parameters)
}
assert not offenders
And the one where I destroyed the evidence myself
The project's centrepiece is an eval gate with an anti-gaming judge: an agent
proposes a rewrite of its own instruction, the proposal is scored against a fixed
suite, and a second model argues that the improvement is not real. The stored
record of one agent gaming its own test and being caught is the most valuable
artefact the system produces.
I ran a second, honest improvement round — no gamed candidate — because a gate
that has only ever said no is hard to distinguish from a gate wired to say no.
It was rejected for an ordinary reason: the proposer truncated the instruction
mid-sentence and the score fell.
And it overwrote the first record.
Version records were keyed on the proposed version number. The bump function is
deterministic, so every rejected proposal from 1.4.2 is named 1.5.0-rc. The
second set() replaced the first. Nothing reported a loss, because overwriting is
what set() is for. The docstring on that function reads: "Rejections are kept deliberately… a gate
whose refusals are not retained cannot be audited." It was true of the intent and
false of the storage. Records are now one document per attempt, and the registry
holds both verdicts — one rejection for gaming, one for quality. The pair is
better evidence than either alone.
The last one: a refusal that was really a crash
The newest feature in the system is the one I was most nervous about. A discharge
letter says to rest the leg on a cushion so the ankle sits higher than the hip,
knee slightly bent. That is correct, a clinician wrote it, and it is delivered on
the worst possible medium — a sheet of A4 handed over once at a hospital desk to
a relative too worried to take it in. A week later it is being done slightly
wrong and nobody knows.
Video is the only format that shows a movement, so the fleet proposes filming
it, a human approves, and Veo renders the action. The clinician's sentence is
quoted above the clip; the numbers stay in the text, because generated lettering
is unreliable and a wrong figure on a care video is worse than no video.
The card can also say the instruction was not filmed — some instructions have
no picture in them. "The district nurse visits on Tuesdays" is a fact, and a
pleasant generated shot of a calendar attached to medical paperwork reads as
evidence. Refusing is the correct behaviour and the card shows the reason.
The first live run rendered nothing, and the reason shown to the user was:
Expecting ',' delimiter: line 6 column 16 (char 583) I had asked the model for JSON in prose and parsed the reply. It wrote a scene
containing a quotation mark, and the response stopped being JSON. Fine — except
for where that string went. The record is keyed by the approval id so the render never runs twice, which meant a transient fault had been written into a
permanent "not filmed" card, with a parser error where a carer would look for the
reason, and nothing left in the system that would ever try again.
Two fixes, and the second is the one I keep thinking about. The schema is now
declared to the serving layer instead of described in a prompt — the same lesson
every agent in this codebase was already built on, which I had failed to apply to
the one call that was not an agent. And a fault and a refusal are now different
things in the type: a refusal is a decision and it is final; an error means try
again later, and it deliberately leaves nothing behind.
They look identical on screen. That is exactly why they must not be stored alike.
Refusing to help is not the same as being safe
The shared rule every agent in this fleet reads used to say "you do not give
medical advice". It was tidy, it was easy to defend, and it meant the part of
the folder the family most struggled with was the part the system would not
touch — while it went on cheerfully filing their insurance paperwork.
The rule is now precise instead of broad: carrying a clinician's words is
allowed, authoring is not. The safety did not come from the silence. It comes
from the quotation, and from a scope owned by the clinical department, which the policy engine sends to a named human at any confidence — including a confidence
of 1.0. Every clip on screen carries who approved it.
Broad rules feel safer to write. They are mostly a way of moving the risk
somewhere you cannot see it.
What I would tell myself three weeks ago
Delete the safety net once, on purpose, in the environment that matters. Not
permanently — just long enough to find out what it has been carrying.
A component that "did what it was told" is not exonerated. All three of the
first bugs had a blameless component chain. The failure was in the seams, and the
seams are where the tests were not.
Numbers in your README expire. I quoted an eval score of 0.67 → 0.92 in mine.
By the time I checked, the deployed record said 0.75 → 0.83. A judge with two
tabs open would have caught it. I now regenerate the numbers from the running
system before I claim them.
Built for Google's All Things Agentic Hackathon, August 2026. Stack: Google ADK,
Gemini 3.5 and 3.6 on Vertex AI, Gemma for redaction, Gemini TTS and Veo, Cloud
Run, Firestore, Pub/Sub, Cloud Trace. All data synthetic. The system writes no
care advice of its own — it carries a clinician's, quoted, and every clinical
action waits for a person.