ctx.agents.sessions beta
Connect to a Chat session and drive it — the counterpart to ctx.agents.profiles for the chat launch arm. Where profiles.launch() starts a terminal and walks away, sessions.connect() holds a live connection: an Agent Client Protocol child speaking structured JSON-RPC over piped stdio, which an extension can prompt, watch, and cancel.
ctx.agents.sessions: AgentSessionsServiceSourced from user-authored profiles only. There is no "connect to this command" — an extension names a Chat profile the user defined on Settings → Agents; it cannot point Silo at an arbitrary binary to spawn with pipes.
Needs the "agents" permission — declare it in your extension's silo.permissions (it is shown at install, like fs:read). Every connect() throws without it.
The same session appears in ctx.agents as an AgentInfo with kind: "chat" and the handle's id — so the Agents navigator, attention badges, and status all work for it with no extra wiring, exactly as for a Terminal session.
session.id and session.sessionId are not the same thing, and they can diverge. id is the ctx.agents-namespaced key — pass it to reveal, resume, acknowledge; it is stable across an in-place ctx.agents.resume(). sessionId is the agent's own protocol id — pass it back as AgentSessionRestore.sessionId on a future connect(); it can change out from under you (session/load may adopt a fresh one on claude). Read session.sessionId back after every connect() and persist that, never the id you passed in — see Resume below.
session.agentId / session.agentName are the handle's own copies of the catalog key and display name — agentId is the profile's asserted or auto-detected catalog agent (same value as AgentProfileSummary.agentId), agentName is what the agent declared at initialize, falling back to the profile's label when it declared nothing.
Example
Drive one turn and render the stream
const session = await ctx.agents.sessions.connect("claude-chat");
const offUpdate = session.onUpdate((u) => {
if (u.kind === "agent_message_chunk" && u.text) {
appendBubble(u.messageId, u.text); // consecutive chunks share messageId
}
});
const offPermission = session.onPermission((req) => {
// The agent is blocked until you answer. This is NOT a safety boundary —
// an agent can touch the filesystem without asking.
// `req.toolCall` is the call it wants to make, in the same shape the update
// stream carries — so you can show the diff before they answer.
req.respond(req.options[0].optionId);
});
const result = await session.prompt([
{ type: "text", text: "Summarise what this repo does." },
]);
ctx.log.info(`turn ended: ${result.stopReason}`);
ctx.subscriptions.push(offUpdate, offPermission, {
dispose: () => session.dispose(),
});Cancel a runaway turn
const turn = session.prompt([{ type: "text", text: "Refactor everything." }]);
// ...user hits stop:
session.cancel();
const { stopReason } = await turn; // "cancelled" — the promise resolves, not rejectsReveal: let Silo focus your UI
ctx.agents.reveal(id) is the kind-agnostic "bring this agent into view" verb — the Agents navigator, a command, a notification all call it without knowing whether the session is a terminal tab or a transcript. Silo can activate the session's workspace on its own; only you know how to focus your UI, so pass it at connect time:
const session = await ctx.agents.sessions.connect(profileId, {
reveal: () => apiRef.current.setActive(), // a dock panel focusing its tab
});It is called after an await, possibly more than once, so read the handle you need through a ref rather than closing over one render's props. Omit it and reveal(id) still activates the workspace — which is all Silo can honestly do for a surface it does not own.
Workspace and cancellation options
workspaceId files the session under a specific workspace (for AgentInfo.workspaceId and reveal) — it defaults to the active one, but a background workspace works too: the session spawns eagerly since no panel is there to trigger it. signal cancels the handshake itself (initialize → session/new/session/resume) — pass your React effect's AbortController.signal so a re-run or unmount before connect() resolves doesn't leak the spawned agent process:
const controller = new AbortController();
const connecting = ctx.agents.sessions.connect(profileId, {
workspaceId: someWorkspaceId,
signal: controller.signal,
});
return () => controller.abort(); // effect cleanupAn aborted connect() rejects with a DOM AbortError and disposes whatever it had already spawned — see Reasons connect() rejects below.
Being the place a Chat profile opens
A profile whose interface is "chat" cannot be launched into a terminal, so the gestures that start an agent — a dock's + menu, the profile's core.newAgent.<id> command — need a panel to open instead. Declare chatProfileHost on your dock panel kind and Silo opens yours, passing params.profileId:
ctx.registerDockPanelKind({
id: "acme.chat",
component: AcmeChatPanel, // reads params.profileId, calls connect()
chatProfileHost: true,
});Without it your panel still works — the user just has to reach it your way rather than through the profile list.
Content blocks
A prompt is structured blocks, never a shell string — the entire quoting/escaping risk surface of a Terminal session's opening prompt does not exist here.
| Block | Meaning |
|---|---|
{ type: "text", text } | A run of plain text. $HOME, backticks, newlines — all literal. |
{ type: "resource_link", uri, name? } | A pointer to a file (usually a file:// path) the agent may read. Always allowed — no capability gate. |
{ type: "resource", uri, text, mimeType? } | The file's own text inlined into the prompt. Only send this when session.promptCapabilities.embeddedContext is true — see below. |
Images are deferred behind a probe of what each agent actually accepts for one (RFC 0040 open question 3 — no agent has been sent one yet).
The update stream
onUpdate delivers each Agent Client Protocol session/update as an AgentSessionUpdate. Tolerate kind values you don't recognize — agents emit different subsets and vendors add their own. Switch on the kinds you render and ignore the rest.
Everything a transcript must draw is a modelled field. You do not read the wire format to build a Chat UI:
kind | What to read |
|---|---|
agent_message_chunk / agent_thought_chunk / user_message_chunk | text — or content for a block that isn't text (AgentContentBlock) |
tool_call / tool_call_update | toolCall — AgentToolCall |
plan | plan — the whole list of AgentPlanEntry |
usage_update | usage — AgentSessionUsage |
messageId groups a run of streaming chunks into one bubble — synthesized by Silo when the agent omits it, which real agents do, so you never have to mint ids yourself.
Tool calls
A tool_call opens a call; a tool_call_update changes one already open and carries only what changed — usually just { toolCallId, status }. So key your rows by toolCall.toolCallId and patch the fields that are present. Every field but the id is optional, and an absent one means unchanged, never empty:
session.onUpdate((u) => {
if (u.kind !== "tool_call" && u.kind !== "tool_call_update") return;
const call = u.toolCall;
if (!call) return;
upsertRow(call.toolCallId, {
title: call.title, // present on the opening call, and on a relabel
kind: call.kind, // "read" | "edit" | "execute" | … | a vendor's own
status: call.status, // "pending" | "in_progress" | "completed" | "failed"
files: call.locations?.map((l) => l.path),
body: call.content, // text blocks, diffs, terminal pointers
});
});An update whose opening tool_call never arrived is a real shape — render it rather than dropping it. rawInput / rawOutput are the agent's own tool arguments and result: protocol-carried, but vendor-shaped by definition, so they are typed unknown and each agent's shape differs.
The plan
A plan update carries the agent's plan in full — the agent reissues the whole list every time — so replace what you are showing rather than appending:
if (u.kind === "plan" && u.plan) setPlan(u.plan); // may be emptySession usage
A usage_update carries session context state — sender-optional: most agents never emit it, so u.usage is undefined for the whole session on those, not a placeholder zero to render:
if (u.kind === "usage_update" && u.usage) {
const { used, size, cost } = u.usage; // cost is itself optional
setContextMeter(used / size);
}What raw is for
raw is the escape hatch, not the way to render a transcript. It holds the untouched protocol object, and is there for the kinds deliberately left unmodelled: vendor extensions such as claude-agent-acp's _meta. Three more — current_mode_update, session_info_update, and available_commands_update — are already surfaced as configOptions, AgentInfo.title, and commands (RFC 0040), so you should not need raw for them either.
raw tracks the protocol, not this SDK's semver. A field inside it can change shape, or vanish, when an agent or its adapter changes — no SDK major required. Read it defensively; and if you find yourself needing it for something every Chat UI must render, that is a gap in this surface worth reporting.
Silo's own Chat panel is the proof this holds: it reads no raw field at all.
Session controls (mode, model, …)
The agent advertises its session-level controls at connect — a Cursor session carries a mode and a model entry, a Claude session its permission mode — and the handle surfaces them as one list:
for (const opt of session.configOptions) {
if (opt.type !== "select") continue; // tolerate a shape you don't render
renderSelect(opt.name, opt.currentValue, opt.options);
}
// change one — the host dispatches on opt.category
await session.setConfigOption("model", "claude-opus-4");
// reflect a mode the agent moved on its own
const off = session.onConfigOptionsChanged(() =>
rerender(session.configOptions),
);configOptions is self-describing, and so is the write — render one control per entry and you get whatever the agent offers with no per-agent code, whatever the category. setConfigOption goes through the protocol's generic session/set_config_option; Silo keeps session/set_mode / session/set_model only as a fallback for an agent that does not implement it. Claude's "effort" (category: "thought_level") has no typed method anywhere in the protocol and sets fine through the generic path.
It rejects, with nothing written, on an unknown id or a value outside that entry's options — and with the agent's own message when the agent refuses. Not every advertisement is settable: claude-agent-acp lists a fast entry its own handler answers -32603 Unknown config option for. Treat a rejected setConfigOption as "stop offering this control" — the bundled panel drops it from the composer.
Commands (and skills) — RFC 0040
The agent advertises its slash commands live, from the moment the session connects — before any prompt, for both agents probed:
for (const cmd of session.commands) {
renderPaletteRow(cmd.name, cmd.description, cmd.input?.hint);
}
const off = session.onCommandsChanged(() => rerender(session.commands));Empty until the agent's first available_commands_update. Both agents probed send one at connect (claude-agent-acp@0.75.1 at ~2.3s, pi-acp@0.0.33 at ~12.5s, behind its startup banner) — but nothing in the protocol requires one, ever, so connect() does not wait for it. input is normalised for you: Claude sends input: null for a command that takes no argument, pi omits the key entirely, and AgentCommand.input only ever sees undefined for both.
Skills arrive in this same list — there is no session.skills. ACP has no separate skills concept. The two agents probed mark them differently: pi prefixes the name (skill:code-review), Claude does not mark them at all outside a (user) / (project) suffix buried in description's prose. Splitting them back into a second list would mean pattern-matching a label neither agent is contractually bound to keep — so render one palette, not two.
There is no runCommand(). Invocation is what prompt already does:
await session.prompt([{ type: "text", text: "/" + command.name }]);Silo's own Chat panel is the proof this is enough: its / palette inserts the name into the composer and sends it through the ordinary path.
Prompt capabilities — what the agent will accept
const caps = session.promptCapabilities; // { image, audio, embeddedContext }
showAttachAffordance(caps.embeddedContext); // or `caps.image`, once modelledRead once from initialize and fixed for the session's life — unlike commands, there is no change event for it. A field the agent's initialize omitted reads false, not undefined: both codex-acp and claude-agent-acp omit audio entirely (recon 2026-09-09), so treating an absent field as "not accepted" is the only reading that does not push a ?? false onto every consumer.
Gate a "resource" prompt block on embeddedContext specifically — "resource_link" is a pointer the agent may or may not read, always allowed, and not gated on anything here. Silo's own Chat panel deliberately leaves its existing file-attach affordance (resource_link) ungated for this reason; it would gate a future embedded-content or image affordance the same way once one exists.
Attaching a file
resource_link blocks carry files into a turn. Pair ctx.ui.pickFile with a file:// URI:
const path = await ctx.ui.pickFile({ defaultPath: workspaceFolder });
if (path) {
await session.prompt([
{ type: "resource_link", uri: `file://${path}`, name: basename(path) },
{ type: "text", text: "Review this." },
]);
}Resume — Chat session resurrection
The agent process is a piped child of Silo and does not survive the app closing — but the session does (RFC 0042). AgentSessionHandle.canResume (and AgentInfo.canResume) is true when the agent advertises session/resume or session/load; ctx.agents.resume(id) spawns a fresh process and reconnects the conversation through whichever the agent advertises.
To restore your own panel's session across a restart, persist AgentSessionHandle.sessionId (e.g. in a recorded panel's DockPanelState — RFC 0041) and pass it back as resume on your next connect():
const session = await ctx.agents.sessions.connect(profileId, {
cwd,
resume: savedSessionId ? { sessionId: savedSessionId } : undefined,
});
// Paint prior turns before subscribing — the transcript journal, read from
// disk. Empty for a brand-new session.
let transcript = session.journal.reduce(applyUpdate, emptyTranscript);
session.onUpdate((u) => (transcript = applyUpdate(transcript, u)));
// Persist the identity to restore next time.
saveSessionId(session.sessionId);connect() probes session/resume (fast, no replay — the transcript comes from journal alone) and session/load (replay, reconciled against the journal) separately, preferring resume, and never rejects just because the target has gone stale: a vanished session falls all the way through to a fresh session/new. Check session.resumeOutcome:
resumeOutcome | Meaning |
|---|---|
"new" | An ordinary session/new — nothing to restore, or the target had gone stale. |
"resumed" | Reconnected over session/resume or session/load. |
"journal-only" | The agent could do neither. journal is the whole transcript; prompt() rejects. Offer "Continue in a new session" — see below. |
session/load may adopt a new session id (observed on claude) — always read session.sessionId back after connect() resolves and persist that, not the id you passed in.
Session Discovery — finding a session to resume you didn't start
Everything above assumes you already have a sessionId to pass back. Session Discovery (RFC 0051) is how you get one for a conversation your own panel never persisted. AgentSessionHandle.canList is true when the connected agent advertises session/list (ACP v1, stable). Where canResume answers "can this conversation come back," listSessions() answers "what other conversations does this agent still know about" — metadata only, not a live handle:
if (session.canList) {
const others = await session.listSessions();
// others: readonly AgentSessionSummary[]
// { sessionId, cwd?, title?, updatedAt?, raw }
}Pick one and pass its sessionId back as resume on a fresh connect() — the exact same restore flow above, with resumeOutcome and the session/resume → session/load → journal fallback all applying unchanged:
const target = others.find((s) => s.cwd === cwd); // scope to your own cwd
if (target) {
const session = await ctx.agents.sessions.connect(profileId, {
cwd,
resume: { sessionId: target.sessionId },
});
}listSessions() rejects when canList is false (check it first, the same way you'd check canResume) or when resumeOutcome is "journal-only" — there is no live connection to ask. Silo's own Chat panel scopes its picker (the /resume reserved command) to sessions whose cwd matches the current panel's — see ADR 0055 for why: session/resume/session/load both take cwd as a wire parameter, and a mismatched folder risks the agent rejecting the reconnect or misresolving relative paths inside the very conversation being restored.
A picked session may have no transcript journal on this machine — Silo only journals sessions it has already seen through some panel of its own. If the agent supports only session/resume (no replay) for that id, the resumed session opens live with an empty journal, even though the agent's own context is genuinely continuing.
Showing the right title while reconnecting
initialize alone can take several seconds (an adapter's own SDK bootstrap dominates it) — well before resume/load even starts. Pass the last title this session showed and connect() registers it immediately, so your tab (and the workspace row, and the Agents navigator) show it right away instead of the plain profile label for however long reconnecting takes:
const session = await ctx.agents.sessions.connect(profileId, {
cwd,
resume: { sessionId: savedSessionId },
title: savedTitle, // what AgentInfo.title last was, persisted alongside sessionId
});Ignored without resume — a fresh session has no prior title to show early. It outlives the handshake: it stays the session's title once the connection is up, until the agent volunteers a real one of its own (a title update). The agent's product name from initialize does not replace it — that is the fallback for a session that never had a title, not a substitute for one this conversation already earned.
Sessions Silo lists before anything connects
A Chat session's agent process dies with the app; the session does not. Silo remembers each one's identity and lists it in the Agents navigator on the next launch with chatResumeState: "dormant" — the conversation exists, its panel is recorded, and nothing is running behind it yet. This is what a session in a workspace the user has not visited this run looks like, and it is why they can find it at all: revealing one activates its workspace and its tab, at which point your panel mounts and its connect({ resume }) takes over.
A dormant entry is always activity: "idle", and its title / agentId are whatever they last were. Nothing is asked of an extension to get this — it follows from persisting sessionId in your panel's state, exactly as the restore flow above already requires.
Painting a transcript before you decide to reconnect
readJournal(sessionId, options?) reads a session's transcript journal straight off disk — no agent process, no connect() handshake, no session/resume/session/load round trip. Useful for a dormant entry: paint what the conversation last looked like the instant the user reveals it, before the reconnect that connect({ resume }) still has to do resolves:
const journal = await ctx.agents.sessions.readJournal(dormantSessionId);
let transcript = journal.reduce(applyUpdate, emptyTranscript); // same reducer as `session.journal`Same "agents" permission as connect(). Never rejects on a missing journal — an id with nothing on disk yet resolves to an empty array, exactly like a brand-new session's session.journal. Pass { workspaceId } if the session isn't in the active workspace.
Continuing a "journal-only" session
There is no live agent to prompt, but the conversation is not lost: reconnect with startFresh to start a new agent process while continuing to write into the same transcript journal —
const session = await ctx.agents.sessions.connect(profileId, {
cwd,
resume: { sessionId: savedSessionId, startFresh: true },
});
saveSessionId(session.sessionId); // the *new* id — same as any other connectPersist session.sessionId afterward, exactly as after a plain connect() — not savedSessionId. Keeping the original id instead means every future restore keeps retrying an id the agent has already shown it can't resume, forever, even while the new conversation itself works fine turn after turn. The journal is carried into the new id's file for you, so nothing is lost by moving on from it.
Clearing a session
Clear is a session reset. The same options carry the opposite intent: transcript: "discard" ends the current session, starts a new one, and throws the old transcript journal away, so neither the agent's context nor Silo's own record survives —
const session = await ctx.agents.sessions.connect(profileId, {
cwd,
resume: {
sessionId: savedSessionId,
startFresh: true,
transcript: "discard",
},
});
saveSessionId(session.sessionId); // the new id, as always"carry" is the default, so leaving transcript off keeps the behavior above. The field is read only alongside startFresh: an ordinary restore never deletes a journal, whatever it says — for a session the agent can neither resume nor load, that file is the only copy of the conversation. Reach for "discard" when a user explicitly asked for the conversation to go away (a Clear command, a ⌘⇧K), never as cleanup.
In Silo's own Chat panel this is what ⌘⇧K, the transcript's Clear Session menu item, and a typed /clear all do — that panel reserves the name clear and answers it itself, so it means the same thing whichever agent is connected. That is a convention of Silo's panel, not of this surface: session.commands gives your extension the agent's own list, clear included, and forwarding it sends it to the agent like any other command. Reserve it yourself if you want the same behavior.
Reasons connect() rejects
- the extension did not declare the
"agents"permission; - no profile has that id;
- the profile is a Terminal profile, not a Chat one;
- there is no target workspace;
- the caller's
signalwas aborted before the handshake finished (aDOMException"AbortError"— see Workspace and cancellation options); - the agent needs authentication — its
session/newfailed (a non-emptyauthMethodslist does not by itself mean auth is required); - the agent reported a startup or business error — the rejection carries its own message.
See also
AgentSessionsServiceAgentSessionHandleAgentSessionConnectOptionsAgentSessionRestoreAgentSessionSummaryChatResumeStateAgentPromptBlockAgentPromptResultAgentStopReasonAgentSessionUpdateAgentToolCallAgentToolCallContentAgentToolCallLocationAgentPlanEntryAgentContentBlockAgentSessionConfigOptionAgentSessionConfigChoiceAgentCommandAgentPromptCapabilitiesAgentPermissionRequestAgentPermissionOptionctx.agents— the shared activity/status view both session kinds feedctx.agents.profiles— start a Terminal session instead- RFC 0038 — Agent Sessions (Terminal and Chat)
- RFC 0051 / ADR 0055 — Session Discovery, the
/resumepicker