Extension manifest reference
Operator·6 minutes to read
Every extension — a connector, a channel, a trigger, an MCP tile, a skill package, a provider —
is one folder, extensions/<kind folder>/<slug>/ (the kind's plural folder: connectors,
channels, triggers, mcp-tiles, skills, … — KINDS in scripts/extensions-gen.mjs),
described by one manifest.json. This page is the
reference for that file: version 2 of the module API (MODULE_API_VERSION = 2). Every block below
is quoted directly from packages/module/src/manifest.ts, and the docs check fails the gate the
moment a quote stops matching the source.
The module manifest reference covers version 1, the ModuleManifest a
TypeScript module passes to defineModule. Version 2 contains it: the identity fields, tools,
dataFlow and oauth mean what they meant there.
Data plus code by path
manifest.json is data. Everything a generator, a reviewer or a catalogue needs is in it — the
identity, the kind and its block, the tile, the auth profile, the scopes, the docs pages, the
provenance, the contributions and the names of the test-account secrets. The TypeScript an
extension ships — tools.ts with its zod input schemas, the driver, the fake — is named by a
relative path in code, never inlined. That keeps the file readable by a standard-library
generator before anything is compiled, and a reviewer reads a policy change as a diff of data.
Every path in a manifest is relative to the extension's own folder. A path that starts with /,
contains .. or uses a backslash is refused: the build imports code.* statically, and a
manifest must not be able to point that import outside its own folder.
Versions: a v1 manifest still loads
A manifest with no kind is read as version 1. validateExtension holds it to version 1's own
rules (it is validateModule underneath) and returns it as kind: 'module', with these defaults
for the fields version 2 made required:
/** The owner a manifest gets when it does not say (a v1 manifest never does): the maintainers
* team, which `.github/CODEOWNERS` is generated from (D7, X0-12). */
export const DEFAULT_EXTENSION_OWNER = '@botseon/maintainers';
/** What a v1 `ModuleManifest` is given for the fields v2 made required (D2). `NOASSERTION` is
* SPDX's own word for "the author did not say", which is the truth about a v1 manifest. */
export const V1_DEFAULTS = {
owner: DEFAULT_EXTENSION_OWNER,
provenance: { repository: 'NOASSERTION', licence: 'NOASSERTION' },
docs: {},
} as const;
A manifest that has a kind was written against version 2 or later, so it must say
apiVersion: 2 or higher; a kind with apiVersion: 1 is refused. Whether this deployment
supports the version a manifest declares is isCompatible's decision at load time, as before:
/**
* F-DEV-2's module-API stability policy: **the module API breaks only on a major.** A manifest
* declares the `apiVersion` it was written against, and a deployment refuses one written against
* a version it does not have. Additive changes — a new optional field, a new permission tag —
* keep the number; anything that could make an existing manifest invalid raises it, and raising
* it is a major release of `@botseon/module` under Changesets (Task 9).
*
* Version 2 is the extension manifest (`ExtensionManifest`, EXT/D2): one type for every kind,
* with a `kind` and its block. Nothing a version-1 manifest wrote stopped being valid —
* `validateModule` reads it unchanged and `validateExtension` reads it as `kind: 'module'` — so
* every version from `FIRST_MODULE_API_VERSION` up stays loadable.
*/
export const MODULE_API_VERSION = 2;
The kinds
/** Every kind of pluggable thing (spec §2.1), and `module` — v1's kindless module, kept. */
export const EXTENSION_KINDS = [
'connector',
'channel',
'trigger',
'mcp-tile',
'skill-package',
'plugin',
'model-provider',
'speech-provider',
'audit-sink',
'coding-agent',
'computer-driver',
'blob-store',
'mail-transport',
'identity-provider',
'module',
] as const;
export type ExtensionKind = (typeof EXTENSION_KINDS)[number];
/** The kinds that are a `manifest.json` alone: no package, no code, so no `code` and no `dataFlow`
* required (§4.2). Every other kind ships code and must declare where its data goes (F-DP-4). */
export const DATA_ONLY_KINDS = [
'mcp-tile',
'skill-package',
'plugin',
] as const satisfies readonly ExtensionKind[];
A data-only kind (mcp-tile, skill-package, plugin) is a manifest.json alone: no
package, no code. It may not carry code, and dataFlow is optional for it. Every other kind
ships code and must declare its dataFlow — the transfer register and the Art. 30(2) record are
generated from these declarations, and a kind with none would be invisible to both.
Each kind has exactly one block, under the field this table names. A manifest carries its own kind's block and no other:
/** The manifest field that carries each kind's block. Exactly that one is present, and no other
* kind's (§4.2: "exactly one kind block, by `kind`"). `module` has no block. */
export const KIND_BLOCK_KEYS = {
connector: 'connector',
channel: 'channel',
trigger: 'trigger',
'mcp-tile': 'mcpTile',
'skill-package': 'skillPackage',
plugin: 'plugin',
'model-provider': 'modelProvider',
'speech-provider': 'speechProvider',
'audit-sink': 'auditSink',
'coding-agent': 'codingAgent',
'computer-driver': 'computerDriver',
'blob-store': 'blobStore',
'mail-transport': 'mailTransport',
'identity-provider': 'identityProvider',
module: null,
} as const satisfies Record<ExtensionKind, string | null>;
Only the connector block has its full shape in this version. The others are reserved: the field exists, it must be an object, and its contents are not yet examined. Each kind's own contract task writes its schema before the first extension of that kind ships, and adding fields is not a breaking change.
/** The block of a kind whose contract is a later task's (X2-0: `mcp-tile`, `skill-package`,
* `plugin`; X3-0: `channel`, `trigger`, `audit-sink`, `coding-agent`; X3-M0 and the 3b families:
* the providers). The key is reserved so a manifest's shape is final; the validator requires the
* block to be an object and examines nothing else until its task writes the schema. */
export type ReservedKindBlock = Record<string, unknown>;
ExtensionManifest
export interface ExtensionManifest extends Omit<ModuleManifest, 'dataFlow'> {
// id, name, version, apiVersion, description, tools, oauth — v1's, unchanged.
kind: ExtensionKind;
/** Required for every kind that ships code; a data-only kind may omit it (§4.2). */
dataFlow?: DataFlow | undefined;
/** Which editions may load it. Default both. A hosted-only extension is `['hosted']`. */
editions?: Array<'home' | 'hosted'> | undefined;
/** Who answers for it: a maintainers team or a lane name; CODEOWNERS is generated from it. */
owner: string;
provenance: {
/** Where the source lives (this repository for first-party). */
repository: string;
/** SPDX id; first-party is Apache-2.0. */
licence: string;
/** Third-party only: the commit or tag reviewed, the attestation id, the publisher key id. */
commit?: string | undefined;
attestation?: string | undefined;
publisherKeyId?: string | undefined;
};
/** Code by path (D17): the tool table and the driver live in TypeScript files the index imports. */
code?: { tools: string; driver?: string | undefined; fake?: string | undefined } | undefined;
docs: {
help?: string | undefined;
setup?: string | undefined;
research?: string | undefined;
/** The published path the setup page keeps, e.g. `docs/connectors/slack` (D10, Q7). */
setupSlug?: string | undefined;
};
assets?: { icon?: string | undefined } | undefined;
/** What this extension adds to the repository's own feature registries, from its own folder. */
contributions?:
| {
strings?: string[] | undefined;
settingsCards?: string[] | undefined;
pages?:
| Array<{ group: '(app)' | '(auth)' | '(onboarding)'; path: string; module: string }>
| undefined;
nav?:
| Array<{
area: 'sidebar' | 'settings' | 'admin';
label: string;
href: string;
module?: string | undefined;
}>
| undefined;
dataClasses?: DataClassFragment[] | undefined;
auditActions?: AuditActionFragment[] | undefined;
jobHandlers?: string[] | undefined;
routineUi?: { menu: string; config: string } | undefined;
}
| undefined;
// Exactly one kind block, by `kind` (`KIND_BLOCK_KEYS`):
connector?: ConnectorBlock | undefined;
channel?: ReservedKindBlock | undefined;
trigger?: ReservedKindBlock | undefined;
mcpTile?: ReservedKindBlock | undefined;
skillPackage?: ReservedKindBlock | undefined;
plugin?: ReservedKindBlock | undefined;
modelProvider?: ReservedKindBlock | undefined;
speechProvider?: ReservedKindBlock | undefined;
auditSink?: ReservedKindBlock | undefined;
codingAgent?: ReservedKindBlock | undefined;
computerDriver?: ReservedKindBlock | undefined;
blobStore?: ReservedKindBlock | undefined;
mailTransport?: ReservedKindBlock | undefined;
identityProvider?: ReservedKindBlock | undefined;
}
kind— one of the kinds above. Anything else is refused, with the list.owner— who answers for the extension: a maintainers team or a lane name. The repository's code-owners file is generated from it, and branch protection requires that owner's review onmanifest.jsonandtools.ts. Permission tags and scopes live in this file, so this review is what stops a driver from labelling its own sendread.provenance— where the source lives and its SPDX licence id (Apache-2.0for first-party extensions). A third-party extension also records the reviewed commit, the provenance attestation and, for a self-hosted publisher, the key id.editions—home,hostedor both. Leave it out for both. If you set it, name at least one edition and none twice.code— required for a connector:toolsis its tool table (tools.ts).docs— the member help page, the operator setup page and the dated research note, by path in the folder.setupSlugkeeps the published URL (docs/connectors/<plugin>) unchanged when a connector moves into its folder.assets.icon—.svg,.pngor.webp.tools,oauth— version 1's fields. Both may be left out of amanifest.json: they default to[]andnull. A connector's tools live in its block, not here.- Unknown fields are refused, at every level. A misspelt key is an error, not a field that gets silently ignored.
The connector block
/** Everything the five hand-written maps, the registry entry, the OAuth profile row, the
* catalogue seed row, the runtime seams and the slug-keyed rule tables of §2.2 say about a
* connector today, in one place. */
export interface ConnectorBlock {
/** `PLUGIN_CONNECTORS` key. Immutable (D16). */
plugin: string;
connectors: ConnectorBlockConnector[];
/** `PLUGIN_VARIABLES`. */
variables?: PluginVariableSpec[] | undefined;
auth: ConnectorAuth;
/** Path of the module the generic OAuth callback dispatches to (D14). */
callbackHooks?: string | undefined;
/** The catalogue.json row. */
tile: {
name: string;
description: string;
category: PluginCategory;
featuredRank?: number | undefined;
available: boolean;
sourceUrl: string;
};
/** `strings.ts` `providerNames`. */
displayName: string;
/** `strings.ts` `scopeLabels`. */
scopeLabels?: Record<string, string> | undefined;
/** `insights.ts` `BY_SLUG`: a `WORK_TYPES` value of `@botseon/db`, which the SDK may not import
* (D5); the derivation that reads it (X0-14) refuses a value outside that list. */
workType?: string | undefined;
/** `MAIL_PLUGINS`, `primary-watch`, `approval-copy`. */
roles?: Array<'mail' | 'calendar' | 'files'> | undefined;
/** `google-identity.ts`, `microsoft-admin-consent.ts`. */
identityRole?: 'google' | 'microsoft' | undefined;
/** `repo-access.ts`: the plugin lists reachable repositories, through its driver or an MCP URL. */
repoAccess?: { lister: 'driver' } | { lister: 'mcp'; url: string } | undefined;
vendor: VendorFacts;
testAccount: {
name: string;
plan: string;
trialEnds?: string | undefined;
signIn: 'operator' | 'none';
/** Secret NAMES only, never values — the live job's environment reads these (D11). */
env: string[];
};
/** A vendor tile this connector supersedes in the catalogue (today `ON_THROUGH_OWN_CONNECTOR`). */
supersedesTile?: string | undefined;
}
/** One connector tool: core's `ConnectorToolDecl` (the policy row — tags, `needs`, the resource
* field) plus the per-tool facts the runtime's slug-keyed tables hold today (§2.2). */
export interface ConnectorBlockTool extends ConnectorToolDecl {
/** `OUTWARD_BODY_FIELDS` (`disclosure.ts`): where the AI-disclosure sentence rides. */
disclosure?: { outwardBody: OutwardBody } | undefined;
/** `otel-families.ts`. */
otelFamily?: string | undefined;
/** `registry/untrusted-tools`: the tool returns text a third party wrote. */
untrusted?: boolean | undefined;
}
export interface ConnectorBlockConnector {
/** Immutable (D16): grants, tokens and the data map key on it. */
slug: string;
tools: ConnectorBlockTool[];
scopes: { read: string[]; write: string[] };
/** `FULL_ACCESS_SENTINELS`. */
fullAccessSentinel?: string | undefined;
/** The runtime `connector-context` fragment, by path: a module exporting `interface Context`. */
context?: string | undefined;
}
export type ConnectorAuth =
| { kind: 'oauth'; profile: OAuthProfileFields }
| { kind: 'api_key'; spec: ApiKeyConnectorSpec }
| { kind: 'hosted_consent'; spec: HostedConsentConnectorSpec }
| { kind: 'none' };
pluginandconnectors[].slug— lowercase slugs, and they never change. Grants, tokens and stored connections all key on them, so renaming one means a new extension plus a data migration. Inside one block, a slug appears once and a tool name appears once.tools[].name—<connector slug>.<verb>. The runtime splits on the dot to pick the driver, so a tool named under another connector's slug is refused.tools[].tags— the permission tags the policy engine reads; see permission tags. The approval rules that act on them stay in core.tools[].disclosure.outwardBody— for an outward send: the field that carries the AI-disclosure sentence. The channel must be one of core's disclosure channels.auth—oauthwith the fields of its OAuth profile that differ from the default,api_key,hosted_consent, ornone. Each kind is checked against its own shape.testAccount.env— the names of the secrets the nightly live test reads (ACME_TEST_TOKEN), never their values. A value-shaped entry is refused. Each connector's live job runs in its own environment and sees only these names.vendor.baseUrl—https://, or a{variable}host the block declares invariables. A conformance check compares its host withdataFlow.destinations.
/** The connector-factory spec's `VendorManifest`, minus what the connector block already says
* (slug, plugin, owner, the test account, the docs pages, the auth kind). `baseUrl` is the one
* required fact: `dataflow-hosts` (X0-9) checks its host against `dataFlow.destinations`. The
* research-grade rest is owed per vendor by the factory's research stage, not invented by a move. */
export interface VendorFacts {
/** May hold one `{variable}` declared in the block's `variables` (Zendesk's subdomain). */
baseUrl: string;
homepage?: string | undefined;
research?: { note: string; date: string; reviewBy: string } | undefined;
docs?:
| {
reference?: Cited | undefined;
auth?: Cited | undefined;
rateLimits?: Cited | undefined;
changelog?: (Cited & { feed?: string | undefined }) | undefined;
status?: string | undefined;
}
| undefined;
spec?:
| {
kind: 'openapi' | 'graphql-sdl' | 'postman' | 'none';
url?: string | undefined;
stored?: string | undefined;
sha256?: string | undefined;
fetched?: string | undefined;
}
| undefined;
apiVersion?:
| { header: string; value: string; source: Cited }
| { inPath: string; source: Cited }
| undefined;
pagination?:
| {
style: 'none' | 'cursor' | 'offset' | 'page' | 'link-header' | 'graphql-relay';
defaultLimit?: number | undefined;
maxLimit?: number | undefined;
source?: Cited | undefined;
}
| undefined;
rateLimit?:
| {
documented: string;
retryAfter: 'header' | 'body' | 'none';
deprecationHeaders?: string[] | undefined;
source?: Cited | undefined;
}
| undefined;
/** The coverage map: every resource of the vendor API. */
resources?:
| Array<{
name: string;
status: 'covered-read' | 'covered-write' | 'planned' | 'out-of-scope';
reason?: string | undefined;
}>
| undefined;
}
Contributions
What an extension adds to the app's own registries, from its own folder: string modules,
settings cards, UI pages and nav entries (the slots a hosted build fills), data-class rows,
audit actions, job handlers and a trigger's routine UI. Every module is named by a path in the
folder. Every page and link is an app path that starts with /.
/** A row of `packages/db/registry/data-classes/*.frag`, contributed from the extension's folder. */
export interface DataClassFragment {
table: string;
class: string;
personal: boolean;
retention: { floorDays: number | null; defaultDays: number | null; ceilingDays: number | null };
erasure: string;
appUpdate?: boolean | undefined;
appUpdateColumns?: string[] | undefined;
privilegedUpdateColumns?: string[] | undefined;
privilegedOnlyColumns?: string[] | undefined;
}
/** An audit action the extension writes through `appendAudit`, beside `AUDIT_ACTIONS`. */
export interface AuditActionFragment {
action: string;
description: string;
}
The entry points
/**
* Structural validation for an extension manifest of unknown provenance — a `manifest.json` the
* generator found, or a third-party package's. A manifest with no `kind` is read as v1: it is
* validated by `validateModule`'s rules and comes back as `kind: 'module'` (D2). Every problem is
* returned at once, as `validateModule` does, one `path: message` string each.
*/
export function validateExtension(
input: unknown,
): { ok: true; manifest: ExtensionManifest } | { ok: false; errors: string[] } {
if (typeof input === 'object' && input !== null && !Array.isArray(input) && !('kind' in input)) {
const v1 = validateModule(input);
return v1.ok ? { ok: true, manifest: fromModuleManifest(v1.module) } : v1;
}
const result = ExtensionManifestSchema.safeParse(input);
if (!result.success) {
return {
ok: false,
errors: result.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`),
};
}
return { ok: true, manifest: result.data };
}
validateExtension returns every problem at once, as path: message strings, the way
validateModule does. The cross-field rules are its job alone: one kind block, dataFlow for
code kinds, tool names under their slug, no duplicates.
/**
* The JSON Schema (draft 2020-12) of `manifest.json`, generated from the zod schema above so the
* two cannot drift: an editor's `$schema`, a third party's own tooling and the generated reference
* page (OS-9) read this. It states the shape; the cross-field rules — one kind block, `dataFlow`
* for code kinds, tool names under their connector's slug, no duplicate keys — are
* `validateExtension`'s alone, because JSON Schema does not carry them.
*/
export function extensionManifestJsonSchema(): Record<string, unknown> {
return z.toJSONSchema(ExtensionManifestSchema, { io: 'input', unrepresentable: 'any' });
}
extensionManifestJsonSchema is the JSON Schema of manifest.json, generated from the same zod
schema. Use it to give an editor completion or to give your own tooling the shape. It does not
carry the cross-field rules, so run validateExtension before you trust a manifest.
Last verified against build b9cee28.