# The plugin API A map of the two API surfaces an extension gets — ctx.api in the main process and pluginAPI in the window — organized by what you are trying to do. Source: https://docs.vampikez.fun/build/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](/reference/plugin-api/). Manifest permissions control named WAMP host operations. They do not restrict what an installed Node entry can do with Node.js, and the UI bundle shares the host renderer's page realm. Installing an extension means trusting its code. ## Two objects ```ts // main/activate.ts — the main process export async function activate(ctx: PluginContext): Promise { ctx.log.info(`Activated ${ctx.pluginId}`); } ``` ```tsx // ui/index.tsx — the window 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 Namespaces marked with a permission are **absent** when it is not declared. See [Permissions](/build/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 `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 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 | ```ts // Key/value — no schema, no permission. await ctx.api.storage.set('lastOpened', noteId); const last = await ctx.api.storage.get('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](/build/typed-data/). ## Call a model ```ts 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: ```ts 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 ```ts 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 ```ts 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](/build/tools/). ## Receive a webhook ```ts // 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](/build/permissions/#absence-not-refusal--and-how-to-write-for-it). For recurring work, contribute an [agent cron trigger](/build/scheduled-work/). ## Run something on the machine ```ts // 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 ```ts // 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 `` from `@wamp/ui`. ## Talk between your halves Use the same channel contract on Desktop and Cloud. Declare `"contributes": { "ipcNamespaces": ["notes"] }` in the manifest. ```ts // 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: ```tsx 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 ```ts // Publish. ctx.api.disposables.add( ctx.api.services.register('notes', { search, create }), ); // Consume — `require` throws if absent, `get` returns undefined. const notes = ctx.api.services.require('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: ```ts 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 ```ts 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. ## Identity and your own backend ```ts // 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](/identity/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: ```ts // 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](/identity/connected-resources/#read-your-service-from-extension-ui). ## 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.** ```ts 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. Both surfaces are fully typed and both are closed: `@wamp/extension-sdk` declares `ctx.api` and `@wamp/plugin-api` declares `pluginAPI`. If something is not in those declarations it is not part of the API, whatever a runtime inspection of the object suggests. ## Next - [Plugin API reference](/reference/plugin-api/) — every signature. - [Typed data](/build/typed-data/) — the schema and query DSL in full. - [Contributing tools](/build/tools/) — input schemas, display, and policy. - [Permissions](/build/permissions/) — how to handle a gated namespace.