> ## Documentation Index
> Fetch the complete documentation index at: https://ade-app.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Threads

> Durable named threads: open-or-resume by key, list, rename, archive, delete, send, steer, interrupt, setModel, paged history, attachments, and export.

A thread is one conversation, keyed by a string you choose. Keys are stored under `home` and survive host restarts.

```ts theme={null}
const thread = await ade.threads.open("support", {
  provider: "claude",
  model: "claude-sonnet-4-5",
});
```

`open(key)` is **open-or-resume**. The first call creates; later calls with the same key return a handle onto the same conversation. Resume does not require provider/model — those come from the stored record.

A resume re-applies the values the key was **created** with. `cwd`, `instructions`, `settingSources`, `permissions`, `mcpServers` and `loadUserMcpServers` are read off the stored record, not off this call: passing a different value is not an error and does not change the thread, and the SDK logs one line naming each option it ignored. To run under different host configuration or a different tool surface, open a different key.

`refresh.mcpServers` is the one exception. It replaces the thread's MCP servers on a resume, so a new token or a new port takes effect. See [MCP servers](https://ade-app.dev/docs/sdk/mcp.md#credentials-that-change).

```ts theme={null}
const again = await ade.threads.open("support");
```

### When the runtime lost the session

If the stored session is gone (wiped home, copied machine, corrupt state root), the SDK drops the stale session and **creates a new conversation** under the same key. This is a **recreate**. Reusing a key is not proof that the old transcript is still there.

A recreate uses the **stored record first**. `provider`, `model`, `cwd`, `instructions`, `settingSources`, `permissions`, `mcpServers` and `loadUserMcpServers` come from the record. An option that you pass to this call only fills a field that the record does not have. The SDK logs one line for each stored value that it used instead of yours.

> [!WARNING]
>
> This rule changed in 0.3. Before 0.3 the call's options won on a recreate. Any caller that could name a key, such as a renderer over the Electron bridge, could then rebuild a lost thread on another provider, under a looser policy, or with no MCP servers.

The record keeps MCP header **names**, not values. A recreate gets the values from `refresh.mcpServers` or from the client's `mcpHeaders` callback. If neither supplies them, the server connects without its headers and the SDK logs a line. See [MCP servers](https://ade-app.dev/docs/sdk/mcp.md#credentials-that-change).

A recreate gives the thread a new `thread.id`. Key your own state on `thread.key`, not on `thread.id`.

## List, rename, archive, delete

```ts theme={null}
const rows = await ade.threads.list();
await ade.threads.archive("support");
await ade.threads.unarchive("support");
await ade.threads.delete("support");
```

`list()` returns every chat in this home as a `ThreadSummary`, archived chats included. The order is not guaranteed. Sort on `updatedAt` yourself.

To read one chat, use `get(key)` (SDK 0.4). It returns the same row as `list()`, or `null` when the runtime has no session for the key: a key that this home never opened, a deleted key, or a key whose session the runtime lost (the next `open()` recreates it).

```ts theme={null}
const row = await ade.threads.get("support");   // ThreadSummary | null
```

| Field | |
| - | - |
| `key` | Your key, or `null` for a chat that was not created through the SDK |
| `title` | The title, or `null` |
| `archived` | `true` after `archive()` |
| `updatedAt` | ISO time of the last activity. Equal to `lastActivityAt` |
| `modelSelection` | `{ modelId, provider, model, displayName }`, or `null` |
| `sessionId`, `provider`, `model`, `status`, `startedAt`, `lastActivityAt` | As the runtime reports them |

`archive(key)` keeps the key and the transcript. `unarchive(key)` reverses it.

`delete(key)` deletes the runtime session, its transcript and the key. A thread object that you hold for that key stops working. Drop it.

`archive` and `delete` interrupt a running turn **first**, so subscribers get the turn's `done` event before the session changes. A key that the store does not know throws `thread_not_found`. A runtime without the matching action throws `invalid_option`.

After a `delete`, `archive` or `unarchive` succeeds, the client emits `threadLifecycle` with `{ key, change }` (SDK 0.4). It fires for every call through this client, from your own code or from a renderer over the Electron bridge, so one listener keeps your chat list correct:

```ts theme={null}
ade.on("threadLifecycle", ({ key, change }) => chatList.apply(key, change));
```

### Title and model

```ts theme={null}
thread.title;   // string | null
thread.model;   // { modelId, provider, model, displayName } | null
```

The runtime can give a thread a title after its first turn. `thread.title` shows that title after the next `open()` or `update()`.

Which title wins:

* A title that you set with `update({ title })` marks the chat as named. The runtime's automatic title never replaces it, at any time. That includes Claude's summary title and Codex's thread name.
* Without a host title, the runtime may name the chat after its first message.
* `update({ title: null })` clears the host title (runtime 1.2.82). The chat then shows the provider's placeholder title, for example `"Claude Chat"`, so `thread.title` reads that string, not `null`. The automatic title may name the chat again after its next message. Before 1.2.82, `null` set the placeholder and still counted as a host title.

So a host that sets a title after the first message, as Versic does, keeps its title, whenever the automatic title runs.

Show `thread.model.displayName` when it is not `null`. When a CLI update retires a model, the runtime resolves the old id to a newer model, and the id that you stored is then the wrong label.

### `update()`

```ts theme={null}
await thread.update({ title: "Outage review" });
await thread.update({ reasoningEffort: "high", fastMode: false });
```

`update()` changes `title`, `reasoningEffort` or `fastMode` on a live thread. It returns `{ title, model }` as the runtime reports them after the change. The SDK also writes the title to its thread store. `title: null` clears the title, and `reasoningEffort: null` returns to the model default.

A rename works at any time. `reasoningEffort` and `fastMode` are **refused while a turn is in flight** with the code `turn_in_flight` (SDK 0.4; `invalid_option` before) unless you pass `{ force: true }`. On Codex, a settings change can restart the provider, and that ends the turn without `error` or `done`. Wait for the turn to end, or call `interrupt()` first.

## Send, steer, interrupt

```ts theme={null}
await thread.send("What changed?");
await thread.steer("Focus on the outage"); // mid-turn follow-up
await thread.steer("", { attachments: [{ path: "/Users/me/MyApp/work/log.txt" }] });
await thread.interrupt();
await thread.interrupt({ queue: "keep" }); // stop the turn, retain queued messages
```

`send()` dispatches the turn and resolves when the runtime accepts it, **not** when the reply finishes. Watch `thread.on("status")` / `thread.on("event")` for the rest.

Submitting during a running turn is a **steer**, not a second send. `steer()` is safe to call while the turn is live. It does not start a new turn.

`steer()` takes `{ attachments }` with the same rules as `send()`. It needs text, at least one attachment, or both. A steer with attachments and empty text is allowed. It resolves when the runtime accepts the steer, not when the agent reads it.

`interrupt({ queue: "keep" })` stops the active turn and preserves queued
messages; `queue: "drop"` clears them. A `mode` option can select one of ADE's
full stop modes and takes precedence over `queue`.

## Queue and recovery controls

`thread.queue` exposes `list`, `cancel`, `edit`, `move`, `dispatch`,
`cancelDispatched`, and `restore`. `list()` reconstructs queued messages from
the thread history, so a host can restore its queue UI after a reload. Each
write is feature-detected and throws `unsupported` when the runtime does not
advertise the matching action.

`thread.respond(requestId, response)` forwards the response to the runtime's
`respondToInput` action. Question answers are keyed by question id:

```ts theme={null}
interface Thread {
  respond(requestId: string, response: {
    answers?: Record<string, string | string[]>;
    decision?: "accept" | "accept_for_session" | "decline" | "cancel";
    responseText?: string | null;
  }): Promise<void>;
}

await thread.respond(requestId, {
  answers: { [question.id]: "the selected answer" },
});
```

Plan approvals use an explicit decision, matching ADE's plan card: `accept`
approves the plan and `decline` keeps planning. The SDK never guesses from
option labels. The response may also include `responseText`, and its decision
may be `accept_for_session` or `cancel` where the runtime supports them.

Tool approval requests still use `thread.approve()`. `thread.stopTask(taskId)` stops a
provider task, `thread.slashCommands()` lists provider commands, and
`thread.recover()` or `thread.resolveUnprocessed()` handles interrupted turns
and queued messages the runtime could not process.

Usage-limit actions are available as `thread.usageLimit.resumeNow()` and
`thread.usageLimit.continueOnAlternate()`. See [Accounts](https://ade-app.dev/docs/sdk/accounts.md#usage-limits)
for automatic handling and the client `usageLimit` event.

## Usage and budgets

```ts theme={null}
const thread = await ade.threads.open("support", {
  provider: "claude",
  model: "claude-sonnet-4-5",
  budget: { maxTokens: 80_000, maxCostUsd: 2 },
});

thread.on("event", ({ event }) => {
  if (event.type === "budget_exceeded") showBudgetNotice(event.usage);
});
console.log(thread.usage());
```

`usage()` returns cumulative `inputTokens`, `outputTokens` and `totalTokens`, plus `contextWindow` when the runtime reports it. `costUsd` is present only when a provider explicitly reports a dollar cost; ADE list-price estimates are excluded. `complete` is false if the SDK could not read the existing transcript to seed its totals. Without a budget, the snapshot then covers only events the SDK observed; with a budget, new turns are refused until the SDK can load history.

The budget is stored with the thread key, so it continues across restarts and resumes. When reported cumulative tokens or provider cost reaches a limit, the SDK emits `budget_exceeded` and interrupts the active turn. If the cost limit has no provider-reported cost to compare, only the token limit can trigger. On resume, the stored budget wins over a different `budget` option.

### Retry and edit

```ts theme={null}
await thread.retry();                                   // run the last user message again
await thread.editLast("Use the March numbers instead");  // replace it, then run it
await thread.editLast("", { attachments: [{ path: "/Users/me/MyApp/work/new.csv" }] });
```

`retry()` and `editLast(text, { displayText?, attachments? })` (SDK 0.4, runtime 1.2.82) roll the provider conversation back to just before the last user message, remove that message and its reply from the history, and send the message again: the same one for `retry()`, the new one for `editLast()`. The transcript then shows the message **once**. `editLast` keeps the original attachments unless you pass `attachments`, and an empty array removes them.

What a subscriber sees, in order:

1. `session_meta_updated` with `historyInvalidated: true` and a new `historyGeneration`. Drop your copy of the events at or after `retractedFromSequence` (from the result), or read `history()` again. `useAdeThread` in chat-ui 0.4 re-reads by itself.
2. The new turn: `user_message`, `status` started, and the rest of the turn.

Both resolve when the new turn is dispatched, with `{ retractedFromSequence, historyGeneration, conversationRollback }`. `conversationRollback` says what the provider did: `claude_fork` or `claude_new_session` (Claude), `codex_thread` (Codex), or `not_delivered` (the old message never reached the provider, so only the transcript changed).

Limits:

* **Claude and Codex only.** Another provider, or a runtime older than 1.2.82, throws `unsupported`.
* **Not during a turn.** A running turn, or one that waits on an approval, throws `turn_in_flight`, and nothing changes. Wait for the turn to end, or call `interrupt()` first.
* **Files are not restored.** The rollback covers the conversation, not the disk: changes the old turn made stay. The result does **not** say whether the old turn wrote files, because the runtime cannot tell which of your tools write. If you allow writes, find them yourself — the removed events are the ones at or after `retractedFromSequence` in your own history copy, so inspect their tool calls and warn the user if any of them was a write.
* A thread with no user message yet throws `rpc_error`.

Over the Electron bridge, `thread.retry` and `thread.editLast` are allowed methods. `editLast` takes only `displayText` and `attachments` from the renderer.

## Events

```ts theme={null}
const off = thread.on("event", (envelope) => {
  // envelope.event.type — assistant text, tool_call, tool_result, …
});
thread.on("status", (envelope) => { /* running | idle */ });
thread.on("usage", (envelope) => { /* token usage */ });
```

Each `on` returns an unsubscribe function. Unknown event types are ignored, not rendered — the runtime can add kinds without breaking listeners.

The SDK adds two envelopes of its own to the `status` channel. Both carry `synthetic: true`.

| `event.type` | When |
| - | - |
| `status` with `turnStatus: "failed"`, `synthetic: true` and a `message` | The runtime exited or its socket closed. A runtime that stops mid-turn sends nothing, so without this event a subscriber would show "running" with no end. See [Runtime](https://ade-app.dev/docs/sdk/runtime.md#when-the-runtime-exits). |
| `capabilities_changed` | After `setModel()`. It carries the four capability reports for the new provider. |

### History

`history({ limit })` returns envelopes in transcript order (`sequence`, then `timestamp`). Provider clocks are not trusted. Without `limit` it returns the full transcript, up to the runtime's cap of about 20,000 events or 2 MB.

For a long thread, read one page at a time:

```ts theme={null}
let page = await thread.historyPage({ limit: 200 });   // newest page
while (page.hasMore) {
  page = await thread.historyPage({ beforeSequence: page.nextBeforeSequence!, limit: 200 });
}
```

`historyPage()` returns `{ events, hasMore, nextBeforeSequence }`. Events are oldest-first inside a page. Omit `beforeSequence` for the newest page. `limit` defaults to 200. The runtime pages by bytes, so a page can hold fewer events than `limit`.

A runtime without the `getEventHistoryPage` action gives the newest page only. A request for an older page then returns an empty page with `hasMore: false`, and the SDK logs one line.

## Instructions

Give a thread its own system instructions. They never appear in the transcript: `exportThread` returns no user message carrying them, which is the whole difference from the hidden-first-message trick this replaces.

```ts theme={null}
const thread = await ade.threads.open("assistant", {
  provider: "claude",
  model: "claude-sonnet-4-5",
  instructions: { mode: "replace", text: "You are Ada, MyApp's assistant." },
});
```

* `{ mode: "append", text }` keeps ADE's own personal-chat prompt and adds yours after it. Use this when you want ADE's safety framing.
* `{ mode: "replace", text }` uses your text alone. Use this when the chat is branded as your assistant and must not mention ADE.
* A bare string is shorthand for `{ mode: "append", text }`.

Append appends to **ADE's personal-chat prompt**, not to Claude Code's own preset. Passing `instructions` at the client level sets a default for every thread; a per-thread value replaces it rather than being merged with it.

| Provider | Level | Mechanism |
| - | - | - |
| claude | applied | Agent SDK `systemPrompt` |
| codex | applied | `developerInstructions` on `thread/start` |
| opencode | applied | the wire `system` field |
| pi | applied | `systemPromptOverride` |
| cursor | best-effort | merged into the system text ADE already prefixes to the first turn |
| droid | best-effort | merged into the harness prompt ADE prefixes on every turn |

`best-effort` means ADE reuses a channel it already owns rather than a first-class one the provider offers. The text reaches the model; it shares a prompt with ADE's own.

```ts theme={null}
if (thread.instructionsCapability?.level !== "applied") {
  console.warn(thread.instructionsCapability?.detail);
}
```

`instructionsCapability` is `null` when the thread asked for none, and also when an older runtime reported nothing — treat the second case as possibly not delivered.

## Working directory

```ts theme={null}
const thread = await ade.threads.open("assistant", {
  provider: "claude",
  model: "claude-sonnet-4-5",
  cwd: "/Users/me/Library/Application Support/MyApp/work",
});
```

The directory the provider CLI runs in, and what its shell and file tools resolve against. Created recursively at mode `0755` when missing.

The default is the runtime's own scratch workspace at `<home>/personal-chats/workspaces`. Omitting `cwd` keeps exactly that, so this option cannot move an existing embedder's files.

Refused with `invalid_option`, each naming the rule:

* A relative path. It would resolve against the **runtime's** working directory, which you cannot see and did not choose.
* A path starting with `~`. It is not expanded.
* A filesystem root, including a Windows drive root and a bare UNC share root.
* The user's home directory itself.
* Anything inside the SDK's own `home`, which holds the runtime's state.

The path is canonicalized before those rules are applied — symlinks resolved, and on Windows the real casing — by the SDK client-side and by the runtime again, to the same answer. The chat runs in that canonical directory. Two spellings of one directory are therefore one directory: a symlink cannot be used to step around the refusals above, `permissions.sandboxRoot` containment is judged on the resolved form, and reopening a key with a different spelling of the same directory is correctly seen as no change. The provider may report a working directory that differs textually from the string you passed.

`cwd` is a working directory, not a sandbox. For containment use `permissions.sandboxRoot`, and read what it does per provider first: on Claude under `fallback: "deny"` the unnamed tool is refused outright instead of being contained. See [Permissions](https://ade-app.dev/docs/sdk/permissions.md).

## Configuration layers

```ts theme={null}
settingSources: "project"   // "none" (default) | "project" | "user" | "all"
```

Which on-disk configuration the provider loads. `"none"` is the default and is what every 0.1.x thread got: no filesystem settings. `"project"` loads files in `cwd`, such as a `CLAUDE.md` your app ships. `"user"` loads the machine user's own global config. `"all"` loads user, project and local.

`"project"` is usually right for an embedder. Loading a user's personal `~/.claude` into an app-branded assistant is a surprise, and it should take an explicit `"user"` or `"all"` to get it.

| Provider | What happens |
| - | - |
| claude | Applied. The value is set explicitly in all four cases, so the behavior is ADE's rather than a dependency's default. |
| codex | Best-effort for `"project"` and `"all"`: Codex reads `AGENTS.md` from `cwd`, and always also reads `~/.codex/AGENTS.md`. Ignored for `"none"` and `"user"` — Codex has no switch to stop reading `AGENTS.md`. |
| cursor | Ignored. ADE pins the Cursor SDK's own `settingSources` from the session permission policy, because dropping the user layer would also drop ADE's tool-gate hook. |
| droid | Ignored. The Factory SDK resolves `~/.factory/settings.json` itself and offers no per-session override. |
| opencode | Ignored. ADE runs a dedicated OpenCode server with an ADE-authored config and project config disabled. There is no per-session switch. |
| pi | Ignored. The Pi SDK exposes no configuration-layer switch. |

Read `thread.settingSourcesCapability` for the answer on the thread you actually got.

## Switch models

```ts theme={null}
await thread.interrupt();
const next = await thread.setModel("claude-opus-4");
// next.modelId / next.provider / next.model / next.displayName — what the runtime resolved
// next.capabilities — the four capability reports on the new provider
```

Crossing providers is supported: the engine tears down the old provider thread and replays the transcript into the new one.

`setModel` **refuses while a turn is in flight** (`turn_in_flight` from SDK 0.4; `invalid_option` before) unless you pass `{ force: true }`. A forced switch ends that turn without `error` or `done` — subscribers just stop receiving. Interrupt first if you need a clean handoff.

`setModel` returns the resolved model and `capabilities`: `mcpCapability`, `permissionCapability`, `instructionsCapability` and `settingSourcesCapability` as they are on the new provider. The thread also emits the same reports as a `capabilities_changed` envelope on the `status` channel. A switch across providers can lower a guarantee. For example, a policy that Claude enforces is best-effort on Codex. Read the new reports before you tell a user that their rules still apply.

`thread.model` and the four capability fields on the thread change to the new values.

## Export and dispose

```ts theme={null}
const jsonl = await ade.exportThread("support"); // one JSON envelope per line
await ade.dispose();
```

`dispose()` stops the child runtime. It is idempotent. Unlike `setModel`, dispose is **not** refused mid-turn: a shutdown that can refuse is worse than a truncated reply. The transcript is durable either way — `exportThread` still returns what was persisted.

Do not call `dispose()` on every page unmount if the host process is staying up. One client per home, disposed when the host actually exits.

## Attachments

```ts theme={null}
await thread.send("Look at this screenshot", {
  attachments: [
    { path: "/Users/me/MyApp/work/screen.png" },                  // sent as an image
    { path: "notes/spec.md", name: "spec.md" },                  // relative to the chat's cwd
    { path: "/Users/me/MyApp/work/mix.wav", hydrate: false },    // path only, bytes never read
  ],
});
```

`send()` and `steer()` each need text or at least one attachment.

An attachment is an `AgentChatFileRef`. It names a file on disk. The SDK does not upload bytes.

```ts theme={null}
type AgentChatFileRef = {
  path: string;              // absolute, or relative to the chat's working directory
  name?: string;
  mimeType?: string;
  bytes?: number;
  type?: "file" | "image";   // inferred when absent
  hydrate?: boolean;         // default true
};
```

### `type`

`"image"` sends the bytes as an image that the model can see. `"file"` sends a path hint, and the model opens the file with its own tools.

When you do not set `type`, the SDK sets it before it sends the attachment, with the runtime's own rule. An `image/*` `mimeType` gives `"image"`. Otherwise the extension of `path` decides: `.bmp`, `.gif`, `.heic`, `.heif`, `.ico`, `.jpeg`, `.jpg`, `.png`, `.svg`, `.tif`, `.tiff` and `.webp` give `"image"`, and everything else gives `"file"`. An explicit `type` always wins. `inferAttachmentType(path, mimeType?)` is exported if you need the same rule.

A provider still decides which image formats it accepts. Claude takes jpeg, png, gif and webp up to 10 MB inline, and sends any other image as a path hint.

Before 0.3 the field did not exist, and every image reached the model as a path hint.

### `hydrate: false`

`hydrate: false` is **reference-only**. The runtime never opens the file, never copies it to a staging directory, and gives the model the path alone. This is also true for an image. Use it for a file that must not download as a side effect, for example a macOS File Provider placeholder: a read of the bytes downloads the whole file from the cloud.

A runtime older than 1.2.81 ignores `hydrate` and reads the bytes. See [Version compatibility](https://ade-app.dev/docs/sdk/runtime.md#version-compatibility).

### Where a path may point

* A **relative** path resolves in the chat's working directory: the thread's `cwd`, or the runtime's scratch workspace when you did not set one.
* An **absolute** path must be inside one of these roots:
  * the chat's working directory;
  * `<home>/personal-chats/state`, the runtime's state directory;
  * a directory in the thread's `attachmentRoots` (SDK 0.4, runtime 1.2.82).
* Any other path throws, and the send fails. The error names the path.

Before runtime 1.2.81, an absolute path had to be inside `<home>/personal-chats/state`. A path inside your own `cwd` threw.

#### How the runtime compares paths

The runtime compares **resolved** paths, not strings:

* It resolves each root with `realpath`, so a symlinked root (a Music folder that is a link) works. A root that no longer exists is skipped.
* It resolves the attachment path one part at a time. Each part that exists is replaced by its real path, and `..` goes to the parent of that real path. A part that does not exist yet is kept as written, and the read fails later if the file is still missing.
* The resolved path must be inside the resolved root. A symlink inside a root that points outside it is refused.
* On macOS, letter case does not matter for parts that exist.

You do not have to spell a path under the root exactly as you passed the root.

#### `attachmentRoots`: attach files where they are

To attach a file from a folder that is not the working directory (Downloads, a media library), add the folder to `attachmentRoots` instead of copying the file:

```ts theme={null}
const thread = await ade.threads.open("main", {
  provider: "claude",
  model: "claude-sonnet-4-5",
  cwd: workDir,
  attachmentRoots: [app.getPath("downloads"), musicLibraryDir],
});

// Later, a new folder: replace the roots. This works on a thread the client
// already holds open, and on a resume. A refused root fails the open and
// changes nothing.
await ade.threads.open("main", { refresh: { attachmentRoots: [app.getPath("downloads"), newLibraryDir] } });
```

* Each root follows the `cwd` rules: absolute, no `~`, not a filesystem root, not the home directory itself, not inside the SDK `home`. At most 32. A bad entry throws `invalid_option` and names it.

* The roots are **read roots for attachments only**. They do not change where the agent runs, and they are not a sandbox.

* **One refused entry fails the whole open.** Because the runtime is sent the whole list, and a host usually sends the same roots on every open, one bad entry would lock every chat. Test a candidate with `checkAttachmentRoot`, or prepare the list with `filterAttachmentRoots` (SDK 0.5), which drops the bad entry and reports why instead of throwing:

  ```ts theme={null}
  const { roots, dropped } = filterAttachmentRoots([downloads, musicDir, "/"], home);
  for (const entry of dropped) log.warn(`attachment root ignored: ${entry.root} — ${entry.reason}`);
  await ade.threads.open("main", { ...createOptions, attachmentRoots: roots });
  ```

* A list **identical** to the one already on the thread is not sent again, the same way an identical `refresh.mcpServers` map is skipped. A host that repeats its four roots on every open costs no update call and does not restart the provider.

* Like `cwd`, `attachmentRoots` on a resume is ignored (with a log line). Use `refresh.attachmentRoots` to replace them; `[]` clears them.

* With `hydrate: false`, a file in a root is attached without being read, so a File Provider placeholder is not downloaded.

#### Do you need to stage files yourself?

No. The runtime already handles each file:

* **Codex:** the runtime copies each attachment that it may read to its own temporary directory at send time. With `hydrate: false` it copies nothing and puts the path in the prompt.
* **Claude:** the runtime reads the bytes into the message.

The file must still be readable when you send. Copy a file yourself only when its folder cannot be a root.

### Size limits

| Route | Limit |
| - | - |
| Claude, `type: "image"` | 10 MB, and only JPEG, PNG, GIF or WebP. A larger image, or another format, goes as a path hint. The turn still runs. |
| Codex, `type: "image"` or `"file"` | No size cap. Codex copies the whole file to a staging directory before the turn, unless `hydrate: false`. |
| Claude, `type: "file"` | No read. The model gets a path hint. |
| OpenCode | ADE sets no cap. OpenCode gets a file URL and reads the file itself, unless `hydrate: false`. |
| Droid, Pi, Cursor, ACP providers, text inline | 512 KB. A larger file goes as a path hint. |
| Any provider, `hydrate: false` | No read. The model gets a path hint. |

Over the [Electron bridge](https://ade-app.dev/docs/sdk/electron.md#attachments-are-paths-not-bytes), attachments cross as paths only.

## Next

> [card] [MCP servers](https://ade-app.dev/docs/sdk/mcp.md)
>
> Inject tools and read the strict-mode honesty table.

> [card] [Permissions](https://ade-app.dev/docs/sdk/permissions.md)
>
> Per-tool rules, and answering an approval before it blocks the turn.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.