Tadashi Shigeoka· Sun, November 8, 2026
Leaving a Claude Code agent session running while I stepped away to another task sometimes ate through the rest of my 5-hour or 7-day quota before I got back. If I were watching, closing claude at 90% would have been enough, but that only works when I’m at the terminal. What I wanted was for the session to stop itself the moment usage crossed a threshold, so that the remaining slice is still available for a short manual fix or a quick check.
I rolled that behavior into two shell scripts and one state file and published cc-usage-guard. The initial PR is codenote-net/cc-usage-guard#1 and the release is v0.1.0. This post walks through the one constraint that shaped the design (that rate_limits is only exposed to the status line command) and the decisions that fell out of it. The tool is unofficial, not affiliated with Anthropic, and targets Claude.ai Pro, Max, and Ultra plans.
What it does #
Once installed, the status line shows the subscription usage after the first response in a claude session, next to anything else that was there before:
If a tool call or a prompt submission happens while any monitored window is at or above the threshold (90% by default), the hook stops the session:
The reset time is included in the message so you can decide when to come back. The threshold is configurable through CLAUDE_USAGE_GUARD_THRESHOLD or the threshold key in config.json, and the monitored windows ( five_hour, seven_day, spend_limit) are selected through windows.
rate_limits is only exposed to the status line #
Reading the Claude Code hooks documentation first, I noticed that the JSON delivered to PreToolUse or UserPromptSubmit does not contain rate_limits. The status line command, which has carried rate_limits since Claude Code 2.1.80, is the one surface that does: on every refresh it receives a JSON payload with fields such as rate_limits.five_hour.used_percentage.
That asymmetry set the architecture. Usage can only be observed from the status line, so one process has to record the state and a different process has to consult it and stop the session. cc-usage-guard is organized exactly that way:
flowchart TD
TUI[Claude Code TUI] -->|status line JSON<br/>includes rate_limits| SL[statusline.sh]
SL -->|atomic write| STATE[(~/.claude/usage-guard/usage.json)]
SL --> BAR[Status bar<br/>5h:xx% 7d:xx%]
TUI -->|PreToolUse / UserPromptSubmit<br/>no rate_limits| HK[guard-hook.sh]
STATE -->|read with jq| HK
HK -->|below threshold| GO[Continue]
HK -->|threshold reached| STOP["continue: false<br/>stopReason: ..."]
The status line is the writer, the hook is the reader, and the only thing they share is ~/.claude/usage-guard/usage.json. Claude Code invokes them independently, so no direct IPC is needed between them.
statusline.sh persists state and passes through the previous status line #
statusline.sh reads the status line JSON on stdin, extracts rate_limits with jq, writes a state document to disk, and prints a short summary to stdout. A single jq call emits both lines, and bash splits them on the newline afterwards:
The write to disk is an atomic update via mv. The script writes to $STATE_FILE.tmp.$$ first and then swaps it in with mv -f, so guard-hook.sh can never read a half-written JSON document:
If another status line command was already configured when install.sh ran, the installer moves that value into config.json under passthrough. At runtime, statusline.sh runs that command through bash -c with the same stdin and appends only the usage figures to its output. The original status line is preserved, with one extra segment on the end.
guard-hook.sh decides with one jq call and fails open #
guard-hook.sh is registered as both a PreToolUse and a UserPromptSubmit hook. Following the hooks documentation, it drains the event JSON on stdin, reads ~/.claude/usage-guard/usage.json, and writes {"continue": false, "stopReason": "..."} to stdout (exiting 0) when any monitored window is at or above the threshold.
The decision is written as a single jq invocation. PreToolUse runs before every tool call, so starting jq multiple times for each decision shows up as latency. Keeping the logic in one jq pipeline also collapses the branching into one place:
The || exit 0 and the early exit 0 guards for each precondition give the hook its fail-open contract. guard-hook.sh never stops the session in any of these cases:
- The state file does not exist (fresh install, desktop usage, or sessions limited to
claude -p). - The state file is older than
max_age_seconds(3600 by default). jqis missing, or jq returns an error.- The input JSON is shaped in a way the hook cannot parse.
If the hook were fail-closed (stop when in doubt), a delayed status line write would turn into a stopped session, which is exactly the opposite of the tool’s job. Keeping it fail-open narrows the responsibility to “stop only when there is fresh data and that data shows high usage.” max_age_seconds is there instead of a stale fallback: if the machine was idle for a while, we would rather let the session proceed than block it based on an old high reading.
Which windows arrive depends on the plan #
While building it, I set CLAUDE_USAGE_GUARD_DEBUG=1 on an Ultra account and looked at the raw status line JSON. Ultra does not emit rate_limits.five_hour at all, only seven_day. The documentation only says that each window may be independently absent and does not describe the per-plan difference, but Pro, Max, and Ultra really do behave differently. The README’s Known limitations section summarizes it like this:
| Plan | Windows sent |
|---|---|
| Pro / Max | five_hour andseven_day |
| Ultra | seven_day only |
| Team / Enterprise | rate_limits is not sent (unsupported) |
| Through a Claude apps gateway | spend_limit (other windows depend on the setup) |
That is why the default windows is ["five_hour", "seven_day"] and spend_limit is opt-in. statusline.sh drops any window that is null from the summary, and guard-hook.sh checks type == "number" before comparing against the threshold, so Ultra accounts without five_hour are not misread as being at 0% or non-numeric.
The installer merges with jq and stays safe on re-run #
Claude Code settings live in ~/.claude/settings.json. To keep from clobbering an existing statusLine or hooks configuration, install.sh merges with jq.
- If
statusLine.commandis already set to something else, the existing value is moved intoconfig.jsonaspassthroughandstatusline.shtakes over the registered command. - If the registered command is already
statusline.sh,passthroughis left alone. PreToolUseandUserPromptSubmitentries that already point atguard-hook.share not duplicated.
The jq side pulls that into two helpers, has_hook and add_hook:
That is also where the shape difference between the two hook events is absorbed: PreToolUse accepts a matcher to narrow the tools (the installer sets "*" explicitly), while UserPromptSubmit has no matcher support and silently ignores one. The write goes through settings.json.tmp.$$ and mv -f, so a Claude Code process reading the file cannot catch it mid-swap.
uninstall.sh reverses the same operation. It removes only entries whose command matches guard-hook.sh and only rewrites statusLine when the current command is usage-guard/statusline.sh. It does not touch hooks or status lines added by anyone else. An install → uninstall round-trip on an existing settings.json produces the same document byte-for-byte after canonicalizing with jq -S.
Two layers: config.json and environment variables #
Settings are stored in ~/.claude/usage-guard/config.json, and environment variables override the file. The intent is: use the file for persistent settings, the environment for a one-off raise.
| Key | Default | Environment variable | Meaning |
|---|---|---|---|
threshold |
90 |
CLAUDE_USAGE_GUARD_THRESHOLD |
Stop when any monitored window is at or above this percentage. |
max_age_seconds |
3600 |
CLAUDE_USAGE_GUARD_MAX_AGE |
Ignore state older than this many seconds. |
windows |
["five_hour","seven_day"] |
CLAUDE_USAGE_GUARD_WINDOWS |
Monitored windows. |
passthrough |
null |
none | Your previous status line command. |
For example, to raise the threshold just for the current session:
I wanted the ability to declare “use this session up to 100%” when I was already in a crunch without editing the config file, while still keeping a conservative 90% as the resting state.
CI assumes macOS /bin/bash 3.2 #
macOS still ships /bin/bash at 3.2.57, which lacks associative arrays, local -n namerefs, and [[ -v var ]]. It is easy to write code that works locally on bash 5.x and breaks on macos-latest in CI. The GitHub Actions workflow runs ShellCheck on ubuntu-latest and bats-core on both ubuntu-latest and macos-latest, invoking the scripts through macOS’s /bin/bash explicitly. The 61 bats tests run against a throwaway HOME so they are fully isolated from the real install location and cover install, uninstall, statusline, and hook behavior.
While writing the scripts, I kept reaching for local -n or [[ -v var ]] out of habit; those got rewritten into POSIX parameter expansion forms like ${var:-default} and ${var%%pat*} under set -u, which stays in scope for bash 3.2.
Out of scope for v0.1.0 #
A few things are intentionally not in v0.1.0:
- Team / Enterprise plans. Claude Code does not send
rate_limitson those plans, so the same path cannot be implemented. If another route to the data opens up, this can be revisited. - Non-interactive coverage.
claude -pand the desktop app do not drive the status line, so the state file is not updated during those runs.max_age_secondsmakes sure stale data does not cause false stops. - Interrupting a streaming response. The hook runs before a tool call or when a prompt is submitted. It cannot stop a single model response that is already streaming.
- A background refresher. During idle sessions the status line is not refreshed, so there can be a gap in the recorded usage.
refreshIntervalonstatusLinecan re-run the command, but therate_limitsvalues do not change without an API response. This would need Claude Code to exposerate_limitsthrough another route, or a separate API path. - Windows, Homebrew, and npm. v0.1.0 targets a
bash+jqsetup and installs throughcurl | bashon macOS and Linux first.
Some of these shrink or disappear if Claude Code grows an abstraction in the right place. Issues reporting that would be welcome.
Wrapping up #
Accepting that rate_limits is only exposed to the status line pushes the design toward a pipeline: statusline.sh records the data, a state file carries it, and guard-hook.sh reads it on every tool call and every prompt. cc-usage-guard v0.1.0 holds that together with three small commitments: the status line write is atomic, the hook decides in one jq call, and the installer moves any existing status line into passthrough instead of overwriting it. Fail-open and max_age_seconds keep the hook from overreacting to stale data, which lets the responsibility stay narrow: stop the session only when fresh data shows usage past the threshold.
If you use Claude Code 2.1.80 or later on a Pro, Max, or Ultra Claude.ai subscription, you can try it with the command below. install.sh downloads the scripts from the ref in CLAUDE_USAGE_GUARD_REF (default main), so the environment variable pins them to v0.1.0 as well:
The uninstaller at bash ~/.claude/usage-guard/uninstall.sh reverses the hooks and status line that the installer added and deletes ~/.claude/usage-guard/.
That’s all from publishing cc-usage-guard v0.1.0, a pair of shell scripts that record Claude Code’s rate_limits from the status line and stop the session from a PreToolUse / UserPromptSubmit hook when a threshold is reached, from the Gemba.