Claude Code in Action
Driving Claude Code well over a real, extended task: the steer loop, CLAUDE.md, scoped permissions (with a runnable matcher), verify-don't-trust, and standardizing it for a team.
Claude Code is an AI that lives in your terminal and can read files, run commands, and edit code — in a loop you influence. This chapter climbs from your first steered session to configuring a repo, controlling permissions, verifying work, and setting team standards. Claude Code commands run in your terminal; the Python blocks (a permission model, a verification gate) run offline.
The words you'll hear (in plain terms):
| Term | What it actually means |
|---|---|
| agent | an AI that takes actions in a loop (act → observe → decide), not one reply. |
| steering | guiding the loop: plan approval, scoping, interrupting, rewinding. |
| CLAUDE.md | a project file Claude reads every session: conventions, commands, guardrails. |
| permission mode | what Claude may do without asking (read/edit/run). |
| verification | checking Claude's work (tests, diff) instead of trusting it. |
What you need before starting:
- The C3 intro is the gentle first look.
- Claude Code installed (or read along to learn the model).
- Basic command-line comfort.
New to the topic? Read this box, then take the chapters in order — each section is tagged essential → expert so you always know the depth you're at.
Learning objectives
- Explain why extended sessions drift and how the steer loop counters it.
- Configure a project with CLAUDE.md and scoped permissions.
- Verify work instead of trusting it.
- Set the steering + config standards a team shares.
code/ak1-claude-code-in-action/. Python runs offline; configs are ready to use.1 · Steering beats prompting essential
With a chat model you write one good prompt. With Claude Code you run a loop you influence: approve a plan, narrow scope, interrupt when it drifts, rewind when it goes wrong. The skill is steering, not one-shot prompting.
This is the whole idea of Claude Code in one picture. Unlike a chatbot where you send one prompt and get one answer, working with Claude Code is a loop you stay inside of and keep nudging. Read the five boxes left to right, then notice it repeats.
- You: goal — you start by saying what you want (your intent), e.g. "add pagination, don't touch auth".
- Plan — before writing any code, Claude proposes an approach. This is where a wrong assumption is cheap to fix.
- Act (edit/run) — Claude uses its tools: it edits files and runs commands in your terminal.
- Verify — you (or a test) check the result: did the tests pass? does the output look right?
- You: steer — you course-correct: approve, narrow the scope, interrupt, or rewind — then the loop runs again.
In short: The arrows never stop at "Act" — they always come back to you. That feedback loop (goal → plan → act → verify → steer → repeat) is what "steering" means, and it's the skill this whole chapter teaches.
2 · The extended-session problem essential
A one-line prompt is easy. A two-hour refactor across twenty files is where it drifts: the context window fills, early decisions get forgotten, the agent wanders. Understanding the loop's lifecycle is what lets you steer a long task without it going sideways.
3 · Plan first, then act essential
For anything non-trivial, get a plan before code — it catches wrong assumptions when they're cheap to fix.
A well-steered task
- State the goal and constraints ("add pagination; don't touch auth").
- Ask for a plan; read it. Correct a wrong step now, not after 12 edits.
- Approve; let it work in a scoped area. Watch the first tool calls to confirm direction.
- Interrupt (Esc) and re-steer if it drifts: "stop — wrong file."
- Verify yourself (run tests, read the diff). Only then move on.
steering.md> Plan a refactor that extracts the retry logic from api.py into a reusable
> decorator. List the files you'll touch and the risks. Do NOT write code yet —
> I'll approve the plan first.
# Claude explores, proposes an approach, and waits. You approve or correct
# BEFORE any edits. Enter plan mode explicitly, or just ask for a plan.
This is what you actually type to Claude Code to make it plan before it touches any code. The > at the start of each line is just the prompt shown in the terminal — you type the words after it. The whole message is one instruction.
- The first two lines state the goal precisely: extract the retry logic from
api.pyinto a reusable decorator. A vague goal gets a vague plan. - "List the files you'll touch and the risks" asks Claude to show its thinking first so you can catch a bad idea before it happens.
- "Do NOT write code yet — I'll approve the plan first" is the key line: it puts Claude in plan mode, so it explores and proposes but waits for your OK before editing.
- The
# commentunderneath is a note for you, the reader — it explains that Claude will explore, propose, and pause. You approve or correct before any files change.
What the output means: Claude replies with a written plan (files, steps, risks) and then stops. Nothing on disk changes until you say go.
Try this: Whenever a task is bigger than a one-line fix, add "plan first, don't code yet" to your request. Reading a plan takes seconds; undoing twelve wrong edits does not.
4 · Configure the project — CLAUDE.md intermediate
CLAUDE.md is project memory Claude reads every session: conventions, commands, and constraints. It's the single highest-leverage file for quality — it stops you re-explaining context every time and makes the whole team's Claude behave consistently.
CLAUDE.md# CLAUDE.md — read at the start of every session
## Commands
- Test: `pytest -q`
- Lint: `ruff check .`
- Run: `python -m app`
## Conventions
- Python 3.12, type hints on public functions.
- Never edit files under `vendor/` — generated.
- Prefer stdlib; a new dependency needs a note in the PR.
## Guardrails
- Do NOT commit or push unless I explicitly ask.
- Ask before deleting files or changing the DB schema.
CLAUDE.md is a plain text file you put in your project. Claude Code reads it automatically at the start of every session, so it's how you tell Claude the things you'd otherwise have to repeat every single time — your commands, your rules, your guardrails.
- The
## Commandssection lists how your project runs its test, lint, and start commands. Now Claude runspytest -qinstead of guessing. ## Conventionsencodes house style: Python 3.12, type hints, never editvendor/, prefer the standard library. These stop Claude from writing code that doesn't fit your repo.## Guardrailsare hard don't rules: don't commit or push unless asked; ask before deleting files or changing the database. These are the safety limits.- The headings (
##) are Markdown — just a readable way to organize the file. Claude reads the whole thing as context, so clear, short bullets work best.
Try this: Add one line to the Conventions section for a rule you keep repeating to Claude. That one line means you never have to say it again — and everyone on your team gets it too.
5 · Permissions — control what it can do intermediate
Claude Code asks before running commands or editing, governed by a permission mode + an allow/deny list in settings.json. Tightening this lets it move fast on safe actions while gating dangerous ones.
settings.json{
"permissions": {
"allow": ["Bash(pytest:*)", "Bash(ruff:*)", "Read(*)", "Edit(src/**)"],
"deny": ["Bash(rm -rf:*)", "Bash(git push:*)", "Edit(vendor/**)"]
}
}
This file (.claude/settings.json) is where you decide what Claude may do without stopping to ask you. It's JSON — a list of names and values in curly braces. Two lists do all the work: allow and deny.
"allow"lists safe actions Claude can take freely.Bash(pytest:*)means "anypytestcommand";Read(*)means "read any file";Edit(src/**)means "edit anything undersrc/". The*is a wildcard — it matches anything."deny"lists actions Claude must never do:Bash(rm -rf:*)(dangerous deletes),Bash(git push:*)(pushing code), andEdit(vendor/**)(editing generated files).- Anything not on either list falls through to "ask me first" — the safe default. You widen the allow-list as you learn which actions are safe.
What the output means: With this file in place, Claude runs tests and edits src/ without interrupting you, but stops and refuses to push or delete.
Try this: The next lab turns these exact rules into runnable code so you can see how a single action gets matched to ALLOW, DENY, or ASK.
6 · Advanced — model the permission decision advanced
How does an allow/deny list actually decide? Modeling it makes the rules unambiguous — and it's the logic a policy check would run.
permission_model.pyimport fnmatch
def decide(action, allow, deny):
"""deny wins over allow; unmatched -> ask the human."""
if any(fnmatch.fnmatch(action, d) for d in deny):
return "DENY"
if any(fnmatch.fnmatch(action, a) for a in allow):
return "ALLOW"
return "ASK"
allow = ["Bash(pytest*)", "Read(*)", "Edit(src/*)"]
deny = ["Bash(rm -rf*)", "Bash(git push*)", "Edit(vendor/*)"]
for act in ["Bash(pytest -q)", "Edit(src/app.py)", "Edit(vendor/x.py)",
"Bash(rm -rf /)", "Bash(curl evil.com)"]:
print(f"{decide(act, allow, deny):5} {act}")
ALLOW Bash(pytest -q)
ALLOW Edit(src/app.py)
DENY Edit(vendor/x.py)
DENY Bash(rm -rf /)
ASK Bash(curl evil.com)
This tiny Python program is the decision logic behind the allow/deny lists you just saw — written out so the rules are unambiguous. Given an action, it returns one of three answers: DENY, ALLOW, or ASK.
fnmatchis a standard-library tool that matches text against a wildcard pattern (soBash(pytest*)matchesBash(pytest -q)). It's the same idea as the*in the settings file.- The function checks the deny list first: if any deny pattern matches, it returns
DENYimmediately. This is the "deny wins over allow" rule from the docstring. - Only if nothing denied it does it check
allow→ALLOW. If neither list matches, it falls through toASK— hand the decision to a human. - The
forloop at the bottom runs five sample actions throughdecide()and prints the verdict for each, lined up in a neat column.
What the output means: Five lines: pytest and editing src/ are ALLOW; editing vendor/ and rm -rf / are DENY; the unknown curl command is ASK — exactly matching the settings you'd expect.
Try this: Add "Bash(curl*)" to the deny list and re-run. The curl line flips from ASK to DENY — proof that adding a deny rule tightens the gate.
7 · Advanced — verify, don't trust advanced
The single habit that makes agentic coding reliable: verify the work. An agent that says "done" has proven nothing until you see green tests and a clean diff.
verify_gate.pydef accept_change(tests_pass, diff_reviewed, touched_forbidden):
reasons = []
if not tests_pass: reasons.append("tests not green")
if not diff_reviewed: reasons.append("diff not reviewed")
if touched_forbidden: reasons.append("touched a forbidden path")
return (not reasons), reasons
print("accept?", accept_change(True, True, False))
print("accept?", accept_change(True, False, True))
accept? (True, [])
accept? (False, ['diff not reviewed', 'touched a forbidden path'])
The most important habit in agentic coding is verify, don't trust: an agent saying "done" proves nothing. This function turns "is this change acceptable?" into a clear yes/no with reasons, so you can't wave work through on vibes.
accept_changetakes three facts about the change: did the tests pass, did you review the diff, and did it touch a forbidden path?- Each
ifadds a human-readable reason to thereasonslist when something is wrong. No reasons means nothing is wrong. - It returns a pair:
(not reasons)isTrueonly when the reasons list is empty (accept it), plus the list of reasons so you know why if it was rejected. - The two
printcalls test it: one perfect change, one broken change (diff not reviewed and a forbidden path touched).
What the output means: The first line is (True, []) — accepted, no problems. The second is (False, ['diff not reviewed', 'touched a forbidden path']) — rejected, with the exact reasons spelled out.
Try this: Flip the first True to False (tests failing) and re-run — watch "tests not green" appear and the change get rejected. This is how you make "done" mean "actually verified".
8 · Professional — a repeatable session workflow professional
Your per-task Claude Code loop
- Pull latest; state the goal + constraints.
- Plan-first for anything non-trivial; approve/correct the plan.
- Let it work scoped; watch early tool calls.
- Verify: run tests, read the full diff, check no forbidden paths.
- Commit yourself with a clear message (DF2); never auto-push.
9 · Tech-lead — standardize steering across the team tech-lead
A lead commits a shared CLAUDE.md + settings.json so every engineer's Claude follows the same conventions, guardrails, and permissions. Consistency scales quality — and a config check keeps it honest.
config_audit.pydef audit_config(cfg):
problems = []
if "deny" not in cfg.get("permissions", {}):
problems.append("no deny-list -> risky actions ungated")
if "Bash(git push*)" not in cfg.get("permissions", {}).get("deny", []):
problems.append("push not denied -> agent could push unreviewed")
if not cfg.get("has_claude_md"):
problems.append("no CLAUDE.md -> conventions not shared")
if not cfg.get("stop_hook_tests"):
problems.append("no test-on-stop hook -> verification is manual")
return problems
weak = {"permissions": {"allow": ["Edit(*)"]}, "has_claude_md": False}
strong = {"permissions": {"allow":["Edit(src/*)"], "deny":["Bash(git push*)"]},
"has_claude_md": True, "stop_hook_tests": True}
print("weak repo:", audit_config(weak))
print("strong repo:", audit_config(strong) or "compliant")
weak repo: ['push not denied -> agent could push unreviewed', 'no CLAUDE.md -> conventions not shared', 'no test-on-stop hook -> verification is manual']
strong repo: compliant
A tech lead wants every engineer's Claude configured safely, not just their own. This function audits a repo's config and returns a list of problems — an automated check that the safety pieces are actually in place.
- It's handed a
cfgdictionary describing the repo's setup, and builds aproblemslist as it finds gaps. - The four checks map to the four safety habits from this chapter: is there a deny-list? is git push denied? is there a CLAUDE.md? is there a test-on-stop hook (automatic verification)?
- Each missing piece appends a plain-English problem explaining the risk, e.g. "no CLAUDE.md -> conventions not shared".
- It's run on two repos: a
weakone (allow-everything, no CLAUDE.md) and astrongone that follows every rule.
What the output means: The weak repo prints a list of three problems; the strong repo prints compliant (its problem list was empty, so [] or "compliant" shows the word instead).
Try this: Delete one good setting from the strong dictionary (say remove stop_hook_tests) and re-run — that repo stops being "compliant" and the matching problem appears. That's the audit doing its job.
.claude/ tree means steering discipline, guardrails, and verification are the default for everyone who clones the repo — not something each engineer re-learns. That's the tech-lead multiplier.Exercise AK1.1 — Steer + configure a real task
Context: This is the full loop of a well-run Claude Code session in miniature: steer the task, pin down conventions and permissions in config, plan before editing, and refuse to accept changes until they're proven.
Your task: Take a small refactor and run it end to end — a scoped CLAUDE.md and settings.json, plan-first execution with one mid-course re-steer, and a green-tests-plus-diff-review gate before accepting.
Requirements:
- The
CLAUDE.mdincludes the project commands and at least one "don't touch" rule - The
settings.jsonis scoped — read/test tools allowed, irreversible actions handled - Run it plan-first and interrupt once to re-steer mid-task
- Require a green test run and a diff review before accepting the change (use the
accept_changelogic) - Run
config_auditon your setup and address what it flags
💡 Hint: Do the smallest real refactor you can find — the value is in exercising the whole steer → configure → plan → verify loop, not the size of the change.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: In day-to-day Claude Code work, the difference between a session that lands and one that drifts is almost always how the request was framed — steering, not one clever prompt, is the real skill.
Your task: Rewrite a vague ask like "make the tests better" into a steered instruction that names the file, the specific gap, and the guardrails.
Requirements:
- The rewrite names a concrete file (or files) to work in
- It states the specific goal, not a mood — e.g. which missing test paths to add
- It sets an explicit constraint / no-go zone (e.g. don't touch production code, no new dependencies)
- The result is verifiable — you can check whether Claude did exactly that
- Contrast the vague and steered versions side by side so the difference is visible
💡 Hint: Think about what a teammate would need to execute the task without asking a follow-up question — file, gap, and guardrail.
Show solution
Vague prompts invite drift; steered instructions bound the work:
Vague: "make the tests better"
Steered: "In tests/test_auth.py, add cases for the expired-token and
missing-header paths. Use the existing pytest fixtures; don't
touch the production code or add new dependencies."
Naming the file, the specific gap, and the guardrails ("don't touch production code") turns an open-ended request into one Claude can execute without guessing — and one you can verify. Steering is giving direction throughout, not a single perfect prompt up front.
Context: Reviewing a plan is far cheaper than reviewing a diff: catching a wrong approach before any code exists costs one sentence instead of a revert, which is why the plan gate is the intermediate habit worth building.
Your task: Describe the plan-first workflow for a multi-file change and explain why gating on the plan matters before any edit is made.
Requirements:
- Show how to enter plan mode in the Claude Code CLI (and note UI shortcuts drift — verify in the docs)
- The plan is requested explicitly as plan-only: list files and approach, do not edit yet
- You read and correct the approach before code is written, not after
- Only after approval does Claude execute the agreed plan
- Explain the payoff: redirecting a plan is cheaper than reverting a diff
💡 Hint: The key insight is when you intervene, not the exact keystroke — put the review before the first edit.
Show solution
The plan-first loop, in practice (Claude Code CLI):
1. Enter plan mode (Shift+Tab to cycle to "plan", or start with a
"plan only, don't edit yet" instruction).
2. Ask: "Plan how to add rate limiting to the API layer. List the files
you'd change and the approach — do not edit yet."
3. Read the plan. Correct the approach BEFORE code exists
("use the existing middleware, not a new decorator").
4. Approve; Claude executes the agreed plan.
Reviewing a plan is far cheaper than reviewing a diff — you catch a wrong approach before any code is written, when redirection costs one sentence instead of a revert. (Exact key to toggle plan mode: verify in the Claude Code docs, as UI shortcuts drift.)
Context: A CLAUDE.md is read every session, so every line spends context; a tight, load-bearing one steers Claude away from the mistakes it keeps making, while a bloated one dilutes the signal.
Your task: Write a focused CLAUDE.md that captures the conventions Claude keeps getting wrong, and explain what does not belong in it.
Requirements:
- Capture stable, repeatedly-needed facts: tooling/package manager, test layout and command, the fallible-return convention
- Include an explicit Do not section (e.g. don't add deps without asking, don't edit generated code)
- Keep it short — state why a bloated file hurts because it is read every session
- Explain what to leave out (whole-architecture dumps, prose novels)
- The examples are concrete conventions, not generic advice
💡 Hint: Ask yourself which facts Claude rediscovers or gets wrong every session — those, and only those, earn a line.
Show solution
CLAUDE.md is persistent project steering — keep it tight and load-bearing:
# CLAUDE.md
## Conventions
- Package manager is `pnpm`, never `npm`.
- Tests live next to source as `*.test.ts`; run with `pnpm test`.
- Use the existing `Result<T>` type for fallible functions; don't throw.
## Do not
- Don't add dependencies without asking.
- Don't edit `generated/` — it's built from the schema.
Put stable, repeatedly-needed facts (tooling, conventions, no-go zones) that Claude otherwise rediscovers or gets wrong. Don't paste the whole architecture or a novel — a bloated CLAUDE.md dilutes the signal. It is read every session, so every line costs context.
Context: Permissions are what let Claude act without a prompt on every step; getting the allow / ask / deny precedence right is what makes an agent both fast and safe.
Your task: Write a pure-Python model of the allow / ask / deny decision for a tool call, given an allowlist, a denylist, and a default mode.
Requirements:
- The function takes the tool, an allow set, a deny set, and a default mode
- Denylist wins first — a denied tool is blocked even if also allowed
- Allowlisted read-only tools resolve to allow (run without prompting)
- Anything unmatched falls back to the session's default mode (ask by default)
- Demonstrate all three outcomes with example tool calls
- Note that exact
settings.jsonpermission syntax should be verified in the docs
💡 Hint: Model it as an ordered check: deny, then allow, then default — the order is the whole behavior.
Show solution
Model the precedence the CLI applies (deny wins, then allow, then default):
def decide(tool, allow, deny, default_mode):
# denylist always wins
if tool in deny:
return "deny"
if tool in allow:
return "allow"
# otherwise fall back to the session's default mode
return default_mode # "ask" (default) | "allow" | "deny"
allow = {"Read", "Grep", "Bash(pnpm test)"}
deny = {"Bash(rm -rf *)", "Bash(git push)"}
print(decide("Read", allow, deny, "ask")) # allow
print(decide("Bash(git push)", allow, deny, "ask")) # deny
print(decide("Edit", allow, deny, "ask")) # ask (default)
Read-only tools go on the allowlist so they run without prompting; irreversible ones (force-delete, push) go on the denylist so they are always blocked; everything else falls to the default mode. Exact settings.json permission keys/syntax: verify in the Claude Code docs.
Context: "All tests pass" from a long session is a claim, not evidence; the professional habit is closing the loop with a check the agent didn't author, because the real risk in extended runs is silent scope creep.
Your task: Design the verification step for a session that made 12 edits and claims green, so you trust the tooling's output rather than the narration.
Requirements:
- Run the test suite yourself (or have Claude show raw output + exit code), not a summary
- Run the linter as an independent gate
- Use
git diff --statto confirm only the expected files changed - Read the diff by hand for anything security- or data-sensitive
- Only commit on green; on failure, feed the real failing output back and re-steer
💡 Hint: The point is independence — verify with something the agent did not write, and read the diff scope, not just the pass/fail.
Show solution
Close the loop with an independent check the agent didn't author:
# 1. Run the checks yourself (or make Claude run them and show raw output):
pnpm test # exit code + real output, not a summary
pnpm lint
git diff --stat # confirm ONLY the files you expected changed
# 2. Read the diff of anything security- or data-sensitive by hand.
# 3. If green: commit. If not: feed the failing output back and steer.
"All tests pass" is a claim, not evidence — run the suite and read the exit code and diff yourself. The extended-session risk is silent scope creep (files you didn't expect changed), which git diff --stat surfaces immediately. Trust the tools' output, not the narration.
Context: A team standard only works if the safe path is the easy path: irreversible and quality gates belong in shared config so they can't be skipped, while judgement steps stay advisory.
Your task: As tech lead, define a repeatable per-task Claude Code workflow as a checklist a new engineer can run, marking each step enforced vs advisory.
Requirements:
- Written as an ordered per-task checklist (branch first, steer, plan, work, verify, commit/PR)
- Each step is labelled enforced or advisory
- Irreversible/quality gates are enforced in shared config — hooks,
settings.jsonpermissions, branch protection - Judgement steps (steering, planning) are kept advisory
- The standard ships with the repo via version-controlled
CLAUDE.md+settings.json - Note that exact hook/settings keys should be verified in the docs
💡 Hint: Split the checklist by what a human can forget (advisory) versus what must never be skippable (enforce it in config).
Show solution
A team playbook that makes good sessions the default:
Team Claude Code workflow (per task)
1. Branch first (never work on the default branch). [enforced: hook]
2. Steer: state file(s), goal, and constraints up front. [advisory]
3. Plan mode for any change touching >1 file; approve plan. [advisory]
4. Let it work; keep read tools on the allowlist, [enforced: settings.json]
push/delete on the denylist.
5. Verify: run tests + lint + `git diff --stat` yourself. [enforced: PreToolUse hook on commit]
6. Commit with a real message; open a PR for review. [enforced: branch protection]
Enforce the irreversible/quality gates in shared config (permissions, a commit-time hook, branch protection) so they can't be skipped; keep the judgement steps (steering, planning) advisory. A shared, version-controlled CLAUDE.md + settings.json makes the team's standard the path of least resistance. Exact hook/settings keys: verify in the docs.
✓ Checkpoint — you can move on when you can…
- Explain drift and counter it with the steer loop.
- Configure CLAUDE.md + scoped permissions.
- Make a verified test run the definition of done.
- Standardize steering/config for a team and audit it.
Knowledge check check yourself
The lesson claims "steering beats prompting" for Claude Code. What is fundamentally different about working with an agent loop versus a chat model, and how does the extended-session problem arise?
Show answer
In the decide permission matcher, deny is checked before allow and unmatched actions return ASK. Why is "deny-wins plus default-ask" called the safe design?