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, instrumentation hooks, reporters, and other cross-cutting concerns — all composable, all zero-overhead when not installed.
Using plugins
Pass plugins to config():
import { config } from '@crux/core'
import { withTelemetry } from '@crux/otel'
export default config({
plugins: [withTelemetry({ serviceName: 'my-app' })],
})Multiple plugins compose automatically:
import { withTelemetry } from '@crux/otel'
config({
devtools: { serverUrl: process.env.DEVTOOLS_URL }, // local server or tunnel only
plugins: [withTelemetry({ serviceName: 'my-app' }), myCustomPlugin],
})Plugins are processed in order. Each plugin's install() receives the cumulative runtime 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. Remote collectors and production export belong under
observability or an explicit telemetry plugin.
Built-in plugins
| Plugin | Package | Purpose |
|---|---|---|
withDevtools() | @crux/core/observability | Visual tracing UI for development |
withTelemetry() | @crux/otel | OpenTelemetry spans for production observability |
Writing a custom plugin
Implement the CruxPlugin interface:
import type { CruxPlugin } from '@crux/core'
export function createMyPlugin(options: { endpoint: string }): CruxPlugin {
return {
name: 'my-tracer',
install(runtime) {
return {
instrumentationHooks: {
onToolStart: (e) => {
fetch(options.endpoint, {
method: 'POST',
body: JSON.stringify({ event: 'tool.start', tool: e.toolName }),
}).catch(() => {}) // fire-and-forget
},
},
dispose: () => {
// cleanup resources
},
}
},
}
}What install() receives
The runtime parameter is a frozen snapshot of the current CruxRuntime — all hooks installed by previous plugins. You don't need to manually call previous hooks; the framework composes them for you.
What install() returns
Return a CruxPluginResult — any subset of CruxRuntime fields plus an optional dispose function:
| Field | Type | Description |
|---|---|---|
middleware | PromptMiddleware | Wraps every generate()/stream() call |
instrumentationHooks | InstrumentationHooks | Observe memory, tools, flows, compositions, etc. |
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 |
evalReporter | EvalReporter | Eval run progress |
flowEvalReporter | FlowEvalReporter | Flow eval progress |
observabilityTransport | CruxObservabilityTransport | Canonical graph transport instance |
observabilityDelivery | ObservabilityDeliveryOptions | Non-blocking delivery bounds |
dispose | () => void | Cleanup on registry.dispose() |
How composition works
Hook fan-out
When two plugins install the same instrumentation hook (e.g., onToolStart), both handlers are called for every event. Neither can suppress the other.
Plugin A installs onToolStart → logs to console
Plugin B installs onToolStart → sends to PostHog
↓
Every tool call → both handlers fireMiddleware layering
When two plugins install middleware, the later plugin wraps the earlier one. The outer middleware controls when next() is called:
Plugin A middleware (inner)
↕ wraps
Plugin B middleware (outer)
↕ wraps
Adapter generate() callCalling next() in the outer middleware invokes the inner middleware, which then calls the adapter.
Dispose order
Plugin dispose() functions are called in reverse order during registry.dispose() — last installed, first disposed.