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
- Your files are merged into one bundle.
- The bundle is parsed and every literal member chain that starts at
host(orglobalThis.host) is recorded:host.agents.list,host.session.todo.create,host.fetch, and so on. - 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.
- If the code calls
host.fetch, the plugin must declaremanifest.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.agentscalls use is held outside the sandbox and scoped to one account (and one session where there is one). It never enters your code.