Skip to content

Preparing and Sending a Turn

A session processes one turn at a time. prepare() is an optional optimization; sendText() starts a user turn and resolves after Core reports that exact operation as completed.

The App and Agent share the session’s physical .pedelec-runtime/assets/ directory. The SDK exposes that store through public /... paths whose implicit root is not included in the returned path. The App can upload input files, recursively list completed files from every level, and read files produced by either side until the session ends.

Use uploadAsset() with one browser File. Files are limited to 100 MiB; returned public paths use /..., with .pedelec-runtime/assets/ as their physical root and assets/ as their implicit SDK root. Upload can run alongside prepare and agent execution, although a session allows only one concurrent upload.

const file = input.files?.[0];
if (!file) return;
const path = await session.uploadAsset(file);
await session.sendText(`Please process this file: ${path}`);

The file body is sent directly to a loopback server listening on 127.0.0.1; it does not travel through the extension, native-messaging host, or Core IPC. A successful upload only means the file is available in the workspace—it does not guarantee that the selected provider or model can understand its format.

const assets = await session.listAssets();
for (const asset of assets) {
console.log(asset.path, asset.sizeBytes, asset.modifiedAt);
}

listAssets() recursively returns regular files from every level of assets/ as a flat array, sorted by filesystem modification time newest first and then by name. A nested file retains its basename in name and uses its complete public path in path, such as /results/report.json. Directory entries and symlinks are excluded; only .pedelec-* entries are excluded from every level, while other dotfiles are included. It has no pagination and can be called while the Agent runs. Use readAsset() with any returned path.

const text = await session.readAsset("/report.txt", "text");
const result = await session.readAsset<{ ok: boolean; score: number }>(
"/results/result.json",
"json",
);
const modelFile = await session.readAsset("/model.glb", "file");

"text" requires valid UTF-8. "json" also parses the text as JSON; its generic type is only a TypeScript annotation, so validate untrusted data before using it. "file" preserves binary bytes in a browser File. Reads support known nested /... paths and files up to 100 MiB.

Listing and reading can run alongside provider execution. When the Agent may still be writing the target path, wait for an explicit tool result, message, or other workflow signal before reading to avoid observing an unstable file. All asset methods reject with SESSION_ENDED after session shutdown.

await session.prepare();

prepare() asks the desktop runtime to prepare the provider session before the first real prompt. This can move some startup work into an earlier moment, such as when the user opens an assistant panel.

Important behavior:

  • It is optional. sendText() works without it.
  • After one successful preparation, later calls resolve immediately.
  • Concurrent calls share the same in-flight preparation promise.
  • Calling it while a user turn is active rejects with SESSION_BUSY.
  • Calling it after session end rejects with SESSION_ENDED.
  • Assistant output emitted during the preparation turn is not delivered through either onChat() or onChatDelta().
  • Status events can still show the preparation lifecycle.
async function warmUpAssistant() {
try {
await session.prepare();
} catch (error) {
// Preparation is an optimization. Keep the normal send flow available.
console.warn("Pedelec preparation failed", error);
}
}

If sendText() is called while preparation is in progress, it waits for that promise. If preparation fails, sendText() falls back to the normal first-run path rather than permanently blocking the user turn.

await session.sendText("Summarize the selected document.");

The SDK performs these steps:

  1. waits for in-flight preparation, if any;
  2. rejects if the session ended or another turn is active;
  3. creates SDK-local turn metadata;
  4. sets status to running with ctx.source === "sdk";
  5. sends send_text through the bridge;
  6. dispatches completed chat messages, optional chat deltas, status, and tool events;
  7. resolves or rejects only when the matching operation completion is reported; idle and error status events are observable state, not promise terminals;
  8. rejects if the turn errors, the session ends, or transport setup fails.

sendText() does not return the assistant text. Use onChat() for completed logical messages; use onChatDelta() separately only when you need live incremental rendering.

const completedMessages: string[] = [];
const offChat = session.onChat((text) => {
completedMessages.push(text);
});
await session.sendText("Write a title.");
console.log("Completed assistant messages:", completedMessages);
offChat();

The bridge request succeeding only means that Core admitted the operation. The promise resolves after the matching internal operation_completed event, so an idle status or a request response alone cannot complete a turn. It does not mean your rendering framework has necessarily painted the final state to the screen.

const first = session.sendText("First task");
const second = session.sendText("Second task"); // rejects with SESSION_BUSY
await first;

Queue work in the application when sequential prompts are desired:

let queue = Promise.resolve();
function enqueuePrompt(text: string) {
queue = queue.then(() => session.sendText(text));
return queue;
}

Do not use this simple queue after session end without error recovery, because one rejected promise can stop later .then() work. A production queue should catch each task and re-check session status.

Use status plus a local submit guard:

let submitting = false;
async function submitPrompt(text: string) {
if (submitting || session.getStatus() !== "idle") return;
submitting = true;
renderComposerDisabled(true);
try {
await session.sendText(text);
} finally {
submitting = false;
renderComposerDisabled(session.getStatus() !== "idle");
}
}

Why keep both?

  • getStatus() prevents obvious invalid sends.
  • submitting closes the small synchronous gap before reactive UI updates.
  • The SDK’s SESSION_BUSY check remains the final authority.

During waiting_tool_result, the session is still busy. Do not re-enable the normal prompt composer just because the provider is waiting on a modal or frontend action.

The SDK currently forwards the provided string and does not enforce non-empty trimmed text in sendText(). Applications should normally reject empty input before calling it:

const text = input.value.trim();
if (!text) return;
await session.sendText(text);

Use both promise rejection and onError():

session.onError((error, ctx) => {
logSessionError(ctx.sessionId, error);
});
try {
await session.sendText(text);
} catch (error) {
showTurnFailure(error);
}

Promise rejection is useful for the action that initiated the turn. A Core error event is diagnostic and reaches onError(); the semantic turn rejection is carried by the matching failed operation completion. onError() also captures asynchronous session-level problems such as a failed tool-result submission or extension disconnect broadcast.

Common errors:

  • SESSION_BUSY;
  • SESSION_ENDED;
  • SEND_TEXT_FAILED fallback for a rejected send request;
  • SESSION_ERROR from runtime state;
  • extension/native/Core transport errors; and
  • provider-specific errors.

The public SDK currently provides end() for ending the session, but no per-turn cancel method. Do not present a “Stop generation” button unless your application is prepared to end the whole session or a future SDK version adds explicit cancellation.

Continue with Streaming responses.