Skip to content

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.sessions creates, drives, reads and changes sessions, acting as the extension.
  • ctx.generate calls a model outside any session.
  • ctx.workspace runs commands and reads files where the session's tools run.
  • ctx.artifacts records 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):

TypeScript
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#

MemberReturnsDoes
create({ cwd, prompt?, title?, agentId?, providerKey?, modelId?, tools?, subscribe? })The session idCreates a session. With prompt, it starts at once. tools (sessions, interactions) and subscribe make an orchestrator (below)
send(id, prompt)booleanSends 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?)RequestsQuestions, plans and tool approvals that wait: { requestId, sessionId, kind, summary, payload }
answer(requestId, response)booleanAnswers a question or a plan
approve(requestId, decision)booleanAnswers a tool approval: 'accept-once', 'accept-session', 'accept-safelist', 'deny' or { kind: 'partial', mask }
TypeScript
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#

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

FieldTypeDoes
whereSessionQueryOnly the sessions that match. Absent: every session
sortSessionSort[]Keys { field, dir? }, 1 to 8. Default [{ field: 'updatedAt', dir: 'desc' }]. Ties break by id
limitnumberRows per page, 1 to 100. Default 30
cursorstringThe previous page's cursor. Pass the same where, sort and archived with it: a cursor made for others is refused
archivedboolean | 'all'false (default) leaves archived sessions out, true lists only them, 'all' both
TypeScript
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 rowNow
sessionIdid
state (idle, running, blocked)status, which adds completed and error
runningGone. Test status === 'running'
orchestratorGone
turnsGone. 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#

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

MemberThe model sees itStarts a run
submit(text, { sessionId?, delivery? })Yes, as a user promptYes. / 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 turnNo
note(id, blocks)No. The user sees it in the transcript, and it is savedNo

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.

TypeScript
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.

TypeScript
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.

MemberResolves 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.

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

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

FieldTypeDoes
promptstringRequired. What to draw
model{ providerId, modelId }An image model. Default: the generate_image tool's
nnumberHow many images, 1 to 4. Default 1
aspectRatiostring1: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
saveboolean | { 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
signalAbortSignalCancels the request
TypeScript
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.

MemberDoes
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
TypeScript
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.

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

FieldDoes
kindRequired. <extension id>.<name> by convention
labelRequired. 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
TypeScript
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#

Appctx.sessions and ctx.generatectx.workspace
Terminal appThrough its chat host. In daemon mode, in the daemonThe session in view, or the one a command runs for
Desktop appIn the main process, or the daemon in daemon modeThe session the command runs for
Web appIn the serverThe 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#

  • workspace and artifacts.record need a session. From a hook or a timer with no session, check available() or pass the session through.
  • send resolves false, it does not throw, when the session will not take the prompt now.
  • submit runs / commands and the prompt interceptor, your own included. Guard against a loop.
  • synthetic messages stay in the conversation and cost tokens on every later turn. Keep them short.
  • generate.text, generate.object and generate.image reject on a host without model access. Catch the error, or check decide.available first.
  • generate.image holds every image in memory as bytes. Ask for a few at a time, and pass save rather than writing large files yourself.
  • node:child_process in an extension runs on the host, not in the session's sandbox. Use ctx.workspace.exec.
  • Orchestration: the same verbs as tools for a session.
  • Hooks: run:settled and session:event.
  • Storage: data per session with kv('session', id).
  • Sidebar: spaces, filters and facets over the same session queries.