Plugin Bridge Capabilities (what a plugin can call)
Every capability a plugin can call through Omniscio’s sandboxed bridge: the permission model, and the host-primitive and session methods, with what each one refuses and the bounds it enforces. The data, file and output capabilities continue on part 2.
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; the
permission each namespace requires is 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 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).
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 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.
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,
plus host-handler.test.ts and the
dispatcher-level cases in 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.
// ── 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 viactx.rpc.handleare 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
eventsand let the call resolve when the work is done.
Locked by invariant backend-namespace-is-self-gated in
plugin-bridge-hardening-contract.md
and backend-invoke-is-plugin-scoped in plugin-worker-backend-contract.md,
plus the backend namespace dispatcher cases in
plugin-bridge-handler.test.ts,
plugin-worker-host.test.ts, and
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.
// 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.
launchshows a stronger consent notice at install; without it everyctx.launch.*call is denied (fail-closed). - exe/cmd ALWAYS confirm, and the plugin cannot bypass it. There is no
confirmedparameter — the host decides fromkind, 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. urlis http(s)-only;pathrefuses 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
(launch-open-gated-by-launch-permission … worker-never-spawns-launch-is-host-mediated) and 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.
// 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 everyctx.core.*call is denied (fail-closed). - Blanket, not scoped — prefer
sessionHistorywhen 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; usecoreReadwhen you genuinely need the whole corpus. - Text-only.
messages.listreturns a{ id, role, content, timestamp }[]projection withcontentreduced to plain text andsystemrows dropped — raw tool-use / tool-result blocks (which can carry shell output, file contents, secrets) are never included. An AI message'scontentis its answer alone: no activity lines, so no command the AI ran. - Hidden + deleted are invisible. Silently-hidden recipe lanes are filtered from every list and refused for messages; soft-deleted rows never appear.
- Windowed.
messages.listcaps 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 (C1–C7) and 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.
// 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 YOURtokenUrl. The redirect URI is AMC-owned and ephemeral — you cannot supply or override it, andextraAuthParamscan never overrideclient_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
statefor CSRF. - Any https provider, no internal addresses. Both
authUrlandtokenUrlmust 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 therefreshToken,tokenUrl, andclientId. - 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
(authorize-gated-by-oauth-permission … no-host-side-token-cache) and 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.
// 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 everyctx.channel.*call is denied (fail-closed). - AMC owns the socket — you never get a raw one.
connectresolves 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
wssonly; internal addresses refused. The scheme must bewss:; 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). Inboundkind: '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.
sendreturns{ ok:false }(never a throw) when the socket is not open or a bound is hit. - The event stream is one callback.
onMessagereceives{ type: 'open' },{ type: 'message', data }(text frames; binary is dropped for now),{ type: 'close', code, reason }, and{ type: 'error', message }. Send after you seeopen. - Your credentials stay Main-side. Any
headersyou 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 (C1–C10) and 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.
// 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 everyctx.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 arecordingIdand 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/getare scoped to the recordings your plugin started; you can never stop or read the user's own or another plugin's recording, andstopworks 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. RecordingSummaryis 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 (R1–R10) and 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.
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 thesessionspermission 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:- the
sessionspermission, declared inmanifest.json; - the
workspace.exectier 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"; - a live per-project workspace grant for that exact project — the same grant
ctx.workspaceuses, revocable from Settings → Plugins → "Folders this plugin can reach".
- the
The grant is re-checked on every later call, not just at spawn.
sendMessage,getMessages,getStatusandstopeach re-read it, so revoking a project stops a plugin driving a session that is already running.worktree: nullmeans 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.
A background session is read by your plugin, not a person. A sandbox session the user did not open (
userInitiatedunset) is hidden from the inbox, so Omniscio gives it none of its person-facing instructions or standing reminders and sends it no check-ins: the last assistant message is the agent's own answer (hidden-plugin-session-contract.md). The stored text can still open with the app's activity lines (▸ …) and a[[OMNISCIO_FINAL]]line — strip those before showing it.One size cap for every message.
create's prompt,launchBuild's prompt and eachsendMessageaccept up to 200,000 characters (PLUGIN_SESSION_MESSAGE_MAX); a longer one is refused with a bridge argument error.
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:
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, answer, timestamp, cursor }— the body field istext, notcontent. Each message carries the opaque cursor pointing at it, so a poller resumes by handing back the last one it saw. - Read
answerfor what the AI said.textis the stored transcript: Omniscio's activity lines (▸ Bash: …,← result received,▸ Extended thinking), the final-message marker and any summary card sit in it, and a plugin that parses those (Test Tracker does) keeps them.answeris the AI's own words with all of that removed —''for a row that is only activity. On your own messagesanswerequalstext. - 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, next to the same answer — show
answer, not content, when you display what the AI said. 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:
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:
{ "permissions": ["sessions.observeStatus"] }
- It carries status only — never message text. The consent copy says exactly that.
- Unlike
host.sessionOutputit 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 broadsessionsone, 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 plainsessionspermission.
For agents
The rest of the bridge is on
Plugin Bridge Capabilities (part 2) — 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 (every method and its argument shapes) and src/shared/plugin-permissions.ts (the permission each namespace requires).
Related
- Plugin Marketplace — installing plugins, the permission-consent UX, the developer dashboard
- Plugin CLI Discovery — how a session discovers + drives installed plugins
- AI spend alerts — the built-in daily spend digest whose numbers
spend.getBreakdownreconciles with - Authoritative sources: bridge method schemas · permission catalog · SpendReportBreakdown shape
Last verified 2026-10-02