Building a custom Claude Code statusline
Updated July 27, 2026 · 8 min read
A statusLine is a shell command Claude Code runs to render one line of session state. It receives a large JSON object on stdin — model, workspace, git repo, context window usage, subscription rate limits, worktree, open PR — and whatever it prints becomes the line.
Schema verified against Claude Code 2.1.220
The payload below is the schema the 2.1.220 binary itself documents. Fields marked optional genuinely don't appear in every session, so guard for them — jq -r '.foo // empty' is your friend.
Claude Code renders a configurable line beneath the prompt. It's a small feature with an unusually rich input: rather than exposing a template language, the CLI hands your command the whole session state as JSON and prints whatever you send back.
Registering the command
{
"statusLine": {
"type": "command",
"command": "~/.claude/statusline.sh"
}
}Put that in ~/.claude/settings.json for every project, or .claude/settings.json inside a repo for that project only. There's also a /statusline command inside a session that will set one up for you interactively.
The JSON you receive on stdin
This is the part worth knowing, because most statuslines people write use two fields out of a couple of dozen available.
{
"session_id": "string", // Unique session ID
"session_name": "string", // Optional: name set via /rename
"prompt_id": "string", // Optional: UUID of the prompt being processed
"transcript_path": "string", // Path to the conversation transcript
"cwd": "string", // Current working directory
"version": "string", // Claude Code app version
"model": {
"id": "string", // e.g. "claude-opus-4-5-20251101"
"display_name": "string" // e.g. "Claude Opus 4.5"
},
"workspace": {
"current_dir": "string",
"project_dir": "string",
"added_dirs": ["string"], // Directories added via /add-dir
"git_worktree": "string", // Optional: worktree name if cwd is in one
"repo": { // Optional: identity from the origin remote
"host": "string", // e.g. github.com
"owner": "string",
"name": "string"
}
},
"context_window": {
"total_input_tokens": 0,
"total_output_tokens": 0,
"context_window_size": 200000,
"current_usage": { // null if no messages yet
"input_tokens": 0,
"output_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0
},
"used_percentage": 0, // Pre-calculated, 0-100, null if no messages
"remaining_percentage": 100
},
"rate_limits": { // Optional: subscribers only, after first response
"five_hour": { "used_percentage": 0, "resets_at": 0 },
"seven_day": { "used_percentage": 0, "resets_at": 0 }
},
"output_style": { "name": "string" },
"effort": { "level": "low|medium|high|xhigh|max" }, // If model supports it
"thinking": { "enabled": false },
"vim": { "mode": "INSERT|NORMAL|VISUAL|VISUAL LINE" }, // If vim mode on
"agent": { // Optional: only with --agent
"name": "string",
"type": "string"
},
"pr": { // Optional: open PR for the current branch
"number": 0,
"url": "string",
"review_state": "approved|pending|changes_requested|draft"
},
"worktree": { // Optional: only in a --worktree session
"name": "string",
"path": "string",
"branch": "string",
"original_cwd": "string",
"original_branch": "string"
}
}A couple of runtime fields exist beyond the documented schema — exceeds_200k_tokens and fast_mode both appear in the payload. Treat them as unofficial and guard accordingly.
A statusline worth having
Read stdin once into a variable — the classic mistake is calling cat several times and getting an empty string on every call after the first.
#!/usr/bin/env bash
# ~/.claude/statusline.sh — chmod +x this file
set -uo pipefail
input=$(cat)
q() { printf '%s' "$input" | jq -r "$1 // empty"; }
model=$(q '.model.display_name')
dir=$(basename "$(q '.workspace.current_dir')")
repo=$(printf '%s' "$input" | jq -r '.workspace.repo | if . then .owner + "/" + .name else empty end')
worktree=$(q '.worktree.name')
remaining=$(q '.context_window.remaining_percentage')
five_hour=$(q '.rate_limits.five_hour.used_percentage')
pr=$(q '.pr.number')
effort=$(q '.effort.level')
parts=()
[ -n "$model" ] && parts+=("$model")
[ -n "$effort" ] && parts+=("·$effort")
[ -n "$repo" ] && parts+=("$repo") || parts+=("$dir")
[ -n "$worktree" ] && parts+=("⑂$worktree")
[ -n "$pr" ] && parts+=("PR#$pr")
# Context left, flagged when it gets tight
if [ -n "$remaining" ]; then
pct=$(printf '%.0f' "$remaining")
if [ "$pct" -lt 20 ]; then
parts+=("$(printf '\033[31mctx %s%%\033[0m' "$pct")")
else
parts+=("ctx $pct%")
fi
fi
# 5-hour subscription window
[ -n "$five_hour" ] && parts+=("5h $(printf '%.0f' "$five_hour")%")
printf '%s' "$(IFS=' '; echo "${parts[*]}")"That renders something like Claude Opus 4.5 ·high anthropics/claude-code ⑂refactor-auth PR#412 ctx 63% 5h 41% — model, repo, worktree, open PR, context headroom, and how much of your five-hour window you've burned.
Use printf for colours
The CLI's own guidance is explicit: use printf rather than echo for ANSI codes, and note that the status line is rendered in dimmed colours — so pick codes that survive dimming.
The fields worth surfacing, and why
| Field | Why it earns its place |
|---|---|
context_window.remaining_percentage | The single most useful number. Compaction mid-task is where agents lose the plot; seeing it coming lets you finish or checkpoint first. |
rate_limits.five_hour.used_percentage | Only appears for subscribers after the first API response. Answers 'can I start another long task right now'. |
workspace.git_worktree / worktree.name | Essential once you run agents in parallel — it's how you know which checkout you're looking at. |
pr.number + pr.review_state | Mirrors the footer PR badge. Useful when the agent's job ends at 'open a PR'. |
model.display_name + effort.level | Catches the case where you meant to run something heavy on a strong model and didn't. |
session_id | Not for display — for correlation. If you also run hooks, this is the key that ties a statusline render to a hook event. |
What a statusline can't do
It renders state; it doesn't interrupt. A statusline is perfect for the question *how is this session doing* and useless for *which of my four agents just stopped and is waiting for me*, because answering that requires you to be looking at the terminal — which, if you're running agents in the background, you aren't.
For that you want an event, not a render: a hook on `Notification` or `PermissionRequest`, or something more persistent. Notifications on macOS walks through the options and where each one drops the signal.
The two compose well, though, and that combination is exactly what BubbleCode uses to track sessions you started in your own terminal: a statusline for live state plus SessionStart, SessionEnd, Stop, and PermissionRequest hooks for the transitions, keyed together on session_id.
Debugging
- Test outside Claude Code by piping a saved payload:
cat sample.json | ~/.claude/statusline.sh. Capture a real one first with a one-line script that doestee /tmp/statusline.json. jqfailures print to stderr and produce an empty line — run the script by hand before assuming the config is wrong.- Read stdin exactly once. Every subsequent
catgets nothing. - Keep it fast. This runs frequently; a statusline that shells out to
gitover a network mount will make the whole session feel sluggish. claude doctorchecks installation health and reads settings files without a trust prompt.
FAQ
What JSON does the Claude Code statusLine command receive?
A single object on stdin containing session_id, transcript_path, cwd, version, model (id and display_name), workspace (current_dir, project_dir, added_dirs, git_worktree, repo), context_window with pre-calculated used_percentage and remaining_percentage, rate_limits for subscribers, output_style, effort, thinking, and optionally vim, agent, pr, and worktree objects.
How do I show remaining context in the Claude Code statusline?
Read .context_window.remaining_percentage — it's pre-calculated as a 0-100 number, so no arithmetic is needed. It's null until the session has messages, so guard with `jq -r '.context_window.remaining_percentage // empty'`.
Can the statusline show my Claude subscription usage?
Yes. rate_limits.five_hour.used_percentage and rate_limits.seven_day.used_percentage give the percentage consumed plus resets_at as Unix epoch seconds. The object only appears for Claude.ai subscribers and only after the first API response of the session.
Why is my statusline blank?
Usually one of three things: the script isn't executable, it reads stdin more than once (only the first read gets data), or a jq expression failed and produced an empty string. Test it by piping a captured payload into the script directly.
Can a statusline notify me when an agent needs permission?
Not usefully. A statusline only renders where you can see the session, so it can't interrupt you when you're in another app. Use a hook on the Notification or PermissionRequest event for that.
Keep reading
Claude Code hooks: a practical guide
All thirteen hook events, the stdin/stdout contract, and scripts you can paste in today.
claude settings.json: an annotated reference
Where the files live, how scopes merge, and what each key that matters actually does.
Claude Code notifications on macOS
Six ways to get told an agent is blocked, from a terminal bell to a phone push — and where each one drops the signal.