# I Built cc-usage-guard — Recording rate_limits from the Claude Code Status Line and Stopping the Session at a Threshold with a PreToolUse Hook

> Source: <https://codenote.net/en/posts/cc-usage-guard-claude-code-rate-limits-stop-hook/>
> Published: 2026-11-08 14:52:58+00:00

# I Built cc-usage-guard — Recording rate_limits from the Claude Code Status Line and Stopping the Session at a Threshold with a PreToolUse Hook

[Tadashi Shigeoka](https://codenote.net/en/author/tadashi-shigeoka/)· Sun, November 8, 2026

Leaving a [Claude Code](https://code.claude.com/docs/en/overview) 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](https://github.com/codenote-net/cc-usage-guard). The initial PR is [codenote-net/cc-usage-guard#1](https://github.com/codenote-net/cc-usage-guard/pull/1) and the release is [v0.1.0](https://github.com/codenote-net/cc-usage-guard/releases/tag/v0.1.0). This post walks through the one constraint that shaped the design (that [rate_limits](https://code.claude.com/docs/en/statusline) 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](https://claude.com/product/overview) 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](https://code.claude.com/docs/en/hooks) first, I noticed that the JSON delivered to `PreToolUse` or `UserPromptSubmit` does not contain `rate_limits`. The [status line command](https://code.claude.com/docs/en/statusline), 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:

``` php
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](https://jqlang.org/), 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`](https://man7.org/linux/man-pages/man1/mv.1.html). 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](https://code.claude.com/docs/en/hooks), 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).
- `jq` is 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` and`seven_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.command` is already set to something else, the existing value is moved into`config.json` as`passthrough` and`statusline.sh` takes over the registered command.
- If the registered command is already `statusline.sh` ,`passthrough` is left alone.
- `PreToolUse` and`UserPromptSubmit` entries that already point at`guard-hook.sh` are 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](https://github.com/codenote-net/cc-usage-guard/blob/v0.1.0/.github/workflows/ci.yml) runs [ShellCheck](https://www.shellcheck.net/) on ubuntu-latest and [bats-core](https://github.com/bats-core/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](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_06_02) 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_limits` on 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 -p` and the desktop app do not drive the status line, so the state file is not updated during those runs.`max_age_seconds` makes 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.`refreshInterval` on`statusLine` can re-run the command, but the`rate_limits` values do not change without an API response. This would need Claude Code to expose`rate_limits` through another route, or a separate API path.
- **Windows, Homebrew, and npm.** v0.1.0 targets a`bash` +`jq` setup and installs through`curl | bash` on 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](https://claude.com/product/overview) 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.
