DEV Community

Henry Godnick
Henry Godnick

Posted on

How to set up Claude Code hooks and slash commands for a faster workflow

Most of the time I lose with Claude Code isn't spent waiting on the model. It goes on the same small chores, over and over: typing out the same review prompt, reminding it to run the formatter, flipping back to the terminal to see whether it's waiting on me, and pasting git diff into the chat for the hundredth time. Claude Code has two built-in features that handle these: hooks, which run your own scripts at fixed points in a session, and custom slash commands, which turn a prompt you keep retyping into a one-word command. Codex CLI now has close equivalents. Here's a setup you can copy, with the config files.

Hooks vs. slash commands: which one you need

The difference matters, so here it is in one line each:

  • Hooks are deterministic. A hook is a shell command that Claude Code runs every time an event fires, like "after any file edit" or "when Claude needs input." The model doesn't decide whether it runs. Use hooks for things that must always happen.
  • Slash commands are prompts. You type /review and Claude gets a saved block of instructions, filled in with your arguments and, optionally, live shell output. Use them for things you want on demand.

A rule of thumb: if your CLAUDE.md says "always run X after editing," that's a hook in disguise. Instructions get skipped sometimes, and hooks don't.

Where hook config lives

Hooks go in the same JSON settings files as the rest of Claude Code's config:

File Scope
~/.claude/settings.json All your projects (just you)
.claude/settings.json This project, committed and shared with the team
.claude/settings.local.json This project, just you (gitignored)

Every hook has three layers: an event (such as PostToolUse), a matcher that filters it (such as Edit|Write), and one or more handlers that run. A command handler gets a JSON description of the event on stdin, so nearly every hook script starts with jq. Install it if you don't have it:

brew install jq
Enter fullscreen mode Exit fullscreen mode

After editing settings, type /hooks inside Claude Code. It opens a read-only list of every configured hook and the file it came from, which is the fastest way to confirm Claude Code picked up your change.

Hook 1: Auto-format every file Claude edits

This is the one hook I'd tell everyone to add. The PostToolUse event fires after a tool call succeeds, and the matcher limits it to the file-editing tools. The event JSON includes tool_input.file_path, so you can pass the edited file straight to your formatter:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Swap in whatever your stack uses: ruff format, gofmt -w, rustfmt, or swift-format. Since formatting now happens without you asking, you can drop "remember to run Prettier" from your prompts and CLAUDE.md. Your diffs also stop filling up with whitespace noise.

If you need finer control than the matcher gives you, handlers accept an if field that uses permission-rule syntax. For example, "if": "Edit(*.ts)" runs the handler only for TypeScript files, and Claude Code skips spawning the process for everything else.

Hook 2: Get a Mac notification when Claude needs you

If you leave a long task running and switch to a browser, you won't notice when Claude stops to ask for a permission. The Notification event fires exactly then. On macOS, osascript can show a native notification:

{
  "hooks": {
    "Notification": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Put this one in ~/.claude/settings.json so it covers every project. One gotcha: osascript notifications go through Script Editor, and if Script Editor doesn't have notification permission, they fail silently. Run osascript -e 'display notification "test"' once in Terminal, then check System Settings → Notifications → Script Editor.

You can narrow the matcher too. permission_prompt fires only for permission requests, and idle_prompt fires when Claude is waiting for your next message.

Hook 3: Block commands you never want run

PreToolUse runs before a tool call, and it can stop the call. The simplest way to block is to exit with code 2. Claude Code cancels the call and shows Claude whatever your script wrote to stderr, so Claude knows why and can try something else.

Save this as .claude/hooks/guard.sh and run chmod +x on it:

#!/bin/bash
# Blocks destructive shell commands and edits to secrets files.
input=$(cat)
tool=$(jq -r '.tool_name' <<<"$input")

if [[ "$tool" == "Bash" ]]; then
  cmd=$(jq -r '.tool_input.command' <<<"$input")
  if grep -Eq 'rm -rf|git push (-f|--force)|git reset --hard' <<<"$cmd"; then
    echo "Blocked by guard.sh: '$cmd' is not allowed. Ask the user to run it." >&2
    exit 2
  fi
fi

if [[ "$tool" == "Edit" || "$tool" == "Write" ]]; then
  file=$(jq -r '.tool_input.file_path' <<<"$input")
  if [[ "$file" == *.env || "$file" == *.env.* ]]; then
    echo "Blocked by guard.sh: don't edit env files directly." >&2
    exit 2
  fi
fi

exit 0
Enter fullscreen mode Exit fullscreen mode

Then register it:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.sh",
            "args": []
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

There are two details worth knowing. First, only exit code 2 blocks. Exit code 1 counts as a non-blocking error, and the tool call still goes ahead. Second, ${CLAUDE_PROJECT_DIR} points at the project root, so the hook still works after Claude cds into a subdirectory. Setting "args": [] runs the command without a shell, which keeps paths with spaces from breaking.

Treat this as a safety net, not a security boundary. A determined command can be written in a way a regex won't catch. For hard limits, use permission deny rules in settings, and use hooks for the friendly guardrails on top.

Hook 4: Inject fresh context at session start

SessionStart hooks run when a session begins or resumes. On this event, anything your command prints to stdout gets added to Claude's context. That makes it a good place for facts that change often and don't belong in CLAUDE.md:

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup|resume",
        "hooks": [
          {
            "type": "command",
            "command": "echo \"Branch: $(git branch --show-current)\"; echo 'Uncommitted:'; git status --short | head -20"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Keep it short and factual. This text loads on every session, so a 300-line dump costs you tokens on every single one.

A related warning about Stop hooks, which run when Claude finishes a turn. Exiting 2 there forces Claude to keep working. That's handy for "don't stop until tests pass," but if the condition can never be met, the loop never ends. The input includes stop_hook_active, which is true when Claude is already continuing because of a stop hook, so check it and exit 0 when it's set.

Custom slash commands: stop retyping prompts

A custom slash command is just a Markdown file. Put it in .claude/commands/ to share it with your project, or in ~/.claude/commands/ to use it everywhere, and the filename becomes the command. (Claude Code has since merged commands into "skills," and .claude/skills/<name>/SKILL.md works the same way, but plain command files still work and are the quickest place to start.)

Here's .claude/commands/review.md:

---
description: Review my uncommitted changes before I commit
argument-hint: [focus area]
allowed-tools: Bash(git diff *) Bash(git status *)
---

## Current changes
!`git status --short`

!`git diff HEAD`

## Task
Review the diff above as a strict senior reviewer. Focus on: $ARGUMENTS

List real problems only (bugs, missing error handling, unclear names,
missing tests), ordered by severity, with file and line. Don't rewrite
code unless I ask. If it looks good, say so in one line.
Enter fullscreen mode Exit fullscreen mode

Now /review error handling does the whole job. Here's what each piece does:

  • !`command` runs the command before the prompt goes to Claude, and its output replaces the placeholder. Claude starts with the real diff instead of spending a tool call (and your tokens) fetching it. The ! has to be at the start of a line or follow whitespace.
  • $ARGUMENTS is whatever you typed after the command name. For positional arguments, $0 is the first and $1 the second (they're 0-based), and you quote multi-word values: /fix "login page" high.
  • allowed-tools pre-approves those tools for the turn that runs the command, so you don't get permission prompts for the git calls.
  • description and argument-hint show up in the / autocomplete menu.

Subfolders become namespaces, so .claude/commands/db/migrate.md turns into /db:migrate.

A few more commands I'd suggest adding to most repos:

  • /test-plan: "Given !`git diff main...HEAD`, list the test cases this change needs and which ones exist already."
  • /explain $ARGUMENTS: "Explain how this part of the codebase works, citing files. Don't change anything."
  • /handoff: "Summarize what we did, what's left, and any open questions as a short note I can paste into the PR."

If you turn a command into a skill, add disable-model-invocation: true for anything with side effects, like deploys or anything that posts somewhere. Claude can then never run it on its own, and only you can trigger it.

The same setup in Codex CLI

Codex has caught up on both features, and the config looks familiar.

Hooks. Codex reads ~/.codex/hooks.json or <repo>/.codex/hooks.json (or inline [hooks] tables in config.toml). It uses the same event, matcher, and handler structure, with events such as SessionStart, PreToolUse, PostToolUse, and Stop. File edits go through apply_patch, but a matcher of Edit or Write also matches it. The big difference is trust: Codex skips a new or changed hook until you review and approve it in /hooks, so after adding one, open /hooks and trust it, or it won't run.

Reusable prompts. The old ~/.codex/prompts/*.md custom prompts (invoked as /prompts:name) still work but are deprecated. The current approach is skills: a folder holding a SKILL.md with name and description frontmatter, in .agents/skills/ in your repo or ~/.agents/skills/ for yourself. You invoke one explicitly by typing $ and the skill name, or by browsing with /skills. Codex can also pick a skill on its own when your request matches its description.

Because both tools follow the open Agent Skills format, one well-written SKILL.md can often work in both with only small changes.

A starter kit to commit today

If you only do four things:

  1. Add a formatter hook on PostToolUse with matcher Edit|Write, in .claude/settings.json.
  2. Add a notification hook in ~/.claude/settings.json.
  3. Add a guard script on PreToolUse for the commands that scare you.
  4. Write a /review command that injects git diff with !`...`.

Commit the project-level files so your teammates get the same setup. Keep personal preferences in settings.local.json or your home directory. Check /hooks after every change.

These changes also save tokens. Injecting the diff up front, scripting formatting instead of describing it, and keeping session-start context short all mean fewer turns where the model is just collecting information. If you want to see how that adds up against your plan limits, that's the reason I made a small tool for it.

Disclosure: I built TokenBar, a $15 Mac menu bar app that shows your Claude and Codex usage and cost: https://tokenbar.site

What hooks or commands have you added that I should steal? Drop them in the comments.

Top comments (0)