Skip to main content
  1. Posts/

The Claude Code statusline is a per-turn telemetry side channel

Nick Liu
Author
Nick Liu
Building infrastructure for Facebook Feed Ranking at Meta. Previously at Walmart, Twitter, AWS, and eBay. MS in Computer Science at Georgia Tech.
Table of Contents
Claude Code calls a custom statusline command every turn with a JSON payload on stdin. The payload includes the current context-window fill percentage, model, cost, and cwd. Nothing in the contract says you can only read it — you can fork it to anything you want, and the command stays a statusline.

(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 1 caps 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>&1 discards 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 1 on 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
#

Related

I polled an undocumented endpoint for 18 hours. The data was on stdin.

My daemon logged 111 consecutive HTTP 429s against `https://api.anthropic.com/api/oauth/usage` over an 18-hour stretch, with zero successful responses ever in its lifetime. The poller was reading `Retry-After: 272` and ignoring it. While I was arguing with the backoff, Claude Code was pushing the same `rate_limits.five_hour` and `rate_limits.seven_day` numbers to my statusline command every turn, on stdin, for free. (Quick framing: Claude Code is Anthropic’s terminal CLI for Claude; Claude Max is the higher-tier subscription plan with weekly and 5-hour usage windows. HTTP 429 is “Too Many Requests” — the server’s polite way of saying “back off.” Retry-After is the response header that tells the client how long to wait. OAuth is the auth protocol Claude Code uses to talk to Anthropic on behalf of a logged-in user. And the statusline — the same one I covered in the statusline side-channel post — is the script Claude Code spawns every turn with a JSON blob on stdin.)

Holding HTTP open for 590 seconds so a Stream Deck key can approve a tool call

Claude Code wants to run a shell command. I want to press a physical Stream Deck key — the YES key, two inches to the left of my keyboard — to approve it. The hook gets exactly one HTTP response to decide allow vs deny. The key press might land in 200 milliseconds; it might land seven minutes later, after I've been pulled into a meeting and come back. The trick is that Claude Code's hook timeout is 600 seconds, which turns out to be just enough headroom to hold the HTTP response open the whole time and let a hardware button write the answer. (Setup, for anyone who hasn’t seen this stack before: Claude Code is Anthropic’s terminal CLI for Claude, and one of its hook events — PreToolUse — is a script Claude spawns and waits on before running a tool like Bash or Edit. The script’s stdout decides “allow” / “deny” / “ask”. Stream Deck is Elgato’s USB grid of programmable LCD keys. The plumbing I’m describing here lives in a daemon — a background process at 127.0.0.1:9127 — that the hook script POSTs to and that the Stream Deck plugin connects to over WebSocket. For the hooks docs themselves and the four other gotchas in that layer, see the hooks-reality post.)

I split my daemon in two so a Node subprocess could own the PTY

I built a Claude Code permission gate that holds an HTTP response open until a Stream Deck key is pressed. Then I needed to inject a keystroke into Claude Code's own TTY so a key press could write `1\r` straight into Claude's stdin. Bun can hold HTTP open all day. Bun cannot reliably wrap a child PTY through `node-pty` and capture the parent shell's PID. So I split my daemon: HTTP and WebSocket stay on Bun, and a Node CommonJS subprocess owns the PTY that runs Claude. (Quick grounding before the story: a PTY — pseudo-terminal — is the kernel object every interactive shell talks to. It’s a pair of file descriptors, master and slave; the program reads/writes the slave end as if it were a real terminal, and anything you write to the master end looks to that program like a human typing. The TTY is the slave end seen from the child’s side. node-pty is Microsoft’s library that gives a JavaScript parent process a writable handle to the master. Bun is a JavaScript runtime — Node’s faster sibling — and Node CommonJS is plain old require()-based Node, no transpile step. The story below is about which runtime owns the PTY.)