Building

Manifest reference

Every key of globalThis.manifest, what it does, and how it is validated.

View as Markdown

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 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.
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. 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.
  • 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 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.