Message Types

Understanding message types and working with agent message history

When you interact with a Letta agent and retrieve its message history using client.agents.messages.list(), you’ll receive various types of messages that represent different aspects of the agent’s execution. This guide explains all message types and how to work with them.

Overview

Letta uses a structured message system where each message has a specific message_type field that indicates its purpose. Messages are returned as instances of LettaMessageUnion, which is a discriminated union of all possible message types.

Message Type Categories

User and System Messages

user_message

Messages sent by the user or system events packaged as user input.

Structure:

1{
2 id: string;
3 date: datetime;
4 message_type: "user_message";
5 content: string | Array<TextContent | ImageContent>;
6 name?: string;
7 otid?: string;
8 sender_id?: string;
9}

Special User Message Subtypes: User messages can contain JSON with a type field indicating special message subtypes:

  • heartbeat - Automated timer events that allow agents to chain multiple tool calls. See Heartbeats for more details.

    1{
    2 "type": "heartbeat",
    3 "reason": "Automated timer",
    4 "time": "2025-10-03 12:34:56 PM PDT-0700"
    5}
  • login - User login events

    1{
    2 "type": "login",
    3 "last_login": "Never (first login)",
    4 "time": "2025-10-03 12:34:56 PM PDT-0700"
    5}
  • user_message - Standard user messages

    1{
    2 "type": "user_message",
    3 "message": "Hello, agent!",
    4 "time": "2025-10-03 12:34:56 PM PDT-0700"
    5}
  • system_alert - System notifications and alerts

    1{
    2 "type": "system_alert",
    3 "message": "System notification text",
    4 "time": "2025-10-03 12:34:56 PM PDT-0700"
    5}

system_message

Messages generated by the system, typically used for internal context.

Structure:

1{
2 id: string;
3 date: datetime;
4 message_type: "system_message";
5 content: string;
6 name?: string;
7}

Note: System messages are never streamed back in responses; they’re only visible when paginating through message history.

Agent Reasoning and Responses

reasoning_message

Represents the agent’s internal reasoning or “chain of thought.”

Structure:

1{
2 id: string;
3 date: datetime;
4 message_type: "reasoning_message";
5 reasoning: string;
6 source: "reasoner_model" | "non_reasoner_model";
7 signature?: string;
8}

Fields:

  • reasoning - The agent’s internal thought process
  • source - Whether this was generated by a model with native reasoning (like o1) or via prompting
  • signature - Optional cryptographic signature for reasoning verification (for models that support it)

hidden_reasoning_message

Represents reasoning that has been hidden from the response.

Structure:

1{
2 id: string;
3 date: datetime;
4 message_type: "hidden_reasoning_message";
5 state: "redacted" | "omitted";
6 hidden_reasoning?: string;
7}

Fields:

  • state: "redacted" - The provider redacted the reasoning content
  • state: "omitted" - The API chose not to include reasoning (e.g., for o1/o3 models)

assistant_message

The actual message content sent by the agent (typically via the send_message tool).

Structure:

1{
2 id: string;
3 date: datetime;
4 message_type: "assistant_message";
5 content: string | Array<TextContent>;
6 name?: string;
7}

Tool Execution Messages

tool_call_message

A request from the agent to execute a tool.

Structure:

1{
2 id: string;
3 date: datetime;
4 message_type: "tool_call_message";
5 tool_call: {
6 name: string;
7 arguments: string; // JSON string
8 tool_call_id: string;
9 };
10}

Example:

1{
2 message_type: "tool_call_message",
3 tool_call: {
4 name: "archival_memory_search",
5 arguments: '{"query": "user preferences", "page": 0}',
6 tool_call_id: "call_abc123"
7 }
8}

tool_return_message

The result of a tool execution.

Structure:

1{
2 id: string;
3 date: datetime;
4 message_type: "tool_return_message";
5 tool_return: string;
6 status: "success" | "error";
7 tool_call_id: string;
8 stdout?: string[];
9 stderr?: string[];
10}

Fields:

  • tool_return - The formatted return value from the tool
  • status - Whether the tool executed successfully
  • stdout/stderr - Captured output from the tool execution (useful for debugging)

Human-in-the-Loop Messages

approval_request_message

A request for human approval before executing a tool.

Structure:

1{
2 id: string;
3 date: datetime;
4 message_type: "approval_request_message";
5 tool_call: {
6 name: string;
7 arguments: string;
8 tool_call_id: string;
9 };
10}

See Human-in-the-Loop for more information on this experimental feature.

approval_response_message

The user’s response to an approval request.

Structure:

1{
2 id: string;
3 date: datetime;
4 message_type: "approval_response_message";
5 approve: boolean;
6 approval_request_id: string;
7 reason?: string;
8}

Working with Messages

Listing Messages

1import { LettaClient } from "@letta-ai/letta-client";
2
3const client = new LettaClient({
4 baseUrl: "https://api.letta.com",
5});
6
7// List recent messages
8const messages = await client.agents.messages.list("agent-id", {
9 limit: 50,
10 useAssistantMessage: true,
11});
12
13// Iterate through message types
14for (const message of messages) {
15 switch (message.messageType) {
16 case "user_message":
17 console.log("User:", message.content);
18 break;
19 case "assistant_message":
20 console.log("Agent:", message.content);
21 break;
22 case "reasoning_message":
23 console.log("Reasoning:", message.reasoning);
24 break;
25 case "tool_call_message":
26 console.log("Tool call:", message.toolCall.name);
27 break;
28 // ... handle other types
29 }
30}

Filtering Messages by Type

1// Get only assistant messages (what the agent said to the user)
2const agentMessages = messages.filter(
3 (msg) => msg.messageType === "assistant_message"
4);
5
6// Get all tool-related messages
7const toolMessages = messages.filter(
8 (msg) => msg.messageType === "tool_call_message" ||
9 msg.messageType === "tool_return_message"
10);
11
12// Get conversation history (user + assistant messages only)
13const conversation = messages.filter(
14 (msg) => msg.messageType === "user_message" ||
15 msg.messageType === "assistant_message"
16);

Filtering Out Special User Messages

When working with user messages, you may want to filter out internal system messages like heartbeats:

1import { parse } from "json5";
2
3function isHeartbeat(content: string): boolean {
4 try {
5 const parsed = JSON.parse(content);
6 return parsed.type === "heartbeat";
7 } catch {
8 return false;
9 }
10}
11
12// Filter out heartbeat messages
13const userMessages = messages
14 .filter((msg) => msg.messageType === "user_message")
15 .filter((msg) => {
16 if (typeof msg.content === "string") {
17 return !isHeartbeat(msg.content);
18 }
19 return true;
20 });

Pagination

Messages support cursor-based pagination:

1// Get first page
2let messages = await client.agents.messages.list("agent-id", {
3 limit: 100,
4});
5
6// Get next page using the last message ID
7const lastMessageId = messages[messages.length - 1].id;
8const nextPage = await client.agents.messages.list("agent-id", {
9 limit: 100,
10 before: lastMessageId,
11});

Message Metadata Fields

All message types include these common fields:

  • id - Unique identifier for the message
  • date - ISO 8601 timestamp of when the message was created
  • message_type - The discriminator field identifying the message type
  • name - Optional name field (varies by message type)
  • otid - Offline threading ID for message correlation
  • sender_id - The ID of the sender (identity or agent ID)
  • step_id - The step ID associated with this message
  • is_err - Whether this message is part of an error step (debugging only)
  • seq_id - Sequence ID for ordering
  • run_id - The run ID associated with this message

Best Practices

1. Use Type Discriminators

Always check the message_type field to safely access type-specific fields:

1if (message.messageType === "tool_call_message") {
2 // TypeScript now knows message has a toolCall field
3 console.log(message.toolCall.name);
4}

2. Handle Special User Messages

When displaying conversations to end users, filter out internal messages:

1def is_internal_message(msg):
2 """Check if a user message is internal (heartbeat, login, etc.)"""
3 if msg.message_type != "user_message":
4 return False
5
6 if not isinstance(msg.content, str):
7 return False
8
9 try:
10 parsed = json.loads(msg.content)
11 return parsed.get("type") in ["heartbeat", "login", "system_alert"]
12 except:
13 return False
14
15# Get user-facing messages only
16display_messages = [
17 msg for msg in messages
18 if not is_internal_message(msg)
19]

3. Track Tool Execution

Match tool calls with their returns using tool_call_id:

1# Build a map of tool calls to their returns
2tool_calls = {
3 msg.tool_call.tool_call_id: msg
4 for msg in messages
5 if msg.message_type == "tool_call_message"
6}
7
8tool_returns = {
9 msg.tool_call_id: msg
10 for msg in messages
11 if msg.message_type == "tool_return_message"
12}
13
14# Find failed tool calls
15for call_id, call_msg in tool_calls.items():
16 if call_id in tool_returns:
17 return_msg = tool_returns[call_id]
18 if return_msg.status == "error":
19 print(f"Tool {call_msg.tool_call.name} failed:")
20 print(f" {return_msg.tool_return}")

See Also