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.api.tools.register(/* … */); ctx.log.info('hello from main');}// ui/index.tsx — the windowimport { 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.
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, 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 |
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 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 |
Store something
Section titled “Store something”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.
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 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.
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}`, 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.
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.
Work on a schedule, or on a webhook
Section titled “Work on a schedule, or on a webhook”// 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 senderThe ! 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.
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". 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.
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”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.
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, 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.
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.
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.
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.