The thinking tokens a CLI subprocess never needed to spend

Resume · No. 006

Shipped

v0.1.1 of the resume skill turned a slow, sometimes-hanging pipeline into one that tailors a résumé through the claude CLI on a normal subscription, no API key required. The release added live streaming progress while the model works, an interactive style picker that re-renders an already-tailored résumé in a different template in under a second, and moved a set of self-audit rules out of the system prompt and into plain code with a bounded corrective retry. It also swept out about 1,580 lines of dead code, dropped an unused dependency that was pulling in 92 transitive packages, and added an MIT license and a release workflow that tags a GitHub Release on merge. The part worth teaching is the speed fix underneath all of it: the pipeline was burning most of its wall-clock time on extended thinking that a structured-extraction task never asked for, and the fix generalizes to any tool that drives Claude from a subprocess.

Setup: what you need before you can see the waste

You need the claude CLI installed and authenticated on a subscription (run claude once interactively and follow the /login prompt if you haven’t), and a task that shells out to it with -p for a single, non-interactive answer. The pattern below works for any extraction-shaped job: turning free text into a fixed JSON shape. Two flags matter for that: --output-format json to get a parseable response, and --json-schema if you want the model’s answer validated against a shape before it ever reaches your code.

claude -p "Extract the main function names from auth.py" \
  --output-format json \
  --json-schema '{"type":"object","properties":{"functions":{"type":"array","items":{"type":"string"}}},"required":["functions"]}'

The response is one JSON object on stdout, with the validated answer in a structured_output field and everything else (timing, cost, token counts) alongside it in usage (Claude Code: Run Claude Code programmatically). That usage object is where the waste hides.

Find the waste: compare the answer to the bill

Extended thinking is billed as output tokens, same bucket as the answer itself. So if you run a call and compare the length of what you asked for against usage.output_tokens, a big gap is the tell. I wrote a small system prompt with the kind of rule-dense instructions a tailoring or extraction task tends to accumulate: don’t invent facts, don’t use certain phrases, self-check before you answer.

claude -p "$(cat notes.txt)" \
  --tools "" \
  --output-format json \
  --model haiku \
  --system-prompt "$(cat rules.txt)"

Three plain-language changelog notes went in. Running that exact call against the live subscription, with no thinking configuration set, gave this:

duration_ms: 12838
output_tokens: 1173
result: ["Fix a race condition in the retry queue that caused duplicate
webhook sends under load.", "Add a --dry-run flag to the migrate
command.", "Upgrade the PDF renderer dependency to fix a font-embedding
bug on Windows."]

Three sentences of JSON is a few hundred tokens at most, not 1,173, and 12.8 seconds is a long wait for that. The gap is thinking: tokens you pay for and wait on but never see in the answer. Anthropic’s own docs confirm that mechanic directly, that you’re billed for the full thinking token count even when what you see is only a summary of that reasoning (Anthropic: Extended thinking). Rewriting three notes into JSON bullets is extraction with rules, not open-ended reasoning. A rule-dense prompt makes a task look hard to a model deciding whether to think; it isn’t the same as the task actually requiring multi-step reasoning, and that mismatch is where the wasted time lives.

Turn thinking off on purpose

The fix is one environment variable on the child process: MAX_THINKING_TOKENS=0. Claude Code’s own docs are specific about what it does: setting it “turns thinking off on the Anthropic API,” and it’s the way to disable thinking “regardless of effort” level on models that don’t do adaptive reasoning (Claude Code: Model configuration). Haiku is one of those models, so leaving thinking unconfigured means whatever the CLI’s default budget is, not “off.”

MAX_THINKING_TOKENS=0 claude -p "$(cat notes.txt)" \
  --tools "" \
  --output-format json \
  --model haiku \
  --system-prompt "$(cat rules.txt)"

Same three notes, same rules, same model, only the env var changed:

duration_ms: 3180
output_tokens: 64
result: ["Fix a race condition in the retry queue that caused duplicate
webhook sends under load.", "Add a --dry-run flag to the migrate
command.", "Upgrade the PDF renderer dependency to fix a font-embedding
bug on Windows."]

Identical answer, a fourth of the time, an eighteenth of the output tokens. In a real subprocess wrapper, that’s a one-line change: pass MAX_THINKING_TOKENS: "0" in the child’s env alongside whatever else you’re forwarding.

import { spawn } from "node:child_process";

const child = spawn("claude", [
  "-p", "--tools", "", "--output-format", "json",
  "--model", "haiku", "--system-prompt", systemPrompt,
], {
  env: { ...process.env, MAX_THINKING_TOKENS: "0" },
});

One thing to know about picking the model for this path: if you’re driving Claude through a CLI subscription rather than a cached API call, there’s no prompt caching, so a large system prompt gets reprocessed cold on every call. A slower, more capable model can time out here in a way it wouldn’t behind a caching API client. Pick the model against how you’re actually calling it, not against a benchmark that assumed caching.

Move the self-audit into deterministic code

There’s a second reason a rule-dense prompt invites thinking: if the prompt also asks the model to police its own output (“scan your answer for these banned phrases before you respond”), that audit runs inside the same thinking budget. You’re paying for it, waiting for it, and getting no guarantee it happened; a system prompt is an instruction the model usually follows, not a contract.

The fix is to run the same checks as plain code after generation, and only re-prompt the specific violations found, once.

const BANNED_PHRASES = ["results-driven", "cutting-edge", "world-class"];
const SCOPE_WORDS = ["enterprise-wide", "at scale", "mission-critical"];

function norm(s) {
  return s.toLowerCase().replace(/\s+/g, " ").trim();
}

function findViolations(bullet, source) {
  const low = norm(bullet);
  const src = norm(source);
  const problems = [];

  for (const p of BANNED_PHRASES) {
    if (low.includes(p)) problems.push(`banned phrase: "${p}"`);
  }
  for (const w of SCOPE_WORDS) {
    if (low.includes(w) && !src.includes(w)) problems.push(`unsupported scope word: "${w}"`);
  }

  const durationRe = /\b(\d+)\+?[\s-]*(?:years?|yrs?)\b/g;
  for (const m of low.matchAll(durationRe)) {
    const n = m[1];
    if (!new RegExp(`\\b${n}\\+?[\\s-]*(?:years?|yrs?)\\b`).test(src)) {
      problems.push(`unsupported duration: "${m[0].trim()}"`);
    }
  }
  return problems;
}

Run it against a source sentence and a few candidate rewrites, no model call involved, and the checks either pass or name exactly what’s wrong:

bullet: Delivered a results-driven overhaul of the retry queue at scale.
  violations: banned phrase: "results-driven"; unsupported scope word: "at scale"
bullet: Rewrote the retry queue and added a --dry-run flag, drawing on 3 years on the project.
  clean
bullet: Spent 12 years hardening the retry queue against duplicate webhook sends.
  violations: unsupported duration: "12 years"

The retry is one more claude -p call, thinking still off, with the violations named directly in the prompt instead of a generic “check your work”:

// runClaude wraps the spawn() call from the earlier block (prompt in,
// parsed structured_output out) with MAX_THINKING_TOKENS always set to "0".
async function repair(bullet, problems, source) {
  const retrySystem =
    `Rewrite the bullet to fix these problems: ${problems.join("; ")}. ` +
    "Return only the corrected bullet text.";
  return runClaude(bullet, retrySystem);
}

async function tailor(bullets, source) {
  const clean = [];
  for (let b of bullets) {
    let problems = findViolations(b, source);
    if (problems.length) {
      b = await repair(b, problems, source);
      problems = findViolations(b, source); // re-check the repair once
      if (problems.length) console.warn("still failing after retry:", problems);
    }
    clean.push(b); // keep the corrected text either way
  }
  return clean;
}

One targeted pass, not a full rerun, and the guardrail is now a guarantee instead of an instruction the model might skip.

Verify: sweep the thinking budget instead of trusting the feel

It’s tempting to stop at “turning it off felt faster” and move on. That’s a vibe, and vibes don’t catch the case where a speedup quietly costs you something. Run the same call across a few thinking budgets and look at both time and output side by side; that comparison is the whole idea behind an eval, to “give an AI an input, then apply grading logic to its output to measure success,” rather than trusting your read of a handful of runs (Anthropic: Demystifying evals for AI agents).

I ran the same three notes and the same rules through budgets of 0, 4,000, and unset (the CLI’s own default) against the live subscription:

budget=0        output_tokens=64    duration_ms=3180
budget=4000     output_tokens=584   duration_ms=9929
budget=default  output_tokens=1173  duration_ms=12838

Same answer at every budget; the extra tokens bought nothing but time. That matches what actually shipped: an eval sweep across 0, 4,000, and 8,000 thinking tokens on the real tailoring task found 0 was both the fastest configuration (about 42 seconds per case versus about 153 seconds at 4,000) and the highest-scoring one on the project’s own quality rubric (69.5 versus 66.5). More thinking made that task slower and worse, not just slower. It’s the same instinct behind Anthropic’s broader agent-design advice: “finding the simplest solution possible, and only increasing complexity when needed,” and adding it only once a measurement shows the simple version falling short (Anthropic: Building effective agents). The sweep is what let the project set thinking off as the actual default, on measured evidence instead of a feeling that the first run was faster.

Gotchas

A CLI subscription has no prompt caching, so pick the model for the path you’re actually using. A model that’s fine behind a caching API client can reliably time out over a subscription CLI call, because the full system prompt gets reprocessed cold every time instead of read from a cache. Benchmark against the transport you’re shipping with, not the one you happened to test on first.

MAX_THINKING_TOKENS only controls fixed-budget thinking; it doesn’t apply everywhere. On models with adaptive reasoning, thinking is driven by an effort level instead of a token ceiling, and MAX_THINKING_TOKENS=0 is what disables thinking regardless of that setting. If you’re on an older, non-adaptive model, unset means whatever the CLI’s built-in default budget is, not off; you have to say so explicitly.

A prompt-level self-check is not a guarantee. Asking the model to scan its own output for a banned phrase list works most of the time, and “most of the time” is exactly the failure mode you can’t see coming. Moving those checks into code after generation turns them into something you can actually rely on, and it stops burning thinking tokens on work a regex does for free.

Watch for a paid API key silently overriding a subscription. If a tool that’s supposed to run free on a subscription instead auto-detects an API key sitting in the environment and switches to metered billing, that’s a footgun for anyone who has both configured. Make the free path the default and require the paid one to be opted into explicitly.

Sources

Changelog

  • feat: streaming UX, thinking-off speedup, style picker; lean dead-code sweep (48b7f9a)
  • ci: auto-tag semver release on merge to master (a75b318)
  • docs: license, packaging metadata, README for public consumption (e3643fe)
  • release: 0.1.1 — license + packaging for public consumption (03f2474)