# Permissions

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` / `.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](plugin-host-api.md#models-from-plugin-code)) |

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

```ts
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()`:

```ts
// 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](plugin-host-api.md#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:

```ts
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:

1. 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 answers `awaiting_confirmation`.
2. 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.
3. 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.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.
