Extensions and apps
Everything you add to WAMP arrives the same way: as an extension. A tool the assistant can call, a page in the sidebar, an agent definition with a schedule, a whole product with its own window — one artifact shape, one lifecycle, one manifest. After this page you can read any extension’s layout and know what each file does, when its code runs, and what makes one an “app” rather than a panel.
The shape on disk
Section titled “The shape on disk”An extension is a directory containing an extension.json manifest. Nothing
else is mandatory. Everything else is convention: the loader scans the
directory and notices which component subdirectories are present.
This is the layout of a complete product-style extension — a task tracker with typed storage, a daily reminder job, and its own window:
- extension.json manifest — the only required file
- package.json only if you bundle extra npm dependencies
- tsconfig.json
Directorymain/
- activate.ts main-process entry, compiled to
dist/main.js
- activate.ts main-process entry, compiled to
Directoryui/
- index.tsx renderer entry, compiled to
dist/plugin.js
- index.tsx renderer entry, compiled to
Directoryshared/
- schema.ts typed-data schema imported by both halves
Directorydist/
- main.js
- plugin.js
The subdirectory names the loader recognizes are skills, agents, ui, and
main. There is no tools/ directory — tools are
registered in code at activation. Only create the directories you use —
shared/ is not one of them, it is an ordinary source folder that both bundles
import from.
The SDK builds up to three outputs:
| Output | From | Format | Runs in |
|---|---|---|---|
dist/main.js |
main/activate.ts |
CommonJS, Node target | The Node process |
dist/plugin.js |
ui/index.tsx |
IIFE, browser target | The window |
The server manifest path (usually dist/server.js) |
server/activate.ts |
CommonJS, Node target | The headless engine |
The build looks for the main entry at main/activate.ts (falling back to
src/main/activate.ts, src/main/index.ts, src/activate.ts) and the
renderer entry at ui/index.tsx (falling back to ui/index.ts,
src/renderer/index.tsx, src/renderer/index.ts, src/index.tsx,
src/index.ts). It builds the main bundle only when the manifest sets main,
and the renderer bundle only when the manifest declares
contributes.pages or contributes.views. A declared bundle with no supported
entry fails the SDK build. The server source alternatives are
src/server/activate.ts and src/server/index.ts; its bundle must be Electron-free.
Identity: the directory names it
Section titled “Identity: the directory names it”An extension has two names and they are not the same field.
- The id is the directory name. It must match
^[a-z][a-z0-9-]*$— lowercase kebab-case. It is what appears in tool prefixes, storage scoping, IPC namespaces, and dependency lists. It is not a manifest field; there is noidkey inextension.json. manifest.nameis the human label shown in the catalog and the extensions list. It can be anything:"Task Tracker".
So the task tracker above lives in a directory called task-tracker and its
manifest says "name": "Task Tracker". Renaming the directory renames the
extension.
The halves
Section titled “The halves”| Half | Entry field | Runs in | Contract |
|---|---|---|---|
| Main | main |
Node, alongside the app | activate(ctx) / deactivate() |
| Renderer | none — implied by contributes.pages / contributes.views |
The window | exports views = { [id]: Component } |
| Server | server |
A headless core, no Electron present | activate(ctx) / deactivate() |
Both of the first two are optional. A pure-UI extension ships only ui/. A
headless tool provider ships only main/. Most products ship both.
An internet service your extension calls is not the server half. Keep it
as an ordinary external service and contribute its URL through
contributes.mcpServers. If it should receive the current WAMP organization and
user without another login, use the
connected-resource identity contract.
The main half is where you register things:
import type { PluginContext } from '@wamp/extension-sdk';
export async function activate(ctx: PluginContext): Promise<void> { ctx.api.disposables.add( ctx.api.tools.register({ name: 'notes_count', description: 'Count the notes the user has saved.', inputSchema: { type: 'object', properties: {} }, async execute() { const notes = await ctx.api.storage.get<string[]>('notes'); return `${notes?.length ?? 0} notes`; }, }), );
ctx.log.info('notes activated');}
export function deactivate(): void { // Optional. Everything on ctx.api.disposables is torn down for you.}The renderer half exports a views object whose keys match the page and view
ids from the manifest:
import { pluginAPI } from '@wamp/plugin-api';import { Button } from '@wamp/ui';
function NotesPage() { return ( <div className="h-full flex flex-col bg-background text-foreground p-6"> <h1 className="text-sm font-semibold">Notes</h1> <Button size="sm" onClick={() => pluginAPI.notify.success('Saved')}> Save </Button> </div> );}
// Keys must match contributes.pages[].id character for character.export const views = { notes: NotesPage,};The server entry exists for extensions that run against a core with no
Electron in the process. A split extension declares both main and server
and the loader activates exactly one per host, so the same tool never
registers twice. Set requiresElectron: true when the main half genuinely
needs Electron APIs — a headless core then skips the extension entirely
unless it also carries a server entry.
The lifecycle
Section titled “The lifecycle”Loading happens in two phases, and the split is what keeps startup time flat as the installed count grows.
Phase one — registration. Eager and cheap, for every enabled extension.
The host validates the manifest, checks compat.pluginApi against the host’s
plugin-API version, claims the extension’s IPC namespaces, and registers
every purely declarative contribution: agents, skills, services, commands,
pages, settings, tool metadata, MCP servers. No JavaScript of yours has run
yet.
Phase two — activation. Lazy. The host loads the entry selected for that host and calls
activate(ctx) the first time one of your declared activation events fires,
and exactly once thereafter.
An extension with no executable entry for that host never reaches phase two.
A headless host chooses server first, then an Electron-free main. If the
extension also has no UI
bundle, it settles in the idle state — declarations are live as data. A
renderer-only extension with a UI bundle is active: the window mounts its
views without a main-process activate().
Activation events
Section titled “Activation events”Declare them in the manifest:
{ "activationEvents": ["onStartupFinished", "onCommand:notes.new"] }| Event | Fires when |
|---|---|
onStartupFinished |
Once, after the initial extension scan completes |
onCommand:<id> |
That command is executed |
onTool:<name> |
That tool is invoked by an agent |
onAgent:<id> |
That agent is routed for a delegated run |
onMcpServer:<id> |
That MCP server is about to be spawned |
onChatCommand:<command> |
That slash command is typed in chat |
There is no *. The schema rejects it, deliberately — an eager-everything
escape hatch is how startup cost becomes proportional to the number of
installed extensions.
An event that fires before any extension declared it is remembered, so an
extension installed after startup still activates on the
onStartupFinished it missed.
activate runs against a clock
Section titled “activate runs against a clock”activate(ctx) must settle within 5 seconds or the host abandons it and
marks the extension faulted with ActivationTimeout. Do not await a network
call, a large migration, or a subprocess handshake inside activate. Register
your contributions and return. Run long-lived work through a registered callback
or service with explicit error handling and teardown; do not leave rejected
promises or background resources unowned.
Teardown
Section titled “Teardown”deactivate() is optional and usually empty. Every handle you obtain through
the context is disposable, and the host disposes them for you:
ctx.api.disposables.add(handle);The disposables are emptied on deactivation, before unloadExtensionContributions
removes your pages, commands, services, settings, agents, skills, and MCP
registrations, and before any AI sessions your extension owns are destroyed.
Writing manual cleanup in deactivate() duplicates work the host already
does — and, unlike the host’s pass, yours does not run when activate() threw
halfway through.
Lifecycle states
Section titled “Lifecycle states”The host reports one of five states per extension, and each one has a distinct visible outcome:
| State | Meaning |
|---|---|
discovered |
Manifest parsed and valid; not yet registered |
idle |
Declaration-only — no main, no UI bundle. Contributions are live as data |
active |
Running: a UI bundle is mounted and/or activate() returned |
faulted |
Activation threw, timed out, a build failed, or an IPC namespace collided. A fault code and message are attached |
uninstalling |
Teardown in progress |
faulted is a reported state, not a crash. The extension stays listed with
its error so you can fix the cause and save again.
Hot reload
Section titled “Hot reload”wamp ext dev watches the source workspace, keeps the last good dist/, and
links the project into the active WAMP profile. Installed extensions live under
the marketplace and imported roots as validated artifacts.
What happens on save:
- The CLI waits for source writes to settle and runs the public SDK checks.
- A successful build atomically replaces
dist/; a failed build leaves the running output intact and prints file/line diagnostics. - WAMP sees the artifact change, deactivates the stale registration, re-scans the manifest, and re-registers pages, commands, services, IPC and Node code.
The host ignores source and dependency changes; dist/ is the runtime reload
signal. This keeps desktop and headless loading identical.
Editing extension.json itself triggers a full re-scan, which is how a newly
added page appears in the sidebar without a restart.
What makes an extension an app
Section titled “What makes an extension an app”Nothing structural. An “app” is an extension whose page claims the window instead of sitting inside WAMP’s chrome, and the only thing that decides it is one field on the page contribution:
{ "contributes": { "pages": [ { "id": "task-tracker", "title": "Tasks", "icon": "ListChecks", "context": "both", "presentation": "app" } ] }}With presentation: 'docked' (the default) the page renders inside the
standard shell, next to the sidebar and the assistant. With
presentation: 'app' the page is a window: it opens in its own window, one
per page id, and the main window never hosts it. That window has no sidebar, no
dock, and no host title bar, so your component owns the entire surface — title
bar and layout included, which is what AppShell from @wamp/ui provides.
That is the whole difference in the manifest. The difference in what you have to build is larger, and it is the subject of Pages and windows.
Once it works, install the artifact into your own Desktop with wamp ext install, bundle it into a branded desktop binary, or use the same application
logic behind your own backend. New public-catalog publication goes through the
WAMP operator’s maintained workflow. See
Choosing a path.
Where to go next
Section titled “Where to go next”- The manifest — every field you set by hand, and the decisions behind them.
- Permissions — what a declaration actually unlocks.
- The plugin API — the surface
ctx.apiandpluginAPIexpose, organized by what you want to do. - Pages and windows — how UI reaches the screen.