Skip to main content

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 install

Or 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"
FlagRequiredDescription
<name>YesPlugin name — lowercase, alphanumeric, hyphens only
--domainYesTarget domain (prefix with . for subdomains — .slack.com matches app.slack.com)
--displayNoHuman-readable name shown in the side panel
--descriptionNoPlugin 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 called get_items becomes my-app_get_items when 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").
  • urlPatternsChrome 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() — return true when 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

  • namesnake_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 in kebab-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:

  • DOMdocument.querySelector, document.createElement, etc.
  • fetch() — with the page's session cookies (use credentials: '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 postJSONdeleteJSON 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 with application/x-www-form-urlencoded body. body is Record<string, string>.
  • postFormData(url, body, init?) — POST with multipart/form-data body. body is FormData. 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 for deleteJSON)
  • postJSON(url, body, init?, schema?) — POST/PUT/PATCH with a body (same for putJSON, 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:

NeedUse
GET + JSON responsefetchJSON
POST/PUT/PATCH/DELETE + JSONpostJSON / putJSON / patchJSON / deleteJSON
POST + URL-encoded formpostForm
POST + multipart file uploadpostFormData
GET + text response (diffs, logs)fetchText
Custom request handlingfetchFromPage (raw Response)
Build URL query parametersbuildQueryString

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 in handle() 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 build

This runs tsc (TypeScript compilation) then opentabs-plugin build, which:

  1. Validates your plugin metadata, tool names, and Zod schemas
  2. Bundles dist/adapter.iife.js — the adapter injected into matching tabs
  3. Generates dist/tools.json — serialized tool schemas for the MCP server
  4. Auto-registers the plugin in ~/.opentabs/config.json (first build only)
  5. Calls POST /reload to notify the running MCP server

After building, verify the server sees your plugin:

opentabs status

Development workflow

This is one of the things I'm most proud of — use watch mode for a fast iteration loop:

npm run dev

This runs tsc --watch and opentabs-plugin build --watch in parallel. When you save a file:

  1. tsc compiles to dist/index.js
  2. opentabs-plugin build detects the change and re-bundles the adapter
  3. The build notifies the MCP server via POST /reload
  4. The server pushes the updated adapter to the Chrome extension
  5. 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

  1. Start the MCP server if it's not running: opentabs start
  2. Open the target web app in Chrome and log in
  3. Check the extension side panel — your plugin should show as Ready
  4. Ask your AI agent to use a tool: "Use the my-app_get_items tool to list items"
  5. 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 publish

How users install your plugin

Users install globally and restart the server:

npm install -g opentabs-plugin-my-app
opentabs start

The 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 logs and opentabs 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