Examples
Six recipes cover the shapes an extension usually takes: a chat surface, a product with stored records, a file tool, a webview, a headless tool pack, and a product with its own end users. Each one is small enough to read in a sitting, and each is the pattern to copy for its shape rather than a toy.
Start with the shape closest to what you are building, copy its load-bearing code from this page, and follow the capability links for the parts you need to change.
Pick one
Section titled “Pick one”| Recipe | Shape | Demonstrates |
|---|---|---|
| task-tracker | Full reference — the shape a real product follows | Typed data in both halves, cron, notifications, a scoped AI session, presentation: 'app' |
| chat-app | Single capability | useAISession + <Chat> + <ModelPicker>, persisted model and conversation |
| file-organizer | Single capability | Folder picker, directory listing, one-shot ai.complete per file |
| mini-browser | Single capability | webviews.create, navigation, setBounds, main↔window IPC |
| ai-tool-pack | Single capability | Three tools, no UI at all |
| account-demo | Single capability | End-user identity, cloud.mine, cloud.shared, AI without a user key |
Only task-tracker is a whole product: two halves, a schema, background work,
and an AI affordance that respects the store. The other five are deliberately
one idea each, so the code you copy is the code that matters. wamp init -t full
scaffolds the task-tracker shape — see the CLI.
task-tracker
Section titled “task-tracker”The full-shape reference. A task list with a typed SQLite store shared by both halves, a daily overdue check that fires with no UI open, and an AI-drafted-notes button whose session has no tools.
Manifest: permissions: ["ai", "database", "cron", "notifications"], one page
with presentation: "app".
The schema is declared once, in a file both halves import — that single source of truth is what makes the renderer’s type inference and main’s DDL agree:
import type { DataSchema } from '@wamp/extension-sdk';
export const schema = { version: 1, tables: { tasks: { id: { type: 'integer', primary: true, autoIncrement: true }, title: { type: 'text', notNull: true }, status: { type: 'enum', values: ['todo', 'doing', 'done'] as const, default: 'todo' }, notes: { type: 'text' }, dueAt: { type: 'datetime' }, createdAt: { type: 'datetime', defaultNow: true, notNull: true }, }, }, indexes: [{ table: 'tasks', columns: ['status'] }],} as const satisfies DataSchema;The main half is forty lines: schema, cron, notification.
import type { PluginContext } from '@wamp/extension-sdk';import { q } from '@wamp/extension-sdk';import { schema } from '../shared/schema';
export async function activate(ctx: PluginContext): Promise<void> { const data = await ctx.api.data.defineSchema(schema);
const job = ctx.api.cron?.schedule('task-tracker.overdue', '0 9 * * *', async () => { const overdue = await data.tasks.findMany({ where: { status: q.ne('done'), dueAt: q.lt(new Date()) }, }); if (overdue.length === 0) return; ctx.api.notifications?.show({ title: `${overdue.length} overdue task${overdue.length === 1 ? '' : 's'}`, body: overdue.slice(0, 3).map((t) => `• ${t.title}`).join('\n'), }); }); if (job) ctx.api.disposables.add(job);}The page reads the same rows through useDataQuery, and its AI assist is a
session narrowed to no tools at all — the right setting for a draft-this-text
button that must not have side effects:
const ai = useAISession({ agent: 'meta', conversationId: `task-tracker.draft.${open ? 'open' : 'closed'}`, tools: { include: [] }, systemPromptExtension: 'You help a user think through a task. Reply with 2-4 bullet points covering ' + 'acceptance criteria and gotchas. Markdown OK.',});
const draftNotes = async () => { const result = await ai.send(`Draft notes for the task: "${title}".`); if (result?.text) setNotes(result.text);};Keying conversationId to the dialog’s open state is what stops a stale session
bleeding between two task creations.
Read on: Typed data has the full worked version of this extension, Scheduled work covers the cron half, and AI sessions covers the session.
chat-app
Section titled “chat-app”A complete chat surface in about 140 lines of page code, and most of it is persistence. The chat itself is one component.
import { useEffect, useState } from 'react';import { Chat, EmptyState, useAISession } from '@wamp/ui';import { pluginAPI } from '@wamp/plugin-api';import { MessageSquare } from 'lucide-react';
function ChatAppPage() { const [model, setModel] = useState<string | undefined>(undefined); const [conversationId, setConversationId] = useState(() => `chat-app-${Date.now()}`);
useEffect(() => { let cancelled = false; Promise.all([ pluginAPI.storage.get<string>('chat-app.model'), pluginAPI.storage.get<string>('chat-app.conversationId'), ]).then(([m, c]) => { if (cancelled) return; if (m) setModel(m); if (c) setConversationId(c); }); return () => { cancelled = true; }; }, []);
const session = useAISession({ agent: 'meta', conversationId, model });
return ( <Chat session={session} model={model} modelPicker onModelChange={(next) => { setModel(next); void pluginAPI.storage.set('chat-app.model', next); }} welcome={ <EmptyState icon={<MessageSquare className="w-8 h-8" />} title="Start a conversation" description="Ask anything. Switch models in the composer." /> } /> );}
export const views = { 'chat-app': ChatAppPage };export default ChatAppPage;Manifest: permissions: ["ai"], one docked page. The main half is a lifecycle
stub — a chat needs nothing there until it wants tools or background work.
Read on: AI sessions for the session and its events,
Interface kit for <Chat> and the tokens around it.
file-organizer
Section titled “file-organizer”Pick a folder, list it, ask the model about one entry at a time. This is the correct use of a one-shot completion: no conversation, no history, one call per item.
const pickFolder = async () => { const picked = await pluginAPI.dialog.openFolder(); if (!picked) return; setFolder(picked); try { const raw = await pluginAPI.fs.listDir(picked); // DirEntry[], not strings setEntries(raw.map((e) => ({ ...e }))); } catch (err) { pluginAPI.notify.error(String(err)); }};
const tagEntry = async (entry: Entry) => { const reply = await pluginAPI.ai.complete( `Given a filename "${entry.name}" (${entry.isDirectory ? 'directory' : 'file'}), ` + 'suggest 1-3 short tags (lowercase, comma-separated, no prose). Just tags.', ); const tags = reply.split(/[,\n]/).map((t) => t.trim().toLowerCase()).filter(Boolean).slice(0, 3); setEntries((prev) => prev.map((e) => (e.path === entry.path ? { ...e, tags } : e)));};Two things this recipe exists to prevent. pluginAPI.fs.listDir returns
{ name, path, isDirectory } objects rather than strings, and it is
single-level — recursion is yours to write. And pluginAPI.terminal.exec is not
a way to list files: its binary allowlist is npm, pnpm, yarn, node,
git, python, python3, so ls never runs.
Read on: The plugin API for the fs and dialog
asymmetries between the two halves, AI sessions for when a
one-shot is the wrong tool.
mini-browser
Section titled “mini-browser”One webview, a URL bar, and back/forward/reload. The main half owns the webview handle and exposes it over IPC; the page positions it.
import type { PluginContext, WebviewHandle } from '@wamp/extension-sdk';
export async function activate(ctx: PluginContext): Promise<void> { let handle: WebviewHandle | null = null;
const getHandle = (): WebviewHandle => { if (!handle) { handle = ctx.api.webviews.create({ id: 'mini-browser.tab', url: 'https://example.com', enableScripts: true, retainContextWhenHidden: true, }); ctx.api.disposables.add(handle); } return handle; };
ctx.api.disposables.add( ctx.api.ipc.handle('mini-browser:load', (_e, url: string) => getHandle().loadURL(url)), ); ctx.api.disposables.add( ctx.api.ipc.handle('mini-browser:attach-rect', (_e, rect: { x: number; y: number; width: number; height: number; dpr: number }) => { const dpr = rect.dpr ?? 1; // setBounds takes WINDOW pixels — multiply the CSS rect by devicePixelRatio. getHandle().setBounds({ x: Math.round(rect.x * dpr), y: Math.round(rect.y * dpr), width: Math.round(rect.width * dpr), height: Math.round(rect.height * dpr), }); }), );}The page calls back with the typed bridge global, and re-sends the rect whenever its placeholder changes size:
await window.electronAPI.extensionIPC.invoke('mini-browser:load', url);Three manifest lines make it work, and each one fails differently if it is
missing: capabilities: ["webviews.navigate"] selects the backing that can
navigate, contributes.ipcNamespaces: ["mini-browser"] is what lets those
channels exist, and the page contribution is what mounts the placeholder.
Read on: The plugin API for webviews and the two IPC
mechanisms, Manifest reference for capabilities and
ipcNamespaces.
ai-tool-pack
Section titled “ai-tool-pack”A headless extension: no ui/ directory, no page, three tools. Everything a
tool pack needs is tools.register plus a disposable.
import type { PluginContext } from '@wamp/extension-sdk';
export async function activate(ctx: PluginContext): Promise<void> { ctx.api.disposables.add( ctx.api.tools.register({ name: 'ai_tool_pack_word_count', description: 'Count words in the provided text. Returns JSON: { words, characters, lines }.', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'Text to analyze' } }, required: ['text'], additionalProperties: false, }, policy: { readOnly: true }, execute: async (input) => { const text = String(input.text ?? ''); return JSON.stringify({ words: text.trim().split(/\s+/).filter(Boolean).length, characters: text.length, lines: text.split('\n').length, }); }, }), );}The names are the part to copy carefully. Tool names must match
^[a-zA-Z0-9_-]+$ — a dot throws inside activate() and faults the whole
extension — and they are global, so the ai_tool_pack_ prefix is both collision
avoidance and what makes tools: { include: ['ai_tool_pack_*'] } a one-line
filter in a session.
Read on: Contributing tools for input schemas, tool cards, and the approval gates.
account-demo
Section titled “account-demo”The shape for a product with its own users: someone who signs in to your app, not to WAMP. It makes four claims concrete — who is signed in, a record that follows them to a second machine, a record everyone shares, and AI for someone who has neither a WAMP account nor a provider key.
// identity — the same call in every accounts mode, so the app never branchesconst session = await pluginAPI.auth.getSession(); // null ⇒ nobody signed inconst unsubscribe = pluginAPI.auth.onChange((next) => apply(next));
// a record that follows the person, not the machineawait pluginAPI.cloud.mine.set('notes', 'scratch', { text, at: Date.now() });const note = await pluginAPI.cloud.mine.get<Note>('notes', 'scratch'); // null if unset
// a record everyone shares — one entry per key, list returns the whole collectionawait pluginAPI.cloud.shared.set('guestbook', crypto.randomUUID(), entry);const all = await pluginAPI.cloud.shared.list<GuestbookEntry>('guestbook');Manifest: permissions: ["ai", "database"] — database is what gates cloud —
and compat: { pluginApi: "^1.2.0" }, the version cloud arrived in. This
recipe is page-only: it declares no main at all.
The state worth copying is the failure state. cloud exists whenever
database is declared, but it throws in a build with no app backend, so the page
probes once and renders “unavailable” rather than hiding it:
try { const note = await pluginAPI.cloud.mine.get<Note>('notes', 'scratch'); setCloud({ kind: 'ready', note });} catch (error) { setCloud({ kind: 'unavailable', message: errorText(error) });}Read on: Hosted end-user accounts for the identity half, Your own backend for the deployment half, and Typed data for the local store to use instead when the app is one person on one machine.
Which one to start from
Section titled “Which one to start from”- The app stores things and does work on a schedule → task-tracker.
- The app is a conversation → chat-app.
- The app operates on files the user picks → file-organizer.
- The app embeds real web content → mini-browser.
- The app has no UI, only capabilities for the assistant → ai-tool-pack.
- The app has users of its own, on more than one device → account-demo.
Anything not on that list is usually one of these plus a second capability page. The plugin API is the map of what else is available.