Messages
Messages are the shared context unit for BeamWeaver models, agents, prompts, tools, and graph state. A message carries:
-
a role:
:system,:user,:assistant, or:tool -
content: a string or a list of content blocks
-
metadata: IDs, names, usage, provider response details, tool calls, artifacts, and server-side tool data
BeamWeaver keeps one persistent public message value: %BeamWeaver.Core.Message{}. Role-specific constructors create that same struct.
Basic Usage
Create messages with BeamWeaver.Core.Message and pass them to any chat model:
alias BeamWeaver.Core.{ChatModel, Message}
{:ok, model} = BeamWeaver.Models.init_chat_model("openai:gpt-5-nano")
messages = [
Message.system("You are a helpful assistant."),
Message.user("Hello, how are you?")
]
{:ok, response} = ChatModel.invoke(model, messages)
IO.puts(Message.text(response))
Text Prompts
For a single standalone request, pass a string. The chat model boundary treats it as a user message:
{:ok, response} =
BeamWeaver.Core.ChatModel.invoke(model, "Write a haiku about spring")
Use a text prompt when there is no conversation history, no system instruction, and no multimodal input.
Message Prompts
Use message lists for multi-turn conversations, system instructions, tool calls, and multimodal content:
alias BeamWeaver.Core.Message
messages = [
Message.system("You are a poetry expert."),
Message.user("Write a haiku about spring."),
Message.assistant("Cherry blossoms bloom..."),
Message.user("Make it shorter.")
]
{:ok, response} = BeamWeaver.Core.ChatModel.invoke(model, messages)
Map Format
BeamWeaver can normalize OpenAI-style maps and role/content tuples through the BeamWeaver.Core.MessageLike protocol:
alias BeamWeaver.Core.Messages.Utils
{:ok, messages} =
Utils.normalize([
%{"role" => "system", "content" => "You are a poetry expert."},
%{"role" => "user", "content" => "Write a haiku about spring."},
{:assistant, "Cherry blossoms bloom..."}
])
Prefer constructors in new application code. Map normalization is useful at transport boundaries, replay fixtures, migration paths, and user-supplied data where the message shape arrives as plain JSON.
Message Roles
| Role | Constructor | Purpose |
|---|---|---|
:system
|
Message.system/2
| instructions that shape model behavior |
:user
|
Message.user/2
| user input, multimodal requests, or provider tool-result blocks |
:assistant
|
Message.assistant/2
| model output, tool calls, reasoning, usage, and response metadata |
:tool
|
Message.tool/2
| client-side tool execution results sent back to the model |
System Messages
System messages are instructions or persistent context:
alias BeamWeaver.Core.Message
messages = [
Message.system("""
You are a senior Elixir developer.
Provide concise code examples and explain tradeoffs.
"""),
Message.user("How do I create a supervised worker?")
]
Provider-specific prompt-cache or routing hints live in metadata or content block metadata and are interpreted only by provider request builders that understand them.
Message.system([
%{type: :text, text: "You analyze literary works."},
%{type: :text, text: "<long source text>", metadata: %{cache_hint: :ephemeral}}
])
User Messages
User messages represent user input. A plain string is enough for text:
Message.user("What is machine learning?", id: "msg_123", name: "alice")
The :name field is preserved on the message and passed to providers that accept it. Some providers ignore names or restrict characters, so keep names short and provider-safe when they are model-visible.
For multimodal input, provide content blocks:
alias BeamWeaver.Core.{ContentBlock, Message}
message =
Message.user([
ContentBlock.text("Describe this image."),
ContentBlock.image(%{url: "https://example.com/path/to/image.jpg"})
])
Assistant Messages
Assistant messages are model outputs. They may include text, content blocks, tool calls, provider response metadata, and token usage:
message =
Message.assistant("I can help with that.",
id: "msg_456",
response_metadata: %{"model" => "gpt-5-nano", "finish_reason" => "stop"},
usage_metadata: %{
input_tokens: 8,
output_tokens: 24,
total_tokens: 32
}
)
Message.text(message)
message.usage_metadata
message.response_metadata
You can add an assistant message manually when restoring history:
messages = [
Message.system("You are helpful."),
Message.user("Can you help me?"),
Message.assistant("Yes. What do you need?"),
Message.user("What is 2 + 2?")
]
Tool Calls
When a model requests client-side tool execution, tool calls are stored on the assistant message:
message =
Message.assistant("",
tool_calls: [
%{id: "call_weather", name: "get_weather", args: %{"location" => "Paris"}}
]
)
for call <- message.tool_calls do
IO.inspect({Map.get(call, :name) || call["name"], Map.get(call, :args) || call["args"]})
end
Standalone model calls return tool-call requests. Agents execute the tool loop for you.
Token Usage
Provider token accounting is stored in message.usage_metadata when returned:
{:ok, response} = BeamWeaver.Core.ChatModel.invoke(model, "Hello!")
case response.usage_metadata do
nil -> :provider_did_not_return_usage
usage -> Map.get(usage, :total_tokens) || usage["total_tokens"]
end
For aggregate application usage, collect message usage maps directly or consume BeamWeaver tracing and telemetry events.
Tool Messages
Tool messages send the result of a client-side tool call back to the model. The tool_call_id should match the assistant tool call:
alias BeamWeaver.Core.Message
ai_message =
Message.assistant("",
tool_calls: [
%{id: "call_123", name: "get_weather", args: %{"location" => "San Francisco"}}
]
)
tool_message =
Message.tool("Sunny, 72 F",
tool_call_id: "call_123",
name: "get_weather"
)
messages = [
Message.user("What is the weather in San Francisco?"),
ai_message,
tool_message
]
Tool outputs can keep downstream-only data in :artifacts while sending concise content to the model:
Message.tool("Found the relevant passage.",
tool_call_id: "call_search",
name: "search_books",
artifacts: [%{document_id: "doc_123", page: 0}]
)
BeamWeaver.Core.ToolResult and :content_and_artifact tools populate the same tool-message shape.
Message Content
Message content is either:
-
a string
-
a list of content-block-like values
Strings are best for ordinary text. Lists are best for multimodal input, reasoning blocks, citations, tool results, server-side tool calls, and provider-specific data.
alias BeamWeaver.Core.{ContentBlock, Message}
text_message = Message.user("Hello, how are you?")
block_message =
Message.user([
ContentBlock.text("Describe this image."),
ContentBlock.image(%{url: "https://example.com/image.jpg"}),
%{type: :citation, url: "https://example.com/source", start_index: 0}
])
Use Message.text/1 to extract text from strings and text-like blocks:
Message.text(block_message)
Use Message.content_blocks/1 to normalize content into typed BeamWeaver blocks:
{:ok, blocks} = Message.content_blocks(block_message)
Standard Content Blocks
BeamWeaver.Core.ContentBlock.known_types/0 returns the native block types the core understands:
| Type | Constructor or shape | Purpose |
|---|---|---|
:text
|
ContentBlock.text/2
| text content |
:plain_text
|
ContentBlock.plain_text/2
| document-style plain text |
:image
|
ContentBlock.image/1
| image URL, data URI, or base64 data |
:audio
|
ContentBlock.audio/1
| audio URL or base64 data |
:video
|
ContentBlock.video/1
| video URL or base64 data |
:file
|
ContentBlock.file/1
| PDFs and other files |
:reasoning
|
ContentBlock.reasoning/2
| model reasoning summaries or thinking output |
:citation
|
ContentBlock.citation/1
| source annotations |
:tool_result
|
ContentBlock.tool_result/1
| provider tool-result block |
:tool_call
|
%{type: :tool_call, ...}
| complete streamed or provider tool call |
:tool_call_chunk
|
%{type: :tool_call_chunk, ...}
| partial streamed client-side tool call |
:server_tool_call
|
%{type: :server_tool_call, ...}
| provider-executed tool call |
:server_tool_call_chunk
|
%{type: :server_tool_call_chunk, ...}
| partial server-side tool call |
:server_tool_result
|
%{type: :server_tool_result, ...}
| provider-executed tool result |
:unknown
|
ContentBlock.unknown/3
| provider-specific escape hatch |
Content block names are Elixir atoms inside BeamWeaver. Provider translators render the provider's external string names at the HTTP boundary.
Text And Reasoning
[
ContentBlock.text("Final answer."),
ContentBlock.reasoning("I compared the available evidence."),
ContentBlock.citation(%{
url: "https://example.com/article",
title: "Article",
start_index: 32,
end_index: 88
})
]
Reasoning fields vary by provider. BeamWeaver normalizes common OpenAI, Anthropic, Google, xAI, and Z.ai reasoning shapes into reasoning blocks where provider translators have coverage, and preserves unknown provider data instead of dropping it.
Multimodal
Core messages can represent image, audio, video, and file blocks:
payload = Base.encode64("binary image bytes")
Message.user([
ContentBlock.text("Describe this image."),
ContentBlock.image(%{url: "https://example.com/image.jpg"}),
ContentBlock.image(%{data: payload, mime_type: "image/jpeg"}),
ContentBlock.file(%{file_id: "file-abc123", filename: "brief.pdf"}),
ContentBlock.audio(%{data: Base.encode64("wav bytes"), mime_type: "audio/wav"})
])
Data URIs are parsed into typed blocks:
{:ok, block} =
ContentBlock.from("data:image/png;base64,#{Base.encode64("png bytes")}")
Tool Calling Blocks
Assistant messages can carry complete tool calls in message.tool_calls or in content blocks. Streamed tool calls use chunks and are finalized by the chunk merge helpers:
alias BeamWeaver.Core.Messages
alias BeamWeaver.Core.Messages.MessageChunk
chunks = [
Messages.ai_chunk("",
tool_call_chunks: [
Messages.tool_call_chunk(
id: "call_weather",
index: 0,
name: "weather",
args: ~s({"city":)
)
]
),
Messages.ai_chunk("",
tool_call_chunks: [
Messages.tool_call_chunk(id: "call_weather", index: 0, args: ~s("Nicosia"}))
]
)
]
message =
chunks
|> MessageChunk.merge_many()
|> MessageChunk.to_message()
message.tool_calls
Malformed streamed tool arguments are preserved in message.metadata[:invalid_tool_calls] so callers can surface a useful error or ask the model to retry.
Server-Side Tool Blocks
Some providers execute tools server-side and return tool calls/results inside a single assistant response. BeamWeaver can preserve those blocks on message content and on the dedicated fields:
Message.assistant([
%{type: :server_tool_call, id: "srv_1", name: "web_search", args: %{"query" => "news"}},
%{type: :server_tool_result, tool_call_id: "srv_1", status: "success"},
%{type: :apply_patch_call, call_id: "patch_1", input: "*** Begin Patch\n..."},
%{type: :apply_patch_call_output, call_id: "patch_1", output: "applied"},
ContentBlock.text("Here is the summary.")
])
Client-side tools still use assistant tool_calls followed by :tool messages. Server-side tool blocks represent provider work that already happened during the model request. Provider-specific blocks such as OpenAI apply_patch_call and apply_patch_call_output are preserved so a later provider turn can replay the same assistant history.
Unknown Provider Blocks
Use ContentBlock.unknown/3 or plain maps when a provider returns data that does not yet have a portable BeamWeaver block:
ContentBlock.unknown("vendor.private", %{"payload" => %{"deep" => true}})
Unknown blocks are preserved for application logic and provider translators. They are not treated as portable model input until a translator defines their provider-specific rendering.
Serialization
Use BeamWeaver.Core.Messages.Utils for safe plain-data serialization:
alias BeamWeaver.Core.{Message, Messages.Utils}
messages = [
Message.user("hello", id: "msg_1"),
Message.assistant("world", usage_metadata: %{total_tokens: 4})
]
{:ok, encoded} = Utils.messages_to_dict(messages)
{:ok, decoded} = Utils.messages_from_dict(encoded)
Serialized messages include a BeamWeaver message version and only plain data. That shape is suitable for checkpoints, replay fixtures, and test assertions.
Message Utilities
BeamWeaver.Core.Messages.Utils includes common history operations:
alias BeamWeaver.Core.Messages.Utils
{:ok, trimmed} =
Utils.trim(messages,
max_tokens: 2_000,
strategy: :last,
token_counter: &my_token_counter/1
)
{:ok, merged} = Utils.merge_runs(messages)
{:ok, filtered} = Utils.filter(messages, exclude_tool_calls: true)
{:ok, count} = Utils.count_tokens_approximately(messages)
{:ok, printable} = Utils.pretty_print(messages)
These helpers operate on message-like values and return tagged results. Use them before provider calls, in middleware, or when preparing checkpointed conversation state.
Use With Chat Models
Chat models accept a string, one message-like value, or a list of %BeamWeaver.Core.Message{} values:
alias BeamWeaver.Core.{ChatModel, Message}
history = [
Message.system("You translate English to French."),
Message.user("Translate: I love programming.")
]
{:ok, response} = ChatModel.invoke(model, history)
For streaming tokens and semantic events, see Event Streaming and Models . For agent-managed message state, checkpoints, tool loops, and middleware, see Agents and Short-Term Memory .