Claude Code, Properly Wired: A One-Stop Guide to Spec-Driven Development on a Real Project A guide to spec-driven development with Claude Code, executed on a Flask + SQLite expense tracker called Spendly, demonstrates how to wire slash commands, subagents, skills, hooks, and a CLAUDE.md file to make AI-assisted development reliable. The repository includes 6 subagents, 7 slash commands, 2 skills, 3 Python hooks, 12 specs, and 252 tests, with the .claude/ directory as the deliverable. Everything in this guide was executed on one repository — a Flask + SQLite expense tracker called Spendly — from empty .claude/ directory to a live app on AWS EC2 behind HTTPS. Every number, screenshot, and command is from that run. Nothing is illustrative. Claude Code becomes reliable when we stop prompting it and start wiring it. A slash command orchestrates subagents; subagents load skills for domain knowledge; hooks enforce invariants deterministically outside the model’s judgment; and a CLAUDE.md holds the facts every session needs. The discipline that makes this work is Spec-Driven Development — write the contract first, review it, then build against it, because a spec is the only artifact that both we and the model can hold each other to. Spendly is deliberately boring: a personal expense tracker, Flask, SQLite, Jinja2 templates, vanilla JS, no build step. The app is the vehicle . The .claude/ directory is the deliverable . spendly/├── app.py every route, single file, no blueprints├── database/│ ├── db.py connection + schema + users│ └── queries.py expense + profile reads/writes├── templates/ base.html + one file per page├── static/css/ style.css global; one file per page otherwise├── tests/ pytest — 252 tests├── requirements.txt dev + test├── requirements-prod.txt -r requirements.txt + gunicorn├── Dockerfile phase 1├── .dockerignore├── compose.yaml└── deploy/vm/ phase 2 — nginx, systemd, bootstrap, RUNBOOK .claude/├── CLAUDE.md project root, actually always-on project facts├── agents/ 6 subagents│ ├── spendly-test-writer.md spec → tests│ ├── spendly-test-runner.md run + diagnose│ ├── spendly-security-reviewer.md auth, injection, exposure│ ├── spendly-quality-reviewer.md naming, placement, idiom│ ├── spendly-devops-engineer.md builds deploy artifacts│ └── spendly-devops-reviewer.md audits them, read-only├── commands/ 7 slash commands│ ├── create-spec.md spec + branch│ ├── test-feature.md writer → runner│ ├── code-review-feature.md security ∥ quality│ ├── deploy-phase.md engineer → reviewer│ ├── ship-feature.md commit → PR → merge → cleanup│ ├── seed-user.md│ └── seed-expense.md├── skills/ 2 skills│ ├── spendly-devops/│ │ ├── SKILL.md router + phase 0 + invariants│ │ └── references/ phase-1-docker, phase-2-cloud-vm,│ │ phase-3-kubernetes, cicd│ └── spendly-ui-designer/SKILL.md├── hooks/ 3 Python hooks│ ├── devops router.py UserPromptSubmit│ ├── format python.py PostToolUse│ └── protect paths.py PreToolUse — blocking├── specs/ 12 specs, one per feature step├── settings.json hooks + permission allowlist shared ├── settings.local.json personal overrides untracked └── verify setup.py 56 checks that the wiring is intact A useful way to understand the repository is to separate the product plane from an agent control plane. The product plane says what the software is. The control plane says how an AI worker is expected to interact with that software. This is why the .claude/ configuration deserves the same engineering attention as CI configuration or infrastructure code. A malformed command can route work incorrectly; a stale skill can produce a bad design; a broken hook can silently stop enforcing a safety rule. This also explains why the guide calls the application the vehicle and the Claude setup the deliverable . The interesting artifact is not a Flask expense tracker; it is a repeatable operating model for an AI-assisted repository. Although most files are Markdown, they are not merely documentation. Their content changes runtime behavior: Human request ↓Claude loads project facts and routing descriptions ↓Commands/agents/skills shape the task ↓Hooks may allow, modify, warn, or block actions ↓Repository or external system changes That makes these files closer to policy-as-code than to a wiki page. The syntax is prose, but the operational effect is real. verify setup.py therefore plays the role of a lightweight type checker for the agent control plane. It turns hidden coupling into explicit tests. The broader lesson is reusable: whenever an AI workflow depends on filenames, skill names, frontmatter fields, documented routes, or expected tool availability, add a small machine check for those assumptions. Keep the control plane small, layered, and testable: That separation reduces prompt duplication and makes failures easier to diagnose. There are two, and they do different jobs. Ours is short — a working-style preamble: Faizul User WorkflowDefault working style:- Prefer actionable engineering outputs- Use markdown tables when comparing- For architecture: include tradeoffs- For troubleshooting: root cause - fix - validationWhen coding:- prefer production-grade patterns- secure defaults- readable structure- explain changed filesWhen uncertain:- say assumptions clearly- suggest verification That is the right content for a global file: preferences, not facts. It applies whether we are in a Flask repo or a Terraform one. Ours is 402 lines and ~5,100 tokens, loaded into every session. It carries: 2. State the why , briefly. Compare: ❌ Never put DB logic in routes. ✅ Never put DB logic in routes — it belongs indatabase/db.py connection, schema, users ordatabase/queries.py anything touchingexpenses . The second version tells an agent which file, which is the part it actually needs. 3. Document the traps, not just the rules. The highest-value lines in our CLAUDE.md are the warnings: - app.secret key is hardcoded and debug=True is hardcoded in main . Fine for local dev, unsafe anywhere else.- seed db runs at import time and creates demo@spendly.com / demo123. Harmless locally; a working backdoor on any public host.- There is no CSRF protection. Known gap — raise it, don't silently add a dependency for it.- There is no migration system. A schema change means hand-editing a live SQLite file — back it up with VACUUM INTO, never cp. Each of those saved an agent from a confident wrong move. 4. Every rule needs an escape hatch, stated. We say “no new pip packages” — and then: The one sanctioned exception — approved and landed. Deployment needs a WSGIserver, because app.run debug=True exposes the Werkzeug debugger remote codeexecution . gunicorn==23.0.0 therefore lives in requirements-prod.txt. Without that, an agent facing phase 1 has to either break a rule or fail. With it, the decision is already made. 5. Sync it in the same change, always. If a PR adds a route, it updates the route table. Not “later”. We enforce this mechanically — verify setup.py compares CLAUDE.md's route table against app.py in both directions, so a route in the code but missing from the docs fails the check, and vice versa. The project CLAUDE.md behaves like a persistent working-memory preamble. The important design question is therefore not "what documentation would be nice to have?" but: What information is so frequently necessary, and so costly to rediscover or get wrong, that it deserves to consume context in every relevant session? That framing produces three categories. architecture, repository conventions, safety rules, known constraints, route ownership, test commands, and the location of important components. A long step-by-step deployment procedure is usually better as a skill or reference file that loads only when deployment work begins. Current issue status, temporary debugging notes, one-off hypotheses, or an unfinished migration checklist should normally be tracked in a task/spec/issue rather than being injected into every future session. Every always-loaded paragraph competes with source code, tool results, the current specification, and the conversation. This is why concise, accurate project facts are more valuable than encyclopedic documentation. A 20-line warning section containing repo-specific traps can outperform 200 lines of generic best practices because it changes decisions at the exact points where mistakes are likely. Think of context as a cache: high reuse + high consequence if wrong → keep warm in CLAUDE.mdlow reuse or large detail → load on demandshort-lived state → keep outside persistent context If six agents trust one stale statement, the error is multiplied. This is a form of configuration drift: the code and the agent’s declared model of the code no longer match. The same idea exists in infrastructure management — desired state and actual state diverge — but here the desired state is natural-language knowledge. The strongest mitigation is not “remind people to update docs.” It is to make drift observable: That is why “sync it in the same change” is more than style. It is a consistency transaction across code and agent knowledge. A slash command is a markdown file in .claude/commands/. The filename becomes the command. create-spec.md → /create-spec. Frontmatter configures it: ---description: Create a spec file and feature branch for the next Spendly stepargument-hint: "Step number and feature name e.g. 2 registration"allowed-tools: Read, Write, Glob, Grep, Agent, Bash git: --- You are a senior developer spinning up a new feature...User input: $ARGUMENTS The body is a prompt, not a script. It is instructions to the model, so it can contain conditionals, gates, and refusals in plain English: If no argument is provided, stop immediately and say:"Please provide a phase. Usage: /deploy-phase <0|1|2|3|cicd " allowed-tools is the security boundary. /code-review-feature is scoped to Bash git diff , Bash git status , Read, Glob — it structurally cannot edit a file, no matter what the diff tempts it to do. A slash command is useful because it converts a vague natural-language request into a named, repeatable procedure. It is not just a shortcut for a long prompt. It can encode gates, actor selection, allowed tools, preconditions, and the expected shape of the final handoff. A good command answers five questions: This makes a command similar to a lightweight runbook or CI job, except its steps can contain model reasoning. Least privilege belongs at the orchestration boundary The allowed-tools field is powerful because it constrains the capability surface of the command. If a code-review workflow only needs to read diffs, it should not have generic Bash or Edit access. This follows the same security principle used for IAM roles and service accounts: grant the minimum capability necessary for the job. The benefit is not only security. Narrow tools improve reasoning quality because the agent has fewer irrelevant actions available. A read-only reviewer is naturally more likely to report findings than to “helpfully” rewrite the code it is supposed to assess. An idempotent workflow can be run twice without creating uncontrolled side effects. For example, a command can: Not every command can be perfectly idempotent, but designing for safe re-entry is important because agent sessions can fail, hit limits, or be interrupted halfway through a multi-step workflow. Natural language is still code-like The body is prose, but operationally it has control flow: IF no phase argument STOP with usageELSE IF prerequisite failed REPORT blockerELSE SPAWN engineer WAIT SPAWN reviewer REPORT handover Writing commands with explicit conditions, stop rules, and expected outputs reduces ambiguity. Treat important prompt workflows with the same care we would give a shell script: clear inputs, predictable branches, and observable failures. Every session starts with a fixed budget. Understanding what consumes it before we type anything is the difference between a setup that scales and one that chokes. What loads automatically, and what does not That table is the whole game. Descriptions are always-on; bodies are on-demand. We first wrote five sibling skills — spendly-devops, spendly-docker, spendly-cloud-vm, spendly-kubernetes, spendly-cicd. Measured cost: spendly-cicd desc = 711 charsspendly-cloud-vm desc = 770spendly-devops desc = 759spendly-docker desc = 664spendly-kubernetes desc = 827-------------------------------------TOTAL 3,731 chars ≈ 932 tokens, in EVERY session Nearly a thousand tokens spent in sessions that never touch deployment. Worse, the descriptions were long because they had to disambiguate from each other — spendly-kubernetes needed 827 characters listing "PVC, StorageClass, CrashLoopBackOff, HPA" purely so it would not collide with spendly-docker. The tell that it was wrong: every one of those skills opened with “load spendly-devops first." A skill that is not usable until you load a sibling is a chapter , not a skill. The fix — progressive disclosure. One skill, four reference files: .claude/skills/spendly-devops/├── SKILL.md router + phase 0 + invariants 281 lines └── references/ ├── phase-1-docker.md 273 lines, loaded only for phase 1 ├── phase-2-cloud-vm.md 393 ├── phase-3-kubernetes.md 430 └── cicd.md 311 Always-on cost: 932 tokens → 146 tokens -786 On-demand body: unchanged — 1,407 lines still available when needed The router’s description no longer disambiguates anything; it only has to recognise “this is infrastructure-shaped.” Watch item: ourCLAUDE.md grew from ~150 lines to 402 ~5,100 tokens over the project. That is the next thing worth trimming — probably by moving the "Adding a new feature" walkthrough into a skill. The context window is not merely a technical limit; it is the agent’s active information budget. The more irrelevant or redundant material occupies it, the less room remains for the actual problem. There are three common forms of context waste: Progressive disclosure is the antidote. Keep routing metadata small then load detail only after the task has been classified. Always loaded └── enough information to choose the right path ↓On-demand skill └── enough information to perform the domain workflow ↓Reference file └── detailed phase-specific facts and commands This is analogous to memory hierarchy in computer systems: keep small, frequently used information close; move bulky, rarely used information behind a lookup. A short context can still be bad if it contains contradictions. The model must then spend reasoning effort deciding which instruction is authoritative, and may choose the wrong one. Context engineering therefore has two goals: A useful review question is: If an agent saw only this paragraph and the code it is about, could it make the intended decision? If not, the paragraph may be too vague, too stale, or stored at the wrong layer. A subagent acts as a temporary private scratch space. It can read many files, perform searches, and return a compact result. This is not free — the subagent still consumes compute and tokens — but it keeps the main session from becoming polluted with every intermediate observation. The architecture is therefore a fan-out / summarize / fan-in pattern: Main context ↓ delegate searchExplore context reads broadly ↓ summarizeMain context receives conclusions This is most useful when exploration is broad but the final decision depends on a small number of findings. The two Review gates are the point. Anyone can get a model to write code; the difficulty is knowing whether the code is the right code . A spec makes that answerable, because it converts "does this look good?" into "does this satisfy the Definition of Done?" Spec → Review → Design → Review → Tasks → Build → Validate Why this matters more with an agent than with a human. A human developer who misunderstands a requirement usually produces something visibly odd. An agent that misunderstands produces something confidently plausible — correct-looking code solving the wrong problem. The spec is the artifact that makes that detectable, because tests get written from the spec, not from the implementation. That last clause is the mechanism. Our spendly-test-writer agent is explicitly forbidden from reading the implementation for test logic: Core PrincipleYou write tests based on feature specifications and expected behavior , never byreading or reverse-engineering the implementation. Your tests define what thefeature should do, serving as a correctness contract. If tests are derived from the code, they assert that the code does what it does — which is always true and tells you nothing. git pull origin main → git checkout -b feature/x → git switch feature/x ↓ Spec → Review → Design → Review → Tasks → Build → Validate ↓git commit -m → git push origin → create+merge PR → git branch -d → switch As for example Step 7 is a useful first end-to-end example because it crosses the route, template, validation, and database layers without adding a new schema. The real spec at .claude/specs/07-add-expense.md says the existing GET /expenses/add placeholder must become a logged-in GET + POST flow, backed by insert expense in database/queries.py. Its validation contract includes a positive numeric amount, one of seven fixed categories, an ISO date, and an optional description. The current implementation in app.py follows that shape: it checks the session, validates each field, calls insert expense ... , flashes success, and redirects to profile. Run: /create-spec 7 add expense /create-spec does more than create a Markdown file. Its current command definition requires this sequence: git status ↓parse step/title/slug ↓ensure branch name is free ↓checkout main + pull ↓create feature branch ↓delegate repository research to Explore ↓write .claude/specs/07-add-expense.md ↓report branch + spec path For this feature, the spec’s most important contract can be summarized as: Routes- GET /expenses/add — logged-in only- POST /expenses/add — logged-in only Rules- amount 0- category ∈ seven fixed categories- date must be YYYY-MM-DD- description is optional- DB writes live in database/queries.py- parameterised SQL only- success redirects to /profile Definition of done- logged-out users redirect to /login- valid submission creates a row- invalid amount/category/date re-renders with an error- optional description works- Add Expense navigation is visible That is what “configure the spec” means in practice: define behavior, boundaries, files, and a testable finish line before asking Claude to implement. Read: .claude/specs/07-add-expense.md Check especially: If any answer is wrong, edit the specification now. This is cheaper than correcting the same misunderstanding after code and tests exist. Use Shift+Tab twice to enter Plan mode. A good implementation-planning prompt is: Read .claude/specs/07-add-expense.md and CLAUDE.md.Use the built-in Plan/Explore workflow required by the project.Do not edit files yet. Produce an implementation plan that maps every Definition-of-Done item to theexact files/functions/templates that need to change. Call out validation anddatabase-isolation risks before implementation. Review the plan and approve it only when it matches the spec. After leaving Plan mode: Implement the approved Step 7 plan exactly against.claude/specs/07-add-expense.md. Keep scope inside the spec. Reuse existing Flask and database patterns.Do not add dependencies or schema changes. When done, summarize changed filesand map them back to the spec. The current codebase confirms the implementation landed in the expected places: app.py, database/queries.py, templates/add expense.html, profile/navigation templates, and CSS. Run: /test-feature 07-add-expense The command is intentionally two-stage: spendly-test-writer ↓ derives tests from the spec, not implementationwrites tests/test 07 add expense.py ↓spendly-test-runner ↓runs only that feature test file and diagnoses failures The current repository contains tests/test 07 add expense.py, which confirms this feature ended with dedicated executable coverage. If tests fail, fix the implementation against the spec, not by weakening the test to match the code. Then rerun /test-feature 07-add-expense. Run: /code-review-feature 07-add-expense The command first collects tracked and untracked changes, then starts the security and quality reviewers in parallel. If the verdict is CHANGES REQUESTED, approve the action plan explicitly, apply it, then rerun the feature tests and review. Run: /ship-feature The shipping command: identify feature branch ↓generate conventional commit message ↓commit ↓push ↓create PR ↓wait for CI checks ↓squash merge ↓delete remote branch ↓pull main ↓delete local branch That is the full SDD loop in executable form: /create-spec → human spec review → Plan mode → human plan review → implement → /test-feature → /code-review-feature → fix/retest if necessary → /ship-feature This is the one spec that we captured and configure end to end through Claude setup. This case is the cleanest reference because both the article and the current repository retain the spec, dedicated tests, security finding, review evidence, and merged-PR screenshots. 1. Create the branch and spec /create-spec 10 export expenses csv The generated .claude/specs/10-export-expenses-csv.md is unusually valuable because it records decisions that a generic implementation prompt would likely miss. Our task is just run the /command of create-spec : Route- GET /expenses/export — logged-in CSV attachmentKey rules- user id is required by the query helper and enforced in SQL- never accept user id from the request- reuse the existing date-filter behavior- return raw amount/date values, not formatted display strings- do not reuse get recent transactions because it formats values and has limit=10- order by date DESC, id DESC- write CSV in memory with csv.writer + io.StringIO- no temporary file Selected Definition-of-Done items make the contract concrete: logged out → 302 /loginlogged in → 200 text/csv attachmentheader row presentraw numeric amount + ISO datemore than 10 expenses → all exportedUser A never sees User B's rowstwo-sided date filter matches profileinvalid/one-sided dates follow existing profile behaviorempty description → empty fieldzero rows → header-only CSVverify setup.py passes 2. Review the spec before code.If require we can edit accrding to our requirements. we should not skip the subtle decisions: 3. Plan in read-only mode Use Shift+Tab twice, then: Read .claude/specs/10-export-expenses-csv.md and CLAUDE.md.Use the built-in Plan agent and delegated repository research. Plan the smallest implementation that satisfies every DoD item.Pay special attention to:- raw vs display-formatted values,- date-filter parity with /profile,- user ownership scoping,- CSV response headers,- testability. Do not edit files yet. 4. Implement only after plan approval Implement the approved Step 10 plan.Treat .claude/specs/10-export-expenses-csv.md as the contract.Do not widen scope. Use the project UI skill for the profile link styling and preserve existingdatabase-layer boundaries. Summarize changed files when complete. 5. Generate and run spec-derived tests /test-feature 10-export-expenses-csv The repository retains tests/test 10 export expenses csv.py. In the captured run, the writer produced 25 test functions / 29 cases and the runner mapped them back to the DoD. 6. Run parallel review /code-review-feature 10-export-expenses-csv This is where the security reviewer found the issue that a purely functional test suite could miss: spreadsheet formula injection in CSV free-text cells. The fix added csv safe in app.py. The current code confirms the mitigation: leading =, +, -, or @ after left-trimming is prefixed with an apostrophe before CSV serialization. After applying a review fix: /test-feature 10-export-expenses-csv/code-review-feature 10-export-expenses-csv Do not ship from a stale pre-fix review. 7. Ship /ship-feature The captured run produced PR 3, squash-merged it, and removed both feature branches. /create-spec 10 export expenses csv checked the tree was clean, branched to feature/export-expenses-csv, delegated research to the Explore subagent, and wrote .claude/specs/10-export-expenses-csv.md with a fixed shape: Overview, Depends on, Routes, Database changes, Templates, Files to change, Files to create, New dependencies, Rules for implementation, Definition of done. The implementation subagent read the spec, then CLAUDE.md, queries.py, app.py, profile.html, profile.css — and then grepped style.css for --token: declarations: That grep is the spendly-ui-designer skill's rule "use the tokens, never hardcode a hex" being obeyed without anyone restating it. This is what a correctly wired setup looks like from the outside. /test-feature 10-export-expenses-csv ran writer → runner, sequentially: 25 test functions, 29 cases, each traceable to a Definition-of-Done item: /code-review-feature 10-export-expenses-csv forked both reviewers in parallel: Visible in the dashboard as two Agent spawns from one Main: The finding that justified the whole pipeline: security cleared the auth guard, SQL-level user id scoping, and the parameterised date filter — then flagged CSV formula injection via free-text description and category. A cell beginning =, +, -, or @ executes as a formula when the file opens in Excel. Fixed in the same PR with a csv safe helper. Severity: Medium. Caught pre-merge. Ship. /ship-feature → PR 3, squash-merged, both branches deleted: With the audit trail written into the PR body: In this project, that behavior is explicitly instructed in the test-writer agent itself . The main source is: .claude/agents/spendly-test-writer.md Its description says the agent generates tests from the feature’s expected behavior and spec, not by reading the implementation code Result: feature live, 186 → 215 tests. The request arrived as one sentence or from a new specification: “change the currency from Rs to BDT, change the default name, make it feel Bangladeshi, and change the design.” This is a good example of why the spec can decide not to generalize a change. Start with: /create-spec 11 bangladesh localization The real .claude/specs/11-bangladesh-localization.md explicitly records two decisions before implementation: Decision 1:Keep Western "{:,.2f}" digit grouping.Do not introduce lakh/crore formatting. Decision 2:Keep demo@spendly.com / demo123.Change display/context data, not the documented demo login. The spec also says: That last point is subtle and important: historical specs are records of their time; the current CLAUDE.md becomes the forward-looking source of truth. Plan prompt Read .claude/specs/11-bangladesh-localization.md and CLAUDE.md. Plan a content/configuration-only change. Identify every exact literal that changesand every file that must remain untouched. Pay special attention to:- database/queries.py must have zero diff,- demo login credentials must not change,- historical specs 05-09 stay unchanged,- the UI skill and seed commands must not retain Indian-context instructions. Do not edit yet. Implementation prompt Implement the approved Step 11 localization plan exactly.Make only the literal/content/configuration changes named in the spec.Do not refactor number formatting or database queries. Testing is intentionally different in this case Current main has no tests/test 11 bangladesh localization.py. The spec itself requires updating the existing tests/test 06 date filter profile.py currency assertions and then running the full suite plus verify setup.py. So the honest validation sequence for this historical case is: python -m pytest tests/test 06 date filter profile.py -vpython -m pytest -qpython .claude/verify setup.pygit diff -- database/queries.pygit diff -- .claude/specs/ This is an important exception to avoid papering over: the current generic /test-feature command expects a feature-specific test file, while Step 11's final repository state used existing coverage. A reusable team workflow should either add a dedicated Step-11 test file or teach /test-feature to honor a spec's explicit "modify existing tests" strategy. Review and ship /code-review-feature 11-bangladesh-localization Check especially for scope creep and stale instruction files. After an approved review and a green full suite: /ship-feature Start with: /create-spec 12 bangladesh design refresh The real spec demonstrates a design decision captured inside the specification. It proposed three palettes, then recorded: Decision: Option A--accent: 006A4E--accent-light: E3F3EC--accent-2: F42A41--accent-2-light: FDE6E8--paper / --ink: unchanged The spec then deliberately narrowed the implementation: Plan prompt Read .claude/specs/12-bangladesh-design-refresh.md and thespendly-ui-designer skill. Plan Option A exactly as selected by the spec.Keep the change token-driven: do not rewrite landing.css, profile.css oranalytics.css. Include the skill-documentation update and visual verificationsteps. Do not edit yet. Implementation prompt Implement the approved Step 12 plan.Use only the chosen Option A token values.Do not introduce new palette choices or layout changes.Update the UI skill in the same change so the reviewer reads the new palette asground truth. Test /test-feature 12-bangladesh-design-refresh The current repository contains tests/test 12 bangladesh design refresh.py, so this case follows the dedicated feature-test pipeline cleanly. Then perform the spec’s visual checks: / before + after/profile before + after/expenses/add before + after<768px responsive verification Review /code-review-feature 12-bangladesh-design-refresh The quality reviewer reading the UI skill is part of the design: if the skill were left stale, correct CSS could be reported as a violation. Ship /ship-feature The final repository test file and current palette confirm this became executable, reviewable project state rather than a one-off styling prompt. That is two features, and splitting them was the highest-value decision. Mixed into one PR we cannot tell a broken layout from a broken currency render, and if the design is wrong we lose the localization on rollback. Step 11 — every ₹ → ৳, placeholders to Faizul islam/ faizul@example.com, and seed data rewritten to Bangladeshi context: Groceries from Meena Bazar, CNG fare to office, DESCO electricity bill. Two decisions were settled in the spec , before any code: Step 12 — --accent: 1a472a → 006A4E Bangladesh bottle green , --accent-2: c17f24 → F42A41 flag red , new favicon.svg, +37 tests. The critical instruction: update spendly-ui-designer/SKILL.md in the same change. It hardcodes the palette, and the quality reviewer reads it as ground truth — leave it stale and the reviewer flags our correct new code as a violation. We can watch it happen: Not a feature: a prerequisite. The app as written could not run outside a dev checkout, for four independent reasons. Why Phase 0 is not run through /create-spec This is the useful counter-example to the four feature cases above. Phase 0 is a deployment prerequisite owned by the DevOps command/agent/skill chain, so forcing it through the normal feature-spec command would duplicate the deployment knowledge already encoded in spendly-devops. The workflow is: /deploy-phase 0 ↓spendly-devops-engineer ↓Skill spendly-devops ↓phase-0 rules/invariants ↓implementation artifacts/code changes ↓spendly-devops-reviewer ↓handover A good user prompt is simply: /deploy-phase 0 or, when coming from a natural-language request: Prepare Spendly for deployment. Start with Phase 0 only.Do not move to Docker/cloud yet. The DevOps router/skill is responsible for recognizing that the application first needs secret handling, persistent state, safe seeding, and health/readiness semantics. After implementation, verify the application tests and wiring checks, then use the deployment reviewer. Only after Phase 0 is green should /deploy-phase 1 create the Docker artifacts. This distinction is deliberate: product feature → /create-spec → Plan → build → /test-feature → /code-review-feature → /ship-featuredeployment prerequisite → /deploy-phase → engineer → DevOps skill → DevOps reviewer → controlled ship Run via /deploy-phase 0. Its pre-flight: Then engineer → reviewer: A design detail worth stealing: /healthz and /readyz are deliberately different. python @app.route "/healthz" def healthz : """Liveness — the process is up. Deliberately does NOT touch the DB.""" return {"status": "ok"}, 200@app.route "/readyz" def readyz : """Readiness — the DB is reachable and writable.""" if not db is healthy : abort 503 return {"status": "ready"}, 200 A liveness probe that touches the database turns a locked database into a restart loop , which makes the lock worse. Liveness asks “is the process wedged”; readiness asks “should traffic come here”. Conflating them is a classic self-inflicted outage. A repo-specific trap this surfaced: verify setup.py hardcodes the route list twice — forward "claimed routes exist in app.py" and reverse "no undocumented routes" . Adding two routes fails both until CLAUDE.md's table and both literals are updated. The duplication is intentional; a comment now says so, so nobody "fixes" the failure by deleting a list. Our project contains many Claude-related components: CLAUDE.md.claude/├── agents/├── skills/├── commands/├── hooks/└── settings.jsonspecs/tests/application files These components reference one another. For example: Slash command ↓references an Agent ↓Agent may use project conventions ↓Skill provides reusable knowledge verify setup.py acts as a structural/integration sanity checker for this configuration. For example, imagine a command says: Use the spendly-test-writer agent but somebody later renames: spendly-test-writer.md to: test-writer.md Now our Claude workflow has a broken reference. The application itself may still work perfectly: pytest252 passed but Claude automation could be broken. verify setup.py is intended to catch this kind of problem. Spec-Driven Development SDD is easiest to understand as a contract-first control loop. The specification defines observable behaviour before implementation. Code, tests, and review are then evaluated against that contract. It is related to, but not identical with, several familiar practices: SDD can include TDD or BDD. The important difference is that the specification is an explicit first-class artifact and review gate, rather than an assumption that lives only in a prompt or developer’s head. A strong feature spec usually separates: This prevents the common failure where a spec is merely an implementation plan. A plan says how we think we will build it ; a spec says what must be true when we are done . The plan may change while the contract remains stable. If the test writer studies implementation details first, it can accidentally mirror bugs. This is called implementation-coupled testing: the test asks whether the code behaves like itself rather than whether it behaves like the requirement. A cleaner chain is: Specification ├──→ implementation └──→ independent tests ↓ compare at runtime Both code and tests derive from the same contract but are produced independently. That creates useful disagreement. If they conflict, the spec becomes the arbitration point. The DoD-to-test table shown in the Spendly run is a lightweight requirements traceability matrix. In regulated systems this idea is formal; here it is pragmatic: every important acceptance statement should have an observable validation path. A simple structure is enough: DoD item → test s → result → reviewer finding → PR evidence This provides three benefits: The two review points before implementation are where humans have the highest leverage. Fixing a misunderstood requirement in a 30-line spec is cheaper than fixing it after code, tests, documentation, and deployment artifacts have all been created from the wrong assumption. That is the economic reason for SDD: move disagreement earlier, when change is cheap. Four, cycled with Shift+Tab: Set a default in settings.json: { "permissions": { "defaultMode": "acceptEdits" } } Or launch straight into bypass: claude --dangerously-skip-permissionsclaude -c --dangerously-skip-permissions -c resumes the current conversation If ~/.claude/settings.json has "skipDangerousModePermissionPrompt": true, the startup warning is pre-dismissed. This is the most under-used mode. In plan mode the model cannot write, which changes its behaviour: it reads more, and it surfaces disagreements before they are baked into a diff. Our CLAUDE.md makes it mandatory: - always use a builtin plan subagent in plan mode- When asked to plan, delegate codebase research to a subagent before presenting Enter with Shift+Tab twice. Exit by approving the plan. Separate from permissions, Claude Code has reasoning effort: low, medium, high, xhigh, max. The status line shows the active tier: Sonnet 5 1M | medium default, active: default | main | $0.3781.2k tokens | 8% | 39% resets 4h 15m bypass permissions on shift+tab to cycle Escalate for hard reasoning — an ambiguous bug, an architectural trade-off, an adversarial review. Drop to low for mechanical work. Most of Spendly ran on medium. There is also a genuinely separate feature: claude ultrareview cloud-hosted multi-agent review of the current branch That runs a fleet rather than a single reviewer, which is a different tool from raising effort on one agent. --dangerously-skip-permissions silences the harness. It does not silence the model's judgment — Claude will still pause before hard-to-reverse or outward-facing actions. In this project, even in bypass mode, it stopped to ask before untracking database files containing real password hashes. To collapse that second layer we must say so in words: “commit and push without asking” , “merge PRs yourself” . Two layers, two mechanisms. A common conceptual mistake is to treat “YOLO” as if it makes the model more intelligent or “plan mode” as if it makes the model more cautious by personality. These settings operate on different axes. Permission mode controls what the harness will allow without human approval. It is an execution control. Reasoning effort controls how much deliberate reasoning the model spends. It is a cognition/compute control. This creates a 2D decision space: more reasoning ↑ plan/high │ bypass/high safe deep design │ autonomous deep work │ ─────────────────────┼────────────────────→ more execution autonomy │ default/low │ bypass/low quick inspection │ mechanical automation The right combination depends on risk, reversibility, and uncertainty. Before choosing a mode, ask: High blast radius + low reversibility is a strong argument for default/plan mode, even when the code change itself looks simple. The guide correctly distinguishes harness permission from model judgment. There is also a third layer worth naming: organizational controls outside Claude, such as branch protection, required reviews, cloud IAM, CI gates, and production approval workflows. Layer 1: Claude permission promptsLayer 2: model instructions / judgment / hooksLayer 3: external systems that enforce policy regardless of Claude For important systems, rely on layer 3 for true enforcement. A local bypass flag should never be able to bypass an organization’s production guardrail. The safest place for bypass mode is an environment where the maximum possible damage is already constrained: a disposable branch, container, test account, ephemeral VM, or isolated development namespace. That is the security principle of containment: instead of trusting every future action to be correct, design the environment so an incorrect action has limited consequences. The distinction that took us longest to internalise: A command decideswhat happens in what order. An agent decideswho does it, with which tools. A skill supplieswhat they need to know. Note the difference between → and ∥. Test-writer must finish before the runner has a file to run. The two reviewers are independent, so they fork — visible as two Agent spawns in the dashboard. The chain only works because the agent definition says so, as its first instruction : Step 1 — Load the skill. Always. Before anything else.1. Invoke the spendly-devops skill using the Skill tool.2. If that fails, read .claude/skills/spendly-devops/SKILL.md with Read . Do not write a single artifact from general Docker or Kubernetes knowledge. The skill carries traps specific to this repo that generic knowledge will miss. Belt and braces — the Skill tool and a Read fallback, so the chain does not depend on one mechanism.We can watch it work: That Skill spendly-devops line is the policy executing. spendly-devops-engineertools: Read, Write, Edit, Grep, Glob, Bash, Skill spendly-devops-reviewertools: Read, Grep, Glob, Bash git diff , Bash git status , Skill The reviewer structurally cannot edit a file. Not “is told not to” — cannot. That is a much stronger guarantee than instruction-following, and it is free. Every link above is a string in a markdown file. Rename an agent and the command still names the old one — and it fails silently at runtime , because a missing subagent looks like a subagent that had nothing to say. We hit exactly this. So: python .claude/verify setup.py 56 checks; non-zero exit on any break It verifies: It found real breaks every time the setup changed. If you build a chain like this, build the verifier too — it is 150 lines and it is the difference between wiring you trust and wiring you hope about. The command → agent → skill architecture maps cleanly to three software-design ideas. Command = orchestration. It owns workflow state and ordering. Agent = execution boundary. It owns context isolation, role, and capabilities. Skill = reusable domain knowledge. It owns procedure, heuristics, traps, and reference material. The reason to keep them separate is the same reason application code separates controllers, services, and libraries: each changes for a different reason. If one large prompt contains all three concerns, every change risks unintended side-effects elsewhere. A robust chain has explicit contracts: Command → Agent input: objective, scope, constraints, expected output Agent → Skill input: domain/task classification Skill → Agent output: rules, references, validation procedure Agent → Command output: handover block / findings / status The Handover convention in the guide is an example of a simple interface. It makes outputs machine- and human-predictable. Use sequential execution when there is a data dependency: test writer → test runner The runner cannot validate tests that do not yet exist. Use parallel execution when tasks are independent and you want diversity: security reviewer ─┐ ├→ combined reviewquality reviewer ──┘ Parallel reviewers are useful not only for speed but for independent failure modes. A security reviewer and a quality reviewer are primed to notice different classes of defects. Because the system has no compiler for its Markdown references, the verifier is a form of contract test for the agent architecture. The generalizable pattern is: If a configuration error would otherwise fail only when a rare workflow is invoked, move that failure to a cheap automated check that runs frequently. This is exactly what unit tests, schema validation, CI linting, and Terraform validation already do in other domains. Beyond custom agents, Claude Code ships general-purpose ones. The two we lean on: /create-spec delegates research to Explore rather than reading files itself: Step 6 — Research the codebase delegate this CLAUDE.md 's Subagent Policy requires codebase research to be delegated. Do notread these files yourself — launch the builtin Explore subagent with breadth"medium" and ask it to report: - app.py — every existing route, its methods, and its auth guard - database/db.py and database/queries.py — the DB layer is two modules - .claude/specs/ .md — so the new spec does not duplicate an existing one Why delegate at all? Context economy. Explore burns its own window reading twelve specs and two DB modules, then hands back a summary. The main session pays for the conclusion, not the search. On a repo this size that is the difference between having room to implement and running out mid-feature. Our policy, verbatim: Subagent Policy- Always use a builtin explore subagent for codebase exploration before implementing any new feature- Always use a subagent to verify test results after any implementation- When asked to plan, delegate codebase research to a subagent before presenting- always use a builtin plan subagent in plan mode Subagents are most valuable when they reduce one of three pressures on the main session: They are less useful when the task is tiny or when every agent must repeatedly read the same large context. Delegation has overhead: spawning, rediscovering context, communicating results, and reconciling disagreement. A useful threshold is: If explaining the task to a subagent costs nearly as much as doing the task,keep it in the main session. A reviewer in a fresh context is less anchored to the implementation story told by the authoring agent. This is analogous to independent code review: the reviewer may notice an authorization flaw precisely because it did not participate in the design choices that made the code look “obviously correct” to the implementer. That does not make subagents objectively independent — the same model family may be used — but separate prompts and context still reduce shared local assumptions. Concurrent agents should usually operate on either: Two writing agents in one working tree can overwrite each other’s assumptions or observe half-written files. The failure described later in the guide is a classic shared-state race condition. Agent orchestration therefore benefits from ordinary concurrency principles: ownership, isolation, synchronization, and clear handoff. We do not use MCP in Spendly. This section is theory plus a working design, and an honest account of why we did not need it. The Model Context Protocol is an open standard that lets a model call tools hosted in a separate process. Instead of teaching the model to shell out to psql, you run an MCP server that exposes query database as a typed tool with a schema. ┌──────────────────────────────────────────────────────────┐│ Claude Code ││ ││ ┌────────────────┐ ┌──────────────────────┐ ││ │ Built-in tools │ │ MCP client │ ││ │ Read/Edit/Bash │ │ │ ││ └────────────────┘ └──────────┬───────────┘ │└────────────────────────────────────────┼─────────────────┘ │ JSON-RPC 2.0 ┌────────────────┼────────────────┐ │ stdio │ HTTP/SSE │ ┌─────────▼──────┐ ┌──────▼───────┐ ┌─────▼────────┐ │ spendly-db │ │ github │ │ docker │ │ MCP server │ │ MCP server │ │ MCP server │ │ │ │ │ │ │ │ tools: │ │ tools: │ │ tools: │ │ query expenses│ │ create pr │ │ ps, logs │ │ schema info │ │ merge pr │ │ compose up │ │ row counts │ │ list issues │ │ inspect │ └────────┬───────┘ └──────┬───────┘ └─────┬────────┘ │ │ │ ┌────▼─────┐ ┌────▼────┐ ┌─────▼──────┐ │ SQLite │ │ GitHub │ │ Docker │ │ spendly. │ │ REST v3 │ │ daemon │ │ db │ │ │ │ socket │ └──────────┘ └─────────┘ └────────────┘ Transports: stdio local subprocess — most common or HTTP/SSE remote . Primitives: tools model-invoked actions , resources readable data , prompts reusable templates . MCP earns its complexity when a capability is not reachable from a shell or when you want a typed, constrained interface instead of arbitrary commands. Every capability was one shell command away. Adding MCP would have meant a second process, a schema to maintain, and tool definitions in the context window — for nothing. The honest engineering answer was: not here. Concretely, for a Python web project with DB + git + Docker: Here is the read-only server that would have been defensible — Bash minus the ability to write: mcp servers/spendly db.py"""Read-only MCP server over Spendly's SQLite database. Deliberately narrower than Bash sqlite3 : SELECT only, capped rows, noattach/pragma, and the DB path comes from the environment rather than the caller. Run: python mcp servers/spendly db.pyRegister in .mcp.json see below .""" python import osimport reimport sqlite3from pathlib import Path python from mcp.server.fastmcp import FastMCP mcp = FastMCP "spendly-db" DB PATH = os.environ.get "SPENDLY DB PATH", "spendly.db" MAX ROWS = 500 php One statement, starting with SELECT. No semicolons - no statement chaining. SELECT ONLY = re.compile r"^\s SELECT\b", re.IGNORECASE FORBIDDEN = re.compile r"\b ATTACH|DETACH|PRAGMA|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|REPLACE \b", re.IGNORECASE, php def connect - sqlite3.Connection: Immutable URI: the driver itself refuses writes, so a bug in our regex cannot become a mutation. Defence in depth, not just validation. conn = sqlite3.connect f"file:{Path DB PATH }?immutable=1", uri=True conn.row factory = sqlite3.Row return conn php @mcp.tool def query readonly sql: str - dict: """Run a single read-only SELECT and return rows as JSON. Rejects anything that is not one SELECT statement. Caps output at 500 rows. """ if ";" in sql.rstrip .rstrip ";" : return {"error": "one statement only — no semicolons"} if not SELECT ONLY.match sql : return {"error": "only SELECT is permitted"} if FORBIDDEN.search sql : return {"error": "statement contains a forbidden keyword"} conn = connect try: rows = conn.execute sql .fetchmany MAX ROWS return { "columns": d 0 for d in conn.execute sql .description , "rows": dict r for r in rows , "row count": len rows , "truncated": len rows == MAX ROWS, } except sqlite3.Error as exc: return {"error": f"{type exc . name }: {exc}"} finally: conn.close php @mcp.tool def schema info - dict: """Return every table and its columns — orientation without guessing.""" conn = connect try: tables = r "name" for r in conn.execute "SELECT name FROM sqlite master WHERE type='table' " "AND name NOT LIKE 'sqlite %' ORDER BY name" return { t: {"name": c "name" , "type": c "type" , "notnull": bool c "notnull" } for c in conn.execute f"PRAGMA table info {t} " for t in tables } finally: conn.close php @mcp.tool def row counts - dict: """Row count per table — the cheapest sanity check after a migration.""" conn = connect try: tables = r "name" for r in conn.execute "SELECT name FROM sqlite master WHERE type='table' " "AND name NOT LIKE 'sqlite %'" return {t: conn.execute f"SELECT COUNT FROM {t}" .fetchone 0 for t in tables} finally: conn.close php @mcp.resource "spendly://schema" def schema resource - str: """The schema as a readable resource, for context rather than a tool call.""" return "\n".join f"{t}: {', '.join c 'name' for c in cols }" for t, cols in schema info .items if name == " main ": mcp.run stdio transport Register it — project-scoped, in .mcp.json at the repo root: { "mcpServers": { "spendly-db": { "command": "python", "args": "mcp servers/spendly db.py" , "env": { "SPENDLY DB PATH": "spendly.db" } } }} Then /mcp lists it, and tools appear as mcp spendly-db query readonly. Note the layered defence: a regex allowlist and immutable=1 on the connection. If the regex has a hole, the driver still refuses to write. Validation alone is how MCP servers become the vulnerability they were meant to prevent. MCP servers add context. Tool schemas are injected. Prefer few, well-scoped tools over dozens. They can vanish. Mid-project our Docker MCP server disconnected and its tools became uncallable. Anything load-bearing needs a fallback — which is precisely why our agents specify the Skill tool and a Read fallback. A clever pattern we found in the wild: agents-observe ships an MCP server that exposes zero tools. It is a lifecycle hook — it starts a Docker container, heartbeats every 10s, and deregisters on SIGTERM. Zero context cost, real work done. MCP as a process supervisor. Claude starts │ ▼MCP server starts │ ├── Starts a Docker container ├── Sends heartbeat every 10 seconds ├── Keeps running in background │ ▼Claude/session terminates │ ▼MCP receives SIGTERM │ ├── Deregisters itself └── Cleans up The MCP server doesn’t give Claude callable functions such as: get metrics restart container query logs There are no MCP tools for Claude to choose from . Instead, simply starting the MCP server causes useful background work to happen . That’s why we call it a: lifecycle hook The MCP process’s start and stop lifecycle is being used as the trigger. Normally, if an MCP server exposes 20 tools, Claude needs information about those tools so it understands when/how to use them. Conceptually: 20 MCP tools ↓tool names + descriptions + schemas ↓added information Claude must understand With this pattern: MCP tools = 0 ↓No tool definitions for Claude ↓Very little/no tool-schema context overhead Yet the MCP process itself can still do work: start containermonitor lifecyclesend heartbeatregister agentclean up So the phrase means: Claude doesn’t need to spend context understanding/calling MCP tools, but the MCP server still performs useful background work. This is the most important sentence. Usually: MCP = Claude ↔ external tools/data MCP = start → supervise → stop background process For example: Claude Code session │ ▼ agents-observe MCP │ ├── start Docker container │ ├── keep it alive │ ├── heartbeat │ └── watch lifecycle │Claude exits ▼ SIGTERM │ ▼ deregister / cleanup So “process supervisor” means it manages the lifetime of another running process/container. This is different from the observability MCP design we might normally imagine. A conventional approach could be: Claude │ └── MCP ├── query Prometheus ├── query Loki └── query Grafana Claude explicitly calls tools. The pattern described in our quote is closer to: So the clever idea is not “MCP lets Claude call something.” It is: “Because Claude automatically manages the MCP server’s lifecycle, we can exploit that lifecycle to automatically start and stop another service — even though Claude never calls an MCP tool.” That’s why we describes it as MCP as a process supervisor . MCP is often introduced as “a way to connect Claude to external tools.” That is true, but the more important architectural idea is capability shaping. With unrestricted shell access, the model receives a broad capability: Bash → potentially every executable and every permission of the current user An MCP server can replace that with a narrow capability: query readonly sql: SELECT only, max 500 rows This is a security improvement when the MCP implementation is actually narrower than the shell access it replaces. The model does not directly “know” how to talk to SQLite, Jira, or an internal service. Claude Code acts as an MCP client. A server publishes typed capabilities, and the client exposes those descriptions to the model. Model reasoning ↓ chooses toolClaude Code / MCP client ↓ protocol requestMCP server ↓ validates + actsTarget system The server is therefore a trust boundary. It must validate arguments, enforce authorization, limit output, handle credentials safely, and return errors that do not leak secrets. The distinction matters for least privilege. If the model only needs to read schema information, a resource is conceptually safer than a generic query tool. JSON schemas and structured return values help the model call a capability correctly, but typing is not authorization. A perfectly typed delete database name tool is still dangerous. Security must be enforced inside the server and in the credentials it holds. The example combines two independent controls: This is stronger because a bug in one layer does not immediately become a write. The general principle is: Prefer a safe underlying primitive plus validation, rather than relying on a parser/regex alone to make a dangerous primitive safe. MCP adds operational surface area: process lifecycle, authentication, versioning, logging, schema maintenance, context cost, and failure handling. If git status or docker ps already solves the problem safely under a restricted command allowlist, an MCP wrapper may be unnecessary abstraction. Use MCP when it creates one of these concrete benefits: A skill is advice . A hook is mechanism . Skills persuade the model; hooks execute regardless of what the model concludes. This distinction has a sharp consequence: Hooks still fire under--dangerously-skip-permissions. A PreToolUse hook returning exit code 2 blocks the tool call in any permission mode. So even in full YOLO, rm -f spendly.db is stopped. You build a floor that YOLO cannot fall through. .claude/settings.json: { "hooks": { "UserPromptSubmit": { "hooks": { "type": "command", "command": "python3 .claude/hooks/devops router.py" } } , "PostToolUse": { "matcher": "Write|Edit", "hooks": { "type": "command", "command": "python3 .claude/hooks/format python.py" } } , "PreToolUse": { "matcher": "Bash", "hooks": { "type": "command", "command": "python3 .claude/hooks/protect paths.py" } } }} Contract: JSON payload on stdin. For UserPromptSubmit, anything on stdout is injected into the model's context. For PreToolUse, exit 2 denies the call and stderr is fed back to the model. There are ~28 events available — SessionStart, SubagentStart, SubagentStop, PreCompact, PermissionDenied, TaskCompleted, FileChanged, and more. PROTECTED = "spendly.db", "spendly-backup.db", ".env", "migrations/", "/var/lib/spendly", "letsencrypt" DESTRUCTIVE VERBS = r"rm", r"unlink", r"truncate", r"shred", r"mkfs \.\w+ ?", r"dd" Destructive verb and protected path → exit 2, blocked 2. format python.py — cosmetic Runs black on any .py file written. No-ops with a message if black is not installed. 3. devops router.py — the interesting one The problem it solves: a new teammate types “can you dockerize this?” . They do not know /deploy-phase exists. Without help, the session writes a plausible Dockerfile from general knowledge — and bakes the SQLite database into an image layer, because it does not know DB PATH is hardcoded. The hook matches DevOps vocabulary and injects routing instructions before the model sees the prompt: