---
title: Plugin Bridge Capabilities (what a plugin can call)
---

# Plugin Bridge Capabilities

## What it is

Every Omniscio plugin talks to the host through a single sandboxed bridge exposed to its
webview as `window.AgentMC` (the same object a v2 worker backend receives as `ctx`).
A plugin never touches the app's internals directly — it calls **namespaced methods**
(`AgentMC.<namespace>.<method>(...)`), and the host runs each one behind a Zod-validated,
permission-gated handler. The full surface (storage, db, sessions, ui, toast, clipboard,
shell, http, auth, …) is large; this page documents the **higher-power capabilities most
useful for building a rich plugin** — the ones a deck/report/AI plugin reaches for. The
complete, authoritative list of methods and their argument shapes is
[src/main/ipc/bridge-method-schemas.ts](/src/main/ipc/bridge-method-schemas.ts); the
permission each namespace requires is [src/shared/plugin-permissions.ts](/src/shared/plugin-permissions.ts).

**Permissions.** A namespace that touches elevated capability is gated: the plugin must
declare the permission in its `manifest.json` `permissions` array, and the user consents at
install (a third-party plugin shows a permission-consent dialog; a
first-party built-in is exempt — its runtime pickers/logs are the control surface). A call to
a namespace whose permission is undeclared is rejected by the host. The
[marketplace consent flow](plugin-marketplace.md) shows the user exactly which permissions a
third-party plugin requests before install. Benign namespaces
(`storage`, `toast`, `export`, `host`, …) are ungated.

## Where to find it

There is no Omniscio menu that opens this surface — it is reached from a plugin’s own code.
The host hands every plugin one sandboxed bridge object (the same one a v2 worker backend
receives as `ctx`), and every capability below is a namespaced method on it.

The one part of this a user meets directly is the permission surface: a plugin has to declare
each elevated namespace in its own manifest, the user consents at install, and the resulting
grants are managed afterwards in **Settings → Plugins** — the same panel that lists the
folders a plugin may reach, the past session history it may read, and the files it may reopen
without being asked again.

## How it behaves

One subsection per capability: what it lets a plugin do, the permission it takes, what the host
refuses, and the bounds it enforces. The host-primitive and session capabilities are on this
page; the data, file and output capabilities are on
[Plugin Bridge Capabilities (part 2)](plugin-bridge-capabilities-part-2.md).

---

### How much a plugin may spend — the $5-a-day cap

The two *metered* capabilities — `ai.*` (title, message and structured generation) and `tts.*`
(speak text aloud) — draw on **one shared budget: $5 of spend per plugin per calendar day**, and
they draw it together rather than separately. A plugin that has spent three dollars on generated
titles has two dollars left for speech, and vice versa. Past the cap every further `ai.*` and `tts.*`
call is **refused** with a message naming the cap and telling the caller to try again tomorrow; the
budget resets at local midnight.

Three details a plugin author should know before building against it:

- **It is not plugin-controllable and not user-configurable.** No setting exposes it, and a plugin
  cannot raise, disable or read it. It is a host-enforced backstop, not a quota the plugin manages.
- **It is per plugin, across the whole install** — not per user and not per account. Every call the
  plugin makes anywhere counts against the same five dollars.
- **The user's own daily ceiling applies on top.** This cap bounds what a *plugin* can spend; it
  does not raise what the *account* may spend, so a plugin can still be stopped by the account's own
  daily limit first.

`stt.*` (transcription) is **not** on this budget — it bills to its own provider label, so a
per-plugin dollar cap on it would read zero forever; it rides the user's existing daily voice cap
and the Pro entitlement check instead. See [Reading Queue](reading-queue.md) for the read-aloud
consumer of the `tts` half.

---

### `host.getCapabilities` — ask what this Omniscio can actually do (no permission)

Returns the capability tokens this host currently offers plugins. It exists so a plugin can
**refuse to spend the user's money** on something this host cannot deliver.

```js
const capabilities = await AgentMC.host.getCapabilities()
if (!capabilities.includes('ai-native-cli')) {
  // Don't open the paid AI door — it would spawn a session that can do nothing.
}
```

**The failure it prevents.** A plugin's "consult the AI" door spawns a **real, paid**
session whose only job is to drive that plugin's own registered CLI endpoints through
`ALL /plugins/:id/cli/:path*`. Before the guard existed, a plugin had no way to check the
door would actually work — if the host did not offer AI-native CLI dispatch, the session
would spawn, 404/409 on every call, edit nothing, and the user would have paid for an empty
result. Today the route is always-on (shipped 2026-08-06), but the guard still earns its
keep: a host may legitimately omit the token in a given deployment, and the plugin should
refuse the paid door rather than discover the gap by burning money.

**Do NOT try to infer a capability by probing.** A plugin *can* guess an offering from the
route's 404-vs-401 answer, but only from a worker via a raw loopback fetch that **evades the
deliberate anti-SSRF guard** on `ctx.http`, and only while the route happens to answer *before*
auth. A one-line reorder would silently invert the inference, and every user of that plugin
would quietly start paying for nothing. Ask the host instead.

Properties worth knowing:

- **Read-only and free** — no side effects, no model call, nothing billed.
- **No permission required**, because it reveals nothing a plugin could not learn by simply
  trying the capability.
- **Tokens only, never settings.** It returns values from a curated allow-list, so it can never
  become a general-purpose window into the user's configuration. Adding a raw setting here is
  the trap; add a token instead.
- **Absence means "not offered", not "error".** An empty array is the correct answer when
  nothing is on — do not treat it as a failure.
- **Today there is exactly one token**, `ai-native-cli`. Treat the list as open-ended: check for
  the token you need, never assume the whole set.
- **Webview surface only** — a plugin's background worker does not currently get this namespace.

Locked by invariant **`every-bridge-case-classified-or-denied`** in
[plugin-bridge-hardening-contract.md](../../.claude/memory/contracts/plugin-bridge-hardening-contract.md),
plus [host-handler.test.ts](/tests/unit/ipc/plugin-bridge/host-handler.test.ts) and the
dispatcher-level cases in [plugin-bridge-handler.test.ts](/tests/unit/plugin-bridge-handler.test.ts).

---

### `backend.invoke` / `ctx.rpc.handle` — call your own backend and await a result (no permission)

Lets a plugin's **webview UI call a method on its own v2 worker backend and await the return
value**. It is the request/response counterpart to the fire-and-forget `events` bus: use
`events.emit`/`events.on` to *notify* (progress ticks, "a file changed"), and `backend.invoke`
when the UI needs an **answer back** (e.g. "scan this file → give me the candidates", "regenerate
→ tell me it worked"). This is what replaces hand-rolling a correlation-id dance over events.

```js
// ── backend (v2 worker) — register the methods your UI may call, in activate() ──
export function activate(ctx) {
  ctx.rpc.handle('scanFile', async ({ path }) => {
    const candidates = await doScan(path)   // your real work, on the sandboxed worker
    return { candidates }                    // the return value crosses back to the UI
  })
}

// ── webview UI — call it and await the result ──
const { candidates } = await AgentMC.backend.invoke('scanFile', { path: 'x.ahk' })
```

Properties worth knowing:

- **No permission — self-gated to your OWN backend.** A webview can only ever reach the backend
  of the same plugin (enforced by the host's un-forgeable webContents→pluginId binding), so it
  grants nothing the plugin does not already hold — like `host`/`decks`, it needs no manifest
  permission.
- **v2 worker backend required.** The plugin must declare `sdkVersion` + `backend.entryPoint`. A
  UI-only plugin (or one whose worker isn't running yet) gets a clean error, never a hang.
- **Register in `activate()`.** Only methods registered via `ctx.rpc.handle` are callable; invoking
  an unregistered method returns a clean "no handler" error. Handlers stay on the worker — the
  function never crosses the process boundary.
- **Never hangs.** A throwing handler, a non-serializable return value, a missing backend, and a
  wedged handler (generous timeout) all resolve to a clean error; a per-plugin in-flight cap bounds
  a runaway UI; in-flight calls are drained if the worker stops.
- **Long work is fine.** The timeout is generous (a UI invoke can drive a real, multi-second
  operation) — stream progress over `events` and let the call resolve when the work is done.

Locked by invariant **`backend-namespace-is-self-gated`** in
[plugin-bridge-hardening-contract.md](../../.claude/memory/contracts/plugin-bridge-hardening-contract.md)
and **`backend-invoke-is-plugin-scoped`** in [plugin-worker-backend-contract.md](../../.claude/memory/contracts/plugin-worker-backend-contract.md),
plus the `backend namespace` dispatcher cases in
[plugin-bridge-handler.test.ts](/tests/unit/plugin-bridge-handler.test.ts),
[plugin-worker-host.test.ts](/tests/unit/services/plugin-worker-host.test.ts), and
[plugin-worker-entry-helpers.test.ts](/tests/unit/services/plugin-worker-entry-helpers.test.ts).

---

### `ctx.launch.open` — open a link/file, or run a program the user confirms (`launch`, Tier-1 elevated)

Lets a worker plugin ask the host to open a url, open a document, or — with a native confirm
the plugin cannot bypass — run a program. The plugin never gets raw `spawn`: it REQUESTS a
launch and AMC (plus, for anything that runs code, the user) authorizes it. This is the
mediated substitute for raw `process` in ~90% of cases.

```js
// open a link — http/https only, no confirm (reversible)
await ctx.launch.open({ kind: 'url', value: 'https://example.com' })
// open a document/folder with the OS default app — executables are refused (use kind:'exe')
await ctx.launch.open({ kind: 'path', value: 'C:\\Users\\me\\report.pdf' })
// run a program — ALWAYS pops a native "Run this?" the plugin can't skip or fake
const r = await ctx.launch.open({ kind: 'exe', value: 'C:\\tools\\app.exe' })
if (!r.ok) { /* user declined, headless context, or a bad target */ }
```

Properties worth knowing:

- **Tier-1 elevated permission.** `launch` shows a stronger consent notice at install; without
  it every `ctx.launch.*` call is denied (fail-closed).
- **exe/cmd ALWAYS confirm, and the plugin cannot bypass it.** There is no `confirmed`
  parameter — the host decides from `kind`, draws a native OS dialog, and runs only on a real
  Run click. A headless / no-window context (e.g. an AI-driven plugin CLI endpoint) fails
  closed and refuses.
- **`url` is http(s)-only; `path` refuses executables** (Windows binaries/scripts, interpreted
  scripts like `.py`/`.sh`, and trailing-dot/space forms), so "open a file" can never silently
  run a program.
- **The launched program is credential-stripped** — it never inherits AMC's Claude OAuth token
  / `AMC_CLI_TOKEN`, and every launch is spawn-audited.
- **Worker (v2) surface only** for now — a plugin's webview does not yet get `ctx.launch`.

Locked by [plugin-launch-primitive-contract.md](../../.claude/memory/contracts/plugin-launch-primitive-contract.md)
(`launch-open-gated-by-launch-permission` … `worker-never-spawns-launch-is-host-mediated`) and [plugin-launch-service.test.ts](/tests/unit/services/plugin-launch-service.test.ts).

---

### `ctx.core.*` — read the user's sessions, projects, and message text (`coreRead`, Tier-1 elevated)

Gives a worker plugin a **blanket, read-only** view of the user's own work: session and
project metadata, and the plain text of messages. Where `sessionHistory` (below) hands the
plugin a *scoped* slice the user picks project-by-project, `ctx.core` is the "I need to see
everything" capability — so it is a **stronger, elevated** permission and the plugin should
reach for it only when a per-session grant genuinely won't do.

```js
// sessions — every non-hidden session, optionally scoped to one project
const all = await ctx.core.sessions.list()
const forProject = await ctx.core.sessions.list({ projectId })
const one = await ctx.core.sessions.get(sessionId) // null if unknown / hidden / deleted

// projects
const projects = await ctx.core.projects.list()
const proj = await ctx.core.projects.get(projectId)

// a session's messages — text-only, capped (default 200; a window can only shrink it)
const messages = await ctx.core.messages.list(sessionId) // { id, role, content, timestamp }[]
const recent = await ctx.core.messages.list(sessionId, 50)
```

- **Tier-1 elevated permission (`coreRead`).** The consent copy is blunt — "read your
  sessions, projects, and the text of their messages (never their secrets)". Without the
  permission every `ctx.core.*` call is denied (fail-closed).
- **Blanket, not scoped — prefer `sessionHistory` when you can.** There is no per-session
  picker; the install-time consent IS the authorization. If your plugin can enumerate the
  handful of sessions it needs, `sessionHistory` (a lower-privilege, user-scoped grant) is
  the better fit; use `coreRead` when you genuinely need the whole corpus.
- **Text-only.** `messages.list` returns a `{ id, role, content, timestamp }[]` projection
  with `content` reduced to plain text and `system` rows dropped — raw tool-use / tool-result
  blocks (which can carry shell output, file contents, secrets) are **never** included.
- **Hidden + deleted are invisible.** Silently-hidden recipe lanes are filtered from every
  list and refused for messages; soft-deleted rows never appear.
- **Windowed.** `messages.list` caps at 200 messages per call so a single read can't pull an
  unbounded transcript; a caller-supplied window only ever shrinks that.
- **Audited + read-only.** Every read is appended to a host-owned audit table the plugin
  can't touch, and there is no write or raw-table path.
- **Worker (v2) surface only** for now — a plugin's webview does not yet get `ctx.core`.

Locked by [plugin-core-read-contract.md](../../.claude/memory/contracts/plugin-core-read-contract.md)
(C1–C7) and [plugin-core-read-service.test.ts](/tests/unit/services/plugin-core-read-service.test.ts).

### `ctx.oauth.authorize` — sign the user in to a third-party service (`oauth`, Tier-1 elevated)

Runs a standard OAuth 2.0 **authorization-code** flow so the user can connect a third-party
service (Notion, Slack, Spotify, anything) to your plugin, and your plugin gets the resulting
tokens. It is **bring-your-own-credentials**: you supply your OWN OAuth app; AMC never uses its
own Google or marketplace secrets. AMC owns only the parts that must not be plugin-controlled —
the loopback redirect, PKCE, and the CSRF `state`.

```js
// you supply your own OAuth app; AMC owns the loopback redirect + PKCE + state
const res = await ctx.oauth.authorize({
  authUrl: 'https://accounts.example.com/o/oauth2/auth', // provider authorize endpoint (https)
  tokenUrl: 'https://oauth2.example.com/token',          // provider token endpoint (https)
  clientId: 'your-oauth-client-id',
  clientSecret: 'your-oauth-client-secret',              // optional (confidential clients only)
  scopes: ['read', 'write'],                             // string[] or a space-delimited string
  extraAuthParams: { access_type: 'offline', prompt: 'consent' } // optional provider params
})
if (res.ok) {
  // { accessToken, refreshToken?, tokenType?, scope?, expiresIn?, expiresAt?, idToken? }
  await ctx.secrets.set('provider-tokens', res.tokens)   // persist in YOUR own encrypted storage
} else {
  ctx.log.warn(`sign-in failed: ${res.error}`)           // friendly message; never a raw provider error
}
```

- **Tier-1 elevated permission (`oauth`).** The consent copy is "sign you in to third-party
  services you choose to connect". Without the permission the call is denied (fail-closed).
- **You bring the OAuth app.** AMC opens the user's browser to YOUR `authUrl`, catches the
  callback on an AMC-owned loopback (`http://localhost:<random>/callback`), and exchanges the
  code at YOUR `tokenUrl`. The redirect URI is AMC-owned and ephemeral — you cannot supply or
  override it, and `extraAuthParams` can never override `client_id` / `redirect_uri` /
  `response_type` / `code_challenge` / `code_challenge_method` / `state`.
- **PKCE + state are mandatory** on every flow (no opt-out), and the loopback constant-time-checks
  the returned `state` for CSRF.
- **Any https provider, no internal addresses.** Both `authUrl` and `tokenUrl` must be https; a
  token endpoint that resolves to localhost / a private / link-local address is refused (SSRF).
- **Tokens are yours to keep.** They are returned to your worker only (never the renderer) and
  AMC keeps no copy — persist them in your own `ctx.secrets` (OS-keychain-backed). Refreshing
  later is your job: you hold the `refreshToken`, `tokenUrl`, and `clientId`.
- **One sign-in at a time per plugin**, and an abandoned browser flow times out after 5 minutes
  with a friendly error.
- **Worker (v2) surface only** for now — a plugin's webview does not yet get `ctx.oauth`.

Locked by [plugin-oauth-primitive-contract.md](../../.claude/memory/contracts/plugin-oauth-primitive-contract.md)
(`authorize-gated-by-oauth-permission` … `no-host-side-token-cache`) and [plugin-oauth-service.test.ts](/tests/unit/services/plugin-oauth-service.test.ts).

### `ctx.channel.connect` — keep a live connection to a service (`channel`, Tier-1 elevated)

Lets a worker plugin maintain a **long-lived outbound WebSocket** to a third-party service it
chooses (e.g. Slack's socket mode). AMC owns the whole socket lifecycle — reconnect, health
checks, backpressure — and your plugin just gets message callbacks plus a `send()` handle. The
plugin never touches a raw socket. This is what lets a real-time integration (a chat bridge, a
live event feed) ship as a plugin.

```js
// AMC owns the socket; you get events + a { send, close } handle
const { send, close } = await ctx.channel.connect(
  {
    kind: 'ws', // outbound WebSocket (the only kind for now)
    url: 'wss://gateway.example.com/socket', // wss:// only; internal addresses refused
    headers: { Authorization: 'Bearer your-token' }, // optional; used for the handshake, stays Main-side
    protocols: ['json'] // optional Sec-WebSocket-Protocol values
  },
  (event) => {
    switch (event.type) {
      case 'open': send(JSON.stringify({ type: 'hello' })); break // connected — start your protocol
      case 'message': handleFrame(event.data); break // a text frame arrived
      case 'close': ctx.log.info(`closed ${event.code}`); break // AMC will reconnect on its own
      case 'error': ctx.log.warn(event.message); break // friendly message; AMC reconnects
    }
  }
)
await send('a text frame') // resolves { ok } / { ok:false, error } — send AFTER the open event
await close() // permanent teardown (no reconnect)
```

- **Tier-1 elevated permission (`channel`).** The consent copy is "maintain a live connection to
  an external service you choose". Without it every `ctx.channel.*` call is denied (fail-closed).
- **AMC owns the socket — you never get a raw one.** `connect` resolves as soon as AMC accepts
  and starts the connection (not when it opens), and hands you only a `{ send, close }` handle.
  AMC reconnects with capped backoff, runs a ping/pong heartbeat that heals a silently-dead
  socket, and a generation guard keeps a superseded socket from interfering.
- **Outbound `wss` only; internal addresses refused.** The scheme must be `wss:`; a url that is
  localhost / a private / loopback / link-local address is refused synchronously, and a hostname
  is DNS-pinned at connect (a name that resolves to an internal IP is refused too — no SSRF).
  Inbound `kind: 'webhook'` is not supported yet.
- **Everything is bounded.** A per-plugin channel cap (and a global cap), a connect-rate limit,
  an outbound send-rate limit + per-message size cap, and a reserved-header denylist. `send`
  returns `{ ok:false }` (never a throw) when the socket is not open or a bound is hit.
- **The event stream is one callback.** `onMessage` receives `{ type: 'open' }`,
  `{ type: 'message', data }` (text frames; binary is dropped for now), `{ type: 'close', code,
  reason }`, and `{ type: 'error', message }`. Send after you see `open`.
- **Your credentials stay Main-side.** Any `headers` you pass are used only for the Main-side
  handshake, never exposed to the renderer, and never logged.
- **A channel never outlives your plugin.** Disabling the plugin (or a worker crash) closes every
  channel it holds; `close()` is permanent and idempotent.
- **Worker (v2) surface only** for now — a plugin's webview does not yet get `ctx.channel`.

Locked by [plugin-channel-primitive-contract.md](../../.claude/memory/contracts/plugin-channel-primitive-contract.md)
(C1–C10) and [plugin-channel-service.test.ts](/tests/unit/services/plugin-channel-service.test.ts).

### `ctx.recording.*` — start and stop screen recordings the user confirms (`recording`, Tier-1 elevated)

Lets a worker plugin **trigger a screen recording** through AMC's own screen recorder. Your plugin
can start a recording (the user confirms each one), stop it, and read the status of the recordings
it started — but it never touches the raw capture, the screen frames, or the media files. The
recording lands in the user's normal recorder library, owned by them. This is what lets a "record
my demo / session" plugin work without ever handing a plugin the user's screen.

```js
// Every start pops a native "Record your screen?" confirm the plugin cannot bypass
const started = await ctx.recording.start()
if (!started.ok) return ctx.log.warn(started.error) // declined / recorder off / already busy
const { recordingId } = started

// ...later, stop the recording YOU started (only while it is the active one)
await ctx.recording.stop(recordingId) // { ok } / { ok:false, error }

// poll status / enumerate — only the recordings YOUR plugin started, redacted
const rec = await ctx.recording.get(recordingId) // { id, status, durationMs, ... } | null
const mine = await ctx.recording.list() // RecordingSummary[]
```

- **Tier-1 elevated permission (`recording`).** The consent copy is "start and stop screen
  recordings, with your confirmation each time". Without it every `ctx.recording.*` call is denied
  (fail-closed).
- **AMC owns the capture — you never get frames or files.** `start()` runs AMC's existing screen
  recorder; you receive only a `recordingId` and redacted status summaries, never a capture buffer,
  a file path, the share token, or the transcript.
- **Every start needs a fresh user confirm.** A spoof-proof native dialog the plugin cannot script
  or auto-dismiss; a headless context (e.g. a plugin CLI endpoint) starts nothing. The plugin does
  not choose the source — AMC records the user's default screen.
- **You can only touch your own recordings.** `stop` / `list` / `get` are scoped to the recordings
  your plugin started; you can never stop or read the user's own or another plugin's recording, and
  `stop` works only while your recording is the active one.
- **Single recorder, bounded.** `start()` refuses when screen recording is turned off or a
  recording is already in progress; one confirm shows at a time, and a per-plugin rate limit caps
  repeated starts. Every failure is a friendly `{ ok:false, error }`, never a throw.
- **`RecordingSummary` is redacted** — `{ id, status, durationMs, sourceType, sourceLabel,
  startedAt, endedAt }` only.
- **Ownership does not survive a restart.** After AMC restarts, a plugin can't manage a recording
  it started earlier (it can always start a new one); the recording itself stays in the user's
  recorder library.
- **Worker (v2) surface only** for now — a plugin's webview does not yet get `ctx.recording`.

Locked by [plugin-recording-primitive-contract.md](../../.claude/memory/contracts/plugin-recording-primitive-contract.md)
(R1–R10) and [plugin-recording-service.test.ts](/tests/unit/services/plugin-recording-service.test.ts).

### `export.savePdf` — render your HTML to a PDF file (no permission)

Turns a self-contained HTML string into a PDF and writes it through the OS **Save dialog**
(the dialog itself is the consent, so — like `export.saveFile` — this needs **no declared
permission**). Under the hood it drives the same hardened, sandboxed `htmlToPdf` the app
uses (offscreen window, JavaScript disabled, single-flight, timeout-bounded), so a plugin
can produce polished PDFs without shipping its own PDF engine.

```js
await AgentMC.export.savePdf({
  filename: 'deck.pdf', // required, ≤255 chars
  html: '<!doctype html>…', // required, self-contained HTML, ≤20 MB
  preferCSSPageSize: true, // optional — honor the document's @page size
  landscape: true // optional
})
```

Only those two print booleans are surfaced (no header/footer templates from untrusted
plugin input). The plugin supplies the HTML; render it client-side from your own components.

---

### `ctx.sessions.*` — start and drive a Claude session (permission: `sessions`)

Lets a plugin **spawn a Claude Code session and talk to it** — the "go do this work and tell me
when it's done" capability (e.g. Test Tracker running a suite and reading back the failures).
Every session it starts spends the user's own Claude quota or API credit, so this is the one
namespace where a loop bug costs real money.

One optional argument decides the blast radius:

- **No `scope` → the plugin's own sandbox.** The session runs in the plugin's private virtual
  project (`__plugin_<id>__`), which is not a directory in any repository of the user's. This is
  the original behaviour and rides on the `sessions` permission alone.
- **With a `scope` → a real repository the user granted.** `{ projectId, worktree }` puts the
  session in an actual working copy with full tool access — it writes files and runs commands
  there. **Three** things must hold at once, and any one missing refuses the spawn:
  1. the `sessions` permission, declared in `manifest.json`;
  2. the **`workspace.exec`** tier in that same manifest — a session that writes and runs cannot
     be authorized by a grant whose consent copy told the user "it cannot change or delete
     anything, and it cannot run anything";
  3. a live per-project **workspace grant** for that exact project — the same grant `ctx.workspace`
     uses, revocable from **Settings → Plugins → "Folders this plugin can reach"**.

- **The grant is re-checked on every later call, not just at spawn.** `sendMessage`,
  `getMessages`, `getStatus` and `stop` each re-read it, so revoking a project stops a plugin
  driving a session that is already running.
- **`worktree: null` means the project's main checkout;** a named worktree means that working
  copy. The host resolves the directory from the grant itself and never re-derives it from what
  the caller asked for, so a path outside the granted set cannot be reached.
- **Ownership is enforced per call.** A plugin reaches only the sessions it started; another
  plugin's session id — or one of the user's own — is refused.

```js
const { sessionId } = await ctx.sessions.create({
  prompt: 'run the test suite and summarise the failures',
  scope: { projectId, worktree: null } // omit `scope` entirely for the plugin's own sandbox
})

await ctx.sessions.sendMessage(sessionId, 'now fix the first failure')
const status = await ctx.sessions.getStatus(sessionId)
await ctx.sessions.stop(sessionId)
```

**Polling a transcript by cursor — worker backend only.** On the worker leg,
`ctx.sessions.getMessages(id, { after, limit })` lets a plugin ask "what's new since I last
looked" instead of re-reading the whole conversation:

```js
const first = await ctx.sessions.getMessages(sessionId) // newest page; no cursor needed
let cursor = first.at(-1)?.cursor
const next = await ctx.sessions.getMessages(sessionId, { after: cursor, limit: 200 })
```

- **A message is `{ id, role, text, timestamp, cursor }`** — the body field is `text`, not
  `content`. Each message carries the opaque cursor pointing **at** it, so a poller resumes by
  handing back the last one it saw.
- **The cursor survives an app restart** — both halves of the position are persisted database
  values, unlike an in-memory high-water mark.
- **A cursor-less call returns the NEWEST page**, not the start of the transcript, so a plugin's
  first tick sees the present rather than ancient history.
- **Partial (streaming) rows are included here, and only here.** With no push primitive on the
  worker leg, a partial row is how a poller sees a turn *in progress* rather than a silent gap
  until it completes. The webview reads filter them; the divergence is deliberate.

**The webview leg differs.** `AgentMC.sessions.create` takes the same `scope` under the same
three gates, but `AgentMC.sessions.getMessages(id)` is the older full-transcript read — no
cursor, partials filtered, and its body field is `content`. It also exposes `rename`, `getCost`,
`launchBuild` and `launchWithDraft`, which the worker leg does not; the worker leg exposes
`onStatusChange` / `offStatusChange`, which the webview does not.

**`fanout` is a separate permission.** Batch-spawning across arbitrary projects is gated on
`sessions.launchAny` rather than `sessions`, because it fires paid sessions into projects the
user never picked.

---

### Watching a session live — `host.sessionOutput` and `sessions.observeStatus`

Two separate, deliberately narrow ways to see activity as it happens. They are **not**
interchangeable, and neither one hands over a transcript.

#### `host.sessionOutput` — live output from a session your plugin OWNS (no extra permission)

A plugin **panel** that launched a session (via `ctx.sessions.create`) can watch that session's
output as it streams, instead of polling `getMessages`:

```js
const unsubscribe = AgentMC.events.on('host.sessionOutput', (data) => {
  // data = { sessionId, text, source, messageId?, streaming?, timestamp? }
  appendToMyView(data.text)
})
// later
unsubscribe()
```

`AgentMC.events.on(channel, cb)` returns an **unsubscribe function** — call it when your view
unmounts. (Subscriptions are also released automatically when the panel reloads or closes.)

**Webview panels only, today.** Delivery is scoped to the plugin's panel webContents. A v2 worker
backend does **not** receive this channel — its `ctx.events` namespace exposes `emit` only. A
headless plugin that wants to follow a session should page the durable
`ctx.sessions.getMessages(id, { after })` cursor instead, which is the reliable path either way.

What the host guarantees:

- **Owner-only.** Delivery is scoped to the plugin whose ownership tag is on that session. A
  plugin never receives output from a session it did not launch, and this rides the per-plugin
  dispatch, never the all-plugins broadcast.
- **Revocable.** For a scope-spawned session the project's workspace grant is re-checked before
  each delivery, so revoking that project in Settings stops the stream.
- **Trimmed.** You get the text and its timing. You do **not** get attachments (which carry the
  user's real on-disk filenames), the free-form metadata record, sidechain ids, or the
  comment/tool counts. It can be widened on request; it will not be narrowed back.
- **Batched, not per-fragment.** Output is gathered over a short window and delivered as one
  event with the text concatenated, so a busy session does not become an event storm.
- **Not a correctness channel.** Treat it as a progress hint. If your plugin misses an event it
  must still be correct from the durable `ctx.sessions.getMessages(id, { after })` cursor read.

#### `sessions.observeStatus` — the ambient status feed for an overlay (standard tier)

An overlay plugin (a companion, a status pet, an activity HUD) can react to **every** session
starting, finishing, or needing attention. Declare it in your manifest:

```json
{ "permissions": ["sessions.observeStatus"] }
```

- It carries **status only** — never message text. The consent copy says exactly that.
- Unlike `host.sessionOutput` it is **not** scoped to your own sessions: reacting to the user's
  whole workspace is the point of an ambient overlay. That breadth is why it takes its own
  declared permission rather than riding the broad `sessions` one, which now also gates
  *spawning* agents.
- An overlay whose plugin does not declare it receives nothing (the host logs the refusal once).
- Do not confuse it with `ctx.sessions.onStatusChange`, which is a per-session subscription for
  a session you own and rides the plain `sessions` permission.

## For agents

The rest of the bridge is on
[Plugin Bridge Capabilities (part 2)](plugin-bridge-capabilities-part-2.md) — reading past
session history, the granted-project file broker and its write and exec slices, single-file
`documents.*`, `ai.generateStructured`, `stt.*`, `share.publishArtifact`,
`spend.getBreakdown` and `inbox.postAlert`.

Each capability above names the contract and the tests that lock it. The two authoritative
sources for the whole surface are
[src/main/ipc/bridge-method-schemas.ts](/src/main/ipc/bridge-method-schemas.ts) (every method
and its argument shapes) and
[src/shared/plugin-permissions.ts](/src/shared/plugin-permissions.ts) (the permission each
namespace requires).

## Related

- [Plugin Marketplace](plugin-marketplace.md) — installing plugins, the permission-consent UX, the developer dashboard
- [Plugin CLI Discovery](plugin-cli-discovery.md) — how a session discovers + drives installed plugins
- [AI spend alerts](ai-spend-alerts.md) — the built-in daily spend digest whose numbers `spend.getBreakdown` reconciles with
- Authoritative sources: [bridge method schemas](/src/main/ipc/bridge-method-schemas.ts) · [permission catalog](/src/shared/plugin-permissions.ts) · [SpendReportBreakdown shape](/src/shared/spend-report.ts)

