Overview
Last updated April 15, 2026
What a plugin is, why you'd write one, and how.
What a plugin is
A plugin is a regular npm package with a manifest that tells the platform what it contributes. It can add CLI commands (defineCommand), REST routes (defineRoute), webhook handlers (defineWebhook), workflow step handlers, Studio pages — any combination. No framework, no special wrapper. Plugins are loaded by the platform, discovered through the marketplace lock file, and executed in a sandbox with declared permissions.
Every AI-ish feature you've used in KB Labs ships as a plugin: commit generation, code review, RAG search, agents, the scaffold tool. The platform itself is small; the features are plugins.
Why you'd write one
- Add your own CLI command. A one-shot script that reads a git diff, calls the LLM, and produces a summary — exposed as
kb my-team post-release. Runs in the same sandbox as first-party plugins, getsuseLLM(),useLogger(), and the rest of the SDK hooks. - Expose a REST route. Internal tool needs an endpoint? Drop it into a plugin as a
defineRoutehandler. The gateway handles auth + CORS; you just write the business logic. - Handle a webhook. GitHub push, Slack event, external CI callback — declare the webhook endpoint in the manifest, write
defineWebhookhandler, done. - Write a workflow step. Custom deployment gates, domain-specific checks, AI-powered release notes — anything a workflow needs to call, write as a plugin handler.
- Ship a Studio page. A dashboard for your team's internal data, rendered inside Studio using the platform's UI kit.
- Bundle a skill / agent behavior. Teach agents to do something specific to your stack — your plugin provides the tools, agents consume them.
A plugin is the unit of extension. If you'd otherwise write a shell script, a GitHub Action, a webhook server, or a one-off internal tool, a plugin is usually the cleaner shape — one package, one manifest, observable, sandboxed.
How you write one
Fastest path — Guides → First Plugin. Scaffolds the package, builds it, links it, runs the command. 10 minutes end-to-end.
kb scaffold run plugin my-plugin --yes
cd .kb/plugins/my-plugin
pnpm install && pnpm build
kb marketplace plugins refresh
kb my-plugin helloThe rest of this page is the mental model — what files exist, what the manifest looks like, what handlers receive. For the full schema see Manifest Reference. For permissions specifics see Permissions.
Package layout
By convention a plugin is a mini-monorepo with three packages:
kb-labs-my-plugin/
├── packages/
│ ├── my-plugin-entry/ # CLI commands + manifest + handlers
│ │ └── src/
│ │ ├── manifest.ts # exports default ManifestV3
│ │ ├── cli/commands/ # one file per command
│ │ └── rest/handlers/ # one file per route
│ ├── my-plugin-core/ # business logic (framework-free)
│ └── my-plugin-contracts/ # shared types, Zod schemas, constants
├── package.json
└── tsconfig.jsonThe split isn't enforced — the runtime only cares about the manifest and the handler paths it points at. But every first-party plugin follows this layout, so copy it and save yourself the thinking. commit plugin is the canonical reference.
Copy configs (tsup.config.ts, tsconfig.json, package.json) from an existing plugin — don't roll your own. The build outputs need to match what the runtime expects, and matching an existing plugin is the cheapest way to guarantee that.
The manifest
Every plugin exports a ManifestV3 from its CLI package. It's a plain TypeScript object (no decorators, no class), typically authored as src/manifest.ts and re-exported as the package default:
// packages/my-plugin-entry/src/manifest.ts
import { defineCommandFlags, combinePermissions, kbPlatformPreset } from '@kb-labs/sdk';
import { greetFlags } from './cli/commands/flags';
export const manifest = {
schema: 'kb.plugin/3',
id: '@kb-labs/my-plugin',
version: '0.1.0',
display: {
name: 'My Plugin',
description: 'Does cool things.',
tags: ['example'],
},
permissions: combinePermissions()
.with(kbPlatformPreset)
.withPlatform({ llm: true })
.build(),
cli: {
commands: [
{
id: 'my-plugin:greet',
group: 'my-plugin',
describe: 'Say hello',
handler: './cli/commands/greet.js#default',
flags: defineCommandFlags(greetFlags),
examples: ['kb my-plugin greet --name=Alice'],
},
],
},
};
export default manifest;The handler string format is <path-relative-to-package-root>#<exportName>. The path points to the built output (hence .js, not .ts), and #default means "default export of that module".
Handlers
Every capability in the manifest points at a handler file. A handler's default export is the result of a define* helper from @kb-labs/sdk. For a CLI command that means defineCommand:
// packages/my-plugin-entry/src/cli/commands/greet.ts
import { defineCommand, useLogger, type PluginContextV3 } from '@kb-labs/sdk';
import type { CLIInput } from '@kb-labs/sdk';
interface GreetFlags {
name?: string;
}
export default defineCommand<unknown, CLIInput<GreetFlags>, { message: string }>({
id: 'my-plugin:greet',
description: 'Greet a user',
handler: {
async execute(ctx, input) {
const logger = useLogger();
const name = input.flags.name ?? 'world';
logger.info('greet called', { name });
return {
exitCode: 0,
result: { message: `Hello, ${name}!` },
};
},
},
});The runtime imports this module, takes handler = module.default ?? module, and calls handler.execute(context, input). If the result doesn't have an execute function, loading fails with INVALID_HANDLER.
defineCommand wraps your handler with a host guard that throws if the command is invoked from anywhere other than cli or workflow. Routes and WebSocket channels use defineRoute / defineWebSocket instead — those helpers guard for their respective hosts.
What's in ctx?
PluginContextV3 is what the runtime passes as the first argument. The shape matters when you're writing handlers:
ctx.host—'cli' | 'rest' | 'ws' | 'workflow' | 'webhook' | 'job' | 'cron'. The SDK's host-aware helpers check this field.ctx.pluginId,ctx.pluginVersion,ctx.handlerId,ctx.requestId,ctx.tenantId— identity and trace metadata.ctx.cwd/ctx.outdir— working and artifact directories.ctx.ui— pretty-printer (success,info,warn,error, spinners, loaders). Use it for human output.ctx.runtime— sandboxedenv,fs,fetch. Use these instead of the raw Node APIs; they're what permissions actually gate.ctx.api— higher-level platform APIs:state,artifacts,shell,events,invoke,jobs,workflows,environment,workspace,snapshot.ctx.platform— raw platform services (logger, llm, cache, storage, analytics, vectorStore, ...). You'll rarely use these directly; the SDK hooks (useLogger(),useLLM(), ...) are the idiomatic surface.
Hooks are the idiomatic way
SDK hooks (useLogger, useLLM, useCache, useStorage, useConfig, useAnalytics, useEventBus, ...) read from a platform singleton, so they work without threading ctx everywhere:
import { useLLM, useCache, useConfig } from '@kb-labs/sdk';
async function execute(ctx, input) {
const llm = useLLM();
const cache = useCache();
const config = await useConfig<{ model: string }>();
// ...
}Hooks degrade gracefully — if the platform doesn't provide a service (e.g. no LLM adapter configured), the hook returns undefined. That means your plugin can declare llm as an optional dependency and still run, as long as you check the return value before using it. The one exception is useLogger(), which always returns a logger (the platform provides a fallback). See SDK Hooks for the full list.
Discovery
The platform does not scan the filesystem for plugins. Discovery is driven entirely by .kb/marketplace.lock, written by the marketplace service when you run kb marketplace install @scope/plugin or kb marketplace link <path>.
The lock file (schema kb.marketplace/2) records, for each installed entity:
version(semver)resolvedPath— where the package lives relative to the workspace rootintegrity—sha256-...hash ofpackage.jsonsource—'marketplace'for npm-installed,'local'for linked dev copiessignature— optional platform-issued signature (ed25519 / sha256-rsa)primaryKind+provides— entity kinds the package advertisesenabled— defaulttrue; set tofalseto disable without uninstalling
On every CLI startup (and whenever a service boots), DiscoveryManager.discover() reads the lock, walks each entry, loads the manifest, verifies integrity, and collects a DiscoveryResult with the live ManifestV3 for each plugin. Failures are collected as DiagnosticEvents (with codes like MANIFEST_NOT_FOUND, INTEGRITY_MISMATCH, PLUGIN_DISABLED) but don't crash the platform — a broken plugin just doesn't load.
If your plugin built but doesn't show up in kb --help, clear the registry cache: pnpm kb marketplace plugins refresh. After rebuilding any CLI plugin this is mandatory — the platform caches the resolved registry snapshot.
Local development
When you run kb marketplace link <path>, the lock entry gets source: 'local'. Local entries behave almost identically to marketplace ones except integrity mismatches don't block loading — the discovery manager silently rewrites the stored hash because local packages change every build. This is what lets pnpm install in a plugin immediately reflect in the CLI without fighting the integrity check.
Execution
When a handler fires, the sandbox runner picks one of two modes:
In-process (runInProcess)
The handler module is dynamically imported into the host's own Node process. Fastest path, no IPC overhead. Suitable for trusted first-party plugins in dev, and it's what most commands run as by default.
Flow:
- Build a
PluginContextV3from the descriptor (pluginId, requestId, tenantId, ...). - Override the analytics source to attribute events to this plugin (restored in
finally). await import(handlerPath).- Call
handler.execute(context, input). - Drain the cleanup stack (any
ctx.api.lifecycle.onCleanup(...)callbacks). - Return
{ ok: true, data, executionMeta }.
If handler.execute isn't a function, the runner throws PluginError('INVALID_HANDLER').
Subprocess (runInSubprocess)
A fork of bootstrap.js (from plugin-runtime) is started with KB_SOCKET_PATH, KB_PLATFORM_SOCKET_TOKEN, KB_EXECUTION_ID, and KB_TENANT_ID in the environment. The parent sends an ExecuteMessage over IPC, the child runs the handler with a platform-client proxy talking back through a Unix socket, and the result comes back as a ResultMessage or ErrorMessage.
This mode is used when:
- The plugin is untrusted (loaded from the marketplace, not a local dev link).
- The handler needs hard isolation (separate process, resource quotas enforced by the OS).
- Timeouts need to be strict (the parent can
child.kill()on abort).
Default timeout for subprocess mode is 30 seconds. Long-running LLM commands need to bump timeoutMs in the manifest.
Where the choice lives
The mode isn't something plugins pick — it's decided by the execution backend configured in kb.config.json (in-process / worker-pool / container). Plugins are written the same way for both modes; the SDK sandboxing shims (ctx.runtime.fs, ctx.runtime.env, ctx.runtime.fetch) enforce permissions identically in both. See Execution Model for the backend selection.
Lifecycle hooks
The manifest has a lifecycle section with four optional entry points:
lifecycle: {
onLoad?: string; // called once when the plugin is first loaded
onUnload?: string; // called during shutdown
onEnable?: string; // called when the plugin is enabled after being disabled
onDisable?: string; // called when the plugin is disabled
}Each value is a handler path, same format as command handlers. There's also setup, which runs once at install time and typically gets broader permissions than runtime handlers (e.g., to create directories, seed databases). Most plugins don't need any of these — start without them and add only when you have a concrete reason.
Entity kinds
A plugin can provide more than one kind of entity. The discovery layer extracts a provides: EntityKind[] list from your manifest based on which sections are present:
| Manifest section | Entity kind |
|---|---|
cli.commands | cli-command |
rest.routes | rest-route |
ws.channels | ws-channel |
workflows.handlers | workflow |
webhooks.handlers | webhook |
jobs.handlers | job |
cron.schedules | cron |
studio.pages / menus | studio-widget / studio-menu |
A single plugin can (and often does) provide many of these at once — commit-plugin ships 6 CLI commands, 14 REST routes, and a Studio page from the same package.
Loading order, on a single page
Putting all the above together — what the platform does from cold start:
.kb/marketplace.lock is parsed; kb.marketplace/2 schema is validated. No lock → no plugins.
For every installed package: resolve resolvedPath against the workspace root, check the directory exists.
SRI hash of package.json must match the lock. Local packages auto-refresh; marketplace packages fail loudly.
Dynamic import of the package's manifest module. Timeout: 5 seconds by default. Validation errors are collected as diagnostics.
If a platform signature is present, it's verified. Missing signatures produce an info-level diagnostic, not an error.
Walk the manifest, build the provides list, check for duplicate plugin IDs.
The manifest is cached, handlers are indexed by (host, id), and the plugin becomes invocable from the CLI, REST, or workflow engine.
When a handler is triggered, the runner creates a context, enforces permissions via runtime shims, calls execute(ctx, input), and collects metadata.
Checklist for your first plugin
- Copy the structure from commit plugin.
- Update
package.jsonandtsconfig.jsonin all three sub-packages. - Write
manifest.tswith yourid, a minimalcli.commands[0], andpermissions: combinePermissions().with(minimalPreset).build(). - Implement the handler with
defineCommand({ id, handler: { async execute(ctx, input) { ... } } }). - Build:
pnpm --filter your-plugin run build. - Link:
kb marketplace link ./path/to/your-plugin. - Clear cache:
pnpm kb marketplace plugins refresh. - Run:
pnpm kb your-plugin:your-command.
If step 6 or 8 fail, check Troubleshooting — the top three entries cover 90% of first-plugin friction.