Skip to content
Discord
Letta Agent SDK

Sending messages

Stream turns and process reasoning, assistant messages, tool calls, and results

Send a message with session.send() and read the response with session.stream(). The stream yields typed events for the whole turn — reasoning, assistant text, tool calls, tool results — and terminates after the turn’s result message, so each turn is one send() plus one for await loop.

Text streams incrementally: assistant and reasoning arrive as multiple messages that share a uuid, each carrying the next fragment of content. Write fragments as they arrive, or read the turn’s complete assistant text from result.result at the end.

import { LettaAgentClient } from "@letta-ai/letta-agent-sdk";
const client = new LettaAgentClient({
backend: "cloud",
apiKey: process.env.LETTA_API_KEY,
});
await using session = client.resumeSession(agentId);
await session.send(
"Run exactly `echo hello-from-docs` in the shell, then tell me what it printed.",
);
const announcedTools = new Set<string>();
for await (const message of session.stream()) {
switch (message.type) {
case "reasoning":
// Fragments share a uuid and concatenate to the full text:
// { type: "reasoning", content: " The user wants me to run a", uuid: "message-…c14df", seqId: 3, runId: "run-…" }
// { type: "reasoning", content: " simple echo", uuid: "message-…c14df", seqId: 4, runId: "run-…" }
process.stdout.write(message.content);
break;
case "tool_call":
// Tool arguments stream too — each fragment carries the next chunk of
// rawArguments; toolInput is not parseable until the fragments complete:
// { type: "tool_call", toolCallId: "chatcmpl-tool-3de0…", toolName: "Bash", rawArguments: '"command": "echo hello', … }
if (!announcedTools.has(message.toolCallId)) {
announcedTools.add(message.toolCallId);
console.log("\ntool:", message.toolName);
// → tool: Bash
}
break;
case "tool_result":
// Complete in a single message:
// { type: "tool_result", toolCallId: "chatcmpl-tool-3de0…", content: "hello-from-docs", isError: false, … }
console.log("tool result:", message.content);
break;
case "assistant":
// { type: "assistant", content: "The command printed:", uuid: "message-…71e5e1", seqId: 2, … }
// { type: "assistant", content: " `hello-from-docs`.", uuid: "message-…71e5e1", seqId: 3, … }
process.stdout.write(message.content);
break;
case "result":
// {
// type: "result", success: true,
// result: "The command printed: `hello-from-docs`.",
// stopReason: "end_turn", durationMs: 3371,
// conversationId: "conv-3f5b5985-…",
// runIds: ["run-fd5ec0b8-…", "run-52251ce8-…"]
// }
if (!message.success) console.error(message.errorCode, message.errorDetail);
break;
}
}

stream() yields the SDKMessage union. The ones most applications handle:

TypeKey fieldsMeaning
initagentId, conversationId, model, dreamingSession initialization details
reasoningcontentThe agent’s thinking, streamed as fragments sharing a uuid
assistantcontentAssistant text for the user, streamed as fragments sharing a uuid
tool_calltoolName, rawArguments, toolCallIdThe agent invoked a tool; arguments stream across fragments
tool_resultcontent, isError, toolCallIdOutput of a tool call, complete in one message
resultsuccess, result, errorCode, durationMsThe turn finished (with the full final text); the stream ends after this

The union also includes error (a failure with full detail), retry (an automatic retry is in progress), queue_update (messages queued behind the active turn), stream_event (token-level deltas), and loop_status.

When a turn fails, the stream emits an error message followed by a failed result. The error message carries the real detail; on the result, prefer errorCode over the legacy error string:

for await (const message of session.stream()) {
if (message.type === "error") {
console.error(message.message, message.errorDetail);
}
if (message.type === "result" && !message.success) {
console.error("turn failed:", message.errorCode);
}
}

errorCode values include "llm_api_error", "max_steps", "interrupted", "stream_closed", "protocol_error", and approval-conflict codes. If the connection closes mid-turn, the closed session cannot be reused — call resumeSession(conversationId) to continue in a new session.

For live typing indicators, handle stream_event messages with the extractStreamTextDelta helper, which normalizes assistant and reasoning deltas:

import { extractStreamTextDelta } from "@letta-ai/letta-agent-sdk";
for await (const message of session.stream()) {
if (message.type === "stream_event") {
const delta = extractStreamTextDelta(message.event);
// → { kind: "assistant", text: "The command printed:" }
// → null for non-text events (usage statistics, tool lifecycle, …)
if (delta) process.stdout.write(delta.text);
}
}

send() accepts a string or an array of content items, including images:

import { imageFromFile } from "@letta-ai/letta-agent-sdk";
await session.send([
{ type: "text", text: "What's wrong with this dashboard?" },
imageFromFile("./dashboard.png"),
]);

imageFromBase64 and imageFromURL are also available. The image helpers that read files are Node-only.

Cloud, remote, and local sessions accept another send() while a turn is streaming. The runtime owns queueing, and stream() may emit queue_update events before the current turn’s result:

for await (const message of session.stream()) {
if (message.type === "queue_update") {
// → { type: "queue_update", queue: [] }
// → { type: "queue_update", queue: [{ id: "…", kind: "user_message", enqueuedAt: "…", … }] }
console.log("queued turns:", message.queue.length);
}
}

Remove queued work with session.removeQueuedMessage(itemId).

Use canUseTool to approve, deny, or edit tool calls. The callback can be fully interactive: return a promise that resolves after your UI or server receives a user’s decision. The SDK keeps the approval pending until the callback returns.

await using session = client.createSession(agentId, {
permissionMode: "standard",
canUseTool: async (toolName, toolInput) => {
if (toolName === "Bash") {
return {
behavior: "deny",
message: "Denied by the example approval policy",
};
}
return { behavior: "allow" };
},
});

An "allow" response can also pass updatedInput to edit the tool call before it runs. permissionMode: "unrestricted" auto-allows tool calls that do not require user input; tools that ask the user for input still flow through canUseTool so your app can render the prompt and return the user’s response.

Call abort() to stop the active turn without closing the session:

await session.send("Run the full test suite and fix failures.");
setTimeout(() => {
void session.abort();
}, 5_000);
for await (const message of session.stream()) {
if (message.type === "result") break;
}

Inspect the model catalog and update the model between turns:

const catalog = await session.listModels();
// catalog.entries[0] → {
// id: "auto", handle: "letta/auto", label: "Auto",
// description: "Automatically select the best model",
// isDefault: true, isFeatured: true, free: true,
// updateArgs: { parallel_tool_calls: true, context_window: 140000, … }
// }
const target = catalog.entries.find(
(entry) => entry.handle === "anthropic/claude-opus-4-8",
);
if (!target) throw new Error("Model is not available");
await session.updateModel({
modelHandle: target.handle,
reasoningEffort: "high",
});

reasoningEffort accepts "none", "minimal", "low", "medium", "high", or "xhigh", resolved against the model catalog. You can also pass model and reasoningEffort when creating or resuming a session; those updates persist on the agent.

For scripts, smoke tests, and evals, client.prompt() sends one message and resolves with the turn’s result:

const result = await client.prompt("Reply with exactly: ok", agentId);
// → {
// type: "result", success: true, result: "ok",
// stopReason: "end_turn", durationMs: 1778,
// conversationId: "conv-cd716204-…", runIds: ["run-71274347-…"]
// }
console.log(result.success, result.result);
// → true ok

Applications should use sessions.

Read paginated conversation history with listMessages():

const history = await session.listMessages({ order: "desc", limit: 20 });
// → {
// messages: [
// { id: "message-4eb205de-…", message_type: "assistant_message",
// content: "The command printed: `hello-from-docs`.",
// date: "2026-08-11T07:06:40.237Z", run_id: "run-52251ce8-…", … },
// { id: "message-354a7295-…", message_type: "tool_return_message",
// status: "success", tool_return: "hello-from-docs\n", … },
// …
// ],
// …
// }

Sessions implement AsyncDisposable. Use await using so the session closes when the scope exits, or call close() manually:

const session = client.createSession(agentId);
try {
// ... use session ...
} finally {
session.close();
}

Most applications should use the typed SDK methods. For protocol-level integrations, sendCommand() sends raw App Server protocol commands; provide responseType when you want to wait for a protocol response. See the SDK reference for the full session interface.