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;

#[hooks]
trait AppHooks {
    #[hook(always)]
    async fn validate_prompt(prompt: &str) -> Result<(), String> {
        if prompt.trim().is_empty() {
            Err("prompt must not be empty".into())
        } else {
            Ok(())
        }
    }

    #[hook(fallback)]
    async fn choose_model(task: &str) -> String {
        let _ = task;
        "gpt-4.1-mini".to_string()
    }

    #[hook(singleton)]
    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};

#[hooks]
trait AppHooks {
    #[hook(always)]
    async fn validate_prompt(prompt: &str) -> Result<(), String> {
        Ok(())
    }

    #[hook(fallback)]
    async fn choose_model(task: &str) -> String {
        "gpt-4.1-mini".to_string()
    }
}

struct Policy<'a> {
    prefix: &'a str,
}

#[impl_hooks(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)].

Note
  • Methods in #[impl_hooks] must be async 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 always and plain fallback slots, include trailing last: Option<Return> in the impl method. For slots with chain, aggregate, or custom, do not include last.

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;

#[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.

#[derive(Default)]
struct Join;

impl lutum::Aggregate<String> for Join {
    async fn call(&self, outputs: Vec<String>) -> String {
        outputs.join(", ")
    }
}

#[hooks]
trait LabelHooks {
    #[hook(always, aggregate = 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.

Note
  • fallback + chain runs hooks when present. If every hook continues, it falls back to the default at the end.
  • fallback + aggregate does not run the default when hooks are present. With no hooks, the default is returned directly, or aggregated as a single item when output = Type is used.
  • always + aggregate includes the default output as the first aggregate item.
  • chain sees outputs before aggregate or finalize.

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>,
}

#[impl_hooks(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 last parameter 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>>>,
}

#[impl_hooks(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.

#[derive(Clone, Debug)]
struct Tenant(&'static str);

let result = session
    .text_turn(&llm)
    .ext(Tenant("eu-prod"))
    .collect()
    .await?;