Building

Permissions

Permissions come from the code you wrote. How they're derived, what the installer sees, and how updates are gated.

You don't write a permission checklist. At publish time the platform reads your code, finds every call to a host member, and derives the permissions from those calls. The installer sees the result before installing, and at run time your code gets exactly those members — any other member of host is simply absent.

How derivation works

  1. Your files are merged into one bundle.
  2. The bundle is parsed and every literal member chain that starts at host (or globalThis.host) is recorded: host.agents.list, host.session.todo.create, host.fetch, and so on.
  3. Each recorded path is mapped to a permission through a fixed table (below). The union of all of them is the plugin's permission set — one set for the whole package, shared by every capability in it.
  4. If the code calls host.fetch, the plugin must declare manifest.egress, or the publish fails.
Call in your code Permission it derives
host.session.get agentApi:session.read
host.session.conversation.read agentApi:conversation.read (+ agentApi:session.read.cross to pass another session's id — see below)
host.session.message.post agentApi:message.post
host.session.todo.list agentApi:todo.read
host.session.todo.create / .update / .complete / .delete agentApi:todo.write
host.session.subagent.spawn agentApi:subagent.spawn
host.session.peer.send agentApi:peer.send
host.session.peer.get agentApi:session.read.cross
host.session.notify.schedule agentApi:notify.write
host.session.sendError host:session:sendError
host.agents.list agentApi:agents.read
host.agents.create / .update / .delete agentApi:agents.write
host.models.list agentApi:models.read
host.workspace.list / .read host:workspace:read
host.workspace.write / .edit / .delete host:workspace:write
host.media.uploadAudio / .transcodeAudio host:media:transcode
host.ssh.command host:ssh:command
host.connection.send host:connection:send
host.fetch no permission — requires manifest.egress
host.log, host.settings.*, host.crypto.* none — always available
host.llm.complete, host.session.llm.complete, host.decide.* none — granted by context (see Host API)

agentApi:session.read.cross is not derived from conversation.read: to read another session's conversation with host.session.conversation.read({ sessionId }), add it through manifest.permissions (or call host.session.peer.get somewhere, which derives it).

Always call host literally

The scanner sees only literal member access. These are not seen:

const agents = host.agents;          // alias
await agents.list();                  // → not observed

const { workspace } = host;          // destructuring → not observed
host[area].list();                   // computed name → not observed

A call the scanner misses doesn't get secret access — the opposite: the member was never granted, so at run time it is undefined and the call throws. Write host.agents.list(), host.workspace.read({ path }), every time. TypeScript's non-null assertion is fine — host.agents!.list() compiles to host.agents.list():

// infrastructure/plugins/oh-my-openagents/src/reconcile.ts
// host.agents / host.models are non-null here: the plugin's capabilities declare agentApi:agents.* + models.read,
// so the platform binds them. … Never destructure/alias host.agents — that would hide the call from the scan.
const [{ agents }, { models }] = await Promise.all([host.agents!.list(), host.models!.list()]);

If you truly must reach a member indirectly, add the permission to manifest.permissions.

Presence is the grant

At run time, a host member is present only when you were granted it and the invocation has the context it needs:

Member Present when
host.log, host.settings, host.fetch, host.crypto always
host.session you hold any agentApi:* permission or host:session:sendError (and the hop-depth fuse hasn't tripped). Operations on "your session" fail when the invocation has none — for example in pluginInstalled
host.session.llm the invocation has a session and agent — no permission needed
host.agents, host.models you hold any agentApi:* permission — no session needed (works in pluginInstalled)
host.workspace you hold host:workspace:* and the invocation carries an agent (tools; the systemPrompt, interAgentMessage, sshCommandSending, sshCommandCompleted, cronJobFiring and cronJobCompleted hooks)
host.media you hold host:media:transcode
host.llm the account has a Utility model set on the AI Providers page
host.decide tools, middleware, lifecycle and compaction invocations
host.ssh you hold host:ssh:command and the invocation is one of the three SSH middleware hooks
host.connection you hold host:connection:send and the invocation is for a channel whose live connection you own

Each host.session.* operation also checks its own permission: host.session can be present (because you hold, say, agentApi:todo.read) while host.session.message.post rejects with a 403 because you never called it in your code. In practice: if you call it, you have it.

Check optional members before use when the context may be missing:

if (host.llm) {
  const reply = await host.llm.complete({ messages: [{ role: "user", content: "…" }] });
}

Model-provider plugins can't act as agents

A plugin that registers any modelProvider.* handler translates a vendor's API; it never acts on an agent's behalf. Publishing it with any of these derived permissions fails:

agentApi:message.post, agentApi:todo.write, agentApi:subagent.spawn, agentApi:peer.send, agentApi:notify.write, agentApi:agents.write.

Read-only account access (for example agentApi:models.read) is still allowed.

What the installer sees

Before installing, the plugin's Browse page shows:

  • Capabilities — what your package does (a tool, three middleware hooks, a channel…).
  • What it can reach — every permission in plain language, every egress host, and "Adds N agents" from manifest.agents.
  • Your readme.

Updates that ask for more

Every version records its access: permissions + egress hosts + declared agents. When a newer version's access is covered by what the owner already accepted, installs on the Latest policy move to it automatically (within about five minutes). When it asks for more — a new permission, a new host, a new agent — the install stays on the old version and the plugin's page shows Update waiting for your OK, listing exactly what the new version adds. Nothing from the new version runs until the owner accepts.

The same rule applies to your own drafts: a draft that asks for more access shows "Your draft asks for access this account hasn't agreed to yet".

Secrets never enter your code's reach by accident

  • Secrets are write-only in the console and encrypted at rest. Your code reads only the secrets your plugin declared, only via host.settings.getSecret.
  • The token your plugin's host.session / host.agents calls use is held outside the sandbox and scoped to one account (and one session where there is one). It never enters your code.