Agent workbench (capstone)
Compose Claude Code config, a custom Skill, a subagent, and your own MCP server into one automated, verifiable developer workbench — the whole section in one repo, shipped to the team.
Learning objectives
- Assemble config, a Skill, a subagent, and an MCP server in one repo.
- Automate quality with a hook and CI.
- Apply the 4 Ds across the setup.
- Ship it so the team inherits the workbench.
code/proj-ak-workbench/. Python runs offline; configs are ready to use.1 · The repo layout essential
tree.txtyour-project/
├── CLAUDE.md # K1: conventions, commands, guardrails
├── .claude/
│ ├── settings.json # K1/K2: permissions + a Stop hook (tests)
│ ├── commands/review.md # K2: /review slash command
│ ├── skills/house-style/SKILL.md # K4: team code + doc conventions
│ └── agents/code-reviewer.md # K5: least-privilege reviewer subagent
├── mcp/server.py # K6: MCP server exposing project tools/data
└── .github/workflows/claude-review.yml # K2: headless review on PRs
settings.json is where you tell Claude Code the rules of the house. It's plain JSON — a set of "key": value pairs wrapped in curly braces. Two sections matter here: permissions (what's allowed) and hooks (what runs automatically).
"allow"lists actions Claude may take without asking — runpytestorruff, read any file (Read(*)), and edit files undersrc/. The patterns in parentheses scope each permission narrowly."deny"is the safety rail: it blocksgit pushand any edit tovendor/. Deny always wins over allow, so risky actions stay in human hands.- The
"Stop"hook fires when Claude finishes a turn."matcher": "*"means "on every stop", and it runs one command. - That command,
pytest -q || echo TESTS-FAILED, runs your tests quietly; the||means "if pytest fails, print TESTS-FAILED" so a failure is loud and visible instead of silent.
What the output means: No visible output on its own — this file just configures Claude Code. Its effect is felt later: blocked pushes, and tests running automatically each time Claude stops.
Try this: Add "Bash(git commit:*)" to the allow list and think about whether you want that. The allow/deny split is exactly where you decide what Claude does on its own versus what needs you.
Before any code, this is a map of the repo you're about to build. Everything Claude reads to behave your way lives in one hidden folder, .claude/, next to your normal project files. A newcomer only needs to know: each file below is one capability, and the capital-K tags (K1, K2, …) point back to the lesson that introduced it.
CLAUDE.mdsits at the top — plain-English notes (conventions, common commands, guardrails) that Claude reads automatically every session, like an onboarding doc..claude/settings.jsonholds permissions (what Claude may or may not do) and hooks (commands that fire automatically at certain moments).commands/,skills/, andagents/each add one thing: a reusable/reviewshortcut, a Skill (packaged know-how), and a subagent (a focused helper with its own narrow permissions).mcp/server.pyis your own small server that hands Claude project data and actions;.github/workflows/…ymlruns a review automatically on every pull request.
What the output means: This is a directory tree, not a program — nothing runs. The ├── and └── lines just show which files live inside which folders (indentation = nesting).
Try this: Picture cloning this repo fresh: the moment you open it, Claude already knows your rules (CLAUDE.md), your limits (settings.json), and your tools. That inheritance-from-one-git pull is the whole point of the workbench.
2 · Wire the config essential
settings.json{
"permissions": {
"allow": ["Bash(pytest:*)", "Bash(ruff:*)", "Read(*)", "Edit(src/**)"],
"deny": ["Bash(git push:*)", "Edit(vendor/**)"]
},
"hooks": {
"Stop": [ { "matcher": "*", "hooks": [
{ "type": "command", "command": "pytest -q || echo TESTS-FAILED" } ]} ]
}
}
3 · The MCP server (K6) intermediate
server.pyfrom mcp.server.fastmcp import FastMCP
mcp = FastMCP("project-tools")
def query_tracker(**kw): ... # your ticket-system integration
def read_deploy_log(service): ... # your deploy-log integration
@mcp.tool()
def open_tickets(component: str) -> list[dict]:
"""List open tickets for a component. Use when triaging or planning."""
return query_tracker(status="open", component=component)
@mcp.tool()
def last_deploy(service: str) -> dict:
"""Return the last deploy's status and commit for a service."""
return read_deploy_log(service)
if __name__ == "__main__":
mcp.run(transport="stdio")
An MCP server is a small program that exposes your own tools to Claude in a standard way (MCP = Model Context Protocol). Here it's built with FastMCP, a helper that turns ordinary Python functions into tools Claude can call. Once this runs, Claude can look up tickets and deploy status through your systems.
mcp = FastMCP("project-tools")creates the server and names it. That name is how Claude will refer to this bundle of tools.query_trackerandread_deploy_logare placeholders (the...means "you fill this in") — they stand for wherever your real ticket system and deploy logs live.- The
@mcp.tool()line above a function is a decorator: it registers that function as a callable tool. The docstring right under it ("""List open tickets…""") is not a comment — Claude reads it to decide when to use the tool, so it must describe the purpose plainly. - The
-> list[dict]and-> dicttype hints tell Claude the shape of the answer. Finallymcp.run(transport="stdio")starts the server, talking over standard input/output so Claude Code can connect to it locally.
What the output means: Run directly, this prints nothing and waits — an MCP server is a background service, not a script that finishes. Claude Code starts it and calls its tools on demand.
Try this: Read each tool's docstring as if you were Claude deciding which to call. A vague docstring means the wrong tool gets picked — clear descriptions are how the model routes to the right tool.
4 · Advanced — Skill + subagent advanced
house-style.md---
name: house-style
description: >
Apply our team's code and doc conventions. Use whenever writing or editing
code, docs, or PR descriptions in this repo.
---
# House style
- Functions: type hints + a one-line docstring stating the contract.
- Errors: raise specific exceptions; never `except: pass`.
- Docs: lead with the "why", then the "how"; examples must be runnable.
- PRs: describe the change, the risk, and how it was verified.
A Skill is packaged know-how Claude loads on demand. The file has two parts: a front-matter header (between the --- lines) that tells Claude when to use the Skill, and a Markdown body that says what to do. This one encodes your team's coding and writing conventions.
- The
---fences mark YAML front-matter — structured metadata.nameis the Skill's id. descriptionis the crucial line: Claude reads it to decide whether this Skill applies. "Use whenever writing or editing code, docs, or PR descriptions" is a clear trigger, so the Skill activates at the right moments.- Everything below the closing
---is plain Markdown — the actual rules. Bullets keep them scannable: type hints, specific exceptions (neverexcept: pass), docs that lead with the "why". - Because it's just text, the Skill is version-controlled with the repo, so the whole team's Claude follows the same conventions automatically.
What the output means: No output — a Skill is instructions, not a program. Its effect shows up in the code and docs Claude writes: they start matching these house rules.
Try this: Add one rule you care about (e.g. "tests live next to the code they test"). The next time Claude edits this repo, that rule is in force — you've taught the whole team's assistant with one line.
5 · Professional — automate the review professional
claude-review.ymlname: Claude review
on: [pull_request]
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- env: { ANTHROPIC_API_KEY: "${{ secrets.ANTHROPIC_API_KEY }}" }
run: |
git diff origin/main...HEAD > /tmp/diff.txt
claude -p "Use the code-reviewer subagent on /tmp/diff.txt. \
Output findings grouped by severity." > review.md
cat review.md
This is a GitHub Actions workflow — a YAML recipe GitHub runs automatically. Its job: every time someone opens a pull request, spin up a fresh machine, run Claude in headless mode (no chat window) to review the change, and save the findings. This is your review, automated for the whole team.
on: [pull_request]is the trigger — the workflow fires on every PR. Theruns-on: ubuntu-latestline gives it a clean Linux machine to work on.actions/checkout@v4withfetch-depth: 0downloads the full git history so the diff can be computed againstmain.ANTHROPIC_API_KEYis read from repo secrets (${{ secrets.… }}) — the key is never written in the file, so it stays private.- The
run:block is shell: save the PR's changes to/tmp/diff.txt, thenclaude -p "…"runs one prompt non-interactively, asking the code-reviewer subagent to review that diff and group findings by severity, writing the result toreview.md.
What the output means: On each PR, a review.md is produced with the subagent's findings grouped by severity, and cat review.md prints it into the CI logs for anyone to read.
Try this: Notice the same subagent used interactively (K5) is reused here headlessly. Write once, run it both in your editor and in CI — that's the leverage of committing your setup.
6 · Professional — the 4 Ds, made concrete professional
The workbench is where AI Fluency becomes concrete: Delegation — the deny-list keeps risky actions human; Description — CLAUDE.md + the Skill carry intent; Discernment — the Stop hook + CI review verify; Diligence — permissions + no-auto-push keep it responsible.
workbench_check.pydef workbench_ready(repo):
required = {
"claude_md": "CLAUDE.md (K1)",
"settings_permissions": "permissions (K1)",
"stop_hook": "test-on-stop hook (K2)",
"skill": "a house Skill (K4)",
"subagent": "a subagent (K5)",
"mcp_server": "an MCP server (K6)",
"ci_review": "CI review (K2)",
}
missing = [desc for key, desc in required.items() if not repo.get(key)]
return (not missing), missing
full = dict(claude_md=1, settings_permissions=1, stop_hook=1, skill=1,
subagent=1, mcp_server=1, ci_review=1)
print("ready:", workbench_ready(full))
partial = dict(claude_md=1, settings_permissions=1, skill=1)
print("partial:", workbench_ready(partial))
ready: (True, [])
partial: (False, ['test-on-stop hook (K2)', 'a subagent (K5)', 'an MCP server (K6)', 'CI review (K2)'])
This little program checks that your workbench is complete — that every piece from the section (CLAUDE.md, permissions, the Stop hook, a Skill, a subagent, an MCP server, CI review) is actually present. It's a self-test you can run to prove the setup is whole, and unlike the configs above, it really runs.
requiredis a dictionary mapping a short key (like"stop_hook") to a human-readable label. It's the checklist of everything the workbench must have.repois expected to be a dictionary describing what's present.repo.get(key)returns the value for that key, orNoneif it's absent — sonot repo.get(key)is true for anything missing.- The line
missing = [desc for key, desc in required.items() if not repo.get(key)]is a list comprehension: it walks the checklist and collects the labels of every piece the repo lacks. return (not missing), missinghands back two things: a True/False "is it ready?" (not missingis True only when the list is empty) and the list of what's still missing. The two calls below test a full repo and a partial one.
What the output means: ready: (True, []) — the full repo passes with nothing missing. partial: (False, [...]) — the partial repo fails and names exactly the four pieces it lacks (the hook, subagent, MCP server, and CI review).
Try this: Delete one key from the full dict and re-run: that piece's label jumps into the missing list and ready flips to False. This is how you'd gate a 'workbench complete' check in CI.
7 · Tech-lead — ship the workbench to the team tech-lead
Commit the whole .claude/ tree + the MCP server. Every teammate who clones the repo inherits your steering, Skills, subagents, and safety — the entire section, working for the whole team from one git pull.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: A shared agent workbench is only as safe as its permission model. The foundation is a .claude/settings.json where a deny always beats an allow, so a risky action stays human even under a broad allow.
Your task: Model the allow/deny permission resolution: given an action, decide ALLOW, DENY, or ASK, with deny checked first and unmatched actions defaulting to a human prompt.
Requirements:
- Parse rules of the form
Tool(pattern:*)into a tool name and argument pattern - Match an incoming action (tool + argument) against a rule
- Check the deny list first — a deny wins even if an allow also matches
- Fall through to ASK when neither list matches
- Show a safe command allowed, a risky command denied, and an unknown command asking
💡 Hint: Order is the whole lesson: evaluate deny before allow so a broad allow can never re-open something the deny list closed.
Show solution
The permission resolver — deny wins (pure stdlib, mirrors settings.json semantics):
ALLOW = ["Bash(pytest:*)", "Bash(ruff:*)", "Read(*)", "Edit(src/**)"]
DENY = ["Bash(git push:*)", "Edit(vendor/**)"]
def matches(rule, action):
tool, pat = rule[:-1].split("(", 1) # e.g. Bash(pytest:*)
return action["tool"] == tool and action["arg"].startswith(pat.rstrip("*"))
def decide(action):
if any(matches(r, action) for r in DENY): # deny checked first, wins
return "DENY"
if any(matches(r, action) for r in ALLOW):
return "ALLOW"
return "ASK" # default: prompt the human
print(decide({"tool": "Bash", "arg": "pytest -q"})) # ALLOW
print(decide({"tool": "Bash", "arg": "git push origin"})) # DENY
print(decide({"tool": "Bash", "arg": "rm -rf /"})) # ASK
The allow-list grants specific safe actions; the deny-list keeps risky ones (git push, vendor edits) human — and deny beats allow so a broad allow can't accidentally open a dangerous door. Anything unmatched falls through to ASK, the safe default.
Context: Quality you rely on a human to remember isn't automated. A Stop hook makes the harness run the tests every time Claude finishes, and a CLAUDE.md makes team conventions travel with the repo instead of in one person's head.
Your task: Define a .claude/settings.json Stop hook that runs the test suite on every finish and a CLAUDE.md carrying conventions, then reason about what the hook guarantees.
Requirements:
- A Stop hook under the
hookskey with a matcher that fires on every stop - A command-type hook that runs the tests and surfaces failure loudly
- A CLAUDE.md holding conventions and common commands in plain Markdown
- Explain that the harness — not the model — runs the hook, so it can't be skipped
- State the guarantee: no change quietly completes with failing tests
💡 Hint: Use a * matcher and a command like pytest -q || echo TESTS-FAILED so a failure is visible; the reliability comes from the harness executing it, not the model.
Show solution
The Stop hook as literal config (this is .claude/settings.json, not runnable Python):
{
"permissions": {
"allow": ["Bash(pytest:*)", "Read(*)", "Edit(src/**)"],
"deny": ["Bash(git push:*)"]
},
"hooks": {
"Stop": [
{
"matcher": "*",
"hooks": [
{ "type": "command", "command": "pytest -q || echo TESTS-FAILED" }
]
}
]
}
}
The Stop hook runs pytest automatically whenever Claude stops, so no change is "done" until the suite is green — the harness executes this, not the model, so it cannot be skipped. CLAUDE.md carries the conventions and commands so intent travels with the repo, not in one person's head.
Context: Claude is far more useful when it can reach your systems. An MCP server exposes your ticket tracker and deploy logs as standard tools, with docstrings acting as the routing hints the model reads to pick the right one.
Your task: Show the correct FastMCP server shape: typed tool functions with docstrings that guide routing, run over stdio so Claude Code can spawn it. Mark this rung as needing the SDK.
Requirements:
- Create a FastMCP server with a descriptive name
- Register each tool with the tool decorator on a typed function
- Write a clear docstring per tool — the model reads it as a routing hint
- Give return type hints so the model knows the response shape
- Run over stdio so Claude Code can launch it as a subprocess
- Label the rung as requiring the MCP SDK
💡 Hint: The docstring is the interface the model routes on — treat it as a spec, not a comment — and return types tell the model what shape comes back.
Show solution
The MCP server — needs pip install mcp (documented FastMCP API):
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("project-tools")
def query_tracker(**kw): ... # your real integrations
def read_deploy_log(service): ...
@mcp.tool()
def open_tickets(component: str) -> list[dict]:
"""List open tickets for a component. Use when triaging or planning work."""
return query_tracker(status="open", component=component)
@mcp.tool()
def last_deploy(service: str) -> dict:
"""Return the last deploy's status and commit for a service."""
return read_deploy_log(service)
if __name__ == "__main__":
mcp.run(transport="stdio") # host launches this as a subprocess
Each @mcp.tool() registers a typed function whose docstring is the routing hint Claude reads to decide when to call it. Written once and committed, the server lets any teammate's Claude query tickets and deploys through the same standard tools — write-once, reuse-everywhere.
Context: A focused subagent does one job with exactly the access that job needs. A code reviewer that can edit files or run shell commands is a liability; narrowing it to read-only is the entire point.
Your task: Define a code-reviewer subagent spec that is read-only on files and outputs findings grouped by severity, then reason about why narrowing its permissions is the design goal.
Requirements:
- A Markdown subagent spec with a clear name and a single purpose
- Behaviour: analyse the diff for correctness and group findings by severity
- Tooling restricted to read-only — no edits, no shell
- An explicit, actionable output format (findings grouped critical/warning/note)
- Explain least-privilege: one job, minimum access, smaller blast radius
💡 Hint: Ask what a reviewer must NOT be able to do — the answer (write files, run commands) is exactly what you leave out of its tool list.
Show solution
The subagent definition (this is .claude/agents/code-reviewer.md — Markdown spec, not code):
# Code Reviewer
A focused subagent for reviewing diffs. Least-privilege: read-only on files.
Behavior:
- Analyze the diff strictly for correctness.
- Group findings by severity: critical (must fix), warning (should fix),
note (nice-to-have).
- Output markdown with line-by-line references.
Tools: Read only. No Edit, no Bash. It reviews; it does not change code.
A subagent is a narrow specialist with its own least-privilege permissions — read-only here, so a reviewer can never edit or run anything. Scoping it tightly is the safety point: it does exactly one job with exactly the access that job needs, and nothing more.
Context: The same subagent that helps you interactively should guard every pull request headlessly. A GitHub Action runs the reviewer on each PR diff, with the API key coming from secrets — never inlined.
Your task: Show a GitHub Actions workflow that runs the reviewer subagent on every PR's diff via the headless claude -p invocation, reading the API key from secrets. Mark this rung as needing an API key.
Requirements:
- Trigger the workflow on pull-request events
- Check out with full history so the PR diff can be computed against main
- Read the API key from repo secrets — never inline it in the YAML
- Compute the PR diff and pass it to a headless
claude -prun - Instruct the run to use the code-reviewer subagent and group findings by severity
- Emit the findings into the CI logs
💡 Hint: Headless means no chat window: claude -p "..." with the diff as input, and the prompt is what points it at your subagent.
Show solution
Headless review in CI — needs the Claude Code CLI + ANTHROPIC_API_KEY secret (this is a GitHub Actions YAML):
name: Claude review
on: [pull_request]
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- env:
ANTHROPIC_API_KEY: "${{ secrets.ANTHROPIC_API_KEY }}" # never inline
run: |
git diff origin/main...HEAD > /tmp/diff.txt
claude -p "Use the code-reviewer subagent on /tmp/diff.txt. \
Output findings grouped by severity." > review.md
cat review.md
The same reviewer subagent runs interactively for a developer and headlessly (claude -p) on every PR — one encoded judgment, two entry points. The key comes from CI secrets, never the file, so the automation stays safe to commit and share.
Context: As tech lead, the leverage is institutional: commit the whole workbench so the team inherits your judgment. This milestone verifies every piece is present and maps each to one of the 4 Ds (Delegation, Description, Discernment, Diligence).
Your task: Write a completeness check that verifies every workbench piece is present and maps each to the 4 D it serves, returning whether the workbench is ready and what's missing.
Requirements:
- Enumerate the pieces: CLAUDE.md, permissions, Stop hook, Skill, subagent, MCP server, CI review
- Map each piece to the D it serves (Delegation / Description / Discernment / Diligence)
- Take a repo-state record and return a ready flag plus a list of missing pieces
- List missing pieces in human-readable form (piece + the D it serves)
- Run it on a complete repo and a partial repo to show it catches gaps
- Explain the leverage: committing
.claude/makes your judgment enforceable team-wide
💡 Hint: A dict mapping each piece to its D, plus a comprehension that collects pieces the repo lacks, gives you both the verdict and the punch-list in one pass.
Show solution
The completeness gate mapped to the 4 Ds (pure stdlib, runnable):
PIECES = { # piece -> which of the 4 Ds it serves
"claude_md": "Description (intent travels with the repo)",
"settings_permissions": "Delegation (allow safe, deny risky)",
"stop_hook": "Discernment (tests verify every change)",
"skill": "Description (house conventions encoded)",
"subagent": "Delegation (least-privilege specialist)",
"mcp_server": "Delegation (domain tools, write-once)",
"ci_review": "Diligence (automated, on every PR)",
}
def workbench_ready(repo):
missing = [f"{k}: {d}" for k, d in PIECES.items() if not repo.get(k)]
return (not missing), missing
full = {k: 1 for k in PIECES}
print("ready:", workbench_ready(full)[0]) # True
partial = dict(full); partial["ci_review"] = 0
print("missing:", workbench_ready(partial)[1]) # Diligence gap
Industry scenario: ten engineers each configure Claude locally and inconsistently. Committing the .claude/ tree + MCP server makes the workbench institutional leverage: your judgment, encoded once, enforced for everyone. The 4 Ds are the checklist — delegate with scoped permissions, describe intent in CLAUDE.md/Skills, discern quality with hooks, and stay diligent with CI review.
✓ Checkpoint — you can move on when you can…
- Assemble CLAUDE.md, settings+hook, Skill, subagent, MCP server.
- Automate verification with a Stop hook and CI review.
- Explain where each of the 4 Ds shows up.
- Ship the workbench so the team inherits it.
| Dimension | Meets the bar | Above the bar (staff) |
|---|---|---|
| Tooling completeness | The repo has CLAUDE.md, a Skill, a subagent, and an MCP server — each present and doing one clear job. | The pieces compose: the subagent uses the Skill and MCP tools, and a fresh git pull gives a teammate the whole working setup. |
| Permission & safety config | settings.json scopes an allow-list and a deny-list; risky actions (git push, vendor edits) are denied so deny wins over allow. | Permissions are genuinely least-privilege, the deny-list is defended against footguns, and dangerous actions stay in human hands by design. |
| MCP tool quality | The MCP server exposes project tools with clear docstrings and typed signatures Claude can route to correctly. | Docstrings are written as routing hints (a vague one picks the wrong tool), and the tools are scoped to what the workbench actually needs. |
| CI review automation | A CI workflow runs a headless Claude review on every PR with the API key read from secrets, output grouped by severity. | The same subagent is reused interactively and headlessly, secrets never touch the file, and the review output is actionable in the PR. |
| Verifiability | A completeness check (Stop hook / workbench_check.py) proves every required piece is present. | The check is a real gate in CI, so an incomplete workbench fails the build rather than shipping half-configured. |
| Ship to the team & the 4 Ds | The whole .claude/ tree + MCP server is committed so teammates inherit it; you can place each of the 4 Ds (Delegation, Description, Discernment, Diligence). | The workbench is institutional leverage: your judgment is encoded once and enforced automatically for every engineer's Claude, verifiably. |
Score each dimension 0 (missing), 1 (meets bar), or 2 (above bar). 0–4: keep building. 5–8: a solid, defensible submission. 9–12: staff-level — you could hand this to a reviewer and defend every call. Any dimension at 0 blocks shipping regardless of the total.
Knowledge check check yourself
Why is git push placed on the deny list in settings.json rather than simply omitted from the allow list?
Show answer
Why does committing the whole .claude/ tree plus the MCP server give more leverage than each engineer configuring Claude locally?
Show answer
git pull makes every teammate inherit the same steering (CLAUDE.md), limits (permissions/deny-list), Skills, subagents, and tools — your judgment is encoded once and enforced automatically across the team, instead of relying on each person to set it up.