AI EngineeringZero to ProductionHome·About·What’s new·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

Here's the moment this chapter is built for. You've spent a week pair-programming with Claude Code and it's genuinely good — but you're still the bottleneck. Every review, every formatting pass, every "run the tests before you commit" happens only when you remember to ask. The obvious next thought is the dangerous one: "what if it just ran on its own?" That instinct is right. The trap is doing it without a plan, and this chapter is the plan.

Think about the difference between a power drill and an assembly line. A drill is a tool you hold — powerful, but every action needs your hand on it. An assembly line is the same tools wired to triggers: a part arrives, a step fires, the result moves on, and it runs at 3am whether you're there or not. K1 handed you the drill. This chapter wires the line. The catch — and it's the whole reason this chapter has a "safe automation" section — is that an assembly line with no guard rails will happily mangle whatever you feed it, at full speed, unattended.

Why the shift matters more than it looks. Interactive Claude Code has a safety net you never notice: you. When it proposes something risky, it asks, and you're sitting there to say no. The instant it runs headless in CI or a git hook, that net is gone — nobody is watching to answer the prompt. So automation isn't just "the same thing, faster"; it's a different risk model. The common beginner mistake is to treat a pipeline like a chat window and wire Claude straight into something that can push code or reach production — then act surprised when an unattended run does exactly what it was allowed to. The skill you're building here is to automate the tedious and gate the dangerous, deliberately, in config rather than in good intentions.

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 Codeheadless -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

Before automation, Claude Code is a conversation; headless mode makes it a command. That one change is what lets everything else in this chapter exist. A conversation waits for you, remembers context, asks follow-ups. A command takes an input, does one job, prints an answer, and exits — which is exactly the shape every script, pipeline, and cron job already knows how to talk to. Headless mode is how you turn the chatty assistant into a well-behaved Unix citizen.

If you've ever used grep or sort, you already understand the mental model. Those tools don't open a window and chat — you feed them text on the way in, they emit text on the way out, and you chain them together with pipes: cat file | grep error | sort. Headless Claude Code slots into that same pipeline as just another filter. git diff | claude -p "review this" reads like plain English precisely because it is the same pattern: stdin in, stdout out, exit. The -p ("print") flag is the switch that says "don't open a chat — take this one prompt, answer it, and get out of the way."

Why the output format is the hinge. A human reading a review wants prose. A script reading a review wants fields it can branch on — did it find bugs, how many, of what severity. That's what --output-format json buys you: instead of paragraphs you get structured data your code can parse and act on, which is the difference between "print the answer" and "make a decision based on the answer." Every real automation in this chapter ultimately rests on that ability to consume Claude's output programmatically. The mistake to sidestep: don't try to regex facts out of free-form prose when a structured format exists — brittle text-scraping is the number-one reason home-grown Claude pipelines break the first time the wording changes. Flag names for output format do evolve, so treat the docs as the source of truth and verify before you depend on an exact spelling in production.

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.

git diff stdin (context) claude -p "…" one prompt, then exits prose for a human to read --output-format json fields a script parses decision branch on it Headless mode is a Unix filter. Text in, one prompt, text out, exit — so Claude chains into pipes like grep. Asking for JSON turns the answer from prose into fields a script can branch on.
🗺️ How to read this diagram

Read it left to right: data flows in, Claude runs once, and the output shape decides what the rest of your automation can do.

  • The left blue box is stdin — here git diff pipes your code changes in as context, exactly as you'd feed text to grep or sort.
  • The middle box is claude -p "…": the -p ("print") flag runs a single prompt and exits — no chat window, no waiting for you.
  • The two right boxes are the fork that matters: plain prose (grey) is fine for a human to read, but --output-format json (green) returns structured fields a script can parse and turn into a decision (amber) — pass/fail, how many bugs, what severity.

In short: stdin → one prompt → stdout, then exit. Prose is for people; JSON is what lets code act on Claude's answer — the basis of every automation in this chapter.

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 1 — claude -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

Here's the uncomfortable truth about telling an AI to "always do X." You can write "always run the tests before you finish" into your prompt, and Claude will usually comply — usually. But "usually" is a word that has no place near a production repo. Models are probabilistic; on a long, messy task the instruction can slip, get crowded out, or be quietly deprioritised. Anything you truly need to happen every single time can't live in a request the model might forget. It has to live somewhere that isn't the model at all. That somewhere is a hook.

Think of a hook like the interlock on a microwave door. The manufacturer doesn't put a sticker on the front saying "please remember to stop the microwave before opening the door" and trust you to comply — they wire a switch that cuts the magnetron the instant the door opens, deterministically, no memory required. A hook is that switch for Claude Code: you register a shell command against a lifecycle event — before a tool runs (PreToolUse), after it runs (PostToolUse), when the session stops (Stop) — and the harness, not the model, runs your command when that event fires. The model can't skip it and can't forget it, because the model was never in charge of it.

Why this reframes what "reliable" means. Once you internalise that hooks are run by the harness, a clean division of labour appears: put judgement in the prompt (what to build, how to approach a problem) and put guarantees in hooks (format on every edit, test on every stop, log on every Bash call). The two don't compete — they cover different needs. The common mistake is the reverse of forgetting: cramming procedural must-happen steps into an ever-growing system prompt and wondering why compliance is flaky. If it must happen deterministically, it's a hook; if it needs the model's reasoning, it's a prompt. Keep that line clean and your automations stop being hopeful and start being dependable. (Event names and matcher syntax are worth confirming in the Claude Code docs — they're the kind of detail that changes across versions.)

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."

"always run tests" in the PROMPT the model probabilistic may skip tests? only usually same rule as a HOOK Stop event fires harness not the model pytest runs — every time Guarantees live in hooks, not prompts. A "please run tests" instruction depends on the model remembering (it might not). A hook is run by the harness on an event, so the command fires deterministically — the model can't skip it.
🗺️ How to read this diagram

Two paths, side by side, for the same goal ("tests always run"). Compare the left (a prompt) against the right (a hook).

  • Left path: the instruction lives in the prompt, so it runs through the model — which is probabilistic. The dashed grey arrow "may skip" lands on a red tests? only usually box: on a long messy task the instruction can get crowded out.
  • Right path: a lifecycle event (here Stop) fires and the harness — not the model — runs your command. The solid arrows are deterministic, not hopeful.
  • The green box is the payoff: pytest runs every single time, because the model was never in charge of whether it ran.

In short: Put judgement in the prompt, guarantees in hooks. If it must happen every time, it belongs on an event the harness owns — not in a request the model might forget.

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

You've written a hook and it didn't fire. Now what? This is the moment hooks stop feeling like magic config and start feeling like a black box — and a black box you can't reason about is a black box you can't trust with your repo. The cure is to stop treating dispatch as mysterious and understand the actual rule the harness uses to decide what runs. Once you can predict it on paper, a silent hook becomes a two-second diagnosis instead of an afternoon of guessing.

The mechanism is simpler than the mystery suggests. Picture a bouncer at a club with a guest list organised by night. An event happens ("someone wants in on Friday"); the bouncer flips to the Friday page (that's keying by event), then checks each name against the tool trying to enter — some entries say "anyone" (the * matcher), others name a specific guest ("Bash", "Edit"). Everyone who matches gets waved through, in list order. That's the entire algorithm: look up the event, filter its entries by whether the matcher matches the tool, collect the survivors, run them in order. No hidden state, no priority tricks.

Why bother modeling it in code? Because a rule you can run is a rule you can no longer argue with. The lab below rebuilds this dispatcher in a dozen lines of Python, and the payoff is diagnostic superpower: given any event, tool, and config, you can compute exactly which commands fire before you ever launch Claude. The common confusion this clears up is the "why did two hooks fire?" surprise — matchers stack, so a * entry and a "Bash" entry both fire on a Bash call, because both match. It's not a bug; it's the collect-all-survivors step doing its job. Understanding that turns hook debugging from superstition into arithmetic.

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

event: PreToolUse tool = Bash that event's entries — filter by matcher vs tool * matches → keep Bash matches → keep Edit no match → drop fired, in order both * and Bash entries run Dispatch is match-and-collect. Key by the event, filter its entries by whether each matcher matches the tool, keep the survivors in list order. Matchers stack: a * and a Bash entry both fire on a Bash call — not a bug.
🗺️ How to read this diagram

Follow it left to right: an event arrives, its entries get filtered, and the survivors run. That's the entire algorithm — no hidden state.

  • The left blue box is the incoming event and the tool in play: PreToolUse with tool = Bash. The harness first keys by the event to find only its entries.
  • The middle stack is those entries. Each matcher is tested against the tool: * matches anything and Bash matches this tool (both kept, solid arrows), while Edit doesn't match and is dropped (dashed arrow).
  • The green box collects the survivors in list order. The "aha": two entries fire because matchers stack — that's the collect-all step doing its job, not a duplicate bug.

In short: look up the event → filter entries by matcher-vs-tool → run every survivor in order. Predict it on paper and a silent hook becomes a two-second diagnosis.

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

Watch what happens to a great prompt over time. You craft the perfect code-review prompt — structured, specific, catches the things your team keeps missing. You use it, it works, you're pleased. Two weeks later you retype it from memory and it's a bit shorter. A teammate asks how you did it and you paste a slightly different version into Slack. Within a month there are five drifting copies and no canonical one. The prompt was valuable; the fact that it lived only in your head and your scrollback was the problem. Slash commands fix exactly that.

A slash command is the same idea as a saved recipe card versus cooking from memory. Cooking from memory, the dish drifts every time and nobody else can reproduce it. Write the recipe on a card and drop it in the shared box, and now anyone can pull it out and get the same result — and if you improve it, you improve the one card everyone reads. A slash command is that card: a Markdown file in .claude/commands/ whose filename becomes the command name (review.md → /review) and whose contents are the prompt that gets expanded when you type it. It turns a good prompt from personal folklore into shared, versioned infrastructure.

Why "it's just a file" is the whole point. Because the command lives as a file in the repo, it inherits everything your repo already gives you for free: it's committed, so it's version-controlled; it's reviewed, so changes get scrutiny; it's shared, so the whole team runs the identical review instead of six personal variants. That's the leap from "my clever prompt" to "our team standard." The mistake worth naming: don't keep reinventing your best prompts in the chat window — the second a prompt is worth typing twice, it's worth saving as a command so it stops decaying. Slash commands also support arguments and frontmatter (things like a description or allowed tools); those specifics are exactly the kind of thing to confirm in the Claude Code docs, since the directory and frontmatter keys can shift between versions.

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

.claude/commands/review.md a Markdown prompt file filename → command name /review prompt expands same review, every time it's a file in the repo versioned reviewed shared w/ team A good prompt becomes shared infrastructure. The filename in .claude/commands/ is the command name (review.md → /review). Because it's a file in the repo, it's versioned, reviewed, and shared — one canonical prompt instead of drifting personal copies.
🗺️ How to read this diagram

Follow the arrow left to right: a file turns into a command, and because it's a file it inherits everything the repo gives you (the right-hand column).

  • The left blue box is the file itself — a plain Markdown prompt saved at .claude/commands/review.md.
  • The middle box shows the rule: the filename becomes the command name, so review.md is invoked as /review, which expands into the saved prompt — the same structured review every time (green box).
  • The right stack of amber tags is the payoff of "it's just a file": committed → version-controlled, changes get reviewed, and it's shared so the whole team runs the identical prompt instead of six drifting variants.

In short: saving a prompt as a file promotes it from personal folklore to team-standard, versioned infrastructure — the second a prompt is worth typing twice, make it a command.

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

This is where the four boxes from the opening diagram finally snap together. Up to now you've met the pieces in isolation — headless mode, hooks, commands. CI is the assembly line that runs them for real, without you, on an event you didn't personally trigger. Everything in this chapter was building toward being able to say: "on every pull request, a reviewer looks at the diff and leaves comments" — and have that reviewer be Claude Code, running itself.

The right way to picture the CI reviewer is as a tireless first-pass triage nurse, not the doctor. The nurse doesn't diagnose and discharge; they take vitals, flag the obvious red flags, and hand a cleaner case to the doctor. A CI review step does the same for your team: it catches the missing test, the hard-coded secret, the off-by-one — the boring, mechanical stuff a human reviewer resents having to spot — so that when a person opens the PR, their attention lands on the judgement calls that actually need a human. The map from the diagram is exact: the PR is the trigger, claude -p is headless mode, and the posted comment is the result feeding the pipeline.

Why the "never replaces human review" line is not a disclaimer but a design rule. The temptation, once the bot is catching real bugs, is to let it gate merges — to make its verdict binding. Resist that. An LLM reviewer is confidently wrong often enough that handing it a veto over your main branch trades one kind of risk for a worse one, and it does so silently. The pattern that ages well is advisory: it comments, a human decides, merge stays behind human approval. And because CI runs unattended with a real API key, this section is also the doorway to the safety concerns that dominate the rest of the chapter — the same run that can post a helpful comment could, if you let it, push a branch or hit an external system. Notice the key comes from encrypted secrets and never touches the workflow file; that discipline is the first of the guard rails the next sections make non-negotiable.

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.

pull request trigger claude -p (headless) reviews the diff advisory comment by severity · result human decides merge stays gated first-pass triage nurse, not the doctor — the bot never merges itself The CI reviewer is advisory, by design. A PR is the trigger, claude -p is headless mode, the posted comment is the result — but the bot only comments; a human still owns the merge. An LLM reviewer is wrong often enough that a binding veto trades one risk for a worse one.
🗺️ How to read this diagram

Read it left to right — it's the same four-box shape as the opening diagram, made concrete for a code review.

  • The pull request (far left) is the trigger: opening or updating a PR starts the job, with nobody pressing a button.
  • claude -p (headless) is Claude running in batch mode on the PR's git diff — the headless mode from §2.
  • The advisory comment (amber) is the result feeding the pipeline: findings grouped by severity, posted onto the PR.
  • The green box is the load-bearing design rule: a human still decides, and merge stays gated behind human approval. The bot is the triage nurse taking vitals — never the doctor discharging the patient.

In short: PR → headless review → comment → human decides. It catches the boring, mechanical bugs so a person's attention lands on the judgement calls — but it advises, it never merges.

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

Every earlier section quietly removed a safety net; this section puts a new one back. When you were at the keyboard, you were the guard rail — Claude proposed, you approved, and anything alarming stopped at your "no." Automation deletes that human in the loop by design. An unattended agent with broad permissions isn't a productivity tool anymore; it's a process that can push, deploy, delete, or spend money with nobody positioned to catch it. The power you built in sections 1–6 is real, and so is the blast radius. This section is where you bound it.

The right mental model is a bank cashier, not a locked vault. A vault that opens for nothing is safe but useless; a cashier is useful precisely because they can move money — under strict, pre-agreed limits. They can hand over cash up to a threshold, but a large withdrawal requires a manager's signature, and that rule holds whether or not the manager happens to be looking. Safe automation is built the same way: give the pipeline exactly the privileges the job needs and no more (least privilege), let it freely do the reversible, cheap things, and put a hard human gate in front of the irreversible ones — push, deploy, delete, refund. Crucially, that gate must be structural, enforced in permissions and config, not a polite instruction the model is trusted to honour.

Why "fail closed" is the load-bearing phrase. Systems face situations their author never anticipated, and the entire question is which way they lean when that happens. Fail open means "when unsure, proceed" — which in an unattended agent is how a novel edge case becomes a production incident. Fail closed means "when unsure, refuse and ask for a human" — the automation stops safely instead of guessing dangerously. Pair that default with an audit hook that logs every action, so that if something does slip through you have a record to reconstruct what happened. The mistake that burns teams is inverting the trust: assuming the agent will behave and bolting on safety after the first scare. Do it the other way round — assume no one is watching, block everything irreversible before the job starts, and treat any pipeline that can act on your repo with the same seriousness as production infrastructure, because that is exactly what it is.

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.

action from the pipeline gate irreversible? no run freely pytest, read, lint — reversible & cheap yes refuse unless human_approved push · deploy · delete · refund fail closed: unsure → stop, ask a human Least privilege + a human gate + fail closed. Reversible, cheap actions run freely; irreversible ones (push, deploy, delete, refund) are refused unless a human approved. When unsure the gate stops rather than guesses — enforced in config, not in a polite instruction.
🗺️ How to read this diagram

Follow the action into the gate in the middle, then split on one question: is this irreversible?

  • The left blue box is any action the unattended pipeline wants to take. Everything must pass through the gate.
  • The upper green path (no) is the cheap, reversible work — running tests, reading, linting. It runs freely, because undoing it is easy and there's no blast radius.
  • The lower red path (yes) is the irreversible set — push, deploy, delete, refund. It is refused unless a human explicitly approved it (the human_approved flag). That's the structural human gate.
  • The last line on the red box is the load-bearing rule: fail closed — when the situation is unclear, stop and ask a human rather than proceed and hope.

In short: free rein on the reversible, a hard human gate on the irreversible, and "refuse when unsure" as the default — all enforced in permissions and config, not in good intentions.

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

Everything so far, you could do for yourself. This section is about doing it for twenty people who won't read the docs. An individual engineer can hold "gate the dangerous stuff" in their head and mostly get it right. A team can't — not because they're careless, but because a standard that lives in one person's judgement is a standard that's already gone the moment they're on holiday. The tech-lead job isn't to be the person who remembers the safe pattern; it's to make the safe pattern the default, so that being careful requires no memory at all.

Think of it as the difference between a lifeguard shouting rules at a busy pool and a pool designed so the rules enforce themselves — a fence around the deep end, a gate that latches on its own, depth markers painted on the tiles. The shouting lifeguard scales to one pool on a quiet day; the design scales to every pool, every day, with no one shouting. A lead codifies automation the second way. The decisions are real ones — which hooks are mandatory, what CI is allowed to run Claude on, the unbending rule that irreversible actions need a human — but the art is encoding them into shared, version-controlled settings.json (hooks and permissions) and .claude/commands/, so the guard rails ship with the repo. A new hire clones the project and inherits the safe defaults without being told.

Why a written selector beats a wise leader. The genuinely hard call is which tasks earn full automation and which stay interactive, and "ask the lead" doesn't scale past the lead's attention. So make the criteria explicit: a task is safe to run headless only at the intersection of reversible, well-specified, and automatically verifiable — and any missing axis pulls it back to interactive, with a human gate if it's also irreversible. Writing that rule down converts your judgement into something the whole team can apply identically, tomorrow, without you. The failure mode to avoid is the heroic lead who is personally the safety system: it works right up until the day they're unavailable and someone ships an unguarded pipeline. Codify the standard, ship it in the repo, and the team automates the tedious and gates the dangerous by default — which is the entire point of this chapter, made durable.

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.

a task automate it? verifiable? reversible? well-specified? HEADLESS — safe to run unattended all three axes true INTERACTIVEno way to verify / ambiguous not verifiable, or under-specified INTERACTIVE + human gate irreversible action needs approval A written selector beats a wise leader. Only the intersection of verifiable, reversible, and well-specified earns full automation; any missing axis pulls the task back to interactive — with a human gate if it's also irreversible. Codified, the whole team applies it identically.
🗺️ How to read this diagram

A task enters on the left and is routed through three yes/no axes; where it exits on the right is the decision.

  • The left blue box is the task. The three middle boxes are the axes the lead writes down: can the result be verified automatically, is the action reversible, and is the task well-specified?
  • The green exit is the only fully-automated outcome — HEADLESS — reached only when all three axes are true (reversible + well-specified + verifiable).
  • The amber exit catches "no automated check" or "too ambiguous": stay INTERACTIVE. The red exit is the irreversible case: INTERACTIVE + human gate — a human must approve.

In short: any missing axis pulls the task off the headless path. Writing the rule down converts one lead's judgement into a standard the whole team can apply the same way, tomorrow, without them.

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