A Claude Code Workflow That Sticks: CLAUDE.md, Skills, and Hooks

by goyostudio

Claude Code is a loop — read, edit, run, check, repeat — and it runs fast enough that the deciding factor is rarely how you phrased one prompt. It is the standing context the tool starts every session with. Same model, same task, two repos: the one with a real CLAUDE.md, a tuned allowlist, and two or three hooks finishes while you do something else. The other asks six questions and gets the build command wrong.

That setup costs about an hour per repository, most of it once. This guide covers the parts that pay back: project memory, permissions, skills, hooks, and the two ways to run the tool unattended — headless print mode and one agent per git worktree.

## Project memory is the highest-leverage file you will write

Every session starts with an empty context window. CLAUDE.md files are how you refill it without typing. They load automatically at the start of every session, so anything in there is knowledge Claude has before it reads a single line of your code.

Bootstrap it with /init, which reads the codebase and writes a starting file with the build commands, test instructions, and conventions it can discover; if a CLAUDE.md already exists it proposes improvements instead of overwriting. Setting CLAUDE_CODE_NEW_INIT=1 gives an interactive multi-phase version that asks follow-up questions and proposes skills and hooks before writing.

Treat the generated file as a first draft. Most of it restates what the code already shows — layout, dependencies, the scripts already in package.json. The value is in what Claude cannot infer:

  • Commands with non-obvious preconditions: the dev server needs a .env.local, the integration tests need a local Redis, the build only works under one Node version.
  • Conventions you enforce but never automated — naming, where new files go, which internal utility to reuse.
  • Red lines: never edit anything under generated/, never commit straight to main, no new dependency without asking.
  • The reason behind a decision that looks wrong, so Claude stops trying to fix it. One line — "this polyfill is for Safari 15, leave it" — saves the same argument weekly.

// note: CLAUDE.md is context, not enforced configuration. Claude reads it, but nothing guarantees compliance, especially with vague or contradictory rules. "Use 2-space indentation" lands; "format nicely" does not. Keep each file under roughly 200 lines — longer files reduce adherence. For anything that must happen every time, write a hook instead.

### Scope: which file wins

Four scopes load, broadest to most specific: managed policy (org-wide), user (~/.claude/CLAUDE.md), project (./CLAUDE.md or ./.claude/CLAUDE.md, committed to git), and local (./CLAUDE.local.md, gitignored). Every discovered file is concatenated with the more specific scopes read last, so a project rule beats a user rule on conflict. Files in subdirectories load on demand when Claude reads files there, which is how a monorepo keeps per-package instructions out of the root file.

# Where memory files live
~/.claude/CLAUDE.md          # you, in every project
./CLAUDE.md                  # this project, committed
./.claude/CLAUDE.md          # same scope, alternate location
./CLAUDE.local.md            # this project, you only — gitignore it
./packages/api/CLAUDE.md     # loads on demand when Claude reads files here

Split by audience: personal habits in the user file, anything a teammate needs in the committed project file, machine-specific paths in CLAUDE.local.md where they never show up in a diff. The @path/to/file import syntax pulls in shared instructions; imports nest up to four levels deep, and importing @AGENTS.md keeps one instruction file that Claude and other agents both read.

When a rule is clearly being ignored, run /context before rewriting it. It breaks the window down by system prompt, CLAUDE.md files, tools, MCP servers, and conversation, and is the reliable way to confirm a file loaded. A file missing from that breakdown was never read, and no rewording fixes that.

## Tune permissions until the prompts stop

A session that halts every ninety seconds for approval is one you have to babysit, and babysitting is what you were trying to avoid. Two layers control it. The permission mode sets the baseline: default reads files and asks before edits or commands; acceptEdits also writes files and auto-approves common filesystem commands like mkdir and mv; plan stays read-only; dontAsk allows only pre-approved tools, which is what you want in locked-down CI; bypassPermissions allows everything outside protected directories and belongs in containers only. Shift+Tab cycles modes, --permission-mode starts in one, permissions.defaultMode makes it the project default.

The second layer is rules. The permissions object in settings.json holds allow, deny, and ask arrays written in Tool(pattern) syntax with glob wildcards. Ten minutes filling allow with the commands you approve reflexively — test runner, linter, read-only git — drops the interruption rate off a cliff while staying specific about what is permitted.

# .claude/settings.json — commit this
{
  "permissions": {
    "defaultMode": "acceptEdits",
    "allow": [
      "Bash(npm run test:*)",
      "Bash(npm run lint)",
      "Bash(git diff *)",
      "Bash(git log *)",
      "Read(./src/**)"
    ],
    "deny": ["Read(./.env)", "Read(./.env.*)"]
  }
}

// note: The space in Bash(git diff *) is load-bearing — written as Bash(git diff*) it also matches git diff-index. Permission arrays merge across every settings scope instead of overriding, and deny always beats allow, so a rule in a project, local, or managed-policy file you did not write can silently block what you explicitly allowed. Run /permissions to inspect the effective rules.

Resist --dangerously-skip-permissions on your own machine. It removes every confirmation, which is exactly the point in a disposable container and a real hazard on the workstation holding your SSH keys. A decent allowlist buys most of the same flow with none of the blast radius.

## Promote a repeated instruction into a skill

Any instruction you have typed three times should be a file. Drop a Markdown file in .claude/commands/ (project) or ~/.claude/commands/ (personal) and its filename becomes a slash command — deploy.md gives you /deploy. The body is the prompt, $ARGUMENTS injects everything typed, $1 and $2 grab positional args, and YAML frontmatter sets description, argument-hint, allowed-tools, and model. Commands have merged with skills, so .claude/skills/deploy/SKILL.md produces the same /deploy.

# .claude/skills/review-diff/SKILL.md
---
description: Review the current diff against our conventions
argument-hint: [base-branch]
allowed-tools: Read, Grep, Bash(git diff *)
---

Review the diff against $1 (default: main).

Check, in this order:
1. Error paths — every await has a failure branch
2. No new dependency without a one-line justification
3. Exported functions are typed; no implicit any
4. New branches have tests

Report findings as a list. Do not edit files.

The win is not saved keystrokes. It is that the instruction stops drifting: every run gets the same checklist, including the step you skip when you are in a hurry. The allowed-tools frontmatter also scopes what the command can touch, so a review command stays read-only by construction rather than by request. These work in print mode too — put /name in the prompt string and it expands before running.

Subagents are the adjacent idea: a Markdown file in .claude/agents/ with name and description frontmatter, plus optional tools and model. Claude reads those descriptions and delegates matching side work automatically. Each subagent runs in its own context window and returns only a summary, keeping a thousand lines of grep output out of your main thread.

## Hooks: for what must always happen

Hooks are your own shell commands wired to lifecycle events under the hooks key in settings.json — PreToolUse, PostToolUse, UserPromptSubmit, SessionStart, Stop, and more. Each event holds matchers (a tool-name regex like Write|Edit) and commands to run, and receives the event JSON on stdin. They execute deterministically regardless of what Claude decides, which is the entire distinction from CLAUDE.md.

# .claude/settings.json — lint after every edit
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          { "type": "command", "command": "npm run lint --silent" }
        ]
      }
    ]
  }
}

The rule of thumb: the moment you catch yourself writing "always run the formatter after editing" into CLAUDE.md, you wanted a hook. Formatting after edits, blocking a command that should never run, notifying you when a turn ends — all hooks. Reference scripts with ${CLAUDE_PROJECT_DIR} so paths resolve from the repository root, not wherever the session launched.

// note: A hook exiting with code 2 blocks the action and feeds its stderr back to Claude as feedback — that is how you make a check corrective rather than merely fatal. Any other non-zero exit is a non-blocking error. Hooks hot-reload when you save settings.json, and /hooks shows what is currently wired up.

## MCP servers: when external systems join the loop

MCP servers extend Claude past its built-in tools to external systems — issue trackers, databases, monitoring, a browser. Register a local process with claude mcp add <name> -- <command>, where everything after -- passes through untouched, or a hosted service with claude mcp add --transport http <name> <url> plus --header for auth. Scope decides reach: local (the default) is private to you here, project ships to the team via a committed .mcp.json, user applies everywhere. Add one when Claude needs a system's actual state rather than a paste of it, and add them narrowly — every connected server permanently occupies part of the context window. And since claude mcp add saves the config without validating credentials, confirm the server shows connected under /mcp.

# Adding and verifying servers
claude mcp add my-db -- npx -y my-mcp-server
claude mcp add --transport http sentry https://mcp.sentry.dev/mcp
claude mcp list

## Headless mode for scripts and CI

claude -p "prompt" runs the agent non-interactively, prints the result, and exits. It reads stdin, so Claude behaves like any other Unix tool: pipe a diff or a log in, redirect the answer out. Every CLI flag works with -p.

# Print mode as a shell tool
claude -p "what does the auth module do?"

git diff main | claude -p "review these changes for issues"

tail -200 app.log | claude -p "summarize anomalies" > summary.txt

claude -p "summarize this project" --output-format json | jq -r '.result'

For scripting, --output-format json returns the result plus session metadata including total_cost_usd, so you parse .result with jq and track spend at once. --json-schema forces the answer to match a schema you supply and puts the validated object in .structured_output. --output-format stream-json emits newline-delimited events live, and requires --verbose in print mode.

Two more flags make unattended runs survivable. --allowedTools pre-approves what the run needs so it never stalls on a prompt nobody is there to answer, and --max-turns with --max-budget-usd bounds how far a loop can go. Set the budget high enough to finish a realistic task, since hitting it stops the run mid-work.

# A CI review step
claude --bare -p "review the staged diff for security issues" \
  --allowedTools "Read,Grep" \
  --permission-mode dontAsk \
  --max-turns 5 \
  --max-budget-usd 1.00 \
  --output-format json > review.json

// note: --bare is the right default in CI and the wrong one locally. It skips auto-discovery of hooks, skills, plugins, MCP servers, auto memory, and CLAUDE.md, so the run depends only on the flags you passed. Without it, a headless run picks up whatever is configured on that box — a teammate's hook, a project .mcp.json — and results drift per machine. The tradeoff: you lose your CLAUDE.md too, so pass the context the prompt needs explicitly.

## One worktree per agent

Run two agents in one checkout and they fight: one rewrites a file the other is mid-edit on, and both branches end up wrong. Git worktrees fix this properly — separate directories, separate branches, one shared repository — and Claude Code has it built in. claude -w feature-auth starts a session inside a managed worktree branched from origin/HEAD, created under .claude/worktrees/<name>/. Run it three times with three names and you have three agents that cannot collide. --tmux opens each worktree in its own tmux session, the practical way to watch several at once.

# Parallel sessions without collisions
echo ".claude/worktrees/" >> .gitignore

claude -w feature-auth -n "auth-refactor"
claude -w bugfix-1204 --tmux

claude --bg "investigate the flaky test"
claude agents

Add .claude/worktrees/ to .gitignore before you start, or the managed worktrees end up staged in the repo. A worktree still holding uncommitted changes is not deleted on exit — Claude asks whether to keep or remove it, which is the right default but means dead worktrees pile up if you always say keep.

Name sessions with -n so the resume picker stays readable, because session lookup is scoped to the current directory and its worktrees: run claude -c from the wrong folder and you get that folder's last session, or nothing. For work needing no supervision, claude --bg detaches entirely and hands your terminal back; claude agents lists background runs and claude attach <id> reconnects.

## Do this today

  • Run /init, then delete half of what it wrote and replace it with the preconditions, red lines, and rationale Claude cannot infer from the code.
  • Add five allow rules to .claude/settings.json for commands you approve reflexively — test runner, linter, Bash(git diff *) — and commit it.
  • Wire one PostToolUse hook on Write|Edit that runs your formatter, and delete the prose instruction doing the same job.
  • Turn your most-repeated instruction into .claude/commands/<name>.md and invoke it as a slash command.
  • Run /context in a real session and cut whatever is not earning its tokens — an unused MCP server, a 400-line CLAUDE.md.