Guide

Build your own status display with Claude Code hooks

Updated

On this page

Claude Code runs commands of your own at fixed moments: when you send a prompt, before and after a tool runs, when it needs you, when it finishes and when a session ends. Each hook gets a small JSON object on stdin with the session's ID and the event. Turn those events into one status file per session, and a two-line loop in your terminal shows which session is thinking, which one is running a tool and which one is waiting for you.

A keypad key showing a Claude Code session thinking

The events that matter

EventWhat it meansStatus
UserPromptSubmitYou sent a promptthinking
PreToolUseA tool is about to run (tool_name)tool
PostToolUseThe tool finishedthinking
NotificationClaude Code needs you, for a permission or your next messagewaiting
StopClaude finished respondingdone
SessionEndThe session endedremoved

Every hook also gets session_id, cwd and hook_event_name.

1. The hook script

Save this as ~/.claude/hooks/status.py:

#!/usr/bin/env python3
import json, os, sys, time

STATES = {"UserPromptSubmit": "thinking", "PreToolUse": "tool",
          "PostToolUse": "thinking", "Notification": "waiting", "Stop": "done"}

event = json.load(sys.stdin)
name = event.get("hook_event_name")
folder = os.path.expanduser("~/.cache/claude-status")
os.makedirs(folder, exist_ok=True)
path = os.path.join(folder, event["session_id"] + ".json")

if name == "SessionEnd":
    if os.path.exists(path):
        os.remove(path)
elif name in STATES:
    status = {"state": STATES[name],
              "project": os.path.basename(event.get("cwd", "")),
              "detail": event.get("tool_name") or event.get("message") or "",
              "time": time.time()}
    with open(path + ".tmp", "w") as f:
        json.dump(status, f)
    os.replace(path + ".tmp", path)

It writes each session's state to its own small file, and replaces the file in one step so a reader never sees half of it.

2. Register it

Add the hooks to ~/.claude/settings.json. If the file already has a hooks section, merge these entries into it:

{
  "hooks": {
    "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "python3 ~/.claude/hooks/status.py" }] }],
    "PreToolUse": [{ "hooks": [{ "type": "command", "command": "python3 ~/.claude/hooks/status.py" }] }],
    "PostToolUse": [{ "hooks": [{ "type": "command", "command": "python3 ~/.claude/hooks/status.py" }] }],
    "Notification": [{ "hooks": [{ "type": "command", "command": "python3 ~/.claude/hooks/status.py" }] }],
    "Stop": [{ "hooks": [{ "type": "command", "command": "python3 ~/.claude/hooks/status.py" }] }],
    "SessionEnd": [{ "hooks": [{ "type": "command", "command": "python3 ~/.claude/hooks/status.py" }] }]
  }
}

Without a matcher, a hook fires on every occurrence of its event, so PreToolUse covers every tool.

3. Watch your sessions

Save this as ~/.claude/hooks/status-view.py:

#!/usr/bin/env python3
import glob, json, os, time

ORDER = {"waiting": 0, "tool": 1, "thinking": 2, "done": 3}
rows = []
for path in glob.glob(os.path.expanduser("~/.cache/claude-status/*.json")):
    with open(path) as f:
        rows.append(json.load(f))
rows.sort(key=lambda s: (ORDER.get(s["state"], 9), -s["time"]))
for s in rows:
    age = int(time.time() - s["time"])
    print(f'{s["state"]:<9} {s["project"]:<20} {age:>5}s  {s["detail"][:40]}')

Then, in a spare terminal:

while true; do clear; python3 ~/.claude/hooks/status-view.py; sleep 1; done

The session waiting for you is always on top:

waiting   docs                     12s  Claude needs your permission to use Bash
tool      web-app                   3s  Bash
thinking  api-server                1s
done      billing                  48s

Where it stops

  • It only knows what hooks tell it. It shows states, not the reply as Claude writes it.
  • It's one agent. Codex, ZCode or Antigravity sessions need their own integration.
  • It's a terminal window. When your editor is in front, you don't see it.

Put it on a keypad or a phone

If you want this for Claude Code without writing it, Orbtile already does it: its Claude Code plugin uses the same hooks and shows every session on the Logitech MX Creative Keypad or an Android phone, with live text, the waiting session first, and Allow and Deny on the keys. It does the same for Codex, ZCode and Antigravity.

For your own tools, Orbtile's Adapter API takes the same kind of states (thinking, tool, waiting, done) from any script. See Show any script or agent on a keypad with the Orbtile Adapter API.

FAQ

Do I need to restart Claude Code after adding hooks?

Usually not: Claude Code picks up edits to hooks in its settings files on its own. If a running session doesn't react, start a new one.

Can a hook slow Claude Code down?

Each hook runs as its own process, so keep it short. The script above only writes a small file.

Does this work on Windows?

The idea does. The script uses ~ paths and python3, which work as they are on macOS and Linux; on Windows, adjust the paths and the command.