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.
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.
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.
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
cronjob. Nobody types anything — an event starts it. - Claude Code (headless) is Claude running in batch mode via the
-pflag ("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.
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
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.
- Line 1 —
claude -p "Summarize what changed…"runs one prompt and prints the reply to the screen. The# one-shotcomment just notes it does a single job. - Line 2 adds
--output-format json, which makes Claude return structured JSON instead of prose. The> todos.jsonpart redirects that output into a file a script can read later. - Line 3 shows the
|(pipe):git diffproduces 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.
--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."
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" } ]}
]
}
}
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.
"Stop"is the event for "Claude finished the task." Its commandpytest -qruns your tests;|| echo 'TESTS FAILED'means "if pytest fails, print that message" (||= "or else")."matcher": "*"means "match everything" — the Stop hook always fires. A matcher narrows which tools or events trigger the hook."PreToolUse"fires before a tool runs. Here"matcher": "Bash"limits it to Bash commands only, so it runs theechoright before any shell command Claude wants to run.- That
echoappends a timestamped line (the date plusbash call) onto.claude/audit.logusing>>(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.
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']
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.
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.hooks.get(event, [])looks up the hooks for that event, safely returning an empty list if none are configured. Thefor matcher, cmd in …loop then checks each one.- The
if matcher == "*" or fnmatch.fnmatch(tool, matcher):line is the decision: a"*"matcher fires for anything, otherwisefnmatchdoes wildcard name-matching (so"Bash"matches the tool"Bash"). Matches get added tofired. - 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.
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.
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.
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.
- 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. - 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.
- 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.
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
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.
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.runs-on: ubuntu-latestasks GitHub for a fresh Linux machine. Theactions/checkout@v4step downloads your code onto it;fetch-depth: 0grabs the full history sogit diffworks.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.- The
run: |block runs shell commands: save the PR's diff to a temp file, runclaude -pheadless to review it intoreview.md, thencat(print) it — or post it as a PR comment via theghtool.
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.
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')
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."
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.- 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. - On refusal it returns
(False, "…needs human approval…")— aFalseflag plus a reason. Otherwise it returns(True, "ok"), allowing the action. - The three
printcalls test it: a harmlessrun pytestis allowed; agit pushwith no approval is blocked; the same push withhuman_approved: Trueis 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.
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
Stophook runs the linter automatically - A
/reviewslash command exists as a shared command file - A headless one-liner reviews
git diff(e.g. piped intoclaude -p) - Use
auto_gateso 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.
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.)
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.jsonunder 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.
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.
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.
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.jsonpermissions - 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
PreToolUsehook 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.
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
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
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?