Skip to content
Radiant LabsRadiant Labs

Advanced topics

Hooks and middleware

Hooks observe graph execution events. Middleware wraps execution units with before-and-after logic.

Hooks and middleware are two mechanisms for attaching cross-cutting behavior to graph execution. Hooks fire at specific points during execution — use them for observation and resource injection. Middleware wraps an entire execution unit, giving you before-and-after control — use it for timing, tracing spans, rate limiting, and anything else that needs to straddle a boundary.

A hook is a callback that runs when a graph execution event occurs. Hooks come in two flavors: observers and providers.

Observers react to events without producing anything. They are the right choice for logging, metrics, and tracing side effects.

use polaris_ai::graph::hooks::{HooksAPI, GraphEvent};
use polaris_ai::graph::hooks::schedule::OnSystemStart;
let hooks = HooksAPI::new();
hooks.register_observer::<OnSystemStart, _>("logger", |event: &GraphEvent| {
if let GraphEvent::SystemStart { node_name, .. } = event {
tracing::info!("system starting: {}", node_name);
}
})?;

Providers return a resource that is inserted into the SystemContext before the system runs. Systems can then access the resource through Res<T>. The DevToolsPlugin uses a provider hook to inject SystemInfo before every system execution.

use polaris_ai::graph::hooks::{HooksAPI, GraphEvent};
use polaris_ai::graph::hooks::schedule::OnSystemStart;
use polaris_ai::graph::dev::SystemInfo;
let hooks = HooksAPI::new();
hooks.register_provider::<OnSystemStart, SystemInfo, _>(
"devtools",
|event: &GraphEvent| {
match event {
GraphEvent::SystemStart { node_id, node_name } => {
Some(SystemInfo::new(node_id.clone(), node_name))
}
_ => None,
}
},
)?;

The provider closure returns Option<T>. When it returns Some, the resource is inserted into the context. When it returns None, nothing happens. If multiple providers on the same schedule produce the same resource type, last-write-wins.

Each hook is registered against one or more schedules. A schedule is a marker type that identifies a point in the graph execution lifecycle. These are the available schedules:

Graph-level: OnGraphStart, OnGraphComplete, OnGraphFailure — fire once per graph execution.

System-level: OnSystemStart, OnSystemComplete, OnSystemError — fire around each system node.

Decision and switch: OnDecisionStart, OnDecisionComplete, OnSwitchStart, OnSwitchComplete — fire around branching nodes.

Loop: OnLoopStart, OnLoopIteration, OnLoopEnd — fire at loop boundaries and on each iteration.

Parallel: OnParallelStart, OnParallelComplete — fire around parallel execution blocks.

Resources provided by OnSystemStart and OnGraphStart hooks are considered during graph validation, since they are guaranteed to be available before any system runs. Resources from other schedules are not, because they arrive after execution has begun.

You can register a single hook on multiple schedules using tuple syntax. This avoids duplicating the same closure for related events.

use polaris_ai::graph::hooks::{HooksAPI, GraphEvent};
use polaris_ai::graph::hooks::schedule::{OnSystemStart, OnSystemComplete, OnSystemError};
let hooks = HooksAPI::new();
hooks.register_observer::<(OnSystemStart, OnSystemComplete, OnSystemError), _>(
"system_tracker",
|event: &GraphEvent| match event {
GraphEvent::SystemStart { node_name, .. } => {
tracing::debug!("start: {}", node_name);
}
GraphEvent::SystemComplete { node_name, duration, .. } => {
tracing::debug!("complete: {} ({:?})", node_name, duration);
}
GraphEvent::SystemError { node_name, error, .. } => {
tracing::warn!("error in {}: {}", node_name, error);
}
_ => {}
},
)?;

The composite type AllGraphSchedules registers on every schedule at once. This is useful for blanket event tracing.

use polaris_ai::graph::hooks::schedule::AllGraphSchedules;
use polaris_ai::graph::hooks::{HooksAPI, GraphEvent};
let hooks = HooksAPI::new();
hooks.register_observer::<AllGraphSchedules, _>(
"trace_all",
|event: &GraphEvent| {
tracing::debug!("{event}");
},
)?;

Hooks execute in registration order. If you register hook A before hook B on the same schedule, A runs first.

Plugins access HooksAPI through the server’s API registry. The DevToolsPlugin is a concrete example of this pattern.

use polaris_ai::graph::hooks::HooksAPI;
use polaris_ai::graph::hooks::events::GraphEvent;
use polaris_ai::graph::hooks::schedule::OnSystemStart;
use polaris_ai::system::plugin::{Plugin, Version};
use polaris_ai::system::server::Server;
pub struct LoggingHooksPlugin;
impl Plugin for LoggingHooksPlugin {
const ID: &'static str = "myapp::logging_hooks";
const VERSION: Version = Version::new(0, 1, 0);
fn build(&self, server: &mut Server) {
if !server.contains_api::<HooksAPI>() {
server.insert_api(HooksAPI::new());
}
let hooks = server
.api::<HooksAPI>()
.expect("HooksAPI should be present");
hooks.register_observer::<OnSystemStart, _>(
"system_logger",
|event: &GraphEvent| {
if let GraphEvent::SystemStart { node_name, .. } = event {
tracing::info!("executing system: {}", node_name);
}
},
)
.expect("hook registration should succeed");
}
}

Hook names must be unique per schedule. Registering two hooks with the same name on the same schedule returns a HookRegistrationError::DuplicateName. The same name on different schedules is allowed.

Middleware wraps an execution unit with before-and-after logic. Where hooks fire at a single point, middleware spans a duration — you run code before the unit executes, call next.run(ctx).await to let it proceed, then run code after it completes.

use polaris_ai::graph::middleware::{MiddlewareAPI, info::SystemInfo};
let mw = MiddlewareAPI::new();
mw.register_system("timer", |info: SystemInfo, ctx, next| {
Box::pin(async move {
let start = std::time::Instant::now();
let result = next.run(ctx).await;
eprintln!("{}: {:?}", info.node_name, start.elapsed());
result
})
});

The handler receives three arguments: a typed info struct with metadata about the execution unit, a mutable reference to the SystemContext, and a Next value representing the rest of the chain. You must call next.run(ctx).await exactly once. Dropping next without calling it produces an ExecutionError::InternalError.

Each middleware target wraps a different execution unit. You register middleware using the corresponding method on MiddlewareAPI.

Target Registration method Info type Wraps
GraphExecution register_graph_execution GraphInfo Entire graph run
System register_system SystemInfo Single system node
Loop register_loop LoopInfo Entire loop node
LoopIteration register_loop_iteration LoopIterationInfo Single loop iteration
Parallel register_parallel ParallelInfo Entire parallel node
ParallelBranch register_parallel_branch ParallelBranchInfo Single parallel branch
Decision register_decision DecisionInfo Decision node evaluation
Switch register_switch SwitchInfo Switch node evaluation
Scope register_scope ScopeInfo Scoped subgraph execution
Dynamic register_dynamic DynamicInfo Dynamic candidate selection and execution

Each info type carries metadata relevant to its target. For example, LoopInfo includes max_iterations and the node name, while ParallelBranchInfo includes the branch index and total branch count.

When you register multiple middlewares on the same target, they form a chain. The last-registered middleware is the outermost layer. If you register A then B, execution flows: B before -> A before -> hooks -> terminal -> hooks -> A after -> B after.

use polaris_ai::graph::middleware::{MiddlewareAPI, info::SystemInfo};
let mw = MiddlewareAPI::new();
mw.register_system("auth_check", |info: SystemInfo, ctx, next| {
Box::pin(async move {
// Runs second (inner layer)
next.run(ctx).await
})
});
mw.register_system("tracing_span", |info: SystemInfo, ctx, next| {
Box::pin(async move {
// Runs first (outer layer, registered last)
let span = tracing::info_span!("system", name = info.node_name);
let _guard = span.enter();
next.run(ctx).await
})
});

Registration calls chain, so you can register multiple middlewares in sequence.

mw.register_system("first", |_info, ctx, next| {
Box::pin(async move { next.run(ctx).await })
})
.register_system("second", |_info, ctx, next| {
Box::pin(async move { next.run(ctx).await })
});

Middleware handlers return Result<(), MiddlewareError>. There are two error variants.

MiddlewareError::Layer(message) indicates an error from the middleware itself. The framework converts this into an ExecutionError::MiddlewareError with the registered middleware name attached automatically.

MiddlewareError::Inner(error) propagates an error from deeper in the chain unchanged. When you call next.run(ctx).await and it returns an error, that error is already wrapped as Inner. You can inspect it, log it, or propagate it.

use polaris_ai::graph::middleware::{MiddlewareAPI, MiddlewareError, info::SystemInfo};
let mw = MiddlewareAPI::new();
mw.register_system("error_logger", |info: SystemInfo, ctx, next| {
Box::pin(async move {
let result = next.run(ctx).await;
if let Err(ref err) = result {
tracing::error!("system {} failed: {}", info.node_name, err);
}
result
})
});

Hooks and middleware solve different problems. The right choice depends on whether you need point-in-time observation or duration-spanning behavior.

Use hooks when you need to react to an event or inject a resource. Hooks are simpler — they are synchronous callbacks that receive a &GraphEvent. Logging a message when a system starts, recording a metric when a loop completes, or injecting a SystemInfo resource before execution are all hook use cases.

Use middleware when you need to hold something open across the execution of a unit. Opening and closing a tracing span, measuring elapsed time, enforcing rate limits, or counting tokens across an LLM call all require before-and-after behavior that hooks cannot express. Two disconnected point events (for example, OnSystemStart and OnSystemComplete) cannot hold a span guard open — middleware can.

Both hooks and middleware are registered through their respective APIs (HooksAPI and MiddlewareAPI) during the plugin build phase. For details on API registration, see APIs.