agent-sdk-watchdog
v0.2.0
Published
An idle watchdog for Claude Agent SDK query() sessions, with the rate-limit monitor that feeds it and an SDK warning filter.
Maintainers
Readme
agent-sdk-watchdog
Liveness helpers for Claude Agent SDK
query() sessions: three small modules for "the query() that always returns".
- an idle watchdog that aborts a query that stops moving;
- a rate-limit monitor that merges the usage readings into one;
- an SDK warning filter that shows one known SDK warning once instead of every time.
The structured-output ladder is the separate
agent-sdk-recovery package.
A community project, not affiliated with, endorsed by or sponsored by Anthropic. "Claude" is a trademark of Anthropic, PBC, and is named here only to say which SDK these helpers work with. Your use of the SDK itself is governed by Anthropic's terms.
The package prints nothing. When a module has something to report, it calls your callback or returns a value, and you decide what to print.
Contents
- Install
- Compatibility
- Quick start
- Idle watchdog:
createWatchdog(config?) - Rate-limit monitor:
createRateLimitMonitor(config?) - SDK warning filter:
installSdkWarningFilter(options?) - Good to know
- Why
- Dependencies
- Security
- Contributing
- License
Install
npm install agent-sdk-watchdog @anthropic-ai/claude-agent-sdkCompatibility
| Needs | Version |
| -------------------------------- | ------------------------------------------------------------------------------------ |
| Node | 22.12 or later. The package is ESM only; CommonJS code can require() it on 22.12+. |
| TypeScript | 5.4 or later. |
| Module resolution | node16, nodenext or bundler. The old node10 ("node") cannot resolve it. |
| @anthropic-ai/claude-agent-sdk | >=0.3.280 <0.4.0, as a peer. CI tests 0.3.280, 0.3.283 and 0.3.293. |
The wait after the result. Since SDK 0.3.284, a query with hooks, canUseTool or SDK MCP
servers waits after its result for the session to go idle, for up to 10 minutes, and its
iterator stays open until then. The SDK bounds that wait itself. So a result settles the
query: the idle policy stops until new work starts (see handle.settle() below).
Quick start
A pump that wires all three modules:
import { query, type Options } from '@anthropic-ai/claude-agent-sdk'
import {
createRateLimitMonitor,
createWatchdog,
formatRateLimitReading,
installSdkWarningFilter,
makeWatchdogHook,
} from 'agent-sdk-watchdog'
// One of each for the whole process, shared by every query.
const rateLimits = createRateLimitMonitor()
const watchdog = createWatchdog({
getUtilization: (now) => rateLimits.active(now)?.utilization ?? null,
})
installSdkWarningFilter({
onFirst: (_warning, code) => console.warn(`${code}: expected, shown once`),
})
export async function run(prompt: string, options: Options, label: string): Promise<void> {
const abortController = new AbortController()
const handle = watchdog.register({
hardCeilingMs: 60 * 60 * 1000,
abortController,
onKill: (kill) => console.warn(`${label}: watchdog ${kill.kind}`),
})
// Every tool boundary of this query feeds its own handle.
const feed = { hooks: [makeWatchdogHook(handle)] }
const hooks = options.hooks ?? {}
try {
const q = query({
prompt,
options: {
...options,
abortController,
includePartialMessages: true, // stream events keep the idle clock moving
hooks: {
...hooks,
PreToolUse: [...(hooks.PreToolUse ?? []), feed],
PostToolUse: [...(hooks.PostToolUse ?? []), feed],
PostToolUseFailure: [...(hooks.PostToolUseFailure ?? []), feed],
},
},
})
for await (const message of q) {
handle.observe(message)
const crossed = rateLimits.observe(message)
if (crossed) console.warn(`usage window high: ${formatRateLimitReading(crossed)}`)
}
} catch (err) {
// A kill reaches the loop as the abort's error; `kill` says the watchdog caused it.
const kill = handle.kill
if (kill) throw new Error(`${label}: stopped by the watchdog (${kill.kind})`, { cause: err })
throw err
} finally {
handle.unregister()
}
}Idle watchdog: createWatchdog(config?)
A watchdog supervises every query you register with it, on one shared timer. It kills a query
that shows no activity of any kind for idleMs. Patience grows to idleMs × extendFactor
before the query's first completed write, since reading and planning are quiet, and while the
usage window is at or above highUtilization, since the provider then paces its answers. Each
query also has a hard ceiling on its age, active or not.
| Option | Default | What it changes |
| ----------------- | ------------------- | --------------------------------------------------------------------------------------- |
| idleMs | 600000 (10 min) | Base idle patience, in ms. Must be > 0. |
| extendFactor | 2 | How many times idleMs a query gets while an extension holds. Must be >= 1. |
| highUtilization | 0.9 | Usage (0 to 1) at or above which patience is extended. |
| tickMs | 30000 (30 s) | How often the shared timer checks every query, in ms. Must be > 0. |
| writeTools | ['Write', 'Edit'] | The tools whose first completed call ends the pre-write extension. |
| toolHeartbeats | true | Whether observe() counts a tool_progress frame as activity. See Good to know. |
| getUtilization | () => null | The usage at a tick, or null when unknown. Wire it to a rate-limit monitor, as above. |
| now | Date.now | The clock for every method you call without a time. |
| onError | drops the error | Gets an error that onKill, onExtend or getUtilization threw. |
WATCHDOG_DEFAULTS holds the defaults. An option you pass as undefined keeps its default. A
value out of range throws a RangeError, for example idleMs must be a number > 0, got -1.
The watchdog has four members:
register({ hardCeilingMs, abortController, onKill?, onExtend? })starts supervising one query and returns its handle. The timer runs while any query is registered, and it never keeps the process alive.tick(now?)runs one check by hand and returns the kills it made. Tests use it.close()stops the timer and forgets every query.configis the resolved options.
On a kill, the watchdog calls onKill({ kind, elapsedMs, sinceToolMs }) once, with kind
'watchdog_idle' or 'hard_ceiling'. Then it aborts abortController, even when onKill
throws, so pass the same controller to query(). onExtend(reason, utilization) runs once,
the first time patience is extended, with reason 'pre_write' or 'high_utilization'.
Feed each handle from its own query only, so parallel queries keep separate clocks:
makeWatchdogHook(handle)is a hook forPreToolUse,PostToolUseandPostToolUseFailure. OnlyPostToolUsepasses the tool name, so a write that a hook denied does not end the pre-write extension.handle.observe(message)takes every SDK message. Stream events,system/thinking_tokensframes andrate_limit_events count as activity, and so dotool_progressframes whentoolHeartbeatsis on. A thinking model streams, so it is never killed as idle. Stream events needincludePartialMessages: true. Aresultsettles the query: no idle kill until a tool boundary or a stream frame shows new work. A heartbeat does not end it. The hard ceiling still applies.handle.activity(toolName?),handle.stream(),handle.heartbeat()andhandle.settle()do the same by hand.handle.killholds the kill, ornull, and stays readable afterunregister().handle.unregister(): call it when the query ends.
Rate-limit monitor: createRateLimitMonitor(config?)
Merges each rate_limit_event into one reading, because no payload shape carries both the
utilization and the overage status. A reading is the SDK's SDKRateLimitInfo. An allowed
frame carries its utilization only in a per-window unifiedWindows map that the type does not
declare, so the monitor reads that map at run time.
| Option | Default | What it changes |
| ---------------- | ------------- | -------------------------------------------------------------------------------------------------- |
| warnAt | 0.85 | Usage (0 to 1) at which observe and note return the reading, once, so you can print a warning. |
| fallbackWindow | 'five_hour' | The window read when a reading has no top-level utilization and names no window. |
| now | Date.now | The clock active() reads when you pass no time. |
RATE_LIMIT_DEFAULTS holds the defaults; warnAt outside 0 to 1 throws a RangeError.
observe(message)folds arate_limit_eventinto the reading; any other message changes nothing. It returns the merged reading the first time utilization reacheswarnAt, andnullevery other time.note(info)does the same for a bareSDKRateLimitInfo.active(now?)gives the reading, ornullonce itsresetsAthas passed. Use it for decisions.latest()gives the reading, stale or not. Use it for display.reset()forgets the reading and arms the warning again.
formatRateLimitReading(reading) renders one line, and rateLimitResetIso(reading) gives the
reset time as an ISO string, or null.
SDK warning filter: installSdkWarningFilter(options?)
The SDK warns CLAUDE_SDK_CAN_USE_TOOL_SHADOWED on every query() that passes canUseTool
beside bare MCP tool names in allowedTools. When that combination is on purpose, the warning
repeats on every query. The filter swallows the warnings whose code is in codes, and calls
onFirst(warning, code) with the first one of each code. Every other warning reaches Node
untouched.
| Option | Default | What it changes |
| --------- | -------------------------------------------------------- | ----------------------------------------------------------------------------- |
| codes | ['CLAUDE_SDK_CAN_USE_TOOL_SHADOWED'] (SHADOWED_CODE) | The warning codes to swallow. |
| onFirst | does nothing | Called with the first warning of each code, and the code. A throw is dropped. |
WARNING_FILTER_DEFAULTS holds the defaults. The call returns true when it installed the
filter. A later call changes nothing and returns false: its options never apply.
Good to know
Auth scope. The rate-limit readings are claude.ai plan limits. The SDK documents
SDKRateLimitInfoas "rate limit information for claude.ai subscription users". Its experimentalQuery.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET()returnsrate_limits_available: falseandrate_limits: nullfor API-key, Bedrock and Vertex sessions. Under those logins, expect no utilization: the monitor never warns, and the watchdog's high-utilization rule never applies (its idle and pre-write rules still work). Anthropic does not let third-party products offer claude.ai login or rate limits, or route requests through a user's subscription (Agent SDK overview, legal and compliance). A product you build for other people uses API-key auth, where the monitor stays idle. It is for sessions you run under your own subscription.Tool heartbeats and in-process MCP servers. While a tool call runs, the CLI sends a
tool_progressheartbeat every 30 seconds. WithtoolHeartbeats: true, the default, a long tool call is never killed as idle. The cost: a tool call that hangs sends heartbeats too, so onlyhardCeilingMsstops it. For a stdio or HTTP MCP server, the CLI's ownCLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUTstill aborts a stalled call, but it does not apply to in-process SDK servers (createSdkMcpServer). If your queries use in-process servers, think abouttoolHeartbeats: false: a tool call then must finish within the idle patience.Instances, and one process-wide exception. Watchdogs and monitors are separate instances: two monitors keep two readings, and two watchdogs run two timers. The warning filter is the exception. It patches
process.emitWarningonce per process, and every copy of the package shares that patch. If you need no callback, Node can hide the warning with no code at all:--disable-warning=CLAUDE_SDK_CAN_USE_TOOL_SHADOWED, on the command line or inNODE_OPTIONS.
Why
The SDK repo has open reports of a query() iterator that never ends:
- #333: it goes silent
after the final tool result, with no
result. The idle kill aborts it afteridleMs. - #403: it stays open
after a
rate_limit_eventand aresult. A result settles the query, so the hard ceiling ends it. If you need nothing after the result, leave the loop when it arrives.
Either way, the hang becomes an abort you can see, with handle.kill telling you which kind.
Dependencies
@anthropic-ai/claude-agent-sdk(peer, required): the.d.tsfiles use its types. The package imports it as types only, so the runtime never loads it.
Nothing else.
Security
Please do not report a vulnerability in a public issue. Use private vulnerability reporting or email [email protected]. SECURITY.md has the details.
Contributing
Bug reports and pull requests are welcome. CONTRIBUTING.md explains the setup, the checks and the commit style. Everyone who takes part follows the Code of Conduct.
License
Apache-2.0 © Reuven Naor
