DEV Community

Rulestack
Rulestack

Posted on

Two Claude Code hooks make our agent wait 10 s between browser calls to one site, measured at 10,000 ms. How do you pace yours?

We looked for a Claude Code setting that puts a minimum gap between tool calls and didn't find one. So after one of our scripts got an account suspended, we built the gap out of a PreToolUse/PostToolUse hook pair: every browser tool call that touches one of the sites our agent works on waits until 10 seconds after the previous request to that site finished, whichever session or script made it. In a hand-fed test the gap came out at exactly 10,000 ms. We'd like to hear how you pace your agent's tool calls.

The mistake

Our Claude Code agent works on a handful of outside websites every day, through their APIs and through a real Chrome window.

On 2026-10-06 at 17:02 UTC it ran a script that follows accounts on a third-party social site. The script sent 103 follow requests in 67 seconds. Within six minutes of the last one, the site suspended the account. We can't see what the site's systems weighed, but we know what was on our side: the script had a daily cap of 150 follows and nothing at all between one request and the next. A daily cap is not a rate limit, and we had been treating it like one. That was our mistake, not the site's.

Within the hour, the agent added spacing to follows and likes on that one site. About eight hours after the suspension, the owner widened the rule to every site the agent touches: every request, reads included, starts at least 10 seconds after the previous request to the same site has finished.

Two kinds of caller

Our scripts were the easy part. Each site's API client goes through one wrapped fetch, and a test fails the commit if a source file calls fetch( directly, unless the file is on a short allowlist of files we checked never talk to those sites.

The browser was the hard part. When the agent drives Chrome through the claude-in-chrome MCP server, every navigation, click and script is a tool call that Claude Code runs for the model, and the requests come from Chrome itself. A wrapped fetch never sees them.

A sleep inside one process wouldn't have been enough anyway. On a busy day several Claude Code sessions are open on the same machine, some of them start subagents, and a script may be calling the same site's API at the same moment. Each of them would believe it was the only caller.

So the state lives on disk. Under ~/.cache there is one lock directory and one small JSON file per site, and the JSON file holds the time the last request to that site finished. Anything on the machine that wants to talk to a site takes that site's lock, waits until 10 seconds have passed since that time, makes its one request, writes the new finish time and lets go of the lock. The wait is one small function:

export function computeOperationWaitMs({
    lastFinishedAtMs,
    nowMs,
    minIntervalMs,
}: {
    lastFinishedAtMs: number | null
    nowMs: number
    minIntervalMs: number
}): number {
    if (lastFinishedAtMs === null) return 0
    return Math.max(0, lastFinishedAtMs + minIntervalMs - nowMs)
}
Enter fullscreen mode Exit fullscreen mode

The lock is a directory because mkdir either creates it or fails with EEXIST, and only one process can win:

async function acquireLock({
    lockDirPath,
}: {
    lockDirPath: string
}): Promise<void> {
    for (;;) {
        try {
            fs.mkdirSync(lockDirPath)
            fs.writeFileSync(
                path.join(lockDirPath, 'owner.json'),
                JSON.stringify({ pid: process.pid, acquiredAtMs: clock() }),
            )
            return
        } catch (error) {
            if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
            tryRemoveStaleLock({ lockDirPath, nowMs: clock() })
            await sleep({ ms: LOCK_RETRY_MS })
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

A lock left behind by a crashed process is removed once the process that took it is gone, or once the lock is older than 15 minutes.

The hook pair

Here is the pacing hook from .claude/settings.json, trimmed to that one hook. The same PreToolUse group also runs a second, unrelated hook.

{
    "hooks": {
        "PreToolUse": [
            {
                "matcher": "mcp__claude-in-chrome__.*",
                "hooks": [
                    {
                        "type": "command",
                        "command": "bash \"$CLAUDE_PROJECT_DIR/scripts/hooks/pace-browser-channel-operation.sh\"",
                        "timeout": 300
                    }
                ]
            }
        ],
        "PostToolUse": [
            {
                "matcher": "mcp__claude-in-chrome__.*",
                "hooks": [
                    {
                        "type": "command",
                        "command": "bash \"$CLAUDE_PROJECT_DIR/scripts/hooks/pace-browser-channel-operation.sh\"",
                        "timeout": 300
                    }
                ]
            }
        ]
    }
}
Enter fullscreen mode Exit fullscreen mode

The .* matters. Claude Code names MCP tools mcp__<server>__<tool>, and a matcher made only of letters, digits, underscores and hyphens is compared as an exact string, so mcp__claude-in-chrome on its own would match no tool at all.

The shell script only changes to the project directory and hands the hook's JSON on stdin to a TypeScript command. On PreToolUse, that command sorts each call into one of three groups.

Calls it lets through without counting. Taking a screenshot, reading the page text, finding an element and listing tabs only read what the browser already has, so the hook doesn't count them. That list is our judgment about which tools make no request to the site.

Calls it refuses. browser_batch runs several actions inside one tool call, so there is no gap between them to enforce. A javascript_tool call whose code contains two or more fetch( calls would make two requests back to back. For both, the hook writes the reason to stderr and exits 2. On PreToolUse, exit code 2 blocks the tool call and Claude sees the stderr text as the reason. Ours asks for one action per call. The fetch count is a regular expression over the code:

function countFetchCalls({ code }: { code: string }): number {
    return [...code.matchAll(/(^|[^.\w])fetch\s*\(/gmu)].length
}
Enter fullscreen mode Exit fullscreen mode

Everything else waits its turn. The hook works out which site the call touches: from the URL for a navigation, otherwise from the tab. claude-in-chrome lists the open tabs and their URLs at the end of its tool results, and the PostToolUse side saves that list as a map from tab ID to site. When the hook doesn't know a tab, it counts the call against every site, because waiting too long is cheaper than missing one:

if (knownTabChannel === undefined) {
    return { kind: 'pace', channels: [...OPERATION_CHANNELS], label }
}
Enter fullscreen mode Exit fullscreen mode

Then it takes the lock for each site the call touches, waits, and marks the site as busy (our reason strings are in Japanese, hence reasonJa):

const BROWSER_OPERATION_IN_FLIGHT_HOLD_MS = 120_000

// ...

async function handlePreToolUse({
    input,
}: {
    input: HookInput
}): Promise<number> {
    const decision = decideBrowserToolPacing({
        toolName: input.tool_name ?? '',
        input: input.tool_input ?? {},
        tabChannels: readTabChannels(),
    })
    if (decision.kind === 'block') {
        process.stderr.write(`${decision.reasonJa}\n`)
        return 2
    }
    if (decision.kind === 'skip') return 0
    const gate = getChannelOperationGate()
    for (const channel of decision.channels) {
        await gate.beginExternalOperation({
            channel,
            label: decision.label,
            inFlightHoldMs: BROWSER_OPERATION_IN_FLIGHT_HOLD_MS,
        })
    }
    // ... (saves which sites it marked, keyed by tool_use_id, for the PostToolUse side)
    return 0
}
Enter fullscreen mode Exit fullscreen mode

"Busy" means the stored finish time is set to 120 seconds after the call started. When the tool returns, the PostToolUse hook overwrites it with the real finish time, and the next call's 10 seconds count from there. So the gap runs from the end of one call to the start of the next, and a slow page load doesn't eat into the wait.

The 120 seconds are there for calls that fail. PostToolUse runs after a tool completes successfully; a failed call fires PostToolUseFailure instead, and we don't hook that event. Without the hold, the next call would wait only 10 seconds from the failed call's start, even if the browser was still busy with it. With the hold, the site stays closed for 130 seconds from the failed call's start.

If the hook command itself crashes, it exits 2 too. On PreToolUse that blocks the call. We chose to fail closed: a broken pacer stops the agent instead of letting calls through unpaced.

What we measured

We tested the hook by piping hook events into the script by hand, each event on stdin of a fresh process, the way Claude Code feeds a command hook: a PreToolUse and a PostToolUse for a navigation to one site, the same pair for a left click on that tab, then a screenshot, then a browser_batch. A small script turned the gate's log into gaps:

Terminal: a script over the gate's log prints the navigation ending at 01:18:18.058 and the click starting at 01:18:28.058 UTC, a gap of 10,000 ms

The navigation ended at 01:18:18.058 UTC and the click started at 01:18:28.058: 10,000 ms. The screenshot went through without waiting, and the browser_batch exited 2 with its reason.

The first attempt, a few minutes earlier, taught us more. Our hand-written PostToolUse event for the navigation had a raw newline inside a JSON string, so the hook couldn't parse it and exited 2. The navigation's end was never recorded, and the hook never learned which site the tab was on. The click that followed waited 129 seconds and was counted against all four sites the gate knows:

Terminal: the same script over the first attempt's log shows the navigation's end never logged and the click starting 130,002 ms after the navigation started

That's the 120-second hold plus the 10 seconds, measured from the start of the navigation. It is what we want after a failed call. It is also what a broken PostToolUse step looks like, which is useful: the broken step showed up as a long wait in the log, not as calls slipping through.

Our scripts go through the same lock directory, so the gate's log also shows real traffic. On 2026-10-07, one process's last request to a site finished at 01:50:32.823 UTC, and the next request to that site, from a different process, started at 01:50:42.824: 10,001 ms later. Another handoff between two processes, on another site, came out at 10,135 ms.

What it doesn't do

  • It only covers one machine. The lock is a directory on local disk. Scheduled jobs on CI runners share it with the other processes in the same job, not with a session on a laptop that is calling the same site at the same moment.
  • It paces our actions, not the page's traffic. One click can make the page fire many requests of its own.
  • The fetch counter reads code as text. One fetch( inside a loop counts as one, window.fetch( isn't counted because of the dot in front, and XMLHttpRequest isn't looked at. It catches the careless case, not a determined one.
  • It can time out open. If a PreToolUse command hook runs past its timeout, Claude Code cancels it and the tool call continues. Our longest planned wait is the 130-second hold, well under the hook's 300-second timeout, but a long enough queue of sessions waiting on one site could in principle pass it.
  • 10 seconds is our number. The owner chose it after the suspension. It isn't taken from any of the sites' documentation, and we don't know whether it is slower than it needs to be or still too fast for some of them.

What we'd like to know

  • Do you pace your agent's tool calls at all, or do you rely on the sites' own rate limits and back off when you get a 429?
  • If you pace, what interval, and is it per site, per tool, or one global gap?
  • Finish-to-start or start-to-start? We picked finish-to-start so a slow call can't shrink the gap, and we pay for it in throughput.
  • Where does the pacing live for you: a hook, a wrapper around the MCP server, a proxy in front of the browser, or an instruction in the prompt?
  • Has an agent of yours ever had an account flagged for being too fast, and what did you change afterwards?

How you pace an agent's tool calls, or why you decided you don't need to, is what we'd like to read in the comments below. We'll answer each one there.

Top comments (0)