(Quick framing for anyone new to Claude Code: it’s Anthropic’s terminal CLI for Claude, and the statusline is the configurable line of text it prints under your prompt every turn — like a shell prompt for the agent. You point at any script in settings.local.json, Claude pipes a JSON object to it on stdin, and whatever the script writes to stdout becomes the visible line.)
I have a Stream Deck plugin called ClaudeDeck — Stream Deck is Elgato’s USB grid of LCD keys, the kind streamers use for scene switching, and “plugin” means the small TypeScript program that runs inside Elgato’s app and draws on those keys — that paints a per-session context-window donut on each key. (Context window = the LLM’s working memory. Once you fill it, Claude starts dropping the oldest turns; /compact summarises the conversation to free space.) Green under 50%, yellow above, red past 80%, so I know when to /compact without squinting at the TUI (text user interface — Claude Code’s full-screen terminal app). The hard part wasn’t drawing the donut. It was: how does a Stream Deck plugin running in its own process know each Claude Code session’s current context fill?
I considered polling. I considered hooks (I’ve already written about the hook layer in a separate post). Then I read the statusline docs more carefully and realised Claude Code was already calling something I controlled, every turn, with the exact JSON I wanted. The statusline command. Not a hook, not an API — the same little script that prints the line under your prompt.
The whole donut pipeline ended up being eight lines of bash dressed up as a statusline.
What the statusline command actually receives #
If you set statusLine in ~/.claude/settings.local.json to a type: "command", Claude Code runs that command on every turn and pipes a JSON object to its stdin. The command’s stdout becomes the visible line under the prompt.
{
"session_id": "01J9...",
"cwd": "/Users/nickboy/workspace/claudedeck",
"model": { "display_name": "Claude Opus 4.6" },
"cost": { "total_cost_usd": 0.234 },
"context_window": {
"used_tokens": 124528,
"max_tokens": 200000,
"used_percentage": 62.26
}
}context_window.used_percentage is exactly the number I want on the Stream Deck. No estimation, no token counting, no API round-trip — Claude already did the math and is handing it to me on a plate, once per turn. The only thing standing between me and a live donut was the realisation that the statusline’s contract doesn’t say I have to only render a string.
The side channel #
The statusline command’s contract is “read JSON from stdin, write text to stdout”. It’s silent on whether you can also POST the JSON to a local daemon on the way through. So that’s what mine does.
#!/bin/sh
# claudedeck-statusline — Claude Code statusline command.
set -e
INPUT="$(cat)"
# Side channel: forward to daemon, fire-and-forget, 1 s cap.
SID="$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2> /dev/null || true)"
if [ -n "$SID" ]; then
printf '%s' "$INPUT" \
| curl -sf -m 1 -X POST "http://127.0.0.1:9127/context/$SID" \
-H "Content-Type: application/json" \
--data-binary @- > /dev/null 2>&1 &
fi
# Visible: prefer the user's existing statusline if present.
DELEGATE="${CLAUDEDECK_STATUSLINE_DELEGATE:-$HOME/.local/bin/claude-statusline}"
if [ -x "$DELEGATE" ] && [ "$DELEGATE" != "$0" ]; then
printf '%s' "$INPUT" | "$DELEGATE"
exit $?
fi
# ...fallback rendering omitted...The daemon (my long-running background process at 127.0.0.1:9127; see the hooks post for why it lives there) receives POST /context/:sid, parses context_window.used_percentage, updates the slot in its in-memory state, and pushes the new value over WebSocket (the always-open two-way HTTP-upgraded connection the plugin keeps to the daemon) to the Stream Deck plugin. End-to-end latency: a few milliseconds. No polling, no extra API call, no new transport. Claude Code was already going to call this command anyway.
Two pieces of that script are doing real work and deserve a closer look.
Background and cap, always #
The statusline command runs on Claude Code’s hot path. Whatever it does adds to the time between you pressing Enter and seeing the prompt redraw. Three protections, all in the same line:
curl -sf -m 1 ... > /dev/null 2>&1 &-m 1caps the curl at one second. If the daemon is wedged, curl gives up and Claude moves on.&backgrounds it (forks the curl into a child process and immediately moves on, the standard shell pattern for “fire-and-forget”). The statusline script doesn’t wait for the POST to complete before printing its line.> /dev/null 2>&1discards curl’s output (redirects both stdout, file descriptor 1, and stderr, file descriptor 2, to the bit-bucket). Anything that leaks to stdout would appear in your statusline.
Skip any of these and you eventually get a turn where the TUI hangs for a few seconds waiting on a curl that’s never coming back. That’s the trap. The visible statusline must render even when the side channel is on fire.
Delegate, don’t replace #
I’d been running a much richer custom statusline for months — model name, git branch, agent type, cost in yellow when it crosses a threshold. Replacing it with my minimal version was a regression I noticed within five minutes of installing my own tool.
The fix was to delegate. If ~/.local/bin/claude-statusline exists and is executable, my script pipes the same stdin to it and exits with its status. The side channel still ran — it happens before the delegate call — so the daemon gets the payload either way. Override via CLAUDEDECK_STATUSLINE_DELEGATE if your delegate lives somewhere weird.
The project-vs-user override that ate an afternoon #
I shipped this, ran ./install.sh, opened a Claude session in the ClaudeDeck repo. Donut went live. Opened another Claude session in my Obsidian vault at ~/Documents/obsidian/MyVault/. Donut: stuck at 0%.
The daemon log showed no POST /context/... requests from that session at all. So the statusline command wasn’t being invoked. The user-level ~/.claude/settings.local.json had it. Why wasn’t Claude reading it?
Because Claude Code’s project-local .claude/settings.local.json doesn’t merge with the user-level file. It overrides it. Wholesale. Every project where I’d ever clicked “Always allow” for a tool had a project-local settings file with a permissions section and no statusLine section. The user-level statusLine was being shadowed by a file that didn’t even mention it.
This is the same shape as the gotcha I hit in the hooks layer — the same settings model, the same override semantics, the same hours-of-debugging surprise. If your config lives only in one place, you’re going to miss the half of projects that have their own version of that place.
The fix has two parts, and both of them are running today.
Install-time patching. The installer walks ~/workspace/* (configurable via CLAUDEDECK_PATCH_PROJECTS) and patches every project’s .claude/settings.local.json to add the statusline command. If a project already has a non-claudedeck statusLine, it’s left alone — we recognise our own by the claudedeck-statusline substring in the path. Anything else is somebody’s hand-rolled config and not ours to touch.
Run-time patching. Workspaces aren’t the only place I run claude from. Obsidian vaults, personal scripts, one-off side projects in ~/code. So the daemon also runs a StatuslineAutoPatcher on every SessionStart hook (one of Claude Code’s hook events — fires when a new Claude session boots in a given working directory): on first session in a new project, patch that project’s settings file. The current session won’t pick it up — Claude reads settings once at session start, before the hook fires — but the next session in that project will. You pay the “first session is dumb” cost exactly once per new project.
export class StatuslineAutoPatcher {
private readonly seen = new Set<string>();
maybePatch(cwd: string): "patched" | "skipped" | "noop" | "no-file" | "cached" {
if (!cwd) return "cached";
if (this.seen.has(cwd)) return "cached";
this.seen.add(cwd);
return this.patcher(cwd, this.scriptPath, (m) => console.log(m));
}
}The in-memory seen set dedupes — after the first SessionStart in a project, we don’t reopen its settings file on every subsequent session. Daemon restart re-checks every project, which is also when stale paths (e.g. a previous install that pointed at a tmpdir) get healed.
The patcher itself is idempotent. It reads the settings file, recognises our entry by the claudedeck-statusline substring, and either replaces a stale path, installs a fresh entry into a file that doesn’t have one, or backs off if the user has their own custom statusLine. Survives Claude itself rewriting the file’s permissions section, because we only ever touch statusLine.
Three rules for safe side-channeling #
This pattern generalises. Anything you want to ship per-turn — Prometheus metrics, cost-per-project dashboards, an editor-side “context filling up” toast — the statusline command is the cheapest hook into Claude Code’s per-turn state. Three rules to keep it safe:
- Background the side channel. Never block the visible statusline on a network call, a file write, or a subprocess.
&it. The statusline runs on the user’s turn loop; a wedged side channel turns into a wedged prompt. - Cap the timeout.
-m 1on curl, equivalent on whatever else. If the daemon is down, the statusline command must still render something within a second. The threshold doesn’t have to be one second — it has to be lower than your patience for a prompt redraw. - Delegate to the user’s existing statusline if they have one. Detect their script, pipe stdin through, exit with its status. Overwriting somebody’s polished statusline with your minimal version is the fastest way to get uninstalled.
Lessons #
- The cheapest hook into Claude Code’s per-turn state is the one that’s already running. Claude is already calling your statusline command every turn with the JSON you want. Don’t reach for a new API or a polling loop — fork the call.
- Project-local settings override user-level wholesale. Not merge. Override. If your config installs only in one place, half your sessions won’t see it. Patch project files at install time and on SessionStart.
- The visible statusline must render on a failed side channel. Cap, background, swallow stderr. The user’s prompt is sacred; your telemetry isn’t.
- Delegate the visible render to whatever the user already has. It’s polite, and it costs you one extra
if [ -x ... ]check. - Recognise your own installs by a substring in the command path. Then your install/uninstall is idempotent, and you never clobber a config that wasn’t yours.
References #
- Claude Code statusline docs: https://docs.claude.com/en/docs/claude-code/statusline — the JSON payload schema piped to the statusline command’s stdin (URL redirects to https://code.claude.com/docs/en/statusline; both resolve)
- Claude Code settings docs: https://docs.claude.com/en/docs/claude-code/settings — project-level
.claude/settings.local.jsonprecedence over user-level - ClaudeDeck’s statusline forwarder:
cli/src/statusline.sh— side-channel POST plus delegate-or-fallback rendering - ClaudeDeck’s SessionStart auto-patcher:
daemon/src/statuslineAutoPatch.ts— in-memory dedup, callspatchProjectSettingsAt - ClaudeDeck’s settings patcher:
hooks/install.ts—patchProjectSettingsAt,installStatusLineForProjects, recognition rule for our own installs - The hook-layer twin of this post (same project-vs-user override at the hook layer): The Claude Code hooks docs are wrong. Here’s what’s actually on the wire.