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 / .channelChat |
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.cronJobs.list / .listEventSources |
agentApi:cronJobs.read |
host.cronJobs.create / .update / .delete |
agentApi:cronJobs.write |
host.models.list |
agentApi:models.read |
host.plugins.list / .get / .logs |
agentApi:plugins.read |
host.plugins.create / .checkout / .saveDraft / .install / .acceptUpdate / .configure / .uninstall |
agentApi:plugins.write |
host.plugins.publish / .yank / .addToBrowse / .removeFromBrowse / .setPrice / .delete |
agentApi:plugins.release |
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.*, host.oauth.*, host.license |
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 is not granted. For host.workspace, host.media, host.ssh and host.connection the
member is undefined at run time and the call throws; for host.session, host.agents, host.models and
host.cronJobs the platform rejects the call with a 403. 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 and permission
At run time, host.workspace, host.media, host.ssh and host.connection are present only when you were granted
them and the invocation has the context they need. The platform-API members (host.session, host.agents,
host.models, host.cronJobs, host.plugins) are present in tool, middleware, lifecycle, compaction and web-provider runs
whatever you were granted, and each call is checked against its permission; they are absent in channel,
live-connection and model-provider runs and once the hop-depth fuse has tripped:
| Member | Present when |
|---|---|
host.log, host.settings, host.fetch, host.crypto, host.oauth |
always |
host.session |
tool, middleware, lifecycle, compaction and web-provider runs, until the hop-depth fuse trips; each operation needs its own permission. Operations on "your session" fail when the invocation has none — for example in pluginInstalled |
host.session.sendError |
you hold host:session:sendError and the invocation has a session |
host.session.llm |
the invocation has a session and agent — no permission needed |
host.agents, host.models, host.cronJobs, host.plugins, host.license |
same runs as host.session — no session needed (works in pluginInstalled); host.license needs no permission |
host.workspace |
you hold host:workspace:* and the invocation carries an agent (tools, the session lifecycle events, and every middleware hook) |
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, compaction and web-provider 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 checks its own permission: host.session.message.post rejects with a 403 unless
your code calls it literally somewhere (or you list agentApi:message.post in manifest.permissions). 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, agentApi:cronJobs.write, agentApi:plugins.write, agentApi:plugins.release.
Read-only account access (for example agentApi:models.read) is still allowed.
Asking for a person's OK
agentApi:plugins.write and agentApi:plugins.release let a plugin make the platform ask the account's user before
something happens. The plugin never writes the question and cannot answer it:
- The tool calls, for example,
host.plugins.publish(...). The platform checks the request and writes the question itself from stored data (the version, the hosts and permissions, what is new), then answersawaiting_confirmation. - When the turn ends with its reply going out, the platform shows the question: Approve and Reject buttons above the message box in the console, or "reply YES" on a channel.
- The answer arrives as an ordinary message and the next turn applies the action, then tells the agent how it ended.
This works only in a direct chat with a user of the account. A subagent, a peer-started turn or a scheduled run answers
setup_needs_account_user; a group chat answers setup_needs_direct_chat; a turn that includes a message from someone
who is not a user of the account answers needs_trusted_turn. The agent's "Can set up AgentParley" switch must be on.
Do not install a plugin that holds agentApi:plugins.write (such as Plugin Studio) on an agent that talks to strangers: a stranger can change the files in a package folder through the agent's ordinary workspace tools, and a later clean save by the owner would ship them. Saving a draft that an install follows for other agents, and installing for any agent but the asking one, always ask.
Arguments are pinned to ids (the exact draft or version the person was asked about), so a different draft needs a new
question. The question always names every host and permission in full and is never shortened: if the access does not fit
one message the request is refused with question_too_long. Adding agents to an installed plugin always asks, and the
question lists them.
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" / "Adds N schedules"
from
manifest.agents/manifest.schedules. - Your readme.
Updates that ask for more
Every version records its access: permissions, egress hosts, declared agents and schedules, failClosed hooks
and the anyone sender policy. 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 or schedule, a new failClosed hook, the anyone sender policy — 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.