---
title: Plugin Bridge Capabilities (part 2)
---

# Plugin Bridge Capabilities (part 2)

## What it is

This is part 2 of the [Plugin Bridge Capabilities](plugin-bridge-capabilities.md) page. That
page covers the bridge itself — the permission model, and the host-primitive and session
capabilities a plugin calls. This half carries the rest of the surface: reading the user’s past
work, brokering files and commands inside a project the user granted, picking single binary
documents, and the output capabilities — structured model output, speech-to-text, sharing,
spend totals and inbox cards.

## Where to find it

Reached exactly the same way as the [main page](plugin-bridge-capabilities.md): from a plugin’s
own code, through the bridge object the host hands it, never from an Omniscio screen. The grants
that authorize the file-facing capabilities here — a project a plugin may read, write or run
commands in, and the past session history it may read — are the ones a user manages in
**Settings → Plugins**.

## 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.

### `sessionHistory` — read the user's past Omniscio work (permission: `sessions.readHistory`)

Lets a plugin read the user's **past session/project conversation history** — the "build
something from my existing work" capability (e.g. Decks turning your sessions into a deck).
Because that history can contain anything the user ever typed, it is governed by a strict
**scoped + audited + persisted** privacy model, which the host enforces:

- **Default-deny, user-scoped.** A plugin reads **nothing** until the user grants it. Call
  `requestAccess` to pop a picker; the user chooses which projects/sessions to hand over.
  A project grant transitively covers that project's (non-hidden) sessions.
- **Text-only.** `getMessages` 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** handed over.
- **Audited + revocable.** Every read is appended to a host-owned audit table the plugin
  cannot touch, and grants persist and are revocable from **Settings → Plugins → "Session
  history access"**, which also shows the read log.

```js
// 1) ask the user to grant scope (opens the picker; resolves with what they granted)
await AgentMC.sessionHistory.requestAccess({ kinds: ['session', 'project'] }) // kinds optional

// 2) enumerate what you're allowed to see
const projects = await AgentMC.sessionHistory.listProjects() // granted projects
const sessions = await AgentMC.sessionHistory.listSessions() // granted sessions (silent lanes filtered out)

// 3) read a granted session's transcript (throws if not granted)
const messages = await AgentMC.sessionHistory.getMessages({ sessionId })
```

---

### `ctx.workspace` — read files inside a project the user picked (permission: `workspace.read`)

Lets a plugin read **actual files on disk** inside one project the user explicitly chose — the
"look at the repo you're reporting on" capability. It is the widest read the bridge grants:
where `sessionHistory` hands over a text projection of database rows, this hands over **raw
file bytes**, so the host wraps it in a tighter envelope.

**Worker-backend only.** The `workspace` namespace is refused on the webview path — there is no
`AgentMC.workspace`. A webview is the untrusted half of a plugin; filesystem reads are brokered
only to the worker backend, where the permission gate runs before dispatch.

- **Default-deny, one project at a time.** A plugin reads nothing until the user grants it.
  `requestAccess` pops a picker; the user picks **one** project. The grant covers that project
  and the working copies Omniscio creates for it (its worktrees) — and nothing else.
- **Secrets stay out, even inside a granted project.** A granted root does not make everything
  in it readable: `.env*`, `id_rsa*`, `*.pem`/`key`/`pfx`/`p12`, `.netrc` and friends are refused
  by the same deny-floor the rest of the app uses.
- **The built-in sentinel projects are never in scope.** `__claude__` and its siblings resolve
  to real directories on disk that the user never chose in the picker, so a sentinel check
  refuses them before anything else runs. (Omniscio's own config tree, `~/.claude`, is a
  separate matter entirely — it is not reachable from a project grant at all.)
- **Revocable, and it lands mid-read.** Grants persist and are revocable from **Settings →
  Plugins → "Folders this plugin can reach"**. A long `glob` re-checks the grant as it walks, so
  revoking stops a read already in flight rather than after it finishes.
- **Visible after the fact.** That same panel lists the commands the plugin has actually run in
  those folders — each distinct command, how many times, when it last ran, and whether it ran
  without asking you (only two exact `git status` forms ever do). The list survives revoking the
  grant, so you can still see what ran under it. Same data headless via
  `GET /plugins/:id/workspace-commands`.
- **And you can retire ONE of them, without revoking the folder.** Each command in that list has
  a **Stop allowing** button, which asks whether you mean *this exact command* or *every command
  with that program name*. Blocked commands get their own section with **Allow again**, so a
  block is never a one-way door. Headless: `GET`/`POST /plugins/:id/workspace-command-denies`.
  - **It can only ever take away.** Nothing about a block can make a command run — the per-call
    confirm is still the only thing that approves one — and if the block list cannot be read,
    the command is refused rather than allowed. It is deliberately the opposite of how the
    grant check fails, because "I could not tell" must not mean "carry on" here.
  - **It lands even mid-flight.** The block is checked at the shared gate and AGAIN immediately
    before the command starts, so blocking one while its confirmation dialog is still on screen
    stops it. It also stops a long-running job that is already executing that command, and it
    covers the two `git status` forms that normally run without asking — the one unattended
    path is the one it most needs to reach.
  - **It blocks a spelling, not a capability.** `/usr/bin/git`, `GIT` and `git.exe` all match a
    `git` block, but `npx` is not `npm`, and a shell wrapper is a different program again.
  - **What it does NOT cover**, stated because a half-true safety control is worse than none: a
    plugin that can start an agent session in your repo, one holding the `system` permission
    (its own code starts programs outside this path entirely), and `launchBuild`. The panel says
    so, and warns explicitly on a `system` plugin, where a block cannot hold at all.
  - Blocks apply to every copy of that project on the machine, worktrees included; they never
    expire, survive the plugin being reinstalled, and travel with a portable backup — losing a
    grant fails safe, losing a block would not.
- **Bounded.** The walk is capped on entries, directories and depth, honours `.gitignore`, and
  refuses a second concurrent `glob` from the same plugin.

- **One list of worktrees, and it is live.** `listWorktrees(projectId)` blocks on git rather than
  reading a cache, so a picker is correct the first time it opens instead of showing only "main"
  until a cache fills. `listProjects` deliberately does NOT carry worktrees — two lists of the same
  thing with different freshness is the bug being avoided.
- **Anything listed is readable.** The list and the permission gate run the same selection rule, so
  a worktree the plugin was just offered cannot be refused on the next read.

Arguments are **positional**, and everything after `resolve` is addressed by a **handle**
(`{ projectId, worktree, path }`) rather than a bare path — so a checked location cannot be
swapped for a different one between the check and the read. `worktree` is required and
nullable; `null` means the project's **main checkout, exactly** — never "whichever copy happens to
have that file".

```js
// 1) ask the user to grant a project (opens the picker)
await ctx.workspace.requestAccess()

// 2) see what you're allowed to read
const projects = ctx.workspace.listProjects() // [{ projectId, name }] — granted only, NO worktrees

// 3) list that project's worktrees — blocking + live, main pinned first
const worktrees = await ctx.workspace.listWorktrees(projects[0].projectId)
// [{ path, ref, isMain, label, createdAt, checkouts, session, updatedAt }]
//   ref        — the value to put in a Scope; null for main
//   label      — 'main', or the folder name with any -YYYYMMDD-HHMMSS stamp split off
//   createdAt  — that stamp as an ISO date, or null; render it as a relative age
//   checkouts  — [{ repoPath, branch }]; branch null = detached. 1 for a repo,
//                1..n for an umbrella folder holding several repos
//   session    — { id, name, status } when Omniscio owns the worktree, else null
//   updatedAt  — the owning session's last activity, else the folder's mtime

const scope = { projectId: projects[0].projectId, worktree: worktrees[0].ref }

// 4) glob returns entries (path relative to the root, POSIX separators) — turn them into handles
const entries = await ctx.workspace.glob(scope, ['**/*.test.ts']) // [{ path, size, mtimeMs, isDir }]
const handles = entries.filter((e) => !e.isDir).map((e) => ({ ...scope, path: e.path }))

// 5) read a batch — max 256 handles; one bad handle never fails the rest
for (const r of await ctx.workspace.readFiles(handles.slice(0, 256))) {
  if ('content' in r) use(r.handle.path, r.content)
  else console.warn(r.handle.path, r.error)
}

// or resolve a single path to a handle, then act on it
const handle = await ctx.workspace.resolve('package.json') // handle | null
if (handle && (await ctx.workspace.exists(handle))) {
  const text = await ctx.workspace.readFile(handle)
}
```

A worktree is a hard **scope**, not a filter: every call takes exactly one, and results from
another worktree are unreachable by design. `listWorktrees` is single-flight per plugin — a
second concurrent call is refused rather than queued, and that limit is currently per METHOD, so
two different projects cannot be enumerated at the same time either.

`readFiles` caps the batch on handle count, total bytes and per-file bytes — chunk your own
list rather than passing thousands at once. `glob` accepts an optional third argument
(`{ exclude, includeIgnored, includeNodeModules, includeWorktrees }`); it is a closed
allow-list, so any other key is dropped before the walker sees it.

`workspace.write` and `workspace.exec` ship as the **write + exec slice** (elevated tier):
`workspace.writeFile` / `writeFiles` / `mkdir` / `deleteFile` behind a hardened write gate — each
mutating call carrying a REQUIRED last-known-modification-time token, so a plugin can never
silently clobber a change it did not see — and TWO ways to run a command — `workspace.run`
(one-shot, bounded) and `workspace.exec` (a JOB that can run for hours, polled via `execStatus` /
`execResults` / `execCancel`). Declaring either without `workspace.read` is rejected at
validation. Details below.

Engine detail + invariants: [plugin-workspace-read-contract.md](/.claude/memory/contracts/plugin-workspace-read-contract.md)
(reads, grants, worktrees) and [plugin-workspace-exec-contract.md](/.claude/memory/contracts/plugin-workspace-exec-contract.md)
(both command paths).

#### `ctx.workspace` write + exec — create/change/delete files and run approved commands (permission `workspace.write` / `workspace.exec`, elevated tier)

The same granted-project broker, extended to MUTATE. A plugin holding `workspace.write` can
`writeFile(handle, content, expectedMtimeMs, opts?)`,
`writeFiles([{ handle, content, expectedMtimeMs }], opts?)`, `mkdir(handle)`, and
`deleteFile(handle, expectedMtimeMs, opts?)` inside a granted project and its worktrees — behind
the SAME ordered gate (permission → grant → worktree scope → path shape → secret deny-floor),
hardened for writes so no write ever creates through a symlink or an intermediate directory it
did not first prove. The deny-floor is segment-level: `.env` as a directory,
`.npmrc`/`.netrc`/keys, and `.git/*` anywhere in the chain are refused, and
alternate-data-stream (`:`) paths are rejected. Deleting a file the plugin did NOT itself create
requires a native confirm you can't skip — only files the plugin authored delete silently.

**`expectedMtimeMs` is REQUIRED — you cannot write blind.** It is the file's last-known
modification time, exactly as `stat()` already returns it, and it is the host's answer to two
plugins (or a plugin and the user's editor) clobbering each other:

- **`null`** means *I believe this file does not exist* — a create. If it does exist, the call is
  refused as **stale** and nothing is written.
- **a number** means *I believe it exists with exactly this mtime* — an overwrite. If it has
  changed since you looked, same stale refusal, and the file on disk is untouched.

So the shape is always `stat` → decide → write with the mtime you just read; on a stale refusal,
re-read and retry. There is deliberately no "just overwrite whatever is there" — that is the
lost update this exists to refuse. The stale refusal has its OWN message, distinct from the
generic "not available to this plugin", so you can tell *retry* from *give up*.

`mkdir` takes no token because it already behaves like one: creating a directory that exists is
refused, never a silent success.

**Writes are atomic and preserve the file's permissions.** The bytes go to a scratch file beside
the target, are flushed to disk, and are renamed into place — so a crash or a failure can never
leave a half-written file under the real name, and a `0600` secrets file or a `0755` script keeps
its mode. Two honest limits: the guarantee holds against other plugins and against Omniscio
itself, but the user's own editor holds no lock, and on filesystems with one-second timestamps
(HFS+, exFAT, many network mounts) a change-and-change-back inside one tick can slip past the
check.

`opts` is reserved for a future `encoding` (only the utf-8 spellings are accepted today, and
anything else is refused rather than silently mangled), so binary support can arrive without
breaking your call.

**Every successful write, folder creation and delete is recorded** — one row each, with the path,
the byte count and the time — in a table the plugin cannot reach or erase. Reads are not:
they roll up into a per-day counter, because a row per read is roughly 13,000 rows for a single
index rebuild. Refused and stale attempts are logged host-side but not stored.

`workspace.run({ scope, command, args, timeoutMs })` runs a bounded command in the granted
project with the host's own hardened spawn: `git status --porcelain`/`--short` runs silently,
and EVERYTHING else (any other command, any path arguments) pops a native confirm showing the
exact command + folder. The child runs credential-stripped with a PATH that excludes the
project, output capped at 1 MiB, and is killed on timeout; the plugin worker denies
`child_process` unless it declares `system`, so this host-mediated path is the only way a
non-`system` plugin reaches a subprocess. Worker-backend only, like the reads — the phone
bridge can't reach any `workspace` method.

**Which do I call — `run` or `exec`?** The names do not tell you, so: **`run` if the command
finishes in seconds and you want its output back; `exec` if it runs for minutes or hours and you
want to watch it.** A test suite is `exec`. `git rev-parse` is `run`.

```js
// One-shot: returns when the command exits, 30s default / 120s hard max.
const { code, stdout } = await ctx.workspace.run({ scope, command: 'git', args: ['status', '--porcelain'] })

// A job: returns a handle immediately, then you poll it.
const { started, jobId } = await ctx.workspace.exec({ scope, command: 'npm', args: ['test'] })
const status = await ctx.workspace.execStatus(jobId)                 // state, exit code, pid-free
const page = await ctx.workspace.execResults(jobId, { consoleCursor }) // head+tail console + results
await ctx.workspace.execCancel(jobId)
```

A job differs from `run` in ways worth knowing before you pick it:

- **It always asks.** `run` may skip the dialog for exactly `git status --porcelain`/`--short`; a
  job never does. It is unbounded, so a silent shortcut would hand a plugin an unattended
  hours-long process nobody approved.
- **It has no time limit — it dies from silence, not age.** Nothing is killed for running long;
  a job is killed when BOTH streams go quiet for 15 minutes. A `timeoutMs` you send is dropped.
- **Starting the same job twice gives you the one already running**, with no second dialog and
  no second directory — you get the incumbent's `jobId` back and `started: false`.
- **Output keeps the head and the tail.** A long run's interesting parts are its first lines and
  its last, so the middle is what gets dropped, never the ends.
- **`PATH` points the other way.** `run` strips the project's own `node_modules/.bin` so a repo
  cannot plant a shadow `git`; `exec` prepends it, because running the project's own `vitest` is
  the entire point. Both spawn the exact command the user read in the dialog.
- **Job ids are per-plugin capabilities** — another plugin's job id is indistinguishable from one
  that never existed, and no host filesystem path is ever returned to you.

A job is stopped for you when the app quits, when the plugin is disabled or uninstalled, or when
its worker crashes; a leftover scratch dir is reclaimed at the next launch. While a job runs, the
worktree it is running in will not be retired out from under it.

---

### `documents.*` — read and save ONE binary file the user picked (no permission)

The only way a plugin touches the raw bytes of a file **outside its own data directory** —
built for a PDF viewer that has to save annotations back into the user's own PDF. Like
`export.savePdf`, the native dialog **is** the consent, so it needs no declared permission;
unlike `ctx.workspace`, it is one file rather than a tree, and it can **write**.

A picker mints **Handles**: opaque capabilities, one per file the person chose. A Handle
carries an unguessable id, the basename, the length, a fixed read/read-write bit, and a
loopback URL. **It never carries a path**, and picking something adds no folder grant — so a
plugin cannot name a file, cannot open one it wasn't handed, and cannot widen its own reach
by picking.

```js
const [doc] = await AgentMC.documents.open({ mode: 'readwrite' }) // native dialog
const bytes = await (await fetch(doc.url)).arrayBuffer() // read: a loopback GET
const { length } = await AgentMC.documents.append(doc.id, deltaBase64, {
  expectedLength: doc.size // what you believe is on disk right now
})
await AgentMC.documents.close(doc.id) // also: list(), stat(id)
```

Saving is **append-only compare-and-append** (ADR 0004): you send an increment, not a whole
file. `expectedLength` is the compare — pass the length your last save returned, and feed
each returned `length` into the next call. Four things a plugin author must know:

- **A save may never discard more bytes than it writes.** That is what lets a half-finished
  save be repaired by simply replaying it, and what stops a foreign edit larger than your
  Delta being silently eaten. Refusals carry a stable `[code]` inside the message
  (`shorter-than-expected`, `discards-more-than-it-writes`, `not-the-picked-document`,
  `read-only-handle`, …) so you can tell "someone else changed this file" from "you asked for
  something impossible" — branch on the code, never on the prose, which the host rewords.
- **A Handle authorizes a file, not a path — call `stat` after anything else touches it.**
  Every "Save" in Preview or Word writes a temp file and renames over the original, which the
  operating system counts as a different file. Both paths that move bytes refuse it: `append`
  fails with `not-the-picked-document`, and fetching the URL returns **404**. Neither will
  quietly follow a file the user did not choose.
  **`documents.stat` is the one call that adopts the replacement.** It re-checks that what is
  now at that path is still something the user was allowed to pick, and carries the Handle
  over. So the sequence after an external save is `stat` → `append`, and `stat` is what you
  should be calling anyway to get `expectedLength`. If the path now leads somewhere protected,
  or to something that is not a regular file, `stat` refuses too and the Handle stops working.
  Identity is dev+ino plus, on Windows and macOS, the file's birth time — so a same-path
  replacement that happens to reuse the inode is still recognised as a different file.
- **A read and a save are ordered, never interleaved.** Fetching the URL while a save is in
  flight waits for the save; it cannot return a half-written body.
- **The host refuses reads AND writes into sensitive trees** (`~/.claude`, `.ssh`, `.env`,
  private keys) even when the user picks one in the dialog. A multi-select is all-or-nothing:
  one refused file fails the whole call rather than quietly returning fewer Handles than the
  person chose.
- **Handles are a bounded resource.** At most 32 open per plugin, no single file over 64 MiB,
  and `open()` is rate-limited (a burst of 5) — because each call throws a modal dialog at the
  person. The extra codes are `too-many-handles`, `file-too-large`, `too-many-requests`,
  `protected-path`, `not-a-file` and `could-not-read`.
- **`doc.url` carries a per-Handle secret.** It is `…/@doc/<id>.<token>`, and the token is
  what authorizes the read. Treat the whole URL as the capability: don't log it, don't put it
  somewhere another plugin or a page could read it. It dies when the Handle is closed, when
  the plugin is disabled, or when it is uninstalled.

#### Reopening a file after a restart — `remember`, `grants`, `reopen`, `forget`

A Handle dies with the app. That was the whole problem: a plugin showing a library of
previously-opened documents had every row fall back to a file dialog the moment Omniscio
restarted — for a file the person opened yesterday and knows they opened.

Ask for the pick to be remembered, and you can reopen that one file later with **no dialog**:

```js
// Ask the host to remember what the person picks.
const [doc] = await AgentMC.documents.open({ mode: 'readwrite', remember: true })

// …a restart later: what may this plugin reopen?
const remembered = await AgentMC.documents.grants() // [{ grantId, name, mode, lastOpenedAt }]
const doc2 = await AgentMC.documents.reopen(remembered[0].grantId) // an ordinary Handle
await AgentMC.documents.forget(remembered[0].grantId) // drop one yourself
```

What a plugin author must know:

- **`remember` is a request, not a grant.** The first time your plugin asks, the person is
  asked once — "Let *your plugin* reopen files you pick without asking again?" — and their
  answer, yes *or* no, is remembered so they are never asked twice. If they say no, nothing is
  remembered and `grants()` stays empty; that is a normal state, not an error.
- **The ask never blocks the pick.** `open()` returns its Handles immediately whether or not
  the person has answered yet, so never wait on the dialog. Call `grants()` afterwards to see
  what actually got remembered.
- **Never store grant ids yourself — always ask `grants()`.** Your copy cannot see a revoke,
  so a stored id would have you offering the person a row that is guaranteed to fail. The host
  is always current.
- **`reopen` gives you an ordinary Handle.** Same shape, same id rules, same `stat` / `append`
  / `fetch(url)` behaviour, same 32-handle cap. Nothing about the rest of this section changes.
- **A grant names ONE file, and carries no path.** `grants()` hands you a basename, exactly as
  a Handle does.
- **Handle these three refusals distinctly** — they need different things from the person:
  - `unknown-grant` — revoked, expired, or never yours. Drop the row and show the picker.
  - `permission-denied` — **the operating system is blocking it**, not your plugin and not a
    broken file. On macOS this is a Files-and-Folders privacy prompt the person declined; tell
    them to check System Settings rather than implying the document is damaged. The grant
    survives, so it will work once they allow it.
  - `could-not-read` — the file is genuinely gone or moved. The host deletes that grant itself,
    so it will not be in the next `grants()`.
- **Grants are bounded and they decay.** At most 64 per plugin (the least-recently-opened is
  dropped past that), and a grant unused for **90 days** stops working — so a library is
  "while the person is actually using these", not forever. `reopen` is paced by the same
  rate limit `open` is, so a tight loop hits `too-many-requests`.
- **Everything is revoked when the plugin is disabled or uninstalled** — grants and the
  remember permission both. A disable is a revoke, not a pause: after a re-enable the person
  is asked again and your library starts empty. Do not design around keeping it.
- **The person can see and take back every remembered file** under Settings → Plugins, in
  "Files this plugin can reopen", including turning remembering off entirely. Treat an empty
  `grants()` as a legitimate answer at any time.

> **This capability ships switched off.** `documents.*` is behind an in-development flag, so
> every call answers `This capability is not available.` until the user turns on **Plugin
> document access** in Settings. Handle that refusal as a normal state, not an error — and
> note it is deliberately identical for a method that does not exist, so a switched-off build
> tells you nothing about the surface.

Engine detail + invariants: [documents-bridge-contract.md](/.claude/memory/contracts/documents-bridge-contract.md).

---

### `ai.generateStructured` — fill a schema with the model (permission: `ai`)

Metered, forced-tool generation: the plugin hands the host a JSON schema (the "tool") plus a
prompt, the host forces the model to produce a value matching that shape (on the user's
Anthropic account), and returns the structured object. Ideal for outlines, cards, extraction —
anything where you want typed data back, not prose. A **host-enforced per-plugin daily spend
cap** protects the user's bill, and prompt/system text is capped at 32 KB as a spend guard.

```js
const outline = await AgentMC.ai.generateStructured({
  prompt: 'Draft a 5-slide outline about our Q3 results',
  tool: {
    name: 'make_outline',
    description: 'Return the deck outline', // optional
    inputSchema: {
      // a JSON Schema object
      type: 'object',
      properties: { slides: { type: 'array', items: { type: 'string' } } },
      required: ['slides']
    }
  },
  systemPrompt: 'You are a concise deck writer.', // optional
  premium: true // optional — opt into the higher-tier model
})
// -> outline is the validated tool input, e.g. { slides: [...] }
```

The simpler `ai.generateMessage` / `ai.generateTitle` / `ai.isConfigured` methods remain for
plain-text needs.

---

### `stt.*` — turn a recording into text (permission: `stt`, plus `microphone` to record)

The speech-to-text mirror of read-aloud: the plugin hands the host a recording and gets a
transcript back. The host owns the speech-provider key throughout, so it never reaches plugin
code — the plugin only ever sees audio it recorded and text it got back.

**Recording and transcribing are two separate permissions, on purpose.** `microphone` lets a
plugin open the mic inside its own webview; `stt` lets it send a recording off-device to
whichever cloud speech service the user configured. They leak differently — one opens a live
sensor, the other ships the user's speech to a third party — and a plugin can legitimately want
either alone (record-and-keep-local, or transcribe a file the user already had). Holding `stt`
does **not** let you record, and holding `microphone` does **not** let you transcribe.

```js
// Decide whether to render a mic button at all.
if (await AgentMC.stt.isConfigured()) showMicButton()

// mimeType is optional and advisory — the host sniffs the real container from the
// bytes and the sniff wins, so a wrong or missing label cannot corrupt the upload.
const { text } = await AgentMC.stt.transcribe(audioBase64, {
  mimeType: 'audio/webm;codecs=opus', // optional
  language: 'en' // optional
})
```

Accepted containers: `audio/webm` (incl. `;codecs=opus`), `audio/ogg`, `audio/mp4`, `audio/mpeg`,
`audio/wav`. WebM/Opus is the best-tested path — it is the `MediaRecorder` default.

**Silence is a success, not an error.** A recording the provider heard nothing in resolves with
`{ text: '' }`; only a real failure rejects. Do not treat empty text as a failure, and do not
treat a rejection as empty text — the two are deliberately distinguishable, and collapsing them
is what makes an expired API key show up to the user as "no speech detected".

Failures reject with a machine-readable `err.code`, so branch on that rather than on prose:
`SERVICE_UNAVAILABLE` (no provider configured, or the provider failed), `VALIDATION_FAILED`
(empty audio, or over the size cap), `RATE_LIMITED` (the user's daily voice-spend cap is
reached), and `FORBIDDEN` / `PAYMENT_REQUIRED` (cloud transcription is a Pro perk).
`isConfigured()` is a snapshot and can go stale — the user may remove their key between your
check and your call, so keep the `catch` either way.

Bounds: one complete recording per call (batch only — there is no streaming API), and audio is
capped at **10 MB decoded**, matching the limit the speech providers themselves enforce so the
two can never disagree. Oversize audio is **rejected, never truncated and never uploaded** — a
clipped recording would transcribe into a sentence that stops mid-word, which reads as a
transcription bug rather than a size error. Spend is bounded by the user's existing daily
voice-spend cap and the Pro entitlement check, both of which run before anything paid happens.

---

### `share.publishArtifact` — publish HTML to a shareable link (permission: `firebase`)

Uploads a self-contained HTML artifact through Omniscio's share relay (authed by the signed-in
user's identity) and returns a public token + URL — the same mechanism that backs "Publish"
in the app. Lets a plugin turn a rendered document into a link the user can share.

```js
const { token, url } = await AgentMC.share.publishArtifact({
  html: '<!doctype html>…', // required, ≤20 MB
  fileName: 'deck.html', // optional
  title: 'Q3 Deck', // optional
  fullBleed: true // optional — edge-to-edge layout
})
```

---

### `spend.getBreakdown` — read the user's AI-spend totals (permission: `spend`)

Returns the host's already-computed **"all AI spend" breakdown** — the same honest two-half
story Omniscio's daily-spend inbox card tells — as typed, plain data a plugin can render
however it likes:

1. **Agent coding value** — your coding sessions priced at API rates but covered by your flat
   subscription. This is the VALUE of the work, **not money out of pocket**.
2. **Background-feature spend** — the real, token-metered charges for background AI features
   (title generation, digests, and the like), with the true **out-of-pocket** slice (charges
   billed to a real API key) broken out.

The call takes **no arguments**: the host resolves "now" and your local timezone itself, so a
plugin has no window or timezone it could abuse. It is **read-only** and returns the **global**
totals across all your accounts (there is no plugin-scoped slice). The return is the locked
`SpendReportBreakdown` shape (three windows — yesterday / last 7 days / last 30 days — plus
yesterday's per-engine, per-feature, and notable-charge drill-downs); all money is USD.

```js
const b = await AgentMC.spend.getBreakdown()
// b.windows.yesterday.codingValue   -> coding VALUE (covered by your plan, not a bill)
// b.windows.yesterday.outOfPocket   -> real money billed to an API key
// b.windows.week / b.windows.month  -> rolling 7- and 30-day windows
// b.codingEngines / b.backgroundFeatures / b.notableCharges -> yesterday's drill-downs
```

The shape is a **published contract**: Omniscio only ever ADDS fields, never renames or
repurposes one, so a plugin compiled against an older version keeps reading it. The bundled
**Daily Spend Report** plugin uses exactly this read to build its daily card.

---

### `inbox.postAlert` — drop a persistent markdown card in the inbox (permission: `inbox`)

Posts **one persistent inbox card** whose `body` is **markdown** (it renders through
Omniscio's markdown pipeline, so headings, tables, and lists all work). The card survives
restart and lives in the user's inbox until they archive or snooze it — the right tool for a
digest, a daily report, or a "here's what I found" summary. It reuses the **same `inbox`
permission** as `inbox.setItems` (no separate permission to declare).

```js
await AgentMC.inbox.postAlert({
  title: 'AI Spend, Monday Jul 20', // required, ≤300 chars
  body: '## Yesterday\n\n| Period | Value |\n|:--|--:|\n| Yesterday | $34.2k |', // required markdown, ≤50 KB
  dedupKey: 'daily-report' // optional — coalesce repeats into ONE refreshing row
})
```

Two host-enforced guarantees a plugin cannot override:

- **It is always an agent-sourced text card.** The host forces the source kind and a text
  body, so a plugin can neither impersonate a non-agent source nor post a non-text payload.
- **The `dedupKey` is namespaced.** Whatever key you pass is stored as
  `plugin:<yourPluginId>:<dedupKey>`, so your card can **never** hijack or coalesce an
  Omniscio-internal alert (an app card keyed, say, `memory-commit-pressure-high`) and two
  plugins that happen to pick the same key never collide. Omit the key and every post is a new
  row.

The post is fire-and-forget: it inherits the user's "agent alerts" setting (if they've turned
agent alerts off, nothing is posted) and it never throws back into your plugin.

### How these fit together

A common "generate → render → deliver" plugin flow uses all four: `ai.generateStructured`
to produce typed content (optionally seeded from `sessionHistory`), your own client-side
renderer to build the HTML, then `export.savePdf` (local file) or `share.publishArtifact`
(a link) to deliver it. Declare the permissions you use in `manifest.json` — for the above:
`["ai", "sessions.readHistory", "firebase"]` (`export` needs none).

## For agents

Each capability below names its own contract and its own test files. Two behaviours are worth
holding in mind while driving them: a capability under an in-development flag answers the same
`This capability is not available.` refusal as a method that does not exist, so a switched-off
build tells you nothing about the surface; and the file-facing namespaces are worker-backend
only — there is no `AgentMC.workspace` on the webview path.

## Related

[Plugin Bridge Capabilities](plugin-bridge-capabilities.md) is the first half of this page —
the permission model, and the host-primitive and session capabilities. Every capability here
leans on the same install-time consent, described in the
[marketplace consent flow](plugin-marketplace.md);
[Plugin CLI Discovery](plugin-cli-discovery.md) covers how a session finds and drives an
installed plugin.
