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; }}Message types
Section titled “Message types”stream() yields the SDKMessage union. The ones most applications handle:
| Type | Key fields | Meaning |
|---|---|---|
init | agentId, conversationId, model, dreaming | Session initialization details |
reasoning | content | The agent’s thinking, streamed as fragments sharing a uuid |
assistant | content | Assistant text for the user, streamed as fragments sharing a uuid |
tool_call | toolName, rawArguments, toolCallId | The agent invoked a tool; arguments stream across fragments |
tool_result | content, isError, toolCallId | Output of a tool call, complete in one message |
result | success, result, errorCode, durationMs | The 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.
Handle errors
Section titled “Handle errors”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.
Token-level streaming
Section titled “Token-level streaming”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); }}Message content
Section titled “Message content”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.
Queueing
Section titled “Queueing”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).
Handle permission requests
Section titled “Handle permission requests”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.
Interrupt a turn
Section titled “Interrupt a turn”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;}Change model and reasoning effort
Section titled “Change model and reasoning effort”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.
One-shot prompts
Section titled “One-shot prompts”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 okApplications should use sessions.
Read history
Section titled “Read history”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", … },// …// ],// …// }Cleanup
Section titled “Cleanup”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();}Advanced protocol commands
Section titled “Advanced protocol commands”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.