An AI coding agent is a language model that can read your files, run commands and edit code in a loop until a task is done. Used well, it removes hours of routine work. Used carelessly, it produces a confident mess, pushes it, and leaves you to find out later. This post explains what an agent really is, then tours one real tool in depth: Claude Code, with its project memory (CLAUDE.md), skills, hooks, subagents, permission modes, MCP servers and cloud sessions. It maps the same ideas onto AGENTS.md, GitHub Copilot and Cursor, gives a workflow that keeps you in charge, and ends with a review checklist and a real case study: the CLAUDE.md that runs this blog. Feature details were checked against the official docs in September 2026. These tools change monthly, so check the docs again before you rely on a detail.

Series map: 01 Text Encoding → … → 17 How LLMs Work → 18 Coding with AI Agents (this post) → 19 RAG → … → 24 Desk Health. Part D · AI tools.


Learning Outcome

  • Explain an agent as "model + tools + a loop", and build a tiny read-only one in Python.
  • Write a short, effective CLAUDE.md, and know where Claude Code loads it from.
  • Choose between a CLAUDE.md rule, a skill, a hook, a subagent and an MCP server.
  • Pick a permission mode, and block irreversible actions with permission rules.
  • Know which files reach a cloud session and which stay on your machine.
  • Share one instruction file across Claude Code, Copilot and Cursor with AGENTS.md.
  • Work in small verified steps, and review AI-written code with a checklist.

Chapter 1 — What an agent is: model + tools + a loop

Terminology

Term Meaning
Tool A function the model may ask to call: read a file, run a command, search the web. Your program runs it, not the model.
Agent A model that calls tools in a loop, reading each result, until it decides the task is done.
Harness The program around the model: it runs the loop, the tools, the permissions and the context. Claude Code, Cursor and Copilot are harnesses.
Context Everything the model can see this turn: instructions, history, file contents, tool results (see Dev Essentials 17 · How LLMs Work).
MCP Model Context Protocol, an open protocol for plugging external tools and data sources into a harness.

Post 17 ended with tool use: the model answers with a structured call, your code runs it, and you send the result back. Put that in a loop and you have an agent. Here is a complete, read-only one. It can list and read files inside the current folder and nothing else:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# mini_agent.py - a read-only coding agent: model + tools + a loop
import pathlib
import anthropic

client = anthropic.Anthropic()
ROOT = pathlib.Path(".").resolve()
TOOLS = [
{"name": "list_files",
"description": "List project files under a folder, relative to the project root. Use '.' for the root.",
"input_schema": {"type": "object", "properties": {"folder": {"type": "string"}}, "required": ["folder"]}},
{"name": "read_file",
"description": "Read one UTF-8 text file from the project.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
]

def inside_project(path):
p = (ROOT / path).resolve()
if not p.is_relative_to(ROOT): # refuse ../../ tricks
raise ValueError("path is outside the project")
return p

def run_tool(name, args):
if name == "list_files":
files = [p.relative_to(ROOT).as_posix() for p in sorted(inside_project(args["folder"]).rglob("*"))
if p.is_file() and not any(part.startswith(".") for part in p.relative_to(ROOT).parts)]
return "\n".join(files[:500])
if name == "read_file":
return inside_project(args["path"]).read_text(encoding="utf-8")[:20_000]
raise ValueError(f"unknown tool: {name}")

messages = [{"role": "user", "content": "What does this project do? Read the files before you answer."}]
for turn in range(10): # hard cap: never loop forever
response = client.messages.create(model="claude-opus-5-5", max_tokens=16000,
tools=TOOLS, messages=messages)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
break
results = []
for block in response.content:
if block.type == "tool_use":
try:
output, failed = run_tool(block.name, block.input), False
except Exception as exc: # report errors back to the model
output, failed = f"Error: {exc}", True
print(f"[tool] {block.name}({block.input}) -> {len(output)} chars")
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": output, "is_error": failed})
messages.append({"role": "user", "content": results}) # all results in one message

print("".join(b.text for b in response.content if b.type == "text"))

Run it from inside any small project folder. Three details carry over to every real agent:

  • The loop has a hard cap. An agent that never stops burns money.
  • Tools check their inputs. inside_project refuses paths like ../../secrets. The model's arguments are untrusted input.
  • The whole assistant turn goes back into the history, including any thinking blocks, and all tool results go back in one user message.

Now add a write_file tool and a run_command tool, and you have a real coding agent. You also have a real risk. A production harness exists to manage exactly that risk: which actions need a human's approval, what fits in the context, and how to recover from mistakes. The rest of this post is about using those controls well.

Where context, hooks, permission rules, tools, MCP servers and subagents plug into the agent loop


Chapter 2 — Claude Code: project memory with CLAUDE.md

Claude Code is Anthropic's coding agent. It runs in the terminal, in IDEs, in a desktop app and in the cloud. Each session starts with a fresh context window, so knowledge that must survive between sessions lives in files.

Where CLAUDE.md files live

Scope Location Shared with
Managed policy Set by IT, e.g. C:\Program Files\ClaudeCode\CLAUDE.md on Windows Everyone in the organisation
User ~/.claude/CLAUDE.md Just you, all projects
Project ./CLAUDE.md or ./.claude/CLAUDE.md The team, through git
Local ./CLAUDE.local.md (add it to .gitignore) Just you, this project

How they load (official docs, September 2026):

  • At launch, Claude Code reads CLAUDE.md and CLAUDE.local.md from the working directory and every directory above it.
  • Files are joined together, not overridden. Content runs from the filesystem root down to the working directory, so the closest instructions are read last.
  • A CLAUDE.md inside a subdirectory loads later, when Claude reads files in that subdirectory.
  • @path/to/file inside a CLAUDE.md imports another file (up to four hops deep).
  • /init generates a starting file; /memory and /context show which files actually loaded.

What to write

The docs suggest staying under 200 lines: every line costs context in every session, and long files reduce how well rules are followed. Write down what Claude cannot guess by reading the code:

  • build, test and lint commands,
  • style rules that differ from the language defaults,
  • project layout and naming conventions,
  • "never do X" rules, each with the reason,
  • known traps in this repository.

Leave out what the code already shows, long tutorials, and file-by-file descriptions. For every line, ask: "Would removing this cause a mistake?"

Trick: block-level HTML comments (<!-- note for humans -->) in a CLAUDE.md are stripped before the text reaches the model. Use them for notes to maintainers.

Rules, and memory Claude writes itself

  • .claude/rules/*.md splits instructions by topic. A rule with a paths: list in its frontmatter (for example "src/api/**/*.ts") only loads when Claude works on matching files.
  • Auto memory is a separate store of notes Claude writes about your corrections and preferences. It is loaded every session (the first 200 lines or 25KB).

Watch out: CLAUDE.md is context, not configuration. The docs are explicit: Claude treats it as guidance, and to block an action no matter what the model decides, you need a hook or a permission rule (Chapters 3 and 4).


Chapter 3 — Skills, hooks and subagents

Skills: knowledge and workflows loaded on demand

A skill is a folder with a SKILL.md file. Project skills live in .claude/skills/<name>/SKILL.md, personal ones in ~/.claude/skills/<name>/SKILL.md. Only each skill's description sits in context all the time. The full body loads when you type /<name> or when Claude decides the description matches the task.

1
2
3
4
5
6
7
8
9
10
---
name: release-notes
description: Draft release notes from merged changes. Use when the user asks for release notes or a changelog entry.
disable-model-invocation: true
---

1. Run `git log --oneline <last-tag>..HEAD` to list the changes.
2. Group them under Added, Changed, Fixed. Skip merge commits and chores.
3. Write the notes to `RELEASE_NOTES.md`. Do not commit.
4. Show the draft and list anything you were unsure how to classify.

disable-model-invocation: true means only you can start it with /release-notes. Use that for any workflow with side effects.

Hooks: things that must happen every time

A hook is a command that Claude Code runs at a lifecycle event, such as SessionStart, UserPromptSubmit, PreToolUse (before a tool call, can block it), PostToolUse (after a tool call succeeds) or Stop (when Claude finishes). Hooks are deterministic: they fire whether or not the model remembers a rule. They live in ~/.claude/settings.json (all your projects), .claude/settings.json (the project, can be committed) or .claude/settings.local.json (the project, just you).

The official docs' example formats every edited file with Prettier:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
]
}
]
}
}

The hook receives the event as JSON on standard input; .tool_input.file_path is the edited file. jq must be on your PATH. On Windows, Claude Code runs shell-form hooks with Git Bash (or PowerShell if Git Bash is not installed), so a Python script is often the most portable choice. Here is a guard that stops Claude from editing secret files:

1
2
3
4
5
6
7
8
9
10
11
12
# .claude/hooks/protect_secrets.py - PreToolUse hook: block edits to secret files
import json
import sys

event = json.load(sys.stdin) # Claude Code sends the event as JSON
path = event.get("tool_input", {}).get("file_path", "").replace("\\", "/")
name = path.rsplit("/", 1)[-1]
if name == ".env" or name.startswith(".env.") or name.endswith((".pem", ".key")):
print(f"Blocked: {path} holds secrets. Ask the user to edit it by hand.", file=sys.stderr)
sys.exit(2) # 2 = block; stderr goes back to Claude
sys.exit(0)
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "python \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect_secrets.py" }
]
}
]
}
}

Common pitfall: exiting with code 1 to "fail". For most events, only exit code 2 blocks; exit 1 is a non-blocking error and the action goes ahead. And PostToolUse cannot block at all, because the tool already ran.

Subagents: a separate context for side jobs

A subagent is a helper with its own context window. It does a noisy job (searching many files, reading long logs, reviewing a diff) and returns only a summary, so your main conversation stays clean. Claude Code ships with built-in ones (Explore, Plan and general-purpose), and you can define your own in .claude/agents/ (project) or ~/.claude/agents/ (personal):

1
2
3
4
5
6
7
8
9
10
---
name: diff-reviewer
description: Reviews the current uncommitted diff for bugs, missing tests and leaked secrets. Use after a change is finished.
tools: Read, Grep, Glob, Bash
model: sonnet
---

You review diffs. Run `git diff` and read every changed file in full.
Report only real problems: bugs, missing or weakened tests, secrets, unsafe input handling.
Give file and line for each finding. Do not edit files.

Which one do I need?

You want... Use
Claude to always know a fact or rule CLAUDE.md (or a path-scoped rule)
A playbook or reference used sometimes Skill
Something to happen every time, no exceptions Hook
A noisy side task kept out of your context Subagent
Access to an outside system (issue tracker, database, browser) MCP server

Chapter 4 — Permissions, MCP and cloud sessions

Permission modes

A permission mode decides what Claude may do without asking. The official list (September 2026):

Mode Runs without asking Best for
default (shown as Manual) Reads only Sensitive work, unfamiliar code
acceptEdits Reads, file edits, common filesystem commands Iterating on code you are reviewing
plan Reads (Claude proposes a plan, no edits) Exploring before changing anything
auto Everything, with a classifier model checking each action Long tasks, less prompt fatigue
dontAsk Only pre-approved tools; everything else is denied Locked-down CI and scripts
bypassPermissions Everything Isolated containers and VMs only

Switch modes with Shift+Tab in the CLI, or start with claude --permission-mode plan. On some plans auto is the default starting mode, so check which mode you are in.

Permission rules: make the dangerous things impossible

Modes set the baseline. Rules add exact allow, ask and deny lists in the same settings files as hooks. Rules are checked in the order deny, then ask, then allow, and the first match wins, so a broad deny always beats a narrow allow.

1
2
3
4
5
6
7
8
{
"permissions": {
"allow": ["Bash(npm run test *)", "Bash(git status)", "Bash(git diff *)"],
"ask": ["Bash(git commit *)"],
"deny": ["Bash(git push *)", "Read(./.env)", "Read(./secrets/**)"]
}
}

Watch out: a Bash rule matches the command as written. The docs note that git -C . push does not match Bash(git push *). Treat rules as a strong guard rail, not a sandbox. For real isolation, use Claude Code's sandbox, a container or a VM.

MCP servers

MCP servers give Claude new tools: your issue tracker, a database, a browser. Add one with claude mcp add:

1
2
3
claude mcp add --transport http docs-search https://example.com/mcp                  # just you, this project
claude mcp add --transport http docs-search --scope project https://example.com/mcp # writes .mcp.json for the team

The scopes are local (default, stored in ~/.claude.json), project (.mcp.json at the repo root, shared through git) and user (all your projects). The docs warn that you should trust every server you connect: a server that fetches outside content can carry a prompt injection, which is text written to make the model do something you did not ask for.

Cloud sessions: only committed files travel

A cloud session (started at claude.ai/code, from the desktop or mobile app, or with claude --cloud "task") runs on a fresh clone of your GitHub repository, on Anthropic-managed infrastructure. --cloud clones your remote at the current branch, not your local checkout, so push first if you have local commits. What reaches the session (official docs, September 2026):

Item In a cloud session?
Repo CLAUDE.md and .claude/rules/ Yes, part of the clone
Repo .claude/settings.json hooks and permission rules Yes, in a single-repository session
Repo .mcp.json Yes, in a single-repository session
Repo .claude/skills/, .claude/agents/, .claude/commands/ Yes, part of the clone
~/.claude/CLAUDE.md, ~/.claude/skills/, ~/.claude/agents/ No, they live on your machine
~/.claude/settings.json, .claude/settings.local.json No
MCP servers added at local or user scope No; add them with --scope project and commit .mcp.json
Plugins, gitignored files, uncommitted changes No

In short: if it is not committed, the cloud session cannot see it. Git pushes from a cloud session are limited to the session's working branch, and you create the pull request when you are happy with it.


Chapter 5 — One instruction file for many tools

Every agent tool invented its own instruction file. The common ground today:

Tool Where it reads instructions
Many agents (Codex, Jules, Cursor, Copilot, Aider, Zed and others) AGENTS.md, an open format now stewarded by the Agentic AI Foundation under the Linux Foundation. The nearest file up the directory tree wins.
Claude Code CLAUDE.md. Since v2.1.277 it also reads AGENTS.md, but by default only when no CLAUDE.md or CLAUDE.local.md exists in the working directory or above it.
GitHub Copilot .github/copilot-instructions.md for the whole repo; .github/instructions/NAME.instructions.md with an applyTo glob for parts of it; also AGENTS.md anywhere, or CLAUDE.md / GEMINI.md at the root.
Cursor .cursor/rules/*.mdc rules (always, by description, by file glob, or manual); also AGENTS.md, including nested ones.

The simplest way to keep one source of truth: put shared rules in AGENTS.md, and make CLAUDE.md import it, then add Claude-only notes below:

1
2
3
4
5
@AGENTS.md

## Claude Code only
- Use the `diff-reviewer` subagent before you say a change is done.

The Claude Code docs recommend this import over a CLAUDE.md → AGENTS.md symlink when anyone uses Windows: symlinks need Developer Mode or Administrator rights there, and Git may check a committed symlink out as a one-line text file.


Chapter 6 — A workflow that keeps you in charge

Plan, then small steps, then verify each one

The Claude Code best-practices guide describes four phases, and they fit any agent:

  1. Explore. In plan mode, ask the agent to read the relevant code and explain it. No edits yet.
  2. Plan. Ask for a written plan: files to change, order of steps, tests to add. Edit the plan yourself. Skip planning only when you could describe the diff in one sentence.
  3. Implement in small steps. One step, then a check, then the next step.
  4. Commit. Small commits with real messages, so any step can be reverted (see Dev Essentials 03 · Git Rescue Kit).

The most useful single habit: give the agent a check it can run. Tests, a build, a linter, a script that compares output. The guide puts it plainly: without a check, "looks done" is the only signal, and you become the verification loop.

Give context the way you would to a new colleague

Weak prompt Strong prompt
"fix the login bug" "Users are logged out after a session timeout. Check the token refresh in src/auth/. Write a failing test that shows it, then fix it."
"add tests for foo.py" "Add a test for foo.py covering a logged-out user. Avoid mocks. Run pytest tests/test_foo.py."
"the build fails" Paste the exact error, the command you ran, and what changed since it last passed.

Name the files, paste real errors, state the constraints (versions, "do not change the public API"), and say what "done" means.

Keep the human in charge of irreversible actions

Pushing, deploying, deleting data, running migrations, sending messages, and changing CI secrets are one-way doors. Let the agent prepare them. You do them. Enforce that with deny or ask rules (Chapter 4), not only with a sentence in CLAUDE.md.

Manage context like a scarce resource

  • /clear between unrelated tasks. A clean session with a better prompt usually beats a long one full of failed attempts.
  • After two failed corrections on the same issue, stop, /clear, and rewrite the prompt with what you learned.
  • Send research to a subagent so only the summary enters your context.
  • Checkpoints (Esc Esc or /rewind) can undo Claude's file edits, but not changes made by shell commands. They are not a replacement for git.

Chapter 7 — Reviewing AI output

You own every line you merge, whoever typed it. Review AI-written changes at least as strictly as a new colleague's.

The checklist

  1. Read the whole diff. git diff and git diff --staged, every file. Look for changes outside the task.
  2. Run the tests yourself, and check the tests were not weakened: deleted assertions, new skip or xfail markers, loosened expected values, .only left in (see Dev Essentials 15 · Testing).
  3. Check every API, flag and config key you do not know. Open the official docs, run --help, or import it in a REPL. Invented functions and flags look perfectly plausible.
  4. Check every new dependency. Does the package exist, is it the one you meant, is it maintained? Spracklen et al. (USENIX Security 2025) found code models suggesting packages that do not exist: at least 5.2% of suggested packages for commercial models and 21.7% for open-source models (independent measurement). Attackers can register those names (see Dev Essentials 09 · Versions & Dependencies).
  5. Security. Injection, missing authorisation checks, disabled TLS verification, unsafe deserialisation, overly broad CORS (see Dev Essentials 12 · OWASP Top 10). Claude Code has a /security-review command for a first pass, but it does not replace your own review.
  6. Secrets. No keys, tokens or passwords in code, tests, logs or fixtures. A quick scan:
1
2
git diff --staged | grep -inE "api[_-]?key|secret|token|password|BEGIN .*PRIVATE KEY"
1
2
git diff --staged | Select-String -Pattern 'api[_-]?key|secret|token|password|BEGIN .*PRIVATE KEY'
  1. Licensing. Large blocks that look copied from elsewhere, removed license headers, new dependencies with incompatible licenses.
  2. Text hygiene. Encoding, line endings and bulk reformatting noise (see Dev Essentials 01 · Text Encoding and 04 · Line Endings).

Trick: use a second, fresh reviewer. A new session or subagent that sees only the diff and your criteria is not biased by the reasoning that produced the change. Claude Code's bundled /code-review skill runs such a review in a fresh subagent.

Common failure modes

Failure What it looks like Guard
Invented APIs Plausible method or flag that does not exist Docs, --help, run it
Scope creep "While I was there I also refactored..." Small tasks; read the whole diff
Test tampering Tests pass because they were changed Review test diffs first
"Looks done" Stops without running anything Give a runnable check; ask for evidence
Ignored rules A long CLAUDE.md where rules get lost Keep it short; use hooks for hard rules
Endless exploration Reads hundreds of files, context fills up Scope the task; use a subagent
Prompt injection Follows instructions found in a web page, issue or file Treat fetched text as data; keep approvals on
Bulk-edit damage A script rewrites many files in the wrong encoding Verify command after bulk edits (Chapter 8)

Chapter 8 — Case study: the CLAUDE.md behind this blog

This blog is written with help from coding agents, and its repository has a root CLAUDE.md that Claude Code loads automatically at the start of every session. Every rule in it came from a real problem:

Rule Why it exists
Never push; commit only The owner pushes. This was asked for repeatedly.
Write directly in the main checkout; no git worktrees for content A worktree once caused real confusion.
Post dates must be in the past The site does not hide future-dated posts; they publish at once with a date that has not happened yet. The date is part of the post's URL, so fixing it later changes the URL. This already caused real 404s.
Leave the cover: field blank Cover images are made separately.
A parallel session may be editing; do not touch unrelated posts Two agent sessions sometimes work in the same repository at once.
A UTF-8 hazard section, with a verify command See below.

The encoding incident

A helper script once rewrote 44 posts through Windows PowerShell 5.1 and saved them in the system's ANSI code page instead of UTF-8. About 2,700 characters were destroyed: — became a literal ??, and · became a stray CJK character, U+7E5A. Microsoft's docs confirm the trap: in Windows PowerShell 5.1, Set-Content and Add-Content use the Default encoding, which is the active ANSI code page, when the file is new or empty. Also, -Encoding utf8 in 5.1 writes UTF-8 with a byte order mark (PowerShell 7 defaults to UTF-8 without one). The CLAUDE.md therefore shows the right way to write files, and a one-line check that searches every post for U+7E5A and must print nothing after any bulk edit. Written so that it does not depend on the shell's locale, the check looks like this:

1
2
grep -l "$(printf '\347\271\232')" content/posts/*.md    # the three UTF-8 bytes of U+7E5A
1
2
Select-String -Path content\posts\*.md -Pattern ([char]0x7E5A) -List | ForEach-Object Path

Watch out: check the checker. A shorter form using bash's $'\uXXXX' quoting only turns into the real character when bash runs in a UTF-8 locale. In a shell with no locale set, it stays plain text, and grep then searches for the wrong thing. In a quick test in such a shell, that form flagged a clean file and missed the corrupted one. The printf version above passes the raw UTF-8 bytes, so it works in any locale. Run any verify command once against a known-bad and a known-good file.

Why the rules live in the root CLAUDE.md

In this repository the whole .claude/ folder is gitignored. That has a direct consequence (Chapter 4): nothing inside .claude/, not settings, not hooks, not skills, reaches a cloud session or a fresh clone. The only instruction file every session is guaranteed to see is the committed root CLAUDE.md. So the durable rules had to go there, in plain words, with the reason for each.

A trimmed, generic version you can adapt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Project instructions

Static blog. Posts live in `content/posts/`, images in `public/assets/<topic>/`.
Build: `npm run build` (must pass before you say a task is done).

## Hard rules
1. Never run `git push`. Commit only; a human reviews and pushes.
2. Work in the main checkout. Do not create git worktrees.
3. Post dates must be in the past. Future-dated posts still publish at once, and the date is part of the URL.
4. Leave `cover:` empty in front matter. Covers are made separately.
5. Another session may be editing. Only touch the files named in the task.

## UTF-8 hazard (Windows)
Windows PowerShell 5.1 `Set-Content` / `Add-Content` default to the ANSI code page.
A bulk rewrite once corrupted 44 posts (`—` became `??`).
- Write files with the editor tools. In PowerShell use
`[System.IO.File]::WriteAllText($p, $t, (New-Object System.Text.UTF8Encoding($false)))`.
- After any bulk edit, this must print nothing (it looks for U+7E5A, the mojibake marker):
`grep -l "$(printf '\347\271\232')" content/posts/*.md`

Lessons that transfer to any project:

  1. Write a rule after an incident, and include the reason. A reason lets the agent handle cases the rule did not foresee.
  2. Give a verify command, not just a warning, and test the command itself. A check the agent can run beats a sentence it may skim.
  3. Put durable rules where every session can see them. Committed and at the root.
  4. Keep it short, so the important rules do not get lost.
  5. Back prose rules with enforcement where you can. A Bash(git push *) deny rule in your user settings stops pushes locally even though the project's .claude/ folder is not shared.

Practice Exercises

Exercise 1 — A CLAUDE.md for a small Python project

Write a CLAUDE.md of at most 25 lines for a Python 3.12 web API that uses uv, pytest and ruff, keeps secrets in .env, and must never change the public /v1/ routes.

Solution
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# Orders API

Python 3.12, FastAPI. Dependencies managed with uv.

## Commands
- Install: `uv sync`
- Test: `uv run pytest -q` (run before saying a task is done)
- Lint/format: `uv run ruff check --fix . && uv run ruff format .`

## Layout
- `app/routes/` HTTP handlers, `app/services/` business logic, `tests/` mirrors `app/`.

## Rules
- Never change request or response shapes under `/v1/`: mobile clients depend on them.
Add a `/v2/` route instead.
- Never read, print or edit `.env`. Ask the user for any value you need.
- New code needs a test. Do not delete or skip existing tests.
- Do not add dependencies without asking first.

Exercise 2 — A post-edit hook for Python

Write a PostToolUse hook that runs ruff format on the edited file, only for .py files, and does nothing if ruff is not installed. Register it in .claude/settings.json.

Solution
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# .claude/hooks/format_python.py - PostToolUse hook: format each edited Python file
import json
import shutil
import subprocess
import sys

event = json.load(sys.stdin)
path = event.get("tool_input", {}).get("file_path", "")
if path.endswith(".py") and shutil.which("ruff"):
done = subprocess.run(["ruff", "format", path], capture_output=True, text=True)
if done.returncode != 0:
print(done.stderr or "ruff format failed", file=sys.stderr)
sys.exit(1) # non-blocking: shown as a hook error
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "python \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format_python.py" }
]
}
]
}
}

On macOS or Linux you may need python3 instead of python.

Exercise 3 — Permission rules

Write a permissions block that allows pytest runs without asking, asks before any git commit, and denies git push, rm -rf and reading anything under ./secrets/.

Solution
1
2
3
4
5
6
7
8
{
"permissions": {
"allow": ["Bash(pytest *)", "Bash(uv run pytest *)"],
"ask": ["Bash(git commit *)"],
"deny": ["Bash(git push *)", "Bash(rm -rf *)", "Read(./secrets/**)"]
}
}

Deny is checked first, then ask, then allow. Remember that rules match the command as written, so they are guard rails, not a sandbox.

Exercise 4 — One file for three tools

Your team uses Claude Code, Copilot and Cursor. How do you keep one set of instructions?

Solution

Put the shared rules in AGENTS.md at the repo root. Copilot and Cursor both read AGENTS.md. For Claude Code, add a CLAUDE.md containing @AGENTS.md plus any Claude-only notes. Claude Code can also read AGENTS.md directly, but only when no CLAUDE.md exists, and the import also works in sessions that cannot load AGENTS.md directly. Use the import rather than a symlink if anyone is on Windows. Path-specific extras can still go in .github/instructions/*.instructions.md or .cursor/rules/*.mdc.

Exercise 5 — Review this diff

An agent produced this change to "add retries to the payment client". Find at least four problems.

1
2
3
4
5
6
7
8
9
10
11
+import requests_retry_helper
+API_KEY = "sk_live_51H..."
def charge(amount, card):
- resp = requests.post(URL, json={"amount": amount, "card": card}, timeout=10)
+ resp = requests.post(URL, json={"amount": amount, "card": card},
+ verify=False, retries=3, headers={"Authorization": API_KEY})
return resp.json()
-def test_charge_rejects_negative():
- with pytest.raises(ValueError):
- charge(-5, CARD)
Solution
  1. Hardcoded live secret (API_KEY = "sk_live_..."): remove it, rotate the key, load it from the environment.
  2. Invented argument: requests.post has no retries parameter. Retries in requests are configured through an HTTPAdapter with urllib3's Retry.
  3. Unknown dependency requests_retry_helper: check that it exists and is trustworthy; it may be a hallucinated name.
  4. verify=False turns off TLS certificate checks on a payment call.
  5. timeout=10 was removed, so a hung server now blocks forever.
  6. A test was deleted instead of fixed.
  7. Retrying a charge without an idempotency key can charge the customer twice (see Dev Essentials 14 · API Reliability).

Exercise 6 — What reaches the cloud?

Your repo gitignores .claude/. You have a project hook in .claude/settings.json, a personal skill in ~/.claude/skills/, a committed root CLAUDE.md, and a committed .mcp.json. Which of these does a single-repository cloud session see?

Solution

Only the root CLAUDE.md and .mcp.json, because only committed files are in the clone. The project hook lives in the ignored .claude/ folder, so it never reaches the clone. The personal skill lives on your machine. To make the hook travel, stop ignoring .claude/settings.json (for example, ignore only .claude/settings.local.json and other personal files) and commit it.


Cheat-sheet

Need Claude Code answer
Always-on project rules ./CLAUDE.md or ./.claude/CLAUDE.md (committed, under 200 lines)
Personal rules, all projects ~/.claude/CLAUDE.md (never reaches cloud sessions)
Personal rules, one project ./CLAUDE.local.md (gitignored)
Import another file @path/to/file inside CLAUDE.md
Rules for some paths only .claude/rules/*.md with paths: frontmatter
See what loaded /memory, /context
Reusable workflow .claude/skills/<name>/SKILL.md, run with /<name>
Must happen every time Hook in .claude/settings.json; exit 2 blocks (PreToolUse)
Side task, separate context Subagent in .claude/agents/<name>.md
Outside tools and data claude mcp add ... --scope project writes .mcp.json
Explore safely claude --permission-mode plan or Shift+Tab
Block push / secrets permissions.deny: Bash(git push *), Read(./.env)
Cloud session sees Only what is committed in the repo
Cross-tool file AGENTS.md, plus CLAUDE.md containing @AGENTS.md
Copilot / Cursor .github/copilot-instructions.md / .cursor/rules/*.mdc
Review Whole diff, run tests, check APIs and packages, security, secrets, licensing

What's next

Next is Dev Essentials 19 · RAG from Scratch in Python: chunk your own notes, embed them, retrieve the right pieces, and have a model answer with citations, plus a small evaluation set to prove it works. It uses the embeddings from post 17 and the "treat fetched text as data" rule from this post.


References

Note: this post is in progress. Planned additions: a worked Stop-hook example that runs the test suite before Claude may finish, a plugin that packages this post's skill, hook and subagent, and a comparison of sandboxing options (built-in sandbox, dev containers, VMs).