Omniscio documentation
Browse all documentation
  1. Getting Started13
  2. Sessions & Agents115
  3. Inbox & Notifications59
  4. Projects & Tasks95
  5. Automation & Scheduling75
  6. Knowledge & Memory26
  7. AI Features60
  8. Integrations100
  9. Plugins & Marketplace33
  10. Cloud & Teams56
  11. Settings & Customization58
  12. Account & Billing28
  13. Troubleshooting84
  14. CLI & API Reference22
  15. Legal & Policies4
  16. Uncategorised22

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 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. An AI message's content is 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. 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".

// 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:

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

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

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:

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


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:

  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.

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