When 'No Error' Is the Bug: Forced Tool Choice and a Silently Skipped Web Search
Aug 4, 2026 · 3 min read
I was debugging a Claude API call that returned an empty result with no error. No exception, no failed request, no rate limit warning — just nothing, every time, for a query that should have had real answers to return.
That combination is worse than a crash. A crash tells you where to look. This told me the code was working fine, right up until it wasn't.
The setup
I was building a discovery step in a small enrichment pipeline: give Claude a web_search tool plus a custom structured-output tool, ask it to research a target and return the results in a fixed shape. To make sure the output actually came back in that shape — not prose, not a half-formed guess — I forced tool_choice to point directly at the custom extractor tool.
The assumption seemed reasonable: Claude searches, finds what it needs, then calls the extractor to package the findings. One call, two tools, a clean handoff between them.
What actually happens
Forcing tool_choice to a specific tool doesn't mean "use this tool eventually." It means the model calls that tool immediately, on this turn. There's no intermediate step where it's still free to reach for web_search first — offering web_search alongside a forced tool doesn't put it in the flow, it just makes it unreachable.
So the model did exactly what I told it to do: it called the extractor right away, with nothing to extract from. And because the prompt also told it not to invent or guess at data it didn't have, the honest response was an empty list. Every field correctly marked as unknown. Schema valid. Call successful. Completely useless.
That's what made it hard to catch — nothing about the response looked like a bug. It looked like a well-behaved model reporting "no data" on a call where, upstream, the data genuinely didn't exist yet.
The fix: split the call in two
The fix isn't a smarter prompt. It's structural — separate the searching from the extracting into two calls instead of one:
// Call 1: unforced, web_search offered, model free to search
// and write up findings in plain text — no tool_choice constraint
const researchResponse = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 1500,
tools: [webSearchTool],
messages: [
{ role: "user", content: `Research ${target} and summarize what you find.` }
]
});
const researchNotes = extractText(researchResponse);
// Call 2: forced tool_choice on the extractor, but no web_search
// offered this time — there's nothing left for the model to skip
const structuredResponse = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 1000,
tools: [extractorTool],
tool_choice: { type: "tool", name: "extract_company_data" },
messages: [
{
role: "user",
content: `Extract structured data from this research:\n\n${researchNotes}`
}
]
});The first call has one job: let the model actually search, with nothing else competing for its first move. The second call has one job: force a specific, well-typed shape out of information that already exists. Each call only does the thing it's actually good at forcing or not forcing.
It costs one extra round trip. That's a small, predictable price for a failure mode that otherwise shows up as silence.
The broader lesson
tool_choice is a determinism knob, not a sequencing tool. It answers "which tool must the model call on this turn," not "which tools should the model use over the course of this task." Those are different questions, and it's easy to write a prompt that quietly assumes the second one while the API is only answering the first.
If you're combining a forced tool with an optional one in the same call, ask what happens to the optional one — not in theory, in your actual test output. In my case the answer was: nothing happens to it, and the model won't tell you that's what occurred. It'll just hand you a correctly-shaped answer to a question nobody actually asked yet.
Non-obvious failure mode, obvious once you see it. Sharing it mainly so the next person staring at an empty array spends less time on it than I did.