anthropic-account
Tier 0. No titan dependencies. zod is a peer dependency.
npm install @titan-design/anthropic-account zodStatus: 0.3. The root entry is pure code with no fs, process or network access, and a test fails if it gains any. File and network access live on the ./node subpath, and the network call goes through a fetch the caller passes in.
The problem it solves
The status line, agent-chat's spawn gate, the pace pass and session-read each read Claude Code account state their own way: which config dirs are accounts, whether a login is live, and how much of each rate-limit window is used. Each copy also sits close to an OAuth token. This package holds the shapes once, and keeps the token out of every one:
UsageReading, the status-line document, withparseUsageReading.usageFromOAuthResponse, which maps the OAuth usage endpoint's response into aUsageReading.LoginState, which says whether a login is present, expired, missing or refused, and when it expires. It has no token field.loginStateFromCredentialsandneedsRefresh.accountLabel, which names an account from its config dir.redactSecrets, which scrubs token-shaped text from a string or an Error.
When to reach for it
Use it to type or validate a usage reading, to turn a response body from GET https://api.anthropic.com/api/oauth/usage into one, to report a login's state from a .credentials.json object you have already read and parsed, or to scrub a message before it is logged. For the usage and cost of one agent run use agent-protocol.
Example
import {
accountLabel,
loginStateFromCredentials,
needsRefresh,
redactSecrets,
usageFromOAuthResponse,
} from "@titan-design/anthropic-account";
const login = loginStateFromCredentials(parsedCredentials, Date.now());
if (login.status === "present" && !needsRefresh(login, Date.now(), 5 * 60_000)) {
const reading = usageFromOAuthResponse(responseBody, {
writtenAt: Math.floor(Date.now() / 1000),
account: accountLabel(configDir),
});
}
logger.warn(redactSecrets(error).message);Usage readings
A UsageReading is the document Claude Code's status line writes per session. Times are epoch seconds and percentages run from 0 to 100.
{
"session_id": "usage-poll",
"written_at": 1791460800,
"rate_limits": {
"five_hour": { "used_percentage": 23, "resets_at": 1791478800 },
"seven_day": { "used_percentage": 41.5, "resets_at": 1791795600 }
},
"source": "oauth-usage",
"account": "agents"
}rate_limits is an open map, so a new window name parses without a release. parseUsageReading(input) returns the reading or null, and drops keys it does not know.
usageFromOAuthResponse(body, { writtenAt, account? }) takes the parsed response body. Each top-level key whose value is { utilization: number, resets_at: string | null } becomes a window: utilization becomes used_percentage, and the ISO resets_at becomes epoch seconds. A null window, a non-window key such as extra_usage, and any key that is not short snake case are dropped. A reset time that does not parse as a date is omitted. The reading's session_id is usage-poll and its source is oauth-usage, so a reader that takes the newest written_at picks it up beside the status line's own readings. It returns null when no window survives, as for an error body.
Login state
type LoginState =
| { status: "present"; expiresAt: number; canRefresh: boolean; subscriptionType?: string; rateLimitTier?: string }
| { status: "expired"; expiresAt: number; canRefresh: boolean }
| { status: "missing" }
| { status: "refused"; reason: "malformed" | "mode-too-wide" | "foreign-owner" | "not-a-regular-file" | "hard-linked" };loginStateFromCredentials(credentials, now) takes the parsed .credentials.json object and the current time in epoch milliseconds. expiresAt is the access token's expiry in epoch milliseconds. canRefresh is true when a refresh token is present and not past its own expiry. It returns:
| status | when |
|---|---|
present | claudeAiOauth has an access token that expires after now |
expired | the access token expires at or before now |
missing | the input, or its claudeAiOauth block, is absent or null |
refused | malformed: the block has no access token, or an expiry outside epoch milliseconds from 1e12 to 1e14, so a seconds or microseconds value is refused |
mode-too-wide, foreign-owner, not-a-regular-file and hard-linked come only from the ./node reader, which refuses a credentials file before reading it.
needsRefresh(state, now, marginMs) is true when the access token expires within marginMs of now, and always true for an expired state, whatever now is passed. It is false for missing and refused, which need a login, not a refresh. It throws a RangeError for a negative or non-finite margin or a non-finite now.
Credential safety
- No
LoginStatehas a field that can hold a token. Token fields are checked for presence and type, and their values are never copied. subscriptionTypeandrateLimitTierare copied only when they look like a short label thatredactSecretsleaves unchanged.- No input makes
loginStateFromCredentialsthrow. An exception from the input, such as a getter whose message carries a token, becomesrefusedwith reasonmalformed. Only a non-finitenow, a caller bug, throws aRangeErrorwith a fixed message. - The tests use synthetic tokens that carry a canary, and assert the canary is absent from every returned value, its
JSON.stringify, every error message and stack, and every redacted Error.
redactSecrets(text) returns the string with each match below replaced by [REDACTED]. redactSecrets(error) returns a new Error with the redacted message, name and stack, and no cause. The original is left untouched. Redaction never throws: if the original's message cannot be read, or is not a string, the result is new Error("[REDACTED]").
usageFromOAuthResponse never throws either; a body whose getter throws gives null.
| pattern | catches |
|---|---|
sk-ant-… | OAuth access and refresh tokens and API keys |
eyJ….…(.…) | JWTs |
Bearer …, Basic … | an Authorization header value, quoted or not, of any length |
access_token, refresh_token, id_token, token, client_secret, password, authorization, api_key, x-api-key, in any case and with or without the underscore, followed by : or = | the value of a secret field in JSON, a query string or a header, of any length |
| 32 or more base64url characters in a row | any other long opaque token |
The last pattern over-redacts on purpose: a git SHA or a long hyphenated name is redacted too. It does not catch an unlabelled opaque value that +, / or . breaks into runs shorter than 32 characters, so redaction backs up the rule that a token is never put in a message; it does not replace it.
The ./node subpath
import { discoverProfiles, readLoginState, readUsage, writeReading } from "@titan-design/anthropic-account/node";
for (const { label, configDir } of discoverProfiles()) {
const login = readLoginState(configDir);
const usage = readUsage(configDir);
console.log(label, login.status, usage?.ageSeconds);
}discoverProfiles({ home?, env? }) returns AccountProfiles: <home>/.claude first, then each directory under <home>/.claude-profiles in name order, each labelled by accountLabel. A non-empty CLAUDE_PROFILE_ROOT in env (PROFILE_ROOT_ENV) names another dir to scan in place of <home>/.claude-profiles, as rate-limits.sh does. A directory that does not exist is left out, and so is a symlink, at either level. A non-empty CLAUDE_CONFIG_DIRS in env replaces the scan: its entries, split on the path delimiter, are taken as given. home defaults to os.homedir() and env to process.env.
readLoginState(configDir, { now?, uid? }) reads <configDir>/.credentials.json and returns loginStateFromCredentials's LoginState. It never writes, chmods or refreshes. Before it reads a byte it:
lstats the path: absent givesmissing, and a symlink or anything but a regular file givesrefusedwithnot-a-regular-file.- Opens it read-only with
O_NOFOLLOWandO_NONBLOCK, so a symlink swapped in after thelstatfails the open (not-a-regular-file) and a FIFO cannot hang it. fstats the open descriptor and refuses unless it is a regular file with thelstat's device and inode (not-a-regular-file), owned byuid(foreign-owner), with no group or other permission bits (mode-too-wide), with exactly one link (hard-linked), and at most 64 KiB (malformed).
A second hard link is refused because the file's other name may sit outside the config dir, under rules this check cannot see. The read and the checks use the same descriptor, so the file checked is the file read. The read stops at 64 KiB plus one byte. The buffer is zeroed after parsing, and also when a read fails partway through. Invalid JSON is malformed, and the parser's message, which quotes the input, is dropped. uid defaults to the process's uid; on a platform without one every file is refused as foreign-owner. An unexpected filesystem error is thrown through redactSecrets, with no cause.
writeReading(configDir, reading) writes <configDir>/status-cache/sessions/usage-poll.json and returns its path. It throws a TypeError with a fixed message, and writes nothing, unless the reading passes parseUsageReading with session_id usage-poll and its JSON is unchanged by redactSecrets. Only the fields parseUsageReading keeps are written. The write is atomic:
- Creates the sessions dir, mode 0700, if needed.
- Creates
.usage-poll.json.<pid>.<random>.tmpin the same dir, exclusively, mode 0600. - Writes the JSON,
fsyncs, and closes. - Renames it over
usage-poll.json, thenfsyncs the dir, best effort.
A reader sees the old reading or the new one, never part of one. The temp name does not end in .json, so a reader that globs the dir never takes it. On a failure the temp file is removed and the error is thrown through redactSecrets. agent-chat's status-line budget reader accepts the file as written; a test restates its rules.
readUsage(configDir, { now?, windows? }) returns { reading, file, ageSeconds } for the reading with the newest written_at across every .json file in the sessions dir, the poller's and each status-line session's, or null. With windows, only a reading that carries every named window counts, so a newer reading that lacks one falls back to an older one that has it. Each file goes through the same lstat, O_NOFOLLOW open and fstat steps as the credentials file, without the owner, mode and link rules, so a symlink or FIFO swapped in mid-read is refused and never blocks. A file is skipped when it is not a regular file, is larger than 256 KiB (checked on the read itself, so growth after the fstat counts), is invalid, or has no rate-limit window. Its session_id, source and account pass through redactSecrets. ageSeconds is never negative.
Polling the usage endpoint
import { pollAll, pollUsage } from "@titan-design/anthropic-account/node";
const result = await pollUsage({ label: "agents", configDir }, { fetch });
if (!result.ok) console.error(`agents: ${result.failure}`);
for (const { label, result, writeError } of await pollAll({ fetch })) {
console.error(`${label}: ${result.ok ? "ok" : result.failure}${writeError ? " (write failed)" : ""}`);
}pollUsage(profile, { fetch?, now?, uid?, timeoutMs? }) reads the access token from <configDir>/.credentials.json through the same gate as readLoginState, sends one request, and resolves to { ok: true, reading } or { ok: false, failure }:
| failure | when | request sent |
|---|---|---|
missing | no credentials file, or no claudeAiOauth block | no |
refused | the gate refused the file, or the block or its token is malformed; reason says which | no |
expired | the token expires within 60 s of now (EXPIRY_MARGIN_MS), or already has | no |
io | an unexpected filesystem error | no |
http-<status> | any status outside 2xx, a 3xx included | yes |
network | the fetch rejected or timed out, the body stalled past the timeout, or the response came from a followed redirect | yes |
malformed | a 2xx body over 64 KiB, not JSON, with a known window in the wrong shape, or with no known window | yes |
The request is GET https://api.anthropic.com/api/oauth/usage with three headers: authorization: Bearer <token>, anthropic-beta: oauth-2025-04-20 (Claude Code's own value) and accept: application/json. It sets redirect: "error" and an AbortSignal.timeout of timeoutMs (default 10 s) that also bounds the body read. It is never retried. The token must be an RFC 6750 b64token of at most 4096 characters, so it cannot split a header; anything else is refused as malformed before a request. now defaults to Date.now(), sets written_at, and decides expiry; uid is as for readLoginState. fetch defaults to globalThis.fetch. Whatever fetch is passed receives the raw access token in the authorization header, so pass only one that does not log, forward or persist request headers.
A 2xx body is parsed against an allowlist of window keys, five_hour, seven_day, seven_day_opus, seven_day_sonnet and seven_day_oauth_apps, each { utilization: number, resets_at: string | null } with resets_at at most 64 characters. Every other key is dropped unread, a known window in another shape makes the whole reading malformed, and the rest goes through usageFromOAuthResponse. The reading's account is the profile's label, left out when redactSecrets would change it. A non-2xx body is cancelled without being read. It throws only a RangeError, with a fixed message, for a non-finite now or a timeoutMs that is not a positive number.
pollAll({ fetch?, now?, uid?, timeoutMs?, profiles?, discover? }) polls every profile at once, profiles or else discoverProfiles(discover), and writes each reading with writeReading. It resolves to one { label, result, file?, writeError? } per profile, in order. file is the written path; writeError is a failed write, through redactSecrets. One profile's failure never stops another.
A http-429 makes pollAll back off that profile: it writes <configDir>/status-cache/usage-poll.backoff (outside the sessions dir, and not .json, so no reading glob takes it) and sets backoffUntil, in epoch seconds, on the entry. The first wait is 5 minutes, and each further 429 doubles it, up to an hour. Until it runs out the profile's entry is { ok: false, failure: "backoff" } with backoffUntil, and no request is sent. A success removes the file. The file is read through the same gate as the credentials file. A backoff file that cannot be read, or that waits longer than an hour from now, counts as none, and one that cannot be written is skipped, so a broken or planted file never stops polling. pollUsage itself keeps no state and never backs off.
Refreshing an access token
import { refreshIfNeeded } from "@titan-design/anthropic-account/node";
import { writeDeposit } from "@titan-design/owner-queue/spool";
const result = await refreshIfNeeded(
{ label: "agents", configDir },
{ fetch, marginMs: 10 * 60_000, onFailure: (deposit) => writeDeposit(spoolDir, deposit) },
);refreshIfNeeded(profile, { fetch, marginMs, now?, uid?, timeoutMs?, onFailure? }) renews the profile's OAuth access token when needsRefresh(state, now, marginMs) says it is due, and writes the new credentials back. It is the one place in the package that writes a credentials file, and running Claude Code sessions read that file, so it follows Claude Code's own refresh closely:
- Reads
.credentials.jsonthrough thereadLoginStategate. Nothing happens unless the token is due and the login has a usable refresh token. It also checks thatJSON.stringifyreproduces the file's exact bytes, compact or indented by 2, 4 or a tab. A layout it cannot reproduce fails asunrecognized-formatbefore any request, because a rotated refresh token that cannot be stored logs the profile out. - Takes Claude Code's two refresh locks,
<configDir>/.oauth_refresh.lockand<realpath(configDir)>.lock. Each is a directory made withmkdir, the schemeproper-lockfileuses. A lock that is already held is never stolen; the result islocked, and the next poll tries again. Claude Code treats a lock older than 60 s as stale, sotimeoutMsis capped at 30 s (MAX_REFRESH_TIMEOUT_MS, default 10 s). - Re-reads the file through the gate. If it changed, another session refreshed it, and the result is
refreshed-elsewhere. - Sends
POST https://platform.claude.com/v1/oauth/token,content-type: application/json, with body{ grant_type: "refresh_token", refresh_token, client_id, scope }. The values are Claude Code 2.1.x's own.client_idis the storedclientId, or else Claude Code's (OAUTH_CLIENT_ID).scopeis space-separated: the stored scopes when aclientIdis stored (the defaults if it stores none), and otherwiseDEFAULT_REFRESH_SCOPESplus anyuser:projects:*scope already held. It setsredirect: "error"and is never retried. Only a 200 counts. Its body must hold anaccess_tokenand an integerexpires_in, and may hold arefresh_tokenandrefresh_token_expires_in; each token must be an RFC 6750b64token. - Writes the new JSON to
.credentials.json.<pid>.<random>.tmpin the config dir, exclusively, mode 0600, thenfsyncs it. - Re-reads the credentials file through the gate once more. If its bytes changed while the request was in flight, the temp file is removed and the login is compared, not the bytes. The old refresh token is spent by now, and a rotated one in the response may be the only live one, so the new tokens are not dropped for an unrelated change:
- The file still holds the refresh token just spent, for example because a session wrote another key. The new tokens are applied to that writer's file, and steps 5 to 7 run again.
- The file is unreadable or half-written. The refresher waits 100 ms and reads it again.
- The file holds a different refresh token (a re-login, or a refresh elsewhere), or it is gone (a logout). That writer's file stands, and the result is
refreshed-elsewhere. - After four attempts the result is
failedwithwrite-conflict, which files a deposit, and the file is left as the other writer left it.
- Renames the temp file over
.credentials.json, thenfsyncs the dir, best effort. If the write or the rename throws, the result isfailedwithwrite-failedwhen the response rotated the refresh token, since the stored one no longer works, and withiowhen it did not.
The file is never truncated in place, so a session reading it sees the old file or the new one. Only accessToken and expiresAt (now plus expires_in) change. So do refreshToken and refreshTokenExpiresAt when the response rotates them. Every other key keeps its bytes and its position. Locks are released on every path, and only a lock directory this call made is removed.
While it holds the locks, the refresher keeps a holder record, .oauth_refresh.lock.anthropic-account, beside them in the config dir: its pid and the inode and ctime of each lock dir it made. The record sits outside the lock dir because Claude Code removes a stale lock with rmdir. If a crash leaves the locks behind, the next call finds them held, sees the recorded pid has exited, removes only the dirs whose inode and ctime still match, and takes the locks again. A lock with no record, or one whose identity differs, is Claude Code's and is left alone.
The record is written with O_CREAT | O_EXCL | O_NOFOLLOW after any stale record is unlinked, so a symlink planted at its path is removed, never written through. It is read through the same gate as the credentials file: a record that is not this uid's own regular, owner-only, singly linked file, or that is a FIFO, names no lock, and nothing is reclaimed. A release whose record removal throws still frees the lock dirs, and the result already reached, such as write-failed, stands.
| result | when | deposit |
|---|---|---|
fresh | the token is not due | no |
refreshed, with expiresAt | the new credentials were written | no |
refreshed-elsewhere | the file changed before the request, or another login or a logout replaced it after | no |
locked | a refresh lock is held | no |
skipped, with reason | the file is missing or refused by the gate, or the login is one Claude Code would not refresh: neither the user:inference scope nor a subscriptionType | no |
failed, with failure and deposited | login-required (no usable refresh token), unrecognized-format, write-conflict, write-failed, http-<status>, network, malformed or io | yes |
Each failure calls onFailure once with a RefreshFailureDeposit. That is an owner-queue OwnerItemDeposit: asker anthropic-account, kind do, a one-line summary naming the profile label and the failure kind, and a fixed context line. A failure that needs a new login (login-required, http-400, http-401, http-403, unrecognized-format, write-conflict, write-failed) has the depositIdtoken-refresh-<label>-relogin-<UTC day>. Any other failure, which a later poll may clear, has token-refresh-<label>-<UTC day>. The spool keeps the first deposit for each id, so a retry deposit filed earlier in the day never hides a log-in-again one, and a poller that retries files at most two items per profile per day. A label that is not a short plain name, or that redactSecrets would change, is written unlabelled. The package is tier 0 and owner-queue is tier 2, so the caller wires writeDeposit. A deposit that throws leaves deposited: false, and the result is otherwise unchanged.
Like pollUsage, it never logs, and never throws for anything the file, the lock or the server does. An error body is cancelled unread, a fetch rejection or filesystem error is dropped with its message, and only a bad now, marginMs or timeoutMs throws a RangeError with a fixed message.
The anthropic-account bin
anthropic-account poll [--write [--refresh]]
anthropic-account status [--json | --statusline]Both commands work on discoverProfiles(): ~/.claude, each dir under ~/.claude-profiles (or CLAUDE_PROFILE_ROOT), or CLAUDE_CONFIG_DIRS when it is set.
pollrunspollUsagefor every profile and prints one line each, such asagents: five_hour 23%, seven_day 41.5%. With--writeit runspollAll, which stores each reading asusage-poll.json. A profile with no login (missing), such as a dir made before its first login, prints<label>: no login, skippedand counts as success;statusstill reports it. With--write, a profile backing off after a 429 prints<label>: rate limited, skipped until <ISO time>and counts as success. The 429 itself is a failure.--refresh, which needs--write, first runsrefreshIfNeededfor every profile with a 10-minute margin, then polls with the renewed token. It writes<config dir>/.credentials.jsonwhenever a token is due, and the server may rotate the refresh token, so leave it off for a poller that only reads credentials. A refreshed profile prints<label>: token refreshed, and a failed refresh printsanthropic-account: <label>: token refresh failed: <failure>to stderr. The bin passes noonFailure, so no owner-queue deposit is filed: this package is tier 0 and owner-queue is tier 2. A tier-2 or product caller wireswriteDeposit.statusreads local files only, with no request. It prints each profile's login state and newest reading with its age.--jsonprints{ profiles: [{ label, configDir, login, usage }] }, whereloginis the token-freeLoginState(or{ status: "unreadable" }) andusageis{ written_at, age_seconds, rate_limits }ornull.status --statuslineprints the lines~/.claude/scripts/rate-limits.shprints: first5h|weekly|5h_reset|weekly_reset|||||age_sfor the account inCLAUDE_CONFIG_DIR(or~/.claude), orunknown|unknown|||||||, thenother|<label>|5h|weekly|age_s|for each other profile whose reading has both windows. Likerate-limits.sh, each line uses the newest reading that has bothfive_hourandseven_day, falling back past a newer one that lacks either. Percentages are floored. It always exits 0 and writes no stderr.
| exit | when |
|---|---|
| 0 | every profile succeeded |
| 1 | a poll, a refresh, a usage-file write or a credentials read failed |
| 2 | a login is expired or refused, or for status missing; this wins over 1 |
| 64 | an unknown command or flag, a repeated flag, or --refresh without --write |
Each account whose login is not present gets one stderr line, anthropic-account: <label>: login <missing|expired|refused (<reason>)>. Every stderr line is a fixed template of the label, a state and a failure kind. A label that is not a short plain name, or that redactSecrets would change, prints as unlabelled. Arguments, file contents, response bodies and error messages are never printed.
Installing the poll timer
systemd/ ships three units. The package installs and enables nothing.
anthropic-account-poll.service, a oneshot running%h/.local/bin/anthropic-account poll --write --refresh, withNoNewPrivilegesandUMask=0077. It also setsProtectSystem=strictwith only~/.claudeand~/.claude-profilesinReadWritePaths. Those dirs hold the credentials file, the refresh lock and the status cache.The sandbox is best effort. A systemd user manager can apply
ProtectSystem=only where unprivileged user namespaces are allowed. On a host that restricts them (Ubuntu withkernel.apparmor_restrict_unprivileged_userns=1, for one), the directive does nothing. The run then shares the caller's mount namespace,/stays read-write, and so does the home dir;PrivateUsers=yesdoes not change that. AnExecStartPrecheck sees the home dir writable and logsProtectSystem is not in effect on this hostat warning priority on each such run. The protection that holds on every host is the CLI's own:- Every credentials and lock-record read goes through the gate above:
O_NOFOLLOW, the caller's uid, 0600 or narrower, one link. - Every write is an exclusive 0600 temp file renamed into place.
- Refreshes run only under Claude Code's refresh lock.
Where the sandbox does apply, the legacy lock beside
~/.claude(~/.claude.lock) is read-only and is skipped as unusable, as Claude Code skips it. The primary lock inside the config dir still serializes every refresh. SettingCLAUDE_PROFILE_ROOTorCLAUDE_CONFIG_DIRSfor the unit means adding those dirs toReadWritePaths.- Every credentials and lock-record read goes through the gate above:
anthropic-account-poll.timer, which starts the service 10 s after the timer starts and every 150 s after that, with up to 30 s ofRandomizedDelaySec.anthropic-account-poll-failed@.service, the service'sOnFailure=target. It logs an error-priority journal line (journalctl --user -p err) and touches~/.local/state/anthropic-account/anthropic-account-poll.service.failed. The poll service removes that file after its next successful run, so the file names only an unrecovered failure, and its mtime says when the latest failure happened.
The host operator runs:
npm install -g --prefix ~/.local @titan-design/anthropic-account
mkdir -p ~/.config/systemd/user
cp "$(npm root -g --prefix ~/.local)/@titan-design/anthropic-account/systemd/"anthropic-account-poll* ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now anthropic-account-poll.timerBecause the service passes --refresh, the timer writes credentials files. Remove the flag from ExecStart before enabling to keep it read-only. Each run's output lands in journalctl --user -u anthropic-account-poll.service.
What it deliberately does not do
The root reads no files, makes no request and writes nothing. Outside refreshIfNeeded, the ./node subpath writes only the usage file and pollAll's backoff file, and sends only the usage request, through the caller's fetch; pollUsage reports an expired token and never renews it. Nothing returns, logs or stores a token anywhere but the credentials file, and no failure carries a message. The tests use canary tokens, a fake fetch and a temp home, and assert the canary is absent from every result, error, captured console or stream line and written file, including when the server echoes the token in its body, a header, a redirect Location or an error body.
Gotchas
expiresAt in credentials is epoch milliseconds, while a UsageReading uses epoch seconds. A credentials expiry that looks like seconds is refused as malformed rather than read as long expired. accountLabel names .claude default and otherwise uses the last path segment without its leading dot, so two profile dirs with the same name get the same label.
Where it came from
New. It is the pure core of account management as a tier-0 module. The status line, agent-chat's budget readers and profiles, the pace pass and session-read are to consume it and delete their own copies.