little cups of claude

tips and techniques for Claude and Claude Code

Today we'll look at how Claude Code remembers things between sessions with auto memory, and how it fits next to the CLAUDE.md files we write ourselves.

The split is simple. We write CLAUDE.md to give Claude instructions. Claude writes auto memory to keep notes for itself. Auto memory is on by default in local sessions.

Let's dive in.

What Gets Saved

As it works, Claude saves four kinds of notes, and records the kind in each file's frontmatter:

  • user: our role, expertise, and how we like to work
  • feedback: corrections we give and approaches we confirm
  • project: ongoing work, deadlines, and decisions the code doesn't show
  • reference: where to find things outside the project, like an issue tracker

It skips anything it can work out from the codebase, and anything our CLAUDE.md files already say. It also doesn't save something every session, only what looks useful later. When it does, we'll see a message like "Saved 2 memories".

Where It Lives

Each project gets its own folder:

~/.claude/projects/<project>/memory/
  MEMORY.md                 # the index, one line per memory
  user_role.md              # one memory per file
  feedback_testing.md
  reference_dashboards.md

The <project> comes from the git repository, so every worktree and subdirectory of the same repo shares one memory folder. It stays on our machine, and isn't synced to other machines or cloud sessions.

What Loads

At the start of each conversation, Claude Code loads the first 200 lines or 25KB of MEMORY.md, whichever comes first. The topic files don't load at startup. Claude reads them when it needs them.

- [Prefers bun](feedback_bun.md): use bun, never node or npm
- [Staging deploys](reference_staging.md): how we ship to staging
- [Q4 freeze](project_freeze.md): no schema changes until Jan 5

That's why the index stays short. When MEMORY.md gets close to the limit, Claude Code reminds Claude to tighten it up. Neat!

The /memory Command

/memory is the control panel:

> /memory

It lists our CLAUDE.md and CLAUDE.local.md files, has a toggle for auto memory, and opens the memory folder. Picking a file opens it in our editor. Memory files are plain markdown, so we can edit or delete anything that's wrong.

Telling It What To Remember

When we say something like "remember that the API tests need a local Redis", Claude saves it to auto memory. If we want it in CLAUDE.md instead, we just ask: "add this to CLAUDE.md".

For instructions that only matter in part of the codebase, .claude/rules/ is the tidier spot:

---
paths:
  - "src/api/**/*.ts"
---

# API rules
- Validate every request body with zod
- Use the shared error response format

A rule with paths only loads once Claude reads or edits a matching file. Rules without paths load at launch, like CLAUDE.md does.

Turning It Off

The /memory toggle saves autoMemoryEnabled to our user settings. To switch it off for one project, set it in that project's settings:

{
  "autoMemoryEnabled": false
}

And autoMemoryDirectory moves the whole thing somewhere else, if we'd rather keep it in a synced folder.

Watch Out

Subagents don't get the main conversation's auto memory, apart from forks, which inherit the whole parent conversation. And memory files are never cleaned up automatically. They stay until we or Claude edit or delete them, so it's worth a look through /memory every so often.

More info about auto memory can be found at the Claude Code docs.

Today we'll look at running Claude Code without the interactive UI, which turns it into a building block for scripts, CI, and cron jobs.

The -p flag (short for --print) is the key. It sends one prompt, lets Claude work, prints the result, and exits. The same engine powers the Agent SDK, so anything we learn here carries over.

Let's take a look.

The Basics

At its simplest, we pass a prompt and read the answer:

claude -p "Explain what this project does"

It reads stdin too, so it fits right into a pipeline:

git diff HEAD~5 | claude -p "Summarize these changes" > changelog.txt

For scripts and CI, the docs recommend adding --bare. It skips discovery of hooks, skills, plugins, MCP servers, auto memory, and CLAUDE.md, so startup is faster and the run doesn't depend on whatever happens to be configured on the machine:

claude --bare -p "Summarize README.md" --allowedTools "Read"

Output Formats

The --output-format flag picks what comes back:

  • text: just the final answer (the default)
  • json: the answer plus metadata like session_id, usage, and total_cost_usd
  • stream-json: one JSON event per line while Claude works
claude -p "Analyze this codebase" --output-format json | jq -r '.result'

For structured data, --json-schema makes the answer match a schema, and it shows up in a structured_output field:

claude -p "List the exported functions in src/hal.ts" \
  --output-format json \
  --json-schema '{"type":"object","properties":{"functions":{"type":"array","items":{"type":"string"}}},"required":["functions"]}' \
  | jq '.structured_output'

Neat! If Claude declines or the run fails, that field is missing, so it's worth checking for it before using it.

Turn And Budget Limits

Unattended runs need guardrails:

claude -p "Review this PR for bugs" \
  --max-turns 5 \
  --max-budget-usd 1.00 \
  --fallback-model sonnet,haiku

--max-turns caps the agentic turns and exits with an error when it hits the limit. --max-budget-usd stops the run once estimated spend reaches the cap (it's a client-side estimate, so leave some headroom). --fallback-model takes a list of models to try if the main one is overloaded.

Permissions

There's nobody around to click "Allow" in a script. --allowedTools pre-approves exactly what the job needs, using permission rule syntax:

claude -p "Look at the staged changes and write a commit" \
  --allowedTools "Bash(git diff *),Bash(git log *),Bash(git status *),Bash(git commit *)"

The space before * matters. Bash(git diff *) matches git diff --cached but not git diff-index.

For a locked-down CI run, --permission-mode dontAsk denies anything that would otherwise prompt, while still allowing reads and whatever our allow rules cover.

In GitHub Actions

Anthropic publishes an official action, so we don't have to install the CLI by hand:

name: Claude review
on: [pull_request]

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: anthropics/claude-code-action@v1
        with:
          anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
          prompt: "Review this pull request for bugs and security issues"

Continuing Conversations

Multi-step scripts can pick a conversation back up:

# Save the session ID from the first run
session_id=$(claude -p "Start a review of src/" --output-format json | jq -r '.session_id')

# Continue that exact conversation
claude -p "Now focus on the database queries" --resume "$session_id"

# Or continue the most recent one
claude -p "Summarize everything we found" --continue

Watch Out

Without --bare, a -p run in a project folder runs that project's hooks and connects its .mcp.json servers, even in a folder we've never trusted, and there's no trust dialog to stop it. On CI or with code we didn't write, --bare is the safer default.

To add instructions for one script without touching any settings, there's --append-system-prompt.

More info about running Claude Code from scripts can be found at the Claude Code docs.

Today we'll check out how to build our own slash commands in Claude Code using skills.

Custom slash commands started out as single Markdown files in a commands folder. They've since been merged into skills. Both still work, and both show up in the same / menu, but skills can do quite a bit more.

Let's get started.

Commands And Skills

A file at .claude/commands/deploy.md creates /deploy. So does a folder at .claude/skills/deploy/ with a SKILL.md inside:

# Older format (still works)
.claude/commands/deploy.md

# Skill (recommended for new work)
.claude/skills/deploy/SKILL.md
.claude/skills/deploy/checklist.md
.claude/skills/deploy/scripts/smoke.sh

The big difference is the folder. A skill can carry templates, checklists, and scripts alongside its instructions, and Claude can load a skill on its own when it's relevant, without us typing the command.

If both exist with the same name, the skill wins.

Frontmatter

Skills are configured with YAML frontmatter at the top of SKILL.md:

---
name: deploy
description: Build, test and deploy the app to production
disable-model-invocation: true
argument-hint: [environment]
allowed-tools: Bash(bun run build) Bash(bun run deploy *)
---

Deploy to $ARGUMENTS following our release checklist
in checklist.md.

A few fields worth knowing:

  • name is the command we type. It defaults to the folder name.
  • description is how Claude decides when the skill applies, so put the main use case first.
  • disable-model-invocation: true means only we can run it. Good for anything with side effects.
  • allowed-tools pre-approves tools while the skill runs, so we don't get permission prompts for them.

Arguments

Everything after the command name lands in $ARGUMENTS. For individual pieces, indexes start at zero:

---
name: fix-issue
arguments: [issue, branch]
---

Fix issue #$issue on the $branch branch.
Same thing by index: #$0 on $1.

Running /fix-issue 42 main fills in 42 and main. Quotes group words, so /fix-issue 42 "release candidate" passes two arguments, not three.

Live Context

Lines with !`command` run before Claude ever sees the skill, and the output replaces the line:

---
name: commit
description: Write a commit for the staged changes
allowed-tools: Bash(git status) Bash(git diff *) Bash(git log *) Bash(git commit *)
---

## Context
- Status: !`git status`
- Staged diff: !`git diff --cached`
- Recent commits: !`git log --oneline -10`

Write one clear commit message for these changes and commit.

Claude gets the real diff already inlined, no extra tool calls needed. Neat!

${CLAUDE_SKILL_DIR} points at the skill's own folder, which makes bundled scripts easy to call from anywhere.

Bundled Skills

Claude Code ships with a set of skills of its own, like /code-review, /batch, /debug, /loop, and /claude-api. They're good examples of how to structure longer workflows, and we can turn them off with the disableBundledSkills setting.

Personal vs Project

Project skills live in .claude/skills/ and get committed for the whole team. Personal ones live in ~/.claude/skills/ and follow us into every project:

mkdir -p ~/.claude/skills/explain

cat > ~/.claude/skills/explain/SKILL.md << 'END'
---
description: Explain code for someone new to the project
---

Explain this code for a new teammate. Cover what it does,
why it's built this way, and any gotchas.
END

Watch Out

If a !`command` line fails, the whole skill invocation stops, and Claude never sees the skill. Commands that are expected to exit non-zero need || true on the end.

Those commands also never prompt for permission. Outside auto mode, anything not already allowed makes the skill fail, which is why the commit example lists its git commands in allowed-tools.

More info about skills can be found at the Claude Code docs.

Today we'll look at one of the best ways to make Claude Code behave the same way every time: hooks.

Hooks run our own code at key moments in a Claude Code session. Instead of hoping Claude remembers to format a file or skip a protected directory, we make it happen automatically, on every matching event.

Let's dive in.

Setting Them Up

Hooks live in our settings files. The user file (~/.claude/settings.json) applies to every project, .claude/settings.json is shared with the team, and .claude/settings.local.json stays on our machine. Plugins, skills, and subagents can bring their own hooks too.

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

This one runs Prettier every time Claude Code edits or writes a file. Every hook gets a JSON payload on stdin, so jq pulls the file path right out of it. Neat!

The Events

Claude Code has more than 30 hook events now. These are the ones we'll reach for most:

  • PreToolUse fires before a tool call and can block it
  • PostToolUse fires after a tool call succeeds
  • UserPromptSubmit fires when we send a prompt, before Claude sees it
  • SessionStart fires when a session begins or resumes
  • Stop fires when Claude finishes responding, and can make it keep going
  • Notification fires when Claude Code wants our attention
  • PreCompact and PostCompact wrap context compaction
  • SubagentStart and SubagentStop wrap subagent runs

There are also events for config changes, file changes on disk, worktrees, model switches, permission requests, and MCP elicitations.

{
  "hooks": {
    "Notification": [
      {
        "matcher": "permission_prompt|idle_prompt",
        "hooks": [{ "type": "command", "command": "say 'HAL needs us'" }]
      }
    ]
  }
}

That one uses macOS say so we hear it when Claude is waiting on us.

Five Handler Types

Each hook handler has a type:

  • command runs a shell command (the most common)
  • http POSTs the event JSON to a URL
  • mcp_tool calls a tool on one of our MCP servers
  • prompt asks a Claude model for a quick yes/no style decision
  • agent spawns a subagent that can read files before deciding (still experimental)

Matchers And if

The matcher field filters what a hook fires on. For tool events it matches the tool name, so Bash, Edit|Write, and mcp__.* all work. For more precise filtering on tool events, the if field takes one permission rule:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(git push *)",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/check-branch.sh"
          }
        ]
      }
    ]
  }
}

Note that if goes on the handler, next to type. It holds exactly one rule, so two conditions means two handlers.

Exit Codes

How our hook exits decides what happens next:

  • Exit 0: success, and the action goes ahead
  • Exit 2: a blocking error. On a PreToolUse hook the tool call is stopped, and our stderr goes back to Claude as the reason
  • Anything else: a non-blocking error, and the action still goes ahead
#!/usr/bin/env python3
import json, sys

data = json.load(sys.stdin)
path = data.get("tool_input", {}).get("file_path", "")

if any(p in path for p in [".env", "bun.lock", ".git/"]):
    print(f"Blocked: {path} is protected", file=sys.stderr)
    sys.exit(2)

Claude reads that message and tries another approach.

Watch Out

Exit code 1 does not block anything, even though it's the usual Unix failure code. If a hook is enforcing a policy, use exit 2.

The timeout field is in seconds, and command hooks default to 600. All matching hooks for an event run in parallel. Edits to hooks in our settings files are picked up automatically, and /hooks opens a read-only browser showing every hook and where it came from.

More info about hooks can be found at the Claude Code docs.

Today we'll take a look at one of the most useful files in any Claude Code project: CLAUDE.md.

A CLAUDE.md file gives Claude Code instructions about our project that stick around between sessions. Claude Code loads it into context at the start of every session, so we stop repeating the same preferences over and over.

Let's dive in.

Setting It Up

The fastest way to get one is to let Claude Code write a first draft for us:

> /init

Claude looks at our codebase and writes a CLAUDE.md with the build commands, test instructions, and conventions it finds. If one already exists, /init suggests improvements instead of overwriting it.

We can also start from scratch:

# Discovery One
Use TypeScript for all new files
Use bun, not node
Run tests with `bun test`
The database is SQLite via better-sqlite3

From then on, every session in this directory starts out knowing all of that. Neat!

Where It Can Live

There are a few places a CLAUDE.md can go, each with a different scope:

# User: our preferences for every project
~/.claude/CLAUDE.md

# Project: checked into git, shared with the team
./CLAUDE.md          # or ./.claude/CLAUDE.md

# Local: personal notes for this project only
./CLAUDE.local.md    # add it to .gitignore

These don't override each other. Claude Code concatenates all of them into context, broadest first: user, then project, then local. Instructions closer to where we launched get read last.

Parent And Child Directories

Claude Code also walks up the directory tree. If we start it in apps/hal/, it loads apps/hal/CLAUDE.md and also apps/CLAUDE.md and the repo root's CLAUDE.md.

monorepo/
  CLAUDE.md            # loaded at launch
  apps/
    hal/
      CLAUDE.md        # loaded at launch (we started here)
  packages/
    ui/
      CLAUDE.md        # loaded later, on demand

Files in subdirectories below where we started load on demand, the first time Claude reads or edits something in that folder. That keeps a big monorepo from dumping every team's notes into context at once.

What To Put In It

The best CLAUDE.md files are short and full of facts Claude can't easily figure out on its own:

  • Commands: how to build, test, lint, and deploy
  • Conventions: naming, patterns to follow or avoid
  • Layout: where things live, how modules connect
  • Gotchas: the things that bit us last week
## Commands
bun dev    # dev server on :3000
bun test   # unit tests
bun lint   # eslint

## Rules
Prefer unknown over any
Never edit files in generated/
Migrations go in db/migrations, one per change

Imports

A CLAUDE.md can pull in other files with @path:

See @README.md for the project overview.
Our API conventions are in @docs/api.md.

# Personal prefs, shared across worktrees
@~/.claude/samantha-prefs.md

Imported files can import other files, up to four hops deep. Relative paths resolve from the file doing the importing, not from wherever we launched Claude Code.

Watch Out

The docs suggest aiming for under around ~200 lines per CLAUDE.md. Longer files cost context and Claude follows them less reliably. Imports help organize a long file, but they don't shrink it, since imported files load right along with it.

If something only matters for one part of the codebase, a path-scoped rule in .claude/rules/ is a better home. Those only load when Claude works with matching files.

To check what loaded, we can run /context and look under Memory files.

More info about CLAUDE.md can be found at the Claude Code docs.