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" }],
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 |
settings |
users must configure something (an API key, a toggle) | settings |
agents |
your plugin creates agents with host.agents.create |
agents |
schedules |
your plugin creates schedules with host.cronJobs.create |
schedules |
oauth |
your plugin calls a vendor API as the installer, through a sign-in | oauth |
modelProvider |
you register any modelProvider.* handler |
modelProvider |
installOwnedConnection |
your live connection belongs to the install, not to a channel | installOwnedConnection |
failClosed |
a middleware handler is a guard, and a crash must block the operation | failClosed |
channelSenderPolicy |
your channel's senders are machines (webhooks), not people | channelSenderPolicy |
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", "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 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.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 withawait host.settings.getSecret(key). Use it for a signing key you hand to a vendor. Install scope only, with norequired,defaultoroptions, and not allowed in a plugin that hasmodelProvider.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.channelTarget→ a person or chat on one of the installer's channels, picked from a list in the console. It cannot declare adefault(an id would belong to your own account).host.settings.getreturns a JSON string{"channelId":"381","personId":null,"chatId":"9127"}with exactly one ofpersonId/chatIdset, or""when nothing is picked. PasschannelId, and the person or chat id asto, tosend_to_channel.
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;
// 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.
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.
keymatches^[a-z0-9][a-z0-9_-]{0,63}$and is unique;nameis 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 for the same oauth field on a connection request.
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".
// 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 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.
failClosed
string[] of middleware hook names. By default a middleware handler is fail-open: 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.
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.
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. 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.