Skip to main content
A thread is one conversation, keyed by a string you choose. Keys are stored under home and survive host restarts.
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.

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.
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. A recreate gives the thread a new thread.id. Key your own state on thread.key, not on thread.id.

List, rename, archive, delete

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).
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:

Title and model

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

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

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.

Retry and edit

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

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.

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:
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.
  • { 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. 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.
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

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.

Configuration layers

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. Read thread.settingSourcesCapability for the answer on the thread you actually got.

Switch models

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

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

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.

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.

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

Over the Electron bridge, attachments cross as paths only.

Next

MCP servers

Inject tools and read the strict-mode honesty table.

Permissions

Per-tool rules, and answering an approval before it blocks the turn.