# 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](https://zidane.si/docs/orchestration.md), 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`):

```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](#orchestrators)) |
| `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 }` |

```ts
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](https://zidane.si/docs/extensions-sidebar.md#the-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](https://zidane.si/docs/extensions-sidebar.md#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`](https://zidane.si/docs/extensions-hooks.md#observe) 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 |

```ts
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](https://zidane.si/docs/extensions-sidebar.md#the-session-query).

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](https://zidane.si/docs/extensions-placements.md), and `text` is the fallback.

```ts
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.

```ts
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](https://zidane.si/docs/decisions.md)) |
| `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.

```ts
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:

```ts
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](https://zidane.si/docs/images.md): 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 |

```ts
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`](https://zidane.si/docs/extensions-hooks.md#observe) 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](https://zidane.si/docs/execution-contexts.md)). 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` |

```ts
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`](https://zidane.si/docs/extensions-hooks.md#shellenv) 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 |

```ts
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](https://zidane.si/docs/extensions-placements.md)).

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

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

## Related

- [Orchestration](https://zidane.si/docs/orchestration.md): the same verbs as tools for a session.
- [Hooks](https://zidane.si/docs/extensions-hooks.md): `run:settled` and `session:event`.
- [Storage](https://zidane.si/docs/extensions-storage.md): data per session with `kv('session', id)`.
- [Sidebar](https://zidane.si/docs/extensions-sidebar.md): spaces, filters and facets over the same session queries.
