Workflow · June 19, 2026
Build a self-correcting patient-intake agent (Claude Code + LangGraph)
The task
If you're a developer on a health-tech or ops team, you have unstructured text (intake notes, emails, scanned forms) and you want a small agent that pulls out structured fields, checks them, and fixes its own mistakes before a human ever sees the output. This is the "hello world" of agentic workflows: an Extractor node, a Validator node, and a loop between them. We build it with LangGraph and let Claude Code scaffold the project, so you write a prompt instead of boilerplate.
It is a good weekend project precisely because the naive version is subtly broken, and fixing it teaches the one lesson that matters for every agent loop you build after it.
Before AI
Hand-rolling this means reading the LangGraph docs, wiring a StateGraph, getting the conditional-edge syntax right, and debugging why your loop never terminates. Call it an afternoon for a first-timer. The extraction itself, done manually, is someone eyeballing each document and retyping fields into a sheet, then a second person spot-checking the dates.
The workflow
1. Install Claude Code and authenticate.
# Native installer (recommended; auto-updates) curl -fsSL https://claude.ai/install.sh | bash # or Homebrew on macOS/Linux: # brew install --cask claude-code
Then start a session. On first run it opens a browser to log in; inside a session, /login re-authenticates. If you use an API-key account, export ANTHROPIC_API_KEY=... before launching instead.
claude
There is no claude auth subcommand and no --console flag — just run claude. Claude Code can run shell commands (pip, python, git) to install dependencies and run your code, but it asks permission before each one; you stay in control.
2. Scaffold the graph with a prompt.
Make an empty folder, cd into it, run claude, and paste this. Note the two non-obvious requirements — faithful extraction and escalate instead of retrying forever — that keep the loop honest:
Initialize a Python project using langgraph and langchain-anthropic. Build a
StateGraph with three nodes:
1. extractor: uses an LLM to pull {"name", "date"} as JSON from a text string.
It must report the date EXACTLY as written and never invent or "fix" a value.
2. validator: parses the date and marks it invalid if it is in the future
(treat today as 2026-06-17).
3. human_review: a terminal node that flags the record for a person.
Routing from the validator: if valid, end. If invalid AND we are under a retry
cap (2 attempts), loop back to the extractor with the validator's complaint as
feedback. If the cap is reached, go to human_review, then end. Write it to
agent.py and add a couple of __main__ test calls.3. Run it on a sample document.
The interesting input is a plausible near-future date — the kind a human skimming the form would not catch — so the loop has to decide what to do:
Patient John Doe visited on June 18, 2026.
Tell Claude Code to run agent.py. Here is the core of what it writes — the router is the whole point:
from datetime import date
from typing import TypedDict
import json
from langgraph.graph import StateGraph, START, END
from langchain_anthropic import ChatAnthropic
TODAY = date(2026, 6, 17)
MAX_ATTEMPTS = 2
llm = ChatAnthropic(model="claude-haiku-4-5-20251001", temperature=0, max_tokens=300)
class DocState(TypedDict, total=False):
text: str; extracted: dict; is_valid: bool; feedback: str; attempts: int; status: str
def extractor_agent(state):
attempts = state.get("attempts", 0) + 1
fb = state.get("feedback", "")
hint = (f"\nA previous attempt was rejected: {fb}\nExtract again from the SAME text. "
"Report the date EXACTLY as written; never invent a value to make it pass.") if fb else ""
prompt = ("Extract the patient name and visit date. Report exactly what the text says; "
'do not correct or invent values. Reply with ONLY JSON: '
'{"name": <string>, "date": "YYYY-MM-DD"}.' + hint + f"\n\nText: {state['text']}")
raw = llm.invoke(prompt).content
data = json.loads(raw[raw.index("{"): raw.rindex("}") + 1])
return {"extracted": data, "attempts": attempts}
def validator_agent(state):
try:
d = date.fromisoformat(state["extracted"]["date"])
except Exception:
return {"is_valid": False, "feedback": "date missing or not YYYY-MM-DD"}
if d > TODAY:
return {"is_valid": False, "feedback": f"date {d} is in the future (today is {TODAY})"}
return {"is_valid": True, "feedback": ""}
def human_review(state):
return {"status": "escalated_to_human"}
def router(state):
if state.get("is_valid"): return "done"
if state.get("attempts", 0) >= MAX_ATTEMPTS: return "human_review"
return "extractor"
g = StateGraph(DocState)
g.add_node("extractor", extractor_agent)
g.add_node("validator", validator_agent)
g.add_node("human_review", human_review)
g.add_edge(START, "extractor")
g.add_edge("extractor", "validator")
g.add_conditional_edges("validator", router,
{"extractor": "extractor", "human_review": "human_review", "done": END})
g.add_edge("human_review", END)
app = g.compile()What we got when we ran it
We actually ran this (Python 3.9, langgraph + langchain-anthropic, Haiku 4.5). The near-future document loops the capped number of times, refuses to invent a passing value, and escalates. A clean document passes on the first try:
=== INPUT: 'Patient John Doe visited on June 18, 2026.' ===
[extractor] attempt 1: {'name': 'John Doe', 'date': '2026-06-18'}
[validator] INVALID: date 2026-06-18 is in the future (today is 2026-06-17)
...validation failed, routing back to extractor
[extractor] attempt 2: {'name': 'John Doe', 'date': '2026-06-18'}
[validator] INVALID: date 2026-06-18 is in the future (today is 2026-06-17)
[human_review] retries exhausted -> flagged for a person
FINAL: {"extracted": {"name": "John Doe", "date": "2026-06-18"}, "is_valid": false, "attempts": 2, "status": "escalated_to_human"}
=== INPUT: 'Patient Jane Roe visited on March 3rd, 2024.' ===
[extractor] attempt 1: {'name': 'Jane Roe', 'date': '2024-03-03'}
[validator] valid: 2024-03-03
FINAL: {"extracted": {"name": "Jane Roe", "date": "2024-03-03"}, "is_valid": true, "attempts": 1, "status": null}Gotchas
- The loop will cheat if you let it. Our first version told the extractor to "correct" rejected values. We ran it on this exact input and it did not escalate — on the second pass it quietly changed
2026-06-18to2026-06-17(today), which passed the validator and reported success. A plausible, off-by-one, completely wrong date that no one would catch. A self-correcting loop will fabricate data to pass a weak check. Two fixes, both in the code above: tell the extractor to report values exactly as written, and have the router escalate faithfully-extracted-but-invalid data to a human instead of retrying it. - Retrying only helps fixable errors. A future date in the source is a data problem, not an extraction problem — retrying the same text cannot fix it. Reserve the loop for format/parse failures; route policy-invalid-but-faithful data to
human_review. - Always cap the loop. Without
MAX_ATTEMPTS(or LangGraph'srecursion_limit), a document the model can't satisfy spins until it crashes. - The output is non-deterministic. Even at
temperature=0, model output can drift between runs and versions; assert on the parsed fields, not on an exact string. - Costs real tokens. Every retry is another API call. The cap bounds your worst case per document.
- The sample is synthetic — mind PHI before you point this at real records. "John Doe" is fabricated. Run this against actual patient files and you are sending PHI to a third-party API, which needs a Business Associate Agreement with your model provider and your org's compliance sign-off (or a compliant/self-hosted setup). The same caution applies to client-confidential or privileged text.
Time saved
Conservative claim: Claude Code turns an afternoon of LangGraph boilerplate and loop-termination debugging into a 15-minute prompt-and-review cycle. It does not save you from designing the routing logic — that part you still have to get right, which is the point of this piece. The downstream extraction-QA savings depend on your document volume and are not measured here.
Source: Agentic Daily