Plugin Development
This is the part I'm most excited about. You can point an AI agent at any web app and it'll build a plugin for you in minutes — but if you want to understand what's happening under the hood, or build one by hand, this guide walks you through the whole thing from scratch. By the end, you'll have a working plugin that gives AI agents access to a web application through the user's browser.
Scaffold Your Plugin
The fastest way to start is with the scaffolder — it sets up the whole project structure so you can skip straight to writing tools:
npx @opentabs-dev/create-plugin my-app --domain .example.com --display "My App"
cd my-app
npm installOr use the opentabs CLI:
opentabs plugin create my-app --domain .example.com --display "My App"Or use the npm create convention:
npm create @opentabs-dev/plugin my-app -- --domain .example.com --display "My App"npx create-opentabs-plugin does not work — the package is scoped under @opentabs-dev, so npm cannot resolve the unscoped name. Use npx @opentabs-dev/create-plugin instead.
| Flag | Required | Description |
|---|---|---|
<name> | Yes | Plugin name — lowercase, alphanumeric, hyphens only |
--domain | Yes | Target domain (prefix with . for subdomains — .slack.com matches app.slack.com) |
--display | No | Human-readable name shown in the side panel |
--description | No | Plugin description |
This creates a standalone npm package with everything you need:
The plugin is registered in ~/.opentabs/config.json during the first npm run build (which runs opentabs-plugin build).
Understand the Plugin Class
Open src/index.ts — this is the heart of your plugin. Every plugin extends OpenTabsPlugin and exports a default instance:
import { OpenTabsPlugin, type ToolDefinition } from '@opentabs-dev/plugin-sdk';
import { exampleTool } from './tools/example.js';
class MyAppPlugin extends OpenTabsPlugin {
readonly name = 'my-app';
readonly description = 'OpenTabs plugin for My App';
readonly displayName = 'My App';
readonly urlPatterns = ['*://*.example.com/*'];
readonly tools: ToolDefinition[] = [exampleTool];
async isReady(): Promise<boolean> {
return true;
}
}
export default new MyAppPlugin();Here's what each field does:
name— unique identifier used as the tool prefix in MCP. A tool calledget_itemsbecomesmy-app_get_itemswhen exposed to AI agents.description— brief description of the plugin's purpose. Shown in discovery and status output.displayName— human-readable name shown in the side panel and health endpoint (e.g.,"My App").urlPatterns— Chrome match patterns that determine which tabs get your adapter injected. Use*://*.example.com/*to match all subdomains.tools— array of tool definitions (covered below).isReady()— returntruewhen the user is authenticated. The extension calls this to determine whether the plugin's tab is ready for tool dispatch.
Lifecycle hooks (onActivate, onDeactivate, onNavigate, onToolInvocationStart, onToolInvocationEnd) require override because they are optional methods defined on the base class (TypeScript's noImplicitOverride rule enforces this). See Lifecycle Hooks for usage examples.
Implementing isReady()
isReady() runs in the page context, so you have access to the DOM, localStorage, and cookies. Common patterns:
// Check for an auth token in localStorage
async isReady(): Promise<boolean> {
return localStorage.getItem('session_token') !== null;
}
// Check for a DOM element that only appears after login
async isReady(): Promise<boolean> {
return document.querySelector('[data-user-id]') !== null;
}
// Check for an auth cookie
async isReady(): Promise<boolean> {
return document.cookie.includes('auth=');
}The extension gives isReady() 5 seconds to resolve. If it takes longer, the tab is treated as unavailable.
For pages that load asynchronously, you can wait for authentication to complete:
import { waitUntil } from '@opentabs-dev/plugin-sdk';
async isReady(): Promise<boolean> {
try {
await waitUntil(() => localStorage.getItem('token') !== null, {
timeout: 3000,
});
return true;
} catch {
return false;
}
}Define Tools
Now the fun part. Each tool lives in its own file under src/tools/. Use defineTool() to create one:
import { defineTool } from '@opentabs-dev/plugin-sdk';
import { z } from 'zod';
export const getItems = defineTool({
name: 'get_items',
displayName: 'Get Items',
description: 'Retrieve items from the current workspace.',
icon: 'list',
group: 'Items',
input: z.object({
limit: z.number().int().min(1).max(100).optional().describe('Maximum items to return (default 20, max 100)'),
status: z.enum(['active', 'archived']).optional().describe('Filter by status'),
}),
output: z.object({
items: z.array(
z.object({
id: z.string().describe('Item ID'),
title: z.string().describe('Item title'),
status: z.string().describe('Current status'),
}),
),
}),
handle: async params => {
const response = await fetch(
'/api/items?' +
new URLSearchParams({
limit: String(params.limit ?? 20),
...(params.status && { status: params.status }),
}),
{ credentials: 'include' },
);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data = await response.json();
return { items: data.items };
},
});Tool naming conventions
name—snake_case, descriptive of the action. Becomes<plugin>_<name>in MCP (e.g.,my-app_get_items).displayName— human-readable, shown in the side panel.description— shown to AI agents. Be specific about what the tool does, what it returns, and when to use it.icon— a Lucide icon name inkebab-case(e.g.,list,send,search).group— optional group name for visual grouping in the side panel. Tools with the same group render together under a section header.
The handle function
handle() runs in the page's MAIN world. You have full access to:
- DOM —
document.querySelector,document.createElement, etc. fetch()— with the page's session cookies (usecredentials: 'include')localStorage/sessionStorage— the page's storage- Page globals — anything the web app exposes on
window
You do not have access to Chrome extension APIs or Node.js modules.
Writing good descriptions
Tool descriptions are how AI agents decide which tool to use. I've seen agents make bad tool choices because the description was vague — write them like you're explaining the tool to a colleague:
// Bad — too vague
description: 'Gets data from the page.'
// Good — specific and actionable
description: 'List channels in the workspace. Returns channel ID, name, topic, and member count. Use the cursor parameter for pagination.'Include parameter descriptions on every field — AI agents use them to fill in the right values:
input: z.object({
channel: z.string().min(1).describe('Channel ID (e.g., C01234567)'),
text: z.string().min(1).describe('Message text — supports Slack mrkdwn formatting'),
thread_ts: z.string().optional().describe('Thread timestamp to reply in a thread'),
}),Wiring tools to the plugin
Import your tools and add them to the tools array in your plugin class:
import { getItems } from './tools/get-items.js';
import { createItem } from './tools/create-item.js';
import { deleteItem } from './tools/delete-item.js';
class MyAppPlugin extends OpenTabsPlugin {
// ...
readonly tools: ToolDefinition[] = [getItems, createItem, deleteItem];
}Use SDK Utilities
This is where the SDK really pays off. I find myself reaching for these utilities constantly — they handle credentials, timeouts, structured errors, and all the little things you'd otherwise have to wire up yourself in every single tool. All utilities run in the page context.
Fetch with session cookies
Use fetchJSON and postJSON instead of raw fetch. They include credentials automatically, handle timeouts, and throw structured ToolError on failure — so you can stop writing the same boilerplate in every tool handler:
import { fetchJSON, postJSON, ToolError } from '@opentabs-dev/plugin-sdk';
// GET request — returns parsed JSON
const data = await fetchJSON<{ items: Item[] }>('/api/items?limit=20');
if (!data) throw ToolError.internal('No data returned');
// POST request — stringifies body, sets Content-Type
const result = await postJSON<{ id: string }>('/api/items', {
title: 'New item',
status: 'active',
});
if (!result) throw ToolError.internal('No data returned');Here's a small thing that saves real time: when building request bodies with optional fields, use stripUndefined instead of writing manual if checks for every parameter. It keeps null, 0, false, and empty string — only undefined is filtered out:
import { postJSON, stripUndefined } from '@opentabs-dev/plugin-sdk';
// Without stripUndefined — verbose conditional assignment
const body: Record<string, unknown> = { title: params.title };
if (params.status !== undefined) body.status = params.status;
if (params.assignee !== undefined) body.assignee = params.assignee;
await postJSON('/api/items', body);
// With stripUndefined — clean one-liner
const { item_id, ...updates } = params;
await postJSON(`/api/items/${item_id}`, stripUndefined(updates));The SDK also provides putJSON, patchJSON, and deleteJSON for other HTTP methods. putJSON and patchJSON follow the same signature as postJSON — deleteJSON omits the body parameter:
import { putJSON, patchJSON, deleteJSON } from '@opentabs-dev/plugin-sdk';
// PUT — full replace
await putJSON('/api/items/abc123', { title: 'Updated', status: 'archived' });
// PATCH — partial update
await patchJSON('/api/items/abc123', { status: 'archived' });
// DELETE — no body
await deleteJSON('/api/items/abc123');Form submissions
Use postForm and postFormData when the API expects form-encoded or multipart bodies instead of JSON:
postForm(url, body, init?)— POST withapplication/x-www-form-urlencodedbody.bodyisRecord<string, string>.postFormData(url, body, init?)— POST withmultipart/form-databody.bodyisFormData. Useful for file uploads.
import { postForm, postFormData } from '@opentabs-dev/plugin-sdk';
// URL-encoded form
const result = await postForm<{ success: boolean }>('/api/submit', {
name: 'test',
value: '42',
});
// Multipart form data (file uploads)
const formData = new FormData();
formData.append('file', blob, 'upload.png');
const uploaded = await postFormData<{ id: string }>('/api/upload', formData);Runtime response validation
All fetch utilities accept an optional Zod schema as the last parameter. When provided, the response is validated at runtime and the return type is inferred from the schema — no separate type annotation needed, which I think is a really nice developer experience. The full signatures are:
fetchJSON(url, init?, schema?)— GET without a body (same fordeleteJSON)postJSON(url, body, init?, schema?)— POST/PUT/PATCH with a body (same forputJSON,patchJSON)
import { fetchJSON, postJSON } from '@opentabs-dev/plugin-sdk';
import { z } from 'zod';
const itemSchema = z.object({ id: z.string(), title: z.string() });
// Return type is inferred as { id: string; title: string }[]
const items = await fetchJSON('/api/items', undefined, z.array(itemSchema));
// For methods that take a body, schema is the 4th argument (after the optional init)
const created = await postJSON('/api/items', { title: 'New item' }, undefined, itemSchema);If validation fails, the fetch helper throws a ToolError with a structured validation error. See the Utilities reference for the full API.
Choosing the right fetch utility
There are several fetch wrappers and it's worth knowing which to reach for. All of them automatically include credentials: 'include' (so session cookies are sent) and apply a 30-second default timeout via AbortSignal.timeout(). Here's the cheat sheet:
| Need | Use |
|---|---|
| GET + JSON response | fetchJSON |
| POST/PUT/PATCH/DELETE + JSON | postJSON / putJSON / patchJSON / deleteJSON |
| POST + URL-encoded form | postForm |
| POST + multipart file upload | postFormData |
| GET + text response (diffs, logs) | fetchText |
| Custom request handling | fetchFromPage (raw Response) |
| Build URL query parameters | buildQueryString |
All the JSON and form helpers throw a ToolError on non-2xx responses, so you don't need to write manual status checks — that's one less thing to forget. If you need custom error handling, use fetchFromPage directly and inspect the Response.
Wait for DOM elements
Use waitForSelector when the page loads asynchronously:
import { waitForSelector, getTextContent } from '@opentabs-dev/plugin-sdk';
// Wait up to 10s for the dashboard to load
const dashboard = await waitForSelector('.dashboard-loaded');
// Get text content from an element
const username = getTextContent('.user-profile-name');Retry on failure
Use retry for flaky operations:
import { retry } from '@opentabs-dev/plugin-sdk';
const data = await retry(() => fetchJSON('/api/items'), { maxAttempts: 3, delay: 1000, backoff: true });Read page state
Use getPageGlobal to safely access deeply nested globals:
import { getPageGlobal } from '@opentabs-dev/plugin-sdk';
// Safe deep access — returns undefined if any segment is missing
const token = getPageGlobal('app.config.apiToken') as string | undefined;Storage helpers
Use the storage utilities for safe access to localStorage and cookies:
import { getLocalStorage, getCookie } from '@opentabs-dev/plugin-sdk';
// Returns null instead of throwing on SecurityError
const session = getLocalStorage('session_id');
const authCookie = getCookie('auth_token');These are the utilities I reach for most often, but there's more. For the complete list — including fetchFromPage, getCurrentUrl, getPageTitle, waitForSelectorRemoval, querySelectorAll, observeDOM, sleep, and all storage helpers — see the SDK Utilities reference.
Handle Errors
This is worth getting right. Use ToolError for structured errors that AI agents can understand and act on — the difference between an agent that retries intelligently and one that gives the user a useless error message comes down to how you report failures:
import { ToolError } from '@opentabs-dev/plugin-sdk';
handle: async params => {
const response = await fetch(`/api/items/${params.id}`, {
credentials: 'include',
});
if (response.status === 401) {
throw ToolError.auth('Not authenticated — please log in to My App');
}
if (response.status === 404) {
throw ToolError.notFound(`Item ${params.id} not found`);
}
if (response.status === 429) {
throw ToolError.rateLimited('Too many requests', 2000);
}
// ...
};ToolError has six factory methods — auth(), notFound(), rateLimited(), timeout(), validation(), and internal(). Each sets the right category and retryable flags so AI agents can make smart decisions about whether to retry, wait, or give up. I spent a lot of time getting these categories right — the Error Handling guide goes deep on why they matter and when to use each one.
If you're using fetchFromPage directly (instead of fetchJSON / postJSON which handle errors automatically), httpStatusToToolError saves you from writing a big switch statement over HTTP status codes:
import { fetchFromPage, httpStatusToToolError } from '@opentabs-dev/plugin-sdk';
const response = await fetchFromPage('/api/items');
if (!response.ok) {
throw httpStatusToToolError(response, `Failed to fetch items: ${response.status}`);
}
const data = await response.json();httpStatusToToolError maps HTTP status codes to appropriate error categories: 401/403 → auth (not retryable), 404 → notFound (not retryable), 429 → rateLimited (retryable, parses Retry-After header), 400/422 → validation (not retryable), 408 → timeout (retryable), 503 → internal (retryable, parses Retry-After header), other 5xx → internal (retryable), other 4xx → no category (not retryable).
Add Logging
Trust me on this one: add logging from the start. Use log from the SDK to send structured logs through the platform — when something breaks, these are the only thing standing between you and blindly guessing what happened:
import { log, fetchJSON, ToolError } from '@opentabs-dev/plugin-sdk';
handle: async params => {
log.info('Fetching items', { limit: params.limit });
const data = await fetchJSON<{ items: { id: string; name: string }[] }>('/api/items');
if (!data) throw ToolError.internal('No data returned');
log.debug('Got response', { count: data.items.length });
return { items: data.items };
};Logs flow from your adapter through the Chrome extension to the MCP server, where they appear in opentabs logs --plugin my-app and in MCP client log messages. Four levels: log.debug, log.info, log.warn, log.error. The Logging & Debugging guide walks through my full debugging workflow — it's saved me hours.
Zod Schema Rules
Here's where I see people get tripped up. Tool schemas are serialized to JSON Schema for the MCP protocol, and not every Zod feature survives that conversion. Keep your schemas serialization-compatible:
- Never use
.transform()— transforms can't be represented in JSON Schema. Normalize input inhandle()instead. - Avoid
.pipe()and.preprocess()— same reason. .refine()callbacks must not throw — Zod may run.refine()even when the base validator fails. Wrap throwable operations in try-catch:
z.string()
.url()
.refine(val => {
try {
return new URL(val).hostname.endsWith('.example.com');
} catch {
return false;
}
}, 'Must be an example.com URL');Build and Test
First build
npm run buildThis runs tsc (TypeScript compilation) then opentabs-plugin build, which:
- Validates your plugin metadata, tool names, and Zod schemas
- Bundles
dist/adapter.iife.js— the adapter injected into matching tabs - Generates
dist/tools.json— serialized tool schemas for the MCP server - Auto-registers the plugin in
~/.opentabs/config.json(first build only) - Calls
POST /reloadto notify the running MCP server
opentabs-plugin is the binary provided by @opentabs-dev/plugin-tools, installed as a devDependency when you run npm install. It runs automatically via npm run build and npm run dev — there is no need to invoke it directly. If you do need to run it outside your plugin directory, use npx @opentabs-dev/plugin-tools build (not npx opentabs-plugin, which will fail with a 404 error because there is no unscoped npm package by that name).
After building, verify the server sees your plugin:
opentabs statusDevelopment workflow
This is one of the things I'm most proud of — use watch mode for a fast iteration loop:
npm run devThis runs tsc --watch and opentabs-plugin build --watch in parallel. When you save a file:
tsccompiles todist/index.jsopentabs-plugin builddetects the change and re-bundles the adapter- The build notifies the MCP server via
POST /reload - The server pushes the updated adapter to the Chrome extension
- The extension re-injects the adapter into matching tabs
No server restart, no extension reload — just save and test. The whole pipeline runs in under a second. It's the kind of developer experience I wish every platform had.
Testing with an AI agent
- Start the MCP server if it's not running:
opentabs start - Open the target web app in Chrome and log in
- Check the extension side panel — your plugin should show as Ready
- Ask your AI agent to use a tool: "Use the my-app_get_items tool to list items"
- Edit your code, save, and the adapter hot-reloads automatically
Use opentabs status to verify plugin state, opentabs logs --plugin my-app to see your log output, and opentabs audit to inspect recent tool invocations.
Publish to npm
Honestly, I recommend building your own plugins over installing pre-built ones when you can — you understand every line of code, and you can tailor the tools exactly to your workflow. But when your plugin is ready, publishing it means anyone else can install it with a single command.
Prepare package.json
The scaffolder already sets up the right fields. Verify these are present:
{
"name": "opentabs-plugin-my-app",
"keywords": ["opentabs-plugin"],
"main": "dist/adapter.iife.js",
"opentabs": {
"displayName": "My App",
"description": "OpenTabs plugin for My App",
"urlPatterns": ["*://*.example.com/*"]
}
}The opentabs-plugin-* naming convention and opentabs-plugin keyword enable automatic discovery — users find your plugin with opentabs plugin search.
Publish
npm run build
npm publishHow users install your plugin
Users install globally and restart the server:
npm install -g opentabs-plugin-my-app
opentabs startThe MCP server auto-discovers packages matching opentabs-plugin-* in global node_modules.
Next Steps
Here's where I'd go from here — these are the guides I find myself recommending most:
- Error Handling — the difference between an agent that retries intelligently and one that gives up. Worth reading.
- Logging & Debugging — my full debugging workflow with
opentabs logsandopentabs doctor. You'll need this the first time something goes wrong. - Streaming & Progress — if your tools do anything slow (bulk exports, multi-page scrapes), you'll want progress reporting.
- SDK Reference — the full API reference for
OpenTabsPlugin,defineTool, and all the utilities.
Last Updated: 10 Mar, 2026