Handler Context
Last updated April 7, 2026
The PluginContextV3 object passed to every handler — every field, every sub-service.
Every handler you write receives a ctx: PluginContextV3 as its first argument. It's the single entry point to everything a handler might need: identity metadata, the host-specific context, the UI facade, the platform singleton, the sandboxed runtime API, and the higher-level plugin API.
This page documents the shape in full. Source of truth: infra/kb-labs-plugin/packages/plugin-contracts/src/context.ts.
The top-level shape
interface PluginContextV3<TConfig = unknown> {
// Metadata
readonly host: HostType;
readonly requestId: string;
readonly pluginId: string;
readonly pluginVersion: string;
readonly tenantId?: string;
readonly cwd: string;
readonly outdir?: string;
readonly config?: TConfig;
// Cancellation
readonly signal?: AbortSignal;
// Tracing
readonly trace: TraceContext;
// Host-specific context (discriminated union)
readonly hostContext: HostContext;
// Services
readonly ui: UIFacade;
readonly platform: PlatformServices;
readonly runtime: RuntimeAPI;
readonly api: PluginAPI;
}All fields are readonly. The runtime constructs the context before calling your handler; you can't mutate it, but you can pass it around freely.
Metadata
host
type HostType = 'cli' | 'rest' | 'workflow' | 'webhook' | 'ws';Which host is executing the handler. Use it to branch logic that depends on the invocation context — though most of the time the define* helper has already guarded the host, so the specific value is predictable.
if (ctx.host === 'cli') {
// Running from the CLI
} else if (ctx.host === 'workflow') {
// Running as a workflow step
}requestId
A unique identifier for this invocation. Logged, included in traces, and forwarded to downstream services (REST requests carry it as X-Request-ID; workflow steps propagate it to spawned jobs). Use it as the correlation key when debugging.
pluginId and pluginVersion
Come from your manifest's id and version. Useful for logging, for building user-facing error messages, and for analytics attribution.
tenantId
The tenant this invocation is scoped to. In single-tenant deployments this is 'default'; in multi-tenant deployments, the REST API middleware populates it from the X-Tenant-ID header.
If your plugin has any tenant-scoped state (per-tenant rate limits, per-tenant storage paths, etc.), key them on ctx.tenantId.
cwd
Current working directory. For CLI handlers, this is where the user ran the pnpm kb ... command. For REST handlers, it's the REST API service's working directory (typically the workspace root). For workflow handlers, it's the workspace attached to the job's execution target.
Use cwd when resolving relative paths provided by the user. Don't read it assuming it's "the project root" — it isn't in every host.
outdir
Optional directory for writing output artifacts. Populated when the host context defines one (workflow steps with artifacts blocks, REST routes that produce file downloads). undefined for hosts that don't care about artifact output.
When present, prefer writing to outdir over arbitrary paths — the platform cleans up artifact directories between runs and collects them for display in Studio.
config
Typed plugin configuration. The platform loads it from profiles[active].products[configSection] and passes the product-specific slice here. The type is whatever you declared as TConfig when writing defineCommand<TConfig, ...>.
defineCommand<MyConfig, CLIInput<MyFlags>, MyResult>({
handler: {
async execute(ctx, input) {
const config = ctx.config; // MyConfig | undefined
if (config?.apiKey) { /* ... */ }
}
}
});ctx.config is undefined when no config is set for the active profile. Handle that case.
Prefer useConfig<MyConfig>() over reading ctx.config directly — the hook is typed, returns a Promise for async resolution, and works identically in tests. ctx.config is for cases where you need synchronous access.
Cancellation
signal
An optional AbortSignal that fires when the invocation is cancelled. Cancellation happens when:
- The user hits Ctrl-C on a CLI command.
- A REST client drops the connection (and the host is configured to propagate).
- A workflow step's timeout expires.
- A parent workflow run is cancelled.
Long-running operations should check the signal and bail out cleanly:
async execute(ctx, input) {
for (const item of items) {
if (ctx.signal?.aborted) {
return { exitCode: 1, error: { code: 'CANCELLED', message: 'Aborted' } };
}
await processItem(item);
}
}Passing the signal to fetch, timers, and AbortController-aware APIs is the idiomatic way to propagate cancellation:
const response = await fetch(url, { signal: ctx.signal });Most adapter methods already respect the signal — the LLM adapter, the cache adapter, and the storage adapter all forward it to their underlying operations.
Tracing
trace
A distributed tracing context:
interface TraceContext {
traceId: string;
spanId: string;
parentSpanId?: string;
}Handler code rarely touches this directly — the platform uses it to correlate logs, metrics, and traces across services. If you want to emit your own trace spans from plugin code, the logging hooks and analytics hooks already include the trace context automatically.
Host-specific context
hostContext
A discriminated union keyed on the same host field as the top level:
type HostContext =
| { host: 'cli'; argv: string[]; cliVersion?: string }
| { host: 'rest'; method: string; path: string; params: Record<string, string>; query: Record<string, string> }
| { host: 'workflow'; runId: string; jobId: string; stepId: string; jobInput?: unknown }
| { host: 'webhook'; event: string; deliveryId: string; headers: Record<string, string> }
| { host: 'ws'; sessionId: string; channel: string };Use type narrowing to access host-specific fields:
if (ctx.hostContext.host === 'cli') {
console.log(ctx.hostContext.argv); // string[]
} else if (ctx.hostContext.host === 'rest') {
console.log(ctx.hostContext.method); // 'GET' | 'POST' | ...
console.log(ctx.hostContext.params); // path parameters
}Or use the exported type guards (isCLIHost, isRESTHost, isWorkflowHost, isWebhookHost, isWSHost) from @kb-labs/sdk:
import { isRESTHost } from '@kb-labs/sdk';
if (isRESTHost(ctx.hostContext)) {
// ctx.hostContext is narrowed to the REST shape
}Services
Four service references sit under the context: ui, platform, runtime, api. They overlap — there are multiple ways to do the same thing — but each serves a slightly different purpose.
ui — UIFacade
User-facing output. The facade abstracts over the presenter (text vs JSON) so handlers don't care which mode they're running in:
ctx.ui.info('Processing...');
ctx.ui.success('Done');
ctx.ui.warn('Slow operation');
ctx.ui.error('Failed', err);
ctx.ui.table(rows, columns);
ctx.ui.section('Summary', summaryContent);In CLI text mode, these emit colored output with spinners and tables. In JSON mode, they're recorded as structured events and included in the final response. In REST mode, they're captured as log entries attached to the response.
Use ctx.ui for anything the user should see. Don't use console.log — it bypasses the presenter and breaks structured output.
platform — PlatformServices
The full platform singleton: llm, cache, storage, vectorStore, embeddings, analytics, eventBus, logger, config, and a handful of internal services.
ctx.platform.llm?.complete(...)
ctx.platform.cache?.set(...)
ctx.platform.logger.info(...)Prefer the hooks over ctx.platform direct access. useLLM(), useCache(), useLogger(), etc. return the same services through a stable, versioned API. ctx.platform is the underlying surface — it works, but it couples your code to the platform contract rather than the SDK's.
See SDK → Hooks for the hook catalog.
runtime — RuntimeAPI
Sandboxed access to filesystem, network, and environment variables. Every operation goes through permission checks declared in the manifest:
await ctx.runtime.fs.readFile('some/file.txt'); // checked against permissions.fs.read
await ctx.runtime.fs.writeFile('out.json', data); // checked against permissions.fs.write
await ctx.runtime.fetch('https://api.example.com'); // checked against permissions.network.fetch
ctx.runtime.env.OPENAI_API_KEY; // checked against permissions.env.readWhen a check fails, the operation throws a permission error. This is how the sandbox enforces declared permissions at runtime — your plugin can't accidentally read a file or call an API it didn't declare access to.
See Plugins → Permissions for the permission model.
api — PluginAPI
Higher-level APIs for plugin-to-plugin invocation, state management, artifact handling, shell execution, event emission, output capture, and lifecycle hooks:
ctx.api.invoke('other-plugin:command', args); // call another plugin
ctx.api.state.get('key'); // read persistent state
ctx.api.state.set('key', value); // write persistent state
ctx.api.artifacts.write('report.md', content); // write an artifact
ctx.api.shell.exec('git status', { cwd }); // run a whitelisted shell command
ctx.api.events.emit('progress', { step: 3 }); // emit a platform event
ctx.api.output.capture(() => { ... }); // capture stdout/stderr
ctx.api.lifecycle.onCleanup(() => { ... }); // register a cleanup callbackThese wrap lower-level platform services with plugin-specific helpers. The invocation API (ctx.api.invoke) is the recommended way to call another plugin's command from your own — it goes through the platform's permission checks and tracing.
A complete handler
Pulling everything together:
import {
defineCommand,
useLLM,
useLogger,
useConfig,
type CLIInput,
} from '@kb-labs/sdk';
interface MyFlags { query: string }
interface MyConfig { defaultModel: string }
interface MyResult { answer: string; model: string }
export default defineCommand<MyConfig, CLIInput<MyFlags>, MyResult>({
id: 'ask:query',
handler: {
async execute(ctx, input) {
const logger = useLogger();
const config = await useConfig<MyConfig>();
const llm = useLLM();
logger.info('query started', {
requestId: ctx.requestId,
tenant: ctx.tenantId,
query: input.flags.query,
});
if (!llm) {
return { exitCode: 1, error: { code: 'NO_LLM', message: 'LLM not configured' } };
}
// Show progress to the user
ctx.ui.info(`Querying ${config?.defaultModel ?? 'default'}...`);
try {
const response = await llm.complete(input.flags.query, {
model: config?.defaultModel,
signal: ctx.signal,
});
// Save to state for later retrieval
await ctx.api.state.set(`last-query:${ctx.tenantId}`, {
query: input.flags.query,
answer: response.content,
model: response.model,
requestId: ctx.requestId,
});
return {
exitCode: 0,
result: {
answer: response.content,
model: response.model,
},
meta: {
tokens: response.usage?.totalTokens,
durationMs: Date.now() - Date.parse(ctx.trace.spanId),
},
};
} catch (err) {
logger.error('query failed', { err });
return {
exitCode: 1,
error: {
code: 'LLM_FAILED',
message: err instanceof Error ? err.message : 'Unknown error',
},
};
}
},
},
});Every field on the context is used at least once: metadata (ctx.requestId, ctx.tenantId) in logging, ctx.signal in the LLM call, ctx.ui for progress, ctx.api.state for persistence, ctx.trace.spanId for timing.
What to read next
- SDK → Hooks — the idiomatic way to reach
ctx.platformservices. - SDK → Commands — handler signatures and
defineCommand. - Plugins → Permissions — what operations through
ctx.runtimeandctx.apiare allowed to do. - SDK → Testing — constructing test contexts.