Claude Streaming Refusals: Detection and Handling
When you stream a response from Claude and the model decides mid-generation that it shouldn't continue — because the request touches something disallowed, ambiguous, or requires more context — you get a streaming refusal: a partial or truncated stream that ends differently from a normal completion. If your app isn't built to detect this, users see a cut-off sentence, a spinner that never resolves, or a broken UI state instead of a clear message.
This article covers how Claude signals refusals during streaming, how to detect them programmatically, and how to build UX that handles them without confusing your users.
How refusals show up in a stream
Unlike a hard API error, a refusal is usually a normal successful response with content that declines the request, plus a stop_reason that tells you why generation ended. In streaming mode, you get this information at the end of the event sequence, after the content_block_delta events, in the final message_delta event.
The stop reasons you'll typically see:
end_turn— normal completion, including refusals expressed as plain text (e.g., "I can't help with that request because...")max_tokens— the response was cut off by length, not a refusalstop_sequence— hit a custom stop sequencetool_use— the model wants to call a tool instead of answering directly
The tricky part: most refusals are just end_turn with refusal text, not a distinct machine-readable flag. Claude doesn't emit a special stop_reason: "refusal" — it declines in natural language, streamed like any other response. That means your detection logic has to look at content, not just metadata, unless you're also handling structural signals like an unusually short response or a stream that ends much earlier than expected.
Detecting a refusal during or after streaming
There's no single boolean flag to check, so practical detection combines a few signals:
- Response length relative to the prompt. A refusal is often much shorter than a normal answer to the same kind of request. If you expect a 500-word explanation and get 20 words, that's worth flagging.
- Common refusal phrasing. Claude's refusals tend to use recognizable patterns: "I can't help with that," "I'm not able to," "I won't provide," "This request involves... which I can't assist with." A lightweight regex or keyword check on the first chunk of content catches most cases.
- Stop reason plus low token count. If
stop_reasonisend_turnandoutput_tokensin the final usage block is very low compared to typical responses for that endpoint, treat it as a likely refusal and route it differently in your UI.
Here's a minimal pattern for catching refusals as you consume a stream:
const refusalPatterns = [
/i can'?t help with/i,
/i'?m not able to/i,
/i won'?t (provide|generate|write)/i,
/this request (involves|violates)/i,
];
let buffer = "";
for await (const event of stream) {
if (event.type === "content_block_delta") {
buffer += event.delta.text;
}
if (event.type === "message_delta" && event.delta.stop_reason) {
const looksLikeRefusal =
buffer.length < 200 &&
refusalPatterns.some((re) => re.test(buffer));
if (looksLikeRefusal) {
handleRefusal(buffer);
} else {
finalizeResponse(buffer);
}
}
}
This isn't perfect — false positives happen when a legitimate short answer starts with "I can't" for unrelated reasons — but combined with length heuristics it's reliable enough for production UX decisions like showing a "this request needs rephrasing" message instead of rendering the raw text as if it were a normal answer.
Handling refusals gracefully in your UI
Once you detect a likely refusal, don't just display the raw model text and move on. A few patterns that work well:
- Distinct visual treatment. Render refusals in a different style (muted color, an info icon) so users understand this isn't a normal answer cut short by an error.
- Offer a rephrase path. Since refusals are often triggered by ambiguous phrasing, a "try rephrasing your request" prompt recovers more sessions than a dead end.
- Log refusals separately from errors. Refusals are a valid model behavior, not a bug in your integration. Mixing them into your error dashboards makes debugging real failures harder.
- Don't retry blindly. Automatically resending the same prompt after a refusal usually produces the same refusal. If you retry, change the prompt (add context, narrow scope, remove ambiguous phrasing).
Refusals vs. actual streaming errors
It's worth separating refusals from genuine stream failures, since they need different handling:
| Situation | Signal | Action | |---|---|---| | Refusal | stop_reason: end_turn, short content matching refusal patterns | Show refusal UI, offer rephrase | | Rate limit | HTTP 429 before or during stream | Backoff and retry | | Connection drop | Stream closes without a message_stop event | Reconnect, resume or restart request | | Max tokens hit | stop_reason: max_tokens | Continue generation or increase token limit |
If you're building on SubToAPI rather than calling Claude directly, streaming responses come through the same event format via the /v1/messages endpoint with "stream": true, so this detection logic works unchanged — you just point requests at your sub_live_... API key instead of managing provider credentials directly. See the streaming docs for the exact event shapes and the messages docs for stop reason details.
A note on prompt design to reduce refusals
Some refusals are avoidable with better prompt structure:
- State the legitimate purpose up front ("I'm building a content moderation tool and need example flagged text for testing")
- Avoid phrasing that reads as a direct request for disallowed content even when your actual use case is benign
- Break multi-part requests into smaller, clearly-scoped calls rather than one large ambiguous prompt
- Use system prompts to set context about the application, which gives Claude more signal about intent
None of this eliminates refusals entirely — nor should it, since some are correct behavior — but it reduces false-positive refusals triggered by ambiguous phrasing.
questions
Does Claude return a special error code for refusals? No. Refusals typically come back as a normal successful response with stop_reason: end_turn and plain-text content declining the request. You detect them by inspecting content and length, not by checking for a distinct error.
Can I stop a refusal from rendering halfway through? You can buffer the first chunk or two of a stream, run it through refusal-pattern detection, and switch your UI treatment before rendering further deltas — but you can't prevent the model from generating refusal text in the first place except through better prompting.
Should I retry automatically after a refusal? Generally no. Retrying the identical prompt usually produces the same refusal. Adjust the prompt — add context, narrow the request, remove ambiguous phrasing — before resending.