DEV Community

Multigrid
Multigrid

Posted on • Originally published at multigrid.ai

stop_sequences in the Claude API: What Comes Back When One Fires

A stop sequence is a string that ends generation the moment the model produces it. The parameter is simple; what people get wrong is the response, because two fields change and the matched text is not in the place they look for it.

What the parameter does

stop_sequences is an optional top-level array of strings. If the model generates any of them, generation halts immediately — not at the end of the sentence, not at the end of the token, at the match.

{
  "model": "claude-opus-4-6",
  "max_tokens": 1024,
  "stop_sequences": ["\n\nHuman:", "</answer>"],
  "messages": [
    {"role": "user", "content": "Write the answer inside <answer> tags."}
  ]
}
Enter fullscreen mode Exit fullscreen mode

Matching is on the generated text, not on token boundaries, so a sequence that spans two tokens still fires. Multiple sequences are allowed and any of them can trigger; the response tells you which one did.

There is a documented cap on how many entries the array may contain, and it is stated in the Messages API reference. It is not reproduced here because it is a figure that has changed and this page will not be the thing that tells you a wrong one. Read it from the reference, and treat exceeding it as a 400 rather than a silent truncation of your array.

The two fields that change

On a normal completion the response carries stop_reason: "end_turn" and stop_sequence: null. When a stop sequence fires, both change:

{
  "id": "msg_01…",
  "type": "message",
  "role": "assistant",
  "content": [
    {"type": "text", "text": "<answer>Roll back with git revert --no-commit HEAD"}
  ],
  "stop_reason": "stop_sequence",
  "stop_sequence": "</answer>",
  "usage": {"input_tokens": 27, "output_tokens": 19}
}
Enter fullscreen mode Exit fullscreen mode

stop_reason becomes the literal string "stop_sequence", and the top-level stop_sequence field — null in every other case — carries the exact string that matched. With one sequence configured that second field is redundant; with several it is the only way to know which branch you are in without re-scanning the text yourself.

This matters because stop_sequence is a distinct outcome from end_turn and from max_tokens. All three produce a successful HTTP 200 with content in it, and only the first means the model finished saying what it wanted to say. See the full set of stop_reason values for the rest.

The matched text is not returned

The stop sequence itself is excluded from content. In the example above, the model generated </answer> and the response text ends just before it. This is the single most common source of confusion, because a naive parser that looks for the closing tag in the output never finds it and concludes the model ignored the instruction.

The consequence for building anything on top: if your downstream parser needs the delimiter, you reattach it yourself, using the value of the stop_sequence field. That is what the field is for.

const text = msg.content.find(b => b.type === "text")?.text ?? "";
const full = msg.stop_reason === "stop_sequence"
  ? text + msg.stop_sequence   // reattach what the API trimmed
  : text;
Enter fullscreen mode Exit fullscreen mode

Where it shows up in a stream

A streamed response does not carry stop_reason on the events that deliver text. It arrives near the end, on the message_delta event, together with the final output token count:

event: content_block_stop
data: {"type":"content_block_stop","index":0}

event: message_delta
data: {"type":"message_delta",
       "delta":{"stop_reason":"stop_sequence","stop_sequence":"</answer>"},
       "usage":{"output_tokens":19}}

event: message_stop
data: {"type":"message_stop"}
Enter fullscreen mode Exit fullscreen mode

Code that streams text to a user and never inspects message_delta will therefore render a response that stopped at a delimiter without ever knowing it did. See the full streaming event sequence for where each field lands.

There is a second streaming difference that is easy to miss. In a buffered response the trimmed text is simply absent from content, and you never see the model produce the sequence. In a stream the same thing is true — no delta carries the matched string — but because you have been appending deltas to a buffer, your buffer and the final message agree only if you built it from deltas alone. A parser that also watches for the delimiter in the delta text to decide when to stop will wait forever, because the delimiter never arrives.

Interactions that surprise people

Stop sequences match generated text, and generated text is not always the prose you were thinking of. Three interactions follow from that.

Tool calls. A tool call’s arguments are generated text too. If one of your stop sequences can appear inside a JSON argument — a closing brace, a quote-and-brace pair, a newline followed by a capitalised word — the sequence can fire in the middle of the tool input. What comes back is a tool_use block whose input was never completed, with stop_reason: "stop_sequence" rather than "tool_use". Code that switches on stop_reason will not even reach its tool branch, so the symptom is a model that appears to have stopped calling tools, with no error anywhere.

Thinking. On a model with reasoning enabled, the thinking block is generated before the answer. A stop sequence that fires inside it ends the response before any visible text exists at all — a successful 200 with a thinking block and nothing else. This is a strong argument against short or common stop sequences on thinking-enabled requests.

Billing. A stop sequence does not save you the tokens that were generated before it fired. Everything up to the match was produced and is charged, including anything the model wrote that you then discarded. Stop sequences save the tokens the model would have gone on to write, which is a real saving on a model that likes to add a closing paragraph, and no saving at all if the sequence fires at the end anyway.

When to reach for it, and when not to

  • Good use: bounding a delimited region. If the model is emitting a block you will parse, a closing delimiter as a stop sequence saves the tokens it would otherwise spend continuing past it, and gives you a definite end.
  • Good use: preventing role bleed. A sequence like "\n\nHuman:" stops a model that has started hallucinating both sides of a conversation. This is rarer than it was, but it costs nothing to keep.
  • Poor use: forcing structured output. Stop sequences were part of the old recipe for coaxing JSON out of a model — prefill an opening brace, stop on a closing one. The API now has purpose-built mechanisms for that, and they do not truncate valid output when a brace happens to appear inside a string. Use a tool with a schema, or structured outputs, instead.
  • Poor use: length control. A stop sequence cannot express “about two paragraphs”. max_tokens caps length; the prompt shapes it.

Related

Top comments (0)