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.
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.
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.
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.
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.
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, ToolProvider, ToolResult,
};
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<'_>) -> ToolResult {
match call.name {
"weather_lookup" => {
let args: WeatherArgs = match serde_json::from_value(call.args.clone()) {
Ok(args) => args,
Err(err) => return ToolResult::err_fmt(format_args!("invalid args: {err}")),
};
let report = lookup_weather(args).await;
match serde_json::to_value(report) {
Ok(value) => ToolResult::ok(value),
Err(err) => ToolResult::err_fmt(format_args!("serialize output: {err}")),
}
}
other => ToolResult::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
pub progress: Option<&'a ProgressSender>, // For streaming progress updates
}
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
ToolContext exposes named capabilities instead of a broad runtime host handle. Tools ask for exactly the capability they need: the current model, the current session snapshot, the flat tool catalog, Tool Catalog membership control, child-session client, process admin, process event append, borrowed durable effects, or a direct completion.
| Method | Use it for |
|---|---|
context.sessions().model().await? | Read the active session's concrete model and optional 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. Durable background work uses process handles granted to sessions, not child-turn handles. |
context.processes() | Session-scoped process admin: start a process from a tool, await its output, and list the process handles granted to the session. |
context.process_events().emit(...).await? | Append typed process events from a tool that is already executing inside a durable process. |
context.durable_effects()? | Borrow the caller's durable effect boundary for advanced in-process tools. Use it only from prepared tool calls running under a controller that supports durable tool effects. |
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, usage attribution, and stable direct-effect replay keys; set DirectRequest::replay when one tool call issues multiple otherwise-identical direct requests. |
use lash::direct::{DirectOutputSpec, DirectRequest};
async fn rank(call: ToolCall<'_>) -> ToolResult {
let model = match call.context.sessions().model().await {
Ok(model) => model,
Err(err) => return ToolResult::err_fmt(format_args!("{err}")),
};
let request = DirectRequest {
model: model.model,
model_variant: model.model_variant,
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) => ToolResult::ok(serde_json::json!({ "text": completion.text })),
Err(err) => ToolResult::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.
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.
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 discovery tools itself; the lash-cli MCP-discovery example is the reference implementation.)
The CLI reference path keeps large MCP tool sets non-resident in RLM mode: search_tools indexes resident members plus an explicit deferred MCP catalog, the prompt renders a catalogue-preview contribution for that deferred tail, and chosen Lashlang call paths execute only through a recorded DeferredToolResolver grant. Validate the full loop with cargo test -p lash-cli cli_mcp_discovery_loop_discovers_previews_searches_resolves_and_executes --features test-provider -- --nocapture.
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.
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 scheduling, deferred completion, durable effects, and tool-output budgeting.