Skip to main content

Utilities

I wrote these utilities because the same boilerplate kept showing up in every plugin: wait for this element, fetch that URL with session cookies, read this localStorage key. Rather than copy-paste the same patterns, they're first-class utilities in the SDK. They all run in the browser page context and are exported from @opentabs-dev/plugin-sdk.

import {
  waitForSelector, waitForSelectorRemoval, querySelectorAll, getTextContent, getMetaContent, observeDOM,
  fetchFromPage, fetchJSON, postJSON, postForm, postFormData, putJSON, patchJSON, deleteJSON,
  fetchText, buildQueryString, httpStatusToToolError, parseRetryAfterMs, parseRateLimitHeader, stripUndefined,
  getLocalStorage, setLocalStorage, removeLocalStorage,
  getSessionStorage, setSessionStorage, removeSessionStorage,
  getCookie,
  getAuthCache, setAuthCache, clearAuthCache, findLocalStorageEntry,
  getPageGlobal, getCurrentUrl, getPageTitle,
  retry, sleep, waitUntil,
  log,
} from '@opentabs-dev/plugin-sdk';

DOM Utilities

Functions for waiting on elements, querying the DOM, and observing mutations.

waitForSelector(selector, opts?)

Waits for an element matching selector to appear in the DOM. Uses MutationObserver for efficient detection, with an immediate querySelector check for elements already present.

function waitForSelector<T extends Element = Element>(selector: string, opts?: WaitForSelectorOptions): Promise<T>;
OptionTypeDefaultDescription
timeoutnumber10000Timeout in milliseconds. Throws an Error if the element does not appear within this time.
signalAbortSignalCancel the wait early. Rejects with the signal's abort reason.
// Wait for the dashboard to load before interacting
const dashboard = await waitForSelector('.dashboard-loaded');
 
// Short timeout for elements that should appear quickly
const header = await waitForSelector('#app-header', { timeout: 3000 });

waitForSelectorRemoval(selector, opts?)

Waits for an element matching selector to be removed from the DOM. Resolves immediately if no matching element exists.

function waitForSelectorRemoval(selector: string, opts?: WaitForSelectorOptions): Promise<void>;

Takes the same timeout and signal options as waitForSelector (timeout default: 10000ms).

// Wait for a loading spinner to disappear
await waitForSelectorRemoval('.loading-spinner');
 
// Then interact with the loaded content
const items = querySelectorAll('.list-item');

querySelectorAll(selector)

Typed wrapper around document.querySelectorAll that returns a real array instead of a NodeList.

function querySelectorAll<T extends Element = Element>(selector: string): T[];
// Returns a real array — use .map(), .filter(), .find() directly
const rows = querySelectorAll<HTMLTableRowElement>('table tbody tr');
const names = rows.map(row => row.cells[0]?.textContent?.trim() ?? '');

getTextContent(selector)

Returns the trimmed textContent of the first element matching selector, or null if no element is found.

function getTextContent(selector: string): string | null;
const username = getTextContent('.profile-name');
const count = getTextContent('.notification-badge');

getMetaContent(name)

Returns the content attribute of the <meta> tag with the given name, or null if the tag is not found or has no content attribute. Uses CSS.escape to safely handle special characters in the name.

function getMetaContent(name: string): string | null;
// Read CSRF tokens or app configuration from <meta> tags
const csrfToken = getMetaContent('csrf-token');
const apiBase = getMetaContent('api-base-url');

observeDOM(selector, callback, options?)

Sets up a MutationObserver on the element matching selector. Returns a cleanup function that disconnects the observer. Throws if no element matches the selector.

function observeDOM(
  selector: string,
  callback: (mutations: MutationRecord[], observer: MutationObserver) => void,
  options?: ObserveDOMOptions,
): () => void;
OptionTypeDefaultDescription
childListbooleantrueWatch for added/removed child nodes.
attributesbooleanfalseWatch for attribute changes.
subtreebooleantrueWatch the entire subtree.
// Watch for new messages in a chat container
const cleanup = observeDOM('#message-list', (mutations) => {
  for (const mutation of mutations) {
    for (const node of mutation.addedNodes) {
      if (node instanceof HTMLElement && node.classList.contains('message')) {
        // Handle new message
      }
    }
  }
});
 
// Later: disconnect the observer
cleanup();

Fetch Utilities

Functions for making HTTP requests using the page's authenticated session. All fetch utilities use credentials: 'include' to send session cookies automatically.

FunctionMethodDescription
fetchFromPageAnyLow-level fetch — returns Response, handles timeout and errors.
fetchJSONAnyFetch and parse a JSON response.
postJSONPOSTPOST with a JSON body, parse JSON response.
postFormPOSTPOST with a URL-encoded form body (application/x-www-form-urlencoded), parse JSON response.
postFormDataPOSTPOST with a multipart/form-data body (file uploads), parse JSON response.
putJSONPUTPUT with a JSON body, parse JSON response.
patchJSONPATCHPATCH with a JSON body, parse JSON response.
deleteJSONDELETEDELETE, parse JSON response.
fetchTextAnyFetch and return the response body as a string (for diffs, logs, raw content).
stripUndefinedFilter out keys with undefined values from an object. Keeps null, 0, false, and empty string.
buildQueryStringBuild a URL query string from a record, filtering out undefined values. Array values can be string[], number[], or boolean[].
httpStatusToToolErrorMap an HTTP error response to a ToolError with the appropriate category.
parseRetryAfterMsParse a Retry-After header value into milliseconds.
parseRateLimitHeaderCheck multiple rate limit headers and normalize to milliseconds.

fetchFromPage(url, init?)

Fetches a URL using the page's session cookies. Provides built-in timeout and throws a ToolError on non-ok HTTP status codes.

function fetchFromPage(url: string, init?: FetchFromPageOptions): Promise<Response>;

FetchFromPageOptions extends the standard RequestInit with:

OptionTypeDefaultDescription
timeoutnumber30000Request timeout in milliseconds. Throws a ToolError with code 'TIMEOUT' on expiry.

If you pass a signal in init, it is combined with the timeout signal via AbortSignal.any() — either can cancel the request.

// GET request with page cookies
const response = await fetchFromPage('/api/users');
const users = await response.json();
 
// POST with custom timeout
const response = await fetchFromPage('/api/export', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ format: 'csv' }),
  timeout: 60_000,
});

fetchJSON(url, init?, schema?)

Fetches a URL and parses the response as JSON. Uses the page's session cookies and provides timeout + error handling.

// Unchecked cast (backward compatible)
function fetchJSON<T>(url: string, init?: FetchFromPageOptions): Promise<T | undefined>;
 
// Zod schema validation — validates the parsed JSON and returns the typed result
function fetchJSON<T extends z.ZodType>(
  url: string, init: FetchFromPageOptions | undefined, schema: T,
): Promise<z.infer<T>>;

Throws a ToolError with code 'VALIDATION_ERROR' if the response is not valid JSON, or if the response fails Zod schema validation when a schema is provided.

// Simple usage with type cast
interface Channel { id: string; name: string }
const channels = await fetchJSON<Channel[]>('/api/channels');
 
// Type-safe usage with Zod schema validation
import { z } from 'zod';
const channelSchema = z.object({ id: z.string(), name: z.string() });
const channels = await fetchJSON('/api/channels', undefined, z.array(channelSchema));
// channels is typed as { id: string; name: string }[] — validated at runtime

postJSON(url, body, init?, schema?)

Convenience wrapper for POST requests with a JSON body. Sets Content-Type: application/json, stringifies the body, and parses the JSON response.

// Unchecked cast (backward compatible)
function postJSON<T>(url: string, body: unknown, init?: FetchFromPageOptions): Promise<T | undefined>;
 
// Zod schema validation — validates the parsed JSON and returns the typed result
function postJSON<T extends z.ZodType>(
  url: string, body: unknown, init: FetchFromPageOptions | undefined, schema: T,
): Promise<z.infer<T>>;
const result = await postJSON<{ ok: boolean }>('/api/messages', {
  channel: 'general',
  text: 'Hello from OpenTabs!',
});
 
// With Zod schema validation
import { z } from 'zod';
const responseSchema = z.object({ ok: z.boolean(), ts: z.string() });
const result = await postJSON('/api/messages', { channel: 'general', text: 'Hi!' }, undefined, responseSchema);
// result is typed as { ok: boolean; ts: string } — validated at runtime

postForm(url, body, init?, schema?)

Convenience wrapper for POST requests with a URL-encoded form body. Sets Content-Type: application/x-www-form-urlencoded, serializes the body using URLSearchParams, and parses the JSON response.

// Unchecked cast (backward compatible)
function postForm<T>(url: string, body: Record<string, string>, init?: FetchFromPageOptions): Promise<T | undefined>;
 
// Zod schema validation — validates the parsed JSON and returns the typed result
function postForm<T extends z.ZodType>(
  url: string, body: Record<string, string>, init: FetchFromPageOptions | undefined, schema: T,
): Promise<z.infer<T>>;
// Submit a URL-encoded form
const result = await postForm<{ ok: boolean }>('/api/auth/token', {
  grant_type: 'authorization_code',
  code: authCode,
  redirect_uri: 'https://example.com/callback',
});

postFormData(url, body, init?, schema?)

Convenience wrapper for POST requests with a multipart/form-data body. Does not set Content-Type manually — the browser sets it automatically with the multipart boundary string. Parses the JSON response.

// Unchecked cast (backward compatible)
function postFormData<T>(url: string, body: FormData, init?: FetchFromPageOptions): Promise<T | undefined>;
 
// Zod schema validation — validates the parsed JSON and returns the typed result
function postFormData<T extends z.ZodType>(
  url: string, body: FormData, init: FetchFromPageOptions | undefined, schema: T,
): Promise<z.infer<T>>;
// Upload a file via multipart form data
const formData = new FormData();
formData.append('file', fileBlob, 'report.csv');
formData.append('channels', 'C12345');
 
const result = await postFormData<{ ok: boolean; file: { id: string } }>('/api/files.upload', formData);

putJSON(url, body, init?, schema?)

Convenience wrapper for PUT requests with a JSON body. Sets Content-Type: application/json, stringifies the body, and parses the JSON response. When a Zod schema is provided as the fourth argument, the parsed JSON is validated against it.

function putJSON<T>(url: string, body: unknown, init?: FetchFromPageOptions): Promise<T | undefined>;
function putJSON<T extends z.ZodType>(
  url: string, body: unknown, init: FetchFromPageOptions | undefined, schema: T,
): Promise<z.infer<T>>;
const updated = await putJSON<{ ok: boolean }>('/api/users/123', {
  name: 'New Name',
  role: 'admin',
});

patchJSON(url, body, init?, schema?)

Convenience wrapper for PATCH requests with a JSON body. Sets Content-Type: application/json, stringifies the body, and parses the JSON response. When a Zod schema is provided as the fourth argument, the parsed JSON is validated against it.

function patchJSON<T>(url: string, body: unknown, init?: FetchFromPageOptions): Promise<T | undefined>;
function patchJSON<T extends z.ZodType>(
  url: string, body: unknown, init: FetchFromPageOptions | undefined, schema: T,
): Promise<z.infer<T>>;
const patched = await patchJSON<{ ok: boolean }>('/api/users/123', {
  status: 'active',
});

deleteJSON(url, init?, schema?)

Convenience wrapper for DELETE requests. Parses the JSON response. Unlike postJSON/putJSON/patchJSON, there is no body parameter. When a Zod schema is provided as the third argument, the parsed JSON is validated against it.

function deleteJSON<T>(url: string, init?: FetchFromPageOptions): Promise<T | undefined>;
function deleteJSON<T extends z.ZodType>(
  url: string, init: FetchFromPageOptions | undefined, schema: T,
): Promise<z.infer<T>>;
const result = await deleteJSON<{ ok: boolean }>('/api/users/123');
// result may be undefined for 204 No Content responses

fetchText(url, init?)

Fetches a URL and returns the response body as a string instead of JSON. Uses the page's session cookies and provides the same timeout and error handling as fetchFromPage. Returns an empty string for 204 No Content responses.

function fetchText(url: string, init?: FetchFromPageOptions): Promise<string>;
// Fetch raw text content — diffs, logs, plain-text API responses
const diff = await fetchText('/api/pull-requests/42/diff');
 
// Fetch a job log with a custom timeout
const log = await fetchText('/api/jobs/123/log', { timeout: 60_000 });

stripUndefined(obj)

Filters out keys with undefined values from an object. Keeps null, 0, false, and empty string — only undefined is removed. Useful for building request bodies with optional fields without manual if checks.

function stripUndefined<T extends Record<string, unknown>>(obj: T): Partial<T>;
// Build a request body with optional fields — no manual if-undefined checks needed
const { task_id, ...updates } = params;
await postJSON(`/api/tasks/${task_id}`, stripUndefined(updates));
 
// Only undefined is removed — null, 0, false, and "" are preserved
stripUndefined({ a: 1, b: undefined, c: null, d: 0, e: false, f: '' });
// → { a: 1, c: null, d: 0, e: false, f: '' }

buildQueryString(params)

Builds a URL query string from a record of key-value pairs. Filters out undefined values, converts numbers and booleans to strings, and supports arrays (appending multiple values for the same key). Array values can be string[], number[], or boolean[] — all items are converted to strings. Returns the query string without a leading ?.

function buildQueryString(
  params: Record<string, string | number | boolean | (string | number | boolean)[] | undefined>,
): string;
// Build a query string for an API call
const qs = buildQueryString({
  channel: 'C12345',
  limit: 50,
  include_all_metadata: true,
  cursor: undefined, // omitted from the output
});
// → "channel=C12345&limit=50&include_all_metadata=true"
 
const messages = await fetchJSON<Message[]>(`/api/messages?${qs}`);
 
// Array values append multiple entries for the same key
const qs2 = buildQueryString({ types: ['public_channel', 'private_channel'] });
// → "types=public_channel&types=private_channel"

httpStatusToToolError(response, message)

Maps an HTTP error response to a ToolError with the appropriate category. Status code mapping:

| Status | Category | retryable | Notes | | --- | --- | --- | --- | | 401, 403 | auth | false | | | 404 | not_found | false | | | 400, 422 | validation | false | | | 408 | timeout | true | | | 429 | rate_limit | true | Parses Retry-After header into retryAfterMs | | 4xx (other) | — | false | Other client errors not individually listed above | | 503 | internal | true | Parses Retry-After header into retryAfterMs | | 5xx (other) | internal | true | Server errors are retryable | | Other | internal | false | Not retryable |

function httpStatusToToolError(response: Response, message: string): ToolError;
const response = await fetch('/api/data', { credentials: 'include' });
if (!response.ok) {
  throw httpStatusToToolError(response, `Failed to fetch data: HTTP ${response.status}`);
}

parseRetryAfterMs(value)

Parses a Retry-After header value into milliseconds. Accepts both seconds (e.g., "120") and HTTP-date formats. Returns undefined if the value cannot be parsed or represents a time in the past.

function parseRetryAfterMs(value: string): number | undefined;
const retryAfter = response.headers.get('Retry-After');
if (retryAfter) {
  const ms = parseRetryAfterMs(retryAfter);
  if (ms) await sleep(ms);
}

parseRateLimitHeader(headers)

Checks multiple common rate limit header names and normalizes them to milliseconds. Checks headers in order: Retry-After, x-rate-limit-reset (X/Twitter — Unix epoch in seconds), x-ratelimit-reset (Reddit — seconds until reset), and RateLimit-Reset (IETF draft — seconds). Returns undefined if no rate limit header is found or the value is invalid.

function parseRateLimitHeader(headers: Headers): number | undefined;
// Handle rate limiting from any API — checks all common header formats
const response = await fetchFromPage('/api/data');
if (response.status === 429) {
  const retryMs = parseRateLimitHeader(response.headers);
  if (retryMs) throw ToolError.rateLimited('Rate limited', retryMs);
  throw ToolError.rateLimited('Rate limited');
}

Unlike parseRetryAfterMs which parses a single header value, parseRateLimitHeader takes the entire Headers object and checks multiple header names used by different APIs (Twitter, Reddit, standard IETF).


Storage Utilities

Safe wrappers around browser storage APIs that handle SecurityError exceptions in sandboxed iframes.

getLocalStorage(key)

Reads a value from localStorage. Returns null if the key is not found or if storage access throws.

function getLocalStorage(key: string): string | null;

setLocalStorage(key, value)

Writes a value to localStorage. Logs a warning via log.warn() if storage access throws (SecurityError or QuotaExceededError).

function setLocalStorage(key: string, value: string): void;

removeLocalStorage(key)

Removes a key from localStorage. Logs a warning via log.warn() if storage access throws (e.g., SecurityError in sandboxed iframes).

function removeLocalStorage(key: string): void;

getSessionStorage(key)

Reads a value from sessionStorage. Returns null if the key is not found or if storage access throws.

function getSessionStorage(key: string): string | null;

setSessionStorage(key, value)

Writes a value to sessionStorage. Logs a warning via log.warn() if storage access throws (SecurityError or QuotaExceededError).

function setSessionStorage(key: string, value: string): void;

removeSessionStorage(key)

Removes a key from sessionStorage. Logs a warning via log.warn() if storage access throws (e.g., SecurityError in sandboxed iframes).

function removeSessionStorage(key: string): void;

getCookie(name)

Reads a cookie by name from document.cookie. Handles URI-encoded values. Returns null if the cookie is not found.

function getCookie(name: string): string | null;
// Check for an auth token stored in different locations
const token =
  getLocalStorage('auth_token') ??
  getSessionStorage('auth_token') ??
  getCookie('auth_token');

getAuthCache(namespace)

Reads a cached value from globalThis.__openTabs.tokenCache[namespace]. Returns null if the namespace is not found or if access throws. The generic type parameter allows retrieving both primitive strings and complex objects.

function getAuthCache<T>(namespace: string): T | null;
// Retrieve a cached API token
const token = getAuthCache<string>('slack-api-token');
 
// Retrieve a complex cached auth object
interface AuthData { token: string; expiresAt: number }
const auth = getAuthCache<AuthData>('my-plugin-auth');

setAuthCache(namespace, value)

Writes a value to globalThis.__openTabs.tokenCache[namespace]. Initializes the __openTabs and tokenCache objects if they don't exist. Silently handles errors.

function setAuthCache<T>(namespace: string, value: T): void;
// Cache an extracted API token for reuse across tool invocations
const token = getPageGlobal('TS.boot_data.api_token') as string | undefined;
if (token) {
  setAuthCache('slack-api-token', token);
}

clearAuthCache(namespace)

Clears the cached value at globalThis.__openTabs.tokenCache[namespace]. Silently handles errors.

function clearAuthCache(namespace: string): void;
// Clear a cached token when it's known to be expired
clearAuthCache('slack-api-token');

findLocalStorageEntry(predicate)

Searches localStorage keys using a predicate function and returns the first matching entry as { key, value }. Returns null if no match is found or if localStorage is inaccessible. Uses the same iframe fallback as getLocalStorage for environments where localStorage is deleted (e.g., Discord).

function findLocalStorageEntry(
  predicate: (key: string) => boolean,
): { key: string; value: string } | null;
// Find a localStorage entry with a dynamic key prefix
const entry = findLocalStorageEntry((key) => key.startsWith('auth_token_'));
if (entry) {
  const token = JSON.parse(entry.value);
}
 
// Find a key matching a pattern
const session = findLocalStorageEntry((key) => key.includes('session'));

Page State Utilities

Functions for reading global state and metadata from the current page.

getPageGlobal(path)

Safe deep property access on globalThis using dot-notation. Returns undefined if any segment in the path is missing or if a getter throws.

function getPageGlobal(path: string): unknown;
// Access deeply nested page globals safely
const token = getPageGlobal('TS.boot_data.api_token') as string | undefined;
const userId = getPageGlobal('App.currentUser.id') as number | undefined;
const config = getPageGlobal('__APP_CONFIG__.features') as Record<string, boolean> | undefined;

getCurrentUrl()

Returns the current page URL (window.location.href).

function getCurrentUrl(): string;

getPageTitle()

Returns the current page title (document.title).

function getPageTitle(): string;

Timing Utilities

Functions for retrying operations, sleeping, and polling conditions.

retry(fn, opts?)

Retries fn on failure up to maxAttempts times. Waits delay ms between attempts (doubled each time when backoff is true). Re-throws the last error after all attempts are exhausted.

function retry<T>(fn: () => Promise<T>, opts?: RetryOptions): Promise<T>;
OptionTypeDefaultDescription
maxAttemptsnumber3Maximum number of attempts (including the first call).
delaynumber1000Delay between attempts in milliseconds.
backoffbooleanfalseUse exponential backoff — doubles the delay after each attempt (1s, 2s, 4s, ...).
maxDelaynumber30000Maximum delay in milliseconds when using exponential backoff. Prevents unreasonably long wait times.
signalAbortSignalCancel retries early. Checks before each attempt and before each delay.
// Retry a flaky API call with exponential backoff
const data = await retry(
  () => fetchJSON<UserData>('/api/user/profile'),
  { maxAttempts: 5, delay: 500, backoff: true },
);
 
// Retry with cancellation
const controller = new AbortController();
const data = await retry(
  () => fetchJSON<SearchResults>('/api/search?q=test'),
  { maxAttempts: 3, signal: controller.signal },
);

sleep(ms, opts?)

Returns a promise that resolves after ms milliseconds. Optionally accepts an AbortSignal to cancel the sleep early.

function sleep(ms: number, opts?: SleepOptions): Promise<void>;
OptionTypeDefaultDescription
signalAbortSignalCancel the sleep early. Rejects with the signal's abort reason.
// Wait 2 seconds between operations
await doFirstThing();
await sleep(2000);
await doSecondThing();
 
// Cancel a sleep early with an AbortSignal
const controller = new AbortController();
setTimeout(() => controller.abort(), 1000);
await sleep(5000, { signal: controller.signal }); // resolves/rejects after ~1s

waitUntil(predicate, opts?)

Polls predicate at a regular interval until it returns true. Rejects on timeout. The predicate can be sync or async — errors thrown by the predicate are caught and treated as false.

function waitUntil(
  predicate: () => boolean | Promise<boolean>,
  opts?: WaitUntilOptions,
): Promise<void>;
OptionTypeDefaultDescription
intervalnumber200Polling interval in milliseconds.
timeoutnumber10000Timeout in milliseconds. Throws an Error if the predicate doesn't return true in time.
signalAbortSignalCancel polling early. The signal is checked between polling intervals.
// Wait for a page global to be set
await waitUntil(() => getPageGlobal('App.ready') === true);
 
// Wait for an element to have specific content
await waitUntil(
  () => getTextContent('.status-badge') === 'Connected',
  { interval: 500, timeout: 15_000 },
);
 
// Cancel polling after 5 seconds
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
await waitUntil(
  () => getTextContent('.status') === 'Ready',
  { signal: controller.signal },
);

Logging

The log namespace provides structured logging that routes entries to the MCP server when running inside the adapter runtime, or to the browser console in standalone contexts (unit tests, etc.).

import { log } from '@opentabs-dev/plugin-sdk';

Methods

MethodMCP LevelDescription
log.debug(message, ...args)debugVerbose details for debugging. Not shown by default in most MCP clients.
log.info(message, ...args)infoNormal operational messages.
log.warn(message, ...args)warningRecoverable issues that don't prevent the operation from completing.
log.error(message, ...args)errorErrors that cause the operation to fail.

Arguments are safely serialized before transport — circular references, DOM nodes, functions, symbols, bigints, and errors are all handled without throwing. Strings are truncated at 4096 characters, and a maximum of 10 arguments are accepted per call.

log.info('Fetching channels', { workspaceId: 'T12345' });
log.warn('Rate limited, retrying', { retryAfterMs: 2000 });
log.error('Failed to send message', error);
log.debug('Raw API response', { status: 200, body: responseData });

The log object is frozen — it cannot be reassigned or extended. See the Logging & Debugging guide for the full transport pipeline and debugging workflows.

Last Updated: 10 Mar, 2026