Tools are declared with macros but never executed by the library. User code owns the tool loop. Lutum handles schema generation, provider request configuration, tool-call decoding, hook-driven pre-execution decisions, and transcript-safe commit validation.

On This Page

Define Tool Input

Use #[tool_input] when you want to define the input type yourself. The input type must be serializable, deserializable, schema-bearing, cloneable, and 'static; the output type has the same requirements.

use lutum::tool_input;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
struct SearchResult {
    snippets: Vec<String>,
}

#[tool_input(name = "web_search", output = SearchResult)]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
struct WebSearchArgs {
    /// The query to search for.
    query: String,
    /// Maximum number of results to return.
    max_results: Option<u32>,
}

The macro generates a call wrapper named <Input>Call, for example WebSearchArgsCall.

Method Meaning
.metadata() Access the ToolMetadata (id, name, raw arguments).
.input() Read the decoded input.
.input_mut() Mutate the decoded input before completing.
.into_input() Take only the decoded input.
.into_parts() Take metadata and decoded input.
.complete(output) Convert an output into a provider-ready ToolResult.
.handled(output) Build a HandledTool<Input, Output> for hook-short-circuited calls.

Doc comments on fields become JSON Schema descriptions. #[tool_input] also supports enums; the enum schema is used as the single tool's input schema.

Define Tool Function

Use #[tool_fn] when the tool can be represented as a plain async Rust function. The function must return Result<Output, Error>.

use lutum::tool_fn;

#[tool_fn]
async fn web_search(
    /// The query to search for.
    query: String,
    /// Maximum number of results to return.
    max_results: Option<u32>,
) -> Result<SearchResult, SearchError> {
    Ok(SearchResult {
        snippets: vec![format!("Result for: {query}")],
    })
}

#[tool_fn] generates:

Item Example
Input struct WebSearchInput
Call wrapper WebSearchInputCall
ToolInput impl Uses the function name as the default tool name
.call() helper Executes the original async function and returns its Result

Function parameter doc comments become field descriptions. Parameters annotated with #[skip] are not exposed to the model schema and must be supplied by execution context.

Derive Toolset

Group the tools available in one turn with #[derive(Toolset)] on an enum. Each regular variant must contain exactly one ToolInput payload.

use lutum::Toolset;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Toolset)]
enum AppTools {
    WebSearch(WebSearchArgs),
    ReadFile(ReadFileArgs),
    WriteFile(WriteFileArgs),
}

The derive generates:

Type Meaning
AppToolsCall Decoded tool-call enum, one variant per tool.
AppToolsHandled Hook-handled call enum, one variant per tool.
AppToolsSelector Selector enum used by availability, requirements, and description overrides.
AppToolsHooks Real hook implementation trait generated via #[hooks].
AppToolsHooksSet<'h> Hook owner/registry generated via #[hooks].

The toolset itself implements Toolset; toolsets with generated hooks also implement HookableToolset.

Selectors expose all(), name(), definition(), definitions(), try_from_name(), and from_name(). For nested toolsets, try_from_name() delegates to flattened nested selectors.

The generated call enum exposes:

Method Meaning
.metadata() Provided by ToolCallWrapper.
.selector() Return the generated selector for this call.
.into_input() Convert into the original toolset enum containing input values.
.into_parts() Return metadata and the original input enum.
.hook(&hooks) Apply generated tool hooks to one call.

Availability and Requirements

ToolAvailability<S> controls which tool definitions are sent to the model:

Value Meaning
Default All default-on tools. This is the default.
All Every tool, including default-off tools.
Only(Vec<S>) Exactly the listed selectors; ignores default-on/off status.
DefaultPlus(Vec<S>) Default-on tools plus specific extra selectors.

Mark a regular variant #[tool(off)] to hide that tool from Default. Mark a nested toolset variant #[toolset(off)] to hide the whole nested group from Default.

#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Toolset)]
enum AppTools {
    WebSearch(WebSearchArgs),
    ReadFile(ReadFileArgs),
    #[tool(off)]
    DeleteFile(DeleteFileArgs),
}

let outcome = session
    .text_turn(&llm)
    .tools::<AppTools>()
    .available_tools_default_plus([AppToolsSelector::DeleteFile])
    .collect()
    .await?;

ToolRequirement<S> controls what the model is asked to do:

Value Builder Meaning
Optional default The model may call tools or answer directly.
AtLeastOne .require_any_tool() The model should call at least one available tool.
Specific(S) .require_tool(selector) The model should call a specific available tool.

If a specific required tool is not in the resolved available set, Lutum returns an invalid tool constraint error before sending the request.

Execute Calls

Execution stays in user code. When a turn returns NeedsTools(round), match on round.tool_calls, execute the requested functions, then commit exactly one result for each assistant tool call.

use lutum::TextStepOutcomeWithTools;

loop {
    let outcome = session.text_turn(&llm).tools::<AppTools>().collect().await?;

    match outcome {
        TextStepOutcomeWithTools::NeedsTools(round) => {
            let mut results = Vec::new();

            for call in round.tool_calls.iter().cloned() {
                let result = match call {
                    AppToolsCall::WebSearch(c) => {
                        c.complete(web_search(c.input().clone()).await?)?
                    }
                    AppToolsCall::ReadFile(c) => {
                        c.complete(read_file(c.input().clone()).await?)?
                    }
                    AppToolsCall::WriteFile(c) => {
                        c.complete(write_file(c.input().clone()).await?)?
                    }
                };
                results.push(result);
            }

            round.commit(&mut session, results)?;
        }
        TextStepOutcomeWithTools::Finished(result) => break result,
    }
}

Commit validation checks missing, extra, duplicate, and mismatched tool results. Results are ordered to match the assistant turn before being appended to the transcript.

Convenience methods:

Method Meaning
round.expect_one()? Return exactly one call or error.
round.expect_at_most_one()? Return zero or one call or error.
round.discard() Drop the uncommitted assistant turn.

Tool Hooks Overview

#[derive(Toolset)] also generates a hook set. For enum AppTools, the hook trait is AppToolsHooks and the owner is AppToolsHooksSet<'h>.

For each regular tool variant, two hook slots are generated:

Tool variant Execution hook Description hook
Weather(WeatherArgs) method weather_hook, trait WeatherHook method weather_description_hook, trait WeatherDescriptionHook

Execution hooks run before your tool function. Description hooks compute dynamic schema description overrides before the request is sent.

use lutum::{ToolDecision, ToolMetadata, impl_hooks};

struct Policy;

#[impl_hooks(AppToolsHooksSet)]
impl AppToolsHooks for Policy {
    async fn weather_hook(
        &self,
        metadata: &ToolMetadata,
        input: WeatherArgs,
    ) -> ToolDecision<WeatherArgs, WeatherResult> {
        let _ = metadata;
        if input.city.trim().is_empty() {
            ToolDecision::Reject("city must not be blank".into())
        } else {
            ToolDecision::RunNormally(input)
        }
    }

    async fn weather_description_hook(&self, def: &lutum::ToolDef) -> Option<String> {
        Some(format!("{}. Use metric units.", def.description))
    }
}

let hooks = AppToolsHooksSet::new().with_hooks(Policy);

Tool hook sets support the same hook API as ordinary #[hooks] sets:

API Use
.with_weather_hook(h) / .register_weather_hook(h) Register one execution hook.
.with_weather_description_hook(h) Register one description hook.
.with_hooks(policy) / .register_hooks(policy) Register multiple methods from one #[impl_hooks] implementation.
.with_extended(other) / .extend(other) Merge another hook set of the same type.
Borrowed policy values AppToolsHooksSet::new().with_hooks(&policy) stores hooks for the hook-set lifetime.

Single-function #[impl_hook(WeatherHook)] remains supported when one function should implement one slot.

Tool Hook Decisions

Execution hooks return ToolDecision<Input, Output>.

Variant Effect
RunNormally(input) Continue the hook pipeline with this possibly edited input. If no later hook handles the call, user code executes the tool with this input.
Complete(output) Stop the hook pipeline and commit this output as a handled tool result. User code does not execute the tool.
Reject(reason) Stop the hook pipeline and commit a standard rejection result for the tool call.

Tool execution hooks are generated as #[hook(fallback, custom = tool_decision_pipeline)]. The default is RunNormally(input).

Pipeline details:

  • With no registered hooks, the outcome is Unhandled(original_call).
  • Registered hooks run in registration order.
  • RunNormally(new_input) threads new_input into the next hook.
  • Complete(output) becomes ToolHookOutcome::Handled(AppToolsHandled::...).
  • Reject(reason) becomes ToolHookOutcome::Rejected(RejectedToolCall { source: Hook, ... }).
  • If every hook returns RunNormally, the final edited input is written back to the pending call.

The pipeline logs decisions to the lutum::tool_hook tracing target with tool name, call id, decision, and effective input JSON where available.

#[lutum::impl_hook(WeatherHook)]
async fn normalize_weather(
    _metadata: &lutum::ToolMetadata,
    mut input: WeatherArgs,
) -> lutum::ToolDecision<WeatherArgs, WeatherResult> {
    input.city = input.city.trim().to_string();
    lutum::ToolDecision::RunNormally(input)
}

#[lutum::impl_hook(WeatherHook)]
async fn cache_tokyo(
    _metadata: &lutum::ToolMetadata,
    input: WeatherArgs,
) -> lutum::ToolDecision<WeatherArgs, WeatherResult> {
    if input.city == "Tokyo" {
        lutum::ToolDecision::Complete(WeatherResult {
            forecast: "cached".into(),
        })
    } else {
        lutum::ToolDecision::RunNormally(input)
    }
}

let hooks = AppToolsHooksSet::new()
    .with_weather_hook(NormalizeWeather)
    .with_weather_hook(CacheTokyo);

Apply Hooks

Apply hooks to a whole tool round with round.apply_hooks(&hooks).await. This returns ToolRoundPlan<T>.

let plan = round.apply_hooks(&tool_hooks).await;

let mut pending_results = Vec::new();
for call in plan.pending_calls().iter().cloned() {
    let result = match call {
        AppToolsCall::WebSearch(c) => c.complete(web_search(c.input().clone()).await?)?,
        AppToolsCall::ReadFile(c) => c.complete(read_file(c.input().clone()).await?)?,
        AppToolsCall::WriteFile(c) => c.complete(write_file(c.input().clone()).await?)?,
    };
    pending_results.push(result);
}

plan.commit(&mut session, pending_results)?;

ToolRoundPlan<T> exposes:

Field/accessor Meaning
pending / pending_calls() Calls that still need user-code execution.
handled / handled_calls() Calls short-circuited by hooks.
rejected / rejected_calls() Calls rejected by hooks.
recoverable_tool_call_issues() Calls that were invalid, unknown, or unavailable but recoverable.
continue_suggestion() Optional hint that the model should continue after recoverable issues.
apply_hooks(&more_hooks) Apply another hook pass to only the remaining pending calls.
commit(session, pending_results) Commit handled + rejected + pending results.
discard() Drop the uncommitted assistant turn.

commit automatically includes handled and rejected hook results. pending_results should only contain results for calls still in pending.

Recoverable tool-call issues are auto-committed as standard rejection results unless you provide an explicit ToolResult for the same call id. This lets the transcript remain provider-valid even when the model emitted an unavailable tool name or invalid arguments.

ToolHooks Trait

ToolHooks<T> abstracts over anything that can hook a HookableToolset. AppToolsHooksSet<'h> implements it automatically, and apply_hooks accepts any H: ToolHooks<AppTools>.

There is also a blanket implementation for closures:

let hook = |call: AppToolsCall| async move {
    match call {
        AppToolsCall::WebSearch(c) if c.input().query == "secret" => {
            lutum::ToolHookOutcome::Rejected(lutum::RejectedToolCall::from_call(
                lutum::RejectedToolSource::Hook,
                AppToolsCall::WebSearch(c),
                "blocked query",
            ))
        }
        other => lutum::ToolHookOutcome::Unhandled(other),
    }
};

let plan = round.apply_hooks(&hook).await;

The closure path is useful for ad-hoc tests or one-off policies. Generated hook sets are usually better for application code because they preserve per-tool typing and compose with #[impl_hooks].

Description Hooks

Tool descriptions can be overridden in two ways:

Mechanism Scope
Builder .describe_tool(selector, text) Directly overrides one turn.
Builder .describe_many_tools(overrides) Directly overrides many descriptions on one turn.
Generated *_description_hook slots Compute overrides from hook policy code.

Builder overrides are applied when the adapter turn config is erased. Last override wins for the same selector.

let dynamic = tool_hooks.description_overrides().await;

let outcome = session
    .text_turn(&llm)
    .tools::<AppTools>()
    .describe_many_tools(dynamic)
    .describe_tool(AppToolsSelector::WebSearch, "Search only public sources.")
    .collect()
    .await?;

Description hooks are singleton slots. The default returns None; a registered hook returns Some(description) to override that tool. For nested toolsets, the outer hook set maps nested selectors back into the outer selector enum.

#[lutum::impl_hook(WeatherDescriptionHook)]
async fn weather_desc(def: &lutum::ToolDef) -> Option<String> {
    Some(format!("{} Respond with JSON-safe values.", def.description))
}

let hooks = AppToolsHooksSet::new().with_weather_description_hook(WeatherDesc);
let overrides = hooks.description_overrides().await;

Nested Toolsets

Mark a variant with #[toolset] to flatten another toolset into the parent. The nested payload type must implement Toolset + HookableToolset.

#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Toolset)]
enum DataTools {
    GetUser(GetUserArgs),
    ListOrders(ListOrdersArgs),
}

#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Toolset)]
enum AppTools {
    WebSearch(WebSearchArgs),
    #[toolset]
    Data(DataTools),
}

For a nested DataTools variant, the generated outer hook set contains a public field named from the variant in snake case:

Item Example
Inner hook set DataToolsHooksSet<'h>
Outer hook set AppToolsHooksSet<'h>
Outer field app_hooks.data
Constructor AppToolsHooksSet::new(DataToolsHooksSet::new())

Nested hook dispatch delegates to the inner hook set. Handled calls are wrapped back into the outer handled enum; rejected calls map their stored call into the outer call enum.

#[lutum::impl_hook(GetUserHook)]
async fn cached_alice(
    _metadata: &lutum::ToolMetadata,
    input: GetUserArgs,
) -> lutum::ToolDecision<GetUserArgs, User> {
    if input.user_id == "alice" {
        lutum::ToolDecision::Complete(User { name: "Alice".into() })
    } else {
        lutum::ToolDecision::RunNormally(input)
    }
}

let mut hooks = AppToolsHooksSet::new(DataToolsHooksSet::new());
hooks.data.register_get_user_hook(CachedAlice);

let plan = round.apply_hooks(&hooks).await;

Nested #[toolset(off)] hides the entire nested group from ToolAvailability::Default. ToolAvailability::All, Only, and DefaultPlus can still expose nested selectors explicitly.

Quirks and Limits

  • Tool execution is never automatic. A hook can short-circuit or reject a call, but pending calls still require user code.
  • Tool hook input types are owned. Mutating input in RunNormally(input) changes the pending call that user code sees.
  • Hook-rejected calls are committed as tool results containing Lutum's standard rejection prefix, so providers see a normal tool result item.
  • ToolInput and Toolset remain Send + Sync + 'static today. The non-'static relaxation is for hook objects, not tool schemas or decoded calls.
  • Generated hook owners are named ...HooksSet, not ...Hooks.
  • Tool hook sets may borrow hook policy values for the hook-set lifetime, but a hook set stored behind a 'static boundary still needs 'static hooks.
  • Native targets require hook objects/futures to satisfy MaybeSend/MaybeSync (Send/Sync); wasm targets make those markers no-ops.