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.
Two objects
Section titled “Two objects”// main/activate.ts — the main processimport type { PluginContext } from '@wamp/extension-sdk';
export async function activate(ctx: PluginContext): Promise<void> { ctx.log.info(`Activated ${ctx.pluginId}`);}// ui/index.tsx — the windowimport { 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 |
The main-process surface
Section titled “The main-process surface”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 |
The window surface
Section titled “The window surface”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.
Store something
Section titled “Store something”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 | nullTyped rows are their own subject — schema definition, the query DSL, and the main/window split are covered in Typed data.
Call a model
Section titled “Call a model”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.
Delegate to an agent
Section titled “Delegate to an agent”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.
Give the assistant a tool
Section titled “Give the assistant a tool”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.
Receive a webhook
Section titled “Receive a webhook”// 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 senderThe ! 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.
Run something on the machine
Section titled “Run something on the machine”// 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.
Talk to the user
Section titled “Talk to the user”// 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.
Talk between your halves
Section titled “Talk between your halves”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.
Compose with other extensions
Section titled “Compose with other extensions”// 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.
Embed web content
Section titled “Embed web content”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 reloadingNavigation 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.
Identity and your own backend
Section titled “Identity and your own backend”// Requires "auth.identity".const session = await ctx.api.auth!.getSession(); // null when signed outctx.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.
Four asymmetries that cost time
Section titled “Four asymmetries that cost time”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); // mainconst data = pluginAPI.data.defineSchema(schema); // windowdialog 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.
- Plugin API reference — every signature.
- Typed data — the schema and query DSL in full.
- Contributing tools — input schemas, display, and policy.
- Permissions — how to handle a gated namespace.