tools

Expose host capabilities as model-callable operations. For fixed app tools, start with StaticToolProvider and StaticToolExecute; implement raw ToolProvider only when manifests, contracts, or provider behavior must be dynamic.

Where Tools Live

Tools are a runtime surface, not a plugin requirement. Start with the fixed-provider helper for ordinary app tools; add raw providers or plugins only when the capability really needs that surface.

fixed app tools

Use lash::tools::StaticToolProvider plus StaticToolExecute when the set of tools is known when the provider is built. This is the common path for app-owned APIs, database actions, file operations, search, and remote-service wrappers. Install it with LashCoreBuilder::tools(...), plugin registration, or the core/session tool admin.

dynamic provider

Implement raw ToolProvider when tool_manifests, resolve_contract, prepare_tool_call, or execution dispatch depends on changing external state, negotiated remote manifests, plugin policy, or a custom provider cache.

plugin provider

Register the provider through reg.tools().provider(...) when the tool travels with plugin-owned prompt context, turn hooks, tool-result projection, typed per-turn input, or resumable session-local state. The tool still executes through the same ToolProvider contract.

remote work

The remote protocol can describe the granted Tool Catalog, but it does not execute tools for you. If a tool calls HTTP, a queue, a workflow, or another service, put that client code inside a normal ToolProvider and keep the runtime-facing contract local.

Atomic Leaf Tools and Durable Intents

Leaf providers opt into AttemptContext and return recorded declarations for durable follow-on work.

Return ToolAttemptOutcome::Done { result: ToolOutcomeDone, intents: ToolIntents::v1(...) }. The type deliberately makes Pending plus intents impossible. AttemptContext exposes recorded reads and local capabilities but no process-command or trigger-command client; use StartProcess, SignalProcess, CancelProcess, EmitProcessEvent, or EmitTrigger declarations instead.

Lash commits the final attempt before draining its declarations in source order. Each declaration receives a stable identity derived from the session, enclosing execution scope (turn id or process id), tool call id, and intent index. Realization is journal-first: command-time unknown, terminal, conflict, or replay outcomes are durable evidence, not decisions recomputed from live visibility. StartProcessIntent also records its complete environment/observer input and an on_parent_end policy of Abandon or Cancel (the default). At realization Lash replaces the request's host-chosen process id with the derived intent replay key; that derived key is the process id returned to and observed by the host. Abandon is a recorded no-op; Cancel issues one replay-keyed cancellation with a recorded typed outcome. Lash deliberately has no hard-kill primitive, so engines keep kill semantics and the v1 parent-end policy does not pretend to offer Temporal's Terminate behavior. A future hard-kill policy must arrive together with a real process primitive under reject-and-recreate versioning.

Declare attempt_may_defer only when the leaf can actually return Pending; Lash reserves a process-lifetime completion key only for declared-deferable providers.

Multi-step tools

Public tool providers are atomic leaf attempts. First-party facade components that need to await durable work use a private process-replay seam; it is not a fifth tool-provider integration class.

The built-in protocol batch and spawn_agent implementations use that private seam. Their durable commands are direct children of the enclosing replay invocation and their orchestration bodies have no ToolAttempt frame. External tool authors should decompose multi-step work into processes rather than depending on this facade-support implementation detail.

The determinism contract is strict: orchestration code must not read wall clock or randomness, let unordered iteration choose command order, perform unjournaled I/O, or leave a journaled action un-awaited. If a tool only needs to cause durable work, keep it leaf-shaped and return an intent instead.

Fixed Tools

StaticToolProvider::new(...) derives manifests and full contracts from the supplied ToolDefinitions once, then serves them from a cache. Your executor only owns runtime state and behavior.

Use ToolDefinition::typed when Rust argument and output structs can derive JsonSchema. Lash validates the incoming JSON against the contract before execution; the tool still decodes call.args into its local Rust type inside execute().

use std::sync::Arc;

use async_trait::async_trait;
use lash::tools::{
    StaticToolExecute, StaticToolProvider, ToolCall, ToolDefinition, ToolOutcome, ToolProvider,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize, JsonSchema)]
struct WeatherArgs {
    city: String,
    units: Option<String>,
}

#[derive(Serialize, JsonSchema)]
struct WeatherReport {
    summary: String,
    temperature_c: f32,
}

struct WeatherTools;

#[async_trait]
impl StaticToolExecute for WeatherTools {
    async fn execute(&self, call: ToolCall<'_>) -> ToolOutcome {
        match call.name {
            "weather_lookup" => {
                let args: WeatherArgs = match serde_json::from_value(call.args.clone()) {
                    Ok(args) => args,
                    Err(err) => return ToolOutcome::err_fmt(format_args!("invalid args: {err}")),
                };

                let report = lookup_weather(args).await;
                match serde_json::to_value(report) {
                    Ok(value) => ToolOutcome::ok(value),
                    Err(err) => ToolOutcome::err_fmt(format_args!("serialize output: {err}")),
                }
            }
            other => ToolOutcome::err_fmt(format_args!("unknown tool: {other}")),
        }
    }
}

pub fn weather_provider() -> Arc<dyn ToolProvider> {
    let definition = ToolDefinition::typed::<WeatherArgs, WeatherReport>(
        "tool:weather_lookup",
        "weather_lookup",
        "Look up the current weather for a city.",
    );

    Arc::new(StaticToolProvider::new(vec![definition], WeatherTools)) as Arc<dyn ToolProvider>
}

async fn lookup_weather(args: WeatherArgs) -> WeatherReport {
    let units = args.units.unwrap_or_else(|| "metric".to_string());
    WeatherReport {
        summary: format!("{} weather in {}", units, args.city),
        temperature_c: 21.0,
    }
}

The ToolCall<'_> Shape

Every execute() call receives a borrowed view of the invocation. Don't store the reference; copy what you need into owned types.

pub struct ToolCall<'a> {
    pub name: &'a str,                           // Advertised tool name the model called
    pub args: &'a serde_json::Value,             // Deserialized JSON args
    pub context: &'a ToolContext<'a>,            // Explicit session/tool capabilities
}

Tools receive plain JSON arguments. In RLM mode, host-projected Lashlang values are normalized through each tool's ToolArgumentProjectionPolicy before schema validation and before execute(); ordinary tools use the default materializing policy and should not know about the internal projection marker. Projection-aware RLM control tools declare PreserveProjectedRefsInField { field: "seed" }, so only root seed entries keep projected wrappers and ref-backed entries keep {"__projection_ref__": ...} for child or AgentFrames.

Typed per-turn input passed via TurnBuilder::with_plugin_input is read off the turn context, not the execute-time ToolContext: a tool's prepare_tool_call sees it through ctx.plugin_input::<MyInput>("plugin_id") on the ToolPrepareContext, and hooks read the same value from their TurnContext. See the Lash API · prompts guide's "Typed Plugin Input" section for the matching PluginBinding pattern.

Tool Capabilities

Legacy ToolContext implementations expose named read and session capabilities instead of a broad runtime host handle. Atomic leaf providers receive the sealed AttemptContext: they read through its projections and return typed ToolIntents for durable starts, signals, cancellations, and event emission. Independently durable composition belongs in processes.

MethodUse it for
context.sessions().model().await?Read the active session's concrete model and typed model_variant. Use this before building a DirectRequest that should match the current session.
context.sessions().snapshot_current().await?Read the current session snapshot when a tool truly needs more than model selection.
context.sessions().snapshot(session_id).await?Read another session snapshot through the tool context when the host grants that runtime capability.
context.sessions().tool_catalog().await?Read the current session's projected flat tool catalog (every member) for host-owned discovery/ranking tools.
context.sessions().set_tool_membership(names, present).await?Add or remove tools from the current session's Tool Catalog. Membership is the execution gate.
context.sessions()Cloneable child-session client: create_session, close_session, and one-shot start_turn. Inside atomic tool attempts, start_turn is available on runtime-owned and key-addressed tiers but returns a typed refusal on ordinal-addressed Restate tiers; decompose it into a process step. Durable background work uses process handles observed by sessions, not child-turn handles.
attempt.processes()Controller-free process reads for a leaf attempt. Durable starts, signals, cancellation, and event emission are returned as typed ToolIntents; multi-step durable work is authored as orchestration. The old leaf process-admin capability and its tier-specific refusals are removed.
context.triggers().emit(...).await?Emit a trigger occurrence. Inside atomic tool attempts this is available on runtime-owned and key-addressed tiers, but ordinal-addressed Restate tiers return a typed refusal; emit from a process step.
context.process_events().emit(...).await?Compatibility route for legacy ToolContext code already executing inside a durable process. It is deliberately unavailable from sealed AttemptContext; atomic leaf providers return EmitProcessEvent, while new multi-step work uses orchestration or a process step. Trigger emission follows the same rule: an atomic attempt returns an EmitTrigger declaration rather than emitting inside the attempt, so no occurrence can outlive an uncommitted cause.
context.direct_completions().complete(request, source).await?Run a one-shot LLM request. Missing session_id and caused_by are filled from the current tool call for tracing and usage attribution. Inside a tool attempt, the provider call is opaque work in that atomic attempt; outside an attempt it is an independently journaled direct effect.

Enable native batch for a non-Standard protocol

The Standard protocol installs its native batch operation automatically. A host with a different protocol driver opts in explicitly through the facade builder's plugin configuration:

fn enable_native_batch(builder: lash::LashCoreBuilder) -> lash::LashCoreBuilder {
    use lash::plugins::{PluginSpec, StaticPluginFactory};

    let spec = PluginSpec::new()
        .with_orchestrating_tool(lash_protocol_standard::standard_batch_orchestrating_tool());
    builder.plugin(Arc::new(StaticPluginFactory::new(
        "my-protocol-native-batch",
        spec,
    )))
}

Submit a host start and settle its parent policy

Hosts outside a turn use the scope-bound intent ingress. A start retains its default Cancel parent-end action durably; call settle_parent_end when the owning scope ends.

let ingress = core.tool_intents(SESSION, lash::runtime::ExecutionScope::process(PROCESS))?;
let key = ingress.key("spawn-report-worker", 0);
let intent = lash::tools::ToolIntent::StartProcess(Box::new(lash::tools::StartProcessIntent {
    session_id: SESSION.to_string(),
    request: lash::process::ProcessStartRequest::external(
        "replaced-by-the-intent-key",
        lash::process::ProcessOriginator::host_scoped("report-worker"),
        serde_json::json!({"report": 42}),
    ),
    on_parent_end: lash::tools::ProcessParentEndPolicy::Cancel,
}));
let lash::tools::ToolIntent::StartProcess(start_intent) = &intent else {
    unreachable!("the documented intent is a process start")
};
assert_eq!(start_intent.session_id, SESSION);
assert_eq!(
    start_intent.request.id.as_str(),
    "replaced-by-the-intent-key"
);
assert_eq!(intent.kind(), lash::tools::ToolIntentKind::StartProcess);
let admitted = ingress.submit(key, intent).await;
assert!(matches!(
    admitted,
    lash::tools::ToolIntentIngressOutcome::Admitted {
        replayed: false,
        ..
    }
));

// Call this when the owning process scope ends. A crash may safely redrive it.
let settled = ingress.settle_parent_end().await?;
assert_eq!(settled.len(), 1);
use lash::direct::{DirectOutputSpec, DirectRequest};

async fn rank(call: ToolCall<'_>) -> ToolOutcome {
    let model = match call.context.sessions().model().await {
        Ok(model) => model,
        Err(err) => return ToolOutcome::err_fmt(format_args!("{err}")),
    };

    let request = DirectRequest {
        model: model.model,
        model_variant: model.model_variant,
        model_capability: model.model_capability,
        messages: vec![/* ... */],
        attachments: Vec::new(),
        output: DirectOutputSpec::Text,
        generation: Default::default(),
        stream_events: None,
        session_id: None, // filled by ToolContext
        caused_by: None,  // filled by ToolContext
        replay: None,
    };

    match call
        .context
        .direct_completions()
        .complete(request, "my_tool")
        .await
    {
        Ok(completion) => ToolOutcome::ok(serde_json::json!({ "text": completion.text })),
        Err(err) => ToolOutcome::err_fmt(format_args!("{err}")),
    }
}

ToolContext exposes no public host() escape hatch. Runtime host wiring stays inside lash-core; plugin-facing code uses explicit context capabilities. Import direct-call request/result types from lash::direct; plugin authors should not depend on provider internals for one-shot model calls.

Tool Registry State

The model sees a projected flat ToolCatalog (membership is the execution gate), but the runtime owns a richer ToolRegistry: live tool sources, per-tool catalog membership, source identity, and a serializable ToolState snapshot.

sources

Static providers, plugin registrations, MCP providers, and host-added providers enter as registry sources. A source handle lets the host replace or remove one advertised surface without rebuilding the session.

projection

ToolRegistry resolves manifests and execution providers; ToolCatalog is the model-facing projection used by provider requests, host search_tools-style discovery, and Lashlang host surfaces. Membership changes advance the registry generation and refresh the session catalog. Lash ships no turnkey discovery tool; examples/agent-workbench is the production reference for composing one as host policy.

MCP discovery example

The Workbench keeps six low-frequency data utilities non-resident in RLM mode: tools.search searches the explicit deferred catalog and persists returned grants in SQLite, the prompt renders a deterministically capped catalogue preview, and chosen Lashlang call paths execute only through a recorded DeferredToolResolver grant. Search and call are intentionally separate blocks because call paths are gathered before linking. Validate the full loop with cargo test -p agent-workbench deferred_search_observation_enables_next_block_call -- --nocapture.

apply vs restore

apply_state is a generation-checked edit for a live registry. restore_state adopts a persisted generation during cold resume or worker rebuild; missing sources remain as orphaned non-members (excluded from the catalog) until a matching source returns.

admin API

App hosts normally use LashCoreBuilder::tools(...), plugin registration, or LashSession::tools() / LashSession::admin().tools(). The advanced admin exposes raw ToolState only for hosts that need to persist, migrate, or inspect the full registry surface.

Where Next

This page owns the Tool Catalog and registry. Use the execution guide for concurrent batches, deferred completion, atomic-attempt semantics, and tool-output budgeting.

read on ·