Crux
GuidesObservability

Plugins

Extend Crux with composable plugins for telemetry, logging, and custom instrumentation.

Crux has a generic plugin system for extending the runtime. Plugins can install middleware, observability transports, reporters, and other cross-cutting concerns: all composable, all zero-overhead when not installed.

Using plugins

Pass plugins to config():

crux.config.ts
import { config } from "@use-crux/core";
import { withTelemetry } from "@use-crux/otel";

export default config({
  plugins: [withTelemetry({ serviceName: "my-app" })],
});

Multiple plugins compose automatically:

crux.config.ts
import { withTelemetry } from "@use-crux/otel";

config({
  observability: {
    serverUrl: process.env.DEVTOOLS_URL,
    token: process.env.CRUX_DEVTOOLS_TOKEN,
  },
  plugins: [withTelemetry({ serviceName: "my-app" }), myCustomPlugin],
});

Plugins are processed in order. Each plugin's install() receives the cumulative hook state from all prior plugins.

When you explicitly set devtools.serverUrl and no observability transport is configured, Crux installs the local devtools transport before your custom plugins. Tunnel/serverless ingest, remote collectors, and production export belong under observability or an explicit telemetry plugin.

Built-in plugins

PluginPackagePurpose
withDevtools()@use-crux/core/observabilityVisual tracing UI for development
withTelemetry()@use-crux/otelOpenTelemetry spans for production observability

Subscribing to graph records

You do not need a full plugin when you only want to consume the canonical graph record stream inside the same process. Use subscribeObservability() and clean it up when your integration stops:

my-record-subscriber.ts
import { subscribeObservability } from "@use-crux/core/observability";

const unsubscribe = subscribeObservability((record) => {
  if (record.type === "artifact" && record.kind === "output") {
    queueAuditWrite(record);
  }
});

unsubscribe();

When an integration only needs specific record variants, pass the discriminants first. The callback is typed to the selected records and is not called for the rest of the stream:

span-end-subscriber.ts
const unsubscribeSpanEnds = subscribeObservability(
  ["span:end"] as const,
  (record) => {
    queueSpanSummary(record.spanId, record.status);
  },
);

unsubscribeSpanEnds();

Node-native vendor integrations can also subscribe to the crux:observability diagnostics channel without installing a Crux plugin.

Writing a custom plugin

Implement the CruxPlugin interface. Prefer subscribing to graph records for instrumentation: CruxHooks is not a free-form per-event callback map. Use subscribeObservability() (or the diagnostics channel) for tool starts, generation ends, and similar signals.

my-plugin.ts
import type { CruxPlugin } from "@use-crux/core";
import { subscribeObservability } from "@use-crux/core/observability";

export function createMyPlugin(options: { endpoint: string }): CruxPlugin {
  return {
    name: "my-tracer",
    install() {
      const unsubscribe = subscribeObservability(
        ["span:start"] as const,
        (record) => {
          if (record.primitive !== "tool.call") return;
          const toolName =
            typeof record.attributes?.toolName === "string"
              ? record.attributes.toolName
              : undefined;
          void fetch(options.endpoint, {
            method: "POST",
            body: JSON.stringify({
              event: "tool.start",
              tool: toolName,
              spanId: record.spanId,
            }),
          }).catch(() => {}); // fire-and-forget
        },
      );

      return {
        dispose: unsubscribe,
      };
    },
  };
}

What install() receives

install(hooks) receives a frozen snapshot of the current CruxHooks (everything installed by previous plugins). Most graph-only plugins ignore that snapshot and only return dispose. You never need to manually fan out prior handlers: the framework merges hook fields for you.

What install() returns

Return a CruxPluginResult: any subset of CruxHooks fields plus an optional dispose function. Observability-relevant fields:

FieldTypeDescription
middlewarePromptMiddlewareWraps every generate() / stream() call
resolveHookResolveHookObserve prompt .resolve() calls
executionHookExecutionHookObserve model calls from agent adapters
streamProgressHookStreamProgressHookLive streaming metrics
streamStartHookStreamStartHookFires before first chunk
observabilityTransportCruxObservabilityTransportCanonical graph transport instance
observabilityDeliveryObservabilityDeliveryOptionsNon-blocking batching, retry, and queue bounds
observabilityCapturecapture policyCentral payload capture / redaction policy
spanActivationHookSpanActivationHookActivate real execution context (e.g. OTel span)
telemetryFlushHookTelemetryFlushHookForce-flush installed telemetry exporters
telemetryResumeAttributesHookTelemetryResumeAttributesHookExtra attributes on resume from a propagation carrier
dispose() => void | Promise<void>Cleanup on registry.dispose()

CruxHooks also carries non-observability slots (records, runtimeEngine, global constraints/guardrails, …) that plugins may set; they are not free-form event maps.

For raw graph consumption, call subscribeObservability() inside install() and return dispose: unsubscribe; do not invent parallel hook maps on CruxHooks.

How composition works

Later plugins wrap earlier ones for middleware (the outer middleware controls when next() is called), other hook fields merge by mergeHooks rules, and dispose() runs in reverse install order. Every subscriber, diagnostics-channel listener, transport, and @use-crux/otel projection observes the same sanitized record stream, with subscriber errors isolated from user code. Details: Plugin composition.

Next steps

On this page