Skip to content

The manifest

extension.json is the only required file in an extension. It declares identity, contributions, host API permissions and disclosures, and when its code should run. It does not sandbox the package: installing an extension means trusting its Node and UI code. This page covers the fields you write by hand and the decisions behind them. For the exhaustive field-by-field table, including the shape of every contribution type, see the manifest reference.

{
"name": "My Notes",
"version": "1.0.0",
"engines": { "wamp": "^12.0.0" },
"icon": "NotebookPen",
"compat": { "pluginApi": "^4.0.0" },
"activationEvents": ["onStartupFinished"],
"contributes": {
"pages": [
{ "id": "my-notes", "title": "Notes", "icon": "NotebookPen" }
]
}
}

Only name and version are strictly required by the schema. Everything else in that example is there because leaving it out costs you something later, and each of those costs is explained below.

Note what is not there: there is no id field. An extension’s id is its directory name, which must be lowercase kebab-case (^[a-z][a-z0-9-]*$). name is the human label and can be anything.

These are the recognized keys. Unknown root keys are discarded; they do not extend the contract.

Field Required Type
name yes string, non-empty — the human label
version yes strict MAJOR.MINOR.PATCH
description no string — one line, used in cards and search
longDescription no Markdown — the catalog detail page body
engines no { wamp: <semver range> }
icon no lucide-react icon name in PascalCase
author no string
permissions no array of permission strings and/or { "auth.outbound": [urls], "auth.resource": [{ audience, baseUrl }] }
capabilities no array of capability strings
products no array of product slugs (^[a-z0-9][a-z0-9-]*$)
webRequestRewrite no { stripCSP?, stripFrameOptions?, allowlist? }
contributes no the contribution block
build no { loaders: [{ match, loader }] }
activationEvents no array of activation-event strings
dependencies no array of extension ids
main no path to the built main bundle, e.g. "dist/main.js"
server no path to the built headless bundle, e.g. "dist/server.js"
requiresElectron no boolean
compat no { pluginApi: <semver range> }

MAJOR.MINOR.PATCH, three numeric segments, nothing else. Pre-release tags are rejected: 1.0.0-beta.1 and 2.1 both fail validation. If you version with pre-release tags elsewhere, the manifest is the one place you cannot.

engines.wamp — optional to load, required in the catalog

Section titled “engines.wamp — optional to load, required in the catalog”

This is the field most worth reading carefully, because its two behaviors differ.

The host treats engines.wamp as optional: an extension without it loads normally, and the host does not compare the range against its own version at install or activation time.

Catalog publication treats it as required: the upload pipeline rejects a package without engines.wamp. The catalog reads the range unconditionally to populate every listing’s compatibility field.

compat.pluginApi — the version check that does run

Section titled “compat.pluginApi — the version check that does run”

This one is enforced, at registration. The host compares the declared range against its own plugin-API version and takes one of three paths:

Declared Result
Absent Registers. No warning — the permissive default for manifests predating the field
Present and satisfied Registers
Present and not satisfied Refuses to register, faults with CompatMismatch, and surfaces an error naming both versions

The current host plugin-API version is 3.0.0, so "^4.0.0" — what the scaffold writes — is satisfied. A range with unrecognized syntax falls back to exact string equality against the host version, so a typoed range does not silently match everything; it silently matches nothing and blocks activation.

A lucide-react component name in PascalCase: NotebookPen, ListChecks, Sparkles. Use an exact export name; an unknown name can leave the surface without its intended icon.

Field Set it when
main The extension has code that runs outside the window — tools, HTTP routes, services
server That code must also run on a core with no Electron in the process
requiresElectron The main half genuinely needs Electron APIs

main is also what drives the build: the main bundle is produced only when main is set, and the renderer bundle only when contributes.pages or contributes.views is non-empty. An extension with main in the manifest but no supported source entry fails the SDK build with a missing-entry diagnostic. The SDK also builds a declared server from server/activate.ts, src/server/activate.ts, or src/server/index.ts, and rejects Electron imports in that bundle.

When both main and server are present, exactly one activates per host, so the same tool never registers twice. An Electron-free main can run headless without a separate server. With requiresElectron: true and no server, a headless core skips the extension at scan. The Cloud browser hides its UI whenever requiresElectron is true, even when the server half is present.

{ "activationEvents": ["onCommand:notes.new", "onTool:notes_search"] }

The narrower the events, the less the extension costs at startup. The recognized forms are onStartupFinished, onCommand:<id>, onAgent:<id>, onTool:<name>, onMcpServer:<id>, and onChatCommand:<command>. *, onView:<id>, and onProjectKind:<id> are rejected by the schema.

dependencies names other extensions by id and is resolved recursively at install.

An array whose entries are either permission strings or the one structured form:

{
"permissions": [
"notifications",
{ "auth.outbound": ["https://api.example.com"] }
]
}

The valid strings are network, ai, terminal, filesystem, http-routes, notifications, process, and auth.identity. Which strings actually withhold a capability — and which are disclosure labels the runtime does not enforce — is the whole subject of Permissions. Read that page before you decide what to declare; the two classes behave very differently when you get one wrong, and neither class confines the extension’s own JavaScript.

A separate, narrower opt-in list, unrelated to permissions. Four values are recognized:

Capability Unlocks
webviews.navigate Navigation control on an embedded webview
webviews.executeScript Script injection into an embedded webview
webviews.interceptRequests Per-request inspection and rewriting
webRequestRewrite Header rewriting declared via webRequestRewrite

Declaring any of the three webviews.* capabilities also selects the higher-powered backing implementation for ctx.api.webviews, so the choice is not purely about permission — it changes what the webview is.

All contribution keys are optional:

Key Declares
pages Top-level surfaces with sidebar entries — see Pages and windows
views Components mounted into a named slot other than the main workspace
commands Command-palette entries, optionally with a keybinding
skills true to scan co-located skills/ for directory packages, or explicit directories
services Typed services other extensions can require
settings User-editable settings with schema defaults
toolMetadata Display and behavior hints for tools
mcpServers MCP servers to register, over stdio, Streamable HTTP, or SSE
agentRuntimes An external agent that can answer a chat turn
ipcNamespaces Namespace prefixes this extension claims for cross-process messaging

The full shape of each is in the manifest reference. Three notes that catch people out:

A page’s context is optional. It takes 'project' or 'both', and omitting it means 'both' — the page appears everywhere. It is not a required field.

ipcNamespaces collide loudly. Namespaces are claimed at registration, before first use, so two extensions claiming the same prefix means the second one faults with IpcNamespaceCollision and does not load. Prefix with your extension id.

skills: true is the common case. It scans the extension’s skills/ directory for portable <name>/SKILL.md packages, including their resources. A loose .md file in skills/ is not a skill package and produces a load diagnostic. Pass a string or array of strings only when you need to name skill directories explicitly.

Validation is strict in some places and permissive in others

Section titled “Validation is strict in some places and permissive in others”

This asymmetry is deliberate and it is worth knowing which way each object leans, because the two failure modes look nothing alike.

The manifest root is permissive. An unknown top-level key is accepted and ignored. A typo like "activationEvent" (singular) validates cleanly and does nothing, which reads exactly like a host bug.

These objects are strict — an unknown key fails the whole manifest with a validation error naming the field: entries in contributes.commands, contributes.pages, contributes.settings, contributes.toolMetadata, contributes.agentRuntimes, the build block and its loaders rules, and the structured auth.outbound / auth.resource permission with its auth.resource entries.

So a typo inside a page contribution stops the extension from loading at all and tells you where; a typo one level up costs you an afternoon. When something you declared has no effect, check the spelling of the top-level key first.

{
"build": {
"loaders": [
{ "match": "presets/**.svg", "loader": "text" }
]
}
}

match is a glob relative to the extension root; loader is one of text, json, base64, dataurl, binary. This exists so an extension with a modest asset-handling need can stay on the standard SDK builder without custom esbuild plumbing. It applies in wamp ext dev and wamp ext pack.

{ "products": ["wamp", "ledger"] }

Which branded products list this extension in their catalog. Absent or empty means every catalog lists it. This is curation, not access control — the download route stays open regardless.