Claude Code for Clinicians Appendix B

Appendix B: Build Your Own Agent with the Claude Agent SDK

This appendix is for readers who want to embed Claude inside a piece of software they are building — a web service, a scheduled job, a Django backend. If that is not you, you can safely skip this appendix; nothing in the main chapters of the book depends on it.

The rest of this book has treated Claude Code as a tool you sit at and talk to. This appendix treats the same underlying engine as a library — something you can call from your own programs.

What this is, in one paragraph

If you’ve used Claude Code, you’ve already seen what an AI agent can do: read files, run commands, edit code, figure out the sequence of steps required to finish a task. The Claude Agent SDK is that same engine, exposed as a library you can wire into anything you like. You get the same agent loop, the same built-in tools, the same context management. You’re not learning a new model — you’re learning a new front door to it.

🧠 Remember. Agent SDK = Claude Code minus the terminal user interface, plus a programmatic API. Same loop, same tools, same context system. Pick the CLI when you’re at a keyboard; pick the SDK when a piece of software is doing the asking.

When to reach for the SDK

You probably want the SDK if any of these are true:

You probably do not need it if you’re sitting at a keyboard doing daily development work. That’s what the CLI is for.

A note on the language

The code examples below are in TypeScript, a typed variant of JavaScript that adds type annotations the editor checks for you. It is the language Anthropic ships the SDK in first. Python bindings exist too (pip install claude-agent-sdk) and follow the same patterns one-to-one — so even if your team writes Python, the structure below transfers directly.

Install

# Requires Node.js 18 or newer
npm install @anthropic-ai/claude-agent-sdk    # the library
export ANTHROPIC_API_KEY=sk-ant-...           # your API key

The first command installs the SDK as a dependency of your project; it bundles the Claude Code engine itself, so there is nothing else to install. The second tells the system your API key — without it, none of the rest will work.

Your first agent

Here is the simplest possible useful example. It asks Claude to list the files in the current directory.

import { query } from "@anthropic-ai/claude-agent-sdk";

for await (const message of query({
  prompt: "What files are in this directory?",
  options: {
    model: "opus",
    allowedTools: ["Glob", "Read"],
    maxTurns: 250
  }
})) {
  if (message.type === "assistant") {
    for (const block of message.message.content) {
      if ("text" in block) console.log(block.text);
    }
  }
  if (message.type === "result") {
    console.log("Done:", message.subtype);
  }
}

This is the entire loop. Let’s walk through it line by line, because the for await syntax is unusual.

for await (...) is JavaScript’s way of looping over an asynchronous stream — a stream that produces things one at a time, when they’re ready. query(...) returns such a stream. Each iteration of the loop receives one message from Claude as it arrives. Some messages are Claude’s text replies; some are Claude calling a tool; some are status updates.

query({ prompt, options }) kicks off an agent run. You give it a prompt and a set of options.

allowedTools: ["Glob", "Read"] restricts Claude to only those two tools. Glob lists files matching a pattern; Read reads a file. Claude cannot edit files, run bash, or open a network connection in this run.

maxTurns: 250 caps how many times the agent can iterate before stopping. A high ceiling — most tasks finish in a handful of turns.

Inside the loop, each message has a type. We check for "assistant" (Claude’s reply) and pull out the text. We also check for "result" (the run finished) and print the outcome.

That’s the whole shape. The SDK handles the loop: call the model, see if it wants a tool, execute the tool, feed the result back, repeat until Claude says it’s done.

Message types you’ll see

case "system":
  // Session was initialized. Capture session_id if you want to resume later.
  if (message.subtype === "init") {
    sessionId = message.session_id;
  }
  break;

case "assistant":
  // Claude's text output, or a tool call.
  break;

case "result":
  // The run is over. message.subtype is "success" or an error type.
  // message.total_cost_usd is the dollar spend for this run.
  // message.usage has the token counts.
  break;

Structured output

For production use — where downstream code has to parse what Claude said — you don’t want free text. You want a JSON object with known fields. You enforce this by passing a JSON Schema:

const reviewSchema = {
  type: "object",
  properties: {
    issues: {
      type: "array",
      items: {
        type: "object",
        properties: {
          severity: { enum: ["low", "medium", "high", "critical"] },
          category: { enum: ["bug", "security", "performance", "style"] },
          file: { type: "string" },
          line: { type: "number" },
          description: { type: "string" },
          suggestion: { type: "string" }
        },
        required: ["severity", "category", "file", "description"]
      }
    },
    summary: { type: "string" },
    overallScore: { type: "number" }
  }
};

for await (const message of query({
  prompt: "Review the code in ./src and identify all issues.",
  options: {
    model: "opus",
    allowedTools: ["Read", "Glob", "Grep"],
    outputFormat: { type: "json_schema", schema: reviewSchema }
  }
})) {
  if (message.type === "result" && message.subtype === "success") {
    const review = message.structured_output;
    // review.issues is now a typed array. Send it to Slack, a database, anywhere.
  }
}

The schema is a contract: Claude’s final output must look exactly like this shape, with these field names and these types. The result is parseable code that downstream programs can rely on. You don’t regex-match on natural language anymore. This is the single biggest unlock the SDK provides for clinical pipelines.

Permission modes

In interactive Claude Code, you approve each tool call. The SDK can run unattended, so you pick a mode upfront:

options: {
  permissionMode: "default",          // Prompt for approval (interactive only)
  // OR
  permissionMode: "acceptEdits",      // Auto-approve file edits
  // OR
  permissionMode: "bypassPermissions" // No prompts at all; CI use only
}

For finer control, supply a callback that decides per call:

options: {
  canUseTool: async (toolName, input) => {
    if (["Read", "Glob", "Grep"].includes(toolName)) {
      return { behavior: "allow", updatedInput: input };
    }
    if (toolName === "Write" && input.file_path?.includes(".env")) {
      return { behavior: "deny", message: "Cannot modify .env files" };
    }
    return { behavior: "allow", updatedInput: input };
  }
}

canUseTool is your last line of defense. It runs before every tool call and either allows it, denies it, or modifies the input.

⚠️ Warning. Using bypassPermissions in a clinical pipeline that touches aidi_catalog is a fast way to lose evening sleep. Always wrap with canUseTool and at minimum block writes to any path containing PHI.

Subagents in the SDK

Same idea as Chapter 13, exposed programmatically. Define your specialists inline:

options: {
  allowedTools: ["Read", "Glob", "Grep", "Task"],   // Task enables subagents
  agents: {
    "security-reviewer": {
      description: "Security specialist for vulnerability detection",
      prompt: "You are a security expert. Focus on injection, secrets, auth.",
      tools: ["Read", "Grep", "Glob"],
      model: "sonnet"
    },
    "test-analyzer": {
      description: "Test coverage and quality analyzer",
      prompt: "You are a testing expert. Find gaps and missing edge cases.",
      tools: ["Read", "Grep", "Glob"],
      model: "haiku"     // cheaper model for simpler work
    }
  }
}

The main agent decides when to delegate. Each subagent has its own context window, its own tool list, and optionally a cheaper model.

Custom tools via MCP

When the built-in tools aren’t enough, expose your own through MCP (Model Context Protocol — explained in Chapter 15). The example below gives Claude read-only SQL access to aidi_catalog:

import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";

const aidiServer = createSdkMcpServer({
  name: "aidi-db",
  version: "1.0.0",
  tools: [
    tool(
      "query_aidi",
      "Query aidi_catalog read-only. Returns up to 100 rows.",
      { sql: z.string().describe("SELECT-only SQL") },
      async (args) => {
        const rows = await runReadOnlyQuery(args.sql);
        return { content: [{ type: "text", text: JSON.stringify(rows) }] };
      }
    )
  ]
});

for await (const message of query({
  prompt: "Find all deceased patients with stage 3 AKI in 2025.",
  options: {
    model: "opus",
    mcpServers: { "aidi-db": aidiServer },
    allowedTools: ["mcp__aidi-db__query_aidi"]
  }
})) {
  // ...
}

You’ve just given Claude one read-only SQL handler. No tool-loop boilerplate; no manual orchestration. This is the pattern for wiring Claude into real KHCC services.

Hooks in the SDK

The same hook system the CLI uses, exposed programmatically:

const blockDangerous: HookCallback = async (input) => {
  if (input.hook_event_name === "PreToolUse" && input.tool_name === "Bash") {
    const cmd = input.tool_input.command || "";
    if (cmd.includes("rm -rf") || cmd.includes("DROP TABLE")) {
      return {
        hookSpecificOutput: {
          hookEventName: "PreToolUse",
          permissionDecision: "deny",
          permissionDecisionReason: "Dangerous command blocked"
        }
      };
    }
  }
  return {};
};

options: {
  hooks: {
    PreToolUse: [
      { matcher: "Bash", hooks: [blockDangerous] }
    ]
  }
}

A hook is a function that runs at a defined moment in the agent loop (PreToolUse, PostToolUse, Stop, etc.). It can deny operations, log them, or transform inputs.

🔧 Technical Stuff. Hooks have the signature (input, toolUseId, { signal }) => Promise<HookResult>. The signal is an AbortSignal you can listen to for cancellation. Return {} to allow the operation to proceed unchanged.

Session resumption

For multi-turn conversations spread across separate HTTP requests, capture the session ID and resume:

let sessionId;
for await (const msg of query({ prompt: "Review the codebase.", options })) {
  if (msg.type === "system" && msg.subtype === "init") sessionId = msg.session_id;
}

// Later, in another HTTP request, perhaps an hour later:
for await (const msg of query({
  prompt: "Now fix the most critical issue.",
  options: { ...options, resume: sessionId }
})) {
  // Claude remembers everything from the first call.
}

This is how you build a stateful Claude-backed service without re-sending the full history on every request.

Cost tracking

if (message.type === "result" && message.subtype === "success") {
  console.log("Cost:", message.total_cost_usd);
  console.log("Tokens:", message.usage);
  for (const [model, usage] of Object.entries(message.modelUsage)) {
    console.log(`${model}: $${usage.costUSD.toFixed(4)}`);
  }
}

The per-model breakdown matters when subagents are in play. You’ll quickly discover that the Haiku-based search agent is essentially free and the Opus reviewer accounts for the entire bill.

A real KHCC pattern

A nightly extraction service for the pathology pipeline:

  1. An Azure Function fires at 02:00.
  2. It calls query() with the extraction prompt, a JSON Schema for the output, and a canUseTool callback that blocks writes to anything outside an extracts/ folder.
  3. A custom MCP server gives Claude read-only schema access to AIDI-DB.
  4. A subagent named security-reviewer (running on Sonnet) scans the output for accidental PHI before it’s written anywhere persistent.
  5. A Stop hook runs the eval suite against yesterday’s baseline and blocks the return if anything regressed.
  6. The structured result lands as JSON in blob storage. The downstream pipeline picks it up at 02:30.

No human in the loop. Full audit trail (every tool call logged through a PreToolUse audit hook). Total cost: under $2 per night.

That is what production looks like.

Where to go next

Closing

You now have the complete picture. Claude Code, the CLI you’ve been using for 22 chapters, is one front door to the agent. The SDK is the other. Same engine; different ergonomics. Pick the one that fits the job in front of you.

The clinical AI office at KHCC will outlive any specific tool, model, or library on this list. Build with that in mind: small, testable, surgical, fail-loud. Claude Code is a force multiplier on those habits. It is not a substitute for them.

Now go ship something.