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

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

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.

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:

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

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

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.

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.

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:

  1. The CLI waits for source writes to settle and runs the public SDK checks.
  2. A successful build atomically replaces dist/; a failed build leaves the running output intact and prints file/line diagnostics.
  3. 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.

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.

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