Spec-Driven Development
Spec-Driven Development with Claude Code: One Setup for Swift, Python, .NET and Android
October 27, 2026 · 13 min read
Claude Code, Spec-Driven Development, Swift, Python, .NET, Android, Agentic Coding, AI, Developer Tools
In June I wrote that Claude Code is prompt-first and Kiro is spec-first. Four months later I'd put it differently: Claude Code is prompt-first by default. With the right files in the repo it becomes spec-first too, and you keep the flexibility for the exploratory work where a spec would only get in the way.
By day I work in C# and .NET on Azure. Alongside that, I build iOS, Android and Mac apps. Setting the same workflow up across those stacks taught me the useful thing: the method doesn't care what language you write. The files, the loop and the guardrails are identical. What changes is four commands: how you build strictly, format, test, and what MCP server gives the agent eyes.
So this post is in two halves: the setup that's the same everywhere, then the short list of what changes for Swift, Python, .NET and Android. It's Part 1 of a series. Part 2 makes the setup portable to Codex and Kiro, and Part 3 runs it against a local model.
How we got here: prompt → context → spec
Three phrases have described "how to talk to an AI" in about three years, and each one swallowed the last.
Prompt engineering was about wording: role prompts, few-shot examples, "think step by step". It still matters, but it's the smallest lever you have once an agent is reading your codebase.
Context engineering is the 2026 term for everything the model sees when it generates a token: instructions, tool definitions, retrieved files, conversation history, memory. The shift is from wording to wiring. Two lessons keep coming up:
- More context isn't better context. Models recall less accurately as the window fills (people call this "context rot"). A small, targeted set of files beats a giant dump of the repo.
- The harness matters as much as the model. Teams report big jumps on coding benchmarks from changing only the loop around the model, mostly by adding verification: run the build, run the tests, check before finishing.
Spec-driven development (SDD) is context engineering applied to a codebase. Instead of re-explaining intent in every chat, you write it down once, version it in git, and have the agent work from it. GitHub's open-source Spec Kit made this mainstream (its 1.0 chains constitution → specify → plan → tasks → implement → converge), Amazon's Kiro built an IDE around it, and by now nearly every coding agent ships its own flavour.
The honest critique is that this is Waterfall 2.0. It isn't, as long as the spec is small, per feature, and allowed to change. A spec for one screen or one endpoint is half a page. Nobody is writing a 60-page requirements document.
Half 1: the setup that's the same for every stack
The project files
MyApp/
├── CLAUDE.md # project rules (keep it short)
├── .mcp.json # shared MCP servers
├── .claude/
│ ├── settings.json # permissions + hooks (committed)
│ ├── hooks/
│ │ ├── format.sh # ← stack-specific (Half 2)
│ │ └── tests-must-pass.sh # ← stack-specific (Half 2)
│ ├── skills/
│ │ ├── write-spec/SKILL.md # interview me, write the spec
│ │ └── implement-task/SKILL.md # do one task from tasks.md
│ └── agents/
│ └── spec-reviewer.md # checks code against the spec
└── specs/
├── constitution.md # principles for every feature
└── 001-export-csv/
├── spec.md # what and why
├── plan.md # how
└── tasks.md # the checklist
Only the two files marked ← change between stacks. Everything else in this half you can copy as-is.
CLAUDE.md: the always-on context
CLAUDE.md loads into every session. Claude Code's docs suggest keeping it under about 200 lines, and they're explicit that it's advisory: guidance the model follows, not a guarantee. So keep it to what the model can't infer from the code:
# MyApp
<One line: platform, language, frameworks.>
## Commands
- Build: <build command>
- Test: <test command>
- Format: <format command>
## How we work
- Every feature starts from a spec in `specs/NNN-name/`. Read `spec.md`
before writing code. If the spec is ambiguous, ask; don't guess.
- Follow `specs/constitution.md`.
- Work one task from `tasks.md` at a time and tick it off when its test passes.
When compacting, always preserve the list of modified files and the
current task ID.
That last line comes straight from the docs: you can tell Claude what to keep when it summarises a long session. Run /init in an existing repo and Claude drafts this file for you. Treat the draft as a starting point and cut it down.
The constitution
Spec Kit's best idea is a short file of rules every feature must respect:
# Constitution
1. Every feature ships with tests for its "Done when" list.
2. No secrets in code or config files checked into git.
3. No new dependencies without asking.
4. Prefer boring: no new architectural pattern without a written reason.
The spec
One folder per feature. The spec says what and why, never how:
# 001 – Export history as CSV
## Problem
Users want their history in a spreadsheet. Today they screenshot it.
## Requirements
- WHEN the user taps Export and picks a date range,
THE APP SHALL produce a CSV of entries in that range.
- WHEN the range is empty, THE APP SHALL say so instead of
exporting an empty file.
## Out of scope
- Scheduling, email delivery, Excel format.
## Files likely touched
- <the export screen/view-model/service in your stack>
## Done when
- Unit tests cover the CSV format and the empty-range case.
- The exported file opens correctly in Numbers or Excel.
The WHEN … THE APP SHALL lines are EARS notation, the style Kiro uses. You don't need it, but it makes requirements easy to turn into tests. Out of scope and Done when do the most work: Claude Code's best-practices guide recommends that a spec name the files, say what's out of scope, and end with an end-to-end check.
The loop
1. Write the spec with Claude interviewing you
Skills live in .claude/skills/<name>/SKILL.md:
---
name: write-spec
description: Interview the user about a new feature and write specs/NNN-name/spec.md
disable-model-invocation: true
---
We are writing a spec for: $ARGUMENTS
1. Read specs/constitution.md and the existing code the feature touches.
2. Ask me questions until requirements, out-of-scope and "done when"
are unambiguous. Ask about edge cases I haven't mentioned.
3. Write specs/NNN-name/spec.md using the template of the previous spec.
Do not write any code.
disable-model-invocation: true means Claude never runs it on its own; you start it with /write-spec export history as CSV.
2. Plan in plan mode, in a fresh session
The interview filled the context with back-and-forth you don't need, and the spec on disk now holds everything that matters. So start a fresh session and switch to plan mode with Shift+Tab (or claude --permission-mode plan). In plan mode Claude reads and explores but can't edit until you approve.
Read specs/001-export-csv/spec.md. Write plan.md and tasks.md next to it.
Tasks must be small, ordered, and each one must name its test.
Review the plan like a design doc. It's the cheapest point to catch a wrong assumption. Ctrl+G opens it in your editor.
3. Implement one task at a time
/implement-task 001 T003
implement-task is another skill: read the spec, the plan and that one task, write the test first, make it pass, tick the box. One task per run keeps the context small and the diff reviewable. /clear between unrelated tasks. Independent tasks can run in parallel, each in its own git worktree: claude --worktree export-t004.
4. Review against the spec with a subagent
A subagent has its own context window, so it reviews the diff without carrying the implementation conversation, and it only gets the tools you give it. .claude/agents/spec-reviewer.md:
---
name: spec-reviewer
description: Checks the current diff against the feature's spec.md and constitution.md
tools: Read, Glob, Grep, Bash
model: sonnet
---
Compare the current git diff with the spec it implements.
Report: requirements with no test, behaviour not in the spec,
anything under "Out of scope" that was built anyway,
and constitution violations. Do not edit files.
This is Spec Kit's "converge" step: the spec is the contract, and something independent checks the code against it.
Guarantees: permissions and hooks
CLAUDE.md is advice. Hooks and permissions are enforcement. Both live in .claude/settings.json, committed so everyone gets them. Notice the hooks call scripts; that's what keeps this file identical across stacks:
{
"permissions": {
"deny": ["Read(./.env)", "Read(./.env.*)", "Bash(git push *)"]
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": ".claude/hooks/format.sh" }]
}
],
"Stop": [
{
"hooks": [{ "type": "command", "command": ".claude/hooks/tests-must-pass.sh" }]
}
]
}
}
- The PostToolUse hook runs after every edit, so formatting never reaches review.
- The Stop hook runs when Claude thinks it's finished. Exit code
2blocks it from stopping, and whatever the hook printed to stderr goes back to Claude as feedback. Point it at your tests and "done" means green tests.
This is the "harness beats model" lesson from earlier: a hook that runs the tests before Claude can finish does more for quality than any amount of "please make sure tests pass" in a prompt.
The test gate is the same shape everywhere; only TEST_CMD changes:
#!/usr/bin/env bash
# .claude/hooks/tests-must-pass.sh
TEST_CMD="<your test command, see Half 2>"
input=$(cat)
# Already blocked once this turn: let it stop instead of looping forever.
[ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ] && exit 0
if ! eval "$TEST_CMD" >/tmp/claude-test.log 2>&1; then
tail -n 40 /tmp/claude-test.log >&2
exit 2
fi
The same loop in CI
- Headless:
claude -p "…"runs one prompt non-interactively, with--output-format jsonand--allowedToolsto limit it. For scripts the docs recommend--bare, which skips local hooks, skills, MCP and CLAUDE.md so runs are reproducible. - GitHub Actions: run
/install-github-apponce, andanthropics/claude-code-action@v1responds to@claudein issues and PRs, or runs a fixed prompt on every PR. A good first one: "Compare this PR with the spec in specs/ it references and list any gaps."
Half 2: what changes per stack
Four things: make the build strict, the format command, the test command, and which MCP server gives the agent eyes.
| Strict build | Format (per edit) | Test gate | MCP | |
|---|---|---|---|---|
| Swift (iOS / Mac) | Swift 6 mode, warnings as errors | swift format --in-place | xcodebuild test / swift test | XcodeBuildMCP |
| Python | ruff + pyright strict | ruff format + ruff check --fix | pytest | usually none |
| .NET | TreatWarningsAsErrors, analyzers | dotnet format | dotnet test | Microsoft Learn |
| Android (Kotlin) | allWarningsAsErrors | ktlint -F | ./gradlew testDebugUnitTest | none needed to start |
The principle behind the first column: every rule you put in the build is a rule you don't have to put in a prompt. The compiler can't be ignored, and it doesn't get forgotten when the context is compacted.
Swift: iOS and Mac apps (what I use daily now)
Strict build: Swift 6 language mode, and in the target's build settings Treat Warnings as Errors set to Yes.
Format hook (swift format ships with the Xcode toolchain):
#!/usr/bin/env bash
# .claude/hooks/format.sh
f=$(jq -r '.tool_input.file_path')
case "$f" in *.swift) swift format --in-place "$f" ;; esac
Test gate: for a Swift package, TEST_CMD="swift test". For an app target:
TEST_CMD="xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=<your simulator>' -quiet"
(For a Mac app, -destination 'platform=macOS'.)
MCP: this is the big one for app developers. In June I complained that an agent in a terminal is blind to the running app. XcodeBuildMCP fixes most of that: it lets Claude build, install and launch on a simulator, then take a screenshot and look at it. For UI work that turns "it compiles" into "it renders".
brew install getsentry/xcodebuildmcp/xcodebuildmcp
claude mcp add XcodeBuildMCP --scope project -- xcodebuildmcp mcp
Add a line to CLAUDE.md saying which scheme, simulator and bundle ID to use, so it doesn't have to ask. And if you live in Xcode rather than the terminal, Xcode 27 talks to Claude Code directly. The project files above still apply.
Python: nothing changes but the tools
If you're a Python developer, everything in Half 1 applies unchanged. The modern toolchain is uv for environments, ruff for lint and format, pytest for tests, and pyright or mypy in strict mode to get the compiler-style feedback statically typed languages give you for free.
Strict build: in pyproject.toml:
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
[tool.pyright]
typeCheckingMode = "strict"
Format hook:
f=$(jq -r '.tool_input.file_path')
case "$f" in *.py) uv run ruff format "$f" && uv run ruff check --fix "$f" ;; esac
Test gate: TEST_CMD="uv run pyright && uv run pytest -q". Putting the type check in the gate matters more in Python than anywhere else: it's the only thing between the agent and a confident-looking AttributeError at runtime.
.NET: nothing much changes either
This is my day job, and the only .NET-specific parts are the strict build and the commands.
Strict build: a Directory.Build.props at the solution root:
<Project>
<PropertyGroup>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
</Project>
Format hook:
f=$(jq -r '.tool_input.file_path')
case "$f" in *.cs) dotnet format whitespace --include "$f" ;; esac
Test gate: TEST_CMD="dotnet test --nologo -v q".
MCP: the Microsoft Learn server lets Claude check current .NET docs instead of what it remembers about an API:
claude mcp add --transport http microsoft-learn https://learn.microsoft.com/api/mcp --scope project
Android: Kotlin and Gradle
Strict build: allWarningsAsErrors = true in the Kotlin compiler options.
Format hook: case "$f" in *.kt|*.kts) ktlint -F "$f" ;; esac
Test gate: TEST_CMD="./gradlew testDebugUnitTest -q". Gradle is slow to start, which is exactly why it belongs in the Stop hook (once per turn) and never in the per-edit hook.
Permissions: allow your stack's commands
The one other per-stack bit: add your build and test commands to permissions.allow so Claude doesn't ask every time. For example "Bash(swift test *)", "Bash(uv run *)", "Bash(dotnet test *)" or "Bash(./gradlew *)". Deny rules always win over allow rules, so the .env and git push denies above stay in force.
When I wouldn't do any of this
The Claude Code docs say it plainly: if you can describe the change in one sentence, skip the plan. A typo, a log line, a rename: just ask. Specs pay off for features, for anything touching data, and for code you'll maintain for years. For a spike, prompt-first is still right, and that's the line I drew in June.
What's next
- Part 2: making this portable.
AGENTS.md, the open Agent Skills format and MCP all work across tools, so the same repo can be driven from Codex or Kiro. What carries over, what doesn't, and why it's worth it. - Part 3: the same specs and tasks against a local model on my M1 Max, with real numbers on what it completes and what it doesn't.
Claude Code moves fast. File locations and flags here are from the current docs at code.claude.com (October 2026). If something has moved, the docs win.
STAY UPDATED
Get new posts on software engineering and AI in your inbox. No spam, unsubscribe anytime.