Skip to content

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.

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
  • Directoryui/
    • index.tsx renderer entry, compiled to dist/plugin.js
  • 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.

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 no id key in extension.json.
  • manifest.name is 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.

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:

main/activate.ts
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:

ui/index.tsx
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.

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.

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

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); // canonical
ctx.subscriptions.push(handle); // equivalent; drained into the same store

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

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.

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:

  1. The watcher waits for the write to settle (500 ms of quiet) so a half-written file never triggers a build.
  2. For extensions with no build.mjs of their own, the host builds the changed half itself.
  3. 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.

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.

  • 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.api and pluginAPI expose, organized by what you want to do.
  • Pages and windows — how UI reaches the screen.