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.
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"}
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 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.
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.
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.
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)
# 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
# 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
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.
#!/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"
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.
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
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.