Skip to content
  • Auto
  • Light
  • Dark
DiscordForumGitHubSign up
Building on the Letta API
View as Markdown
Copy Markdown

Open in Claude
Open in ChatGPT

Message Types

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.

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.

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

Structure:

{
id: string;
date: datetime;
message_type: "user_message";
content: string | Array<TextContent | ImageContent>;
name?: string;
otid?: string;
sender_id?: string;
}

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

  • login - User login events

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

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

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

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

Structure:

{
id: string;
date: datetime;
message_type: "system_message";
content: string;
name?: string;
}

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

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

Structure:

{
id: string;
date: datetime;
message_type: "reasoning_message";
reasoning: string;
source: "reasoner_model" | "non_reasoner_model";
signature?: string;
}

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)

Represents reasoning that has been hidden from the response.

Structure:

{
id: string;
date: datetime;
message_type: "hidden_reasoning_message";
state: "redacted" | "omitted";
hidden_reasoning?: string;
}

Fields:

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

The actual message content sent by the agent.

Structure:

{
id: string;
date: datetime;
message_type: "assistant_message";
content: string | Array<TextContent>;
name?: string;
}

A request from the agent to execute a tool.

Structure:

{
id: string;
date: datetime;
message_type: "tool_call_message";
tool_call: {
name: string;
arguments: string; // JSON string
tool_call_id: string;
}
}

Example:

{
message_type: "tool_call_message",
tool_call: {
name: "archival_memory_search",
arguments: '{"query": "user preferences", "page": 0}',
tool_call_id: "call_abc123"
}
}

The result of a tool execution.

Structure:

{
id: string;
date: datetime;
message_type: "tool_return_message";
tool_return: string;
status: "success" | "error";
tool_call_id: string;
stdout?: string[];
stderr?: string[];
}

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)

A request for human approval before executing a tool.

Structure:

{
id: string;
date: datetime;
message_type: "approval_request_message";
tool_call: {
name: string;
arguments: string;
tool_call_id: string;
}
}

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

The user’s response to an approval request.

Structure:

{
id: string;
date: datetime;
message_type: "approval_response_message";
approve: boolean;
approval_request_id: string;
reason?: string;
}
import { LettaClient } from "@letta-ai/letta-client";
const client = new LettaClient({
baseUrl: "https://api.letta.com",
});
// List recent messages
const messages = await client.agents.messages.list("agent-id", {
limit: 50,
useAssistantMessage: true,
});
// Iterate through message types
for (const message of messages) {
switch (message.messageType) {
case "user_message":
console.log("User:", message.content);
break;
case "assistant_message":
console.log("Agent:", message.content);
break;
case "reasoning_message":
console.log("Reasoning:", message.reasoning);
break;
case "tool_call_message":
console.log("Tool call:", message.toolCall.name);
break;
// ... handle other types
}
}
// Get only assistant messages (what the agent said to the user)
const agentMessages = messages.filter(
(msg) => msg.messageType === "assistant_message",
);
// Get all tool-related messages
const toolMessages = messages.filter(
(msg) =>
msg.messageType === "tool_call_message" ||
msg.messageType === "tool_return_message",
);
// Get conversation history (user + assistant messages only)
const conversation = messages.filter(
(msg) =>
msg.messageType === "user_message" ||
msg.messageType === "assistant_message",
);

Messages support cursor-based pagination:

// Get first page
let messages = await client.agents.messages.list("agent-id", {
limit: 100,
});
// Get next page using the last message ID
const lastMessageId = messages[messages.length - 1].id;
const nextPage = await client.agents.messages.list("agent-id", {
limit: 100,
before: lastMessageId,
});

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

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

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

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

def is_internal_message(msg):
"""Check if a user message is internal (login, system_alert, etc.)"""
if msg.message_type != "user_message":
return False
if not isinstance(msg.content, str):
return False
try:
parsed = json.loads(msg.content)
return parsed.get("type") in ["login", "system_alert"]
except:
return False
# Get user-facing messages only
display_messages = [
msg for msg in messages
if not is_internal_message(msg)
]

Match tool calls with their returns using tool_call_id:

# Build a map of tool calls to their returns
tool_calls = {
msg.tool_call.tool_call_id: msg
for msg in messages
if msg.message_type == "tool_call_message"
}
tool_returns = {
msg.tool_call_id: msg
for msg in messages
if msg.message_type == "tool_return_message"
}
# Find failed tool calls
for call_id, call_msg in tool_calls.items():
if call_id in tool_returns:
return_msg = tool_returns[call_id]
if return_msg.status == "error":
print(f"Tool {call_msg.tool_call.name} failed:")
print(f" {return_msg.tool_return}")