Hooks are named, typed async extension slots. A #[hooks] trait is the user-facing
implementation trait, and the macro generates a companion owner named <TraitName>Set.
The implementation side is intentionally rust-analyzer friendly: implement hook traits with
ordinary async fn. The generated public traits avoid #[async_trait] internally by spelling
their methods as fn -> impl Future + MaybeSend.
On This Page
Defining a Hook Set
Annotate a trait with #[hooks]. Each method must be an async fn with a default body and one
#[hook(...)] attribute. The trait remains a real trait that application types can implement.
The generated owner/registry is <TraitName>Set<'h>.
use lutum::hooks;
#[]
trait AppHooks {
#[()]
async fn validate_prompt(prompt: &str) -> Result<(), String> {
if prompt.trim().is_empty() {
Err("prompt must not be empty".into())
} else {
Ok(())
}
}
#[()]
async fn choose_model(task: &str) -> String {
let _ = task;
"gpt-4.1-mini".to_string()
}
#[()]
async fn cost_center() -> &'static str {
"default"
}
}
let hooks = AppHooksSet::new();#[hooks] trait AppHooks generates:
| Generated item | Purpose |
|---|---|
trait AppHooks |
The real implementation trait. You can implement this directly with async fn. |
AppHooksSet<'h> |
The owner/registry that stores hook objects and dispatches slots. |
ValidatePrompt, ChooseModel, ... |
Per-slot hook traits used by with_<method> and #[impl_hook]. |
StatefulValidatePrompt, ... |
Mutable-state variants used with Stateful<T>. |
with_<method> / register_<method> |
Register one slot implementation. |
with_hooks / register_hooks |
Register all methods present in one #[impl_hooks] impl block. |
with_extended / extend |
Merge another hook set of the same type into this one. |
The old generated-owner style (AppHooks::new()) is intentionally replaced by
AppHooksSet::new().
Implementing Many Hooks
Use #[impl_hooks(AppHooksSet)] impl AppHooks for Data when one value should provide one or more
methods from a hook set. Only methods physically present in that impl block are registered.
Trait default methods that you omit are not registered.
use lutum::{hooks, impl_hooks};
#[]
trait AppHooks {
#[()]
async fn validate_prompt(prompt: &str) -> Result<(), String> {
Ok(())
}
#[()]
async fn choose_model(task: &str) -> String {
"gpt-4.1-mini".to_string()
}
}
struct Policy<'a> {
prefix: &'a str,
}
#[(AppHooksSet)]
impl<'a> AppHooks for Policy<'a> {
async fn validate_prompt(
&self,
prompt: &str,
last: Option<Result<(), String>>,
) -> Result<(), String> {
last.unwrap()?;
if prompt.contains("sk-") {
Err("prompt looks like an API key".into())
} else {
Ok(())
}
}
async fn choose_model(&self, task: &str, _last: Option<String>) -> String {
format!("{}:{task}", self.prefix)
}
}
let prefix = String::from("tenant-a");
let policy = Policy { prefix: &prefix };
let hooks = AppHooksSet::new().with_hooks(&policy);register_hooks(data) wraps data once in a shared internal handle, then registers lightweight
per-method adapters. The data type does not need Clone.
You may pass an owned value or a borrowed value:
| Call | Stored lifetime |
|---|---|
hooks.register_hooks(policy) |
The hook set owns the policy value. |
hooks.register_hooks(&policy) |
The hook set borrows policy for the hook-set lifetime. |
The attribute argument must be the generated hook-set type path without generic arguments, for
example #[impl_hooks(AppHooksSet)] or #[impl_hooks(my_mod::AppHooksSet)].
- Methods in
#[impl_hooks]must beasync fn, must take&self, and cannot be generic methods. - The impl block may contain only hook methods. Associated types, constants, helper methods, and non-hook items are rejected.
- Unknown method names and signature mismatches are reported by Rust trait checking; errors may point at both the implementation trait and the generated slot adapter.
- For
alwaysand plainfallbackslots, include trailinglast: Option<Return>in the impl method. For slots withchain,aggregate, orcustom, do not includelast.
Implementing One Hook
#[impl_hook(SlotTrait)] is still available for single function-like handlers. It generates a
zero-sized type named from the function in UpperCamelCase.
use lutum::impl_hook;
#[(ValidatePrompt)]
async fn reject_api_keys(
prompt: &str,
last: Option<Result<(), String>>,
) -> Result<(), String> {
last.unwrap()?;
if prompt.contains("sk-") {
Err("prompt looks like an API key".into())
} else {
Ok(())
}
}
let hooks = AppHooksSet::new().with_validate_prompt(RejectApiKeys);with_<method>(hook) consumes and returns the hook set, so it is convenient for builder chains.
register_<method>(&mut self, hook) mutates an existing hook set and returns &mut Self.
Combining Hook Sets
Every generated hook-set owner can merge another hook set of the same type.
Use extend(other) when mutating an existing hook set. Use with_extended(other) in builder
chains.
let mut hooks = AppHooksSet::new().with_validate_prompt(RejectApiKeys);
let tenant_hooks = AppHooksSet::new()
.with_choose_model(ChooseTenantModel)
.with_cost_center(TenantCostCenter);
hooks.extend(tenant_hooks);extend consumes the other hook set and registers its contents into the receiver:
| Slot kind | Merge behaviour |
|---|---|
always / fallback |
Hooks from other are appended after hooks already registered on self. |
singleton |
A singleton from other replaces the receiver's singleton, with the same overwrite warning as a direct registration. |
| nested hook sets | Nested hook sets are extended recursively. |
This is useful when different modules own independent policy bundles and the application wants to
assemble them into one hook set before constructing Lutum, an adapter, or a tool loop.
Slot Kinds
| Kind | No registered hooks | With registered hooks | last |
|---|---|---|---|
always |
Run the default | Run default first, then hooks in registration order | Yes, unless chain, aggregate, or custom is set |
fallback |
Run the default | Run registered hooks instead of the default | Yes for plain fallback, no with chain, aggregate, or custom |
singleton |
Run the default | Run the single registered hook | Never |
For singleton, the last registration wins. Lutum emits a tracing warning when a singleton slot
is overwritten.
Plain always and plain fallback dispatch pass last: Option<Return> to each registered hook.
For always, last starts as Some(default_output). For fallback, last starts as None
because the default is skipped whenever any hook is registered.
Chain Aggregate Finalize
Hook slots can opt into companion behaviours:
| Option | Companion trait | Effect |
|---|---|---|
chain = Type |
Chain<Output> |
Inspect each output and stop dispatch when it returns ControlFlow::Break(()). |
aggregate = Type |
Aggregate<Output> or AggregateInto<Input, Output> |
Collect multiple hook outputs and reduce them into one value. |
finalize = Type |
Finalize<Output> or FinalizeInto<Input, Output> |
Transform the final dispatch result before returning it. |
output = Type |
Used with aggregate/finalize | Changes the hook-set method return type while hook implementations still return the method's declared type. |
aggregate and finalize are mutually exclusive. output requires either aggregate or
finalize.
#[(Default)]
struct Join;
impl lutum::Aggregate<String> for Join {
async fn call(&self, outputs: Vec<String>) -> String {
outputs.join(", ")
}
}
#[]
trait LabelHooks {
#[(,= Join)]
async fn label(name: &str) -> String {
format!("default:{name}")
}
}Built-in companions:
| Type | Behaviour |
|---|---|
ShortCircuit<T, E> |
A Chain<Result<T, E>> that stops on the first Err. |
FirstSuccess<T> |
A Chain<Option<T>> that stops on the first Some. |
Companion trait implementations should also be written with async fn; no #[async_trait] is
needed.
fallback + chainruns hooks when present. If every hook continues, it falls back to the default at the end.fallback + aggregatedoes not run the default when hooks are present. With no hooks, the default is returned directly, or aggregated as a single item whenoutput = Typeis used.always + aggregateincludes the default output as the first aggregate item.chainsees outputs beforeaggregateorfinalize.
Stateful Hooks
Use Stateful<T> when a hook needs mutable state. Implement the generated
Stateful<SlotName> trait for your state type, then register Stateful::new(value).
use lutum::Stateful;
struct Counter {
next: usize,
}
impl StatefulNextId for Counter {
async fn call_mut(&mut self, seed: usize, last: Option<usize>) -> usize {
let current = self.next.max(last.unwrap_or(seed));
self.next = current + 1;
current
}
}
let hooks = IdHooksSet::new().with_next_id(Stateful::new(Counter { next: 0 }));Stateful hooks are protected by an internal async mutex. Re-entering the same stateful hook while
it is already running calls on_reentrancy(err). The default implementation panics, but a
stateful hook can override it and return a slot-specific value.
Lifetimes and Send
Hook sets have a lifetime: <TraitName>Set<'h>. Hook objects only need to live for 'h; they no
longer need to be 'static, and they do not need to implement Clone.
The hook set itself derives Clone. Cloning a hook set clones the internal Arc handles, so both
sets point at the same registered hook objects.
struct Borrowed<'a> {
table: &'a std::collections::HashSet<String>,
}
#[(AppHooksSet)]
impl<'a> AppHooks for Borrowed<'a> {
async fn choose_model(&self, task: &str, _last: Option<String>) -> String {
if self.table.contains(task) {
"special".to_string()
} else {
"default".to_string()
}
}
}
let table = std::collections::HashSet::new();
let policy = Borrowed { table: &table };
let hooks = AppHooksSet::new().with_hooks(&policy);Native targets use MaybeSend and MaybeSync as aliases for Send and Sync requirements.
Wasm targets make these marker traits no-ops, so hook futures may be !Send on
wasm32-unknown-unknown.
A Lutum, adapter, or other owner that stores hooks behind an existing 'static boundary may
still require a ...HooksSet<'static>. Borrowed non-'static hooks are usable where the hook set
itself stays local.
Argument Handling
The dispatch method on <TraitName>Set keeps the argument types from the hook definition.
Generated per-slot traits normalize arguments for replay:
| Slot argument | Per-slot hook trait parameter |
|---|---|
&str |
String |
&T where T != str |
&T |
T |
T |
&str is owned as String internally so multiple hooks can receive the same value without
lifetime-heavy closure bounds. #[impl_hooks] adapts this back to the implementation trait, so an
impl AppHooks for Data method can still take &str.
Owned by-value arguments require Clone on the hook-set dispatch method when the value may need
to be replayed through the default and multiple hooks. Non-str references are passed by
reference and do not require cloning the referent.
Macro Limits
#[hooks] currently supports plain, non-generic traits only:
- no trait generics,
- no supertraits,
- no associated items,
- no methods without default bodies,
- no
lastparameter in the hook-set trait definition.
#[hook(custom = path)] is an internal extension point used by generated tool hooks. It marks a
fallback slot as a custom pipeline and is not a general user extension mechanism today.
Built-in Hook Sets
Lutum exports built-in hook traits and hook sets:
| Trait | Owner | Used by |
|---|---|---|
LutumHooks |
LutumHooksSet<'h> |
Lutum::with_hooks(...) and Lutum::from_parts_with_hooks(...) |
OpenAiHooks |
OpenAiHooksSet<'h> |
OpenAiAdapter::with_hooks(...) |
ClaudeHooks |
ClaudeHooksSet<'h> |
ClaudeAdapter::with_hooks(...) |
{Tools}Hooks |
{Tools}HooksSet<'h> |
Generated by #[derive(Toolset)]; see docs/tools.eure |
Adapter/context hook sets are commonly stored at 'static, so borrowed hooks only work there if
the surrounding adapter/context also has a compatible lifetime.
LutumHooks currently provides runtime-level hooks:
| Method | Kind | When it runs |
|---|---|---|
resolve_usage_estimate |
singleton |
Before budget reservation for every text turn, structured turn, completion, and structured completion. |
on_model_input |
always |
After defaults/extensions are applied and before a text or structured turn is erased for the adapter. |
on_stream_event |
always |
For every adapter stream event before Lutum maps it into typed user-facing events and reducers consume it. |
on_model_input receives ModelInputHookContext, which exposes extensions(), kind(), and
input(). It is available for operations backed by ModelInput: text turns and structured turns.
on_stream_event receives StreamEventHookContext, which exposes extensions(), kind(), and
event(). event() returns a LutumStreamEvent enum:
| Variant | Payload |
|---|---|
TextTurn(&ErasedTextTurnEvent) |
Raw text-turn adapter event, before tool availability checks and typed tool parsing. |
StructuredTurn(&ErasedStructuredTurnEvent) |
Raw structured-turn adapter event, before semantic output deserialization and typed tool parsing. |
Completion(&CompletionEvent) |
Completion event. |
StructuredCompletion(&ErasedStructuredCompletionEvent) |
Raw structured-completion event, before semantic output deserialization. |
use std::sync::{Arc, Mutex};
use lutum::{
impl_hooks, Lutum, LutumHooks, LutumHooksSet, LutumStreamEvent,
ModelInputHookContext, StreamEventHookContext,
};
struct Observer {
seen: Arc<Mutex<Vec<&'static str>>>,
}
#[(LutumHooksSet)]
impl LutumHooks for Observer {
async fn on_model_input(&self, cx: &ModelInputHookContext<'_>) {
let _input = cx.input();
self.seen.lock().unwrap().push("input");
}
async fn on_stream_event(&self, cx: &StreamEventHookContext<'_>) {
match cx.event() {
LutumStreamEvent::TextTurn(_) => self.seen.lock().unwrap().push("text-event"),
LutumStreamEvent::StructuredTurn(_) => self.seen.lock().unwrap().push("structured-event"),
LutumStreamEvent::Completion(_) => self.seen.lock().unwrap().push("completion-event"),
LutumStreamEvent::StructuredCompletion(_) => {
self.seen.lock().unwrap().push("structured-completion-event")
}
}
}
}
let observer = Observer {
seen: Arc::new(Mutex::new(Vec::new())),
};
let hooks = LutumHooksSet::new().with_hooks(observer);
let llm = Lutum::with_hooks(adapter, budget, hooks);You can add runtime hooks after constructing Lutum:
let mut llm = Lutum::new(adapter, budget);
llm.extend_hooks(LutumHooksSet::new().with_hooks(observer));For chained construction, use with_extended_hooks(...).
RequestExtensions
RequestExtensions is a per-request opaque type map. Use .ext(value) or .extensions(map) on
turn builders to attach typed metadata. Hook implementations, adapters, and model selectors can
read these values by type.
#[(Clone, Debug)]
struct Tenant(&'static str);
let result = session
.text_turn(&llm)
.ext(Tenant("eu-prod"))
.collect()
.await?;