Nearly everyone arrives at the Messages API from a chat-completions shape where the system prompt is the first element of the messages array. Claude does not accept it there, and the reason is not arbitrary — the separation is load-bearing for caching and for instruction priority.
The request shape
system is a sibling of model, max_tokens and messages, not an element inside messages. Here is a complete request that would run:
curl https://api.anthropic.com/v1/messages \
-H "content-type: application/json" \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "claude-opus-4-6",
"max_tokens": 1024,
"system": "You are a release engineer. Answer with commands, not prose.",
"messages": [
{"role": "user", "content": "How do I roll back the last deploy?"}
]
}'
The messages array holds only the conversation: turns with role user and role assistant. The first message must be a user turn. Everything that is instruction rather than conversation goes in the top-level field.
The error if you use a system role
Put the system prompt where a chat-completions request would put it and the API rejects the request outright, before any generation. The 400 response names the problem and the fix in the same sentence:
{
"model": "claude-opus-4-6",
"max_tokens": 1024,
"messages": [
{"role": "system", "content": "You are a release engineer."},
{"role": "user", "content": "How do I roll back the last deploy?"}
]
}
// HTTP 400
{
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "messages.0.role: Input should be 'user' or 'assistant'"
}
}
The exact wording has varied across API versions — some versions return a message that explicitly points at the top-level system parameter — but the class of error is stable: it is an invalid_request_error on the offending message index, and it costs nothing because nothing was generated. It is a fast, loud failure, which is the good kind.
The array form, and why it exists
system accepts either a plain string or an array of text blocks. The array form looks like pointless ceremony until you want to cache part of the prompt and not the rest:
"system": [
{
"type": "text",
"text": "<20,000 tokens of API reference the model should have>",
"cache_control": {"type": "ephemeral"}
},
{
"type": "text",
"text": "The current on-call engineer is Priya."
}
]
The cache_control marker is a property of a content block, so the string form has nowhere to put it. Splitting the prompt into a stable block and a volatile one lets the stable part be cached while the changing part is appended after the cache boundary. Order matters: caching is a prefix match, so the volatile block must come last or it invalidates everything after it. See cache_control breakpoints for the placement rules.
Render order for the whole request is tools, then system, then messages. That is why a cache breakpoint on the last system block also caches the tool definitions above it, and why adding a tool invalidates a system-prompt cache.
The one place a system role is legal
There is now a narrow exception, and it is worth knowing because it reads like a contradiction. On some recent models, a message with role system is accepted inside the messages array — appended mid-conversation, never as the first element — as a way to deliver an operator instruction that arrives after the conversation has started.
The motivation is caching. Editing the top-level system field changes the very front of the prompt and invalidates the cached prefix for the entire conversation; appending a system message after the existing history leaves that prefix intact. It does not replace the top-level field, it is model-gated, and an unsupported model returns a 400 saying the role is not supported. The rule for the initial system prompt is unchanged: it goes in the parameter.
Which models accept a mid-conversation system message changes with releases. Check the Messages API reference before relying on it, and catch the 400 as a fallback path rather than assuming support.
Why the user-turn workaround is worse
Faced with the 400, the quickest fix is to move the system text into the first user message and prefix it with something like Instructions:. The request then succeeds and the model broadly complies, which is exactly why this workaround survives in codebases long after anyone remembers choosing it. Three things are worse about it, and none of them show up in testing.
- Instruction weight. Content in the system field is treated as operator instruction; content in a user turn is treated as something the user said. Those are not the same authority, and the gap shows up precisely when it matters — when a later user message contradicts the instruction. A constraint in the system field tends to hold. The same words in turn one are just an earlier opinion in the conversation, and a determined user can talk over them.
- Cache placement. The system field renders before the messages, so a cache breakpoint at its end covers the tools and the instructions in one stable prefix. The same text inside
messages[0]is still cacheable, but it now sits inside the conversation, so anything you do to the conversation’s early turns — including the trimming you will eventually want — reaches back and invalidates it. - Turn structure. A first user turn containing 2,000 tokens of instruction followed by an actual question is a strange shape, and it gets stranger over a long conversation, where the model is re-reading a wall of setup as though it were part of the dialogue. Multi-turn behaviour drifts in ways that are hard to attribute back to the cause.
The related workaround — putting instructions in the last user turn instead, so they are most recent — trades one problem for another. Recency does buy compliance, which is why the pattern persists for a single hard constraint. But the instructions are now duplicated on every request at the end of the prompt, where they defeat caching completely, and they are interleaved with the user’s actual words rather than separated from them.
The field costs one line to use correctly. There is no case where the workaround is the better engineering.
Porting from an OpenAI-shaped request
The mechanical translation is three steps, and the third one catches people:
- Lift the system message out of
messagesand into the top-levelsystemfield. - Add
max_tokens. It has no default on this API and its absence is a 400. - Merge multiple system messages into one. A chat-completions request can carry several; the Claude field is singular, so concatenate them in order rather than picking one — see the multiple system messages error.
The remaining difference is behavioural rather than structural. The system field carries more instruction weight on this API than a system message does on some others, so prompts ported across often need their emphasis dialled down rather than up: an instruction written in capitals to overcome a different model’s reluctance tends to over-apply here.
Top comments (0)