Studio Notebook

Claude Code Atlas

Tool Search And Deferred Loading

Learn how Claude Code keeps some tools deferred and fetches them later through ToolSearch.

Why this matters

By this point in the tools subtree, you already know what a tool is. The next question is different: does the model have to see every full schema on turn one? Claude Code’s answer is no.

Big picture first

The deferred-loading story has three moving parts:

  1. utils/toolSearch.ts decides whether tool search is enabled and which tools count as deferred.
  2. tools/ToolSearchTool/prompt.ts explains to the model how to fetch those deferred tools later.
  3. tools/ToolSearchTool/ToolSearchTool.ts runs the actual keyword/exact-match lookup when the model asks.

This is why the registry chapter stopped at the runtime tool pool. Prompt-time deferral is a later layer on top of that pool.

ToolSearch prompt head

tools/ToolSearchTool/prompt.ts

Fetches full schema definitions for deferred tools so they can be called.

The location hint below explains where those deferred tools are announced.

getToolLocationHint() decides where deferred tools show up

function getToolLocationHint(): string {
  const deltaEnabled =
    process.env.USER_TYPE === 'ant' ||
    getFeatureValue_CACHED_MAY_BE_STALE('tengu_glacier_2xr', false)
  return deltaEnabled
    ? 'Deferred tools appear by name in <system-reminder> messages.'
    : 'Deferred tools appear by name in <available-deferred-tools> messages.'
}

This is the runtime switch for the location hint. The model does not always see the same message type.

isDeferredTool() decides who stays name-only

export function isDeferredTool(tool: Tool): boolean {
  // Explicit opt-out via _meta['anthropic/alwaysLoad'] - tool appears in the
  // initial prompt with full schema. Checked first so MCP tools can opt out.
  if (tool.alwaysLoad === true) return false

  // MCP tools are always deferred (workflow-specific)
  if (tool.isMcp === true) return true

  // Never defer ToolSearch itself - the model needs it to load everything else
  if (tool.name === TOOL_SEARCH_TOOL_NAME) return false

  // Fork-first experiment: Agent must be available turn 1, not behind ToolSearch.
  // Lazy require: static import of forkSubagent -> coordinatorMode creates a cycle
  // through constants/tools.ts at module init.
  if (feature('FORK_SUBAGENT') && tool.name === AGENT_TOOL_NAME) {
    type ForkMod = typeof import('../AgentTool/forkSubagent.js')
    const m = require('../AgentTool/forkSubagent.js') as ForkMod
    if (m.isForkSubagentEnabled()) return false
  }

  // Brief is the primary communication channel whenever the tool is present.
  // Its prompt contains the text-visibility contract, which the model must
  // see without a ToolSearch round-trip. No runtime gate needed here: this
  // tool's isEnabled() IS isBriefEnabled(), so being asked about its deferral
  // status implies the gate already passed.
  if (
    (feature('KAIROS') || feature('KAIROS_BRIEF')) &&
    BRIEF_TOOL_NAME &&
    tool.name === BRIEF_TOOL_NAME
  ) {
    return false
  }

  // SendUserFile is a file-delivery communication channel (sibling of Brief).
  // Must be immediately available without a ToolSearch round-trip.
  if (
    feature('KAIROS') &&
    SEND_USER_FILE_TOOL_NAME &&
    tool.name === SEND_USER_FILE_TOOL_NAME &&
    isReplBridgeActive()
  ) {
    return false
  }

  return tool.shouldDefer === true
}

This function is the first important filter. MCP tools are deferred by default, alwaysLoad opts a tool out, Brief and SendUserFile are immediate-availability exceptions, and ToolSearch itself must never be deferred because the model needs it to fetch everything else.

getToolSearchMode() decides whether the feature is active

export function getToolSearchMode(): ToolSearchMode {
  if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)) {
    return 'standard'
  }

  const value = process.env.ENABLE_TOOL_SEARCH
  const autoPercent = value ? parseAutoPercentage(value) : null
  if (autoPercent === 0) return 'tst'
  if (autoPercent === 100) return 'standard'
  if (isAutoToolSearchMode(value)) {
    return 'tst-auto'
  }

  if (isEnvTruthy(value)) return 'tst'
  if (isEnvDefinedFalsy(process.env.ENABLE_TOOL_SEARCH)) return 'standard'
  return 'tst'
}

This is the policy switchboard. The important beginner lesson is not the exact environment variable syntax. The important lesson is that deferred discovery is a mode, not just one hard-coded behavior.

ToolSearchTool.ts is the lookup engine

const exactMatch =
  deferredTools.find(t => t.name.toLowerCase() === queryLower) ??
  tools.find(t => t.name.toLowerCase() === queryLower)
if (exactMatch) {
  return [exactMatch.name]
}
const getDeferredToolTokenCount = memoize(
  async (
    tools: Tools,
    getToolPermissionContext: () => Promise<ToolPermissionContext>,
    agents: AgentDefinition[],
    model: string,
  ): Promise<number | null> => {

ToolSearchTool.ts handles the model-facing search request. utils/toolSearch.ts handles when deferral is worthwhile at all, including token-based heuristics like getDeferredToolTokenCount(...).

Takeaways

  • Deferred loading happens after the runtime tool pool is assembled.
  • ToolSearch is the bridge from a name-only deferred tool to a callable tool.
  • MCP tools are deferred by default, but `alwaysLoad` and mode checks can change that story.