Auto-Saving Claude Responses with the Stop Hook

Every time Claude gives you a substantial response — a plan, an architecture decision, a debugging analysis — that response lives in the chat window and nowhere else. Once context compaction kicks in, it’s gone. You’ll re-derive the same analysis next session, burning tokens and time to reach the same conclusion.

This article shows how to fix that with a single Claude Code hook that silently saves every worthwhile response to a file the moment Claude stops talking.


What Claude Code hooks are

Claude Code hooks are shell commands that fire automatically at specific points in a session lifecycle. You configure them in ~/.claude/settings.json and Claude Code runs them for you — no manual trigger needed.

The available hook events:

Event When it fires
Stop After every Claude response — the one we want
SessionStart When a session begins
SessionEnd When a session ends
PreCompact Before context is compacted
PreToolUse Before Claude calls a tool
PostToolUse After a tool call completes

Hooks receive context via stdin as JSON. The Stop hook receives:

{
  "session_id": "bc4ba386-...",
  "transcript_path": "/home/you/.claude/projects/.../session.jsonl",
  "cwd": "/your/project",
  "hook_event_name": "Stop",
  "last_assistant_message": "The full text of Claude's response...",
  "stop_hook_active": false
}

That last_assistant_message field is the key to everything in this article.

To show a notification back to the user, the hook outputs JSON to stdout:

{"systemMessage": "Your message here"}

The problem we’re solving

Claude Code does not persist responses anywhere useful. The transcript JSONL files in ~/.claude/projects/ exist but are session-scoped and not designed for retrieval. Once a session compacts or ends, the analysis Claude gave you two hours ago is effectively gone.

This matters most for:

The naive fix — scrolling up and copying manually — works once. It doesn’t work across a day of 30 responses.


The naive approach and why it fails

The obvious first attempt is to parse the transcript file directly. Read the JSONL, find the last assistant entry, extract the text.

TRANSCRIPT=$(ls -t ~/.claude/projects/$CWD_KEY/*.jsonl | head -1)
python3 -c "
import json
with open('$TRANSCRIPT') as f:
    for line in f:
        msg = json.loads(line)
        if msg.get('type') == 'assistant':
            # extract text...
"

This breaks in two ways.

Bug 1: Wrong transcript file. The CWD-based key lookup (pwd | sed 's|[/.]|-|g') finds the right project folder, but not necessarily the right session file. Multiple sessions can be active.

Bug 2: Tool calls split the response. When Claude uses tools, the JSONL structure looks like:

assistant: { type: "thinking" }
assistant: { type: "tool_use" }
user:      { type: "tool_result" }   ← counts as user message
assistant: { type: "text" }          ← the actual response

If you grab “the last user message and everything after it,” you get a tool result as the boundary — not the actual human question. You end up with fragments instead of the full response.

We spent three iterations on this approach before reading the debug log.


The discovery

Add one line to the hook to log what Claude Code actually sends:

cat > "$INPUTFILE"
echo "stdin: $(cat "$INPUTFILE")" >> /tmp/hook-debug.log

The log shows:

stdin: {
  "session_id": "bc4ba386-...",
  "last_assistant_message": "The full text of Claude's last response...",
  ...
}

Claude Code hands you the complete response text in last_assistant_message. No transcript parsing. No entry splitting. No tool call confusion. One field, always accurate.

The entire parsing approach was solving a problem that didn’t exist.


The scoring system

Saving every response creates noise — “yes”, “got it”, “done” would fill your responses folder with junk. We need to filter.

A single threshold (word count, sentence count) misses too many edge cases. A short response with three code blocks is worth saving. A 200-word conversational reply is not.

The solution: a multi-signal scoring system.

Hard skips (checked before scoring)

These exit immediately regardless of any other signals:

# Under 40 words — always trivial
if word_count < 40:
    print("SKIP:too-short")
    sys.exit(0)

# Conversational opener on a short reply
CONVERSATIONAL = re.compile(
    r"^(yes|no|sure|ok|okay|got it|sounds good|perfect|great|done|noted|...)[,.\s!]",
    re.IGNORECASE,
)
if CONVERSATIONAL.match(text) and word_count < 120:
    print("SKIP:conversational")
    sys.exit(0)

# Pure question (Claude asking back, not answering)
if len(sentences) <= 2 and text.rstrip().endswith("?"):
    print("SKIP:question-only")
    sys.exit(0)

Positive signals (each adds to score)

# Code blocks — strongest signal
code_blocks = len(re.findall(r"```", text)) // 2
if code_blocks >= 1:
    score += 4 * min(code_blocks, 3)   # caps at 12

# Markdown headers — structured answer
headers = len(re.findall(r"^#{1,4}\s+\S", text, re.MULTILINE))
if headers >= 1:
    score += 3 * min(headers, 4)       # caps at 12

# Tables
tables = len(re.findall(r"^\|.+\|.+\|", text, re.MULTILINE))
if tables >= 2:
    score += 4

# Lists
list_items = len(re.findall(r"^\s*[-*+]\s+\S|\s*\d+\.\s+\S", text, re.MULTILINE))
if list_items >= 3:
    score += 2

# Word count tiers
if word_count >= 300:   score += 4
elif word_count >= 150: score += 2
elif word_count >= 80:  score += 1

# Multi-paragraph prose
paras = [p for p in re.split(r"\n\s*\n", text) if len(p.strip()) > 60]
if len(paras) >= 3:
    score += 2

Negative signals

# Shallow list — many items but no code or structure
if list_items >= 3 and code_blocks == 0 and headers == 0 and word_count < 100:
    score -= 2

Decision thresholds

if score >= 5:    print(f"SAVE:{score}:{reasons}")
elif score >= 2:  print(f"ASK:{score}:{reasons}")   # notify, don't auto-save
else:             print(f"SKIP:low-score:{score}")

A response with one code block scores 4 (code) + 1 (words ≥ 80) = 5 → auto-save. A “got it, I’ll do that” with a short list scores 2 - 2 = 0 → skip.


The full script

#!/usr/bin/env bash
# dump-response.sh — Stop hook: saves last assistant response
# Claude Code passes last_assistant_message directly in stdin JSON.

set -uo pipefail

INPUTFILE=$(mktemp /tmp/claude-input-XXXXXX)
TMPFILE=$(mktemp /tmp/claude-dump-XXXXXX)
trap 'rm -f "$INPUTFILE" "$TMPFILE"' EXIT

cat > "$INPUTFILE"

RESULT=$(python3 - "$INPUTFILE" "$TMPFILE" <<'PYEOF'
import sys, json, re, os

input_file = sys.argv[1]
tmp_file   = sys.argv[2]

try:
    with open(input_file) as f:
        d = json.load(f)
    text = (d.get("last_assistant_message") or "").strip()
except Exception:
    print("SKIP:parse-error")
    sys.exit(0)

if not text:
    print("SKIP:no-text")
    sys.exit(0)

word_count = len(text.split())
score = 0
reasons = []

# Hard skips
if word_count < 40:
    print("SKIP:too-short"); sys.exit(0)

CONVERSATIONAL = re.compile(
    r"^(yes|no|sure|ok|okay|got it|sounds good|perfect|great|done|noted|"
    r"absolutely|certainly|of course|makes sense|agreed|correct|exactly|"
    r"right|fair enough|understood|will do|on it)[,.\s!]", re.IGNORECASE)
if CONVERSATIONAL.match(text) and word_count < 120:
    print("SKIP:conversational"); sys.exit(0)

clean = re.sub(r"```.*?```", "", text, flags=re.DOTALL).strip()
sentences = [s.strip() for s in re.split(r"[.!?]+", clean) if len(s.strip()) > 5]
if len(sentences) <= 2 and text.rstrip().endswith("?"):
    print("SKIP:question-only"); sys.exit(0)

# Positive signals
code_blocks = len(re.findall(r"```", text)) // 2
if code_blocks >= 1:
    score += 4 * min(code_blocks, 3); reasons.append(f"code:{code_blocks}")

headers = len(re.findall(r"^#{1,4}\s+\S", text, re.MULTILINE))
if headers >= 1:
    score += 3 * min(headers, 4); reasons.append(f"headers:{headers}")

tables = len(re.findall(r"^\|.+\|.+\|", text, re.MULTILINE))
if tables >= 2:
    score += 4; reasons.append(f"table:{tables}")

list_items = len(re.findall(r"^\s*[-*+]\s+\S|\s*\d+\.\s+\S", text, re.MULTILINE))
if list_items >= 3:
    score += 2; reasons.append(f"list:{list_items}")

if word_count >= 300:   score += 4; reasons.append(f"words:{word_count}")
elif word_count >= 150: score += 2; reasons.append(f"words:{word_count}")
elif word_count >= 80:  score += 1

paras = [p for p in re.split(r"\n\s*\n", text) if len(p.strip()) > 60]
if len(paras) >= 3:
    score += 2; reasons.append(f"paras:{len(paras)}")

inline_code = len(re.findall(r"`[^`\n]+`", text))
if inline_code >= 4:
    score += 1; reasons.append(f"inline:{inline_code}")

# Negative
if list_items >= 3 and code_blocks == 0 and headers == 0 and word_count < 100:
    score -= 2; reasons.append("penalised:shallow-list")

with open(tmp_file, "w") as f:
    f.write(text)

reason_str = ",".join(reasons) if reasons else "prose"
if score >= 5:   print(f"SAVE:{score}:{reason_str}")
elif score >= 2: print(f"ASK:{score}:{reason_str}")
else:            print(f"SKIP:low-score:{score}")
PYEOF
)

ACTION="${RESULT%%:*}"
REST="${RESULT#*:}"

case "$ACTION" in
  SKIP) exit 0 ;;
  ASK)
    SCORE="${REST%%:*}"; SIGNALS="${REST#*:}"
    printf '{"systemMessage": "Response score %s (%s) — /dump to save."}\n' "$SCORE" "$SIGNALS"
    exit 0 ;;
  SAVE) ;;
  *) exit 0 ;;
esac

TEXT=$(cat "$TMPFILE")
DATE=$(date +%Y-%m-%d)
SLUG=$(echo "$TEXT" \
  | grep -m1 '^## \|^# \|^[A-Z]' \
  | sed 's/^[#* \t-]*//; s/[^a-zA-Z0-9 ]//g' \
  | tr '[:upper:]' '[:lower:]' \
  | tr -s ' ' '-' \
  | cut -c1-45 \
  | sed 's/-*$//')
[ -z "$SLUG" ] && SLUG="response"

if git -C "$(pwd)" rev-parse --show-toplevel >/dev/null 2>&1; then
  SAVE_DIR="$(git -C "$(pwd)" rev-parse --show-toplevel)/responses"
else
  SAVE_DIR="$HOME/.claude/responses"
fi
mkdir -p "$SAVE_DIR"

OUTPUT="$SAVE_DIR/RESPONSE_${DATE}_${SLUG}.md"
[ -f "$OUTPUT" ] && OUTPUT="$SAVE_DIR/RESPONSE_${DATE}_${SLUG}-$(date +%H%M%S).md"

cp "$TMPFILE" "$OUTPUT"
notify-send "Claude saved" "→ $(basename "$OUTPUT")" -t 4000 2>/dev/null || true
printf '{"systemMessage": "Saved → %s"}\n' "$OUTPUT"

Wiring it up

1. Save the script:

mkdir -p ~/.claude/hooks
# paste the script above into ~/.claude/hooks/dump-response.sh
chmod +x ~/.claude/hooks/dump-response.sh

2. Register the Stop hook in ~/.claude/settings.json:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "/home/YOU/.claude/hooks/dump-response.sh",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

Replace /home/YOU with your actual home path.

3. Optional — desktop notifications:

sudo apt install libnotify-bin   # Ubuntu/Debian

The notify-send call is already in the script. It fails silently if not installed.


Debugging tips

Check if the hook fires at all:

Add this near the top of the script temporarily:

echo "$(date) fired" >> /tmp/hook-debug.log
cat >> /tmp/hook-debug.log

Then check /tmp/hook-debug.log after a Claude response.

Check what’s being saved:

ls -lt ~/your-project/responses/ | head -5

Test the script manually:

echo '{
  "last_assistant_message": "## Test\n\nThis is a test response with code:\n\n```bash\necho hello\n```\n\nAnd more content here to reach the word threshold.",
  "session_id": "test"
}' | bash ~/.claude/hooks/dump-response.sh

What the series covers

This is Part 3 of Practical Claude Code — a series on real patterns built during actual sessions, not documentation rewrites.

The code in every article came from a real session. The bugs shown were real bugs. That’s the point.