A good fallback hides the failure it was built for A developer who built a fleet of five agents for home care administration for Google's All Things Agentic Hackathon discovered that his system's fallback mechanisms masked three critical failures for three weeks. The issues—an empty API key, a short-circuit to fixtures, and a content-type mismatch—were hidden because the fallbacks worked as designed, and were only exposed when the developer deleted the sample data. The developer emphasizes that fallbacks should be loud in environments where they should never occur. 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.