Module 07: Skills, Hooks, and Workflow Automation in Claude Code
This module covers the full automation layer of Claude Code: skills (slash commands), hooks
(lifecycle event handlers), the settings.json configuration system, and workflow patterns
that combine them into powerful automated pipelines.
By the end of this module you will be able to:
- Write production-ready skill files that Claude can invoke as slash commands
- Configure hooks that fire on specific tool events to lint, log, or block behavior
- Structure settings.json correctly for both global and project-level configuration
- Design multi-step automated workflows triggered by Claude’s actions
- Understand when to use skills vs CLAUDE.md instructions vs hooks
1. What Are Claude Code Skills
The Core Concept
A skill is a markdown file that Claude Code loads as a slash command. When you type
/commit or /review-pr, Claude Code finds a markdown file with that name, expands its
contents as a detailed prompt, and injects that prompt into the conversation context.
Claude then executes the instructions in the skill body.
This is fundamentally different from chat-level instructions. A skill is:
- Reusable: defined once, invoked anywhere
- Shareable: committed to a repo or dotfiles
- Composable: one skill can reference another
- Auditable: version-controlled like code
Where Skills Live
Skills are looked up in two locations, in order of precedence:
Project-level (higher precedence):
.claude/skills/<skill-name>.md
Global (lower precedence, shared across all projects):
~/.claude/skills/<skill-name>.md
If both exist, the project-level skill wins. This allows project overrides of global
defaults — for example, your global /commit might follow Conventional Commits, but a
specific project might override it with a different commit style.
Invocation
Three ways to invoke a skill:
-
Slash command in the terminal: type
/skill-nameat the Claude Code prompt.
Claude Code intercepts it, loads the file, and injects the body as your message. -
Skill tool in agent code: the
Skilltool is available in Claude agent contexts.
Use it withskill: "skill-name"and optionalargs: "some arguments". -
Auto-invocation via trigger conditions: skills can declare trigger patterns in
their frontmatter. Claude evaluates these on every message to decide if a skill
should be auto-loaded.
Example agent invocation:
# Inside an agent, the Skill tool is available as a built-in
# Claude will call it like:
Skill(skill="commit", args="-m 'Fix null pointer'")Anatomy of a Skill File
A skill file is a standard markdown file with an optional YAML frontmatter block:
---
name: daily-standup
description: Generate a daily standup summary from git history and open issues
trigger: "when the user asks for standup, status update, or daily summary"
args:
- name: since
description: How far back to look (default: 24h)
default: "24h"
---
# Daily Standup Generator
You are generating a daily standup summary. Follow these steps exactly:
## Step 1: Check recent git activity
...Frontmatter fields:
name: canonical name (must match filename)description: shown in/helplistingstrigger: natural language condition for auto-invocationargs: list of named parameters the skill accepts
Body:
The body is a full prompt. It can contain:
- Step-by-step instructions
- Output format specifications
- Examples (few-shot)
- Tool invocation patterns
- Conditional logic expressed in prose
Skill vs CLAUDE.md Instruction
This is a common point of confusion. The key distinction:
| Aspect | CLAUDE.md | Skill |
|---|---|---|
| Loaded when | Always (every session) | Only when invoked |
| Purpose | Standing context, project norms | On-demand task execution |
| Length | Should be concise | Can be very detailed |
| Scope | Background knowledge | Active instructions |
| Example content | ”This project uses PEP 8” | Step-by-step commit workflow |
Rule of thumb: If it should always be true, put it in CLAUDE.md. If it’s a
procedure you run sometimes, make it a skill.
CLAUDE.md entries consume context on every message. Skills only consume context when
invoked. For long procedural prompts, skills are much more efficient.
2. Writing a Skill
File Format
---
name: <skill-name> # Must match the filename (without .md)
description: <one-liner> # Shown in /help
trigger: <condition> # Optional: when to auto-invoke
---
<skill body as markdown>The body is injected verbatim as the user’s message when the skill is invoked. Write it
as a direct instruction to Claude, in second person: “You are…”, “Do the following…”,
“Output a JSON object with…”.
Trigger Conditions
The trigger field is evaluated by Claude before every response. If the user’s message
matches the trigger condition, Claude will auto-invoke the skill even without an explicit
/skill-name call.
Trigger conditions are written in natural language, not regex:
trigger: "when the user asks to review a pull request or says 'review this PR'"Be specific. Vague triggers cause false positives (skill fires when it shouldn’t).
Overly narrow triggers cause false negatives (skill never fires automatically).
Good triggers:
"when the user asks for a standup summary or daily status""when the user asks to explain a piece of code they've pasted""when the user says 'commit' or 'create a commit' or 'make a commit'"
Bad triggers (too vague):
"when the user needs help""when writing code"
Prompt Structure in the Skill Body
A well-structured skill body has these sections:
1. Role declaration (optional but helpful for complex skills):
You are acting as a senior code reviewer. Your job is to...
2. Context gathering instructions:
First, read the following files to understand the current state:
- Run `git log --oneline -20` to see recent commits
- Read the PR description from the current branch
3. Analysis instructions:
Based on what you've read, identify:
1. Any obvious bugs or logic errors
2. Missing test coverage
3. Style inconsistencies vs the existing code
4. Output format:
Output your review in this exact format:
## Summary
<2-3 sentence overview>
## Issues Found
- [CRITICAL] <description>
- [WARNING] <description>
- [SUGGESTION] <description>
## Verdict
APPROVE / REQUEST_CHANGES
Testing a Skill
To test a skill you’ve written:
-
Manual invocation: place the file in
.claude/skills/, then type/skill-name
in Claude Code. Check that the output matches your expected format. -
Args testing: invoke with different arguments to verify conditional logic:
/daily-standup since=48hvs/daily-standup -
Trigger testing: say the trigger phrase and verify auto-invocation fires.
Say something clearly unrelated and verify it does NOT fire. -
Edge case testing: what happens if git returns no commits? What if there are
no open PRs? Your skill body should handle these gracefully.
Useful Skill Ideas
/commit: check staged changes, write a conventional commit message, confirm before committing/review-pr: fetch PR diff, check for bugs/style/tests, output structured review/daily-standup: summarize git activity, highlight blockers, list today’s priorities/explain-code: accept a code snippet, explain line-by-line with context/add-tests: read a function, generate pytest tests covering happy path + edge cases/changelog: diff two git refs, generate a changelog in Keep a Changelog format/debug-error: accept a stack trace, form hypotheses, suggest fixes with code
3. Claude Code Hooks
What Hooks Are
Hooks are shell commands (or scripts) that Claude Code executes at specific points in its
lifecycle. They let you intercept Claude’s actions — before or after a tool call — and
respond programmatically.
Think of hooks as middleware for Claude’s tool execution pipeline:
User message
→ Claude decides to call a tool
→ [PreToolUse hook fires]
→ Tool executes
→ [PostToolUse hook fires]
→ Claude sees result, continues
→ Claude finishes response
→ [Stop hook fires]
Hooks are shell commands. They can be:
- A simple one-liner:
echo "tool called" >> /tmp/log.txt - A path to a Python/Bash/Node script
- A piped command:
jq '.tool_name' | xargs notify-send
Event Types
PreToolUse
Fires before Claude executes a tool. You receive a JSON payload on stdin describing
what Claude is about to do. Your exit code controls whether the tool runs:
- Exit 0: proceed normally
- Exit 1 (or any non-zero): block the tool; Claude receives your stdout as the error message
This is the most powerful hook type. Use it to:
- Block dangerous operations (e.g.,
rm -rf) - Require confirmation for writes to production files
- Validate arguments before execution
- Rewrite or sanitize tool parameters
Example payload on stdin:
{
"event": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf /tmp/important"
},
"session_id": "abc123",
"transcript_path": "/tmp/claude-transcript.json"
}PostToolUse
Fires after a tool executes, before Claude sees the result. Use it to:
- Log tool calls and their outputs to a file
- Send notifications on specific events
- Trigger side effects (run a formatter, update a dashboard)
- Record metrics (latency, tool call frequency)
Exit code is informational here — Claude proceeds regardless. Your stdout is ignored.
Example payload:
{
"event": "PostToolUse",
"tool_name": "Write",
"tool_input": {
"file_path": "/src/main.py",
"content": "..."
},
"tool_result": {
"success": true
}
}Notification
Fires when Claude needs the user’s attention (e.g., waiting for input, permission
required). Use this to surface alerts in your OS notification system so you can step
away from the terminal while Claude works.
Stop
Fires when Claude finishes its response and stops. Use for:
- Final notifications (“Claude finished the task”)
- Cleanup operations
- Sending a summary to Slack
- Updating a task tracker
Where Hooks Live
Hooks are configured in the hooks section of settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/usr/local/bin/check-bash-safety.py"
}
]
}
],
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "python3 ~/.claude/hooks/log_tool_calls.py"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude finished\" with title \"Claude Code\"'"
}
]
}
]
}
}Structure:
- Each event type maps to an array of hook groups
- Each group has a
matcher(tool name or"*"for all tools) - Each group has a
hooksarray of commands to run - Commands run in order; for PreToolUse, first non-zero exit wins
Hook Input and Output
Input: JSON payload delivered on stdin. Always parse stdin in your hook script;
don’t rely on environment variables or arguments.
Output:
PreToolUse: stdout is sent to Claude as the error/block message if you exit non-zeroPostToolUse: stdout is ignoredStop: stdout is ignored
Timeout: hooks have a default timeout (typically 60s). Long-running hooks will be
killed. Keep hooks fast. For slow operations, use PostToolUse and run async.
Writing a Hook Script
#!/usr/bin/env python3
"""
PreToolUse hook: block bash commands containing rm -rf
"""
import json
import sys
payload = json.load(sys.stdin)
if payload.get("tool_name") == "Bash":
command = payload.get("tool_input", {}).get("command", "")
if "rm -rf" in command:
print(f"BLOCKED: rm -rf detected in command: {command}")
sys.exit(1) # Non-zero blocks the tool
sys.exit(0) # Zero allows the toolMake the script executable: chmod +x ~/.claude/hooks/check-bash-safety.py
Then register it in settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/check-bash-safety.py" }]
}
]
}
}4. settings.json Structure
Overview
Claude Code uses a layered configuration system:
~/.claude/settings.json Global defaults (all projects)
~/.claude/settings.local.json Machine-specific overrides (gitignored by default)
.claude/settings.json Project-level config (committed to repo)
.claude/settings.local.json Project + machine specific (gitignored)
Later layers override earlier ones. A project setting overrides a global setting.
settings.local.json is never committed to version control.
Full Annotated Schema
{
// --- PERMISSIONS ---
// Control which tools Claude can use without asking permission
"permissions": {
// Tools Claude can always use without asking
"allow": [
"Read",
"Glob",
"Grep",
"Bash(git *)", // Allow git commands
"Bash(npm run *)" // Allow npm scripts
],
// Tools Claude must never use (hard block)
"deny": [
"Bash(rm -rf *)", // Block destructive rm
"Bash(sudo *)" // Block sudo
]
},
// --- ENVIRONMENT VARIABLES ---
// Inject env vars into every Claude Code session
"env": {
"ANTHROPIC_MODEL": "claude-sonnet-4-5",
"LOG_LEVEL": "debug",
"PROJECT_ENV": "development"
},
// --- HOOKS ---
// Lifecycle event handlers
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 ~/.claude/hooks/safety_check.py"
}
]
}
],
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "python3 ~/.claude/hooks/log_tool_calls.py"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Done\" with title \"Claude Code\"'"
}
]
}
]
},
// --- MCP SERVERS ---
// Register Model Context Protocol servers
"mcpServers": {
"my-data-server": {
"command": "npx",
"args": ["-y", "@myorg/mcp-data-server"],
"env": {
"DB_URL": "postgresql://localhost/mydb"
}
},
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/dir"]
}
}
}The permissions Section
The allow and deny arrays use a pattern syntax:
"Read"— allow/deny the Read tool entirely"Bash"— allow/deny all Bash commands"Bash(git *)"— allow/deny Bash only when command starts withgit"Bash(npm run *)"— allow/deny Bash only fornpm runcommands
deny takes precedence over allow. If you allow "Bash" but deny "Bash(sudo *)",
sudo commands are still blocked.
Global vs Project vs Local
Global (~/.claude/settings.json):
- Apply to all projects on this machine
- Good for: personal preferences, global hooks, allowed tools you trust everywhere
- Committed to your personal dotfiles repo
Project (.claude/settings.json):
- Specific to this repository
- Good for: project-specific permissions, MCP servers used by this project
- Committed to the project repo — all team members share these settings
Local (settings.local.json):
- Overrides the corresponding settings.json but never committed
- Good for: personal API keys, local paths, development-only flags
- Add to
.gitignoreautomatically or manually
Best practice: Keep project settings.json minimal and checked in. Use
settings.local.json for anything sensitive or machine-specific.
5. Workflow Automation Patterns
Pattern 1: Auto-Format After File Edit
Goal: every time Claude edits a Python file, run black automatically.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "python3 ~/.claude/hooks/auto_format.py"
}
]
},
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": "python3 ~/.claude/hooks/auto_format.py"
}
]
}
]
}
}Hook script (auto_format.py):
#!/usr/bin/env python3
import json, sys, subprocess
payload = json.load(sys.stdin)
file_path = payload.get("tool_input", {}).get("file_path", "")
if file_path.endswith(".py"):
subprocess.run(["black", "--quiet", file_path])Pattern 2: Log All Tool Calls
Goal: maintain a full audit log of every tool Claude calls, for debugging and review.
#!/usr/bin/env python3
"""PostToolUse hook: log all tool calls to ~/.claude/tool_log.jsonl"""
import json, sys
from datetime import datetime
from pathlib import Path
payload = json.load(sys.stdin)
log_entry = {
"timestamp": datetime.utcnow().isoformat(),
"event": payload.get("event"),
"tool": payload.get("tool_name"),
"input": payload.get("tool_input"),
"session": payload.get("session_id")
}
log_path = Path.home() / ".claude" / "tool_log.jsonl"
with open(log_path, "a") as f:
f.write(json.dumps(log_entry) + "\n")Pattern 3: Auto-Commit After Tests Pass
This is a skill-level workflow, not a hook:
---
name: test-and-commit
description: Run tests; if they pass, commit with a generated message
---
Run the test suite using the project's test command (check package.json or Makefile).
If ALL tests pass:
1. Run `git diff --staged --stat` to see what's staged
2. If nothing is staged, run `git add -p` to interactively stage changes
3. Generate a conventional commit message based on the diff
4. Run `git commit -m "<generated message>"`
5. Report: "Committed as <hash>"
If ANY tests fail:
1. Show the failing tests
2. Attempt to fix them
3. Re-run tests
4. Only commit once all tests are greenPattern 4: Slack Notification on Completion
Stop hook that posts to Slack:
#!/bin/bash
# ~/.claude/hooks/notify_slack.sh
# Read payload from stdin (Stop event has minimal payload)
PAYLOAD=$(cat)
# Post to Slack webhook
curl -s -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-type: application/json' \
-d '{"text": "Claude Code finished a task in '"$(basename $PWD)"'"}'Register in settings.json:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/notify_slack.sh"
}
]
}
]
}
}Pattern 5: Block Writes to Production Config
#!/usr/bin/env python3
"""PreToolUse: block edits to production config files"""
import json, sys
PROTECTED_PATHS = [
"config/production.yml",
".env.production",
"k8s/prod/",
]
payload = json.load(sys.stdin)
tool = payload.get("tool_name")
file_path = payload.get("tool_input", {}).get("file_path", "")
if tool in ("Write", "Edit"):
for protected in PROTECTED_PATHS:
if protected in file_path:
print(f"BLOCKED: {file_path} is a protected production file.")
print("To edit this file, use a direct terminal command outside Claude Code.")
sys.exit(1)
sys.exit(0)6. Cron and Scheduled Agents
CronCreate Tool
The CronCreate tool schedules a prompt or skill to run at a recurring interval.
Claude Code will re-invoke itself with the specified message on the schedule.
Usage pattern:
CronCreate(
schedule="0 9 * * 1-5", # 9 AM Monday-Friday (cron syntax)
prompt="/daily-standup", # The message or skill to run
description="Daily standup summary"
)
The cron expression uses standard 5-field cron syntax:
┌─ minute (0-59)
│ ┌─ hour (0-23)
│ │ ┌─ day of month (1-31)
│ │ │ ┌─ month (1-12)
│ │ │ │ ┌─ day of week (0-6, Sun=0)
│ │ │ │ │
* * * * *
ScheduleWakeup Tool
ScheduleWakeup is a one-shot future execution — run once at a specific time:
ScheduleWakeup(
time="2024-12-01T09:00:00Z",
prompt="Generate the Q4 quarterly report and save to reports/Q4-2024.md"
)
Use Cases for Scheduled Agents
Daily digest: Every morning, summarize:
- Overnight GitHub activity (new issues, PR merges)
- CI/CD status
- Key metrics from the monitoring dashboard
Periodic health check: Every hour during business hours, verify:
- API endpoints are responding
- Database connection pool is healthy
- No new critical errors in logs
Weekly code quality report: Every Friday afternoon:
- Run static analysis tools
- Summarize new tech debt added this week
- Post report to team Slack channel
Dependency update check: Every Monday:
- Check for outdated npm/pip packages
- Open PRs for safe patch-level updates
- Flag major version changes for manual review
7. Interview Flashcards
Work through these questions out loud. Aim for concise, precise answers.
Q1: What is a Claude Code skill and how does it differ from a CLAUDE.md instruction?
A: A skill is a markdown file stored in .claude/skills/ that Claude loads as a slash
command when explicitly invoked (or when a trigger condition matches). It contains a full
prompt for a specific on-demand task.
A CLAUDE.md instruction is always loaded into context at the start of every session. It
provides standing context, coding standards, and background knowledge that should always
be present.
Key difference: CLAUDE.md is always-on background context; a skill is on-demand
task execution. Use CLAUDE.md for “always true” facts; use skills for “sometimes needed”
procedures. Skills are more token-efficient because they only consume context when invoked.
Q2: What hook event would you use to automatically format code after Claude edits a file?
A: PostToolUse, configured with a matcher on "Write" and "Edit" tool events.
The hook script reads the file path from the JSON payload on stdin, checks if it’s a
Python file (e.g., ends with .py), and runs black on that path. PostToolUse is
correct here (not PreToolUse) because you want formatting to happen after Claude writes
the file, not before.
Q3: How do you prevent Claude from running a specific tool using hooks?
A: Use a PreToolUse hook with the relevant tool as the matcher. In your hook script,
read the payload from stdin, inspect the tool name and arguments, and exit with a
non-zero exit code to block execution.
The text you write to stdout before exiting non-zero is sent to Claude as an error
message explaining why the tool was blocked. Claude will see this explanation and can
decide how to proceed differently.
Example: to block rm -rf, hook on Bash, check if the command contains rm -rf,
and exit 1 with a message like “BLOCKED: destructive rm command detected”.
Q4: What is the difference between settings.json and settings.local.json?
A: settings.json is committed to version control (whether global in ~/.claude/ or
project-level in .claude/). It contains shared configuration that should apply to all
developers or all machines.
settings.local.json is machine-specific, never committed (excluded by .gitignore). It
contains overrides like personal API keys, local file paths, development-only flags, or
settings that differ between machines.
The layering order is: global settings.json < global settings.local.json < project
settings.json < project settings.local.json. Each layer overrides the one before it.
Q5: How would you build a “notify on completion” workflow?
A: Use the Stop hook event. Configure a hook command in the Stop section of
settings.json. The hook fires after every Claude response.
For macOS desktop notifications, the command is a one-liner using osascript:
osascript -e 'display notification "Claude finished" with title "Claude Code"'
For Slack, write a small shell script that reads the Stop payload from stdin and posts
to a Slack incoming webhook URL via curl. Store the webhook URL in an environment
variable (set in the env section of settings.json or in your shell profile) so it’s
not hardcoded in settings.
Q6: What is the purpose of the Skill tool vs just writing prompt instructions?
A: The Skill tool (available in agent contexts) explicitly loads and injects a skill
file’s contents, rather than relying on Claude to remember or interpret standing
instructions.
Writing prompt instructions directly (in system prompt or CLAUDE.md) means those
instructions are always present in context, consuming tokens on every interaction.
The Skill tool defers loading until needed — the skill body is injected only when
invoked, keeping context leaner for tasks that don’t require that skill.
Additionally, skills are modular and reusable: the same skill file can be invoked from
different agents, across different projects, and via slash commands by human users. This
gives one source of truth for a procedure rather than duplicating prompt text across
multiple system prompts or configuration files.