Skip to content
skyl

Messages and Roles

Three roles, no system role, and why skyl does not police your ordering.

A conversation is a []skyl.Message, and each message is a role plus ordered content. There are exactly three roles, and one that pointedly does not exist.

You will learn

  • The three roles and what each is for
  • Why there is no system role
  • Why skyl refuses to validate role ordering
  • The constructors that cover the common shapes

The three roles#

const (
	RoleUser      Role = "user"
	RoleAssistant Role = "assistant"
	RoleTool      Role = "tool"
)
const (
	RoleUser      Role = "user"
	RoleAssistant Role = "assistant"
	RoleTool      Role = "tool"
)

RoleUser is input from your application or its users. RoleAssistant is what the model produced — you append it to replay prior turns. RoleTool carries the result of a tool the model asked you to run.

Role.Valid() reports whether a value is one skyl understands; anything else is rejected by Validate with ErrBadRequest.

There is no system role#

goCompiles
req := &skyl.Request{
	System:   "You are a terse Go expert.",  // ← a field
	Messages: []skyl.Message{skyl.UserText("What is a nil map?")},
}
req := &skyl.Request{
	System:   "You are a terse Go expert.",  // ← a field
	Messages: []skyl.Message{skyl.UserText("What is a nil map?")},
}

Providers place the system prompt in three different locations: a top-level system parameter for Anthropic, a leading system message for OpenAI, systemInstruction for Gemini. Modelling it as a role would force skyl to either pick one vendor's convention and translate, or make you know which.

A dedicated field means the adapter puts it where its provider expects, and you never think about it. See System Prompts.

Constructors#

Most turns are one run of text, so skyl provides constructors for the shapes you write constantly:

ConstructorProduces
skyl.UserText(s)A user message with one Text part
skyl.AssistantText(s)An assistant message with one Text part
skyl.UserImage(mediaType, data, caption)A user message with an Image and optional Text
skyl.ToolResultMessage(callID, content)A tool message answering a call
skyl.ToolErrorMessage(callID, content)The same, with IsError set

For anything else, build the struct directly:

skyl.Message{
	Role: skyl.RoleAssistant,
	Parts: []skyl.Part{
		skyl.Text{Text: "Let me check the weather."},
		skyl.ToolCall{ID: "call_1", Name: "get_weather", Arguments: args},
	},
}
skyl.Message{
	Role: skyl.RoleAssistant,
	Parts: []skyl.Part{
		skyl.Text{Text: "Let me check the weather."},
		skyl.ToolCall{ID: "call_1", Name: "get_weather", Arguments: args},
	},
}

skyl does not police ordering#

You can send two user turns in a row, or start with an assistant turn. skyl will not stop you.

Deep diveWhy not validate the conversation shape?

Because providers disagree about what is legal, and they change their minds. Some accept a leading assistant turn as a prefill; some reject it. Some accept consecutive user messages; some merge them; some 400.

If skyl rejected a shape that one vendor accepts, it would be deciding something it has no business deciding — and you would have no way to reach a capability your provider genuinely offers. An ordering a provider dislikes comes back as that provider's own error, classified as ErrBadRequest, which is strictly more information than a local rejection.

The one exception is structural validity: a message with no parts is rejected locally, because that is meaningless on every provider.

Reading a message back#

Message.Text() concatenates every Text part and ignores the rest, which is the common case. Message.ToolCalls() returns every ToolCall part in order.

goCompiles
fmt.Println(resp.Message.Text())        // just the prose
for _, c := range resp.Message.ToolCalls() {
	fmt.Println(c.Name, string(c.Arguments))
}
fmt.Println(resp.Message.Text())        // just the prose
for _, c := range resp.Message.ToolCalls() {
	fmt.Println(c.Name, string(c.Arguments))
}

Response.Text() and Response.ToolCalls() are shorthands for exactly these.

Recap

  • Three roles: user, assistant, tool. There is no system role.
  • System prompts live on Request.System because vendors place them differently.
  • Constructors cover the common shapes; build Message directly for mixed content.
  • skyl does not validate role ordering — providers disagree, so their error is more useful.
  • A message with no parts is rejected locally, because that is meaningless everywhere.
  • Do not prefill an assistant turn; several current models reject it.

Try out some challenges

Each of these is solvable with what this page covered. Run them against the sandbox — no API key needed.

Build a mixed-content assistant turn

Reconstruct an assistant turn that contained both prose and two tool calls, so it can be appended to a conversation.

Show hint

Order matters — parts are ordered, and the provider replays them in sequence.

Show solution
goCompiles
parts := []skyl.Part{skyl.Text{Text: "I'll check both cities."}}
for _, c := range calls {
	parts = append(parts, c)
}
turn := skyl.Message{Role: skyl.RoleAssistant, Parts: parts}
parts := []skyl.Part{skyl.Text{Text: "I'll check both cities."}}
for _, c := range calls {
	parts = append(parts, c)
}
turn := skyl.Message{Role: skyl.RoleAssistant, Parts: parts}

In practice you rarely build this by hand — Response.Message already is it, which is exactly why it is exposed in the same shape a request takes.

Edit this page on GitHub