# Extensions and apps The anatomy of a WAMP extension — the files on disk, the main and renderer entry points, the activation lifecycle, and what turns an extension into a full app. Source: https://docs.vampikez.fun/build/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 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 - main/ - activate.ts main-process entry, compiled to `dist/main.js` - ui/ - index.tsx renderer entry, compiled to `dist/plugin.js` - shared/ - schema.ts typed-data schema imported by **both** halves - dist/ - 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 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 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. ## 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](/identity/connected-resources/). The main half is where you register things: ```ts // main/activate.ts export async function activate(ctx: PluginContext): Promise { 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('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: ```tsx // ui/index.tsx function NotesPage() { return (

Notes

); } // Keys must match contributes.pages[].id character for character. export const views = { notes: NotesPage, }; ``` A sidebar entry alone does not prove the UI works. The host's authoring checks report missing view exports and failed mounts; inspect the renderer logs and match each `views` key to its page id exactly. 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 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 Declare them in the manifest: ```json { "activationEvents": ["onStartupFinished", "onCommand:notes.new"] } ``` | Event | Fires when | | --- | --- | | `onStartupFinished` | Once, after the initial extension scan completes | | `onCommand:` | That command is executed | | `onTool:` | That tool is invoked by an agent | | `onAgent:` | That agent is routed for a delegated run | | `onMcpServer:` | That MCP server is about to be spawned | | `onChatCommand:` | 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. If `activationEvents` is absent or an empty array, the host substitutes `["onStartupFinished"]`. An extension with a `main` and no declared events activates at boot, not on first use. Declare the narrow event you actually want. 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 `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 `deactivate()` is optional and usually empty. Every handle you obtain through the context is disposable, and the host disposes them for you: ```ts 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 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 `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. Reloading an extension tears down its registrations. A page that the user has opened in a detached window is re-registered rather than closed — see [Pages and windows](/build/pages-and-windows/) for what the window does while its page is briefly absent. ## 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: ```json { "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](/build/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](/ship/choosing-a-path/). ## Where to go next - [The manifest](/build/manifest/) — every field you set by hand, and the decisions behind them. - [Permissions](/build/permissions/) — what a declaration actually unlocks. - [The plugin API](/build/plugin-api/) — the surface `ctx.api` and `pluginAPI` expose, organized by what you want to do. - [Pages and windows](/build/pages-and-windows/) — how UI reaches the screen.