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():
import { config } from "@use-crux/core";
import { withTelemetry } from "@use-crux/otel";
export default config({
plugins: [withTelemetry({ serviceName: "my-app" })],
});Multiple plugins compose automatically:
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
| Plugin | Package | Purpose |
|---|---|---|
withDevtools() | @use-crux/core/observability | Visual tracing UI for development |
withTelemetry() | @use-crux/otel | OpenTelemetry 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:
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:
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.
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:
| Field | Type | Description |
|---|---|---|
middleware | PromptMiddleware | Wraps every generate() / stream() call |
resolveHook | ResolveHook | Observe prompt .resolve() calls |
executionHook | ExecutionHook | Observe model calls from agent adapters |
streamProgressHook | StreamProgressHook | Live streaming metrics |
streamStartHook | StreamStartHook | Fires before first chunk |
observabilityTransport | CruxObservabilityTransport | Canonical graph transport instance |
observabilityDelivery | ObservabilityDeliveryOptions | Non-blocking batching, retry, and queue bounds |
observabilityCapture | capture policy | Central payload capture / redaction policy |
spanActivationHook | SpanActivationHook | Activate real execution context (e.g. OTel span) |
telemetryFlushHook | TelemetryFlushHook | Force-flush installed telemetry exporters |
telemetryResumeAttributesHook | TelemetryResumeAttributesHook | Extra 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.