Express API with Claude Code Workflow

Build a TypeScript Express API using Claude Code hooks, subagents, and CLAUDE.md.

Introduction

30 Second Summary

AI coding assistants can write code for you. But without the right setup, they generate inconsistent output that you have to constantly correct.

In this project, you will build a TypeScript Express API while configuring Claude Code's advanced features around it. You'll set up project memory, lifecycle hooks, and a custom subagent that make Claude Code work autonomously and consistently.

What You'll Build

A task management REST API with full CRUD endpoints, surrounded by a Claude Code power-user configuration that you can transfer to any future project.

By the end of this project, you'll have:

  • A TypeScript Express API with task CRUD endpoints, built collaboratively with a tuned Claude Code environment.
  • A CLAUDE.md project memory file and lifecycle hooks that shape Claude's coding behavior automatically.
  • A verification-driven workflow where Claude runs tests and iterates until they pass without manual intervention.
  • Secret Mission: Build a reusable skill that scaffolds new API endpoints following your project's patterns.

Are there any prerequisites?

You need Claude Code installed and authenticated via an enterprise account, Node.js 18+ installed, and Git installed. Step 1 will verify all of these are ready.

Scaffold the TypeScript Express Project

Every Claude Code power-user workflow starts with a real project. You need working code before you can configure hooks, subagents, and verification loops around it.

In this step, you'll create a TypeScript Express API from scratch. This server will be your playground for the rest of the project as you layer Claude Code's advanced features on top.

In this step, get ready to:
  • Initialize the project and install Express with TypeScript dependencies.
  • Create the project structure and starter server file.
  • Verify the server compiles and responds to requests.
Initialize the project and install dependencies

You need a Node.js project with Express and TypeScript tooling installed before you can write any server code.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir claude-code-masterclass
cd claude-code-masterclass
  • Confirm your folder was created by running:
ls ~/Desktop | grep claude-code-masterclass

You should see claude-code-masterclass in the output.

  • Initialize a new Node.js project by running:
npm init -y

You should see a confirmation showing the generated package.json contents printed to your terminal.

  • Install Express as your production dependency by running:
npm install express@5.2.1
  • Install the TypeScript tooling and development dependencies by running:
npm install -D typescript@6.0.3 @types/express@5.0.6 @types/node prettier@3.9.1 tsx

You should see added messages in your terminal confirming packages were installed.

What are these packages?

You just installed the runtime and the tooling for your TypeScript project.

  • express is the web framework your API runs on.
  • typescript compiles your TypeScript into JavaScript for production.
  • tsx runs TypeScript directly in development without a separate compile step.
  • prettier auto-formats your code. You'll wire it into a Claude Code hook later.

Seeing permission errors or network timeouts?

  • Permission errors on macOS often mean you need to prefix with sudo. Try running the install command again with sudo at the start.
  • Network timeouts mean npm cannot reach the registry. Check your internet connection and try again.

Help me troubleshoot my npm install error

Create the project structure and starter files

Your project needs a specific directory layout. Routes, controllers, and types each get their own folder. You'll also create the .claude/ directory for hooks and agents that come in later steps.

  • Create the full directory structure by running:
mkdir -p src/routes src/controllers src/types .claude/hooks .claude/agents
  • Confirm the directories exist by running:
ls -R

You should see src/ with its subdirectories and .claude/ with hooks/ and agents/ listed in the output.

Not seeing .claude in the output?

Directories starting with a dot are hidden by default. Run ls -Ra instead to include hidden directories in the listing.

Help me see my hidden directories

  • Create a tsconfig.json file in the root of your claude-code-masterclass folder. Paste in the following configuration:
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

What does this configuration do?

  • target and module tell TypeScript to output modern ES2022 JavaScript using Node.js ESM module format.
  • strict: true enables all strict type-checking options. This catches more bugs at compile time.
  • rootDir points to src/ so TypeScript knows where your source files live.
  • outDir sends compiled JavaScript to dist/ for production use.
  • Save tsconfig.json.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
  • Create a file called index.ts inside the src/ folder. Paste in the following code:
import express from "express";

const app = express();
const PORT = 3000;

// Parse incoming JSON request bodies
app.use(express.json());

// Health check endpoint to verify the server is running
app.get("/health", (req, res) => {
  res.json({ status: "ok" });
});

// Start the server
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

export { app };

What does this code do?

  • express.json() is middleware that parses JSON request bodies. Your CRUD endpoints will need this later.
  • The /health endpoint returns a simple JSON response. You'll use this to confirm the server is alive.
  • export { app } makes the Express app available for testing in later steps.
  • Save src/index.ts.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

import express from "express";

const app = express();
const PORT = 3000;

app.use(express.json());

app.get("/health", (req, res) => {
  res.json({ status: "ok" });
});

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

export { app };
Verify the setup compiles and runs

Before moving on, you need to add npm scripts and confirm the server actually starts and responds to requests.

  • Open package.json in the root of your claude-code-masterclass folder. Replace its entire contents with the following:
{
  "name": "claude-code-masterclass",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js",
    "format": "prettier --write \"src/**/*.ts\"",
    "test": "node --test src/**/*.test.ts"
  },
  "dependencies": {
    "express": "5.2.1"
  },
  "devDependencies": {
    "@types/express": "5.0.6",
    "@types/node": "latest",
    "prettier": "3.9.1",
    "tsx": "latest",
    "typescript": "6.0.3"
  }
}

What did you just add?

  • "type": "module" tells Node.js this project uses ES modules (import/export syntax) instead of CommonJS (require).
  • The dev script uses tsx watch to auto-restart the server when you save changes.
  • The test script uses Node.js built-in test runner. No external test framework needed.
  • The format script runs Prettier across all TypeScript files.
  • Save package.json.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

{
  "name": "claude-code-masterclass",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js",
    "format": "prettier --write \"src/**/*.ts\"",
    "test": "node --test src/**/*.test.ts"
  },
  "dependencies": {
    "express": "5.2.1"
  },
  "devDependencies": {
    "@types/express": "5.0.6",
    "@types/node": "latest",
    "prettier": "3.9.1",
    "tsx": "latest",
    "typescript": "6.0.3"
  }
}
  • Start your server by running:
npx tsx src/index.ts

You should see Server running on http://localhost:3000 printed in your terminal.

  • Open a new terminal tab by pressing Cmd+T (macOS) or Ctrl+Shift+T (Windows/Linux).
  • Navigate back into your project folder by running:
cd ~/Desktop/claude-code-masterclass
  • Test the health check endpoint by running:
curl http://localhost:3000/health

You should see {"status":"ok"} printed in your terminal. This confirms your server is running and responding to requests.

  • Switch back to the first terminal tab where your server is running.
  • Stop the server by pressing Ctrl+C.

Not seeing the server start?

  • If you see a syntax error, double-check that src/index.ts matches the code above exactly. Watch for missing semicolons or mismatched quotes.
  • If curl shows Connection refused, make sure the server is still running in your first terminal tab before trying again.
  • If you see a port conflict error, another process is using port 3000. Close it or change PORT in src/index.ts to 3001.

Help me debug my server startup issue

Your project foundation is solid. Next up, you'll create a CLAUDE.md file that teaches Claude Code your project's conventions. This is where your coding assistant starts to feel like a teammate who already knows how you work.

Craft Your CLAUDE.md and Build the Task API

Claude Code is powerful out of the box, but without guidance it makes its own decisions about file structure, naming, and patterns. Those decisions might not match what you want for your project.

A CLAUDE.md file solves this. It gives Claude Code persistent instructions that it reads at the start of every session. In this step, you will create a CLAUDE.md with your project's conventions, then watch Claude follow them while building a complete task API.

In this step, get ready to:
  • Create a CLAUDE.md file with build commands, code style rules, and architecture decisions.
  • Use Claude Code to generate a full Task CRUD API that follows your conventions.
  • Validate that the generated code matches your CLAUDE.md rules.
Create a project CLAUDE.md with coding standards

Claude Code reads CLAUDE.md from your project root at the start of every session. This is where you define the rules Claude cannot infer from code alone.

  • Create a new file called CLAUDE.md in the root of your claude-code-masterclass folder by running:
touch CLAUDE.md
  • Open CLAUDE.md in your editor and paste the following content:
# Project: Claude Code Masterclass - Task API

## Build and Run

- Dev server: `npm run dev`
- Build: `npm run build`
- Tests: `npm test` (requires server running in another terminal)
- Format: `npm run format`

## Architecture

- Routes in `src/routes/` (one file per resource)
- Controllers in `src/controllers/` (business logic, one file per resource)
- Types in `src/types/` (TypeScript interfaces)
- Tests co-located as `*.test.ts` next to the file they test

## Code Style

- Use named exports (no default exports)
- Use `const` over `let` where possible
- Express handlers return `void`, use `res.json()` or `res.status().json()`
- Error responses follow format: `{ error: "Human-readable message" }`
- Use Node.js built-in `node:crypto` for UUID generation
- Import with `.js` extension for ESM compatibility

## Testing

- Use Node.js built-in test runner (`node:test` and `node:assert`)
- Tests call the running server via fetch (integration style)
- Test file suffix: `.test.ts`

## Verification

Before considering any task complete, run `npm test`. If tests fail, fix the code and re-run until all tests pass.

## Constraints

- No external database. Use in-memory arrays for storage.
- Express 5 (ESM, no callback-style error handling)
- TypeScript strict mode enabled

What does this file do?

Each section of CLAUDE.md targets a different type of decision Claude makes when writing code:

  • Build and Run tells Claude how to run, build, and test the project without asking you.
  • Architecture dictates where files go, so Claude places routes in src/routes/ instead of inventing a different structure.
  • Code Style enforces named exports, consistent error formats, and ESM import extensions.
  • Verification is the key to autonomous coding. It tells Claude to self-check by running tests before calling a task done.
  • Save CLAUDE.md.
  • Confirm the file exists by running:
ls CLAUDE.md

You should see CLAUDE.md listed in the output.

Keep CLAUDE.md concise

Anthropic recommends keeping CLAUDE.md under 200 lines. Shorter files produce better adherence because Claude can hold the entire thing in working context.

Use Claude Code to build the task routes

Now that CLAUDE.md is in place, Claude Code will read it automatically when you start a session. You are about to see the difference firsthand.

  • Start a Claude Code session in your project directory by running:
claude
  • Once the session starts, paste this prompt to ask Claude to build the Task CRUD API:
Create a Task CRUD API with these endpoints: GET /tasks, GET /tasks/:id, POST /tasks, PUT /tasks/:id, DELETE /tasks/:id. Each task has an id, title, description, completed boolean, and createdAt timestamp. Use the architecture and code style defined in CLAUDE.md.

What happens next?

Claude reads your CLAUDE.md at session start and uses it to guide every file it creates. Watch how it:

  • Places type interfaces in src/types/task.ts.
  • Puts controller logic in src/controllers/tasks.ts.
  • Creates route definitions in src/routes/tasks.ts.
  • Updates src/index.ts to mount the new router.
  • Uses named exports, .js import extensions, and the { error: "..." } pattern throughout.
  • Allow Claude to create and edit the files. Review each file it proposes and accept the changes.

Claude not following your conventions?

If Claude places files in unexpected locations or uses default exports, confirm that CLAUDE.md is in the project root (not inside .claude/). Claude loads the file from ./CLAUDE.md at the directory level where you launched the session.

help me debug why Claude Code is not reading my CLAUDE.md file

Validate the generated code matches your CLAUDE.md rules

Claude has generated the files. Now verify that the output matches the conventions you specified.

  • Check that all expected files exist by running:
ls src/types/task.ts src/controllers/tasks.ts src/routes/tasks.ts

You should see all three files listed without errors.

  • Verify your code matches the conventions. Open each file and check for:
  • Named exports (export function and export const) in every file.
  • Error responses using the { error: "..." } format.
  • Import paths ending in .js for ESM compatibility.
  • Types defined as Task, CreateTaskInput, and UpdateTaskInput interfaces.
  • Start the server and test an endpoint by running:
npx tsx src/index.ts
  • In a separate terminal, test the task creation endpoint by running:
curl -X POST http://localhost:3000/tasks -H "Content-Type: application/json" -d '{"title": "Test task", "description": "My first task"}'

You should see a JSON response with an id, title, description, completed: false, and a createdAt timestamp.

  • Verify the listing endpoint works by running:
curl http://localhost:3000/tasks

You should see an array containing the task you just created.

  • Stop the server with Ctrl+C once you have confirmed the endpoints respond correctly.

Getting a 404 on /tasks?

Check that src/index.ts imports and mounts the tasks router. It should contain app.use("/tasks", tasksRouter). If this line is missing, ask Claude to add it or add it manually.

help me fix a 404 error on my /tasks endpoint in Express

✔️ Awesome, I've got everything!

Great. Double check that you have saved all files and that the server responds on both /health and /tasks.

ⓧ I'd like to double check the full code

Here is the full code for each file at this point:

# Project: Claude Code Masterclass - Task API

## Build and Run

- Dev server: `npm run dev`
- Build: `npm run build`
- Tests: `npm test` (requires server running in another terminal)
- Format: `npm run format`

## Architecture

- Routes in `src/routes/` (one file per resource)
- Controllers in `src/controllers/` (business logic, one file per resource)
- Types in `src/types/` (TypeScript interfaces)
- Tests co-located as `*.test.ts` next to the file they test

## Code Style

- Use named exports (no default exports)
- Use `const` over `let` where possible
- Express handlers return `void`, use `res.json()` or `res.status().json()`
- Error responses follow format: `{ error: "Human-readable message" }`
- Use Node.js built-in `node:crypto` for UUID generation
- Import with `.js` extension for ESM compatibility

## Testing

- Use Node.js built-in test runner (`node:test` and `node:assert`)
- Tests call the running server via fetch (integration style)
- Test file suffix: `.test.ts`

## Verification

Before considering any task complete, run `npm test`. If tests fail, fix the code and re-run until all tests pass.

## Constraints

- No external database. Use in-memory arrays for storage.
- Express 5 (ESM, no callback-style error handling)
- TypeScript strict mode enabled
export interface Task {
  id: string;
  title: string;
  description: string;
  completed: boolean;
  createdAt: string;
}

export interface CreateTaskInput {
  title: string;
  description?: string;
}

export interface UpdateTaskInput {
  title?: string;
  description?: string;
  completed?: boolean;
}
import type { Request, Response } from "express";
import type { Task, CreateTaskInput, UpdateTaskInput } from "../types/task.js";
import { randomUUID } from "node:crypto";

const tasks: Task[] = [];

export function getAllTasks(req: Request, res: Response): void {
  res.json(tasks);
}

export function getTaskById(req: Request, res: Response): void {
  const task = tasks.find((t) => t.id === req.params.id);
  if (!task) {
    res.status(404).json({ error: "Task not found" });
    return;
  }
  res.json(task);
}

export function createTask(req: Request, res: Response): void {
  const input: CreateTaskInput = req.body;
  if (!input.title) {
    res.status(400).json({ error: "Title is required" });
    return;
  }
  const task: Task = {
    id: randomUUID(),
    title: input.title,
    description: input.description ?? "",
    completed: false,
    createdAt: new Date().toISOString(),
  };
  tasks.push(task);
  res.status(201).json(task);
}

export function updateTask(req: Request, res: Response): void {
  const index = tasks.findIndex((t) => t.id === req.params.id);
  if (index === -1) {
    res.status(404).json({ error: "Task not found" });
    return;
  }
  const input: UpdateTaskInput = req.body;
  tasks[index] = { ...tasks[index], ...input };
  res.json(tasks[index]);
}

export function deleteTask(req: Request, res: Response): void {
  const index = tasks.findIndex((t) => t.id === req.params.id);
  if (index === -1) {
    res.status(404).json({ error: "Task not found" });
    return;
  }
  tasks.splice(index, 1);
  res.status(204).send();
}
import { Router } from "express";
import {
  getAllTasks,
  getTaskById,
  createTask,
  updateTask,
  deleteTask,
} from "../controllers/tasks.js";

export const tasksRouter = Router();

tasksRouter.get("/", getAllTasks);
tasksRouter.get("/:id", getTaskById);
tasksRouter.post("/", createTask);
tasksRouter.put("/:id", updateTask);
tasksRouter.delete("/:id", deleteTask);
import express from "express";
import { tasksRouter } from "./routes/tasks.js";

const app = express();
const PORT = 3000;

app.use(express.json());

app.get("/health", (req, res) => {
  res.json({ status: "ok" });
});

app.use("/tasks", tasksRouter);

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

export { app };

Your CLAUDE.md is already shaping how Claude writes code. Next up, you will add lifecycle hooks that automatically format every file Claude touches and block edits to protected files.

Configure Hooks for Auto-Formatting and File Protection

In the previous step, your CLAUDE.md shaped how Claude writes code. But you still relied on Claude remembering to format files and stay away from sensitive ones.

Claude Code hooks are lifecycle event listeners that fire shell commands automatically at specific points during Claude's workflow. They remove the need to ask Claude to do something. Instead, the system enforces it every time.

In this step, get ready to:
  • Add a PostToolUse hook that auto-formats every file Claude edits with Prettier.
  • Add a PreToolUse hook that blocks Claude from writing to protected files.
  • Add a Notification hook that sends a macOS desktop alert when Claude goes idle.
Add a PostToolUse hook to auto-format with Prettier

A PostToolUse hook fires after Claude finishes using a tool (like editing or writing a file). You will wire one up so that every time Claude touches a file, Prettier reformats it instantly.

The hook commands depend on jq for parsing JSON input. Before creating the hook, confirm jq is available on your machine.

  • Check if jq is installed by running:
jq --version

✔️ I see a version number

You are all set. Move on to creating the hook file below.

ⓧ Command not found

  • Install jq by running:
brew install jq
  • After installation completes, confirm it works by running jq --version again.
  • Create the project hook configuration file by running:
touch .claude/settings.json
  • Open .claude/settings.json and paste the following configuration:
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
          }
        ]
      }
    ]
  }
}

What does this configuration do?

  • PostToolUse tells Claude Code to run this hook after Claude finishes using a tool.
  • The matcher value Edit|Write means the hook only fires when Claude edits or writes a file. The | separates tool names.
  • The command uses jq to extract the file path from the hook's JSON input, then pipes it to Prettier.
  • Save .claude/settings.json.
  • In your Claude Code session, ask Claude to make any small edit to a TypeScript file (for example, "add a comment to the top of src/index.ts"). After the edit, you should see Prettier run automatically in the hook output.

Prettier not running after the edit?

  • Confirm the file is saved as .claude/settings.json (inside the .claude/ directory, not the project root).
  • Check that jq is installed by running jq --version in your terminal.
  • Run /hooks in Claude Code to open the hooks browser and verify your hook appears under PostToolUse.

Still stuck? Help me debug why my PostToolUse hook isn't running Prettier after Claude edits a file.

Add a PreToolUse hook to block writes to protected files

A PreToolUse hook fires before Claude uses a tool. If the hook exits with code 2, it blocks the action entirely. Any text sent to stderr becomes feedback that Claude sees.

You will create a bash script that checks whether Claude is about to edit a protected file. If it is, the script blocks the edit and tells Claude why.

  • Create the protection script by running:
touch .claude/hooks/protect-files.sh
  • Open .claude/hooks/protect-files.sh and paste the following:
#!/bin/bash
# Blocks edits to protected files

INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")

for pattern in "${PROTECTED_PATTERNS[@]}"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
    exit 2
  fi
done

exit 0

What does this script do?

  • INPUT=$(cat) reads the JSON that Claude Code pipes to the hook via stdin. This JSON contains the tool name and its arguments.
  • jq extracts the file_path from the tool input.
  • The script loops through three protected patterns: .env, package-lock.json, and .git/.
  • If the file path matches a pattern, it prints a reason to stderr and exits with code 2. This blocks the edit and sends the reason back to Claude as feedback.
  • Exit code 0 means "allow the edit."
  • Save .claude/hooks/protect-files.sh.
  • Make the script executable by running:
chmod +x .claude/hooks/protect-files.sh
  • Now register this script as a PreToolUse hook. Open .claude/settings.json and replace the entire contents with:
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
          }
        ]
      }
    ]
  }
}

What is $CLAUDE_PROJECT_DIR?

Claude Code sets the $CLAUDE_PROJECT_DIR environment variable automatically in every hook command. It points to your project's root directory, so the hook resolves the script path no matter where Claude runs from.

  • Save .claude/settings.json.
  • In your Claude Code session, ask Claude to "edit package-lock.json and add a comment." You should see the hook block the attempt and Claude acknowledge that the file is protected.

Claude still edits the protected file?

  • Run chmod +x .claude/hooks/protect-files.sh again and confirm it is executable with ls -la .claude/hooks/.
  • Make sure the PreToolUse entry in .claude/settings.json has a comma after the PostToolUse block. Invalid JSON silently disables hooks.
  • Run /hooks in Claude Code to verify both hooks appear.

Still stuck? Help me debug why my PreToolUse hook isn't blocking edits to protected files.

Add a Notification hook for macOS desktop alerts

When Claude finishes a long-running task, you might be in another window and miss it. A Notification hook sends a macOS desktop alert so you know Claude needs your attention.

This hook belongs in your user-level settings (~/.claude/settings.json) so it works across all projects, not just this one.

  • Create the user-level settings file (if it does not exist already) by running:
mkdir -p ~/.claude && touch ~/.claude/settings.json
  • Open ~/.claude/settings.json and add the following Notification hook. If the file already has content, merge the Notification key into the existing hooks object:
{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}

What does the empty matcher mean?

An empty string in the matcher field means "match all notifications." Every time Claude Code triggers a notification event (like going idle or needing input), this hook fires.

  • Save ~/.claude/settings.json.
  • Test all three hooks in one go. In your Claude Code session, ask Claude: "Add a comment to the top of src/index.ts, then try to edit package-lock.json."
  • Watch for these three signals:
  • After Claude edits src/index.ts, Prettier auto-formats the file (PostToolUse hook).
  • When Claude attempts package-lock.json, the edit is blocked and Claude acknowledges the protection (PreToolUse hook).
  • When Claude finishes and goes idle, a macOS notification bubble appears (Notification hook).

Not seeing the macOS notification?

  • Open **System Settings**, then **Notifications**. Make sure osascript or **Script Editor** is allowed to send notifications.
  • Test the notification command directly in your terminal by running osascript -e 'display notification "test" with title "test"'. If it works there but not in Claude Code, the hook file may have a JSON syntax error.

Still stuck? Help me debug why my Notification hook is not sending macOS desktop alerts.

Why hooks instead of prompting Claude?

You could ask Claude to "always run Prettier after editing" in your CLAUDE.md. But prompts are suggestions. Claude might forget, skip the step to save tokens, or misinterpret the instruction.

Hooks are enforced by the system. They fire every time, regardless of what Claude decides to do. This is the difference between asking and guaranteeing.

✔️ Awesome, I've got everything!

Great. Double check you have saved both .claude/settings.json and .claude/hooks/protect-files.sh.

ⓧ I'd like to double check the full code

Here is the final state of both files:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
          }
        ]
      }
    ]
  }
}
#!/bin/bash
# Blocks edits to protected files

INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")

for pattern in "${PROTECTED_PATTERNS[@]}"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
    exit 2
  fi
done

exit 0

Your Claude Code environment now enforces formatting and file protection automatically. Next up, you will set up a verification-driven development loop where Claude runs tests autonomously and iterates until they pass.

Set Up Verification-Driven Development

Your hooks from the previous step ensure every file Claude touches is formatted and protected files stay safe. Now it's time to set up the single biggest productivity unlock in Claude Code.

Verification-driven development gives Claude a way to check its own work. You write tests that define what "done" means, and Claude autonomously runs them, spots failures, and iterates until everything passes.

In this step, get ready to:
  • Write an integration test suite for the task API endpoints.
  • Leverage the verification section in your CLAUDE.md.
  • Watch Claude autonomously implement a new feature using the test-driven loop.
Write a test file for the task endpoints

Tests serve two purposes in this project. They verify your API works correctly, and they give Claude a concrete definition of "done" that it can check against autonomously.

  • Create src/routes/tasks.test.ts with the following test suite:
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert";

// Base URL for the running server
const BASE_URL = "http://localhost:3000";

describe("Tasks API", () => {
  // Shared variable to track the created task's ID across tests
  let taskId: string;

  it("GET /tasks returns an array", async () => {
    const res = await fetch(`${BASE_URL}/tasks`);
    assert.strictEqual(res.status, 200);
    const body = await res.json();
    assert.ok(Array.isArray(body));
  });

  it("POST /tasks creates a task", async () => {
    const res = await fetch(`${BASE_URL}/tasks`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: "Test task", description: "A test" }),
    });
    assert.strictEqual(res.status, 201);
    const body = await res.json();
    assert.ok(body.id);
    assert.strictEqual(body.title, "Test task");
    assert.strictEqual(body.completed, false);
    taskId = body.id;
  });

  it("GET /tasks/:id returns a specific task", async () => {
    const res = await fetch(`${BASE_URL}/tasks/${taskId}`);
    assert.strictEqual(res.status, 200);
    const body = await res.json();
    assert.strictEqual(body.id, taskId);
  });

  it("PUT /tasks/:id updates a task", async () => {
    const res = await fetch(`${BASE_URL}/tasks/${taskId}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: "Updated task" }),
    });
    assert.strictEqual(res.status, 200);
    const body = await res.json();
    assert.strictEqual(body.title, "Updated task");
  });

  it("PATCH /tasks/:id/complete marks task as completed", async () => {
    const res = await fetch(`${BASE_URL}/tasks/${taskId}/complete`, {
      method: "PATCH",
    });
    assert.strictEqual(res.status, 200);
    const body = await res.json();
    assert.strictEqual(body.completed, true);
  });

  it("DELETE /tasks/:id removes a task", async () => {
    const res = await fetch(`${BASE_URL}/tasks/${taskId}`, {
      method: "DELETE",
    });
    assert.strictEqual(res.status, 204);
  });

  it("GET /tasks/:id with invalid ID returns 404", async () => {
    const res = await fetch(`${BASE_URL}/tasks/nonexistent-id`);
    assert.strictEqual(res.status, 404);
  });
});

What does this test file do?

This is an integration test suite that calls your running server over HTTP using fetch.

  • Each it() block tests one endpoint behavior (create, read, update, delete, 404).
  • The tests run in order and share taskId so later tests operate on the task created by the POST test.
  • The PATCH test for /tasks/:id/complete tests an endpoint that doesn't exist yet. This is intentional. You're writing the test first.
  • Save src/routes/tasks.test.ts.
  • Start the dev server in a terminal by running:
npm run dev
  • Open a second terminal in the same claude-code-masterclass directory and run the tests:
npm test
  • You should see 6 tests pass (marked with ✔) and 1 test fail (the PATCH test, because the endpoint doesn't exist yet).

All tests failing?

  • Make sure your dev server is running in another terminal. The tests call http://localhost:3000 and need the server to be responding.
  • Check that you ran npm run dev (not npm test) in the first terminal.
  • If you see import errors, confirm "type": "module" is present in your package.json.

Still stuck? Help me debug why my tests aren't connecting to the server.

Review the verification section in CLAUDE.md

Your CLAUDE.md already contains the section that unlocks autonomous iteration. Open CLAUDE.md and locate this block:

## Verification

Before considering any task complete, run `npm test`. If tests fail, fix the code and re-run until all tests pass.

Why is this section so powerful?

Without this section, Claude finishes a task and stops. It trusts that the code it wrote is correct.

With it, Claude treats your test suite as its exit condition. It writes code, runs npm test, reads the output, and loops back to fix anything that failed. You get a self-correcting agent instead of a one-shot code generator.

This is what Anthropic calls providing "a reliable verification mechanism" in their Claude Code best practices.

Ask Claude to add a new feature using the verification loop

The PATCH test you wrote expects a PATCH /tasks/:id/complete endpoint that doesn't exist yet. You wrote the test first on purpose. Now let Claude implement the feature while using that test as its target.

  • Make sure your dev server is still running in one terminal.
  • In your second terminal, start a Claude Code session by running:
claude
  • Give Claude this prompt:
Add a PATCH /tasks/:id/complete endpoint that marks a task as completed. The test already exists in src/routes/tasks.test.ts. Implement the endpoint so all tests pass.

What should I see Claude do?

Watch Claude's process carefully. It should:

  • Read your test file to understand the expected behavior.
  • Add a completeTask function to src/controllers/tasks.ts.
  • Register a PATCH /:id/complete route in src/routes/tasks.ts.
  • Run npm test to verify all 7 tests pass.
  • If any test fails, fix the issue and re-run tests automatically.

This is the verification loop in action. Claude does not stop until all tests are green.

  • Wait for Claude to finish iterating. All 7 tests should pass.
  • Confirm the test output shows 7 passing tests with no failures.

Claude not running tests automatically?

  • Double-check that the Verification section in your CLAUDE.md says to run npm test before considering a task complete.
  • If Claude asks for permission to run commands, approve it. Claude needs to execute the test runner to verify its work.
  • If Claude stops without running tests, remind it: "Please run npm test and fix any failures."

Need help? Claude isn't running my tests automatically after making code changes.

✔️ Awesome, I've got everything!

Great. Confirm you've saved all files and that all 7 tests pass when you run npm test.

ⓧ I'd like to double check the full code

Here are the files that should have changed during this step.

src/routes/tasks.test.ts (new file):

import { describe, it, beforeEach } from "node:test";
import assert from "node:assert";

const BASE_URL = "http://localhost:3000";

describe("Tasks API", () => {
  let taskId: string;

  it("GET /tasks returns an array", async () => {
    const res = await fetch(`${BASE_URL}/tasks`);
    assert.strictEqual(res.status, 200);
    const body = await res.json();
    assert.ok(Array.isArray(body));
  });

  it("POST /tasks creates a task", async () => {
    const res = await fetch(`${BASE_URL}/tasks`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: "Test task", description: "A test" }),
    });
    assert.strictEqual(res.status, 201);
    const body = await res.json();
    assert.ok(body.id);
    assert.strictEqual(body.title, "Test task");
    assert.strictEqual(body.completed, false);
    taskId = body.id;
  });

  it("GET /tasks/:id returns a specific task", async () => {
    const res = await fetch(`${BASE_URL}/tasks/${taskId}`);
    assert.strictEqual(res.status, 200);
    const body = await res.json();
    assert.strictEqual(body.id, taskId);
  });

  it("PUT /tasks/:id updates a task", async () => {
    const res = await fetch(`${BASE_URL}/tasks/${taskId}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: "Updated task" }),
    });
    assert.strictEqual(res.status, 200);
    const body = await res.json();
    assert.strictEqual(body.title, "Updated task");
  });

  it("PATCH /tasks/:id/complete marks task as completed", async () => {
    const res = await fetch(`${BASE_URL}/tasks/${taskId}/complete`, {
      method: "PATCH",
    });
    assert.strictEqual(res.status, 200);
    const body = await res.json();
    assert.strictEqual(body.completed, true);
  });

  it("DELETE /tasks/:id removes a task", async () => {
    const res = await fetch(`${BASE_URL}/tasks/${taskId}`, {
      method: "DELETE",
    });
    assert.strictEqual(res.status, 204);
  });

  it("GET /tasks/:id with invalid ID returns 404", async () => {
    const res = await fetch(`${BASE_URL}/tasks/nonexistent-id`);
    assert.strictEqual(res.status, 404);
  });
});

src/controllers/tasks.ts (completeTask function added at the bottom):

import type { Request, Response } from "express";
import type { Task, CreateTaskInput, UpdateTaskInput } from "../types/task.js";
import { randomUUID } from "node:crypto";

const tasks: Task[] = [];

export function getAllTasks(req: Request, res: Response): void {
  res.json(tasks);
}

export function getTaskById(req: Request, res: Response): void {
  const task = tasks.find((t) => t.id === req.params.id);
  if (!task) {
    res.status(404).json({ error: "Task not found" });
    return;
  }
  res.json(task);
}

export function createTask(req: Request, res: Response): void {
  const input: CreateTaskInput = req.body;
  if (!input.title) {
    res.status(400).json({ error: "Title is required" });
    return;
  }
  const task: Task = {
    id: randomUUID(),
    title: input.title,
    description: input.description ?? "",
    completed: false,
    createdAt: new Date().toISOString(),
  };
  tasks.push(task);
  res.status(201).json(task);
}

export function updateTask(req: Request, res: Response): void {
  const index = tasks.findIndex((t) => t.id === req.params.id);
  if (index === -1) {
    res.status(404).json({ error: "Task not found" });
    return;
  }
  const input: UpdateTaskInput = req.body;
  tasks[index] = { ...tasks[index], ...input };
  res.json(tasks[index]);
}

export function deleteTask(req: Request, res: Response): void {
  const index = tasks.findIndex((t) => t.id === req.params.id);
  if (index === -1) {
    res.status(404).json({ error: "Task not found" });
    return;
  }
  tasks.splice(index, 1);
  res.status(204).send();
}

export function completeTask(req: Request, res: Response): void {
  const index = tasks.findIndex((t) => t.id === req.params.id);
  if (index === -1) {
    res.status(404).json({ error: "Task not found" });
    return;
  }
  tasks[index].completed = true;
  res.json(tasks[index]);
}

src/routes/tasks.ts (completeTask import and PATCH route added):

import { Router } from "express";
import {
  getAllTasks,
  getTaskById,
  createTask,
  updateTask,
  deleteTask,
  completeTask,
} from "../controllers/tasks.js";

export const tasksRouter = Router();

tasksRouter.get("/", getAllTasks);
tasksRouter.get("/:id", getTaskById);
tasksRouter.post("/", createTask);
tasksRouter.put("/:id", updateTask);
tasksRouter.delete("/:id", deleteTask);
tasksRouter.patch("/:id/complete", completeTask);

With verification-driven development set up, Claude can now work autonomously on your codebase. Next, you'll create a custom code-reviewer subagent that reviews Claude's output in isolation.

Create a Code-Review Subagent

Your verification-driven workflow lets Claude autonomously iterate until tests pass. That handles code correctness. But what about code quality?

In this step you will create a subagent. A subagent is a specialized Claude instance that runs in its own isolated context with restricted tools. You will build one that reviews your code for quality issues without being able to edit anything.

In this step, get ready to:
  • Create a code-reviewer subagent definition file.
  • Invoke the subagent and observe its isolated review.
  • Fix an identified issue and confirm tests still pass.
Create the subagent definition file

Subagents live in the .claude/agents/ directory. Each subagent is a single Markdown file with YAML frontmatter that defines its name, tools, and model. The body of the file becomes the subagent's system prompt.

What is a subagent?

A subagent is a specialized Claude instance that runs in a separate context from your main conversation. It has its own tool restrictions and model choice. Your main session stays clean while the subagent performs a focused task.

Think of it like delegating a task to a colleague. They go away, do their work, and come back with a summary. Your desk stays tidy.

  • Create the file .claude/agents/code-reviewer.md by running:
touch .claude/agents/code-reviewer.md
  • Confirm the file exists by running:
ls .claude/agents/

You should see code-reviewer.md in the output.

  • Open code-reviewer.md in your editor and paste the following subagent definition:
---
name: code-reviewer
description: Expert code reviewer. Use proactively after code changes to review for quality, security, and maintainability.
tools: Read, Grep, Glob, Bash
model: sonnet
---

You are a senior TypeScript code reviewer. When invoked:

1. Run `git diff` to see recent changes (if available)
2. Read the modified files
3. Begin review immediately

Review checklist:
- Error handling: are all error cases covered? Are error responses consistent?
- Input validation: is user input validated before use?
- Type safety: are types specific (not `any`)? Are optional fields handled?
- Naming: are functions and variables clearly named?
- Test coverage: are there tests for the new/changed code?
- Consistency: does the code follow the patterns in existing files?

Provide feedback organized by priority:
- Critical (must fix before shipping)
- Warnings (should fix)
- Suggestions (nice to have)

Include specific line references and code examples for fixes.

What does this file do?

The YAML frontmatter between the --- fences configures the subagent:

  • name is how you reference the subagent in your Claude Code session.
  • tools: Read, Grep, Glob, Bash restricts the subagent to read-only operations. It can read files and run commands like git diff, but it cannot edit or write files.
  • model: sonnet uses Sonnet for the review, which is fast and cost-effective for analysis tasks.

Everything below the frontmatter is the system prompt that shapes the subagent's behavior. It tells the reviewer exactly what to look for and how to organize findings.

  • Save .claude/agents/code-reviewer.md.

Why read-only tools?

The code-reviewer subagent deliberately has no Edit or Write tool access. This follows the security principle of least privilege. A reviewer should identify problems, not silently fix them. Your main session handles fixes.

File not recognized as a subagent?

Make sure your file is in the .claude/agents/ directory (not .claude/hooks/). Also check that the YAML frontmatter starts on line 1 with --- and has no leading whitespace.

Help me troubleshoot my subagent file.

Test the subagent by reviewing current code

Your subagent definition is ready. Now you will invoke it inside a Claude Code session to see how it runs in isolation and returns structured findings.

  • Switch back to your Claude Code session in the terminal.
  • Type the following prompt to invoke the code-reviewer subagent:
Use the code-reviewer subagent to review the task API.

What should I see?

Claude will spawn the code-reviewer in a separate context. You will notice a few things:

  • The subagent reads your source files and runs commands like git diff in the background.
  • Your main conversation stays clean. The subagent's internal reasoning does not clutter your session.
  • It returns a focused summary organized by priority: Critical, Warnings, and Suggestions.
  • Findings include specific line references and code examples for fixes.

You can also run /agents in Claude Code to see a tabbed interface showing your available subagents and any running ones.

Read-only by design

Notice the subagent cannot make edits to your code. It can only read, search, and report. This separation keeps code reviews clean. The reviewer identifies issues. You decide which ones to fix.

Fix issues identified by the reviewer

The code-reviewer will likely flag at least one legitimate finding. Common ones include missing input validation in updateTask or inconsistent error handling patterns. You will now fix the top finding using your main Claude Code session.

  • Pick the highest-priority finding from the code-reviewer's output.
  • In your main Claude Code session, ask Claude to fix that specific issue. For example:
Fix the [top finding description] that the code-reviewer identified.

Why fix through the main session?

The subagent cannot edit files. It only reports. Your main Claude Code session has full edit access and will also run npm test after the fix (thanks to the verification section in your CLAUDE.md). This creates a clean workflow: review in isolation, fix in the main session, verify automatically.

  • Watch Claude apply the fix and run npm test automatically.
  • Confirm that all 7 tests still pass after the fix.

Tests failing after the fix?

If a test fails, Claude should automatically iterate thanks to the verification loop in CLAUDE.md. If Claude stops without passing tests, ask it: "Tests are still failing. Please fix and re-run until all tests pass."

Make sure your dev server is still running in a separate terminal (npm run dev) since the tests hit the running server.

Help me get my tests passing after the code review fix.

✔️ Awesome, I've got everything!

Great. Double check you have saved .claude/agents/code-reviewer.md and that all tests pass.

ⓧ I'd like to double check the full code

Here is the complete subagent definition file:

---
name: code-reviewer
description: Expert code reviewer. Use proactively after code changes to review for quality, security, and maintainability.
tools: Read, Grep, Glob, Bash
model: sonnet
---

You are a senior TypeScript code reviewer. When invoked:

1. Run `git diff` to see recent changes (if available)
2. Read the modified files
3. Begin review immediately

Review checklist:
- Error handling: are all error cases covered? Are error responses consistent?
- Input validation: is user input validated before use?
- Type safety: are types specific (not `any`)? Are optional fields handled?
- Naming: are functions and variables clearly named?
- Test coverage: are there tests for the new/changed code?
- Consistency: does the code follow the patterns in existing files?

Provide feedback organized by priority:
- Critical (must fix before shipping)
- Warnings (should fix)
- Suggestions (nice to have)

Include specific line references and code examples for fixes.

You now have a code-review subagent that runs in isolation, identifies quality issues, and feeds findings back to your main session for fixing. Combined with your hooks and verification loop, you have a full power-user Claude Code environment. Next up, you will put it all to the test in the Secret Mission.

Secret mission

Build a Reusable Endpoint Scaffolding Skill

You've configured Claude Code with hooks, subagents, and verification loops. Now take it a step further by creating a reusable skill that scaffolds entire API resources in seconds. Skills let Claude follow a repeatable recipe across any project, turning your best patterns into one-command automation.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources in place, pause them to come back later, or delete them entirely.

Resources you used:

  • The claude-code-masterclass project folder on your Desktop.
  • User-level skill directory at ~/.claude/skills/scaffold-resource/.
  • Notification hook added to ~/.claude/settings.json.

Keep everything running

No action needed. Choose this if you're still actively building or want to keep experimenting with your Claude Code configuration.

  • Your project folder, CLAUDE.md, hooks, subagent, and skill all stay in place.
  • The notification hook in ~/.claude/settings.json will continue to fire for all Claude Code sessions on your machine.
  • The scaffold-resource skill in ~/.claude/skills/ is available across all your projects.

Pause - I'll come back to this later

Stop any running processes to free up memory, but keep all files for later.

  • If the dev server is still running, press Ctrl+C in the terminal to stop it.
  • Close your Claude Code session by typing /exit or pressing Ctrl+C twice.
  • Your project folder, hooks, subagent, and skill will be waiting when you come back.

Delete - I don't want to use this again

Remove all project resources and Claude Code configuration created during this project.

  • If the dev server is still running, press Ctrl+C in the terminal to stop it.
  • Delete the project folder by running:

macOS

cd ~/Desktop
rm -rf claude-code-masterclass

Windows

cd ~\Desktop
rmdir /s /q claude-code-masterclass
  • Delete the scaffold-resource skill by running:

macOS

rm -rf ~/.claude/skills/scaffold-resource

Windows

rmdir /s /q %USERPROFILE%\.claude\skills\scaffold-resource
  • Remove the notification hook from ~/.claude/settings.json. Open the file and delete the Notification entry you added in Step 3. If that was the only entry in the file, you can delete the file entirely.

Nice Work!

Nice Work!

You just built a TypeScript Express API from scratch and surrounded it with a fully configured Claude Code power-user environment.

You've learned how to:

  • Craft a CLAUDE.md that shapes Claude's coding behavior, enforcing your project's architecture and style conventions without repeating yourself in every prompt.
  • Configure lifecycle hooks that automate formatting with Prettier on every edit, block writes to protected files, and send macOS notifications when Claude needs attention.
  • Set up verification-driven development where Claude autonomously runs tests, identifies failures, and iterates on fixes until all tests pass.
  • Secret Mission: Built a reusable skill that scaffolds new API resources following your project's established patterns.

Ready to quiz yourself?