Back to Blog
Implementation

How to Build a BitSentry Code Plugin

Build a TypeScript code plugin for SuperTerminal with the BitSentry Plugin SDK: declare actions, handle credentials, test locally, and prepare a single-file artifact.

Agustinus Theodorus July 18, 2026 8 min read

BitSentry plugins are small TypeScript programs that add integrations and reusable runbook actions to SuperTerminal. A plugin can query an API, create a ticket, fetch incident context, or act as an error source. The desktop app supplies discovery, credential storage, input validation, and execution; your plugin supplies the integration-specific behavior.

The right starting point is @bitsentry/plugin-sdk. It is the public contract between a plugin and SuperTerminal. Do not import desktop internals such as @bitsentry-ce/core: those are application implementation details and are not a stable plugin boundary.

Current product boundary: code plugins run in SuperTerminal (BitSentry Desktop) only. The Dashboard has its own cloud runtime and built-in integrations; it does not download or execute desktop plugin artifacts. Keep shared API-client logic in a separate package if you need it in both products, but build a Dashboard integration through its native backend/worker path rather than presenting a desktop plugin as Dashboard-compatible.


What a plugin contains

A plugin exports one DesktopCodePlugin object. Its important parts are:

PartWhat it does
id, name, version, descriptionIdentifies the plugin in the UI and CLI. Keep the ID stable and filesystem-safe.
auth.fieldsDeclares saved credentials and configuration. Mark tokens and passwords with secret: true.
actionsDeclares work that can be selected in a runbook. Every action has typed fields, a read/write label, and an execute handler.
metadata.dataSource and dataSourceOptional. Use these when the plugin also provides an error-source integration and setup flow.

The runtime validates the descriptor, applies field defaults, layers saved credentials over per-call credentials, validates action input, and validates the result returned by execute. An action returns an HTTP-like status, a human-readable summary, optional data, and optionally ok (which defaults to true).

The first-party GitHub, Sentry, Wazuh, and PostHog plugins are useful patterns to study: they use the SDK type, keep auth and action fields explicit, return normalized data, and put URL validation close to the outbound request.

Start a plugin project

Create a small standalone TypeScript package. The SDK is intentionally the only BitSentry dependency your package should need. The first-party plugins also use Effect for bounded, cancellable API work; it is the recommended pattern for production integrations.

mkdir bitsentry-plugin-status
cd bitsentry-plugin-status
pnpm init
pnpm add effect
pnpm add -D typescript @types/node @bitsentry/plugin-sdk esbuild
mkdir src

Add a TypeScript configuration for checking the plugin source.

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2021",
    "module": "Node16",
    "moduleResolution": "Node16",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noEmit": true
  },
  "include": ["src/plugin.ts"]
}

Then add scripts that type-check and bundle a CommonJS artifact to package.json:

{
  "scripts": {
    "typecheck": "tsc -p tsconfig.json",
    "build": "pnpm run typecheck && esbuild src/plugin.ts --bundle --platform=node --format=cjs --target=node22 --outfile=dist/plugin.js"
  }
}

This produces dist/plugin.js with runtime dependencies such as Effect bundled into it. The installed artifact is a single plugin.js; SuperTerminal does not install a node_modules tree alongside it. A plain tsc build is useful for types, but is not a distributable artifact when the plugin has runtime dependencies.

Write your first action

Here is a small but complete plugin that calls an internal status endpoint. The SDK import is deliberately type-only, so it disappears from the compiled JavaScript artifact. Effect provides the request deadline and connects the host cancellation signal to fetch.

// src/plugin.ts
import type { DesktopCodePlugin, DesktopPluginOperationContext } from "@bitsentry/plugin-sdk";
import { Effect } from "effect";

const REQUEST_TIMEOUT_MS = 30_000;

function requiredString(value: unknown, field: string): string {
  if (typeof value !== "string" || value.trim().length === 0) {
    throw new Error(`${field} is required`);
  }

  return value.trim();
}

function timeoutMs(operation?: DesktopPluginOperationContext): number {
  if (typeof operation?.deadlineAt !== "number") {
    return REQUEST_TIMEOUT_MS;
  }

  return Math.max(0, Math.min(REQUEST_TIMEOUT_MS, operation.deadlineAt - Date.now()));
}

async function fetchStatus(
  url: URL,
  accessToken: string,
  operation?: DesktopPluginOperationContext
) {
  const timeout = timeoutMs(operation);

  return Effect.runPromise(
    Effect.tryPromise({
      try: async (effectSignal) => {
        const signals = [effectSignal, operation?.signal].filter(
          (signal): signal is AbortSignal => signal !== undefined
        );
        const response = await fetch(url, {
          headers: {
            Accept: "application/json",
            Authorization: `Bearer ${accessToken}`,
          },
          redirect: "error",
          signal: AbortSignal.any(signals),
        });
        const body = await response.text();

        if (!response.ok) {
          throw new Error(`Status API returned ${response.status}: ${body.slice(0, 300)}`);
        }

        return {
          status: response.status,
          data: JSON.parse(body),
        };
      },
      catch: (cause) => (cause instanceof Error ? cause : new Error("Status API request failed")),
    }).pipe(
      Effect.timeoutFail({
        duration: timeout,
        onTimeout: () => new Error(`Status API request timed out after ${timeout}ms`),
      })
    )
  );
}

const plugin: DesktopCodePlugin = {
  id: "acme-status",
  name: "Acme Status",
  version: "0.1.0",
  description: "Checks the health of an Acme service from a runbook.",
  auth: {
    fields: [
      {
        key: "baseUrl",
        label: "Status API URL",
        type: "string",
        required: true,
        placeholder: "https://status.acme.example",
      },
      {
        key: "accessToken",
        label: "API token",
        type: "string",
        required: true,
        secret: true,
      },
    ],
  },
  actions: [
    {
      id: "get_service_status",
      title: "Get service status",
      description: "Fetch the current status for one service.",
      riskLevel: "read",
      fields: [
        {
          key: "service",
          label: "Service",
          type: "string",
          required: true,
          placeholder: "api",
        },
      ],
      async execute({ auth, input, operation }) {
        const baseUrl = requiredString(auth.baseUrl, "baseUrl");
        const accessToken = requiredString(auth.accessToken, "accessToken");
        const service = requiredString(input.service, "service");
        const url = new URL(`/v1/services/${encodeURIComponent(service)}`, baseUrl);

        if (url.protocol !== "https:") {
          throw new Error("Status API URL must use https://");
        }

        const result = await fetchStatus(url, accessToken, operation);

        return {
          status: result.status,
          summary: `Fetched status for ${service}.`,
          data: result.data,
        };
      },
    },
  ],
};

export { plugin };
export default plugin;

Use riskLevel: "read" for actions that only retrieve information and riskLevel: "write" for actions that change external state. This is an accurate description of the action, not a security boundary: a runbook’s execution is the user’s explicit approval to run its steps.

Model inputs and credentials clearly

Fields drive both validation and the generated UI. The supported field types are string, number, boolean, json, and string_array. A field may be required, have a defaultValue, and, for strings, define enumValues.

Put reusable connection values in auth.fields; put values that vary each run in an action’s fields. For example, a GitHub token belongs in auth, while an issue title belongs in the create_issue action. The desktop host stores configured auth separately and passes it to execute as auth.

Treat all input as untrusted even after the runtime validates its shape. Check required strings, bound pagination values, URL-encode path segments, and validate the destination before sending credentials. The first-party plugins also restrict credential-bearing requests to approved HTTPS hosts; adopt the same allowlist pattern for every integration that can target a user-provided URL.

Every action receives an optional operation context. It may carry an abort signal, an absolute deadline, and an execution ID from the parent runbook or host operation. Follow the Effect pattern above for network and retry work: cap the request to the earlier of your plugin limit and the host deadline, pass the combined signal to fetch, and ensure retry waits are abortable. Plugins compiled against older SDK versions still work because this context is optional.

Never include a token, password, request authorization header, or complete upstream error body in summary, data, or logs. Mark secret fields with secret: true, but continue to design the action so a mistaken debug statement cannot expose credentials.

Add an error-source setup flow when you need one

Most plugins only need actions. An error-source plugin adds a metadata.dataSource declaration to describe setup UI and optional dataSource functions to translate setup values into the stored source configuration and action auth.

metadata: {
  dataSource: {
    sourceType: "acme",
    setupFields: [
      {
        key: "baseUrl",
        label: "Acme API URL",
        control: "text",
        required: true,
      },
      {
        key: "accessToken",
        label: "API token",
        control: "password",
        required: true,
      },
    ],
  },
},
dataSource: {
  resolveSetup({ setupValues }) {
    return {
      accessTokenRef: String(setupValues.accessToken ?? ""),
      configuration: { baseUrl: setupValues.baseUrl },
    };
  },
  buildAuth({ source }) {
    return {
      ...source.configuration,
      accessToken: source.accessTokenRef,
    };
  },
}

Use the Wazuh, Sentry, and PostHog plugins as the fuller references for this shape. Keep resolveSetup, buildAuth, and buildProbeAuth small and deterministic: their job is to map stored source state to the credentials and configuration your query actions expect.

Build and test it locally

Build the plugin first:

pnpm run build

For local discovery, place the artifact beneath a plugin directory. The loader recognizes a child directory containing plugin.js (or dist/plugin.js).

mkdir -p .bitsentry/plugins/acme-status
cp dist/plugin.js .bitsentry/plugins/acme-status/plugin.js

bitsentry plugin list --plugin-dir .bitsentry/plugins
bitsentry plugin execute \
  --plugin-id acme-status \
  --action-id get_service_status \
  --plugin-dir .bitsentry/plugins \
  --auth-json '{"baseUrl":"https://status.acme.example","accessToken":"replace-me"}' \
  --input-json '{"service":"api"}'

Also add ordinary unit tests around URL construction, auth parsing, pagination, and error responses. You can validate the exported object without the desktop app by calling desktopCodePluginSchema.parse(plugin) in a test. The SDK catches contract mistakes early; tests should catch your integration’s behavior.

Distribution and trust

The public install command, bitsentry plugin install <name>, downloads the latest single-file artifact from BitSentry’s first-party index. Third-party publishing to that index is not open in the current release, and there is no version-selection or version-history interface yet. Local discovery is the supported way to develop and evaluate a plugin; contact BitSentry before planning a first-party listing.

This restriction matters because code plugins execute in the desktop process with the application’s privileges. Install only code you trust. A plugin is not sandboxed, and riskLevel does not limit what its JavaScript can do.

Before you share a plugin

  • Use only @bitsentry/plugin-sdk for BitSentry contracts; do not couple to desktop internals.
  • Export one valid default or named plugin object, type-check it, and bundle a CommonJS plugin.js artifact with esbuild.
  • Give every auth and action field a clear label, type, required state, and safe default where appropriate.
  • Mark credentials as secrets, validate outbound URLs, and never write secrets to output or logs.
  • Use read/write risk levels honestly and make write actions narrowly scoped and idempotent where possible.
  • Use Effect for production API calls so host cancellation and request deadlines reach fetch; test cancellation, timeouts, retries, success, bad input, and upstream failures.
  • Keep Dashboard support separate: share API-client code if useful, but implement Dashboard execution in the Dashboard’s backend and worker runtime.

That is the whole contract: TypeScript code plus the Plugin SDK, compiled into one artifact. It keeps the integration portable and lets SuperTerminal give it a consistent configuration and runbook experience.

Try BitSentry Desktop free

Uses your existing access and your own AI keys. Set up in under 5 minutes.

Tags

BitSentry Desktop SuperTerminal plugins TypeScript runbooks integrations