Skip to content

Day 15 of 52 · about 20 min · Hooks

Hooks that brief Claude and call you back

Claude starts each session without knowing what you changed since yesterday, and you end up watching the terminal for its next question. Two small hooks fix both: one briefs Claude as the session opens, one calls you back.

You’ll finish with

  • A SessionStart hook that tells Claude the git branch and uncommitted files when a session opens.
  • A Notification hook that shows a desktop alert or rings the bell when Claude waits for you.
  • The habit of checking /hooks to see what loaded, and from where.

You’ll need

  • The project with the Day 14 hooks, or the Day 15 kit with today’s hooks merged in.
  • Node.js 22 or newer, git and Claude Code.

Before you start

Tell us about your setup and the steps change to fit it. Your answers stay in this browser, and you can change them at any time.

Which system are you on?

Commands differ between systems. Pick the one you’ll run Claude Code on.

The steps

One step at a time. When a step’s check matches, tick it and the next one opens.

0 of 4 steps done0%
  1. Step 1 of 4: Add the two hook scripts

    Save both Node scripts in .claude/hooks, next to the Day 14 ones, with their test.

    acme-quotes/

    • folder .claude
      • folder hooks
        • session-context.mjsNewprints the branch and changed files
        • notify.mjsNewreturns the alert for Claude Code to show
        • protect-files.mjsDay 14
        • run-tests.mjsDay 14
      • settings.jsonChanged
    • folder test
      • session-hooks.test.jsNew
    Both scripts read the event as JSON on stdin, like the Day 14 hooks.
    .claude/hooks/session-context.mjsJavaScript
    // SessionStart hook: tells Claude which git branch the project is on and
    // which files have uncommitted changes, so its first answer starts from the
    // real state of the folder.
    //
    // Claude Code sends the event as JSON on stdin. For SessionStart, whatever
    // this script prints (plain text, exit 0) is added to Claude's context. It
    // runs on every new, resumed and cleared session, so it stays fast: two git
    // commands with a time limit.
    //
    // Registered in .claude/settings.json. Tested by test/session-hooks.test.js.
    import { execFileSync } from 'node:child_process';
    
    const MAX_FILES = 10; // list at most this many changed files by name
    
    let input = {};
    try {
      input = JSON.parse((await readStdin()) || '{}');
    } catch {
      // Bad input still gets a useful answer below.
    }
    const cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : process.cwd();
    
    const lines = [];
    try {
      const branch = git(['branch', '--show-current'], cwd).trim() || 'none (detached HEAD)';
      const changed = git(['status', '--porcelain'], cwd)
        .split(/\r?\n/)
        .filter(Boolean)
        .map((line) => line.slice(3)); // drop the two status letters and the space
      lines.push(`Git branch at session start: ${branch}.`);
      if (changed.length === 0) {
        lines.push('The working tree had no uncommitted changes at session start.');
      } else {
        const shown = changed.slice(0, MAX_FILES).join(', ');
        const more = changed.length > MAX_FILES ? `, and ${changed.length - MAX_FILES} more` : '';
        lines.push(`Files with uncommitted changes at session start (${changed.length}): ${shown}${more}.`);
      }
    } catch {
      lines.push('This folder is not a git repository yet, or git is not installed.');
    }
    console.log(lines.join('\n'));
    
    function git(args, dir) {
      return execFileSync('git', args, {
        cwd: dir,
        encoding: 'utf8',
        timeout: 5000,
        stdio: ['ignore', 'pipe', 'ignore'],
      });
    }
    
    async function readStdin() {
      let text = '';
      for await (const chunk of process.stdin) text += chunk;
      // Windows PowerShell 5.1 can put a byte order mark in front of piped text.
      return text.replace(/^\uFEFF/, '');
    }
    
    .claude/hooks/notify.mjsJavaScript
    // Notification hook: calls you back when Claude Code is waiting for you.
    //
    // Hooks have no terminal of their own, so this script doesn't write escape
    // codes itself. It returns them in the `terminalSequence` field and Claude
    // Code writes them to your terminal. Claude Code accepts only a short list of
    // sequences there (OSC 0, 1, 2, 9, 99, 777 and a bare BEL), so this sends:
    //
    // - OSC 9 with Claude Code's message: a desktop notification in the
    //   terminals the docs list for it (iTerm2, ConEmu, Windows Terminal and
    //   WezTerm);
    // - a bare BEL: the terminal bell, in any terminal that has one turned on.
    //
    // Notification hooks can't block anything; this one only alerts you.
    // Registered in .claude/settings.json. Tested by test/session-hooks.test.js.
    const ESC = '\u001b';
    const BEL = '\u0007';
    const FALLBACK = 'Claude Code needs your attention';
    
    let input = {};
    try {
      input = JSON.parse((await readStdin()) || '{}');
    } catch {
      // Bad input still gets the fallback message.
    }
    
    // Control characters would end the sequence early, so they become spaces.
    const message =
      String(input.message || FALLBACK)
        .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
        .trim()
        .slice(0, 200) || FALLBACK;
    
    process.stdout.write(JSON.stringify({ terminalSequence: `${ESC}]9;${message}${BEL}${BEL}` }));
    
    async function readStdin() {
      let text = '';
      for await (const chunk of process.stdin) text += chunk;
      // Windows PowerShell 5.1 can put a byte order mark in front of piped text.
      return text.replace(/^\uFEFF/, '');
    }
    

    Hooks have no terminal of their own, so it hands Claude Code the alert to write.

    test/session-hooks.test.jsJavaScript
    // Kit check for the Day 15 hooks. It feeds each hook script sample input on
    // stdin, the way Claude Code does, and checks what the script prints.
    // Nothing here calls Claude or changes a project file.
    import assert from 'node:assert/strict';
    import { spawnSync } from 'node:child_process';
    import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
    import { tmpdir } from 'node:os';
    import path from 'node:path';
    import { test } from 'node:test';
    import { fileURLToPath } from 'node:url';
    
    const root = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
    const hookPath = (name) => path.join(root, '.claude', 'hooks', name);
    
    function runHook(name, input) {
      return spawnSync(process.execPath, [hookPath(name)], {
        input: JSON.stringify(input),
        encoding: 'utf8',
        timeout: 20_000,
      });
    }
    
    test('settings.json registers the SessionStart and Notification hooks', () => {
      const settings = JSON.parse(readFileSync(path.join(root, '.claude', 'settings.json'), 'utf8'));
      const args = (event) => settings.hooks[event].flatMap((group) => group.hooks.flatMap((h) => h.args ?? []));
      assert.ok(args('SessionStart').includes('${CLAUDE_PROJECT_DIR}/.claude/hooks/session-context.mjs'));
      assert.ok(args('Notification').includes('${CLAUDE_PROJECT_DIR}/.claude/hooks/notify.mjs'));
      assert.equal(settings.hooks.Notification[0].matcher, 'permission_prompt|idle_prompt');
    });
    
    test('session-context prints plain text for Claude, even outside a git repository', () => {
      const dir = mkdtempSync(path.join(tmpdir(), 'acme-session-'));
      try {
        const result = runHook('session-context.mjs', { hook_event_name: 'SessionStart', source: 'startup', cwd: dir });
        assert.equal(result.status, 0, result.stderr);
        const text = result.stdout.trim();
        assert.ok(text.length > 0, 'prints something');
        assert.ok(!text.startsWith('{'), 'plain text, so Claude Code adds it as context');
        assert.match(text, /git/i);
      } finally {
        rmSync(dir, { recursive: true, force: true });
      }
    });
    
    test('notify returns a terminalSequence: an OSC 9 notification, then a bell', () => {
      const result = runHook('notify.mjs', {
        hook_event_name: 'Notification',
        message: 'Claude needs your permission',
        notification_type: 'permission_prompt',
      });
      assert.equal(result.status, 0, result.stderr);
      assert.deepEqual(JSON.parse(result.stdout), {
        terminalSequence: '\u001b]9;Claude needs your permission\u0007\u0007',
      });
    });
    
    test('notify turns control characters in the message into spaces', () => {
      const result = runHook('notify.mjs', { hook_event_name: 'Notification', message: 'one\ntwo\u0007three' });
      assert.equal(result.status, 0, result.stderr);
      assert.equal(JSON.parse(result.stdout).terminalSequence, '\u001b]9;one two three\u0007\u0007');
    });
    
    test('notify falls back to a fixed message when there is none', () => {
      const result = runHook('notify.mjs', { hook_event_name: 'Notification' });
      assert.equal(result.status, 0, result.stderr);
      assert.equal(JSON.parse(result.stdout).terminalSequence, '\u001b]9;Claude Code needs your attention\u0007\u0007');
    });
    

    Check it worked

    Run npm test. Every test passes, including the notify and session-context checks.

  2. Step 2 of 4: Register them in settings

    Add two events to .claude/settings.json and keep everything already in it.

    Changes to .claude/settings.json

    Unchanged: "hooks": {
    Added: "SessionStart": [{ "hooks": [{ "type": "command", "command": "node",
    Added: "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/session-context.mjs"],
    Added: "timeout": 10 }] }],
    Unchanged: "PreToolUse": [ … ],
    Added: "Notification": [{ "matcher": "permission_prompt|idle_prompt",
    Added: "hooks": [{ "type": "command", "command": "node",
    Added: "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/notify.mjs"],
    Added: "timeout": 10 }] }],
    Unchanged: "Stop": [ … ]
    No matcher on SessionStart, so it also runs after /clear, a resume or compaction.
    .claude/settings.jsonJSON
    {
      "$schema": "https://json.schemastore.org/claude-code-settings.json",
      "permissions": {
        "allow": [
          "Bash(npm test *)",
          "Bash(npm run quote *)"
        ],
        "ask": [
          "Bash(git push *)",
          "Edit(/data/rates.json)"
        ],
        "deny": [
          "Read(./.env)"
        ]
      },
      "hooks": {
        "SessionStart": [
          {
            "hooks": [
              {
                "type": "command",
                "command": "node",
                "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/session-context.mjs"],
                "timeout": 10
              }
            ]
          }
        ],
        "PreToolUse": [
          {
            "matcher": "Edit|Write",
            "hooks": [
              {
                "type": "command",
                "command": "node",
                "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-files.mjs"]
              }
            ]
          }
        ],
        "Notification": [
          {
            "matcher": "permission_prompt|idle_prompt",
            "hooks": [
              {
                "type": "command",
                "command": "node",
                "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/notify.mjs"],
                "timeout": 10
              }
            ]
          }
        ],
        "Stop": [
          {
            "hooks": [
              {
                "type": "command",
                "command": "node",
                "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/run-tests.mjs"],
                "timeout": 120,
                "statusMessage": "Running the tests"
              }
            ]
          }
        ]
      }
    }
    

    Check it worked

    Start Claude Code and run /hooks. SessionStart and Notification are listed from project settings.

    If it didn’t work

    `/hooks` doesn’t list them.

    Hooks only load from the hooks key of a settings file. Check the JSON is valid and that both events sit inside that key.

  3. Step 3 of 4: Ask Claude what it already knows

    Change a file, start a new session and ask about your work.
    In Claude Code

    You type: Which branch am I on, and which files have uncommitted changes? Answer from what you already know.

    What happens: The hook’s lines reached Claude before your first prompt, so it can answer without running git.

    Edit any file first, so there is a change to report.

    Check it worked

    Claude names your branch and the file you changed.

  4. Step 4 of 4: Let Claude call you back

    Ask for something that needs your approval, then step away.

    In order:

    1. Claude

      Claude waits for your approval

    2. You

      About six seconds pass

      Without a keystroke.

    3. Hook

      notify.mjs runs

      It returns a terminalSequence.

    • If you use iTerm2, Windows Terminal, WezTerm or ConEmu

      A desktop notification

    • If your terminal has a bell turned on

      The bell rings

    An idle prompt sends the same alert about 60 seconds after Claude finishes.

    Check it worked

    Press Shift+Tab until the status bar shows ⏸ manual mode on, then ask Claude to create notes.txt and don’t touch the keyboard. An alert or the bell arrives.

    If it didn’t work

    Nothing arrives.

    Any keystroke resets the wait. If your terminal shows neither alert, the hooks guide has notification commands for each system.

Finish · 4 of 4 steps to go

Claude starts informed, and you can look away

Each session now opens with your branch and changes in Claude’s context, and a waiting prompt comes to find you.

What you’ll have checked

  • /hooks lists SessionStart and Notification from project settings.
  • npm test passes.

Lesson updates by email

Get lesson updates by email

Leave your email and we’ll tell you when we add or update a lesson.

We’ll email you when we add or update a lesson. We use your email only for this. Privacy policy

Claude Code for your team

Want Claude Code set up across your team?

HorizonLux sets up and repairs Claude Code for teams: CLAUDE.md rules, hooks that enforce them and reviewed MCP servers. Book a free call to talk it through.

Questions

Questions about Day 15

Why not put the briefing in CLAUDE.md?

CLAUDE.md suits facts that don’t change. Your branch and uncommitted files change daily, so a script works them out fresh each time.

Sources

Checked against the official Claude Code docs on .

Independent resource, not affiliated with Anthropic. HorizonLux wrote this lesson; Claude Code is made by Anthropic.