AI EngineeringZero to ProductionHome·About·Contact
Anthropic Skills · Chapter K2

Claude Code automation

Claude Code running without you: headless mode, hooks (with a runnable dispatcher), slash commands, CI review, and the safe-automation standards a lead owns.

⏱️ ~3 hours🧪 8 labs🎯 Beginner→Tech-lead

Learning objectives

  • Run Claude Code headless and script it into pipelines.
  • Automate behavior with hooks on tool events.
  • Package repeatable prompts as slash commands.
  • Integrate and govern Claude Code in CI.
▶ Runnable companionCode saved under code/ak2-claude-code-automation/. Python runs offline; configs are ready to use.

1 · From interactive to automated essential

K1 was you at the keyboard. Automation is Claude Code running without you — in a script, a git hook, or CI. Three mechanisms: headless mode, hooks, and slash commands.

Trigger commit/CI/cron Claude Code (headless) -p prompt Hooks fire on tool events Result → pipeline PR comment, exit code
🗺️ How to read this diagram

This picture is the whole lesson in one line: it shows how Claude Code runs without a human at the keyboard. Read it left to right — each box hands off to the next.

  • Trigger (far left) is what kicks things off: a git commit, a CI run, or a scheduled cron job. Nobody types anything — an event starts it.
  • Claude Code (headless) is Claude running in batch mode via the -p flag ("prompt") — it does one job and exits, no chat window.
  • Hooks fire means the harness automatically runs your own commands on tool events (for example, before every Bash command) — this is where safety and formatting get enforced.
  • Result → pipeline (far right) is the payoff: the output becomes a PR comment or an exit code the rest of your pipeline can act on. An exit code of 0 usually means pass; non-zero means fail.

In short: Every automation in this lesson is just these four boxes: something triggers Claude, it runs headless, hooks guard the actions, and the result flows on.

2 · Headless mode essential

The -p flag runs one prompt and exits. With --output-format json you get structured output a script can parse — the basis of every automation.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
shell · headless runs
headless.shclaude -p "Summarize what changed in the last commit"        # one-shot
claude -p "List any TODO comments added in this diff" --output-format json > todos.json
git diff | claude -p "Review this diff for security issues. Be concise."   # pipe context in
▶ How this works

These three shell lines are the foundation of every automation: run Claude with a single prompt, get the answer, done. The magic flag is -p ("prompt") — it means "do this one thing and exit" instead of opening an interactive chat.

  1. Line 1claude -p "Summarize what changed…" runs one prompt and prints the reply to the screen. The # one-shot comment just notes it does a single job.
  2. Line 2 adds --output-format json, which makes Claude return structured JSON instead of prose. The > todos.json part redirects that output into a file a script can read later.
  3. Line 3 shows the | (pipe): git diff produces your code changes, and the pipe feeds them in as context for the review prompt. Piping is how you hand Claude data to work on.

What the output means: Lines 1 and 3 print Claude's answer straight to the terminal; line 2 writes a JSON file (todos.json) with nothing shown on screen.

Try this: Run just line 1 in a real repo. Then try line 3 — the difference is that piping git diff gives Claude the actual code to look at, not just a request.

Headless still respects permissionsIn automation you can't answer prompts, so set a scoped allow-list (K1) or --permission-mode deliberately. Never disable safety wholesale in a pipeline that can push code or reach prod.

3 · Hooks — automate on events intermediate

A hook is a shell command the harness runs when an event fires (before/after a tool call, on session stop). Hooks turn "remember to run tests" into "tests always run."

config · a Stop hook that runs tests + audits Bash
settings.json{
  "hooks": {
    "Stop": [
      { "matcher": "*", "hooks": [
        { "type": "command", "command": "pytest -q || echo 'TESTS FAILED'" } ]}
    ],
    "PreToolUse": [
      { "matcher": "Bash", "hooks": [
        { "type": "command", "command": "echo \"$(date) bash call\" >> .claude/audit.log" } ]}
    ]
  }
}
▶ How this works

This JSON file teaches Claude Code to run your own commands automatically when certain things happen. A hook = an event name + a command to run when that event fires. Here we set two hooks so tests and an audit log happen every time, with no one remembering to trigger them.

  1. "Stop" is the event for "Claude finished the task." Its command pytest -q runs your tests; || echo 'TESTS FAILED' means "if pytest fails, print that message" (|| = "or else").
  2. "matcher": "*" means "match everything" — the Stop hook always fires. A matcher narrows which tools or events trigger the hook.
  3. "PreToolUse" fires before a tool runs. Here "matcher": "Bash" limits it to Bash commands only, so it runs the echo right before any shell command Claude wants to run.
  4. That echo appends a timestamped line (the date plus bash call) onto .claude/audit.log using >> (append to a file). That is your audit trail of every Bash command.

What the output means: Nothing runs now — this is configuration. But once saved, every task-completion runs your tests, and every Bash command adds a line to audit.log.

Try this: Change "Bash" to "Edit" and the audit hook would fire on file edits instead. The matcher is the dial that picks which events you care about.

4 · Advanced — model a hook dispatcher advanced

How does the harness decide which hooks fire for an event? A matcher dispatch. Modeling it makes hook behavior predictable.

Python · a hook dispatcher (runs)
hook_dispatch.pyimport fnmatch

def fire_hooks(event, tool, hooks):
    """hooks: {event: [(matcher, command)]}. Return commands that fire."""
    fired = []
    for matcher, cmd in hooks.get(event, []):
        if matcher == "*" or fnmatch.fnmatch(tool or "", matcher):
            fired.append(cmd)
    return fired

hooks = {
    "PreToolUse": [("Bash", "audit.sh"), ("Edit", "format.sh")],
    "Stop": [("*", "pytest -q")],
}
print("before Bash:", fire_hooks("PreToolUse", "Bash", hooks))
print("before Edit:", fire_hooks("PreToolUse", "Edit", hooks))
print("on stop:    ", fire_hooks("Stop", None, hooks))
before Bash: ['audit.sh']
before Edit: ['format.sh']
on stop:     ['pytest -q']
▶ How this works

This small Python program models the rule Claude Code uses internally to decide which hooks fire for an event. Modeling it makes hook behavior predictable — you can predict exactly what will run before it does.

  1. def fire_hooks(event, tool, hooks): defines a function: given the event (e.g. "PreToolUse"), the tool in play (e.g. "Bash"), and the config, it returns the list of commands that should run.
  2. hooks.get(event, []) looks up the hooks for that event, safely returning an empty list if none are configured. The for matcher, cmd in … loop then checks each one.
  3. The if matcher == "*" or fnmatch.fnmatch(tool, matcher): line is the decision: a "*" matcher fires for anything, otherwise fnmatch does wildcard name-matching (so "Bash" matches the tool "Bash"). Matches get added to fired.
  4. The three print(...) lines test it: a Bash call, an Edit call, and a Stop event — each shows which commands would fire.

What the output means: before Bash: ['audit.sh'], before Edit: ['format.sh'], on stop: ['pytest -q'] — each event fires only its matching commands, and the "*" Stop hook fires regardless of tool.

Try this: Add ("*", "log.sh") to the PreToolUse list and re-run — now both Bash and Edit fire log.sh too, because * matches every tool.

Hooks are how 'always do X' actually happensClaude can forget an instruction; a hook cannot. Anything that must run every time — format on edit, test on stop, audit every Bash call — belongs in a hook, not a prompt.

5 · Slash commands — reusable prompts intermediate

A slash command is a saved prompt in .claude/commands/. Type /name and it expands — great for team-standard workflows like reviews.

config · a /review slash command
review.md<!-- .claude/commands/review.md -->
Review the current git diff for:
1. Correctness bugs and edge cases
2. Security issues (injection, secrets, authz)
3. Missing tests
Group findings by severity. Cite file:line. Be concise — no praise.
▶ How this works

A slash command is just a saved prompt in a file under .claude/commands/. This one is named review.md, so typing /review in Claude Code expands into these exact instructions — a team-standard code review with no re-typing.

  1. The first line is an HTML comment (<!-- … -->) noting the file's location. Everything after it is the prompt text Claude receives when you run /review.
  2. The numbered list tells Claude exactly what to look for — correctness bugs, security issues, and missing tests. Being specific gives you a consistent review every time.
  3. The last line, Group findings by severity. Cite file:line. Be concise — no praise., controls the format of the answer: organized, pinpointed to lines, and no filler.

Try this: Save this as .claude/commands/review.md, then type /review. Add a 4th rule (say "4. Performance concerns") and every future review includes it — that is the point of saving the prompt once.

6 · Advanced — CI integration advanced

Put it together: on every PR, run Claude Code headless as a reviewer and post findings. It never replaces human review — it catches the obvious before a human looks.

config · GitHub Actions review step
claude-review.ymlname: Claude review
on: [pull_request]
jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - env: { ANTHROPIC_API_KEY: "${{ secrets.ANTHROPIC_API_KEY }}" }
        run: |
          git diff origin/main...HEAD > /tmp/diff.txt
          claude -p "Review /tmp/diff.txt for bugs & security. Markdown, by severity." > review.md
          cat review.md   # or post as a PR comment via gh
▶ How this works

This is a GitHub Actions workflow — a YAML file GitHub runs automatically. It makes Claude Code review every pull request and post its findings, so the obvious problems get caught before a human reviewer even looks.

  1. on: [pull_request] is the trigger — this whole job runs each time someone opens or updates a PR. This is the "Trigger" box from the diagram.
  2. runs-on: ubuntu-latest asks GitHub for a fresh Linux machine. The actions/checkout@v4 step downloads your code onto it; fetch-depth: 0 grabs the full history so git diff works.
  3. ANTHROPIC_API_KEY: "${{ secrets.ANTHROPIC_API_KEY }}" passes your API key in from GitHub's encrypted secrets — the key is never written in the file.
  4. The run: | block runs shell commands: save the PR's diff to a temp file, run claude -p headless to review it into review.md, then cat (print) it — or post it as a PR comment via the gh tool.

What the output means: On every PR, CI produces a Markdown review grouped by severity. Here it is just printed to the build log; in practice you would post it back onto the PR.

Try this: This ties the whole lesson together: the PR is the trigger, claude -p is headless mode, and review.md is the result feeding the pipeline.

7 · Professional — safe automation professional

Automated agents are powerful and unattended — so bound them: least-privilege permissions, no auto-push/deploy without a human gate, log every action (audit hook), and fail closed. Treat a pipeline that can act on your repo as production infrastructure.

Python · gate an automated action (runs)
auto_gate.pydef allow_automated(action, context):
    HARD_DENY = {"git push", "deploy", "delete", "issue refund"}
    if any(d in action for d in HARD_DENY) and not context.get("human_approved"):
        return False, f"'{action}' needs human approval in automation"
    return True, "ok"

print(allow_automated("run pytest", {}))
print(allow_automated("git push origin main", {}))
print(allow_automated("git push origin main", {"human_approved": True}))
(True, 'ok')
(False, "'git push origin main' needs human approval in automation")
(True, 'ok')
▶ How this works

When Claude runs unattended, some actions are too dangerous to ever do without a human saying yes. This function is a safety gate: it inspects a requested action and blocks the irreversible ones unless a human has approved. "Fail closed" means "when unsure, refuse."

  1. HARD_DENY = {"git push", "deploy", "delete", "issue refund"} is a set of dangerous actions — the things that change prod, delete data, or spend money. These always need a human.
  2. The if any(d in action for d in HARD_DENY) and not context.get("human_approved"): line checks two things at once: is this action one of the dangerous ones, and has no human approved it? If both are true, it refuses.
  3. On refusal it returns (False, "…needs human approval…") — a False flag plus a reason. Otherwise it returns (True, "ok"), allowing the action.
  4. The three print calls test it: a harmless run pytest is allowed; a git push with no approval is blocked; the same push with human_approved: True is allowed.

What the output means: (True, 'ok'), then (False, "…needs human approval…"), then (True, 'ok') — the identical push is blocked without approval and allowed with it. That approval flag is your human gate.

Try this: Add "drop table" to HARD_DENY and test allow_automated("drop table users", {}) — it now returns False. This is exactly how you keep an unattended agent from doing damage.

8 · Tech-lead — own the automation standards tech-lead

A lead decides what's automated and where the human gates are: which hooks are mandatory, what CI runs Claude on, and the rule that irreversible actions always need a human. Codify it so the team automates safely by default.

Automate the tedious, gate the dangerousThe winning pattern: automate review, formatting, test-running, changelog generation (all safe, high-toil) — and always keep a human gate on push/deploy/delete. A lead who sets that line lets the team move fast without foot-guns.

Exercise AK2.1 — Automate a check safely

Context: This wires the automation primitives together the way a real repo does: a Stop hook, a shared slash command, a headless one-liner, and a gate that stops the pipeline from doing anything irreversible unattended.

Your task: Add a Stop hook that runs your linter and a /review command, write a headless one-liner that reviews git diff, gate it so automation can't push without approval, and wire the review into CI.

Requirements:

  • A Stop hook runs the linter automatically
  • A /review slash command exists as a shared command file
  • A headless one-liner reviews git diff (e.g. piped into claude -p)
  • Use auto_gate so the automation cannot push without approval
  • The review is wired into CI so it runs on every change

💡 Hint: Build it so the destructive path is blocked by config, not by remembering — the gate should hold even with no human watching.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Run Claude Code headlesslyBeginner

Context: Headless mode is the bridge from an interactive assistant to an automated one: a one-shot prompt that takes input, does the work, and exits with output you can redirect or pipe like any other CLI tool.

Your task: Run a one-shot Claude Code task with the headless print flag and capture its output. Needs the Claude Code CLI to run.

Requirements:

  • Use the non-interactive print flag (-p / --print) to run one prompt and exit
  • Redirect the result to a file (e.g. > summary.txt)
  • Show it composing with a pipe — e.g. git diff | claude -p "…"
  • The command exits after producing output; it is not interactive
  • Note that exact output-format/JSON flags should be verified in the docs

💡 Hint: Treat claude -p like any Unix filter: stdin in, stdout out, then it exits.

Show solution

The -p / --print flag runs one prompt and exits — scriptable:

# One-shot, non-interactive; prints the result to stdout and exits.
claude -p "Summarize the changes in the last commit" > summary.txt

# Pipe input in, capture output — composes like any CLI:
git diff | claude -p "Write a conventional-commit message for this diff"

Headless mode is the bridge from an interactive assistant to an automated one: it takes a prompt, does the work, and exits with output you can redirect, pipe, or consume in a script. (Exact flag names for output format/JSON: verify in the Claude Code docs.)

Exercise 2 · Automate on an event with a hookIntermediate

Context: Hooks make behavior reliable because the harness runs them on lifecycle events, not the model — so a formatter that fires after every edit never depends on Claude remembering to run it.

Your task: Configure a hook that runs your formatter automatically after every file edit. Config only — verify event names in the docs.

Requirements:

  • The hook lives in settings.json under the hooks config
  • It fires on the post-edit lifecycle event (e.g. PostToolUse)
  • A matcher targets the edit/write tools so it only runs on writes
  • The command invokes the formatter on the changed file
  • Explain why this is reliable: the harness runs the hook, not the model
  • Verify exact event names, matcher syntax, and env vars in the docs

💡 Hint: You are wiring config, not writing a prompt — the trigger is an event name plus a tool matcher.

Show solution

Hooks live in settings.json and fire on lifecycle events:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "pnpm prettier --write $CLAUDE_FILE" }
        ]
      }
    ]
  }
}

A PostToolUse hook matching the edit tools runs your formatter automatically after each write, so the code is always formatted without Claude having to remember. The harness runs the hook, not the model — that is why it is reliable. Verify the exact event names (PreToolUse/PostToolUse/…), matcher syntax, and env vars in the Claude Code docs.

Exercise 3 · Model a hook dispatcherAdvanced

Context: Understanding how the harness dispatches hooks — match by event, filter by a tool matcher, run in order — is what explains why a hook fires or silently doesn't.

Your task: Model the hook dispatcher in pure Python: given an event, a tool name, and a config, return the ordered list of commands to run. No CLI needed.

Requirements:

  • Key the config by event and iterate only that event's entries
  • Match each entry's matcher against the tool name (regex/alternation like Edit|Write)
  • Collect commands from all matching entries, preserving order
  • Multiple matching entries stack; a non-matching tool yields an empty list
  • Demonstrate both a match (returns the commands) and a miss (returns empty)

💡 Hint: The matcher is a regex over the tool name — full-match it, and accumulate across all entries that hit.

Show solution

Model the match-and-collect the harness performs:

import re

def dispatch(event, tool, config):
    cmds = []
    for entry in config.get(event, []):
        if re.fullmatch(entry["matcher"], tool):
            cmds += [h["command"] for h in entry["hooks"]]
    return cmds

config = {"PostToolUse": [
    {"matcher": "Edit|Write", "hooks": [{"command": "prettier --write"}]},
    {"matcher": "Write",      "hooks": [{"command": "git add -A"}]},
]}
print(dispatch("PostToolUse", "Write", config))  # ['prettier --write', 'git add -A']
print(dispatch("PostToolUse", "Read",  config))  # []

The dispatcher keys by event, filters by a matcher regex on the tool name, and runs the matching commands in order. Modeling it clarifies why a hook fires (or doesn't): the matcher must match the tool, and multiple entries stack. Verify the real matcher semantics in the docs.

Exercise 4 · Create a reusable slash commandExpert

Context: A slash command turns one person's habit into a versioned, shareable prompt: saved as a file in the project, it becomes a command everyone on the team can run.

Your task: Create a reusable /review slash command that runs a structured code review. File config — verify the directory in the docs.

Requirements:

  • The command is a Markdown prompt file in the project commands directory (.claude/commands/)
  • The filename determines the command name (review.md/review)
  • The prompt structures the review (e.g. correctness, security, missing tests) and groups findings
  • It is report-only — it inspects the staged diff and does not fix anything
  • Being file-based makes it versioned and shared with everyone on the project
  • Verify the exact directory and any frontmatter keys (allowed tools, description) in the docs

💡 Hint: The prompt is the command — write the review instructions you'd otherwise retype, and let the file location expose it.

Show solution

A slash command is a Markdown prompt in the project's commands directory:

# .claude/commands/review.md

Review the staged diff for:
1. Correctness bugs (logic, off-by-one, error handling).
2. Security issues (injection, secrets, unsafe deserialization).
3. Missing tests for changed behavior.

Report findings grouped by severity. Do not fix anything — report only.
Use `git diff --staged` to see the changes.

Saving the prompt as .claude/commands/review.md exposes it as /review for everyone on the project — a versioned, shareable prompt instead of one person's habit. Arguments and frontmatter (allowed tools, description) are supported; verify the exact directory and frontmatter keys in the docs.

Exercise 5 · Safe automation — gate the destructive pathsProfessional

Context: Headless automation removes the human "are you sure?", so an unattended pipeline that can push or delete is a standing hazard — the guardrails have to move into config.

Your task: Design the guardrails that keep an automated (headless) pipeline safe from destructive actions. Config + policy.

Requirements:

  • Denylist the irreversible actions (push, force-delete, PR merge) in settings.json permissions
  • Allowlist only the read/test tools the job actually needs
  • Run the job on a throwaway/sandbox branch, never the default branch
  • Keep merge behind human PR review — the pipeline never merges itself
  • Add a PreToolUse hook to block commits that touch secrets
  • State the rule: never grant an unattended pipeline push/merge/delete rights

💡 Hint: Assume no human will intervene mid-run — everything irreversible must be blocked by config before the job starts.

Show solution

Safety comes from denying the irreversible and scoping the allowed:

// settings.json (CI profile)
{
  "permissions": {
    "deny":  ["Bash(git push*)", "Bash(rm -rf*)", "Bash(gh pr merge*)"],
    "allow": ["Read", "Grep", "Bash(pnpm test)", "Bash(pnpm lint)"]
  }
}
// Plus: run in a sandbox/branch, require PR review before merge,
// and use a PreToolUse hook to block commits that touch secrets.

Headless automation removes the human "are you sure?" — so the guardrails move into config: denylist the irreversible actions, allowlist only the read/test tools the job needs, run on a throwaway branch, and keep merge behind human PR review. Never give an unattended pipeline push/merge/delete rights. Verify exact permission syntax in the docs.

Exercise 6 · Own the automation standards for the orgIndustry scenario

Context: The org-level call is which tasks to automate headlessly versus keep interactive; the axes that decide it are reversibility, specification quality, and verifiability.

Your task: As tech lead, write a selector that decides headless-vs-interactive for a task, plus the rollout policy for the standard.

Requirements:

  • The selector takes reversibility, specification quality, and verifiability
  • No automated way to verify → interactive
  • Irreversible → interactive with a human gate
  • Reversible + well-specified + verifiable → safe to run headless
  • Ambiguous but otherwise fine → interactive (too ambiguous to automate reliably)
  • Roll out via shared, version-controlled settings.json (hooks + permissions) and .claude/commands/ so guardrails ship with the repo

💡 Hint: Only the intersection of safe, well-specified, and checkable earns full automation — any missing axis pulls it back interactive.

Show solution

Automate the safe, well-specified, verifiable tasks; keep judgement interactive:

def automate(reversible, well_specified, has_automated_check):
    if not has_automated_check:
        return "INTERACTIVE — no automated way to verify the result"
    if not reversible:
        return "INTERACTIVE + human gate — irreversible action needs approval"
    if well_specified:
        return "HEADLESS — scriptable, verifiable, safe to run unattended"
    return "INTERACTIVE — task too ambiguous to automate reliably"

print(automate(True,  True,  True))    # HEADLESS (e.g. commit-message gen, lint fixes)
print(automate(False, True,  True))    # INTERACTIVE + human gate (e.g. deploys)
print(automate(True,  False, True))    # INTERACTIVE (ambiguous refactor)

The three axes are reversibility, specification quality, and verifiability. Roll standards out as shared, version-controlled settings.json (hooks + permissions) and .claude/commands/ so the safe defaults ship with the repo — the team inherits the guardrails instead of reinventing them.

✓ Checkpoint — you can move on when you can…

  • Run Claude Code headless with JSON output.
  • Write hooks on tool/stop events; model the dispatcher.
  • Create slash commands and a CI review.
  • Set safe-automation standards with human gates.

Knowledge check check yourself

✓ Knowledge check

The lesson says "hooks are how 'always do X' actually happens" and contrasts them with instructions in a prompt. Why put format-on-edit or test-on-stop in a hook rather than telling Claude to do it?

Show answer
Claude can forget an instruction, but a hook is a shell command the harness runs deterministically on an event, so anything that must run every single time — formatting, tests, audit logging — is guaranteed rather than dependent on the model remembering.
✓ Knowledge check

The allow_automated gate blocks actions like git push or deploy unless human_approved is set, and the lesson says automation should "fail closed." Why is a human gate on irreversible actions non-negotiable in unattended runs?

Show answer
In automation there's no one to answer a permission prompt, so an unbounded agent could push, deploy, delete, or spend money unreviewed; failing closed (refuse when unsure) plus a required human-approval flag on irreversible actions keeps an unattended agent from doing real damage.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in