Free playbooks in your inbox
how-to · Claude Fable 5

How to Prove Claude Fable 5 Wrote the Code You Are About to Ship

Read two fields in the response and you can prove which model did the thinking, which is what lets you put client work on its judgment instead of hoping. On Anthropic's own coding benchmark, 20.9% of trials tripped a claude fable 5 refusal and quietly finished on Opus 4.8.

From the youcanbuildthings catalog ▸ Build-tested

Hello builders,

Two fields in the response prove which model did the thinking on a request, and that proof is what lets you put real work on its judgment instead of hoping. A security engineer put the problem best: every one of my questions is getting flagged down to 4.8, and I am just doing software security research, and on Anthropic’s own coding benchmark 20.9% of trials fell back to Opus 4.8 mid-run. That is a claude fable 5 refusal doing its job badly, and the reason nothing warned you is that a refusal comes back as a successful HTTP 200, so your work continues on Opus 4.8 and the session looks completely normal.

That is one request in five, on their evals, on work nobody would call adversarial.

A refusal-detection decision tree. The orange top node reads FABLE 5, architect seat. It flows into a decision diamond labelled SILENT FALLBACK? containing three checks: usage.iterations arrow fallback_message, model arrow claude-opus-4-8?, and below a dashed rule, the plain-language version, asked Fable, got Opus? The diamond branches to three green outcome pills, REROUTE, REPHRASE and ACCEPT. A green stat bar reads 20.9% of trials silently fell back to Opus. A monospace strip along the bottom lists the category values cyber, bio, frontier_llm, reasoning_extraction, null.

Everything in this article hangs off that one diamond. Two fields tell you whether you were switched, five category values tell you why, and three outcomes are all you ever do about it.

A refusal is a 200

Start with the mechanic, because almost nobody knows it and it is the reason your dashboards are green.

When the safety layer declines a request, it doesn’t throw. It returns a completely successful HTTP 200 with stop_reason: "refusal" and a stop_details object attached. Your error monitoring watches for 5xx and elevated error rates. A refusal is neither. We can be getting bounced on a quarter of our requests and every board we own will look perfect.

stop_details.category names the policy area. Five values are documented, and the docs are unusually honest about what else sets them off:

  • cyber: the request could enable cyber harm, such as malware or exploit development. Benign cybersecurity work can also trigger this category.
  • bio: the request could enable biological harm, such as dangerous lab methods. Beneficial life sciences work can also trigger this category.
  • frontier_llm: the request could assist the development of competing AI models. Benign machine learning work can also trigger this category.
  • reasoning_extraction: the request asks the model to reproduce its internal reasoning in the response text.
  • general_harms: the request could be related to an area determined to be harmful. Benign work might sometimes trigger this category.

Read those three “can also trigger” admissions again. That’s the vendor writing down, in its own reference documentation, that the security engineer complaining about being flagged for security research was right. (Refusals and fallback, Claude Platform Docs)

category can also come back null, which is a permanent valid value meaning the refusal maps to no named area rather than a placeholder waiting to be filled in.

One pitfall the docs call out directly, and we should obey it, because getting this wrong builds a detector that silently stops working:

Branch on stop_reason or stop_details.type, not on content or the inner stop_details fields. The stop_details object is always present on a refusal, but its category and explanation fields can be null.

The explanation string is display text meant for a human to read, and its wording is not a stable contract, so it is the one field to leave alone in code.

Now the correction I want in early, because it is the single most repeated wrong thing about this behavior.

“I’m paying Fable rates for a refusal” isn’t true. A refusal that arrives before any output is not billed at all: content comes back empty, token counts appear in usage but are not charged, and only your rate limit takes the hit. A mid-stream refusal is different and does bill the input plus whatever already streamed, so we discard the partial and do not build on it.

The real cost is the next thing that happens, and it is sneakier than a wasted call. Claude Code reroutes you to Opus 4.8 and serves you a perfectly good answer at $5/$25 per million tokens. You didn’t choose that spend. Worse, you may have just made an architecture decision on Opus-grade judgment while believing it was Fable-grade, which costs more than the rate difference does.

Asked Fable, got Opus?

That question is the whole detector, and three fields answer it.

The top-level model field reports whoever produced the message you are holding. usage.iterations records every attempt: the model that declined shows up as an ordinary message entry, and the model that actually served the turn shows up as a fallback_message entry. And a fallback content block marks the exact point where one model’s output gives way to the next.

{
  "model": "claude-opus-4-8",
  "stop_reason": "end_turn",
  "content": [{"type": "fallback",
               "from": {"model": "claude-fable-5"},
               "to":   {"model": "claude-opus-4-8"}}],
  "usage": {"iterations": [
      {"type": "message"},
      {"type": "fallback_message"}
  ]}
}

Both models are named in the object. We never have to guess which one answered. The check the docs themselves publish is two expressions:

fallback_ran = any(
    iteration.type == "fallback_message"
    for iteration in response.usage.iterations or []
)
served_by_fallback = fallback_ran and response.stop_reason != "refusal"

Wire that into every code path that routes work to Fable, then act on it. If served_by_fallback is true, you are reading Opus output on a Fable seat.

In Claude Code headless we do not need the SDK object at all. The CLI wraps the reply in a result envelope, so we read the served model out of modelUsage, taking the entry that did the real work:

served=$(claude -p --model fable --output-format json "your task" \
  | jq -r '.modelUsage | to_entries | max_by(.value.costUSD) | .key')
[ "$served" = "claude-fable-5" ] || echo "WARNING: $served served this, not Fable" >&2

Inspect the JSON keys once against your own CLI version before trusting that shape, then never think about it again. Interactively the tell is simpler: Claude Code shows a banner when it switches. That banner is easy to scroll past at 2am, which is exactly why we want the scripted check.

The nuance everyone gets wrong

Whether you get switched silently depends entirely on which surface you are on, and the two behave in opposite directions.

On the raw API, a refusal just stops. There is no silent fallback. The request declines and nothing else happens unless you have explicitly opted in by sending a fallbacks parameter.

On Claude Code and the other consumer surfaces, built-in Opus 4.8 fallback ships on by default. You’re opted in whether you know it or not, and that is precisely where most of us live.

So a sentence like “Fable automatically falls back to Opus” is false about half the time, and it is worth naming the surface whenever you write it down for somebody else.

One more trap if you fan work out to sub-agents: an opt-in fallbacks parameter applies to the request you set it on and does not propagate into the calls your sub-agents make. Set it once on the parent and every branch underneath is running bare. A sub-agent that trips the wall with no fallback of its own just stops, deep inside a fan-out where we are least likely to be watching. Budget your refusal handling per request, not per session.

Before deciding the wall is being unreasonable, check whether we are asking for the thing it blocks. reasoning_extraction fires on requests to reproduce internal reasoning in the response, and half of us pasted exactly that boilerplate everywhere for the older models:

grep -rniE 'show your (thinking|reasoning|work)|explain your reasoning|think out loud|chain of thought' \
  CLAUDE.md .claude/ docs/ 2>/dev/null

Every line that comes back is a line begging the model to transcribe itself:

CLAUDE.md:12:Please show your thinking as you go.
.claude/skills/deep-review.md:4:explain your reasoning in the response

Reword each one to ask for the decision instead of the transcript. “State the fix and the one-sentence why” gets you the useful part and trips nothing.

Reroute, rephrase, or accept

Once we can see the wall, there are exactly three responses and no fourth.

Reroute when you want the work done and Fable’s judgment is not essential to it. Send it to a seat on purpose instead of letting the classifier pick for you, so that you know what you are paying for and why. The thing to avoid here is carrying on with output you did not order, since a detected switch means the run’s assumptions were wrong and not just its bill.

Rephrase when the flag is a false positive on genuinely benign work. Describe the goal rather than the flagged mechanism. It is trial and error, it takes thirty seconds, and on innocent work it usually clears.

Accept when the wall is right, because sometimes it is. The classifiers cover four real areas and a general one, and a widely-upvoted comment cut against the pile-on with a fair question: am I the only one who understands all these safeguards? A refusal on genuinely dangerous work is the product doing its job, and rerouting around that one is not a win.

The honest summary is that the wall is imperfect and improving, and the vendor says so out loud. What we can do something about is the not-knowing, since a refusal turns out to be a structured event with a documented shape rather than a random failure, and once we can look at any response and say “that was Opus, not Fable,” the part we control is back in our hands.

So go add those two expressions to whatever script routes your work to Fable, run one real task through it, and read the served model off the result. Then run it on ten tasks and count how many came back on the wrong seat. Whatever that number is, it was already happening yesterday. The only thing that’s changed is that now we can see it.

Now go build something this weekend!

John Cook

Why trust this? Every youcanbuildthings guide is pulled from a build-tested book: code that ran in production before it was written down.