Skip to content

The plugin API

An extension talks to WAMP through two objects. Which one you have depends on which half of the extension you are writing, and they are not the same object with two names — they differ in membership, in method names, and in whether calls are awaited. This page is the map: what each namespace is for, which permission it needs, and the smallest call that works. Exact signatures live in the plugin API reference.

// main/activate.ts — the main process
import type { PluginContext } from '@wamp/extension-sdk';
export async function activate(ctx: PluginContext): Promise<void> {
ctx.log.info(`Activated ${ctx.pluginId}`);
}
// ui/index.tsx — the window
import { pluginAPI } from '@wamp/plugin-api';
pluginAPI.notify.success('hello from the window');

ctx also carries three things that are not namespaces:

Member What it is
ctx.pluginId This extension’s id — the directory name
ctx.pluginPath Absolute path to the extension’s own directory
ctx.log info / warn / error / debug, routed to the host log

Namespaces marked with a permission are absent when it is not declared. See Permissions for how to handle that without ! or ?..

ctx.api. Permission For
ai — One-shot completions and structured output
agents — Delegate a task to a configured agent and await its result
tools — Register tools the assistant can call
data — The local typed store — schema, tables, queries
storage — Per-extension key/value, no schema
secrets — Encrypted credential storage
commands — Register and execute palette commands
services — Publish and require typed services across extensions
ipc — Named channels between your two halves
events — In-process pub/sub, fanned out to every window
fs — File reads, writes, and path helpers
dialog — Native file open/save pickers
shell — Intercept URL opens before the OS browser gets them
webviews — Embed web content you control
settings — Read and write this extension’s settings
tokens — Subscribe to AI token-usage events
disposables — The teardown sink
http http-routes Local HTTP routes, for webhooks
notifications notifications System notifications
process process Spawn child processes
pty process Interactive terminal sessions
git process Worktrees, diffs, repo probing
auth auth.identity The signed-in user’s identity
sessions host What session is on screen — absent on a headless host
clientAffordances host Answer reverse calls from a remote core

pluginAPI is a closed interface with every member required, so a typo is a compile error rather than a runtime undefined.

pluginAPI. For
notify Toasts. Callable directly, plus .success / .error / .info
ai Completions, sessions, and listModels() for a model picker
agents Delegate, plus the full agent catalog and save/delete
skills List and inspect skills, search registries, and review/install/uninstall exact packages
data The same typed store as main, queried from the window
fs File operations
dialog openFile, openFolder, saveFile
http fetch, routed through the main process to bypass CORS
storage Key/value, get and set
shell openExternal(url)
ui showExtension(id), exitAppMode()
navigation go(pageId, params), back(), params()
workspace path(), pluginsPath()
tools call(name, input) — invoke your own registered tools
ipc Invoke or subscribe to channels owned by your Node half, in Desktop and Cloud
events Subscribe to file.changed and ai.toolUse
auth The signed-in user, and fetch to your own backend

The renderer API has no terminal or MCP namespace. Use pluginAPI.ipc to ask your Node half to run process work; MCP server lifecycle is managed in Settings.

Three stores, and picking the wrong one is the most common design mistake.

Use When
storage A handful of values. Preferences, last-opened id, a cached token expiry
data Rows you query. Local to the machine, typed, no permission needed
secrets Anything you would be unhappy to see in a log
// Key/value — no schema, no permission.
await ctx.api.storage.set('lastOpened', noteId);
const last = await ctx.api.storage.get<string>('lastOpened');
// Encrypted.
await ctx.api.secrets.set('apiKey', key);
const stored = await ctx.api.secrets.get('apiKey'); // string | null

Typed rows are their own subject — schema definition, the query DSL, and the main/window split are covered in Typed data.

const summary = await ctx.api.ai.complete('Summarize this in one line: ' + text, {
maxTokens: 200,
});
const parsed = await ctx.api.ai.generateObject<{ tags: string[] }>(
'Extract topic tags: ' + text,
{ type: 'object', properties: { tags: { type: 'array', items: { type: 'string' } } } },
);

These are one-shot: they do not thread history, so chaining them without supplying it yourself is a bug. There is no multi-turn session primitive — a chat-shaped UI keeps its own message array (component state, ctx.api.storage, or typed data) and passes it as the { role, content }[] input on each call:

const history: { role: 'user' | 'assistant'; content: string }[] = [];
async function send(text: string) {
history.push({ role: 'user', content: text });
const reply = await ctx.api.ai.complete(history);
history.push({ role: 'assistant', content: reply });
return reply;
}

For a turn that should also call tools and run to completion on its own, delegate to an agent instead of hand-rolling a tool loop — see the next section.

const run = await ctx.api.agents.delegate('meta', 'Rename every draft note to its first line.');
ctx.log.info(run.stopReason, run.costUsd === undefined
? { knownCostSubtotalUsd: run.knownCostSubtotalUsd, cost: 'unknown' }
: { costUsd: run.costUsd }, run.toolsUsed);

delegate is fire-and-await: it returns text, the tools the agent used, a stop reason, token usage, and costUsd only when every call has a known charge. A partial result carries knownCostSubtotalUsd instead. Call ctx.api.agents.getAvailable() before hardcoding an agent id — the set is dynamic.

ctx.api.disposables.add(
ctx.api.tools.register({
name: 'notes_search',
description: 'Search the user\'s notes. Returns matching titles.',
inputSchema: {
type: 'object',
properties: { query: { type: 'string' } },
required: ['query'],
},
async execute(input) {
const hits = await search(String(input.query));
return hits.map((h) => h.title).join('\n');
},
}),
);

No permission gates tool registration. Tool names must match [a-zA-Z0-9_-] and registration throws otherwise — a dot is rejected by major model providers, so namespace with underscores: notes_search, not notes.search. Full treatment in Contributing tools.

// Requires "http-routes".
const route = ctx.api.http!.route('POST', '/incoming', async (req) => {
await handle(req.body);
return { status: 202 };
});
await route.ready;
ctx.api.disposables.add(route);
const base = ctx.api.http!.baseUrl(); // give this to the sender

The ! here is for brevity in a snippet. In real code, guard once at the top of activate — see Permissions. For recurring work, contribute an agent cron trigger.

// Requires "process". A pipe-and-wait child process.
const child = ctx.api.process!.spawn('rg', ['--json', pattern], { cwd: root });
child.stdout?.on('data', (chunk) => collect(String(chunk)));
// Requires "process". A real terminal — resizable, interactive, xterm-256color.
const term = ctx.api.pty!.spawn({ command: 'claude', cwd: root, cols: 120, rows: 40 });
ctx.api.disposables.add(term.onData((chunk) => render(chunk)));
term.write('hello\r');
// Requires "process". Inspect existing worktrees.
if (await ctx.api.git!.isRepo(root)) {
const [wt] = await ctx.api.git!.worktree.list(root);
const diff = wt ? await ctx.api.git!.worktree.diff(wt) : [];
}

Reach for pty rather than process whenever a human or a terminal UI is on the other end. process gives you line-buffered pipes with no resize; a terminal application will misbehave in it.

// Requires "notifications". A system notification — works with no UI open.
ctx.api.notifications!.show({ title: 'Digest ready', body: '12 new notes.' });
// No permission — the user picking a file is its own consent.
const picked = await ctx.api.dialog.openFile({
filters: [{ name: 'Markdown', extensions: ['md'] }],
});

In the window, pluginAPI.notify('Saved') raises an in-app toast, with .success, .error, and .info variants. Never call window.alert, prompt, or confirm — they are blocked, and the replacement is <Dialog> from @wamp/ui.

Use the same channel contract on Desktop and Cloud. Declare "contributes": { "ipcNamespaces": ["notes"] } in the manifest.

// Request/response. The window calls, main answers.
// `handle` returns an unregister function, which `disposables.add` accepts.
ctx.api.disposables.add(
ctx.api.ipc.handle('notes:count', async (_e, folder: string) => {
return { count: await countIn(folder) };
}),
);
// Broadcast. Every window hears it.
ctx.api.ipc.broadcast('notes:changed', { id: noteId });

The UI uses the portable bridge:

import { pluginAPI } from '@wamp/plugin-api';
const off = pluginAPI.ipc.subscribe('notes:changed', () => {
pluginAPI.notify.info('Notes changed');
});
const result = await pluginAPI.ipc.invoke<{ count: number }>('notes:count', 'drafts');
// Call on effect cleanup or when this subscription is no longer needed.
off();

A channel passed to broadcast must be prefixed with a namespace listed in the manifest’s contributes.ipcNamespaces, and a mismatch throws synchronously. Use ctx.api.ipc.broadcast for extension UI notifications.

// Publish.
ctx.api.disposables.add(
ctx.api.services.register<NotesService>('notes', { search, create }),
);
// Consume — `require` throws if absent, `get` returns undefined.
const notes = ctx.api.services.require<NotesService>('notes');

If your extension cannot start without a service, wait for it with ctx.api.services.onRegister before calling require rather than failing on a service that hasn’t registered yet.

Commands are the other composition point, and they are what a keybinding or a palette entry actually invokes:

ctx.api.disposables.add(
ctx.api.commands.register({
id: 'notes.new',
title: 'New note',
keybinding: 'cmd+shift+n',
handler: () => createNote(),
}),
);

The handler may go inside the descriptor (shown above, and the shape to prefer) or as a second argument. Both are accepted; passing neither throws.

const view = ctx.api.webviews.create({
id: 'preview',
url: 'https://example.com',
surface: 'headless', // renders off-screen; promote later
});
const title = await view.executeScript('document.title');
view.setSurface('visible'); // reparents without reloading

Navigation control, script injection, and request interception each need a matching entry in the manifest’s capabilities array. headless is what you want for autonomous browsing: the page renders, runs scripts, and answers automation, and the user never sees it. enableScripts defaults to true; pass false to disable page JavaScript. Page messaging is not supported — use ctx.api.ipc between the extension’s main and UI halves.

// Requires "auth.identity".
const session = await ctx.api.auth!.getSession(); // null when signed out
ctx.api.disposables.add(
ctx.api.auth!.onChange((s) => { if (!s) clearLocalState(); }),
);
// Requires { "auth.outbound": ["https://api.example.com"] }.
const res = await ctx.api.auth!.fetch('https://api.example.com/notes', {
method: 'POST',
body: JSON.stringify({ title }),
});

Your extension never sees WAMP’s own token. fetch attaches a signed token whose audience is your extension alone, so a leaked one replays against nothing else. See Sign in with WAMP.

A panel that reads a service someone else registered — an organization’s knowledge base, say — reads it as the viewer instead:

// Requires { "auth.resource": [{ "audience": "internal-docs", "baseUrl": "https://docs.example.com/api/v1" }] }.
const { status, body } = await pluginAPI.auth.resourceFetch(
{ audience: 'internal-docs', path: '/api/v1/documents?limit=20' },
{ signal },
);

WAMP Desktop sends the request with the viewer’s resource identity; the window gets the status, three headers and parsed JSON. See Read your service from extension UI.

The two halves diverge in ways that look like typos when you hit them.

fs has different method names. Main is readFile / writeFile / readDir / remove; the window is read / write / listDir / delete. The main-side namespace also carries path helpers — join, dirname, basename, extname, getDataPath — that the window does not have.

data.defineSchema is awaited in main and not in the window.

const data = await ctx.api.data.defineSchema(schema); // main
const data = pluginAPI.data.defineSchema(schema); // window

dialog has three methods in the window, two in main. openFolder exists only on pluginAPI.dialog; main-side, openFile takes directory: true.

Only the window can list models, skills, and agent entries. pluginAPI.ai.listModels(), pluginAPI.skills.*, and pluginAPI.agents.listEntries() have no main-process equivalent — ctx.api.agents carries delegate and getAvailable and nothing more. Build pickers in the window.