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, a scheduled job, an agent definition, 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 main, ui, tools,
agents, skills, commands, hooks, and mcp. Only create the ones you
use — shared/ is not one of them, it is an ordinary source folder that both
bundles import from.
Two build outputs, both under dist/:
| 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 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.
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.
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, waits on requiredServices, 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 dist/main.js and calls
activate(ctx) the first time one of your declared activation events fires,
and exactly once thereafter.
An extension with no main never reaches phase two. Its declarations are
live as data and it settles in the idle state.
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 |
onView:<id> |
That contributed view is activated |
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 |
onProjectKind:<id> |
Reserved for project templates; no runtime fires it yet |
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, kick off slow work unawaited, and return.
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); // canonicalctx.subscriptions.push(handle); // equivalent; drained into the same storeBoth sinks 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: activate() returned, views registered |
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”Extensions the host is watching live under one of three roots in its user-data
directory: extensions/dynamic/ (development), extensions/marketplace/
(installed from the catalog), and extensions/imported/ (sideloaded from a
zip). A file change under any of them is picked up automatically.
What happens on save:
- The watcher waits for the write to settle (500 ms of quiet) so a half-written file never triggers a build.
- For extensions with no
build.mjsof their own, the host builds the changed half itself. - The host deactivates the stale registration — pages, commands, services, IPC namespaces, the main module — re-scans the manifest, and re-registers.
Two things the watcher never reports: anything under a node_modules
directory at any depth, and any path segment starting with a dot. Both are
performance decisions; the practical consequence is that dist/ is
watched, because a dist write is the reload signal for extensions that build
themselves.
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.
The same artifact ships three ways once it works: as a marketplace extension, as a branded desktop binary, or against your own backend. 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.