Level 10Lesson 84โฑ๏ธ 70 min

Migrating to the Claude Agent SDK

You wrote the loop so you would know what it costs. Now delete it, and keep everything that was actually yours.

What you will be able to do

  • Run Dayflow on the Claude Agent SDK without rewriting a single tool handler
  • Say precisely which parts of lessons 81-83 the SDK replaces, which it keeps, and which it quietly changes
  • Lock the SDK down so a calendar assistant cannot run shell commands, read your Claude Code settings, or pick up MCP servers you did not give it
  • Keep your own audit log with a hook, and know why it is no longer the source of truth for resume
Companion repo: posixplus/learn2exel-dayflow, tag lesson-84. New files: dayflow/sdk_agent.py, dayflow/clock.py, ts/src/sdkAgent.ts. Both engines stay: dayflow brief is the raw loop, dayflow brief --engine sdk is this lesson.

Why build the loop first, then throw it away

Three lessons of hand-written loop was not a detour. You now know that an agent is awhile loop over stop_reason, that the message history is the state, that a checkpoint has to land before the next API call, and that compaction is lossy. When the SDK does those things for you, you can tell whether it is doing them well. People who start with the SDK cannot.

The SDK gives you the loop, the permission system, session persistence with resume, automatic compaction, hooks on every tool call, and the same runtime Claude Code itself uses. In exchange it runs a subprocess, decides some things for you, and ships with a default tool surface (Read, Bash, Edit, WebFetch and more) that a personal ops agent must not have. Most of this lesson is about that exchange.

The migration is one adapter, not a rewrite

Lesson 82 made the registry flat on purpose: a Tool has a name, a description, a strict property map and a handler. The SDK wants tools as an MCP server. An in-process one is a function call away, and every registry entry maps onto it mechanically:

# dayflow/sdk_agent.py
from claude_agent_sdk import tool, create_sdk_mcp_server
from dayflow.tools import REGISTRY, Tool, dispatch

SERVER = "dayflow"          # tools are addressed as mcp__dayflow__<name>

def _as_mcp_tool(t: Tool):
    schema = {"type": "object", "properties": t.properties,
              "required": list(t.properties), "additionalProperties": False}

    @tool(t.name, t.description, schema)
    async def handler(args):
        envelope = dispatch(t.name, args)        # same handler, same {"ok": ...} envelope
        return {"content": [{"type": "text", "text": envelope}]}
    return handler

def build_server(kinds=("read", "write")):
    return create_sdk_mcp_server(name=SERVER, version="0.84.0",
        tools=[_as_mcp_tool(t) for t in REGISTRY.values() if t.kind in kinds])

Read that twice. dispatch() is the lesson 82 function, unchanged. The handler that runs, the strict schema the model sees, and the bytes the model reads back are the ones the raw loop produced. The transport changed; the contract did not. That is what the lesson 82 discipline bought you.

๐Ÿ’ก
Do not set is_error
The MCP result type has an is_error flag. Leave it alone. Dayflow's prompt tells the model to branch on ok inside the envelope, and lesson 82's tests check that a failed tool is information, not an exception. Setting is_error too would give the model two signals that can disagree. One contract.

The options block is the security boundary

Here is the whole configuration. Every line that is not obvious is doing a job, and three of them are there to take things away:

options = ClaudeAgentOptions(
    system_prompt=prompts.MORNING_BRIEF,       # ours, not the Claude Code preset
    mcp_servers={SERVER: build_server(kinds)},
    strict_mcp_config=True,                    # ignore ~/.claude.json and any .mcp.json
    setting_sources=[],                        # ignore the developer's own Claude Code settings
    tools=[],                                  # no Read/Bash/Edit/WebFetch: the registry is the whole surface
    allowed_tools=tool_names(kinds),           # auto-approve mcp__dayflow__* (lesson 86 narrows this)
    permission_mode="default",
    max_turns=max_turns,
    max_budget_usd=max_budget_usd,             # a hard stop in dollars, enforced client-side
    model=model,
    hooks={"PostToolUse": [HookMatcher(matcher="mcp__dayflow__.*", hooks=[record_step])]},
)
  • tools=[] is the line you must never forget. The SDK's default surface is Claude Code's: it can read files, run shell commands and fetch URLs. Dayflow reads a calendar. Leave this out and a prompt injection in an email (lesson 88) has a shell to work with. allowed_tools does not restrict the model to those tools; it only pre-approves them. Removal is a separate decision.
  • setting_sources=[] and strict_mcp_config=True keep your laptop out of production. Without them the agent inherits whatever is in your personal Claude Code settings and connects to whatever MCP servers you have configured for yourself. It worked on your machine for reasons that will not exist on the server.
  • system_prompt as a plain string replaces the Claude Code preset entirely. The preset is thousands of tokens about editing code. You are not editing code.
  • max_budget_usd is new. The raw loop had a token budget; this is a dollar budget computed from the model's price list, and the run stops withsubtype == "error_max_budget_usd" when it is hit. Keep both habits.
โš ๏ธ
Allow is not the same as restrict
allowed_tools answers "may this run without asking?". tools answers "does this exist at all?". Security lives in the second list. The first is convenience.

What you keep, what you lose, what moves

Lesson 81-83 pieceUnder the SDK
loop.py / _drive()Replaced. query() is the loop. Your stop_reason handling is now someone else's tested code.
tools.py registry and dispatch()Kept verbatim. Wrapped, not rewritten.
Result envelopeKept verbatim. The model reads the same JSON.
Per-step checkpoint before the next callMoved. The SDK persists its own session after every turn. Your steps table is still written (via a hook) but it is an audit log now.
resume(run_id) rebuilt from your rowsReplaced. ClaudeAgentOptions(resume=session_id). You store the SDK's session_id on your run row so you can hand it back.
compact.pyReplaced. The SDK compacts when the context fills. Yours stays as a tested teaching artifact and as the fallback if you ever leave the SDK.
Per-step usageLost. You get totals on the final ResultMessage (usage, total_cost_usd, model_usage), not per-step numbers. Lesson 90 recovers per-step visibility with tracing.
Process modelChanged. A Claude Code subprocess starts per query(). Roughly a second of startup on a laptop. Lesson 93 keeps one warm.
โš ๏ธ
Two sources of truth is one too many
The moment the SDK owns the session, your steps table stops being able to resume anything. If you keep treating it as the replay source, the two will drift and you will debug a resume that "worked" against stale history. Dayflow makes the call explicit: steps is for audit and evals (lesson 89), runs.session_id is for resume. Pick one and write the decision in the docstring.

Hooks: the SDK calls you back

A PostToolUse hook fires after every tool call with the tool name, the input the model sent, the response your handler returned and the tool_use_id. That is exactly one row in lesson 83's steps table, so the audit log costs eleven lines:

async def record_step(hook_input, _tool_use_id, _ctx):
    step["n"] += 1
    name = hook_input["tool_name"].removeprefix("mcp__dayflow__")
    store.append_step(run_id, StepRow(
        n=step["n"], stop_reason="tool_use",
        assistant=[{"type": "tool_use", "id": hook_input["tool_use_id"], "name": name, "input": hook_input["tool_input"]}],
        tool_results=[{"type": "tool_result", "tool_use_id": hook_input["tool_use_id"],
                       "content": json.dumps(hook_input["tool_response"])}],
        input_tokens=0, output_tokens=0,       # not available per step; totals land on the run
    ))
    return {}                                   # empty dict: do not alter the flow

The matcher mcp__dayflow__.* is a regex on the tool name. With tools=[] it is redundant, since nothing else can fire, but write it anyway: when lesson 85 adds a subagent with its own tools you will want this hook to stay scoped.

PreToolUse is the other half: it runs before the call and can return a decision that blocks it. That is lesson 86's human-in-the-loop gate, and it is why the registry has carried akind: read | write flag since lesson 82. The hook will read it.

The live run on lesson-84: both engines, same fixtures, back to back

$ dayflow brief -v                       # raw loop, lessons 81-83
[step 1] stop=tool_use in=1260 out=190
[step 2] stop=tool_use in=2720 out=527
[step 3] stop=end_turn in=3422 out=675
[3 steps ยท 6 tool calls ยท 7402 in / 1392 out ยท run 04c8c46806c0]

$ dayflow brief -v --engine sdk          # this lesson
  โ†ณ read_inbox({"limit": 20})
  โ†ณ read_calendar({"range": "week"})
  โ†ณ read_tasks({"include_done": false})
  โ†ณ create_task({"title": "Confirm vendor sign-off to Priya (procurement lock-in)",
                 "due": "2026-09-10", "idempotency_key": "confirm-vendor-signoff-priya-2026-09-10"})
[sdk ยท 5 turns ยท 4 tool calls ยท 6 in (+5000 cached) / 1150 out ยท $0.0213
 ยท run 4b4f59b9033a ยท session a7801a51-1a08-4681-8807-69d0b7545a19]

$ sqlite3 dayflow.db "select id, kind, status, input_tokens, output_tokens from runs"
04c8c46806c0|brief|done|7402|1392
4b4f59b9033a|brief-sdk|done|5006|1150

Three things in that transcript are worth more than the rest of the lesson:

  1. "6 in (+5000 cached)". The SDK put a cache breakpoint on the system prompt and tool definitions without being asked, so turns 2 to 5 re-read them at a tenth of the price. Your raw loop sent 7402 uncached input tokens for the same job. Lesson 90 adds caching to the raw loop; for now, notice that a default did something your code did not.
  2. 3 steps with 6 calls versus 5 turns with 4 calls. The raw loop received paralleltool_use blocks and ran them together; the SDK session called tools one at a time. Neither is wrong. It is a reminder that "steps" and "turns" are different units and your budgets must say which one they count.
  3. The run row has a session_id. dayflow runs now shows two kinds of run, and only one kind can be resumed through your own code.

The bug the migration found

The first TypeScript SDK run produced a brief that began: "read_calendar returned only one event, dated 2026-09-07, not today's actual date (2026-11-04)", then flagged every task and email as overdue. It was right. The fixtures are a static week starting on a Monday in September, and nothing in three lessons ever told the model what day it was. The raw loop and the Python SDK run happened to assume the fixture date. The TypeScript run assumed the wall clock. All three were guessing.

The fix is small and belongs in the prompt, not the fixtures: a clock.today() that prefers DAYFLOW_TODAY, then the fixture Monday under MOCK=1, then the real date; and a user turn that states it. prompts.brief_request(today) now renders Today is 2026-09-07. Give me my morning brief for today. Both engines use it.

๐Ÿ’ก
A second runtime is a free test
Nothing about the SDK made this bug visible. A different harness made a different assumption, and the disagreement pointed at the hole. Keeping both engines runnable is worth the small maintenance cost for exactly this reason, until lesson 89 gives you evals that would catch it on purpose.

TypeScript: the one real difference

The TypeScript SDK's tool() takes a Zod shape, not JSON Schema. The registry declares four property shapes (integer, string, string enum, string-or-null), so the translation is a twelve-line function and the registry stays the single source:

function zodFor(p: Prop): ZodTypeAny {
  const types = Array.isArray(p.type) ? p.type : [p.type]
  const nullable = types.includes('null')
  const base = types.find(t => t !== 'null') ?? 'string'
  let s: ZodTypeAny =
    p.enum ? z.enum(p.enum as [string, ...string[]])
    : base === 'integer' ? z.number().int()
    : base === 'boolean' ? z.boolean()
    : z.string()
  if (p.description) s = s.describe(p.description)
  return nullable ? s.nullable() : s
}

Everything else mirrors Python line for line: buildServer, toolNames, thePostToolUse hook, tools: [], strictMcpConfig, settingSources: [].npm run brief -- --engine sdk -v.

๐Ÿ–ฅ๏ธHANDS-ON EXERCISEโฑ 30 min

Take the loop away and watch what changes

Every step is against the real API. Budget about fifteen cents; the SDK runs cost more than the raw ones for now because of subprocess startup, not tokens.

  1. Run `pytest`. 29 pass, 5 skipped (live). Read tests/test_sdk_agent.py first: the unit tests are about the seams (tool naming, envelope passthrough, the store migration), because the adapter itself is thin.
  2. Run `dayflow brief -v` and then `dayflow brief -v --engine sdk`. Compare the summary lines. Write down the cached token count and the turn count. Then `dayflow runs`: two kinds of run in one table.
  3. Remove `tools=[]` from the options in dayflow/sdk_agent.py. Run the SDK brief again with `-v`. If the model calls anything that is not mcp__dayflow__*, you have just watched the default surface leak in. Put the line back. (If it does not leak this time, that is luck, not safety.)
  4. Set `max_budget_usd=0.001` and run. Read the error the CLI prints: `error_max_budget_usd`, plus the cost that triggered it. Restore it.
  5. Run `DAYFLOW_TODAY=2026-11-04 dayflow brief --engine sdk --read-only`. The brief should now flag the fixtures as stale on purpose. Then run it without the override and confirm it does not. One environment variable, two correct behaviours.
  6. Open `sqlite3 dayflow.db "select n, assistant from steps where run_id = (select id from runs where kind = 'brief-sdk' order by created_at desc limit 1)"`. Each row is a tool_use block with an id the SDK minted. Now try `dayflow resume <that run id>`. It fails, and the message tells you why: raw-loop resume cannot rebuild a session it did not drive. Resume for SDK runs arrives in lesson 91 via `resume=session_id`.
  7. TypeScript: `cd ts && npm run brief -- --engine sdk -v`. Diff ts/src/sdkAgent.ts against dayflow/sdk_agent.py. Apart from zodFor(), the files should read as translations. If they do not, one of them has drifted and the registry is no longer the single source.

What this lesson deliberately does not do

  • Delete the raw loop. It stays as --engine raw. It is the control group for every SDK behaviour you want to check, and lesson 90 instruments it to show what caching and tracing cost.
  • Use ClaudeSDKClient. The stateful client gives you multi-turn conversations and interrupt(). A morning brief is one turn. Lesson 85 introduces it when an orchestrator needs to talk to a running agent.
  • Gate the write tool. create_task is still auto-approved. The PreToolUse hook that asks a human first is lesson 86, and the kind flag is waiting for it.
  • Resume an SDK run. The session_id is stored; wiring resume= into the CLI belongs with the retry policy in lesson 91, because a resume without a policy for when is a footgun.
Lesson 84 Quick Reference
Registry โ†’ MCP

create_sdk_mcp_server(tools=[@tool-wrapped handlers]). Each handler calls dispatch(); the envelope is unchanged. Address tools as mcp__<server>__<name>.

tools=[]

Removes Claude Code's built-in surface. allowed_tools only pre-approves; it never restricts. Security lives in tools, not allowed_tools.

setting_sources=[] + strict_mcp_config=True

Ignore the developer's own Claude Code settings and MCP servers. Production must not depend on your laptop.

system_prompt="..."

A plain string replaces the Claude Code preset. Dayflow is not a coding agent.

max_budget_usd

Client-side dollar cap; the run ends with subtype error_max_budget_usd. Keep the token budget habit too.

PostToolUse hook

Fires with tool_name, tool_input, tool_response, tool_use_id. Dayflow writes one steps row per call. Return {} to leave the flow alone.

Source of truth

The SDK session resumes runs; runs.session_id records it. The steps table is audit and evals only.

clock.today()

DAYFLOW_TODAY > fixture Monday under MOCK=1 > wall clock. The prompt states the date; the model never guesses it.

Unlocks in ~18 min of reading