Inspiration

My wife is a veterinarian, she regularly talks about her work with me.

What it does

The Veterinary Care Continuity Agent keeps the thread connected between a veterinary visit and the animal's recovery at home.

A vet types or dictates rough clinical notes. The agent drafts a structured SOAP note and pulls the treatment plan into machine-readable fields. Nothing moves until a human approves it — and the vet's edits, not the draft, are what get frozen. From that approved plan the agent writes discharge instructions in plain language a pet owner can act on.

Then it follows the patient home. On each scheduled post-operative day it opens a short conversation with the owner. When the owner says something vague — "it looks a little weird" — it doesn't guess. It asks what they can actually see, whether it's changed since yesterday, whether there's discharge or bleeding, and converts the answers into structured clinical observations.

Those observations go to a deterministic rules engine, not the model, which returns NORMAL, REVIEW, or URGENT. A REVIEW raises a task for veterinary staff citing the exact rule IDs that fired. An URGENT stops the conversation immediately rather than working through the rest of the questionnaire. Every step appends to an immutable event log, so the patient timeline and case state are derived — never overwritten — and you can always answer what the agent knew and when.

The agent does not diagnose, prescribe, or modify treatment. The veterinarian remains the clinical decision-maker.

How we built it

Python, FastAPI, SQLite-free (an append-only event log snapshotted to JSON), and a single vanilla-JS page with no build step. Claude Opus 5 via the Anthropic Python SDK, using structured outputs — client.messages.parse() with Pydantic models — at every call site.

The central architectural decision was not to build an open-ended tool-calling agent. The clinical workflow is a fixed sequence with a mandatory human approval gate; that's a state machine, not an open-ended task. Letting a model decide whether to raise a clinical alert would be slower to build and weaker on safety than calling it deterministically.

So the orchestrator is ordinary code, and the model is used at three narrow seams:

  1. SOAP drafting — restructure the vet's notes; never add findings or invent doses.
  2. Discharge rewriting — translate the approved plan into owner language.
  3. Follow-up intake — turn an owner's reply into observation slots and choose the next question.

Only the third is genuinely agentic, and it's bounded: a six-question budget, and four explicit exit conditions.

Two safety properties are enforced in code rather than asked for in a prompt:

  • Grounding check. After the model writes discharge instructions, a deterministic checker builds an allowlist from the approved plan — drug names, (number, unit) dose pairs, durations — and scans the generated text. An unprescribed drug, an altered dose, or an invented duration is rejected and regenerated with the violation fed back. A second failure falls back to a plain template rendered straight from the plan, which is grounded by construction.
  • Triage rules as config. rules.yaml holds every escalation rule with an ID and a human-readable reason, so an alert cites exactly what fired. The loader validates at startup that every rule targets a field the observation schema actually has.

The whole system runs offline from recorded fixtures, so a demo never depends on venue wifi.

Challenges we ran into

The source spec contradicted itself, and we only found it by testing. The design doc's rules engine read four fields — heavy_bleeding, difficulty_breathing, incision_open, vomiting_count — that no other part of the system produced. Worse, the document's own headline scenario (increasing swelling plus yellow discharge → REVIEW) returned NORMAL under its own code. We rebuilt around one canonical observation schema and wrote the failing scenario as the first test. The rules loader now rejects any rule pointing at a nonexistent field at startup, so that class of bug can't ship silently again.

Missing data was a crash waiting to happen. data["heavy_bleeding"] raises KeyError when the owner hasn't answered yet, and there was no way to express "the owner doesn't know." Every clinical field became an enum with an explicit unknown member that can never match a rule — so absent data can neither crash the evaluator nor raise a false alarm.

A YAML trap that would have silently disabled an emergency rule. Unquoted yes and no in YAML are booleans. equals: yes would have compared True against the string "yes" and never matched — meaning the rule for an open incision would never have fired. Every value in the rules file is quoted, with a regression test as a canary.

The agent was too thorough to be usable. Early on, an owner whose dog was completely fine got marched through six separate questions. Correct behaviour by the letter of the spec; bad product. We let it group routine confirmations into one question when nothing is wrong, so an uneventful day closes in three exchanges.

We tried to fix a control-flow problem with prompt engineering. Once a case was already heading for veterinary review and every safety-critical field was resolved, the agent kept asking routine questions. We spent several iterations tuning the prompt before recognising this was the wrong tool — when to stop asking is a control-flow decision, so it belongs in the orchestrator. It became a deterministic exit condition, tested in both directions.

The offline demo mode nearly created a dangerous failure. Our fixture replay fell back to "the most recent recording of this kind" when there was no exact match. For the follow-up conversation, the most recent recording was the URGENT one — so any unrehearsed reply would have made the agent announce it had notified the vet team about bleeding nobody described. Fallback is now disabled for the intake seam specifically; an unknown reply returns a clear error instead of inventing a turn.

Getting the grounding check to be strict without being noisy. Our first version would have flagged the model's perfectly valid rewrite of 2026-09-12 as "12 September 2026" as an unverified number. Scoping the duration scan to numbers adjacent to a time unit fixed it, and comparing number sets means the plan's "10-14 days" correctly licenses the model's "10 to 14 days."

Accomplishments that we're proud of

The safety claim is verifiable, not asserted. Against the real generated discharge text, the grounding check produces zero false positives — while injecting gabapentin, 150 mg, 30 days, or ibuprofen is caught every time. We deliberately included the human painkillers that are toxic to dogs, because "just give her some ibuprofen" is the failure mode with actual consequences.

An URGENT signal terminates the conversation. This wasn't in the original design at all. Continuing down a questionnaire while an owner describes an emergency is the wrong behaviour, and it's the exit we'd least want to get wrong.

The model never makes the clinical call. It turns "it looks a little weird" into incision_swelling: increasing and incision_discharge: yellow. A YAML rules file decides that means REVIEW, and the alert cites R-REV-01 and R-REV-02 so a vet can audit exactly why.

A full audit trail for free. Because case state and the patient timeline are folds over an append-only event log, nothing is ever overwritten — and an unresolved escalation can't quietly drift back to "monitoring" because a later day went fine.

89 tests and a demo that runs with the wifi off. Plus two end-to-end smoke walks that exercise the NORMAL, REVIEW, and URGENT paths against the live model.

What we learned

A prompt instruction is not a control. "The AI should not create new treatments" is unverifiable and fails silently. The same requirement expressed as sixty lines of deterministic checking can be tested, can reject output, and can be shown to a skeptic. Any safety property that matters should be enforced somewhere the model doesn't reach.

Know which decisions belong to the model. Our sharpest debugging lesson was recognising that when to stop asking questions is control flow, not a prompting problem. Once we stopped negotiating with the model about it and made it a deterministic exit, the behaviour became correct and testable.

Deterministic components can steer the model without ceding authority to it. The rules engine tells the intake prompt "concerning findings are already recorded, ask only about remaining safety-critical items" — which shapes how the agent spends its questions without ever letting it decide the triage level.

unknown deserves to be a first-class value. Enums with an explicit unknown state, which can never match a rule, removed an entire class of both crashes and false alarms.

Correct behaviour and good behaviour aren't the same thing. Our over-questioning agent was doing exactly what it was told. Making it usable meant changing what we asked for, not fixing a bug.

Offline-first is a feature, not a fallback. Building fixture replay early made development fast, tests deterministic, and the demo immune to the venue's network — and verifying it end-to-end is what surfaced the most dangerous bug we found.

What's next for Veterinary Care Continuity Agent

Clinical validation first. The rules in rules.yaml are illustrative. Real escalation thresholds have to be written and validated by practising veterinarians, versioned, and signed off — the format already supports per-rule provenance, we just haven't earned the content yet.

A real formulary. The grounding check uses a hardcoded list of ~70 drug names. It catches a model naming a real drug that wasn't prescribed, but not a plausible invented one. Production means checking against a veterinary formulary and the practice's own dispensing records.

Close the loop with the vet. Today an alert is raised and acknowledged. Next it should carry the vet's decision back to the owner, adjust the follow-up schedule, and record the outcome — which also gives us the labelled data to measure whether the rules are calibrated.

Photos. Owners describing an incision in words is the hardest part of the problem. A photo, assessed alongside the structured answers, would sharpen triage considerably.

Real delivery. SMS or a practice app instead of a simulated chat pane, with a real scheduler driving the follow-up protocol rather than a simulated clock.

An eval set for the extraction seam. The slot-filling step is the one place model quality directly affects clinical outcomes. It deserves a labelled set of owner utterances and a measured extraction accuracy, not our confidence.

Multi-patient, multi-practice. The architecture already supports it — the case store is keyed by patient and the event log is per-case — but the MVP deliberately runs one patient end to end rather than many badly.

Built With

Share this project:

Updates

Submission history