Plugin Bridge Capabilities (part 2)
The second half of the plugin bridge: reading the user’s past session history, brokering files and commands in a granted project, picking single binary documents, structured model output, speech-to-text, sharing, spend totals and inbox cards.
What it is
This is part 2 of the Plugin Bridge Capabilities 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: 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
requestAccessto 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.
getMessagesreturns 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 handed over. An AI message'scontentis its answer alone: no activity lines, so no command the AI ran. - 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.
// 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.
requestAccesspops 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,.netrcand 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
globre-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 statusforms ever do). The list survives revoking the grant, so you can still see what ran under it. Same data headless viaGET /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 statusforms 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,GITandgit.exeall match agitblock, butnpxis notnpm, 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
systempermission (its own code starts programs outside this path entirely), andlaunchBuild. The panel says so, and warns explicitly on asystemplugin, 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 concurrentglobfrom 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.listProjectsdeliberately 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".
// 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 (reads, grants, worktrees) and 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:
nullmeans 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.
// 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.
runmay skip the dialog for exactlygit 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
timeoutMsyou 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
jobIdback andstarted: 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.
PATHpoints the other way.runstrips the project's ownnode_modules/.binso a repo cannot plant a shadowgit;execprepends it, because running the project's ownvitestis 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.
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
statafter 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:appendfails withnot-the-picked-document, and fetching the URL returns 404. Neither will quietly follow a file the user did not choose.documents.statis 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 isstat→append, andstatis what you should be calling anyway to getexpectedLength. If the path now leads somewhere protected, or to something that is not a regular file,statrefuses 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 aretoo-many-handles,file-too-large,too-many-requests,protected-path,not-a-fileandcould-not-read. doc.urlcarries 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:
// 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:
rememberis 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 andgrants()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. Callgrants()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. reopengives you an ordinary Handle. Same shape, same id rules, samestat/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 nextgrants().
- 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.
reopenis paced by the same rate limitopenis, so a tight loop hitstoo-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 answersThis 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.
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.
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.
// 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.
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:
- 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.
- 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.
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).
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
dedupKeyis namespaced. Whatever key you pass is stored asplugin:<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 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 CLI Discovery covers how a session finds and drives an installed plugin.
Last verified 2026-10-02