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, instrumentation hooks, 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 '@crux/core'
import { withTelemetry } from '@crux/otel'

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

Multiple plugins compose automatically:

crux.config.ts
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

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

Writing a custom plugin

Implement the CruxPlugin interface:

my-plugin.ts
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:

FieldTypeDescription
middlewarePromptMiddlewareWraps every generate()/stream() call
instrumentationHooksInstrumentationHooksObserve memory, tools, flows, compositions, etc.
resolveHookResolveHookObserve prompt .resolve() calls
executionHookExecutionHookObserve model calls from agent adapters
streamProgressHookStreamProgressHookLive streaming metrics
streamStartHookStreamStartHookFires before first chunk
evalReporterEvalReporterEval run progress
flowEvalReporterFlowEvalReporterFlow eval progress
observabilityTransportCruxObservabilityTransportCanonical graph transport instance
observabilityDeliveryObservabilityDeliveryOptionsNon-blocking delivery bounds
dispose() => voidCleanup 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 fire

Middleware 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() call

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

Next steps

On this page