Extension sessions
Drive other sessions from an extension, call models without a session, run commands in the workspace, and record artifacts.
Four domains reach past the extension's own code:
ctx.sessionscreates, drives, reads and changes sessions, acting as the extension.ctx.generatecalls a model outside any session.ctx.workspaceruns commands and reads files where the session's tools run.ctx.artifactsrecords and lists what sessions produced.
Use them for workflows that span sessions (a reviewer that spawns workers), commands that need a model answer, and commands that touch the project's files.
They need host support. Every app has it. A host without it, such as an agent built with the SDK and no chat host, rejects the async calls with an error that names the call and the host.
Sessions#
ctx.sessions holds the verbs of orchestration, acting as the extension. What it creates is tagged with the extension's id. From the kitchen-sink example (examples/extension-kitchen-sink/sessions.ts):
ctx.commands.register({
id: 'spawn',
title: 'Kitchen sink: start a helper session',
run: async () => {
const id = await ctx.sessions.create({ cwd: ctx.paths.cwd, title: 'Kitchen helper', prompt: 'Read the counter and report it.' })
await ctx.ui.navigate({ session: id })
},
})Drive sessions#
| Member | Returns | Does |
|---|---|---|
create({ cwd, prompt?, title?, agentId?, providerKey?, modelId?, tools?, subscribe? }) | The session id | Creates a session. With prompt, it starts at once. tools (sessions, interactions) and subscribe make an orchestrator (below) |
send(id, prompt) | boolean | Sends a prompt. false when the user stopped the session, or when the user's queued prompts come first |
steer(id, message) | Puts a message into the run going on | |
cancel(id) | Stops the run | |
wait(id, timeoutSec?) | { reason, session } | Waits until the run ends. reason is idle, ended, blocked (a question waits) or timeout |
pending(id?) | Requests | Questions, plans and tool approvals that wait: { requestId, sessionId, kind, summary, payload } |
answer(requestId, response) | boolean | Answers a question or a plan |
approve(requestId, decision) | boolean | Answers a tool approval: 'accept-once', 'accept-session', 'accept-safelist', 'deny' or { kind: 'partial', mask } |
const id = await ctx.sessions.create({ cwd: ctx.paths.cwd, prompt: 'Fix the flaky test' })
const { reason } = await ctx.sessions.wait(id, 300)
if (reason === 'blocked') {
for (const request of await ctx.sessions.pending(id)) {
if (request.kind === 'approval')
await ctx.sessions.approve(request.requestId, 'accept-once')
}
}Read sessions#
| Member | Returns |
|---|---|
get(id) | { id, title?, cwd, agentId?, providerId?, modelId?, thinking?, status }, or undefined. status takes the values of a list row's: idle, running, blocked, completed or error |
list({ where?, sort?, limit?, cursor?, archived? }) | One page of sessions that match a session query, newest first: { rows, cursor? }. Pass cursor back for the next page |
facets.register({ key, compute, on? }) | A value per session that queries read as facet.<extension>.<key>. Works on every host (facets) |
facets.refresh(sessionId?) | Asks the host to compute the extension's facets again |
read(id) | The session's detail: title, status, last messages |
transcript(id, limit?) | The last turns as text |
events({ sessionId?, status?, since?, text?, limit? }) | Recent session events, newest first: runs that ended, sessions that wait |
To hear events as they happen, observe session:event and run:settled.
List sessions#
list takes a query, not a count. Every field is optional:
| Field | Type | Does |
|---|---|---|
where | SessionQuery | Only the sessions that match. Absent: every session |
sort | SessionSort[] | Keys { field, dir? }, 1 to 8. Default [{ field: 'updatedAt', dir: 'desc' }]. Ties break by id |
limit | number | Rows per page, 1 to 100. Default 30 |
cursor | string | The previous page's cursor. Pass the same where, sort and archived with it: a cursor made for others is refused |
archived | boolean | 'all' | false (default) leaves archived sessions out, true lists only them, 'all' both |
const page = await ctx.sessions.list({
where: { all: [{ field: 'status', eq: 'error' }, { field: 'updatedAt', gte: '-1d' }] },
limit: 10,
})
for (const row of page.rows)
ctx.logger.warn(`${row.title} failed (${row.agentId ?? 'default agent'}, ${row.worker ? 'mine' : 'not mine'})`)A row holds the session's fields (id, title, projectRoot, projectName, cwd, agentId, status, createdAt, updatedAt, runStartedAt, archived, favorite, isMain, spaceId, providerKey, modelId, account, effort, hostId, repoKey, environment), its facets by <extension>.<key>, and worker: true when this extension created it. status is idle, running, blocked (a run waits on the user), completed (the last run ended well) or error (it failed). Side sessions are left out. An invalid query throws a SessionQueryError whose issues name each problem.
A page resumes after the last row's place in the sort (its sort keys, then its id), so sessions added or removed between two calls do not shift it. A session that moves in the sort between two calls (it was updated, under the default sort) is read where it sorts at each call. The query language, facets and paging: sidebar.
A host without the query verb lists its 100 newest orchestration rows and filters them in the extension. Those rows have only id, title, cwd, projectName, status, updatedAt and worker: no facets, no createdAt and no archived sessions. A query those rows cannot answer throws an error that says why: a query on another field or a facet, archived other than false, a filter or a sort other than newest first when the host has 100 sessions or more, or a page that the 100th row cuts short. A page that ends exactly at the 100th row comes back whole, with no cursor.
list(limit) from earlier versions is gone. Call list({ limit }) and read .rows. The rows changed too:
| Earlier row | Now |
|---|---|
sessionId | id |
state (idle, running, blocked) | status, which adds completed and error |
running | Gone. Test status === 'running' |
orchestrator | Gone |
turns | Gone. read(id) has the detail |
get(id) answered status: 'ended' for a session whose last run ended. It now answers completed or error, as list rows do.
Change sessions#
| Member | Does |
|---|---|
switchModel(id, { providerId, modelId }) | Changes the model |
switchAgent(id, agentId) | Changes the agent profile |
setThinking(id, level) | Changes the thinking level |
compact(id) | Compacts the history now |
remove(id) | Deletes the session |
Each resolves to whether the host did it.
Add to a conversation#
Three calls put text in a session. They differ in who sees it and whether a run starts:
| Member | The model sees it | Starts a run |
|---|---|---|
submit(text, { sessionId?, delivery? }) | Yes, as a user prompt | Yes. / commands, interceptors and skills apply, as if the user typed it. Without sessionId, it goes to the session in view |
synthetic(id, { role, text }) | Yes, on the next turn | No |
note(id, blocks) | No. The user sees it in the transcript, and it is saved | No |
delivery is auto, steer (into the run going on) or followUp (after it). A note block is { text, detail?, custom? }: detail draws it as a collapsed row, custom: { renderer, data } draws it with a renderer, and text is the fallback.
ctx.commands.register({
id: 'session-info',
title: 'Kitchen sink: about this session',
when: 'session',
run: async ({ sessionId }) => {
if (!sessionId)
return
const info = await ctx.sessions.get(sessionId)
await ctx.sessions.note(sessionId, [{ text: `**${info?.title ?? sessionId}**: ${info?.status ?? 'unknown'}` }])
await ctx.sessions.synthetic(sessionId, { role: 'user', text: '(The kitchen-sink counter is now 3.)' })
},
})Orchestrators#
create with tools and subscribe makes an orchestrator: a session that holds the sessions and interactions tools and hears the sessions in its scope as <session-event> turns. One per project is subscribe: { projectRoot }. One across projects is subscribe: {}. Without prompt, it waits for events.
const projectRoot = ctx.paths.projectRoot ?? ctx.paths.cwd
await ctx.sessions.create({ cwd: projectRoot, title: 'Project lead', tools: ['sessions', 'interactions'], subscribe: { projectRoot } })Generate#
ctx.generate calls a model outside any session. It uses the user's provider settings and credentials, and counts the usage.
| Member | Resolves to |
|---|---|
text({ prompt, system?, model?, signal? }) | The answer as text |
object({ prompt, schema, system?, model?, signal? }) | An object checked against schema (zod, valibot, arktype or JSON Schema), typed from it |
decide(request) | A structured decision (decisions) |
image({ prompt, model?, n?, aspectRatio?, references?, save?, signal? }) | { images }: each image's mimeType and bytes, and its path when saved |
model is { providerId, modelId }. Without it, the app uses the user's default model. A call aborts when its signal aborts or the extension unloads.
import { z } from 'zod'
const name = await ctx.generate.text({ prompt: 'Give a two-word name to the number 42.', signal: ctx.signal })
const dish = await ctx.generate.object({
prompt: 'Invent a dish. Answer with its name and a price in euros.',
schema: z.object({ name: z.string(), price: z.number() }),
})
ctx.logger.info(`${name.trim()}: ${dish.name} (${dish.price} EUR)`)decide answers predicate, choice and score questions with a decision model:
if (ctx.generate.decide.available) {
const r = await ctx.generate.decide({
input: 'git push --force origin main',
questions: { destructive: { type: 'predicate', instructions: 'Does this command overwrite shared history?' } },
})
if (r.ok && r.answers.destructive.type === 'predicate' && r.answers.destructive.probability > 0.8)
ctx.logger.warn('destructive command')
}image generates pictures with the models of the generate_image tool: OpenRouter, OpenAI (with a platform key), Google Gemini or xAI, whichever the user connected. Without model, it takes the same model as the tool. The options:
| Field | Type | Does |
|---|---|---|
prompt | string | Required. What to draw |
model | { providerId, modelId } | An image model. Default: the generate_image tool's |
n | number | How many images, 1 to 4. Default 1 |
aspectRatio | string | 1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3 or 21:9 |
references | ({ path } | { mimeType, data })[] | Up to 8 images to start from: a PNG, JPEG, WebP or GIF file, or image bytes |
save | boolean | { sessionId? } | Also writes each file as the tool does, under the folder of sessionId (else of the active session, else the project), and records it as a file artifact of that session. Relative reference paths resolve against that folder |
signal | AbortSignal | Cancels the request |
const { images } = await ctx.generate.image({ prompt: 'A cast-iron pan on a wooden table, top view', aspectRatio: '16:9', save: true })
ctx.logger.info(`saved ${images.map(image => image.path).join(', ')}`)An invalid option rejects with a TypeError. With no connected image provider, or a model that is not an image model, the call rejects with the reason. The usage is counted with the provider's cost.
On a host without model access, text, object and image reject, decide.available is false, and decide answers { ok: false, reason: 'unavailable' }. Inside a hooks.on handler, decide runs as the run's own decision: it fires the run's decision:* hooks, stops with the run, and replays from the journal.
Workspace#
ctx.workspace runs commands and touches files in the session's execution context: the local folder, or a Docker, E2B or remote sandbox when the session uses one (execution contexts). Use it instead of node:child_process and node:fs, which reach the host's disk, not the sandbox.
| Member | Does |
|---|---|
available() | Whether a session's workspace is reachable. Never starts a sandbox |
exec(command, { cwd?, env?, timeout?, signal? }) | Runs a shell command. Resolves with { stdout, stderr, exitCode } even for a non-zero exit |
readFile(path) | The file's text |
writeFile(path, content) | Writes the file, and creates its folders |
listFiles(path, options?) | The folder's entries. { glob: true } reads path as a pattern |
fileSize(path) | The size in bytes, or null |
ctx.commands.register({
id: 'workspace',
title: 'Kitchen sink: workspace status',
when: 'session',
run: async ({ sessionId }) => {
if (!sessionId || !await ctx.workspace.available())
return
const status = await ctx.workspace.exec('git status --short', { timeout: 10_000 })
await ctx.sessions.note(sessionId, [{ text: status.stdout || '(clean)', detail: { label: 'git status' } }])
},
})Without a session, every call but available rejects. The first exec may start the session's sandbox. Output is capped by the context's transport (10 MB by default). The shell.env interceptor applies to exec too.
Artifacts#
An artifact is something a session produced and the app tracks: a pull request, a deployment, a file. ctx.artifacts gives meaning to a kind, and reads and records artifacts.
| Member | Does |
|---|---|
kinds.register(kind) | Teaches the app a kind: how to find it in tool results, draw its row and badge, and refresh it |
list({ scope?, kind?, since?, limit? }) | Artifacts of the session in view (session, the default), its project (project) or every session (all) |
find(kind, uri) | An artifact by kind and URI, across sessions |
record({ kind, uri, label, meta? }) | Records an artifact on the session in view. Tool calls do not need it: detection records theirs |
A kind registration:
| Field | Does |
|---|---|
kind | Required. <extension id>.<name> by convention |
label | Required. The kind's name, such as "Pull request" |
detect({ tool, input, result, isError }) | Finds artifacts in a finished tool result: { uri, label, meta? }, a list, or nothing. It never changes the result the model sees |
validateMeta(meta) | Checks meta on what detect found. A failing check drops meta, not the artifact |
render(artifact) | The row: { title, subtitle?, icon?, tone? } |
resolve(artifact) | Refreshes it when a window shows it ("is the PR merged?"). Returns a patch for meta |
badge(artifacts) | The session badge for this kind |
ctx.artifacts.kinds.register({
kind: 'kitchen-sink.counter',
label: 'Kitchen counter',
detect: ({ tool, isError, result }) => {
if (tool !== 'kitchen_count' || isError || typeof result !== 'string')
return undefined
const count = (JSON.parse(result) as { count?: number }).count ?? 0
return { uri: 'kitchen://counter', label: `Counter at ${count}`, meta: { count } }
},
render: artifact => ({ title: artifact.label, subtitle: 'kitchen-sink', tone: 'accent' }),
})An artifact's detail view is a renderer for { artifact: kind } (placements).
In each app#
| App | ctx.sessions and ctx.generate | ctx.workspace |
|---|---|---|
| Terminal app | Through its chat host. In daemon mode, in the daemon | The session in view, or the one a command runs for |
| Desktop app | In the main process, or the daemon in daemon mode | The session the command runs for |
| Web app | In the server | The session the command runs for, on the server's machine or sandbox |
ctx.ui.navigate({ session: id }) shows a session in the window that the user works in. A server with no window open answers false.
Pitfalls#
workspaceandartifacts.recordneed a session. From a hook or a timer with no session, checkavailable()or pass the session through.sendresolvesfalse, it does not throw, when the session will not take the prompt now.submitruns/commands and thepromptinterceptor, your own included. Guard against a loop.syntheticmessages stay in the conversation and cost tokens on every later turn. Keep them short.generate.text,generate.objectandgenerate.imagereject on a host without model access. Catch the error, or checkdecide.availablefirst.generate.imageholds every image in memory as bytes. Ask for a few at a time, and passsaverather than writing large files yourself.node:child_processin an extension runs on the host, not in the session's sandbox. Usectx.workspace.exec.
Related#
- Orchestration: the same verbs as tools for a session.
- Hooks:
run:settledandsession:event. - Storage: data per session with
kv('session', id). - Sidebar: spaces, filters and facets over the same session queries.