KB LabsDocs

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

TypeScript
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

TypeScript
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.

TypeScript
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, ...>.

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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.

uiUIFacade

User-facing output. The facade abstracts over the presenter (text vs JSON) so handlers don't care which mode they're running in:

TypeScript
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.

platformPlatformServices

The full platform singleton: llm, cache, storage, vectorStore, embeddings, analytics, eventBus, logger, config, and a handful of internal services.

TypeScript
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.

runtimeRuntimeAPI

Sandboxed access to filesystem, network, and environment variables. Every operation goes through permission checks declared in the manifest:

TypeScript
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.read

When 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.

apiPluginAPI

Higher-level APIs for plugin-to-plugin invocation, state management, artifact handling, shell execution, event emission, output capture, and lifecycle hooks:

TypeScript
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 callback

These 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:

TypeScript
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.

Handler Context — KB Labs Docs