# Manifest reference

`globalThis.manifest` is one optional, plugin-wide object. It carries what the platform can't learn by reading
your handlers: which hosts you call, what settings the user fills in, which agents you'll create, and which
provider you register as.

```ts
globalThis.manifest = {
  egress: ["api.example.com", "api.example.com:8443", "*.discord.gg"],
  settings: [{ key: "apiKey", title: "API key", type: "secret", required: true }],
  agents: [{ key: "oracle", name: "Oracle" }],
  schedules: [{ key: "daily", name: "Daily digest" }],
  modelProvider: { kindId: "my-vendor", displayName: "My Vendor" },
  installOwnedConnection: true,
  failClosed: ["sshCommandSending"],
  channelSenderPolicy: "anyone",
  permissions: ["agentApi:agents.write"],
};
```

Assign it at the top level as a plain JSON value. It is **validated strictly**: an unknown key — a typo such as
`egres` — fails the publish with *"unknown globalThis.manifest key 'egres'"*. Many plugins need no manifest at all.

| Key | Needed when | Section |
| --- | --- | --- |
| `egress` | your code calls `host.fetch`, or opens a live connection | [egress](#egress) |
| `settings` | users must configure something (an API key, a toggle) | [settings](#settings) |
| `agents` | your plugin creates agents with `host.agents.create` | [agents](#agents) |
| `schedules` | your plugin creates schedules with `host.cronJobs.create` | [schedules](#schedules) |
| `oauth` | your plugin calls a vendor API as the installer, through a sign-in | [oauth](#oauth) |
| `modelProvider` | you register any `modelProvider.*` handler | [modelProvider](#modelprovider) |
| `installOwnedConnection` | your live connection belongs to the install, not to a channel | [installOwnedConnection](#installownedconnection) |
| `failClosed` | a middleware handler is a guard, and a crash must block the operation | [failClosed](#failclosed) |
| `channelSenderPolicy` | your channel's senders are machines (webhooks), not people | [channelSenderPolicy](#channelsenderpolicy) |
| `permissions` | rarely — a `host.*` call the scanner can't see | [permissions](#permissions) |

## `egress`

`string[]` — the hosts `host.fetch` may reach. Outbound HTTP is **deny-by-default**: a request to any host not on
this list (or on an installer-provided `hosts` setting) is refused. Publishing code that calls `host.fetch` with an
empty `egress` fails: *"the code calls host.fetch but declares no egress hosts"*.

| Entry | Matches |
| --- | --- |
| `api.example.com` | `https://api.example.com/…` and `http://api.example.com/…` on the default port (443 / 80) only |
| `api.example.com:8443` | that host on port 8443 only |
| `*.discord.gg` | any subdomain (`gateway.discord.gg`, `a.b.discord.gg`) — **never** the bare `discord.gg` |
| `*.example.com:9000` | any subdomain on port 9000 |

No scheme, no path, no trailing slash. A wildcard needs at least two labels after `*.`. Matching is
case-insensitive.

The installer sees these hosts under **What it can reach** before installing, and a later version that adds a
host waits for their OK. Examples: Brave Search declares `["api.search.brave.com"]`; Discord declares
`["discord.com", "gateway.discord.gg", "*.discord.gg", "cdn.discordapp.com", "media.discordapp.net"]`.

## `settings`

An array of fields that become the plugin's settings form in the console. Values reach your code through
`host.settings.get(key)`; secrets through `await host.settings.getSecret(key)`.

```ts
// plugins/discord/index.ts
settings: [
  { key: "botToken", title: "Bot token", type: "secret", required: true, scope: "channel" },
  {
    key: "readMessageContent",
    title: "Use the Message Content intent",
    type: "bool",
    default: true,
    scope: "channel",
    description: 'Needs "Message Content Intent" on in the Developer Portal. DMs and @mentions work without it.',
  },
  { key: "respondToBots", title: "Answer other bots", type: "bool", default: false, scope: "channel" },
],
```

| Field | Type | Rules |
| --- | --- | --- |
| `key` | string | required; `^[a-zA-Z][a-zA-Z0-9_]{0,63}$`; unique |
| `title` | string | the label in the form |
| `description` | string | help text under the field |
| `type` | `"text"` (default), `"secret"`, `"bool"`, `"int"`, `"enum"`, `"hosts"`, `"channelTarget"`, `"generatedSecret"` | see below |
| `required` | boolean | the user must fill it in |
| `default` | string / integer / boolean | must match the type (`int` an integer, `bool` a boolean, `enum` one of `options`, the rest a string); **not allowed on `secret`, `channelTarget` or `generatedSecret`** |
| `options` | string[] | required (non-empty) for `enum`, not allowed otherwise; `default` must be one of them |
| `scope` | `"install"` (default) or `"channel"` | `channel` = filled in per connected channel, never inherited from the install |

Any other field key refuses the publish.

**Types:**

- `text` → a string. `int` → a number. `bool` → a boolean. `enum` → one of your `options`.
- `secret` → write-only in the console, stored encrypted, never returned to the browser. Read it with
  `await host.settings.getSecret(key)`; `host.settings.get` never returns secrets.
- `generatedSecret` → a secret the platform fills in itself: 32 random bytes (base64url) when the plugin is installed,
  or on the first update that declares it. The user never sees or edits it, and it stays the same across updates; a
  reinstall gets a new one. Read it with `await host.settings.getSecret(key)`. Use it for a signing key you hand to a
  vendor. Install scope only, with no `required`, `default` or `options`, and not allowed in a plugin that has
  `modelProvider`.
- `hosts` → up to 10 `host[:port]` values (no wildcards) that are **added to this installation's egress
  allowlist**. Use it when the user picks the server — a self-hosted instance, a home gateway. `host.settings.get`
  returns it as a **JSON array encoded in a string**: `JSON.parse` it.
- `channelTarget` → a person or chat on one of the installer's channels, picked from a list in the console. It
  cannot declare a `default` (an id would belong to your own account). `host.settings.get` returns a **JSON
  string** `{"channelId":"381","personId":null,"chatId":"9127"}` with exactly one of `personId` / `chatId` set, or
  `""` when nothing is picked. Pass `channelId`, and the person or chat id as `to`, to `send_to_channel`.

```ts
globalThis.manifest = { settings: [{ key: "teamChannel", title: "Where to report", type: "channelTarget" }] };

const raw = String(host.settings.get("teamChannel") ?? "");
const target = raw ? (JSON.parse(raw) as { channelId: string; personId: string | null; chatId: string | null }) : null;
```

```ts
// infrastructure/plugins/hello-live/index.ts
function gatewayHost(): string {
  const raw = host.settings.get("gatewayHost");
  const hosts = typeof raw === "string" ? (JSON.parse(raw) as string[]) : [];
  if (!hosts[0]) {
    throw new Error("hello-live: no gatewayHost configured");
  }
  return hosts[0];
}
```

`host.settings.get` on a key you didn't declare **throws** — it's a bug in your plugin, not a missing value.
Defaults apply until the user saves a value.

**Scope `channel`** is for per-bot credentials: the official Discord plugin needs one bot token per connected
channel, so every Discord field is `scope: "channel"` and is entered on **Channels → Connect a channel**.

## `agents`

`{ key, name }[]` — the agents your plugin will create with `host.agents.create`. Declaring them up front lets the
console say **"Adds 7 agents"** and check the account has room on its plan *before* install, instead of your setup
hook discovering it half-way.

```ts
// infrastructure/plugins/oh-my-openagents/index.ts
globalThis.manifest = {
  agents: CREW.map((member) => ({ key: member.key, name: member.name })),
};
```

- At most 20 entries. `key` matches `^[a-z0-9][a-z0-9_-]{0,63}$` and is unique; `name` is 1–100 characters.
- Use the same `key` you pass to `host.agents.create`. See [Agent crews](plugin-agent-crews.md).
- A new version that declares an extra agent counts as new access and waits for the owner's OK.

## `schedules`

`{ key, name }[]` — the schedules your plugin will create with `host.cronJobs.create`. Like `agents`, declaring them
up front lets the console say **"Adds 2 schedules"** and check the account has room *before* install. The platform
never creates a schedule from this entry; your setup code does.

- At most 20 entries. `key` matches `^[a-z0-9][a-z0-9_-]{0,63}$` and is unique; `name` is 1–100 characters.
- A new version that declares an extra schedule counts as new access and waits for the owner's OK.

## `oauth`

An array of up to 5 sign-ins your plugin needs, such as the installer's Google account. The installer connects each
one on the plugin's page, and you call the vendor with `host.fetch({ url, oauth: key })`; the platform adds the
`Authorization: Bearer` header and renews the token, and you never see it. See
[Live connections](plugin-live-connections.md) for the same `oauth` field on a connection request.

```ts
globalThis.manifest = {
  egress: ["gmail.googleapis.com"],
  oauth: [{
    key: "google",
    preset: "google",
    client: "installer",
    scopes: ["https://www.googleapis.com/auth/gmail.readonly"],
    apiHosts: ["gmail.googleapis.com"],
  }],
};
```

| Field | Rules |
| --- | --- |
| `key` | required; `^[a-z][a-z0-9-]{0,31}$`; unique |
| `displayName` | optional, 1–80 characters |
| `preset` | `"google"` or `"microsoft"`: built-in vendor endpoints. Don't combine with `authorizeUrl`/`tokenUrl` |
| `client` | `"installer"` (the installer pastes their own client id and secret), `"discover"` (sign in to the server at `resource`, nothing to paste), `"platform"` or `"platform-or-installer"` (AgentParley's own app registration; needs a `preset`, and only official plugins may use them). Default `"platform"` when a `preset` is set, otherwise required |
| `authorizeUrl`, `tokenUrl` | https URLs, declared together. `tokenUrl` (and `revokeUrl`) must be on the same registered domain as `authorizeUrl` |
| `revokeUrl` | https; only together with `authorizeUrl` and `tokenUrl` |
| `resource` | https; required for `client: "discover"`, which can't be combined with `preset`, `authorizeUrl`, `tokenUrl` or `revokeUrl` |
| `scopes` | always requested; up to 50 entries of 1–512 characters, no spaces |
| `scopeSets` | `{ setting, scopes }[]`, up to 20: extra scopes requested only while the named `bool` setting is on |
| `apiHosts` | up to 20 hosts the token may be sent to, no ports. Each must also appear in `egress` (a wildcard `*.host` entry must match `egress` exactly). Defaults to the preset's hosts (Google: `*.googleapis.com`, Microsoft: `graph.microsoft.com`) or the `resource` host, so name them yourself when the default wildcard isn't in `egress`. A preset allows only its own hosts; required with explicit endpoints |
| `multiple` | boolean: allow several connected accounts for this key |

Declare one of `preset`, `authorizeUrl` + `tokenUrl`, or `resource`. Any other field fails the publish. The calls
are `host.fetch` with `oauth` (and `account` when several accounts are connected) and `host.oauth.accounts()`.

## `modelProvider`

Required when you register any `modelProvider.*` handler, and forbidden when you don't. It makes your plugin a
selectable provider kind under **AI Providers → Add provider**, shown as "*displayName* from plugin".

```ts
// infrastructure/plugins/openai-voice/index.ts
modelProvider: { kindId: "openai-voice", displayName: "OpenAI Voice" },
```

| Field | Rules |
| --- | --- |
| `kindId` | required; `^[a-z][a-z0-9-]{1,63}$` (2–64 characters); built-in kind ids such as `openai-compatible`, `anthropic`, `fish-audio`, `elevenlabs`, `deepgram` are reserved |
| `displayName` | optional, ≤ 100 characters |
| `description` | optional, ≤ 500 characters |
| `mediaSupport` | optional; any of `"image"`, `"audio"` — the media your chat models accept. The platform strips media you don't support from the request before calling you. |
| `voiceDirection` | optional, only with `modelProvider.speak` and `voices`: text that tells the model how to write for your voice. A string, or `{ default, models? }` where `models` maps a model identifier to its own text (at most 20; case-insensitive keys). Each text is non-empty and at most 4000 characters |

See [Model providers](plugin-model-providers.md) and [Voice and speech-to-text](plugin-voice-and-speech.md).

## `installOwnedConnection`

`boolean`. Set `true` when your `connection.*` handlers serve a connection that belongs to the **installation**
rather than to a chat channel — for example a smart-home event stream that fires schedules. Declaring it without
`connection.*` handlers fails the publish. See [Live connections](plugin-live-connections.md#two-kinds-of-owner).

## `failClosed`

`string[]` of middleware hook names. By default a middleware handler is [fail-open](plugin-middleware.md): if it
crashes, times out or returns something invalid, the operation goes ahead as if it were not there. List a hook here
and the same fault **blocks** the operation instead, with a plain message to the agent ("*Name's safety check could
not run (timed out), so this was blocked. Try again; if it keeps failing, check the plugin or turn it off.*").

Use it for a guard, never for an observer. Allowed values: `userMessageInbound`, `interAgentMessage`,
`assistantMessageOutbound`, `todoAdded`, `notifyScheduled`, `notifyDelivered`, `sshConnectionOpening`,
`sshCommandSending`, `sshCommandCompleted`, `cronJobFiring`. The others (`systemPrompt`, `sessionClearing`,
`sessionClearCompleted`, `cronJobCompleted`, `companionBriefing`, `companionPerformance`, `companionSpeech`) never
cancel, so listing them fails the publish, as does a duplicate or a hook your code has no handler for.

```ts
globalThis.manifest = { failClosed: ["sshCommandSending"] };
globalThis.middleware.sshCommandSending = async (context, tools) => checkCommand(context, tools);
```

The installer sees "Blocks SSH commands and file changes if its safety check can't run" on the consent panel, and a
later version that adds a hook here asks them to agree again.

## `channelSenderPolicy`

`"anyone"` or `"authorized"` (the default; any other value, including `"Anyone"`, fails the publish). It sets who may
message a channel created from your plugin, at creation; the owner can change it later. A webhook from GitHub is a
machine and can never be on a people list, so a plugin whose channel receives webhooks declares `"anyone"`. Declaring
it without any channel handlers fails the publish.

```ts
globalThis.manifest = { channelSenderPolicy: "anyone" };
```

The Connect page shows which policy a new channel starts on.

## `permissions`

`string[]` — an escape hatch. Permissions are normally [derived from your code](plugin-permissions.md). If you
reach a `host` member in a way the scanner can't see (through an alias or a computed property), list the
permission here. Values must come from the known set; anything else fails the publish:

`agentApi:session.read`, `agentApi:conversation.read`, `agentApi:message.post`, `agentApi:todo.read`,
`agentApi:todo.write`, `agentApi:subagent.spawn`, `agentApi:peer.send`, `agentApi:notify.write`,
`agentApi:session.read.cross`, `agentApi:agents.read`, `agentApi:agents.write`, `agentApi:cronJobs.read`, `agentApi:cronJobs.write`, `agentApi:models.read`, `agentApi:plugins.read`, `agentApi:plugins.write`, `agentApi:plugins.release`,
`host:workspace:read`, `host:workspace:write`, `host:media:transcode`, `host:session:sendError`,
`host:ssh:command`, `host:connection:send`.

The better fix is almost always to call `host.x.y(...)` literally.
