DEV Community

Cover image for A SessionStart Hook That Hands Claude Code the Failing Tests Before Its First Turn
Gabriel Anhaia
Gabriel Anhaia

Posted on Edited on AI-assisted

A SessionStart Hook That Hands Claude Code the Failing Tests Before Its First Turn


CI went red over the weekend. Three checkout tests fail, and you know their names. A fresh Claude Code session doesn't. Before it can touch the bug, it has to run the suite and read the output to learn what you already knew.

Claude Code has a hook built for this. Its hooks reference says that for SessionStart, "Claude Code adds plain-text stdout as context that Claude can see and act on," and that the text lands "at the start of the conversation, before the first prompt." So a short script can run your fast tests, keep only the failures, and hand Claude that list before you type anything.

What a SessionStart hook is allowed to say

A few rules from the reference shape the whole design:

  • It fires on how the session began. The matcher values are startup, resume, clear, compact and fork. On resume the hook runs again "so they can refresh their context," which is what you want for test results that go stale.
  • Plain text is enough. You can return JSON with hookSpecificOutput.additionalContext, but the docs say a hook that only adds context "can print it without building JSON."
  • Output is capped at 10,000 characters. Past that, Claude Code saves the text to a file and passes Claude the path plus a preview of up to the first 2,000 characters. Claude can read the file, but Claude Code doesn't ask it to. A 900-line pytest log would mostly vanish into that file.
  • Claude waits for it. At launch the hook runs in the background and you can type right away, but "Claude's first response still waits for the hooks to finish."
  • Write facts, not orders. The docs recommend factual statements, because text "framed as out-of-band system commands can trigger Claude's prompt-injection defenses."

That last point decides the wording. The hook says "these tests fail," not "you must fix these tests."

Branch and diff are one cheap git call away, and Claude can make it when it needs to. The expensive one to rediscover is test state, because it means running the suite. That is the one to precompute.

A cream index card with three short red marks, slid under a closed door ahead of a figure about to walk in
The red tests reach the agent before the first prompt does.

The script: fast subset, failures only, capped

This uses pytest, standard library only. Save it as .claude/hooks/red_tests.py and make it executable with chmod +x.

#!/usr/bin/env python3
"""SessionStart hook: tell Claude which fast tests fail."""
import os, subprocess, sys

CMD = ["python3", "-m", "pytest", "-m", "not slow", "-q",
       "--tb=no", "-rfE"]
BUDGET = 45      # seconds, under the hook timeout
MAX_FAILS = 15
MAX_LINE = 200

os.chdir(os.environ.get("CLAUDE_PROJECT_DIR", "."))
try:
    run = subprocess.run(CMD, capture_output=True, text=True,
                         timeout=BUDGET)
except subprocess.TimeoutExpired:
    print(f"Fast tests did not finish within {BUDGET}s at "
          "session start. Their status is unknown.")
    sys.exit(0)

fails = [l[:MAX_LINE] for l in run.stdout.splitlines()
         if l.startswith(("FAILED ", "ERROR "))]
if run.returncode == 0:
    print("Fast tests (pytest -m 'not slow') pass at start.")
elif fails:
    print(f"Fast tests failing at session start ({len(fails)}):")
    print("\n".join(fails[:MAX_FAILS]))
    if len(fails) > MAX_FAILS:
        print(f"...and {len(fails) - MAX_FAILS} more.")
else:
    print(f"pytest exited {run.returncode} at session start "
          "and listed no failures (4: usage or conftest "
          "error, 5: no tests collected).")
Enter fullscreen mode Exit fullscreen mode

What each piece does:

  • -m "not slow" is pytest's marker deselection. Tag your integration tests @pytest.mark.slow and register the marker, and the hook runs only the fast ones. Use whatever subset finishes in seconds on your repo.
  • --tb=no drops tracebacks ("no traceback at all", per the output docs). -rfE keeps the "short test summary info" for failures and errors, one line per test with its id and a short form of the failure, in the shape FAILED test_example.py::test_fail - assert 0.
  • The filter keeps only lines that start with FAILED or ERROR. Everything else in the run, the dots and the counts, is noise for this job.
  • MAX_FAILS and MAX_LINE cap the output at roughly 3,000 characters, well under the 10,000 limit. If 40 tests fail, Claude gets 15 names and a count, which is enough to start.
  • The last branch covers pytest's other exit codes: 4 is a usage error or a conftest.py that fails to import, 5 is "No tests were collected." Saying so beats saying nothing, because silence reads as "all green."

If your project runs tests through uv run, a virtualenv path or npm test, change CMD and the prefix filter. The shape stays the same: run, filter, cap, print facts.

Register it, with a timeout that leaves room

Put this in .claude/settings.json at the repo root:

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup|resume|clear",
        "hooks": [
          {
            "type": "command",
            "command":
              "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/red_tests.py",
            "timeout": 60,
            "statusMessage": "Running fast tests"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

There are two timeouts on purpose.

The timeout field is Claude Code's. For a command hook it defaults to 600 seconds, and when a hook reaches it, Claude Code cancels it, "discarding the hook's output." With the default, a hung test could make your first response wait ten minutes, and then Claude gets nothing at all.

BUDGET in the script is yours, and it is shorter. Python's subprocess.run kills the child process when its timeout expires and raises TimeoutExpired. The script catches that and prints one line saying so. A slow suite costs you 45 seconds and Claude learns that the status is unknown, instead of the hook being cut off at 60 with no output.

If even 45 seconds is too long for you, the reference also documents async: true, which never blocks. The trade-off: an async hook's result only reaches Claude on the next turn, it has to be printed as JSON, and Claude Code doesn't enforce timeout on it. For a list Claude should read before its first answer, the synchronous version fits better.

compact is left out of the matcher. Compaction summarizes the conversation and can lose details like this list, so add it if your sessions run long, at the cost of another test run every time the context compacts. The hooks guide uses a compact SessionStart hook for exactly that kind of re-injection.

Use the project file, not .claude/settings.local.json. The guide's table marks the local file as gitignored once Claude Code saves a setting to it, and anything git ignores never reaches a fresh clone or worktree.

Check that Claude actually got it

A successful hook is quiet. The guide says that in the Ctrl+O transcript view, on success "you see nothing." So check it other ways.

First, run /hooks. The hooks browser lists every configured hook by event and labels where each one came from, such as project settings. If yours isn't under SessionStart, the JSON didn't load.

Second, run the script by hand from the repo root. What it prints is exactly what Claude receives:

CLAUDE_PROJECT_DIR="$PWD" .claude/hooks/red_tests.py
Enter fullscreen mode Exit fullscreen mode

Third, read the logs. Start a session with a debug file and search it:

claude --debug-file /tmp/claude.log
# in another terminal:
grep 'Hooks: SessionStart' /tmp/claude.log
Enter fullscreen mode Exit fullscreen mode

From Claude Code v2.1.296, the debug section says each SessionStart command hook leaves one Hooks: line when it finishes, saying how the run ended, for example finished with status 0 with a duration, or timed out after <N>ms. If you see the second one, lower BUDGET.

The reference also says Claude Code saves injected text in the session transcript, the .jsonl file under ~/.claude/projects/. To confirm the list is in there:

latest=$(ls -t ~/.claude/projects/*/*.jsonl | head -1)
grep -c 'Fast tests' "$latest"
Enter fullscreen mode Exit fullscreen mode

A count above zero means the hook's text was saved with the session. The behavioral check is cheaper still: as your first prompt, ask "Which tests were failing when this session started?" If Claude names them without calling a tool, the hook did its job.

Facts that stay, facts that change

The hooks reference draws a line here too. "For static context that doesn't require a script, use CLAUDE.md instead." Conventions, layout and the test command don't change between sessions. Which tests are red changes every time you pull.

I build Hermes IDE, so weigh this section with that in mind. Hermes runs Claude Code in a real terminal, "exactly as it would anywhere else" per the README, so the hook above runs unchanged inside it. On the static side Hermes has its own two layers. Context injection points the agent at a context file with what Hermes's project scan found (languages, frameworks, architecture and conventions), kept within a token budget you set per project. Memory and context pins are facts and pinned files you save for a project, and they reach every later session's agent, also after a restart.

The Hermes New task sheet with a refund-endpoint task typed, chips for Claude Code, the orders-api project, a new hermes/ worktree, Accept edits, model and effort, and a preview of the claude command Hermes will run
The ⌘N New task sheet in Hermes 2.3.4: Claude Code on orders-api in a new hermes/ worktree, with the "Hermes will run" preview of the claude command.

There is one catch with worktrees. Each Hermes task (⌘N, Ctrl+Shift+N on Windows and Linux) can start on its own hermes/ branch in its own worktree, and the hook fires there. A committed .claude/settings.json and red_tests.py come along with your tracked files, and the README says new worktrees get their dependencies in seconds. Other ignored files your tests read, such as a .env or a generated fixture, are a different matter: if one is missing, pytest fails to start and the hook reports the exit code instead of test names. The worktree recipe runs its setup commands in the new worktree, and "The session starts once setup is done," so the hook only runs after setup has finished. Put those steps there.

A Claude Code session in Hermes after pressing the Context chip, showing Claude Code's /context breakdown of 44.4k of 1M tokens, per-session context gauges in the sidebar, and the status strip with token total and estimated cost
The Context chip in Hermes 2.3.4 runs Claude Code's /context: 44.4k of 1M tokens used, with per-session gauges in the sidebar.

In the screenshot, pressing the Context chip brings up Claude Code's /context breakdown for that session. The hook covers the start of a task. For the end, the README says "your own checks can decide when a task is done," with done_when in the same recipe file. Pair the two: the hook reports what was failing when the task began, and a done_when check decides whether it is finished.

Hermes is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux. It is not affiliated with Nous Research, Anthropic or any other agent vendor.


If this was useful

The hook is a small piece of a bigger habit: decide what an agent knows before its first turn, instead of letting it spend turns finding out. The AI Agents Pocket Guide covers that habit, from context and tool loops to the guardrails that keep an agent's work checkable. It is one of 8 short books in the Pocket Guides for Developers series, covering agents, RAG, prompts, observability, events, databases and system design.

If you want to run this hook across several Claude Code tasks at once, each in its own worktree, download Hermes IDE and star it on GitHub if it helps.

AI Agents Pocket Guide: Patterns for Building Autonomous Systems with LLMs

Top comments (0)