Why the Claude API Rejects a Second System Message
8 min read · updated August 11, 2026
The Claude Messages API has no system role. The instruction goes in a top-level system field, and a message with role: "system" inside the messages array is a validation error — which is the first thing most people porting from another chat API hit.
The error
Send a body shaped like an OpenAI Chat Completions request and you get a 400 that names the problem precisely:
$ curl https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{
"model": "claude-sonnet-4-5-20250929",
"max_tokens": 1024,
"messages": [
{"role": "system", "content": "You are a terse assistant."},
{"role": "user", "content": "Summarise this ticket."}
]
}'
HTTP/1.1 400 Bad Request
{
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "messages.0: Unexpected role \"system\". The Messages API accepts a top-level \"system\" parameter, not \"system\" as an input message role."
}
}Note the messages.0 prefix. The validator reports the index of the offending message, which is worth reading when you have a long history assembled from several sources and only one of them is wrong — it tells you which turn to look at rather than making you scan the array.
The corrected body:
{
"model": "claude-sonnet-4-5-20250929",
"max_tokens": 1024,
"system": "You are a terse assistant.",
"messages": [
{"role": "user", "content": "Summarise this ticket."}
]
}It follows that there is no such thing as a second system message here. Not because two are forbidden, but because system is a field on the request rather than an entry in a list, and a field has one value. The question “how do I send two system messages to Claude” has no answer in the sense it is asked; it has an answer in the sense that two instructions become one field, which is what the porting section below is about.
It is worth noticing that the error message is unusually helpful about this. It does not merely reject the role, it names the parameter you should have used. Errors that tell you the fix are rare enough that it is worth reading them completely before searching for the message — which is, in fairness, probably how you arrived here.
message string is human-facing and its exact wording has changed between validation-layer versions. Branch on the 400 and error.type. Field definitions are in Anthropic’s Messages API reference.Why system is not a message
It is easy to read this as gratuitous API divergence. It is not; the separation buys three things that a system-role-in-the-array design gives up.
The instruction cannot be forged by the conversation
If the system instruction is an element of the same list as user input, then anything that can append to that list can append a system instruction. That is a real vulnerability class in applications that build the array from stored records, from a database of conversation history, or from a client that sends its own message objects. When the instruction lives in a separate field, a user-supplied message cannot become one no matter what it contains. It can still contain text arguing that it is a system message, which is ordinary prompt injection; it cannot occupy the structural position.
It is a clean cache boundary
The system prompt is the part of a request most likely to be identical across every call your application makes, which makes it the natural prefix to cache. Because it is a separate field, a cache_control breakpoint can be placed on it without reasoning about where in a message list the stable part ends. See cache_control breakpoints.
Alternation stays simple
The Messages API expects messages to be a conversation: user and assistant turns, starting with a user turn. Removing a third role from that list keeps the validation rule to one sentence, and keeps the array a faithful transcript of the exchange — which is what makes prefilling the assistant turn a coherent feature rather than a special case.
Porting code that sends several
Real OpenAI-shaped code frequently has more than one system message: a base persona, a tenant-specific policy appended by middleware, a per-request instruction. All of them have to become one field.
def to_anthropic(openai_messages):
"""Split an OpenAI-shaped array into (system, messages)."""
system_parts, messages = [], []
for m in openai_messages:
role = m["role"]
if role in ("system", "developer"):
system_parts.append(m["content"])
elif role in ("user", "assistant"):
messages.append({"role": role, "content": m["content"]})
else:
raise ValueError(f"unmappable role: {role}")
return "\n\n".join(system_parts), messagesThree things that conversion has to get right, and which a naive version gets wrong.
- Order and separation. Join with a blank line, not a space. Two instructions run together into one paragraph read as one instruction, and the second frequently qualifies the first.
- Position is not preserved. An OpenAI array can place a system message after several turns — a mid-conversation instruction change. Hoisting it to the top loses the fact that it applied only from that point on. Where that matters, the faithful translation is a user turn at the same position carrying the new instruction, not a line appended to
system. - Tool results do not map. A
role: "tool"message becomes atool_resultcontent block inside a user message on the Messages API, which is a different shape entirely and not something a role rename can cover. The function above raises rather than guessing, deliberately.
The developer role is folded in above because OpenAI introduced it as the successor to the system role on its reasoning models; Anthropic has no equivalent, so both land in the same field. See the developer message role.
System as an array of blocks
system accepts a plain string or an array of text content blocks. The array form exists mainly so a cache breakpoint can sit part way through it — a long stable policy that is cached, followed by a short volatile section that is not:
"system": [
{
"type": "text",
"text": "<8,000 tokens of product documentation and policy>",
"cache_control": {"type": "ephemeral"}
},
{
"type": "text",
"text": "The current date is 2026-08-11. The user is on the Team plan."
}
]Everything up to and including the marked block is cacheable; the volatile tail is not, and changing it does not invalidate the cached prefix. Putting the date at the top of a single string instead would invalidate the entire cached system prompt daily, which is the sort of quiet cost regression that survives for months.
The general rule the array form encodes is that a prompt should be ordered by volatility, most stable first. That is not a caching trick bolted onto the API; it is what a prefix cache can express, and the array of blocks exists so that the boundary between stable and volatile can be stated rather than inferred. If you are converting a single concatenated system string into blocks, the useful first question is which parts of it change per request, per user, per day and never — and the answer usually reorders the prompt.
The neighbouring validation errors
The same validator produces three other errors that arrive together during a port, and recognising them saves a round of debugging:
- Consecutive same-role turns.
messages: roles must alternate between "user" and "assistant". Two user messages in a row is a rejection, not a concatenation. Merge them yourself, or put both pieces in one message as two content blocks. - An assistant turn first. The array must begin with a user turn. Stripping a system message from the front of an OpenAI-shaped array can leave an assistant message leading, which fails for a different reason than the one you were fixing.
- Empty content. An empty string or empty content array is rejected. This shows up when a filtering step removes the only block in a message and leaves the envelope behind.
All four are validation failures before generation, so none of them costs tokens, and all four are deterministic — a request that fails validation will fail identically every time. That makes them the cheapest class of bug in this API to fix, and the reason to run a single representative request through by hand before wiring up a conversion layer.