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.api.tools.register(/* … */);
ctx.log.info('hello from main');
}
// ui/index.tsx — the window
import { pluginAPI } from '@wamp/plugin-api';
pluginAPI.notify.success('hello from the window');

ctx also carries four 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
ctx.subscriptions An array; push disposables here and the host tears them down

ctx.storage and ctx.api.storage are the same object, reachable by either path.

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, structured output, multi-turn sessions
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
workspace The current workspace root
disposables The teardown sink
cron cron Scheduled jobs
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
cloud database Records on your own backend
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 installed skills, search and install from registries
tasks Enqueue observable, cancellable work into the user’s Tasks view
data The same typed store as main, queried from the window
cloud Your backend’s document store (needs database)
fs File operations
dialog openFile, openFolder, saveFile
terminal exec(command, args) — argv only, allowlisted binaries
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
events Subscribe to file:changed and ai:tool-use
mcp List MCP servers, start and stop them (needs mcp)
auth The signed-in user, and fetch to your own backend

Four 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
cloud Rows that must follow the user across devices, or be shared between users
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
// Cloud — requires "database". `mine` follows one user across devices;
// `shared` is one copy for everyone using the app.
await ctx.api.cloud?.mine.set('notes', noteId, { title, body });
const board = await ctx.api.cloud?.shared.list<Entry>('guestbook');

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 is a bug. For anything conversational use a session, which owns a conversation id, a tool subset, and a stream of events:

const session = ctx.api.ai.createSession({
agent: 'meta',
systemPromptExtension: 'You help triage support tickets. Be terse.',
tools: { include: ['notes_*'] },
});
const result = await session.send('Triage the newest ticket.');
ctx.log.info(result.text);

Sessions live until destroy() or until the extension deactivates, whichever comes first — the host destroys any your extension still owns, so a leftover session cannot outlive it. Tool filters match exactly, except a trailing * which matches a prefix.

In the window, prefer the useAISession hook from @wamp/ui over calling pluginAPI.ai.createSession by hand: it manages the lifecycle across renders. See Interface kit.

const run = await ctx.api.agents.delegate('meta', 'Rename every draft note to its first line.');
ctx.log.info(`${run.stopReason}, $${run.costUsd}`, run.toolsUsed);

delegate is fire-and-await: it returns text, the tools the agent used, a stop reason, token usage, and cost. Call ctx.api.agents.getAvailable() before hardcoding an agent id — the set is dynamic.

From the window, pluginAPI.tasks.enqueue is the alternative worth knowing: it returns a run id immediately and streams lifecycle events, so the work appears in the user’s Tasks view where they can watch and cancel it.

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 "cron".
const job = ctx.api.cron!.schedule('notes.digest', '0 9 * * *', async () => {
await sendDigest();
});
ctx.api.disposables.add(job);
// Requires "http-routes".
ctx.api.disposables.add(
ctx.api.http!.route('POST', '/incoming', async (req) => {
await handle(req.body);
return { status: 202 };
}),
);
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. More on both in Scheduled work.

// 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". Worktrees for parallel work on one repo.
if (await ctx.api.git!.isRepo(root)) {
const wt = await ctx.api.git!.worktree.create(root, { branch: 'feature/x' });
const diff = 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.

Two mechanisms, and they answer different questions.

// 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 });

A channel passed to broadcast must be prefixed with a namespace listed in the manifest’s contributes.ipcNamespaces, and a mismatch throws synchronously. ctx.api.events.emit(name, …) is the lighter option: no namespace claim, no channel discipline, delivered to every in-process listener and fanned out to every window.

// 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, list it in requiredServices in the manifest: activation waits until the service is registered rather than failing.

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.

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

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.