Building AI Agents with Claude
Capstone Project 8B
Capstone 8B — Bonus8–10 hoursAgent Skills
← Capstone 8: Subagent Build 🏠 Home Course Home →

Capstone 8B — Legacy Migration Agent, Skills-First

Build the Oracle → PostgreSQL migration agent again — same schema, same planted defects, same twenty evaluation cases — with Agent Skills instead of subagents. Then measure which architecture actually did better, and write down the answer even when it is unflattering.

Project Brief

You already built this system. Meridian Public Records, eleven Secretary of State offices, 6 TB of UCCUniform Commercial Code — the body of US law governing secured transactions. A UCC-1 financing statement is the public filing a lender makes to claim an interest in a borrower's assets. filings on Oracle since 2003, both PL/SQL authors retired in 2021. In Capstone 8 you migrated it with a coordinator and five specialist subagents.

Now look back at three of those five specialists — schema-translator, plsql-converter, appsql-rewriter. Read their prompts again. None of them is really delegating work. Each one is a rulebook that happens to have a context window attached to it: the Oracle → PostgreSQL type matrix, the PL/SQL construct catalog, the rewrite rules.

That is what Agent SkillsAgent Skill — a directory under .claude/skills/ containing a SKILL.md with YAML frontmatter, optionally alongside references/ (material loaded on demand) and scripts/ (executables the agent runs). Claude loads a skill when the request matches its description. exist to hold. So this capstone takes the rulebooks out of the subagents and ships them as skills.

Think about the difference between handing a job to a colleague and handing yourself a manual.

Before: you have a hard task and two ways to get help. You can call in a specialist — brief them, wait, take their answer. Or you can pull the reference manual off the shelf and do it yourself.

The pain is that these feel interchangeable and are not. The specialist gives you something you cannot give yourself: an opinion formed without knowing what you already decided. The manual gives you something the specialist cannot: it is the same manual you hand to the person who checks your work afterwards, so the two of you cannot be working from different editions.

Mapping onto this project: a subagent is the colleague — separate context, independent read, and no memory of your reasoning. A skill is the manual — loaded into your own context, shared with every phase that needs it, and unable to tell you anything you did not just work out yourself. Capstone 8 was built out of colleagues. This one is built out of manuals. Neither is the right answer everywhere, and this lab is how you find out where each one belongs.

Do Capstone 8 first. This is a comparison, and a comparison needs a baseline. If you have not built the subagent version and seen its numbers, most of this page will read as assertion rather than evidence.

The thesis, stated once, because every section below returns to it:

Skills are knowledge loaded into the current context. Subagents are work delegated to a separate one.

Collapsing five subagents into one context buys shared knowledge and costs independence — and the phase that needs independence most is the one that checks the work.

The deliverable includes one artifact Capstone 8 does not produce: evaluation/architecture_comparison.md, a measured comparison of the two builds on the same problem. It is scored on honesty, not on the numbers being favourable. A comparison that flatters the newer architecture is a failed deliverable — it is worth nothing to the person who reads it deciding what to build next.

Prerequisites

  • Capstone 8, completed and run. You need its artifacts/migration_report.json for the baseline column.
  • M25 — Claude Code Mastery for the six configuration layers, and M26 for hooks and the Agent SDK.
  • Docker (or Rancher Desktop) with ~6 GB free. Oracle Free 23c is a large image.
  • Python 3.11+, an ANTHROPIC_API_KEY, and roughly $2 of budget per full run.

Budget note. Capstone 8's reference run cost $1.78 in output tokens. 8B has no per-phase model routing — every phase runs on Sonnet, where Capstone 8 sent the mechanical phases to Haiku. Expect 8B to cost more, and treat that difference as one of the things you are measuring rather than a surprise.

Skill or Subagent?

The question is not "which is better". It is "which problem am I solving". And it is not a two-way choice, because one frontmatter line moves a skill across the divide: context: fork makes a skill spawn a subagent, so it runs in its own window and returns only a result.

So there are three shapes, not two:

 Inline skill
(the default)
Forked skill
context: fork
Subagent
ContextYours, sharedIts ownIts own
Independent judgementNoYesYes
ReuseOne file, many loadersOne file, many loadersCopy the prompt
ModelWhatever the caller ismodel:, or the agent'sChosen per specialist
System promptThe caller'sThe agent type'sWritten for the job
Cost when usedTokens in your windowA whole extra turnA whole extra turn
Can ship codeYes — scripts/Yes — scripts/No — needs a tool

Run Capstone 8's five specialists through that last row and they sort themselves:

SpecialistWhat it actually isBecomes
schema-translatorThe type-mapping rulebookInline skill
plsql-converterThe construct catalog + refusal rulesInline skill
appsql-rewriterThe rewrite rulebookInline skill
data-migratorLong per-table work, wants its own windowInline, or forked
migration-validatorMust not have seen the work it auditsForked skill or subagent

Notice that the honest answer already refuses to make the last one a plain inline skill. This lab makes all five inline anyway — not because that is the right production design, but because converting the one that should not be converted is how you measure what the conversion costs. You cannot price independence without giving it up once.

Keep context: fork in view while you read the rest. It is the cheapest available fix for the problem this design creates, it is one line, and The Validator Problem is where you will want it.

🎓 Cert Tip. The Claude Certified Architect exam asks you to place a capability into the right configuration layer — CLAUDE.md, skills, slash commands, hooks, subagents, MCP. The reliable discriminator is the first row of the table above: does this need a separate context, or does it need to be shared? Reusable procedural knowledge is a skill. Deterministic enforcement is a hook, never either. But do not answer "needs isolation → subagent" reflexively — a skill with context: fork gets isolation too. Reach for a real subagent when you also need a system prompt written for that job and its own model routing.

Animation 1: The Same Five Phases, Two Shapes

Both builds run the identical pipeline: discover, schema, data, code, validate. Watch where the knowledge lives as each phase fires.

Subagents vs Skills — same pipeline

What just happened? On the left, each phase hands its object to a specialist that starts from nothing and returns an answer. Five contexts, five fresh starts, five prompts that each carry their own copy of whatever rules they need.

On the right, one context runs the whole way through. Skills slide in when a phase needs them and slide out when it does not. The pipeline is identical; only the location of the knowledge moved.

Moving the knowledge is easy to describe and hard to price. The next three animations put numbers on the three things that actually change: what gets loaded, when, and what has seen what.

Anatomy of a Skill

A skill is a directory, not a file. Three parts, each with a different loading cost:

.claude/skills/oracle-pg-typing/ ├── SKILL.md always loaded when the skill loads ├── references/ │ ├── type-matrix.md loaded only when the agent asks for it │ └── number-precision.md loaded only when the agent asks for it └── scripts/ └── check_mapping.py EXECUTED, never loaded into context

That third row is the one people underuse. A scripts/ file can be run without its source ever entering the context window — the agent pays for the output, not the program. For anything deterministic, that is strictly better than reasoning:

python .claude/skills/oracle-pg-typing/scripts/check_mapping.py \
       --ddl ../legacy-oracle/01_schema.sql
  filed_date  DATE  ->  timestamp(0)
    [confident] Oracle DATE carries a TIME component. Mapping it to `date`
    silently truncates 14:32:07 to midnight, which changes which rows
    appear to have lapsed.
? records_expected  NUMBER  ->  numeric
    [check_data] NUMBER with no declared precision can carry a scale;
    narrowing it to an integer type would silently truncate.

46 columns, 20 need a look at the data.

Forty-six columns triaged, deterministically, for the cost of forty lines of output. The model now spends its judgement on the twenty that genuinely need it instead of re-deriving whether NUMBER(9) fits in an int.

What Just Happened?

The skill did not tell Claude the answer and it did not make Claude work the answer out. It handed Claude a program and told it when to run it. The mapping table is now unit-tested, diffable in review, and identical on a bad day — three properties a paragraph in a prompt can never have.

Here is the real SKILL.md from the lab, trimmed to its skeleton. Note how short it is: the long material lives in references/ and is pulled in only when the agent hits the case.

---
name: oracle-pg-typing
description: This skill should be used when mapping Oracle column types to
  PostgreSQL 16 -- when the user or the migration asks to "translate a table",
  "generate DDL", "map this column", "what does NUMBER(9) become", or when any
  Oracle type name (NUMBER, VARCHAR2, DATE, RAW, CLOB, TIMESTAMP WITH LOCAL
  TIME ZONE) needs a PostgreSQL equivalent.
allowed-tools: [Read, Bash, mcp__oracle_src__oracle_get_ddl,
  mcp__oracle_src__oracle_sample_rows, mcp__pg_target__pg_apply_ddl,
  mcp__migration_local__write_artifact]
---

# Oracle to PostgreSQL type mapping

## The rule that governs everything else

**The DDL tells you the declared type. The rows tell you what the column
actually holds. Those are different questions, and the mapping depends on
the second one.**

So the procedure is always two reads, never one.

## Step 1 -- Run the checker before you reason

Load `references/type-matrix.md` for the full table, and
`references/number-precision.md` for the NUMBER(p,s) rules. Do not load
either speculatively -- they are long, and you need the context for the rows.

Why it matters. The description is not documentation — it is the trigger. It is the only thing Claude reads when deciding whether this skill is relevant, and it is read against the user's phrasing, not yours. A description reading "Type mapping utilities" never fires. One that names the exact phrases a caller would use — and the type names that appear in the work — fires reliably. Write it in the third person, and put the vocabulary of the problem in it.

The Frontmatter Contract

Two keys are required. The rest are optional, and three of them decide something this capstone spends the whole page on: where the skill runs.

KeyRequiredWhat it does
nameYesDisplay name; defaults to the filename. Must match the directory
descriptionYesOne-line summary shown in listings and the Skill tool — this is what decides whether the skill is ever loaded
contextNoinline (default) expands the skill into the current conversation; fork spawns a subagent
agentNoWhich agent type to spawn when context: fork
backgroundNoOnly for forks. The fork reports back as a task notification instead of blocking the turn; false keeps the caller waiting in-line
allowed-tools / disallowed-toolsNoTools added or removed while the skill is active
when_to_useNoExtra triggering guidance, folded into the Skill tool description
pathsNoGlobs. The skill loads only when the model touches matching files
hooksNoHooks registered while the skill is active, same shape as settings.json
model, effort, shellNoModel override, thinking effort, and the shell for !-blocks
user-invocable, disable-model-invocation, argument-hintNoWho may invoke it, and the slash-command placeholder

The one that matters here is context.

A skill with context: fork is not just knowledge loaded into your window — it spawns a subagent. The skill runs in its own context, and only its result comes back.

So "skill" and "subagent" are not two ends of a spectrum. One frontmatter line moves a skill from one to the other.

Every skill in this lab runs inline, and that is a deliberate experimental choice rather than a default nobody examined. One shared context is the architecture being measured against Capstone 8. The Validator Problem is where that choice costs something — and where context: fork is the lever sitting right there.

An unrecognised key is ignored silently. Not rejected, not warned about — ignored. A skill with contxt: fork, or with a key someone assumed existed, loads and runs and behaves exactly as if the line were absent, while its author believes it is isolated.

The same is true of a valid key with an invalid value: context: forked is not fork, and nothing tells you.

Which is why the lab asserts the contract at build time rather than hoping to notice at runtime:

# Taken from Claude Code's own schema, not from a survey of published
# skills -- a key being absent from every example you can find is not
# evidence that it is unsupported.
REQUIRED_KEYS = {"name", "description"}
OPTIONAL_KEYS = {
    "model", "allowed-tools", "disallowed-tools", "argument-hint",
    "disable-model-invocation", "user-invocable", "effort", "shell",
    "when_to_use", "paths", "hooks", "context", "agent", "background",
    ...
}
CONTEXT_VALUES = {"inline", "fork"}


@pytest.mark.parametrize("skill", SKILL_NAMES)
def test_no_invented_frontmatter_keys(skill, parsed):
    data, _ = parsed[skill]
    unknown = set(data) - KNOWN_KEYS
    assert not unknown, (
        f"{skill}: unrecognised frontmatter key(s) {sorted(unknown)}. "
        f"Claude Code ignores unknown keys silently."
    )


@pytest.mark.parametrize("skill", SKILL_NAMES)
def test_context_value_is_valid_if_present(skill, parsed):
    """`inline` expands into the conversation; `fork` spawns a subagent.
    Anything else leaves the skill running inline while its author
    believes it is isolated."""
    data, _ = parsed[skill]
    if "context" not in data:
        pytest.skip(f"{skill} does not set context (defaults to inline)")
    assert data["context"] in CONTEXT_VALUES
What Just Happened?

You wrote a test for a configuration file, which feels excessive until you internalise the failure mode. A broken subagent throws. A broken tool call throws. A broken skill does not — it loads, ignores what it does not recognise, and the agent improvises. This is the only class of bug in the whole capstone with no runtime signal at all, so the signal has to be manufactured at build time.

🎓 Cert Tip. Know what context: fork does, and know that it blurs the line the exam otherwise draws between skills and subagents. A forked skill is a subagent invocation wearing skill frontmatter: own context window, result returned, caller's window untouched. What it does not give you is a separate system prompt and per-specialist model routing designed around one job — that is still what a real subagent definition is for.

Animation 2: Progressive Disclosure, Measured

A subagent prompt loads whole, every time it is invoked. A skill loads in layers. Watch the token counter as the agent works through one table and only pulls in what it hits.

What is actually in the context window

What just happened? The skill's entry point is small enough to keep resident. The long material — the full type matrix, the NUMBER(p,s) bands — arrives only when the agent meets a column that needs it, and the scripts/ file never enters the window at all.

The comparison against the subagent is not automatically favourable, and you should be suspicious of any page that tells you it is. A subagent's prompt is loaded once per invocation, into a context that is then thrown away. A skill's material is loaded into a context that persists for the rest of the run. Which wins depends on how many times the phase repeats and how much of the reference material actually gets touched.

Why it matters. Phase 2 runs six times, once per table. Under subagents, each run pays the schema-translator prompt and then discards it — six identical charges, zero residue. Under skills, the material is paid for once and then stays, competing with the DDL and sample rows for the rest of the migration. Six tables is roughly where these two trade places. Your job in architecture_comparison.md is to find out which side of that line this migration falls on, and to say so with numbers rather than with a diagram.

Animation 3: The Phase → Skill Map

A subagent's tools: frontmatter stops it reaching things it has no business touching. The skills build needs the same containment, and gets it from the SDK.

PHASE_SKILLS — what each phase may load
PHASE_SKILLS: dict[str, list[str]] = {
    "discover": [],
    "schema":   ["oracle-pg-typing"],
    "data":     ["nullability-preservation"],
    "code":     ["plsql-conversion", "appsql-rewriting"],
    "validate": ["migration-validation", "nullability-preservation"],
    "cutover":  [],
}

Three decisions in that map are worth defending out loud:

  • discover loads nothing. Taking an inventory needs no rulebook. Loading the type matrix here would put it in the window for the whole run to no purpose.
  • data cannot load migration-validation. The phase doing the work must not be able to reach the procedure that decides whether the work was correct. It could not mark its own homework even if it tried.
  • nullability-preservation appears twice. That is the next section, and it is the strongest argument this architecture has.

One File, Two Phases

Go back to Capstone 8 and open two files side by side: .claude/agents/data-migrator.md and .claude/agents/migration-validator.md. Both contain a description of the empty-string trap. They have to — the loader needs to know how to avoid it, and the validator needs to know how to detect it.

They are two copies of one rule, and nothing keeps them in step.

Capstone 8
the rule, written twice
data-migrator.md — "set null_as"
migration-validator.md — "empty-string count must be zero"
Free to drift
Capstone 8B
the rule, written once
nullability-preservation/SKILL.md
loaded by data and validate
Cannot disagree

Edit the rule in 8B and both phases change together, because there is only one of it. That property is asserted, not assumed:

def test_nullability_skill_is_shared_by_two_phases():
    """The reuse argument, asserted rather than claimed.

    If this ever becomes one phase, the architecture's headline benefit over
    copy-pasted subagent prompts has quietly gone away.
    """
    mapping = _phase_skills()
    phases = [p for p, skills in mapping.items()
              if "nullability-preservation" in skills]
    assert sorted(phases) == ["data", "validate"]

The same idea shows up once more in the lab, one level down. plsql-conversion ships no scanner of its own — it calls the one bundled with appsql-rewriting. One construct catalog, two consumers, so the phase that rewrites application SQL and the phase that converts PL/SQL can never disagree about what counts as an Oracle-ism.

What Just Happened?

This is the clearest win in the whole comparison, and it has nothing to do with tokens. Two prompts that say the same thing are a latent bug: someone tightens one, the other keeps the old wording, and six months later the loader and the validator have different ideas about what a correct load looks like. One file cannot develop that problem.

Animation 4: Context Growth Across Six Tables

Phases 2 and 3 each iterate the six tables. Watch what happens to the context window under each architecture.

Context occupancy, table by table

What just happened? The subagent line is a sawtooth — each table gets a fresh window that is discarded when the specialist returns. The skills line climbs and does not come back down, because every table's DDL, sample rows and decisions stay in the one conversation.

Read that carefully, because the shape alone does not tell you which is better. A monotonic climb is only a problem if it reaches the ceiling; below the ceiling, the accumulated context is the thing that lets phase 4 remember what phase 2 decided. Six tables and 19,065 rows is comfortably inside a 200K window. Six hundred tables would not be.

Where this actually bites. Not on this schema. The failure mode arrives when a migration has enough objects that the accumulated context crosses the compaction threshold mid-run — and the phase that gets compacted is, by construction, the earliest one, which holds the type decisions everything downstream depends on. If you are sizing this pattern for a real 6 TB migration rather than a 19,065-row teaching schema, that is the number to compute first.

Tool or Script? The One That Moved

Capstone 8's tools_local.py exposed two MCP tools. 8B's exposes one. scan_app_sql is gone, and the same regex now ships as appsql-rewriting/scripts/find_oracleisms.py.

 MCP toolSkill script
Present in the tool listEvery request, alwaysNever — it is a file
Costs context when unusedYes, its schemaNo
Where its rationale livesSomewhere else entirelyNext to it, in SKILL.md
Enforces a boundaryCanCannot

That last row is why write_artifact stays a tool. It is not knowledge — it is the thing that confines every generated file to artifacts/, and that boundary has to hold in phases where no relevant skill is loaded at all. A capability that must always be true cannot live somewhere that is only sometimes loaded.

The rule: if it is knowledge about how to do something, it belongs in a skill, next to the prose explaining when to use it. If it is a capability or a boundary that must hold regardless of context, it belongs in a tool.

Read the module docstring the lab ships in place of the deleted tool — the absence is documented, because a future reader will otherwise assume it was an oversight:

"""Local (non-database) tools: artifact writing.

## What is NOT here, and why

The subagent build of this migration (Capstone 8) also exposed a
`scan_app_sql` MCP tool here. In the skills build it is gone -- the same
scanning logic now ships as
`.claude/skills/appsql-rewriting/scripts/find_oracleisms.py`, and the agent
runs it with Bash.

  MCP tool     always present, described in every request's tool list,
               costs context whether or not this phase needs it, and its
               *rationale* lives somewhere else entirely.

  Skill script loaded only when the skill loads, and it sits next to the
               SKILL.md that explains when to run it and how to read the
               output.

`write_artifact` stays an MCP tool, because it is genuinely a capability
rather than knowledge: it enforces the artifacts/ confinement boundary, and
that boundary must hold in every phase regardless of which skills happen to
be loaded.
"""

Animation 5: The Validator Problem

This is the cost. Everything above was instrumentation for measuring it.

Phase 5 begins — what has the validator seen?

In Capstone 8, migration-validator started from an empty context. It had never seen the reasoning that chose timestamp(0) over date, or the decision to let null_as default. When it said "all clear", that was an opinion formed independently of the work.

In 8B, phase 5 is phases 2 through 4. Everything it is auditing is already in its context, written by it, and — from its point of view — already justified.

Note carefully what this is and is not. It is a consequence of this lab running every skill inline, not an inherent property of skills: context: fork would give the validator its own window. The inline choice is what makes the cost measurable, and the fork is the fix waiting at the end of the measurement.

A validator that reports "all clear" on a broken load is worse than no validator, because it converts an unknown risk into a false assurance. Nobody re-checks a migration that passed.

The lab compensates in two places. First, in the skill itself:

### The bias you are running with

In the subagent build of this migration, validation ran in its own context and
had never seen the loading decisions. **Here it does not.** You are the same
context that chose the type mappings and ran the loads. You will be inclined to
accept your own work.

Two rules follow, and they are not optional:

1. **Re-derive every number from the database.** Never report a count you
   remember writing. If you did not just query it, you do not know it.
2. **Assume the load is broken until a query says otherwise.** Start from
   suspicion, not from your recollection that phase 3 went fine.

Second, in the phase prompt, which says the same thing in the imperative. And then — because an instruction is a hope, not a mechanism — the lab makes you measure whether it worked.

The experiment. Run phase 3 deliberately broken, with null_as omitted. Then run phase 5 and record two things: did it report the defect, and did it re-query or did it cite phase 3? Repeat three times, because one trial tells you nothing.

The second column is answerable from the audit log rather than from the prose the agent produced:

import json
calls = [json.loads(l) for l in open("migration_audit.jsonl", encoding="utf-8")]
reads = [c for c in calls
         if "pg_query" in c["tool_name"] or "pg_row_count" in c["tool_name"]]
print(f"{len(reads)} target reads during the run")

If phase 5 emitted no reads against the target, it did not re-derive anything — it reported from memory, and a clean result from it means nothing at all. That is a measurable property, not a vibe, and it is the single most important number in the whole comparison.

Three ways out, and they are not equivalent

Once you have the numbers, the interesting part is what you do about them. There are three fixes, in increasing order of what they cost you:

FixWhat it costsWhat it keeps
Instruct harder
sharpen the two rules in SKILL.md
Nothing — but an instruction is a hope, not a mechanism. Measure it; do not assume it. Everything
Fork the skill
context: fork on migration-validation
One extra turn. The fork cannot see the shared context, so it also loses whatever phases 2–4 legitimately established. The single shared rulebook — nullability-preservation is still one file
Restore the subagent
back to Capstone 8's shape
An extra turn, plus the rule duplicated into an agent prompt where it can drift again A system prompt written for auditing, and its own model

The middle row is the one this capstone exists to surface. It is one line of frontmatter. It buys back exactly the property the inline design gave up — a context that has not seen the work — without giving up the single shared file that made the skills architecture worth building. Whether it is enough is an empirical question, which is why Going Further asks you to run the three trials again with the validator forked.

A forked skill is not a free subagent. It runs under the agent type you name in agent:, with that agent's system prompt — not one written for adversarial reconciliation. If your measurements show the fork catches the defect but reports it limply, that gap is the argument for a real subagent, and it belongs in your write-up.

Why it matters. There is a defensible answer to this lab in which the honest conclusion is "keep four inline skills and fork the fifth", and another in which it is "put the validator back as a subagent". If your measurements support one, write it down and say why. The course is not trying to sell you skills; it is trying to give you the evidence to choose. A capstone whose conclusion was fixed before the experiment ran would be worth nothing.

Animation 6: Guardrails, Unchanged

Three PreToolUse guards and a PostToolUse audit log. They are byte-identical to Capstone 8's, and the point of showing them again is that nothing about them needed to move.

Three guards that run before the tool does

What just happened? Nothing changed — and that is the finding. Guardrails sit between the agent and the tools, so they are indifferent to how the agent's knowledge is organised. hooks.py, hooks_cli.py and .claude/settings.json carried across untouched.

🎓 Cert Tip. This is the layering principle the exam keeps returning to. A validation check written inside a tool protects that tool. A can_use_tool callback protects every tool that exists now and every tool anyone adds later — and, as this capstone demonstrates, it keeps protecting them through an architecture rewrite. Enforcement belongs in hooks. Never in a prompt, and never in a skill.

When a Skill Silently Does Not Load

Two ClaudeAgentOptions fields make skills work. Both are required, and omitting either produces no error.

return ClaudeAgentOptions(
    model=model,
    system_prompt=system_prompt,
    mcp_servers={...},
    can_use_tool=hooks.can_use_tool,
    hooks=[HookMatcher(matcher="*", hooks=[hooks.audit_log])],
    # Without this, `.claude/skills/` is never read and every skill
    # silently does not exist. The agent then improvises the type
    # mapping from memory and the run looks like it worked.
    setting_sources=["project"],
    # Per-phase allowlist. `None` would mean "no skills"; `"all"` would
    # mean every discovered skill in every phase, which defeats the
    # scoping this architecture depends on.
    skills=skills,
)

Miss setting_sources and the skills directory is never opened. Miss skills and nothing is selected. In both cases Claude answers from its own knowledge of Oracle and PostgreSQL — which is quite good, and that is precisely the problem. The DDL it generates will be plausible. It will also have decided DATE on its own.

Prove a skill actually ran

Do not trust a green run. Check the audit log for an execution of a bundled script:

grep -c "check_mapping" migration_audit.jsonl    # must be > 0
python coordinator.py --list-skills             # sanity-check the map first

A zero means the skill never loaded, whatever the migration report says.

What Just Happened?

You added a verification step for something that has no failure signal. Notice the pattern across this whole capstone: skills fail quietly, so every safeguard around them is an assertion you write yourself — a build-time test on the frontmatter, a runtime grep on the audit log. Subagents did not need either, because a missing subagent throws.

File Structure

Everything that is not agent logic ships complete. You build the skills, the phase map, the guardrails, and the comparison.

labs/capstone-8b-skills-first/ ├── README.md ├── spec/agent-spec.md read next to Capstone 8's -- diff §5 and §12 │ ├── legacy-oracle/ IDENTICAL to Capstone 8, deliberately ├── app/ IDENTICAL -- same rewrite targets │ ├── starter/ YOUR WORKSPACE │ ├── .claude/ │ │ ├── skills/ │ │ │ ├── oracle-pg-typing/ │ │ │ │ ├── SKILL.md TODO(1)-(6) │ │ │ │ ├── references/type-matrix.md │ │ │ │ ├── references/number-precision.md │ │ │ │ └── scripts/check_mapping.py TODO(1)-(12) │ │ │ ├── plsql-conversion/ │ │ │ │ ├── SKILL.md TODO(1)-(6) │ │ │ │ └── references/{construct-catalog,refusal-template}.md │ │ │ ├── appsql-rewriting/ │ │ │ │ ├── SKILL.md TODO(1)-(5) │ │ │ │ └── scripts/find_oracleisms.py TODO(1)-(10) │ │ │ ├── nullability-preservation/ │ │ │ │ ├── SKILL.md the most important file here │ │ │ │ └── scripts/compare_nulls.py TODO(1)-(7) │ │ │ └── migration-validation/ │ │ │ ├── SKILL.md TODO(1)-(6) │ │ │ ├── references/check-catalog.md │ │ │ └── scripts/compare_checksums.py TODO(1)-(11) │ │ ├── commands/{migrate,validate,report}.md │ │ └── settings.json identical to Capstone 8 -- do not edit │ ├── coordinator.py PHASE_SKILLS + the two SDK options │ ├── tools_local.py write_artifact ONLY │ ├── hooks.py the three guards + the audit log │ ├── tools_oracle.py ships complete │ ├── tools_postgres.py ships complete │ └── evaluation/architecture_comparison.md the deliverable │ ├── solution/ the reference build, 199 tests green ├── tests/ same suite + test_skills_wellformed.py ├── expected_output/ what a good run looks like └── appendix/manual-loop.py raw API, for contrast only

Note what is absent from the root, compared with Capstone 8: no type_mapping.py, no oracle_constructs.py, no validation.py, and no .claude/agents/ at all. That logic moved inside the skills that explain it. If you generate this project from the spec and those files come back, the generator produced the Capstone 8 shape.

Spec-Driven: Build It Twice

This is a Tier 3 capstone, so the spec is the source and the solution is the reference:

/generate-from-spec spec/agent-spec.md      # writes into generated/
diff -r generated/ solution/                # where they differ, decide who is right

Then do the thing this capstone makes possible and Capstone 8 alone does not: read the two specs side by side.

SectionCapstone 8 vs 8B
1. Business ContextSame problem, different architecture paragraph
2. Agent ConfigurationDiffers — no model routing; adds skill discovery
4. ToolsDiffers — one local tool instead of two
5. Subagents / SkillsEntirely different
6. HooksIdentical
7. GuardrailsIdentical
8. SessionsIdentical mechanism, one new consequence
9. DeploymentIdentical
10. TestsSame, plus test_skills_wellformed.py
11. EvaluationIdentical — that is what makes scores comparable
12. File StructureDiffers

Four sections differ out of twelve. Everything you would call "the safety architecture" is in the eight that do not. That is the most reusable thing on this page: the skills-versus-subagents question is a question about where knowledge lives, and it is close to orthogonal to how the system is made safe.

Two specs, four differing sections, one identical evaluation set. Everything is now in place to run both and find out which one was actually better — which is the only part of this capstone that can tell you something you did not already believe.

Step-by-Step Build Guide

Step 1 — Bring up both databases
~5 minutes, most of it waiting

What & why: the same two containers as Capstone 8. Oracle Free 23c seeds itself from legacy-oracle/*.sql on first boot and is slow about it.

# Credentials
cp starter/.env.example starter/.env
# Edit starter/.env and add your ANTHROPIC_API_KEY

pip install -r requirements.txt

docker compose -f starter/docker-compose.yml up -d

# WAIT. This is the step people skip.
# oracle must read "healthy", not just "running".
docker compose -f starter/docker-compose.yml ps
Expected output
NAME IMAGE STATUS oracle gvenzl/oracle-free:23-slim Up 3 minutes (healthy) postgres postgres:16-alpine Up 3 minutes (healthy)
Checkpoint

Confirm the legacy schema seeded — UCC_DEBTOR must report 7,418 rows. If Oracle says (unhealthy), give it another two minutes before investigating; first boot is genuinely slow.

Step 2 — Build the checkers before the prose
~2 hours · no API key needed

What & why: counter-intuitive ordering, and it is deliberate. The scripts/ are pure Python, unit-tested, and free to run. More importantly, running them over the real schema is how you discover what the skills need to say. Write the prose first and you will document the traps you already knew about.

# Work through the TODOs in these four files:
#   oracle-pg-typing/scripts/check_mapping.py          TODO(1)-(12)
#   appsql-rewriting/scripts/find_oracleisms.py        TODO(1)-(10)
#   nullability-preservation/scripts/compare_nulls.py  TODO(1)-(7)
#   migration-validation/scripts/compare_checksums.py  TODO(1)-(11)

# Each carries its own cases:
python starter/.claude/skills/oracle-pg-typing/scripts/check_mapping.py --self-test

# Then point it at the real thing -- this is the discovery step:
python starter/.claude/skills/oracle-pg-typing/scripts/check_mapping.py \
       --ddl legacy-oracle/01_schema.sql
Expected output
11/11 passed filed_date DATE -> timestamp(0) [confident] Oracle DATE carries a TIME component... ? records_expected NUMBER -> numeric [check_data] NUMBER with no declared precision can carry a scale... 46 columns, 20 need a look at the data.
Checkpoint

Those twenty flagged columns are the content of SKILL.md's traps section. Run the scanner too — find_oracleisms.py --dir legacy-oracle --refuse-only should surface exactly one construct, in PKG_FILING_MAINT.log_audit. That one construct is the entire refusal lesson.

Step 3 — Write the five SKILL.md files
~2 hours

What & why: now you know what the schema actually contains, write the procedures. Start with nullability-preservation — it is the shortest and the most consequential, and two phases depend on it.

Uses Step 2: every trap you document should be one the checker flagged.

TEST_TARGET=starter pytest tests/test_skills_wellformed.py -v
Expected output (fresh starter)
45 tests: 39 passed, 6 failed FAILED test_bundled_script_self_test_passes[oracle-pg-typing-check_mapping.py] FAILED test_bundled_script_self_test_passes[appsql-rewriting-find_oracleisms.py] FAILED test_bundled_script_self_test_passes[nullability-preservation-compare_nulls.py] FAILED test_bundled_script_self_test_passes[migration-validation-compare_checksums.py] FAILED test_phase_skill_map_references_real_skills FAILED test_nullability_skill_is_shared_by_two_phases
Checkpoint

Those six failures are your work list, not a problem. The 39 that pass are already checking your frontmatter is valid, your allowed-tools resolve, and your references/ exist. The last two failures clear in Step 4.

Step 4 — Wire the coordinator
~1 hour

What & why: PHASE_SKILLS plus the two ClaudeAgentOptions fields. This is the step where a mistake produces no error at all, so verify it explicitly.

Uses Step 3: every skill named in the map must exist on disk.

cd starter
python coordinator.py --list-skills
Expected output
discover (none) schema oracle-pg-typing data nullability-preservation code plsql-conversion, appsql-rewriting validate migration-validation, nullability-preservation cutover (none)
Checkpoint

nullability-preservation appears on two lines. If it does not, re-read the section on why the loader and the validator must read one file. test_nullability_skill_is_shared_by_two_phases should now pass.

The map looks right but nothing loads

You almost certainly set skills= and forgot setting_sources=["project"]. There is no error for this. Run one phase and grep the audit log — grep -c check_mapping migration_audit.jsonl must be greater than zero.

Step 5 — Run it, and expect the first run to fail
~10 minutes per run · ~$2

What & why: the first end-to-end run is designed to fail check 4. Nothing yet forces null_as, so 1,412 Oracle NULLs land as empty strings.

python coordinator.py --migrate-all
Expected output
=== PHASE 5 / 5 VALIDATE ======================================== skills: migration-validation, nullability-preservation NOTE: this phase audits work done earlier in THIS context. DEFECTS (1) [BLOCKER] empty_string_divergence -- ucc_debtor.mailing_address_2 Oracle NULL count: 1412 PostgreSQL NULL count: 0 PostgreSQL '' count: 1412 -> Every IS NULL query against this column now returns fewer rows. filing_repository.debtors_missing_address_line_2 returns 0. Re-load with null_as set. Blockers: 1 Warnings: 0 Cutover must not proceed while a blocker is open.

If your first run comes back clean, do not celebrate. Check that the validator actually ran. An empty defect list and a validator that never executed look identical in the JSON — and in this architecture the validator has every reason to believe its own earlier work. Confirm artifacts/validation_summary.json has a non-zero checks_passed, and that phase 5 emitted reads against the target.

Checkpoint

Fix it in nullability-preservation/SKILL.md, then --phase data and --phase validate. The second run should show zero blockers. Keep the first run's output — it is data for the comparison.

Step 6 — The cutover gate
~2 minutes

What & why: unchanged from Capstone 8. Verify it still bites after an architecture rewrite.

python coordinator.py --phase cutover                     # must be denied
python coordinator.py --phase cutover --approve-cutover   # only a human
Expected output (first command)
CUTOVER REQUIRES HUMAN APPROVAL. Current validation state: 6 tables validated, 36 checks passed, 0 failed A person must read artifacts/validation_report.html and re-run: python coordinator.py --phase cutover --approve-cutover Do not attempt to work around this.
Step 7 — Fill in the comparison
~1 hour · the actual deliverable

What & why: everything above was instrumentation. This is the capstone.

Uses Steps 5 and 6, plus Capstone 8's expected_output/migration_report.json for the baseline column.

python evaluation/test_suite.py            # must score >= 18/20

# The validator-independence trials -- run this THREE times:
#   1. break phase 3 (omit null_as)
#   2. python coordinator.py --phase validate
#   3. record whether it caught its own mistake, and whether it re-queried
python - <<'EOF'
import json
calls = [json.loads(l) for l in open("migration_audit.jsonl", encoding="utf-8")]
reads = [c for c in calls
         if "pg_query" in c["tool_name"] or "pg_row_count" in c["tool_name"]]
print(f"{len(reads)} target reads during the run")
EOF
Checkpoint

Every number in architecture_comparison.md comes from an artifact, not from console scrollback. And if 8B did worse — on cost, on the evaluation score, or on catching its own defect — the file says so. That is the deliverable being graded.

Measuring It Yourself

The baseline column is pre-filled from Capstone 8's reference run — a clean second run, after the empty-string defect was fixed. The 8B column is deliberately empty on this page, because publishing invented numbers for the new architecture would destroy the one thing this capstone teaches.

MeasureCapstone 8Capstone 8BSource
Total output tokens118,940total_output_tokens
Wall clock279.4 stotal_ms
Estimated cost$1.78estimated_usd
Spans19spans
Tables validated6validation_summary.json
Checks passed36validation_summary.json
Manual-review items3manual_review_queue
Evaluation scoretest_suite.py

Capstone 8 queued three items for manual review. A migration that queues fewer is not doing better — it is either finding less or guessing more:

  1. PKG_FILING_MAINT.log_auditPRAGMA AUTONOMOUS_TRANSACTION, no in-process equivalent
  2. MV_STATE_ROLLUP — PostgreSQL materialized views have no fast refresh and no query rewrite
  3. RiskReportDao.java:lapsingSoondate - date is a NUMBER in Oracle and an INTERVAL in PostgreSQL, and the Java reads it with rs.getInt()

Before you measure, write down a prediction. Which phases should get cheaper under skills, and which more expensive? What happens to schema, which runs six times and reloads the same rulebook each time? Then check yourself. The gap between your prediction and the measurement is the part worth keeping.

Test Cases

The twenty evaluation scenarios are identical to Capstone 8's. That is what makes the two scores comparable, and it is why you must not "improve" them.

pytest tests/ -v                       # grades solution/ by default
TEST_TARGET=starter pytest tests/ -v   # grades your work
python evaluation/test_suite.py        # the 20 scenarios; >= 18 to pass
FileWhat it holds
test_skills_wellformed.pyNew in 8B. 45 assertions: frontmatter validity, no invented keys, description quality, allowed-tools resolvable, scripts import / self-test / run standalone, references exist, PHASE_SKILLS consistency, two-phase sharing
test_type_mapping.pyThe mapping table — imported from the skill script, by path
test_plsql_conversion.pyThe construct registry and the refusal set
test_validator_catches_empty_string.pyThe planted defect, the spot-check, and that the loader and validator agree
test_hooks_readonly.pyOracle writes denied, reads allowed
test_hooks_pg_guard.pyTarget schema fenced
test_cutover_hitl.pyCutover impossible without the human flag

The solution suite is 199 tests green. A fresh starter gives 39 passes and 160 failures.

Why the tests load skill scripts by path. .claude/skills/*/scripts/ is deliberately not on sys.path, and test_bundled_script_runs_standalone runs each script with an emptied PYTHONPATH. An agent that loads a skill gets the file and nothing else — if the script only works when the solution directory happens to be importable, it does not work.

📂 Get the files: labs/capstone-8b-skills-first on GitHub — or clone the course once: git clone https://github.com/varasrinivas/agenticai-course.git

Troubleshooting

The run completed and no skill was ever used

Both setting_sources=["project"] and skills=[...] must be set on ClaudeAgentOptions. Neither produces an error when missing. Verify with grep -c check_mapping migration_audit.jsonl.

A skill exists but never fires

Almost always the description. It is matched against the caller's phrasing, so it needs the actual vocabulary of the request — type names, verbs like "translate a table". "Type mapping helper" matches nothing. Check name matches the directory too.

Frontmatter looks right, test says unrecognised key

You used a key Claude Code does not read — most likely context:. Remove it. Only name and description are required; see the frontmatter table above for the optional set.

Validation passes on the very first run

Suspicious, not good. Nothing has forced null_as yet, so check 4 should fail. Confirm the validator ran: non-zero checks_passed in artifacts/validation_summary.json, and target reads in the audit log for phase 5.

Oracle container never turns healthy

First boot seeds the whole schema and takes 1–3 minutes, longer on slower disks. Watch docker compose logs -f oracle for DATABASE IS READY TO USE! before investigating anything else.

Context runs out partway through phase 4

Expected behaviour of a single-context design on a larger schema, and worth recording rather than working around. Run phases separately with --phase; the session file carries the state even though the conversation does not. Note in the comparison that this is a cost the subagent build does not pay.

Going Further (all OPTIONAL)

  • Add a sixth skill to the spec. Put index-strategy in spec/agent-spec.md, regenerate, and compare the cost of that change against adding a sixth subagent to Capstone 8's spec.
  • Give phase 5 its independence back. Run --phase validate in a fresh process, so it has the session file but not the conversation. Does the validator behave more like Capstone 8's? Record it — this is a cheap experiment with a genuinely uncertain answer.
  • Build the hybrid. Keep the four knowledge skills and restore migration-validator as a subagent. Measure whether it recovers Capstone 8's validator reliability while keeping 8B's shared knowledge. If the measurements support the hybrid, that is the right answer and your write-up should say so.
  • Fork the validator. Add context: fork to migration-validation/SKILL.md and re-run the three independence trials. This is the cheapest fix for the validator problem and the one the frontmatter puts within reach — measure whether it actually recovers Capstone 8's reliability, and what it costs in tokens and wall clock.
  • Make a skill user-invocable. Add user-invocable: true to oracle-pg-typing and call it directly from Claude Code to triage a table by hand.

Knowledge Check

Q1: You are deciding whether a capability belongs in a skill or a subagent. Which single property settles it most reliably?

Q2: A skill's SKILL.md declares context: fork. What happens at runtime, and what does it mean for this capstone?

Q3: PHASE_SKILLS maps nullability-preservation to both the data phase and the validate phase. What does that duplication buy?

Q4: A student wires up five skills, runs the migration, and gets clean-looking DDL for all six tables. What must they check before believing it?

Q5: scan_app_sql moved from an MCP tool to a skill-bundled script, but write_artifact stayed a tool. What distinguishes them?

Q6: Your measurements show 8B's validator missed the planted empty-string defect in two of three trials, where Capstone 8's subagent caught it every time. What does architecture_comparison.md say?

References & Resources

The lab

  • labs/capstone-8b-skills-first/README.md — setup and the done-when list
  • labs/capstone-8b-skills-first/spec/agent-spec.md — the canonical spec; read it beside Capstone 8's
  • labs/capstone-8b-skills-first/solution/.claude/skills/ — the five reference skills
  • labs/capstone-8b-skills-first/tests/test_skills_wellformed.py — the frontmatter contract, asserted
  • labs/capstone-8b-skills-first/solution/evaluation/architecture_comparison.md — the deliverable

Elsewhere in this course

Primary sources for the frontmatter contract

  • Claude Code's own frontmatter schema — the authority on which keys exist and what each does. It is the only source that settles the question; the two below are useful but neither is complete.
  • plugin-dev/skills/skill-development/SKILL.md in the official Anthropic plugin marketplace — good on the scripts/ / references/ / assets/ layout and on writing a description that triggers reliably. It does not document every key.
  • The skills shipped in that marketplace — useful for house style, but a key appearing in none of them is not evidence it is unsupported. context is exactly that case: absent from every published example, and fully supported.

What you built. The same migration system twice, and the evidence to say which shape was right. The five skills are the visible artifact; the comparison file is the transferable one. Most teams pick an agent architecture by taste and then defend it. You now have a method for picking one by measurement — including the discipline to publish the result when it disagrees with what you hoped.