Skip to content

Wiring Claude Code's Hook System into a Living Status Bar

Claude Code fires shell scripts on every tool event. Here's how to use that to build a session-aware, zany status bar that tells you exactly what's happening.

Matt Dennis

The Claude Code status bar can run a shell command. That command reads a JSON blob from stdin — workspace info, model, context window percentage. It outputs a string. Claude Code renders it.


Every hook Claude Code fires also receives JSON on stdin. Tool name, file path, session ID, output.


They share the same architecture. Which means you can pipe them together: hooks write to files, the statusline script reads from files, and the bar updates live as Claude works.


Here’s what that looks like in practice.


The Hook Surface

Claude Code exposes five hook types:


HookFires when
PreToolUseBefore any tool runs
PostToolUseAfter any tool completes
StopClaude finishes responding
NotificationPermission prompts, idle alerts
UserPromptSubmitUser hits enter on a prompt

Each hook gets a JSON payload on stdin. For PostToolUse, that includes tool_name, tool_input (with file_path for edits), and session_id. For Stop, you get session context. The statusline command gets workspace and model info — and also session_id.


That last field is the important one.


The Slug System

The idea is simple: every time Claude touches a file, a random verb appears in the status bar describing what just happened.


➜  zintex ⎇ main ✗  [sonnet ██████░░░░]  🍝 spaghettifying WorkOrderHandler.cls

PostToolUse on Edit and Write fires a script that reads the edited filename, picks a random verb from a pool of 43, and writes the result to a temp file. The statusline script reads that file on every render.


# post-edit-slug.sh (simplified)
input=$(cat)
session_id=$(echo "$input" | jq -r '.session_id // ""')
filepath=$(echo "$input" | jq -r '.tool_input.file_path // ""')
filename=$(basename "$filepath")

verbs=(
  "🍝 spaghettifying"
  "👻 haunting"
  "⌬ triangulating bugs in"
  "🦆 rubber-ducking"
  "💀 murdering bugs in"
  # ...40 more
)

verb=${verbs[$RANDOM % ${#verbs[@]}]}
echo "$verb $filename" > "/tmp/claude_slug_${session_id}.txt"

PreToolUse fires a separate script with tool-specific pools. Bash gets 🧨 doing something questionable. Read gets 👁 snooping in. Edit gets ⬡ performing surgery. The slugs are present-tense so if they linger they read as a recap, not a stalled promise.


The Session Isolation Problem

The first version wrote everything to /tmp/claude_edit_slug.txt. One file. That worked fine until two Claude Code windows were open at the same time — they stomped on each other constantly.


The fix: use session_id from the hook’s stdin JSON as a filename suffix. Every session gets its own slug file. Both the hook scripts (writers) and the statusline script (reader) receive session_id in their JSON payload, so they naturally converge on the same filename without coordination.


session_id=$(echo "$input" | jq -r '.session_id // ""')
slug_file="/tmp/claude_slug_${session_id}.txt"

If session_id is absent, fall back to an MD5 hash of the working directory. Two sessions in the same directory would still collide, but that’s an edge case worth accepting.


The Stop Hook’s Two Pools

The stop hook does two different things depending on what happened in the session.


A separate touch-flag.sh fires on every PostToolUse with a .* matcher and stamps a session-specific flag file. When Stop fires, it checks whether that flag exists and was touched recently (within 10 minutes).


If yes — real work happened. Write a finished slug:


✦ landed safely
◉ done. probably.
🧘 at peace with the diff
⬡ shipped (locally)

If no — pure conversation, nothing written. Write a yapper roast:


🎭 performed helpfulness
⣿ all texture, no substance
🪐 orbited the problem
🦋 fluttered. nothing more.

42 roasts, 20 finished slugs, random pick each time.


The Statusline Integration

The statusline script already reads input=$(cat) to get workspace and model info. Adding the slug is three lines:


session_id=$(echo "$input" | jq -r '.session_id // ""')
if [ -z "$session_id" ]; then
  session_id=$(echo "$cwd" | md5 | cut -c1-8)
fi

slug_file="/tmp/claude_slug_${session_id}.txt"
if [ -f "$slug_file" ]; then
  slug=$(cat "$slug_file")
  slug_fmt=$(printf '  \033[1;38;5;93m%s\033[0m' "$slug")
  line="$line$slug_fmt"
fi

38;5;93 is a deep blue-purple — the original \033[35m magenta was too pink and low-contrast. Bold + 256-color gives it enough weight to read clearly without shouting.


The Full settings.json Wiring

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit",
        "hooks": [{ "type": "command", "command": "bash ~/.claude/hooks/post-edit-slug.sh" }]
      },
      {
        "matcher": "Write",
        "hooks": [{ "type": "command", "command": "bash ~/.claude/hooks/post-edit-slug.sh" }]
      },
      {
        "matcher": ".*",
        "hooks": [{ "type": "command", "command": "bash ~/.claude/hooks/touch-flag.sh" }]
      }
    ],
    "PreToolUse": [
      {
        "matcher": ".*",
        "hooks": [{ "type": "command", "command": "bash ~/.claude/hooks/pre-tool-slug.sh" }]
      }
    ],
    "Stop": [
      {
        "hooks": [{ "type": "command", "command": "bash ~/.claude/hooks/stop-slug.sh" }]
      }
    ]
  },
  "statusLine": {
    "type": "command",
    "command": "bash ~/.claude/statusline-command.sh"
  }
}

One note on UserPromptSubmit: it fires when you submit a prompt and could show a “thinking” slug. In practice the status bar doesn’t refresh mid-turn, so the slug is never visible before the first tool fires and overwrites it. Not worth the hook.


Generate This Yourself

Drop this prompt into a Claude Code session. It has everything it needs.


I want to wire up Claude Code's hook system to make my status bar alive and fun.

Here's what I want:

1. A statusline command at ~/.claude/statusline-command.sh that shows:
   - Current directory (bold cyan)
   - Git branch (red) with a dirty indicator (yellow ✗) if uncommitted changes
   - Model name + a 10-char context window bar (green/yellow/red based on %)
   - A live "slug" from a temp file, shown in bold deep purple (ANSI 38;5;93)

2. PostToolUse hooks for Edit and Write that write a random zany verb + filename
   to the slug file. Use at least 40 verbs mixing emojis and Unicode glyphs
   (◆ ⬡ ✦ ⣿ ⌬ ↯ ◉ ⬢ ✧ ⟁ etc). Examples: "🍝 spaghettifying", "⌬ triangulating
   bugs in", "👻 haunting", "🦆 rubber-ducking".

3. A PreToolUse hook (matcher ".*") with tool-specific slug pools:
   - Bash: shell/execution themed slugs
   - Edit: surgery/precision themed slugs
   - Read: snooping/inspection themed slugs
   - Write: manifestation/creation themed slugs
   - Fallback: generic activation slugs
   Use ~10 slugs per bucket, present-tense phrasing.

4. A Stop hook with two pools:
   - If any tool ran in the last 10 minutes (detected via a session flag file):
     write a "finished" slug (e.g. "◉ done. probably.", "✦ landed safely",
     "🧘 at peace with the diff"). ~20 entries.
   - If no tools ran (pure conversation): write a yapper roast (e.g.
     "🎭 performed helpfulness", "🪐 orbited the problem", "⣿ all texture,
     no substance"). ~40 entries.

5. A touch-flag.sh that fires on all PostToolUse (matcher ".*") and stamps a
   session-specific flag file.

CRITICAL: All temp files must be session-specific using session_id from stdin
JSON, e.g. /tmp/claude_slug_${session_id}.txt. Multiple Claude Code windows
must not stomp on each other. The statusline script must use the same session_id
derivation (with an md5-of-CWD fallback if session_id is empty).

Put all hook scripts in ~/.claude/hooks/, wire them in ~/.claude/settings.json,
and update ~/.claude/statusline-command.sh. Make all scripts executable.

The hook system has more surface area than most people use. Once you understand that every event is just JSON on stdin and every response is just a file write, the whole thing opens up — sounds, notifications, log files, external webhooks, whatever you want to fire when Claude does anything.