Request.System is a plain string field. That looks like a small decision and
is actually the reason your prompt works identically on three vendors that place
it in three different places.
You will learn
- Where each adapter puts the system prompt on the wire
- Why it is a field rather than a role
- How it interacts with token accounting and caching
- What to do when a provider wants several system blocks
Setting one#
req := &skyl.Request{
Model: "claude-opus-5",
System: "You are a terse Go expert. Answer in at most one sentence, and prefer standard-library solutions.",
Messages: []skyl.Message{skyl.UserText("How do I copy a map?")},
}req := &skyl.Request{
Model: "claude-opus-5",
System: "You are a terse Go expert. Answer in at most one sentence, and prefer standard-library solutions.",
Messages: []skyl.Message{skyl.UserText("How do I copy a map?")},
}Where it lands#
| Adapter | Wire location |
|---|---|
| anthropic | system |
| openai / openaicompat | a leading system message |
| gemini | systemInstruction |
All three are ✅ mapped in the feature matrix
— this is one of the fields where skyl genuinely hides a real difference at no
cost.
Deep diveWhy not model it as a role?
Two reasons.
The first is placement. If system were a role, skyl would have to decide what
a system message in the middle of a conversation means. Anthropic has no such
concept — there is one system parameter, not a message stream. Gemini is the
same. So skyl would have to either concatenate mid-conversation system messages
into the top-level field (surprising) or reject them (arbitrary).
The second is that a field cannot be misordered. A role can appear anywhere, and "a system message after three user turns" behaves differently on every vendor. A field has exactly one meaning.
The cost is that you cannot express OpenAI's developer role separately from
system. If you need that, ProviderOptions reaches it.
Token accounting#
The system prompt counts toward Usage.InputTokens on every provider — it is
input like any other. That matters when it is long: a 2,000-token system prompt
on a high-volume classifier is 2,000 tokens per call.
This is exactly what prompt caching is for. On Anthropic, mark it cacheable
through ProviderOptions:
req.ProviderOptions = map[string]any{
// Anthropic applies options by JSON path, so this sets one nested field
// without disturbing its siblings.
"system.0.cache_control": map[string]any{"type": "ephemeral"},
}req.ProviderOptions = map[string]any{
// Anthropic applies options by JSON path, so this sets one nested field
// without disturbing its siblings.
"system.0.cache_control": map[string]any{"type": "ephemeral"},
}Then Usage.CacheReadTokens tells you how much of InputTokens was served at a
discount. Remember that cache figures are a breakdown of InputTokens, not an
addition to it — see Token Usage.
Several system blocks#
Some providers accept a list of system blocks so parts of it can be cached
independently. skyl models one string, because that is what all three have in
common. To reach the list form, restate it through ProviderOptions:
// Anthropic: replace the whole system field with a block list.
req.System = "" // avoid sending it twice
req.ProviderOptions = map[string]any{
"system": []map[string]any{
{"type": "text", "text": stableInstructions, "cache_control": map[string]any{"type": "ephemeral"}},
{"type": "text", "text": perRequestContext},
},
}// Anthropic: replace the whole system field with a block list.
req.System = "" // avoid sending it twice
req.ProviderOptions = map[string]any{
"system": []map[string]any{
{"type": "text", "text": stableInstructions, "cache_control": map[string]any{"type": "ephemeral"}},
{"type": "text", "text": perRequestContext},
},
}Clear Request.System when you do this, or you will send both and get whichever
the provider prefers.
Empty is fine#
Leaving System empty sends no system prompt at all — not an empty one. That
distinction matters on providers that treat an empty system string as a real
instruction to be terse.
Recap
Systemis a field, so skyl can place it where each of three vendors expects.- It counts toward
InputTokenson every provider, every call. - Prompt caching is reachable through
ProviderOptions, and only Anthropic supports JSON-path options. - The multi-block form requires clearing
Request.Systemto avoid sending it twice. - Empty means "no system prompt", not "an empty one".
Try out some challenges
Each of these is solvable with what this page covered. Run them against the sandbox — no API key needed.
Measure what your system prompt costs
Report how many of your input tokens are the system prompt, across a run.
Show hint
Send the same conversation twice — once with the system prompt and once
without — and compare InputTokens.
Show solution
with, err := client.Complete(ctx, req)
if err != nil {
return err
}
bare := *req
bare.System = ""
without, err := client.Complete(ctx, &bare)
if err != nil {
return err
}
fmt.Printf("system prompt costs ~%d tokens per call\n",
with.Usage.InputTokens-without.Usage.InputTokens)with, err := client.Complete(ctx, req)
if err != nil {
return err
}
bare := *req
bare.System = ""
without, err := client.Complete(ctx, &bare)
if err != nil {
return err
}
fmt.Printf("system prompt costs ~%d tokens per call\n",
with.Usage.InputTokens-without.Usage.InputTokens)Multiply by your call volume. On a classifier doing a million calls a day, a system prompt you never re-read is a line item.