technology

The Agent That Understands Healthcare

Proposals over answers, forced tools over chat, and the two bugs that survived a green test suite

Sathyan··18 min read
An AI agent examining healthcare records through structured comparison, with identity resolution proposals flowing between parsed data and a review gate

The parser from Part 4 reads an X12 834 because the 834 tells it how. Position 3 is the element separator. INS01 is the subscriber indicator. DTP*349 is the benefit end date. Every element has a position, every position has a meaning, every meaning is defined by a specification written before most of us were born.

Then someone emails you a CSV.

EMP_LAST, EMP_FIRST, BIRTH_DT, COV_START, COV_END, PLAN, SSN

No ISA header. No segment terminators. No specification. Just column names that someone at a regional employer's HR department typed into an Excel export because that's how they've always done it. The parser from Part 4 is useless here. There is no structure to parse — only intent to interpret.

That's one problem. Here's the other.

The 834 parser stored three hundred members into the canonical model. Person rows. Identifier rows. Coverage rows. One of those members is named Priya Kumar — date of birth March 15, 1990, SSN last four 6789. The database already has a Priya Sharma. Same date of birth. Same SSN last four. Different last name. Name change after marriage? Two different people who happen to share a birthdate and four digits? The parser can't tell you. The schema from Part 3 can store both answers. Someone has to decide.

These are the two problems Part 5 solves. Neither has a deterministic answer.

The Agent Doesn't Chat

The word "agent" in AI conjures chatbots — multi-turn conversations, chains of thought, tool calling loops that run until the model decides it's done. The agent in this platform does none of that.

It receives a structured prompt. It calls exactly one tool. It returns a structured response. One turn. No conversation. No autonomy beyond the single decision it's asked to make.

response = self._client.messages.create(
    model=self._config.model,
    max_tokens=self._config.max_output_tokens,
    temperature=0.0,
    system=system_prompt,
    messages=[{"role": "user", "content": user_prompt}],
    tools=tools,
    tool_choice={"type": "tool", "name": "submit_resolution"},
)

tool_choice is the load-bearing line. {"type": "tool", "name": "submit_resolution"} tells the model: you will call this specific tool. You will fill in every field it requires. You will not respond with text. You will not hedge. You will not ask for clarification. You will fill out the form.

This is the Anthropic SDK's forced tool use. The response comes back as structured JSON matching the tool's schema — a decision, a confidence score, a reasoning string, a field-by-field comparison. No parsing surprises. No "the model said something unexpected." The schema is the contract, and the contract is enforced.

The agent uses the raw Anthropic SDK — no LangChain, no LlamaIndex, no agent framework. One module imports anthropic. Every other module in the agent layer works with Pydantic models and plain strings. That boundary is what makes the layer testable without mocking the API: tests inject a fake client that returns canned tool-call responses, and the resolver, mapper, and orchestrator all run against real logic with no network dependency.

Temperature is zero. Every call is meant to be deterministic given the same input. The agent layer processes enrollment files, not conversations — consistency matters more than creativity. Two runs of the same file should produce the same proposals, so the gate layer doesn't have to account for randomness in the thing it's evaluating.

The Data That Never Leaves

The code block above sends person records — names, dates of birth, SSN last four — to an API. In healthcare, that's protected health information. The question a reader should be asking right now: where does that data go?

In this series, nowhere real. Every person record in the test suite and every example in these articles is synthetic. No actual PHI has touched the Anthropic API. The build-in-public series builds the architecture, not the production deployment.

In production, the answer depends on the organization's risk tolerance:

PathWhat It MeansTrade-off
BAA with AnthropicBusiness Associate Agreement. PHI is encrypted in transit, not used for training. Anthropic becomes a covered entity under HIPAA.Simplest path. Some organizations won't accept PHI leaving the network regardless.
Local SLMRun a model on-premises — Llama, Mistral, Phi. All PHI stays inside the firewall. No BAA needed for the model provider.Smaller models are worse at nuanced reasoning. But identity resolution is a narrow task — a fine-tuned 8B model may do it well enough.

The architecture makes the deployment decision a one-module swap. AgentClient is the only class that imports anthropic. The resolver doesn't know it's talking to Claude. The mapper doesn't know. The orchestrator doesn't know. They all work with Pydantic models and plain strings.

Point AgentClient at a local vLLM endpoint running a fine-tuned Llama behind localhost:8000, change the client initialization, and every other module — the prompts, the tools, the forced tool choice, the proposal pipeline — runs without modification. The same boundary that makes the layer testable without an API key makes it deployable without an external API.

The production deployment — local model selection, fine-tuning on healthcare identity patterns, performance benchmarking against the hosted API — is a separate engineering effort. One worth its own series.

Teaching the Agent What We Built

A general-purpose language model knows what a date of birth is. It doesn't know that this platform stores coverage as a half-open interval, or that an identifier is meaningless without its assigning authority, or that INS05 T means TEFRA — continuing coverage, not termination.

Those are our design decisions from Part 3. If the prompt doesn't carry them, the agent guesses at our schema. Here's what the field mapping system prompt tells the agent about dates:

Our canonical model uses half-open intervals: [valid_from, valid_to). An end date like "12/31/2026" (the last covered day) must be stored as 2027-01-01. If a column appears to be an end/termination date, note in sample_transformation whether the source values are inclusive (need +1 day) or exclusive (use as-is).

The canonical model itself is not hardcoded in the prompt. It is generated at runtime by introspecting the Pydantic models:

def canonical_model_description() -> str:
    excluded = {"id", "created_at", "updated_at",
                "recorded_at", "superseded_at"}
    sections: list[str] = []
 
    for model_cls in (Person, PersonIdentifier, Coverage):
        hints = get_type_hints(model_cls, include_extras=True)
        lines: list[str] = [f"{model_cls.__name__}:"]
        for name, field in model_cls.model_fields.items():
            if name in excluded:
                continue
            lines.append(
                _field_description(name, field, hints.get(name, "unknown"))
            )
        sections.append("\n".join(lines))
 
    return "\n\n".join(sections)

If a field is added to Person in Part 7, the next agent call includes it. No manual prompt update needed, no drift between the model and the prompt. Internal fields — id, created_at, recorded_at — are excluded because the agent never maps to them. A field the agent shouldn't touch is a field the agent shouldn't see.

The resolution system prompt needed more work than the field mapping one. A language model has strong priors about names and dates, but it doesn't know how we store identity. The prompt teaches six healthcare-specific patterns: name changes after marriage, name truncation by legacy systems, transposed digits in dates of birth, SSN sharing within families, cultural naming conventions where family and given names swap positions, and suffix variations.

Each pattern is a case where two records look different but describe the same human. The prompt doesn't just list them — it explains why each one happens in healthcare data specifically, so the agent weighs them correctly. A married name change is routine. An SSN shared between a parent and child is a data artifact, not proof they're the same person.

Narrowing the Search Before Asking the Agent

An agent call costs time and money. Running it against every person in the database would be absurd — a file with three hundred members against a database of fifty thousand persons means fifteen million comparisons. The agent should only see records with a realistic chance of matching.

The candidate finder runs three tiers of database queries, ordered from strongest to weakest signal:

TierWhat It MatchesWhy It's Credible
1SSN last four + date of birthTwo people rarely share both
2Exact date of birth + same last name (case-insensitive)Catches the married-name scenario
3Exact first + last name + DOB within one yearCatches DOB typos when everything else matches

Each tier adds candidates the previous tiers missed. The results are deduped — a person who appears in both Tier 1 and Tier 2 goes to the agent once — and capped at five candidates per incoming record. Five is generous. In enrollment data, a genuine match is almost always in the first two.

# Tier 3: name match + DOB within one year
_TIER_NAME_DOB_CLOSE = """
    SELECT id, first_name, last_name, middle_name, date_of_birth,
           gender, ssn_last_four, created_at, updated_at
    FROM persons
    WHERE LOWER(first_name) = LOWER($1)
      AND LOWER(last_name) = LOWER($2)
      AND date_of_birth BETWEEN ($3::date - INTERVAL '1 year')::date
                            AND ($3::date + INTERVAL '1 year')::date
      AND id != $4
    LIMIT $5
"""

The $3::date cast in Tier 3 is load-bearing, not decoration. Without it, Postgres has no type for the parameter and resolves $3 - INTERVAL '1 year' through the interval - interval operator — inferring $3 as an interval, then failing with "cannot cast type interval to date." The query is syntactically valid and passes every linter. It throws on every execution. More on that later.

Before a candidate reaches the agent, the finder scores them deterministically — how many demographic fields match exactly. This score doesn't decide anything. It shows up in the trace, so a human reading the observability data sees "candidate scored 0.8 deterministically before the agent ran" and can judge whether the agent's response makes sense relative to the evidence.

The Conservative Principle

The resolution prompt's most important paragraph isn't about names or dates. It's about consequences:

A wrong merge is worse than a missed merge. Two records for the same person is an inconvenience that a later review can fix. One record for two different people is a safety incident — wrong claims, wrong medications, wrong history attached to the wrong human. When genuinely uncertain, say "uncertain".

This is the principle that governs every healthcare identity system worth trusting. The asymmetry is severe — a missed merge means duplicate records, extra paperwork, maybe a phone call to reconcile. A wrong merge means one person's medical history is attached to someone else. Claims processed against the wrong record. Medications listed for the wrong human. A safety incident.

The prompt encodes this as a calibrated confidence scale:

ConfidenceWhat It MeansExpected Decision
0.95+Multiple independent fields agree (DOB + SSN + similar name)match
0.8–0.95Strong match with one explainable divergence (name change, typo)match
0.6–0.8Plausible but multiple uncertaintiesuncertain
Below 0.6Insufficient evidenceno_match

Three decisions. Not two. The uncertain band — the 0.6 to 0.8 range — is the one that matters most. And it's the one that almost didn't work.

A wrong merge is worse than a missed merge. The asymmetry is severe — it's the difference between duplicate paperwork and the wrong medical history attached to the wrong human.

Proposals, Not Answers

The agent proposes. It does not act.

Every comparison produces a ResolutionProposal — a structured record containing the decision, the confidence, the reasoning, and a field-by-field breakdown of what matched and what didn't. The proposal is stored in the database. Nothing else happens.

No identity link is created. No persons are merged. No coverage is reassigned. The agent fills out the form, and the form goes into a queue.

proposal = ResolutionProposal(
    incoming_person_id=str(incoming.id),
    candidate_person_id=str(candidate.id),
    decision=ResolutionDecision(tool_input["decision"]),
    confidence=float(tool_input["confidence"]),
    reasoning=str(tool_input["reasoning"]),
    field_comparisons=field_comparisons,
)

This is the proposal-gate pattern. Part 5 builds the proposer. Part 6 builds the gate — the automated rules that evaluate whether a proposal should be approved, rejected, or escalated to a human. Until Part 6 arrives, the gate is a reviewer who opens the pending proposals endpoint, reads the agent's evidence, and clicks approve or reject.

The proposal-gate pattern — the agent writes proposals, a reviewer creates links · Click chart to expand

Resolution runs after the canonical write, and only when the caller asks for it. POST /ingest?resolve=true is the opt-in. The members are already committed when the agent starts, so a rate limit, a missing API key, or any agent failure costs proposals and nothing else. The file finishes mapped regardless of whether resolution succeeded, partially failed, or was never attempted. A parser that blocks on an API call it can't control turns an infrastructure problem into a data problem.

The approve endpoint wraps the identity link insert and the proposal claim in a single database transaction. Without that, two concurrent reviewers leave orphan state — Reviewer A inserts a link, finds the proposal already claimed, gets a 409, but their link is already in the database pointing at nothing that records why it exists. One transaction, one outcome. The link exists because the proposal was approved, and both facts commit together or not at all.

The Decision That Almost Didn't Exist

When the first build session produced the agent layer, the pending proposals endpoint returned only match decisions. The uncertain band — the 0.6 to 0.8 confidence range that the prompt specifically describes as "needs human review" — was filtered out of the review queue.

The prompt told the agent: "when you're not sure, say uncertain rather than guess." The agent did exactly that. And the system threw it away. An uncertain proposal became a dead letter — stored in the database, invisible to every endpoint, unapproved and unaprovable. The approve endpoint rejected it with a 400 because uncertain wasn't in the approvable set.

The conservative principle told the agent to produce uncertain decisions. The API told the system to ignore them. Two pieces of code, each correct on its own, combining to break the design.

The fix was two lines. Add "uncertain" to APPROVABLE_DECISIONS. Include uncertain proposals in the pending query.

The hard part was the comment that had to go with it:

# `uncertain` is in here deliberately. The resolution prompt
# describes the 0.6-0.8 band as "needs human review" and tells
# the agent to say uncertain rather than guess — so uncertain
# is the agent working correctly, and a human looking at the
# evidence and deciding "yes, same person" is exactly the
# disposition that band was routed here for.

The comment exists because someone reading the code a year from now will ask the same question the first build session didn't: why is uncertain approvable? The answer is load-bearing. Remove it, and the conservative principle is just words in a prompt the system doesn't honor.

What Ninety-Eight Tests Didn't Catch

The first build session wrote ninety-eight tests for the agent layer. All passed. All ran in 0.07 seconds. That speed should itself have been the signal.

Nothing that fast had talked to anything real.

The $3::date cast. Tier 3 of the candidate finder queries persons whose date of birth is within one year of the incoming record's. The SQL was syntactically valid. Mypy accepted it. Ruff accepted it. The mocked tests accepted it — a mocked asyncpg connection will happily execute any SQL you hand it and return whatever you told it to. The query threw cannot cast type interval to date on every execution against a real Postgres instance. Tier 3 silently never matched anyone.

The orchestrator catches per-member exceptions, so files still ingested fine. The agent just never saw candidates it should have. A finder that quietly drops a search tier doesn't announce that it missed someone — it announces that it found no one, which reads exactly the same.

The field_values dict. Every approval — every single one — would have returned HTTP 500. The route passed the raw field_comparisons array from the proposal row into link_persons(field_values=...), whose model field is typed dict[str, Any]. Pydantic raised a validation error on every call. Mypy saw Any from the database row and waved it through. The only approve tests were 404, 409, and 400 cases — no test had ever successfully approved a proposal.

Both bugs survived a green test suite for the same reason: every test that touched them replaced the component that would have failed. A mocked link_persons accepts a list where a dict belongs. A mocked asyncpg connection accepts SQL that Postgres rejects. The mocked tests were correct about the logic. They said nothing about the integration.

There was a third problem, quieter than the other two. The observability span helpers for the resolution and field-mapping agents had been written with zero call sites. grep -rn agent_resolution_span src/ returned one line: its own definition. The agents ran untraced. Every call was invisible in Jaeger. The code looked complete because the helpers existed. They just weren't called from anywhere.

For a component whose absence is silent — a span, a log, a metric — the test that matters is the one asserting somebody calls it. A span helper cannot fail loudly. It can only be absent. A test that drives the real resolve() method and asserts a span came out catches the disconnect between "the helper exists" and "the helper runs."

Forty-two new tests against a live Postgres instance — not mocked, not faked — pinned all three fixes. The candidate finder's tiers now execute real SQL. The approve flow writes a real link. The resolver and mapper emit real spans. The things that mocked tests couldn't catch are the things integration tests exist for.

What the Pipeline Holds

95
Source files
605
Tests
2
Agent tasks

The platform now has an AI layer, and it runs on the raw Anthropic SDK — no framework, no abstraction, no magic. One module imports anthropic. Everything else is Pydantic models, SQL queries, and the same five-layer architecture from Part 2.

The agent handles two tasks:

TaskInputOutput
Field mappingColumn headers + sample rows from an unknown CSVA proposed mapping to canonical fields, with per-column confidence
Identity resolutionTwo person records (incoming vs. candidate)A proposal: match, no_match, or uncertain — with field-by-field reasoning

Both produce proposals, not actions. The confidence score is stored and — honestly — not yet acted on. Nothing thresholds on it. We have a number, and no evidence yet about whether it's calibrated enough to make decisions with. That evidence comes from production use, and Part 6's gate needs it before it can automate approvals.

What the agent layer adds to the ingest pipeline is opt-in intelligence — a ?resolve=true parameter that asks the question "does this person already exist?" without letting the answer block the enrollment. The parser reads the file. The mapper translates it. The agent proposes connections between what was just written and what was already there. Every proposal goes to the review queue. The forty-seven failed records from Part 1 still haven't been fixed — the gate layer that enforces payer-specific business rules is Part 6's territory. But the system can now look at a record and say, with reasoning attached, whether it thinks it has seen that person before.

In Part 6, the proposals get a judge. Deterministic rules evaluate what the agent proposed — confidence thresholds, field-match requirements, payer-specific overrides. The human reviewer becomes the exception handler, not the first line of defense. The gate answers a different question than the agent: not "do these records look like the same person?" but "is the evidence strong enough to act on that belief?"

The agent proposes. The gate disposes. Neither sees the other's work, and neither trusts the other's judgment. That independence is the safety property.

Enjoyed this?

Get new articles delivered to your inbox. No spam, unsubscribe anytime.

Related Articles

More from Narchol