Course · Claude Code · Models & thinking
Raise extended thinking when the problem earns deeper reasoning
Some bugs are found by lookup; others only by reasoning about an ordering. Here is the second kind, from the small TypeScript payments service this course uses as its running example. In src/payments/rate-limiter.ts:
async allowAsync(key: string): Promise<boolean> { const current = this.hits.get(key) ?? []; await new Promise((r) => setTimeout(r, 1)); if (current.length >= this.maxRequests) return false; current.push(Date.now()); this.hits.set(key, current); return true; }The method reads the current array reference for the key - or creates a private empty array if the key is absent - then awaits, and only then checks and writes back. Two callers that interleave at that await can both pass the limit check. The temptation on a bug like this is to describe the symptom and let the agent start editing; that is a mistake, because an agent that has not reasoned about the ordering can confidently patch the wrong line. On a capable model (pick it with /model), the next lever is how hard the model reasons before it acts.
One honesty note before reasoning about it: the route in src/index.ts guards POST /payments with the synchronous allow(), which is sound. allowAsync is the variant waiting for the day the check needs to consult something asynchronous - and it is where the race lives. The agent has to see why one method is safe and the other is not. That is reasoning, not lookup, and Claude Code has a dial for precisely it.
What extended thinking is
Section titled “What extended thinking is”Extended thinking is the reasoning the agent does before it answers - working the problem internally, walking the interleavings, ruling out approaches, then committing to an edit. On a one-string message tweak that is wasted motion; there is nothing to reason about. On a race it is the whole game.
How much of it happens is governed by an effort level. Higher effort spends more of the budget on thinking through the steps that benefit from it; lower effort answers faster and cheaper. For this bug you want it up.
Turning the effort up
Section titled “Turning the effort up”The dedicated control is /effort, typed at the prompt of a running Claude Code session (the install lesson covers getting one open in a repo):
/effort # open an interactive slider - drag from lower to higher/effort high # set a level directly by name/effort auto # clear your saved level, back to the model's defaultYou can also adjust the same slider with the left and right arrow keys while the /model picker is open, so choosing a model and choosing how hard it thinks happen in one place.
The current level shows in the session header next to the model name - “with high effort” - and briefly in the footer when it changes, so you always know which gear you are in. Level names vary by model; read the slider rather than trusting a remembered name, and keep the model-config reference as the source of truth.
One cost note the docs are explicit about: on most models each effort level has its own prompt cache - the provider’s reuse of already-processed prompt content, discounted while it holds - so changing effort mid-session recomputes the entire request, just like switching models does. Claude Code asks you to confirm while the cache is warm (while reuse is still available). The exception is Opus 5.5, Sonnet 5.5, Haiku 5.5, and Fable 5.1 on an API key or Claude subscription, where changing effort keeps the cache and applies without a prompt. Pick the level at the top of a session when you can; see the prompt-caching reference for the mechanics.
The race, read at high effort
Section titled “The race, read at high effort”Before reading the transcript below, find the interleave point yourself: the place where a second caller’s execution can get between this caller’s steps. The transcript’s thinking block names it.
Here is an illustrative exchange, not a captured one - constructed to show the shape of the reasoning (the service’s limit is 100 requests per window):
> /effort high
> the async rate limiter in src/payments/rate-limiter.ts can admit more requests than its limit allows. find the race and propose the smallest correct fix.
✻ Thinking… allowAsync captures `current` before a 1ms await - that await is the only place two calls interleave. On a key with no entry yet, `?? []` hands each caller its own private array: each checks length 0, each passes, each pushes, each set()s. Last write wins, the other pushes vanish from the map, and every concurrent caller was admitted. Second wound: a caller that captured before its sleep can set() a stale array back over entries recorded while it slept. allow() is safe because filter, check, push, set run in one synchronous block and the event loop never splits it. Smallest fix keeping the await: re-read the map after it, insert the empty array if missing, keep check+push+set synchronous - the allow() shape.
Here is the race, and the fix I propose: ## The race Two wounds, one cause: state read before an await and written after it. On a cold key, concurrent callers each materialize their own array via `?? []`, each pass the check against their own length, and each `set` clobbers the others' pushes - with the service's limit of 100, a first burst all gets in and the map undercounts. The 1ms gap also lets a resumed caller overwrite entries recorded while it slept. ## Fix Re-read `this.hits` after the await, insert-if-missing, then run the check, push, and set as one synchronous block - the shape `allow()` already uses. Dropping the await entirely also works. [continues]That reasoning block is the agent earning its keep - but “earning” is a verdict you check, not one you take from the confidence of the text. Higher effort makes real reasoning more likely; it does not guarantee it, and a plausible-sounding block can still be wrong. The reproduction below is what turns the block’s central claim into something observable. The interleave point it names is the only await: everything before it is a snapshot, everything after runs on whatever that snapshot still points at. It caught the two real wounds - the private-array admit on a cold key, and the stale set across the sleep, which bites when another writer has replaced the map entry in between - and noticed why the synchronous allow() does not share them: its read, check, and write never yield the thread. The proposed fix is the smallest one that keeps the await; judge it, like any patch, by predicted behavior and tests rather than by how well it reads.
You can check the central claim yourself. The mechanism predicts that concurrent first-time callers all get in, so run three against a fresh key. If you are working in a repo with this class, save the file below as tests/rate-limiter-race.ts (the import matches the existing specs); if not, the class is five lines to rebuild - the constructor takes (maxRequests, windowMs) and keeps its history in hits: Map<string, number[]> of timestamps. Run it with npx tsx tests/rate-limiter-race.ts:
import { RateLimiter } from "../src/payments/rate-limiter.js";
const limiter = new RateLimiter(2, 60_000);const verdicts = await Promise.all([ limiter.allowAsync("first-burst"), limiter.allowAsync("first-burst"), limiter.allowAsync("first-burst"),]);console.log(verdicts);The limit is 2. Predict the print, then run it (the mechanism says [ true, true, true ]: every concurrent first caller gets in). Then make the same three calls against the fixed shape - re-read after the await, insert-if-missing, synchronous check and write - and watch the third caller finally get false.
The toggle and the verbose view
Section titled “The toggle and the verbose view”Two smaller controls worth knowing. Extended thinking has a session toggle - Option+T on macOS, Alt+T on Windows and Linux - that turns it on or off without opening a menu (on Opus 5.5, Sonnet 5.5, Haiku 5.5, and the Fable models thinking cannot be turned off, so the toggle and a saved alwaysThinkingEnabled: false have no effect there). That is a different control from the effort level: the toggle is the on/off switch for extended thinking, while /effort sets how much thinking happens; the model-config reference owns how the two interact on the current version. The thinking output is collapsed by default; press Ctrl+O to toggle verbose mode and read the reasoning as gray italic text while it happens, which is how you would watch a block like the one above unfold.
To make thinking-on your global default, run /config and toggle thinking mode; it saves as alwaysThinkingEnabled in ~/.claude/settings.json. The keys and settings live in the same model-config reference - pin to the docs, not to muscle memory.
The one-turn escalation keyword
Section titled “The one-turn escalation keyword”Sometimes one turn is unusually hard and you do not want to touch a session-wide setting. Claude Code recognizes the keyword ultrathink anywhere in your message and requests deeper reasoning for that turn alone, without changing your saved effort setting - under the hood it adds an in-context instruction, and the effort level sent to the API stays the same. Because that level does not change, it also avoids the cache recompute that switching effort levels triggers:
> ultrathink: if allowAsync's Map were swapped for a store where get and set are separate network round trips, does the re-read-after-await fix still hold, or does the await stop being the only interleave point?Be precise here, because it is easy to assume more keywords work than do: ultrathink is the recognized one. Phrases like “think”, “think hard”, or “think more” pass through as ordinary prompt text - the agent may treat them as instructions to consider, but they do not trigger the escalation. For a one-turn escalation Claude Code recognizes, use ultrathink; for a stretch of work, raise the effort level. Current behavior is documented in model-config.
When deeper reasoning is wasted
Section titled “When deeper reasoning is wasted”The flip side: on a mechanical task - rewording an error message, a rename, a format change - there is nothing to reason about, so extra thinking is pure cost and latency for an answer that was never in doubt. Turning effort up does not make the rewrite better; it makes it slower and more expensive. Reasoning effort is a capability lever, and like the model choice it is paid for in cost.
- Genuinely hard - a race, a subtle design decision, a bug that only shows under load. Turn it up; deeper reasoning is what gives the real answer a chance against a plausible wrong one. It still does not certify the answer, so keep the verification habit whatever the effort level.
- Mechanical and unambiguous - copy, renames, formatting. Leave it low; you are paying for thinking the task does not have.
The heavy gear belongs on the hard problem, not on the workload as a whole - and both dials cost money, which is the discipline the closing lesson pulls together.