Tutorials
Ren Okabe10 min read9 views

Bisecting a Claude Agent SDK behaviour change: why grepping the bundle dates it wrong

A grep bisect tells you when today's code landed, not when the behaviour changed. Here is the argv-capture harness that dates it properly, and the worked example where grepping made a maintainer look wrong and running the versions proved him right.

Flat dark diagram: a row of eight rounded-square nodes, with an amber arrow pointing down at one node and a blue arrow pointing up at a different node, showing two instruments dating the same change to two different versions.
Flat dark diagram: a row of eight rounded-square nodes, with an amber arrow pointing down at one node and a blue arrow pointing up at a different node, showing two instruments dating the same change to two different versions.
On this page

Quick Answer (September 2026). To find which version of @anthropic-ai/claude-agent-sdk changed a behaviour, do not grep the published bundles for the current implementation. A grep bisect tells you when today's code landed, not when the behaviour changed, and those are routinely different releases. Install each candidate version and measure what it actually does. In the worked example below, a grep bisect dated a fix to 0.2.47 and made an Anthropic maintainer look like he had closed a bug seven releases early. Running the versions proved the maintainer right: the behaviour was fixed in 0.2.40, and 0.2.47 was a later rewrite of an already-fixed path.

Everything here was measured against published tarballs on 16 September 2026, with @anthropic-ai/claude-agent-sdk at 0.3.273 and Node 24.8.0.

Anthropic npm Node.js

The problem with reading the bundle

You hit an SDK behaviour you did not expect. The natural next question is "when did this start?", because the answer decides whether you pin a version, file a regression, or accept it as by-design.

The tempting method is to download a spread of published tarballs, grep each one for the code you just read in the current release, and report the first version where it appears. It is fast, it needs no API key, and it produces a confident-looking table.

It is also the wrong instrument, and the failure is not subtle. A vendor can fix a behaviour in one release with a small patch, then rewrite that whole code path months later. Your grep is keyed on the rewrite. It will date the fix to the rewrite and silently skip the release that actually fixed it.

The fix is to stop grepping for code and start measuring behaviour.

turn the question into something observable

The Claude Agent SDK does not do the work itself. It builds an argument vector and spawns the Claude Code CLI as a subprocess. That boundary is the observable: for any set of options, the SDK produces a specific argv, and the argv is the SDK's entire decision.

The SDK lets you point that subprocess anywhere, via pathToClaudeCodeExecutable. So you can hand it a script of your own that records its arguments and exits. No API key, no network, no billing.

build the capture harness

Two files. The first is the fake CLI:

javascript
#!/usr/bin/env node
// fakecli.js
const fs = require('fs');
const out = process.env.ARGV_OUT || '/tmp/argv.json';
fs.writeFileSync(out, JSON.stringify(process.argv.slice(2)));
process.exit(0);

The second drives the SDK and drains the (empty) result stream:

javascript
// probe.mjs
import fs from 'node:fs';
import { query } from '@anthropic-ai/claude-agent-sdk';

const CLI = '/tmp/fakecli.js';

async function argvFor(opts, label) {
  const out = `/tmp/argv-${label}.json`;
  try { fs.unlinkSync(out); } catch {}
  process.env.ARGV_OUT = out;
  try {
    const q = query({
      prompt: 'hi',
      options: { ...opts, pathToClaudeCodeExecutable: CLI, executable: 'node' },
    });
    for await (const _m of q) { /* drain */ }
  } catch (e) { /* the fake CLI exits 0 with no stream; ignore */ }
  try { return JSON.parse(fs.readFileSync(out, 'utf8')); } catch { return null; }
}

Two details that cost me time. The flag you are usually looking for is --allowedTools in camelCase, not kebab-case, so a probe grepping for --allowed-tools reports "flag absent" on every row and reads exactly like a feature that does nothing. And subagent definitions and toolAliases do not travel as CLI flags at all, they go over the control protocol on stdin, so an argv-only probe is structurally blind to them. If you need those, have the fake CLI read stdin too, which is how I captured subagent definitions when checking how the SDK validates skill names.

get the real version list, with dates

Do not guess version numbers. Ask the registry. (If you would rather inspect tarballs than install them, npm pack downloads one without touching node_modules.)

bash
npm view @anthropic-ai/claude-agent-sdk time --json

This matters more than it looks. When I bisected this package, 0.2.46 came back as a pack failure on every attempt. It was not a network problem: that version was never published. Sparse version lines are normal, and a bisect that treats a missing version as a failed measurement will stall on it.

install each candidate in its own directory

bash
for V in 0.2.38 0.2.40 0.2.44 0.2.45 0.2.47; do
  mkdir -p /tmp/ver/$V
  (cd /tmp/ver/$V && npm init -y >/dev/null \
     && npm install --save-exact "@anthropic-ai/claude-agent-sdk@$V" >/dev/null)
done

Use --save-exact and a separate directory per version. Do not reach for --no-save: if you install package A with --no-save and later install package B with --no-save in the same directory, the second install prunes A, because neither is in package.json. I lost the SDK out of my own scratch directory that way, mid-run, and the symptom is a bare "Cannot find module" on a package you watched install successfully.

Then import by path rather than by bare specifier, so one script can drive every version:

javascript
const { query } = await import(
  `/tmp/ver/${V}/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs`
);

measure, then read the code

Here is the worked example. The question: how does the SDK translate its thinking options into CLI flags, and when did that change?

Five option shapes, five versions, argv captured for each:

Scroll to see more

Version{type:'adaptive'}{type:'adaptive'} + maxThinkingTokens:10000{type:'enabled', budgetTokens:2000}{type:'enabled'}maxThinkingTokens:10000
0.2.38(no thinking flag)(no thinking flag)--max-thinking-tokens 2000(no thinking flag)--max-thinking-tokens 10000
0.2.40--max-thinking-tokens 32000--max-thinking-tokens 10000--max-thinking-tokens 2000(no thinking flag)--max-thinking-tokens 10000
0.2.44--max-thinking-tokens 32000--max-thinking-tokens 10000--max-thinking-tokens 2000(no thinking flag)--max-thinking-tokens 10000
0.2.45--max-thinking-tokens 32000--max-thinking-tokens 10000--max-thinking-tokens 2000(no thinking flag)--max-thinking-tokens 10000
0.2.47--thinking adaptive--thinking adaptive--max-thinking-tokens 2000--thinking adaptive--max-thinking-tokens 10000

Now, and only now, read the bundles to explain what you measured.

At 0.2.38 the normalisation is, deobfuscated:

javascript
let thinkingTokens = options.maxThinkingTokens;
if (options.thinking) switch (options.thinking.type) {
  case 'adaptive': thinkingTokens = void 0; break;
  case 'enabled':  thinkingTokens = options.thinking.budgetTokens; break;
  case 'disabled': thinkingTokens = 0; break;
}

The adaptive branch nullifies the value the flag depends on, so asking for adaptive thinking produced no thinking flag at all. That is a real bug and it was reported as issue 168 on 10 February 2026.

At 0.2.40 exactly one line differs:

javascript
switch (options.thinking.type) {
  case 'adaptive': if (!thinkingTokens) thinkingTokens = 32000; break;
  case 'enabled':  thinkingTokens = options.thinking.budgetTokens; break;
  case 'disabled': thinkingTokens = 0; break;
}

A hardcoded default instead of a nullification. That is the fix, and it matches the maintainer's reply on the issue: "Yes, this should be fixed as of 0.2.40."

At 0.2.47 the whole path is rewritten. A dedicated --thinking flag appears, and the normalisation becomes a two-stage thing: options are folded into a thinking config, then the config is turned into flags.

javascript
if (thinkingConfig) {
  switch (thinkingConfig.type) {
    case 'enabled':
      if (thinkingConfig.budgetTokens === undefined) push('--thinking', 'adaptive');
      else push('--max-thinking-tokens', String(thinkingConfig.budgetTokens));
      break;
    case 'disabled': push('--thinking', 'disabled'); break;
    case 'adaptive': push('--thinking', 'adaptive'); break;
  }
}

That expression is what a grep bisect finds. It first appears at 0.2.47 and is still there at 0.3.273.

the trap, stated plainly

My grep bisect searched every published tarball for the current release's guard, the budgetTokens === void 0 test and the --thinking flag string. It reported, correctly, that both appear first at 0.2.47 on 18 February 2026.

From that I drew a conclusion that felt solid and was false: that the maintainer had closed issue 168 as fixed at 0.2.40 while the fix did not actually land until 0.2.47, seven releases later.

The behavioural bisect killed it in one table. At 0.2.40 the adaptive case already produces a thinking flag. The bug was fixed exactly where the maintainer said it was. What landed at 0.2.47 was a rewrite of a path that had already been correct for six days.

A grep bisect answers "when did this code appear". A behavioural bisect answers "when did this behaviour change". Those are different questions, and when a vendor rewrites a fixed path the two answers diverge by however long the gap between patch and rewrite happens to be.

Note the direction of the error. The grep bisect did not merely give a vaguer answer, it produced a specific, publishable, accusatory claim about a named engineer. If you are about to write "the maintainer closed this too early", that sentence is a signal to go and run the versions.

what the measurement found that neither instrument alone would

Read the table again along the {type:'enabled'} column, the case with no budgetTokens.

At 0.2.38 through 0.2.45 it produces no thinking flag. At 0.2.47 through 0.3.273 it produces --thinking adaptive. It has never produced a fixed budget. The failure mode changed, silently, and the option has never once done what its name suggests.

This matters because it typechecks. budgetTokens is optional in the ThinkingEnabled type, as the TypeScript reference shows, so under tsc --strict this compiles with no error at all:

typescript
const opts: Options = { thinking: { type: 'enabled' } };

I checked: the only shape in that file tsc rejects is an invalid variant such as { type: 'auto' }, which fails with TS2322: Type '"auto"' is not assignable to type '"adaptive" | "disabled" | "enabled"'. The no-budget case passes clean.

Worth crediting: this conversion is not my discovery. The Cherry Studio project documented the exact mechanism in March 2026, including the deobfuscated branch, after it produced a 400 from a non-Anthropic proxy that accepts only enabled and disabled. What the version archaeology adds is when it started and why: it arrived with the 0.2.47 rewrite, as a side effect of re-doing a fix that was already working.

One practical consequence. Issue 168 is closed, and it still recommends dropping thinking entirely and using only maxThinkingTokens. On 0.3.273 that advice is unnecessary. Measured on the full argument vector, not just the thinking flags:

  • maxThinkingTokens: 2000 produces a 9-element argv
  • thinking: { type: 'enabled', budgetTokens: 2000 } produces a 9-element argv
  • the two arrays are identical, element for element

So the workaround points readers at a deprecated option for no benefit. Pass budgetTokens and use the supported one.

The current translation table

For reference, every thinking option shape at 0.3.273, measured:

Scroll to see more

Optionsargv
thinking: {type:'adaptive'}--thinking adaptive
thinking: {type:'enabled', budgetTokens:2000}--max-thinking-tokens 2000
thinking: {type:'enabled'}--thinking adaptive
thinking: {type:'disabled'}--thinking disabled
maxThinkingTokens: 2000--max-thinking-tokens 2000
maxThinkingTokens: 0--thinking disabled
thinking: {type:'adaptive'} + maxThinkingTokens: 2000--thinking adaptive
thinking: {type:'auto'} + maxThinkingTokens: 2000(no thinking flag)
neither option set(no thinking flag)

Two rows deserve a second look. {type:'enabled'} with no budget is byte-identical to asking for adaptive. And an invalid variant is worse than useless: it produces no thinking flag and suppresses the maxThinkingTokens fallback, because the normalisation is an if (thinking) ... else if (maxThinkingTokens !== undefined) ... and an unrecognised type falls through the inner switch without setting anything. Its argv is identical to passing no thinking options at all. TypeScript catches that one, so it is a hazard for JavaScript consumers, config loaded from JSON, or anything that reaches the option through a cast.

Scope and limits

Steps 1 through 7 are first-party measurements: I installed each version, ran it, and captured the argv. The version dates come from the npm registry.

What is not measured here is what the CLI and the model do with those flags. The deprecation note on maxThinkingTokens says that on Opus 4.6 the value is treated as on/off rather than as a budget. That happens downstream of the SDK, and this article cannot confirm it, because confirming it needs a real Claude Code install, an API key, and a billed run. What I can say precisely is that the SDK forwards your number as --max-thinking-tokens N; whether a 2000 you pass is honoured as 2000 is a question for a different harness.

The same limit applies to toolAliases. I confirmed it is transmitted verbatim over the control protocol with no SDK-side validation at all, including chains, self-references and unknown tool names, but the documented single-hop resolution happens in the CLI and is outside what this harness can see.

The method, condensed

  1. Find the boundary where the thing you are measuring becomes observable. For this SDK it is the spawned subprocess.
  2. Replace whatever is on the other side of that boundary with a recorder.
  3. Get the real version list from the registry, with dates.
  4. Install candidates in isolated directories with exact versions.
  5. Measure first. Coarse bisect, then narrow, then pin.
  6. Read the source only to explain a result you already have.
  7. If your explanation implies someone made a mistake, run one more measurement before you write it down.

The same harness, pointed at a different option, is what showed that an unknown hook type is silently stripped rather than rejected. Once the recorder exists, most "when did this change" questions are a twenty-minute job.

Ren Okabe

Written by

Ren Okabe

Ren builds agent infrastructure and writes copy-paste tutorials for engineers shipping LLM tool-use systems.

Frequently asked questions

Why not just grep the published bundles to find when a behaviour changed?

Because a grep bisect keyed on the current release's code tells you when that code landed, not when the behaviour changed. A vendor can fix a behaviour with a small patch and then rewrite the whole path months later. Measured here: grepping for the current thinking-flag guard dates it to 0.2.47, while running the versions shows the behaviour was already correct at 0.2.40. Install the candidates and measure what they do.

How do I capture the arguments the Claude Agent SDK passes to the CLI?

Set pathToClaudeCodeExecutable to a script of your own that writes process.argv to a file and exits 0, and set executable to node. The SDK spawns your script instead of the real CLI, so you get the full argument vector with no API key, no network and no billing. Subagent definitions and toolAliases travel over the control protocol on stdin rather than as flags, so read stdin too if you need those.

Does thinking type enabled without budgetTokens give me a fixed thinking budget?

No. On 0.3.273 the SDK converts it to --thinking adaptive, byte-identical to asking for adaptive. budgetTokens is optional in the ThinkingEnabled type, so it compiles clean under tsc --strict and you get no warning. It has never produced a fixed budget: from 0.2.38 to 0.2.45 it produced no thinking flag at all, and from 0.2.47 onward it produces adaptive. Always pass budgetTokens.

Is the deprecated maxThinkingTokens option still safe to migrate away from?

Yes, and the migration is a no-op at the argv layer. Measured on 0.3.273, maxThinkingTokens 2000 and thinking type enabled with budgetTokens 2000 produce identical nine-element argument vectors, element for element. The workaround still recommended in the closed issue 168, which is to drop thinking and use only maxThinkingTokens, therefore buys nothing and points you at a deprecated option.

Why did npm pack fail on one version during the bisect?

Because that version was never published. Version 0.2.46 does not exist in the registry, and npm returns a 404 for it. Sparse version lines are normal. Pull the real list with npm view PACKAGE time --json before bisecting, and treat a missing version as a gap to skip rather than as a failed measurement.

Why did installing a second package with --no-save break my bisect?

Because --no-save installs are not recorded in package.json, so a later --no-save install prunes the earlier one as an extraneous package. If you install the SDK with --no-save and then install TypeScript the same way in that directory, the SDK disappears and you get a Cannot find module error for a package you watched install. Use --save-exact and one directory per version.

Tutorials

Claude Agent SDK skills: "docs:*" throws, "pdf-*" does not

The skills option validates every name before the session starts, and its wildcard guard tests for exactly two endings. Eight of eleven wildcard shapes get through, become a Skill(name) rule that names no skill, and say nothing. Measured first-party on 0.3.272, with the bisect.

11 min read26