---
title: Sending messages | Letta Docs
description: 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

`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

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

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

`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

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

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

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

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

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 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

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

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](/agent-sdk/reference/index.md) for the full session interface.
