| name | caido-plugin-development |
| description | Guidance for building Caido plugins — what Caido is, the backend/frontend/shared monorepo structure, the typed plugin contract (Spec via @caido/sdk-shared, the shared API and Events types, Result/ok/err), Backend SDK patterns (registering API endpoints, init, events), Frontend SDK patterns (mounting a page with navigation.addPage, sidebar items, commands, menus, sending HTTP requests), findings, code-quality and naming conventions, the linter, and SDK API validation. Use whenever writing or editing code in a Caido plugin (anything under packages/backend, packages/frontend, or packages/shared, or caido.config.ts). |
Caido Plugin Development
What is Caido
Caido is a lightweight web application security auditing toolkit designed to help security professionals audit web applications with efficiency and ease.
Key features include:
- HTTP proxy for intercepting and viewing requests in real-time
- Replay functionality for resending and modifying requests to test endpoints
- Automate feature for testing requests against wordlists
- Match & Replace for automatically modifying requests with regex rules
- HTTPQL query language for filtering through HTTP traffic
- Workflow system for creating custom encoders/decoders and plugins
- Project management for organizing different security assessments
Environment
We are running in a plugin environment where we interact with Caido through the Caido Backend or Frontend SDK.
Plugin Structure
Modern Caido plugins are pnpm monorepos with up to three packages:
packages/shared → Shared, framework-agnostic code: the typed plugin contract (API + Events), the Result type, and data schemas/types. Imported by BOTH backend and frontend as shared.
packages/backend → Backend plugin code: server-side logic, data processing, and the API endpoint implementations.
packages/frontend → Frontend plugin code: UI components, user interactions, and calls to the backend.
A plugin needs at least one of backend/frontend. shared is what keeps the two sides type-safe against each other.
Plugin Development
Plugins are described by a caido.config.ts file that declares each sub-plugin (kind: "backend" | "frontend", its id, and root), and the frontend's backend: { id } link. They are packaged together as a single installable plugin.
Key Development Concepts
- The
shared package defines the contract; backend implements it; frontend calls it. Communication is typed end-to-end.
- Frontend plugins create pages, UI components, and handle user interactions.
- Backend plugins register API endpoints (and can emit events) that the frontend consumes.
Toolchain (mise)
This plugin pins its Node.js and pnpm versions with mise in mise.toml at the repo root. Use mise so you build and run with the exact toolchain CI uses — don't rely on a globally-installed Node/pnpm:
- Install the pinned tools once with
mise install.
- Run package scripts through the mise-managed toolchain. Either activate mise in your shell (so
pnpm ... resolves to the pinned version) or prefix commands with mise exec --, e.g. mise exec -- pnpm install, mise exec -- pnpm build.
mise.toml is the single source of truth for tool versions — CI reads it too (via jdx/mise-action). If a version needs to change, edit mise.toml; don't hardcode versions elsewhere.
Package scripts (run with pnpm under the mise toolchain): build, watch, typecheck, lint, knip.
Plugin API Contract (the shared package)
The contract lives in shared and is consumed by both sides. The current SDK builds it from @caido/sdk-shared's DefinePluginPackageSpec.
shared/src/result.ts — the Result type and helpers used everywhere errors can occur:
export type Result<T> =
| { kind: "Ok"; value: T }
| { kind: "Error"; error: string };
export function ok<T>(value: T): Result<T> {
return { kind: "Ok", value };
}
export function err<T>(error: string): Result<T> {
return { kind: "Error", error };
}
shared/src/api.ts — the API type. IMPORTANT: these signatures are the frontend-facing call shapes and do NOT include the sdk parameter. The backend implementations take sdk as their first argument; sdk.api.register bridges the two.
import type { Result } from "./result";
export type API = {
getConfig: () => Result<Config>;
updateConfig: (updates: UpdateConfig) => Promise<Result<Config>>;
createSession: (providerId?: string) => Promise<Result<Session>>;
};
shared/src/events.ts — optional events the backend can emit to the frontend, as a map of name → payload callback:
export type Events = {
"session:created": (session: Session) => void;
"config:updated": (config: Config) => void;
};
shared/src/index.ts — assemble the Spec and re-export the public API:
import type { DefinePluginPackageSpec } from "@caido/sdk-shared";
import type { API } from "./api";
import type { Events } from "./events";
export { type Result, ok, err } from "./result";
export type { API } from "./api";
export type { Events } from "./events";
export type Spec = DefinePluginPackageSpec<{
manifestId: "my-plugin";
api: API;
events: Events;
}>;
Older plugins (and the current create-plugin templates) instead use DefineAPI<{ fn: typeof fn }> and DefineEvents<{...}> imported directly from caido:plugin, with SDK<API, BackendEvents> / Caido<API, BackendEvents>. Prefer the shared + Spec approach above for new code.
Backend SDK
The backend implements the API and is typed with SDK<Spec>.
backend/src/types.ts:
import type { SDK } from "caido:plugin";
import type { Spec } from "shared";
export type BackendSDK = SDK<Spec>;
backend/src/index.ts — init registers the endpoints (it may be async to do setup) and can subscribe to lifecycle events:
import type { SDK } from "caido:plugin";
import type { Spec } from "shared";
import { getConfig, updateConfig } from "./api";
export async function init(sdk: SDK<Spec>) {
sdk.api.register("getConfig", getConfig);
sdk.api.register("updateConfig", updateConfig);
sdk.events.onProjectChange(async () => {
});
}
API implementations take sdk as the FIRST argument (even though the shared API type omits it) and return a Result. Prefer Result over throwing so the frontend can handle errors without try/catch:
import type { SDK } from "caido:plugin";
import type { Config, Result, UpdateConfig } from "shared";
import { err, ok } from "shared";
export function getConfig(_sdk: SDK): Result<Config> {
return ok(configStore.getConfig());
}
export async function updateConfig(
sdk: SDK,
updates: UpdateConfig,
): Promise<Result<Config>> {
try {
await configStore.updateConfig(updates);
return ok(configStore.getConfig());
} catch (e) {
return err(e instanceof Error ? e.message : String(e));
}
}
Frontend SDK
The frontend is typed with Caido<Spec> (a single type parameter — the Spec carries both the API and the events).
frontend/src/types.ts:
import type { Caido } from "@caido/sdk-frontend";
import type { Spec } from "shared";
export type FrontendSDK = Caido<Spec>;
frontend/src/index.ts — build your UI, mount it onto a root element, then register the page with navigation.addPage and add a sidebar entry pointing at the same path:
import { type FrontendSDK } from "./types";
export const init = (sdk: FrontendSDK) => {
const root = document.createElement("div");
Object.assign(root.style, { height: "100%", width: "100%" });
sdk.navigation.addPage("/my-plugin", { body: root });
sdk.sidebar.registerItem("My Plugin", "/my-plugin", {
icon: "fas fa-rocket",
});
};
Calling the Backend
Backend endpoints are available on sdk.backend, typed from the API. Handle the Result — no try/catch needed:
const result = await sdk.backend.updateConfig(updates);
if (result.kind === "Error") {
sdk.window.showToast(result.error, { variant: "error" });
return;
}
const config = result.value;
sdk.window.showToast("Saved", { variant: "success" });
Command Pattern
Commands provide a unified way to register actions triggered from:
- Command palette (Ctrl/Cmd+Shift+P)
- Context menus (right-click)
- UI buttons
- Keyboard shortcuts
Commands are a frontend-only concept.
const Commands = {
generateUrl: "my-plugin.generate-url",
} as const;
sdk.commands.register(Commands.generateUrl, {
name: "Generate OAST URL",
run: async () => {
const result = await sdk.backend.createSession();
if (result.kind === "Ok") {
await navigator.clipboard.writeText(result.value.url);
sdk.window.showToast("URL copied", { variant: "success" });
} else {
sdk.window.showToast(result.error, { variant: "error" });
}
},
group: "My Plugin",
});
sdk.commandPalette.register(Commands.generateUrl);
sdk.menu.registerItem({
type: "Request",
commandId: Commands.generateUrl,
leadingIcon: "fas fa-cog",
});
Working with Requests and Responses
import { RequestSpec } from "caido:utils";
const spec = new RequestSpec("https://api.example.com/data");
spec.setMethod("POST");
spec.setHeader("Content-Type", "application/json");
spec.setBody(JSON.stringify({ key: "value" }));
const { request, response } = await sdk.requests.send(spec);
if (response) {
const statusCode = response.getCode();
const body = response.getBody()?.toText();
}
For viewing/editing HTTP data in the UI, create editors:
const reqEditor = sdk.ui.httpRequestEditor();
const respEditor = sdk.ui.httpResponseEditor();
const reqElement = reqEditor.getElement();
const respElement = respEditor.getElement();
Findings SDK
Findings create alerts when Caido detects notable characteristics in requests/responses. From the backend, use sdk.findings.create:
await sdk.findings.create({
title: `Success Response ${response.getCode()}`,
description: `Request ID: ${request.getId()}\nResponse Code: ${response.getCode()}`,
reporter: "Response Logger Plugin",
request: request,
dedupeKey: `${request.getPath()}-${response.getCode()}`,
});
From the frontend, the equivalent is sdk.findings.createFinding(requestId, { title, description, reporter }).
Important Caido SDK Types
export type Request = {
getId(): ID;
getHost(): string;
getPort(): number;
getTls(): boolean;
getMethod(): string;
getPath(): string;
getQuery(): string;
getUrl(): string;
getHeaders(): Record<string, Array<string>>;
getHeader(name: string): Array<string> | undefined;
getBody(): Body | undefined;
getRaw(): RequestRaw;
getCreatedAt(): Date;
toSpec(): RequestSpec;
toSpecRaw(): RequestSpecRaw;
};
export type Response = {
getId(): ID;
getCode(): number;
getHeaders(): Record<string, Array<string>>;
getHeader(name: string): Array<string> | undefined;
getBody(): Body | undefined;
getRaw(): ResponseRaw;
getRoundtripTime(): number;
getCreatedAt(): Date;
};
For Body and Raw you can use methods like getBody()?.toText() to extract text content. Import these types from caido:utils:
import { type Request, type Response } from "caido:utils";
Code Quality Best Practices
TypeScript and Code Quality
- Use TypeScript for all files.
- Only use types, not interfaces.
- Do not use
any type.
- Use
undefined over null.
- Do not add any comments to the code that you generate.
- Don't cast to
any.
- Don't unnecessarily add
try/catch.
- Use
computed for derived state instead of reactive variables when possible.
- Use
knip to remove unused code if making large changes.
- When refactoring, avoid creating alias types like this:
export type Options = ScanConfig;
Instead, actually rename the type and fix all occurrences if needed.
Structure, Naming, and Organization
- Follow consistent naming conventions:
- Folders: camelCase (
intercept, replay, httpHistory).
- Component folders: PascalCase (
PassiveFormCreate, PassiveTable).
- All other files: camelCase (
useForm.ts, assistant.graphql).
- Avoid massive template blocks; compose smaller components.
- Colocate code that changes together.
- Declare variables close to their usage:
- Avoid declaring all variables at the top of functions/files.
- Place variable declarations as close as possible to where they are first used.
- Group related variables together (e.g., event bus declarations next to their corresponding listeners).
Simplicity and Readability
- Only create an abstraction if it is actually needed.
- Prefer clear function and variable names over inline comments.
- Avoid helper functions when a simple inline expression would suffice.
- Use built-in Tailwind values, occasionally allow dynamic values, rarely globals.
Linter
Plugins use ESLint configured at the repo root. After making any significant change, run the linter with pnpm lint and fix all issues.
Most common mistakes that lead to linter errors
Lint Rule: Unexpected nullable string value in conditional. Please handle nullish or empty cases explicitly.
To prevent this, when comparing strings, instead of writing:
if (!str) {}
do this:
if (str !== undefined) {}
SDK API Validation
Always use documented Caido SDK APIs directly without runtime checks or validation.
-
Good
sdk.window.showToast("Message", { variant: "error" });
sdk.backend.myFunction();
sdk.commands.register("my-command", { name: "Command", run: () => {} });
-
Bad (NEVER do this)
if ("showToast" in sdk.window && typeof sdk.window.showToast === "function") {
sdk.window.showToast("Message");
}
Rules:
- Never use runtime API existence checks (
typeof, in operator)
- Never assume undocumented SDK methods exist
- If unsure about an API, ask for clarification rather than guessing
The SDK is typed and guaranteed to have the documented methods available.