The handoff, not the diagnosis
A test is ordered. The lab runs it correctly. The result is correct. And then nobody reads it.
This is not a rare failure and it is not a clinical one. In a systematic review of 19 studies, the share of ambulatory laboratory results with no documented follow-up ran from 6.8% to 62%. A review of 39 studies found a median of 23.3% of abnormal outpatient results never followed up. A survey of 262 physicians found 83% had experienced at least one clinically significant delay in the previous two months — and the causes they reported were administrative, not diagnostic.
The medicine works. The handoff is what breaks. And the hospital's record of the break is usually the chart itself, which you only open after something has already gone wrong.
CareChaser is a tracking layer for the handoff. Not a diagnostic tool, not an EHR, not a second opinion. It gives every diagnostic test a tracked lifecycle and chases the ones that stall.
What it does
It reads the paper. Results arrive as scanned pages. Amazon Textract reads them, and a geometry pass rejoins the columns Textract returns as separate blocks, so a row split across three blocks becomes one test again.
It tracks the state. Each case moves through seven workflow stages, each
with its own owner role and its own deadline — measured from the moment the
case entered that stage, not from when the test was ordered. A case open
eleven days is not late. A case sitting in result_produced for twenty-five
hours is.
It chases. Overdue cases surface on a board, and can be asked about and acted on by voice through Amazon Nova 2 Sonic: what is stuck today, who owns CC-014, mark CC-007 reviewed.
And one thing it deliberately does not do: CareChaser never stores a clinical
value. The extractor strips every number, unit and reference range before
anything is written down — 450 of them across this run. It cannot interpret a
result, and it cannot decide a case is urgent by reading one. Urgency comes
only from a STAT, CRITICAL or urgent label printed on the page by the lab.
The decision we made first
A language model is in this product. It is not allowed to decide anything.
The workflow lives in workflow.yaml — not in code, not in a prompt. Each
stage declares its legal next stages, the events that reach them, the role that
owns the case, its deadline, and who it escalates to. The model turns a spoken
sentence into a tool call and a tool result into a sentence. That is its entire
job.
Here is the moment from the demo that makes the point better than any architecture diagram. The clinician says they have reviewed the case. Nova 2 Sonic hears it correctly. The deterministic layer agrees that an acknowledgement was given. And the engine still refuses:
3. DECIDE (deterministic, no model)
transcript: "yes, i have reviewed it and documented the next step."
verdict: acknowledged
4. RECORD (MCP write-back)
Illegal transition: event 'result_reviewed' has no configured target
from state 'sample_collected'.
board now: 221 stuck
CC-001 still listed: True
result_reviewed is only legal from result_produced. The sample is still in
a tray. So the board does not move. A language model can mishear. It cannot
move a case.
How we built it
One EC2 t3.micro runs all of it, behind Caddy. A self-hosted MCP server exposes 17 tools over Streamable HTTP, on protocol version 2026-07-28. A systemd timer runs the morning ingest at 07:30 — and that job never touches the store directly, it calls the deployed MCP server, so the unattended run exercises exactly the code path a person clicking in the browser would. If the tools were wrong, the morning run would be wrong too.
There is no human at 07:30 to review proposed rows, so the job applies the strictest possible stand-in rule: a row is accepted only if intake classified it completely and raised no issue at all. Everything ambiguous is recorded as set aside, with a reason, and never guessed at. A run with zero rows set aside would mean the guard had stopped working.
Textract runs under an IAM instance role. There are no AWS keys on the box or in the repository.
Amazon Polly renders the clinician’s side of the call as 16 kHz mono PCM — exactly Nova 2 Sonic’s input format, so the two services connect with no resampling step. The voice loop is the Alexa+ track submission: speech in, tool call, deterministic decision, speech out.
Challenges we ran into
The 49% that wasn't. For most of the run the log reported that about half
of all OCR'd rows were being set aside. That looked like a serious extraction
problem and it was the thing we most wanted to explain. It was not an
extraction problem — the denominator included page furniture. Every
Patient:, Date:, Batch: and Released by: line was being counted as a
test row that had failed to classify. Counting test rows against test rows,
extraction runs at 99%, and the two genuine failures are two rows you can
open and inspect. The fix was not a better parser; it was recording why each
row was set aside instead of only how many.
A rule that was too clever. The first version of the page-furniture matcher
used prefix matching, which meant a row named Patient satisfaction survey was
thrown away alongside Patient ref. A test written specifically to hunt for
that kind of over-reach caught it. Prefix matching is gone; the rule matches
whole field names only.
A test that passed against broken code. One regression test went green against a genuine bug, because its fixture happened to contain no urgent cases and the counter under test was zero either way. We now verify a test by deliberately re-breaking the code and watching it fail.
A sentence that was clinically wrong. An early phrasing template described
a case in patient_informed as "closed". It is not closed — it carries a
24-hour SLA and still has to reach closed. Describing an open case as
finished is the worst sentence a clinical tool can produce, and it shipped
before we caught it.
Silence as a failure mode. The first Nova Sonic bidirectional call opened
the stream, accepted audio, and returned nothing at all — no error, no empty
event. The model id was wrong (amazon.nova-sonic-v1:0 rather than
amazon.nova-2-sonic-v1:0) and nothing said so. A long session went into the
audio pipeline, where nothing was wrong.
A role for one service silently rewired another. Attaching an IAM instance
role so EC2 could call Textract made report extraction start returning zero
rows, with no error. build_extractor() chose its backend by asking whether
any AWS credentials were reachable; before the role there were none, so it
used the deterministic parser, and after the role it chose Bedrock — which this
account cannot call for text models. "Are there credentials?" is the wrong
question. "Can I call this specific model?" is the right one.
Bedrock text models are blocked account-wide — both sets of credentials,
both APIs, ValidationException: Operation not allowed. The probe scripts that
establish this are in the repository, and they report the error code actually
received, because the first version announced "AccessDenied on everything" when
every failure was in fact ResourceNotFound. Nova 2 Sonic's bidirectional
stream is reachable. The text models are not. The system does not need them —
which is precisely why it still works.
A frozen date nearly invalidated the evidence. The deployment was carrying
CARECHASER_SIMULATED_NOW from an earlier demo, so every case was being judged
against a date two weeks in the past. Had the operating run started in that
state, every timestamp in the evidence would have been fiction and nothing
afterwards could have separated the real from the simulated. The variable is
gone, and CARECHASER_CLOCK_LOCKED=1 now makes the clock controls refuse while
a run is recording.
What we verified
Nine days running unattended, every morning, 14 to 22 October 2026.
| Scanned reports read | 87 |
| Lines read off the pages | 518 |
| Page furniture (patient, date, batch, released-by) | 261 |
| Actual test rows | 257 |
| Test rows extracted into tracked cases | 255 (99%) |
| Genuine extraction failures | 2 |
| Unreadable files | 0 |
| Clinical values stripped before storage | 450 |
| Automated tests passing | 422 |
| MCP tools | 17 |
The claim a judge can falsify in a minute. From nine days of timestamps,
CareChaser works out each clinician's actual working hours. It has no access to
the answer: the real windows live in the generator's ward_profiles.json, and
a test fails the build if the inferring module so much as opens that file.
Scored against the truth, 4 of 4 recovered windows named no hour at which
nobody works, covering 86% of the real span. The under-coverage is the honest
part — nine days is a small sample, and an hour with no activity looks
identical to an hour nobody works. Both numbers are in the repo, and
evidence.py prints its working.
What this is not
Stated plainly, because you will find these anyway.
- The data is synthetic. No real patient data has been near this system. The generated reports are also more uniform than real scanned lab paper — Textract handles them well, which is weaker evidence than it looks.
- The breach and bottleneck statistics are simulator artifacts, not findings. The simulator advances cases by reading the board, and the board returns only cases that are already stuck, so nothing is ever touched before it breaches. Any claim of the form "X% of cases breach at stage Y" is a property of the simulator, not of CareChaser and certainly not of a hospital.
- SLA thresholds are engineering defaults, not hospital policy.
- The voice runs from a terminal, not in the browser, and there is no physical Alexa device in the loop.
- No cost saving is claimed. The literature measures the size of the problem, not the value of fixing it with this tool. A savings figure would need a hospital's own volumes and its own assumptions, and would be an invention here.
What we learned
That the honest number is usually more useful than the impressive one. "Half the rows were set aside" was the most alarming figure in the project and it was an artifact of our own counting. Recording why instead of how many turned the scariest number into the most informative one.
And that the hard part of putting a model near a ward is not making it capable. It is deciding, in writing and in advance, what it is never allowed to do.
What's next
Replace the synthetic generator with a real feed behind hospital authorisation. Move the roster into the database where it belongs. Put the Nova 2 Sonic stream in the browser so the voice and the board are one surface. And run the inferred-versus-actual marker over a longer window, where coverage has a chance to close.
CareChaser tracks workflow completion. It does not diagnose, interpret clinical values, decide clinical urgency, or replace an EHR or LIS.
Built With
- alexa-plus
- amazon-ec2
- amazon-nova-2-sonic
- amazon-polly
- amazon-textract
- amazon-web-services
- boto3
- caddy
- css
- fastapi
- html
- iam
- javascript
- json
- mcp
- model-context-protocol
- ocr
- pytest
- python
- streamable-http
- systemd
- yaml

Log in or sign up for Devpost to join the conversation.