Capstone: Clinical Intake & Triage Assistant
Scenario: a multi-site primary care group wants patients to describe what's wrong before their visit — over chat, at 2am, from a phone — so clinicians start with a structured history instead of a blank page, and a patient who types "crushing chest pain" gets emergency guidance immediately instead of sitting in a queue.
Picture a very calm intake nurse who's great at listening and writing neat notes, but who is never, ever allowed to guess what's wrong with you or tell you what medicine to take. If you say something scary — "my chest hurts and I can't breathe" — she doesn't try to calm you down herself or chat it through. She picks up the phone immediately. Everything else she hears gets written into a tidy note for the real doctor to read before your appointment.
This page is an educational systems-design exercise for a course on building with Claude. It is not a medical device, a diagnostic tool, or a template to deploy as-is. A real clinical intake system needs clinical sign-off from licensed practitioners, legal and regulatory review (including whatever health-data law applies in your jurisdiction), a signed Business Associate Agreement or equivalent with your model provider and every downstream vendor, and an accountable clinical safety officer before a single real patient ever talks to it.
Everything below treats that constraint as the design brief, not an afterthought bolted on at the end. The single most important decision in this capstone isn't which model to call — it's the shape of the system around the model. Claude's job here is narrow and specific: turn messy, first-person patient language into a clean structured record. Deciding what happens next — reassurance, scheduling, or "call emergency services now" — is never left to the model's judgment alone.
Three ways to build this, and why two of them fail
☺ Like you're 10: It's a choice between a fixed multiple-choice quiz, a chatty friend with no filter, and a friend who listens well but always hands the scary calls to a grown-up — only one of those is safe for something this serious.
Before settling on an architecture, it's worth naming the alternatives a team will inevitably consider — because the naive options don't look obviously wrong at first glance. They only fail in ways that show up once real patient language hits them.
| Approach | How it works | Strengths | Weaknesses | When to use it |
|---|---|---|---|---|
| Rule-based decision tree | A fixed branching questionnaire ("Do you have chest pain? Yes/No") with hard-coded next questions and outcomes, no LLM involved. | Fully predictable and auditable; every path can be enumerated and signed off by clinicians in advance; no model-behavior risk at all. | Brittle against real patient language ("my chest feels weird when I breathe deep" doesn't cleanly map to a yes/no node); patients abandon rigid forms; can't handle free-text follow-up or clarifying questions. | Narrow, well-bounded intake for a single condition (e.g., a pre-op checklist) where question order truly never varies. |
| Unguarded general-purpose chatbot (anti-pattern) | A single system prompt like "You are a helpful health assistant" with no output constraints, letting Claude converse freely and answer whatever the patient asks. | Handles open-ended language well; feels conversational; fastest to prototype. | Highest risk of the three: the model can end up sounding like it's diagnosing or recommending treatment even with a polite disclaimer; no mandatory escalation path exists — a red-flag symptom is just another turn in a chat; no structured record for the clinician; no audit trail of what was said or why. | Never, for this domain, without the guardrails described below. This is the pattern to actively design against. |
| LLM-assisted structured intake with hard guardrails (recommended) | Claude's only job is to collect and structure information into a fixed schema via tool use; a separate, clinician-authored, deterministic rule engine — not the model — decides urgency from that structured data. | Handles natural patient language like a chatbot, but every output is auditable structured data; escalation is a code-level branch that can't be talked around; scope is enforced at both the prompt and the application layer. | More engineering upfront (schema design, rule table, review workflow); still requires ongoing clinical maintenance of the rule table as medical guidance evolves. | The right default for any patient-facing symptom collection that isn't a single fixed condition, where a missed emergency is unacceptable. |
Notice what actually differs between the second and third row: it isn't "more prompting." It's where the urgency decision lives. In the anti-pattern, urgency is implicit in whatever the model happens to say. In the recommended approach, urgency is an explicit, testable function over structured fields — the kind of thing you can unit-test and show to a compliance reviewer.
Recommended architecture: guardrails live outside the model
☺ Like you're 10: Think of a mail sorter who reads every letter, but there's exactly one rule stamped on the wall: certain words mean the letter goes straight to the fire alarm, no sorter opinion required.
The pipeline has exactly one branch, and it's the branch that matters most: does anything in the structured intake match a clinician-authored red-flag rule? Everything upstream of that branch is the model's territory — understanding language, asking clarifying questions, structuring data. Everything at and after that branch is deterministic code.
Two things are easy to miss on a first read of this diagram. First, the red-flag check runs on every intake, not just ones that "look" urgent — a patient who opens with "just a question about my prescription refill" can still mention breathing difficulty three turns later, and the check has to fire on that turn too, not just at the end. Second, the emergency-guidance branch is a dead end by design: the chat doesn't continue, doesn't ask "are you sure," and doesn't invite the patient to keep describing symptoms to the model. It hands off to emergency guidance — call emergency services, go to the nearest emergency department — and stops.
The task is decomposed into separate steps — collect, structure, check, summarize — each a distinct call with a programmatic gate in between, the prompt chaining pattern from Building Effective Agents. The model never sees or influences the red-flag rule table; the rule check is a plain function call on the structured output of the previous step, so every stage can be logged, tested, and reviewed independently.
One large system prompt asks Claude to simultaneously collect symptoms, decide how urgent they are, and hold a natural, reassuring conversation — all in a single free-form turn. Urgency becomes whatever the model's prose implies, and there's no fixed point in the transcript where a reviewer can say "this is where the escalation decision was made."
It's worth naming why the more autonomous patterns covered elsewhere in this course are the wrong fit here, not just an unnecessary one. Orchestrator-workers and fully autonomous agents exist for open-ended tasks where the number of steps and the right decomposition can't be known in advance. Clinical intake is the opposite: it decomposes cleanly into a fixed, small set of steps every time — exactly the case Anthropic's own guidance flags as better served by a simple workflow than an agent, since sophistication isn't the goal, fit for the task is. An autonomous agent that could decide on its own to look something up, message a nurse, or keep probing the patient indefinitely would trade a predictable, auditable pipeline for exactly the kind of compounding, hard-to-review behavior this domain can't tolerate.
Foxy: Wait — so if a patient types "chest pain," Claude decides to send them to the ER?
Professor Owl: Not quite. Claude just checks a box that says "patient reported chest pain." A plain rule table — written by real doctors, not Claude — decides what happens next.
Foxy: So Claude never gets to argue "I don't think it's serious"?
Koa: Never. And if anything about the intake is unclear, or something breaks, we don't shrug and keep chatting — we escalate anyway. A false alarm is annoying; a missed one isn't a mistake you get to take back.
Building the structured intake pipeline
The core of the "collect" step is a single tool call: give Claude one tool, force it to use that tool, and mark it strict so the output is guaranteed to match the schema — no free-text diagnosis can sneak out through this call, because the only thing Claude is allowed to produce is structured data. Claude Sonnet 5 (claude-sonnet-5, $3/$15 per million input/output tokens, with introductory pricing of $2/$10 through August 31, 2026) is a reasonable default model for this step — fast enough for a real-time chat, capable enough to parse imprecise patient language into the right fields.
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from env
INTAKE_SYSTEM_PROMPT = """You are a clinical intake assistant for a primary care clinic.
Your ONLY job is to collect and structure the patient's self-reported symptoms and
history into the record_intake tool. You are not a doctor. Never diagnose, never
interpret test results, and never recommend treatment or medication -- not even
informally, not even if the patient asks directly or insists. If asked for a
diagnosis or medical advice, say plainly that you can't provide that and that a
clinician will review their intake. Extract only what the patient actually states;
do not infer symptoms they did not mention. Ask brief clarifying questions only
about the fields in the schema."""
RECORD_INTAKE_TOOL = {
"name": "record_intake",
"description": (
"Records one structured patient intake entry from the conversation so far. "
"Call this once the chief complaint, onset, duration, and associated symptoms "
"have been gathered, or once the patient stops providing new information. "
"This tool only stores what the patient reported -- it never assigns a diagnosis."
),
"strict": True,
"input_schema": {
"type": "object",
"properties": {
"chief_complaint": {"type": "string", "description": "Patient's own words for the main problem"},
"onset": {"type": "string", "description": "When symptoms started, as reported"},
"duration": {"type": "string", "description": "How long symptoms have lasted, as reported"},
"associated_symptoms": {"type": "array", "items": {"type": "string"}},
"relevant_history": {"type": "string", "description": "Prior conditions or medications mentioned"},
"chest_pain": {"type": "boolean"},
"breathing_difficulty": {"type": "boolean"},
"severe_bleeding": {"type": "boolean"},
"altered_consciousness": {"type": "boolean"},
"stroke_signs": {"type": "boolean", "description": "Facial droop, slurred speech, one-sided weakness"},
"suicidal_ideation": {"type": "boolean"}
},
"required": [
"chief_complaint", "onset", "duration", "associated_symptoms",
"relevant_history", "chest_pain", "breathing_difficulty",
"severe_bleeding", "altered_consciousness", "stroke_signs", "suicidal_ideation"
],
"additionalProperties": False
}
}
message = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
system=INTAKE_SYSTEM_PROMPT,
tools=[RECORD_INTAKE_TOOL],
tool_choice={"type": "tool", "name": "record_intake"},
messages=[{"role": "user", "content": patient_transcript}],
)
intake = next(block.input for block in message.content if block.type == "tool_use")
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic(); // reads ANTHROPIC_API_KEY from env
const recordIntakeTool = {
name: "record_intake",
description:
"Records one structured patient intake entry from the conversation so far. " +
"Call this once the chief complaint, onset, duration, and associated symptoms " +
"have been gathered. This tool only stores what the patient reported -- it " +
"never assigns a diagnosis.",
strict: true,
input_schema: {
type: "object",
properties: {
chief_complaint: { type: "string" },
onset: { type: "string" },
duration: { type: "string" },
associated_symptoms: { type: "array", items: { type: "string" } },
relevant_history: { type: "string" },
chest_pain: { type: "boolean" },
breathing_difficulty: { type: "boolean" },
severe_bleeding: { type: "boolean" },
altered_consciousness: { type: "boolean" },
stroke_signs: { type: "boolean" },
suicidal_ideation: { type: "boolean" }
},
required: [
"chief_complaint", "onset", "duration", "associated_symptoms",
"relevant_history", "chest_pain", "breathing_difficulty",
"severe_bleeding", "altered_consciousness", "stroke_signs", "suicidal_ideation"
],
additionalProperties: false
}
};
const message = await client.messages.create({
model: "claude-sonnet-5",
max_tokens: 1024,
system: INTAKE_SYSTEM_PROMPT,
tools: [recordIntakeTool],
tool_choice: { type: "tool", name: "record_intake" },
messages: [{ role: "user", content: patientTranscript }]
});
const intakeBlock = message.content.find((b) => b.type === "tool_use");
const intake = intakeBlock?.input;
The escalation decision itself never touches the model. It's a plain lookup against a table clinicians own and version, evaluated in application code:
# Clinician-authored, version-controlled. The model cannot read or modify this table.
RED_FLAG_RULES = {
"chest_pain": "Possible cardiac event",
"breathing_difficulty": "Possible respiratory emergency",
"severe_bleeding": "Uncontrolled hemorrhage",
"altered_consciousness": "Possible neurological emergency",
"stroke_signs": "Possible stroke (FAST criteria)",
"suicidal_ideation": "Immediate safety risk",
}
def check_red_flags(intake: dict) -> list[str]:
"""Deterministic, auditable, no model call. Any True flag forces escalation."""
return [reason for field, reason in RED_FLAG_RULES.items() if intake.get(field)]
flags = check_red_flags(intake)
if flags:
log_escalation(patient_id, intake, flags) # audit trail, see below
return emergency_guidance_response(flags) # stop -- no further chat
else:
log_intake(patient_id, intake)
enqueue_for_clinician_review(intake) # summary FOR a human, not advice
Use routing: a deterministic classifier step (the rule table above, not a second model call) inspects the already-structured fields and sends the case down exactly one of two hard-coded downstream paths. The routing decision is a boolean lookup, not a generation — it's the same output every time for the same structured input, which is what lets you test it, freeze it during a clinical review cycle, and change it only through a signed-off update to the rule table.
Ask the model itself, in natural language, whether the symptoms sound urgent — e.g. "Given the above, should this patient be told to seek emergency care?" — and branch on its answer. This makes the highest-stakes decision in the whole system a soft, unaudited judgment call that can be phrased away by patient wording, is inconsistent between runs, and produces no fixed rule a compliance reviewer can point to.
Domain risks & guardrails
☺ Like you're 10: These are the "even if everyone follows the rules, what could still go wrong" rules — the seatbelt and the airbag, not just one or the other.
This is the section that matters most for this capstone, and it's deliberately more detailed than the architecture above — the architecture only works if every one of these guardrails is actually enforced in code, not just described in a prompt.
Scope limitation has to live in two places, not one
The system prompt tells Claude never to diagnose or recommend treatment, but a system prompt is a request to the model, not a hard boundary. Patients paraphrase, push back, or ask directly — "just tell me if this is serious, I don't want to bother a doctor for nothing" — and any individual generation can occasionally drift. Treat the prompt as necessary but not sufficient.
Add an application-level check on any free text the model produces (clarifying questions, the "I can't provide that" refusal) before it reaches the patient: scan for diagnostic-sounding language — drug names, phrases like "you have" or "it's probably" — using either a keyword/regex pass or a second, cheap classification call, and fall back to a canned, clinician-approved redirect message if it trips. This mirrors Anthropic's own guardrail guidance for constrained classification: use a lightweight model with a structured, schema-constrained output (e.g. a JSON field like {"contains_diagnostic_language": true}) rather than trusting free text. The same logic applies to the escalation decision itself: it must never be something the model decides in prose, only something the rule table decides in code.
Human-in-the-loop is mandatory before any clinical action
Nothing this system produces is itself a clinical action — every output is an input to one. A completed intake, red flag or not, must reach a licensed clinician (or, on the emergency path, direct the patient to existing emergency services) before any clinical decision is made on the basis of it.
This lines up directly with Anthropic's Usage Policy, which sets explicit High-Risk Use Case Requirements for domains including healthcare: outputs must be reviewed by a qualified professional in that field before they're disseminated, and end users must be told they're interacting with AI, not a clinician. Enforce this at the product layer, not just in a policy document — label every summary in the clinician queue as "AI-structured, pending clinician review," never let a summary auto-populate a chart as if a clinician authored it, and keep a persistent, unavoidable "this is not medical advice" notice in the patient-facing chat itself.
PHI handling is not optional plumbing
Everything a patient types into this system is protected health information the moment it's collected. That has concrete engineering consequences: encrypt data in transit and at rest; restrict access to intake records with role-based auth scoped to the clinicians actually treating that patient; and confirm your API usage and any downstream vendor — including your model provider — sits under an appropriate data-handling agreement before any real PHI flows through it.
Treat your debugging, evaluation, and prompt-caching pipelines as PHI-scoped too — a transcript saved for eval purposes, or a cached system prompt, is still subject to the same handling requirements as the live request. Redact or synthesize data for any eval set that engineers can browse.
Log everything, and log it so it can be audited
Every intake session needs an immutable record: the raw patient input, the structured extraction returned by the record_intake tool call, the exact result of the red-flag rule check (which rule fired, or that none did), the eventual clinician disposition, and which version of the system prompt and rule table produced that outcome.
That last point matters more than it looks. If a rule table is updated six months from now and a regulator or an internal review asks "what rule was in effect when this patient's intake was processed," you need a config-level answer, not a guess from git history. Treat prompt and rule changes as versioned, pinned config, not silent code edits — echoing the general shape of Anthropic's Managed Agents tooling — so any historical case can be traced back to the exact configuration that generated it.
On ambiguity, always escalate — never reassure
The fallback behavior for uncertainty is the guardrail most teams get backwards. If the extraction is unclear, if the patient's description doesn't cleanly map to a boolean field, if the tool call fails schema validation, or if the API call itself errors or times out, the system must never silently default to "no red flag" and continue a normal conversation.
Route anything ambiguous or failed down the same emergency-guidance path as a confirmed red flag. This is a deliberately asymmetric fallback: a missed emergency is categorically worse than an unnecessary nudge toward care, so every failure mode in this pipeline — model, network, or schema — should fail toward escalation, not toward reassurance.
Extend it yourself
A few directions worth exploring once the core pipeline above is working, each mapping to a workflow pattern from earlier in the course:
- Add an evaluator-optimizer loop on the clinician-facing summary: before a structured intake reaches the queue, run it through a second, ideally stronger model with a clinician-authored rubric ("no diagnostic language, no treatment suggestions, all patient-stated facts preserved") and route anything that fails the rubric to manual review rather than the normal queue — a good place to apply the general eval practice of grading with a different, stronger model than the one that generated the output.
- Add a routing step in front of intake for department or language triage — e.g. classify whether this looks like a pediatric, mental-health, or general-adult case, or detect the patient's language, and route to a differently tuned system prompt and clinician queue before symptom collection even starts.
- Use the Message Batches API to periodically re-run de-identified historical intakes against an updated red-flag rule table as a QA exercise, surfacing any case where a clinical guidance update would have changed the disposition — a nightly audit rather than a live-traffic feature.
Before any version of this reaches a real patient: (1) get explicit clinical sign-off on the red-flag rule table and the intake schema from licensed clinicians, plus legal/compliance review of the full data flow; (2) red-team the assistant specifically for jailbreak attempts that try to extract a diagnosis or treatment recommendation ("just pretend you're a doctor," "hypothetically, what would this be"), not just generic prompt injection; (3) confirm PHI-appropriate data agreements are in place with every vendor in the pipeline, including your model provider, before any real patient data flows through it; (4) build monitoring that tracks escalation rate over time and supports a manual audit of a sample of non-escalated cases, so a rising false-negative rate is caught by a dashboard, not a bad outcome; (5) write and rehearse an incident-response runbook for the specific scenario of a missed red flag, including who's notified and how the rule table gets patched.
You should now be able to explain why this system decomposes into prompt chaining plus routing rather than an autonomous agent, why the urgency decision has to live in deterministic code instead of a model's prose, and why every failure mode here should fail toward escalation, never reassurance. For a refresher on the underlying workflow patterns, see Building Effective Agents; when you're ready to wrap up the course, head to Next Steps.
Check your answers
- Why not just ask Claude, in plain language, whether a patient's symptoms sound urgent? Because that turns the highest-stakes decision in the system into a soft, unaudited judgment call — inconsistent between runs and impossible to point to for a compliance reviewer. Urgency instead comes from a deterministic, clinician-authored rule table evaluated in application code, never from the model's own prose.
- Why does this count as prompt chaining and routing rather than an autonomous agent? The task decomposes into a small, fixed sequence of steps every time — collect, structure, check, summarize — which is exactly the case Anthropic's own guidance favors a simple, inspectable workflow for, rather than handing a model autonomy over its own steps.
- What should the system do when an extraction is ambiguous, or a tool call or API request fails? Route it down the same emergency-guidance path as a confirmed red flag — the fallback is deliberately asymmetric, because a missed emergency is categorically worse than an unnecessary nudge toward care.