Building
Manifest reference
Every key of globalThis.manifest, what it does, and how it is validated.
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.
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" }],
modelProvider: { kindId: "my-vendor", displayName: "My Vendor" },
installOwnedConnection: true,
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 |
settings |
users must configure something (an API key, a toggle) | settings |
agents |
your plugin creates agents with host.agents.create |
agents |
modelProvider |
you register any modelProvider.* handler |
modelProvider |
installOwnedConnection |
your live connection belongs to the install, not to a channel | installOwnedConnection |
permissions |
rarely — a host.* call the scanner can't see |
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).
// 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" |
see below |
required |
boolean | the user must fill it in |
default |
string / number / boolean | must match the type; not allowed on secret |
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 youroptions.secret→ write-only in the console, stored encrypted, never returned to the browser. Read it withawait host.settings.getSecret(key);host.settings.getnever returns secrets.hosts→ up to 10host[: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.getreturns it as a JSON array encoded in a string:JSON.parseit.
// 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.
// infrastructure/plugins/oh-my-openagents/index.ts
globalThis.manifest = {
agents: CREW.map((member) => ({ key: member.key, name: member.name })),
};
- At most 20 entries.
keymatches^[a-z0-9][a-z0-9_-]{0,63}$and is unique;nameis 1–100 characters. - Use the same
keyyou pass tohost.agents.create. See Agent crews. - A new version that declares an extra agent counts as new access and waits for the owner's OK.
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".
// 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. |
See Model providers and Voice and speech-to-text.
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.
permissions
string[] — an escape hatch. Permissions are normally derived from your code. 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:models.read,
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.