Skip to content

Panel Commands ​

Your plugin's backend should only ever act on a command that arrives through a door an AI agent cannot open: your own panel, or your own code. A row in one of your tables is not such a door. Every agent session on the person's computer can read your tables, and on Omniscio versions without the plugin-table fence it can write them too. A backend that runs whatever pending rows it finds lets any agent act as the person.

@agent-mc/plugin-sdk/commands (SDK 3.2.0 and later) is the safe way to send commands from your panel to your backend, and it is meant to be the easy way too.

What the channel promises ​

  1. Commands arrive only through your panel or your own code. The channel never reads a table row as a command, so a forged, edited or re-armed row does nothing.
  2. Input is checked before it is accepted. Bad input queues nothing.
  3. At most once. An accepted command runs at most once; changes run one at a time, in arrival order; the result (or the failure) is handed to the panel exactly once, and the panel never re-sends on its own.
  4. Honest outcomes. When the panel cannot know whether a command ran, it says so (outcome-unknown) — never "failed", never "nothing ran".
  5. Old Omniscio versions fail safe. Before v0.1.92 the panel gets an "update Omniscio" error and writes nothing. It never falls back to rows.
  6. No leaks, and no storage. Inputs and results are never logged and never written to tables or to storage. The channel never reads or writes ctx.storage at all: an AI agent on the person's computer can read and change a plugin's storage, so the channel keeps nothing there to trust. Commands live only in memory, so a restart drops the ones still waiting.
  7. Bounded. The queue, parallel reads, input size and uncollected results all have limits.
  8. One check, one answer. The ship check reads only your manifest, so validate, preflight and publish agree.

Each promise is locked by a test in the SDK.

Quick start ​

Backend — declare each command once: parse checks the input (throw to refuse it), run does the work. Return the input you validated from parse; the panel's input type comes from it.

typescript
// src/backend/commands.ts
import { defineCommand } from '@agent-mc/plugin-sdk/commands'

export const commands = {
  sendDraft: defineCommand({
    parse(input) {
      const { draftId } = (input ?? {}) as { draftId?: unknown }
      if (typeof draftId !== 'string') throw new Error('draftId is required')
      return { draftId }
    },
    async run({ draftId }) {
      // ... send it ...
      return { sentAt: new Date().toISOString() }
    },
  }),
  listDrafts: defineCommand({
    mode: 'read',
    parse: () => null,
    run: async () => [{ id: 'd1', subject: 'Hello' }],
  }),
}
typescript
// src/backend/index.ts
import type { PluginActivate } from '@agent-mc/plugin-sdk'
import { createCommandChannel } from '@agent-mc/plugin-sdk/commands'
import { commands } from './commands'

const activate: PluginActivate = (ctx) => {
  createCommandChannel(ctx, { name: 'mail', commands })
  return {}
}
export default activate

Panel — one client per channel. With CommandMapOf, inputs and results are typed from the backend's own definitions.

typescript
import { createCommandClient, CommandError, type CommandMapOf } from '@agent-mc/plugin-sdk/commands'
import type { commands } from '../backend/commands'

const mail = createCommandClient<CommandMapOf<typeof commands>>('mail')

try {
  const { sentAt } = await mail.run('sendDraft', { draftId: 'd1' }, {
    onPending: ({ waiting, runningForMs }) => showProgress(waiting, runningForMs),
  })
} catch (err) {
  if (err instanceof CommandError) showError(err.code, err.message)
}

The client reads window.AgentMC.backend when a command runs. A panel without a bundler can copy client.js, protocol.js and errors.js from node_modules/@agent-mc/plugin-sdk/dist/commands/ and load client.js as an ES module: the three files import nothing else.

Errors ​

Every failure is a CommandError whose code says what is known about whether the command ran.

CodeWhat happenedDid it run?
host-too-oldThis Omniscio has no panel-to-backend channel (before v0.1.92)No — nothing was sent or written
worker-not-readyThe backend is not running, or is an older build without this channelNo
refusedTurned away: full queue, input over the limit, or a busy hostNo
invalid-inputThe input is not plain JSON data, or parse rejected it (its message is passed through)No
failedIt ran and threw (the error's message is passed through), or ran but its result could not be sent backYes
outcome-unknownContact was lost after the backend may have accepted it and did not come back within a minute, or the plugin restarted before handing over the resultMaybe — never retry it blindly

How commands run ​

  • Writes run one at a time, in arrival order. mode defaults to 'write'.
  • Reads (mode: 'read') run alongside each other, up to maxParallelReads.
  • Results are handed over once. A finished result nobody collects is dropped after resultTtlMs.
  • Inputs and results are plain JSON data. They cross a JSON boundary inside Omniscio: a Date arrives as a string. Send ids, not documents.
  • Nothing is logged except the fact that a command failed (its type and id, never its data).
Limit (limits)DefaultBeyond it
maxQueued50New commands are refused
maxParallelReads4Reads wait their turn
maxInputBytes64 KiB of JSONThe command is refused
resultTtlMs10 minutesAn uncollected result is dropped

Long commands are fine. The panel checks in about every 15 seconds, so a command can run for as long as it needs. A command has no time limit, because nothing can stop a promise that is already running.

A stuck command holds the queue. While a write never finishes, later writes wait (they are not run and not lost), and each status answer says how long the stuck one has been running: onPending receives { waiting: 'queued', runningForMs }. To clear it, restart the plugin (disable and re-enable it, or restart Omniscio). The waiting commands are then gone, none of them runs later, and their panels are told outcome-unknown.

After a restart ​

Commands live only in your backend's memory. When the plugin restarts — an update, a crash, or you clearing a stuck command — every command that was waiting or running is gone, and none of them runs later.

  • A panel still waiting hears outcome-unknown. While the backend is away the panel keeps asking for up to a minute; the restarted backend no longer knows the command, so the panel cannot say whether it ran. Show that, and let the person check before sending it again.
  • Nothing is kept in ctx.storage, on purpose. An AI agent on the person's computer can list, read and change a plugin's storage through Omniscio's local control server. A command saved there could be read (its input leaks) or planted (it would run at the next start), so the channel saves none.

Commands from your own code ​

channel.submit(type, input) runs a command from your backend code — for example a CLI endpoint you declared with requiresConfirmation, which Omniscio approves with the person first. It goes through the same checks, queue and limits, and resolves with the result or rejects with a CommandError.

typescript
const channel = createCommandChannel(ctx, { name: 'mail', commands })
ctx.cli.handle('/send', async (req) => ({ status: 200, body: await channel.submit('sendDraft', req.body) }))

Moving off a command table ​

  1. Move each command type onto the channel; the panel calls run instead of inserting a row.
  2. Stop reading the old table as instructions — no drain, no cron, no "process pending" step.
  3. Call channel.retireRows('commands') when the plugin starts. It settles every row still marked pending, queued or running (status → 'error', optionally a note in noteColumn) without running anything; a row someone re-arms later is settled again at the next start.
  4. Remove the table, or reduce it to a job list that describes rather than instructs: label, status, error and times — never an instruction, its arguments and a status together.
  5. Set plugin.minAppVersion to "0.1.92" so the store offers the plugin only where the channel exists.

Older Omniscio versions ​

  • The channel needs Omniscio v0.1.92 (ctx.rpc and AgentMC.backend.invoke). On older hosts the client throws host-too-old and writes nothing; the backend logs one warning, keeps running, and submit still works.
  • The plugin-table fence (agentWritable) only exists on newer Omniscio builds. Older builds ignore the key and leave every table writable from outside, so on those the channel is the only thing that protects the person. See Outside writes.

Testing it ​

@agent-mc/plugin-sdk/testing has two pieces for this:

  • createRpcDouble() — a stand-in for Omniscio's channel. Pass rpc to createTestContext({ rpc }) and backend to createCommandClient(name, { backend }). It makes the same JSON round trip as Omniscio and fails with Omniscio's own messages; setStopped(true) simulates a stopped backend.
  • describeForgedRowsAreInert() — the test that matters. It runs each of your commands once through the channel (and fails if your effects probe cannot see it run), then forges, edits and re-arms rows of every command type in every table your manifest declares — every row, page by page — and plants each command in ctx.storage. Then it fires every cron, the start-up hooks, a second round of commands and a restart on the same data, and fails if anything extra happened. It plants storage records only where the channel itself once kept them (__commands:<channel>:); if your plugin keeps command-like records of its own in storage, test those yourself.
typescript
import { describe, it } from 'vitest'
import { describeForgedRowsAreInert } from '@agent-mc/plugin-sdk/testing'
import manifest from '../manifest.json'
import activate from '../src/backend'

const sent: string[] = []

describeForgedRowsAreInert({ describe, it }, {
  manifest,
  activate,
  channel: 'mail',
  commands: [{ type: 'sendDraft', input: { draftId: 'd1' } }],
  context: { fetch: async (url) => (sent.push(String(url)), new Response('{}')) },
  effects: () => sent.length,
})

If your backend polls on a timer, use fake timers and pass a settle that lets the time pass (for example () => vi.advanceTimersByTimeAsync(60_000)); the check cannot advance time by itself.

The ship check ​

amc-plugin validate, preflight and publish fail when a table your manifest declares can hold a runnable command: an instruction column, an argument column and a status column together, such as type + payload + status. Fuzzier shapes only warn. A table that is genuinely safe can carry a written commandTableWaiver. The rule, its warnings and what it cannot see are in the CLI reference.

What this does not protect ​

  • Reads. Agents can still read every table your plugin declares; nothing here hides data.
  • Your own use of ctx.storage. Agents can list, read and change it too. Keep credentials in ctx.secrets, and never act on what storage returns as if it were an instruction.
  • A plugin that keeps acting on rows by its own code, channel or not.
  • Anyone who controls the person's screen or disk. They can act as the person.
  • Anything the ship check cannot see: see its limits in the CLI reference.

Omniscio Plugin SDK