lash guide

writing/plugins

A plugin is the unit of extension in lash. It can register tools, contribute to prompts, observe turn lifecycle hooks, apply tool-output budgets, register monitors, mutate history, or expose typed actions. This page walks through the three traits you actually implement (PluginFactory, SessionPlugin, ToolProvider), a minimal worked example, and the contracts you need to know to avoid surprising the runtime.

Three Traits, Two Lifetimes

A plugin lives at two timescales: the factory exists for the lifetime of the LashCore and is shared across every session built from it; the session plugin exists for one LashSession. The factory is asked to build a fresh session plugin every time a session opens. Both must be cheap to construct.

PluginFactory

Per-core. Owns expensive, shared state. Builds a SessionPlugin on demand. Don't do I/O in build() — it runs on the hot path for every new session, every subagent, every fork, every compaction child.

pub trait PluginFactory: Send + Sync {
    fn id(&self) -> &'static str;
    fn build(
        &self,
        ctx: &PluginSessionContext,
    ) -> Result<Arc<dyn SessionPlugin>, PluginError>;
}

SessionPlugin

Per-session. Wires hooks into a PluginRegistrar during register(), then participates in the session for its lifetime. Optionally implements snapshot / restore for durability across resume.

pub trait SessionPlugin: Send + Sync {
    fn id(&self) -> &'static str;
    fn register(
        &self,
        reg: &mut PluginRegistrar,
    ) -> Result<(), PluginError>;
    // …snapshot / restore / session_ready hooks
}

ToolProvider

If your plugin exposes tools, implement this on a struct and hand it to reg.tools().provider(...). definitions() is sync and called often; cache the result. execute() is async and runs the actual tool work.

pub trait ToolProvider: Send + Sync + 'static {
    fn definitions(&self) -> Vec<ToolDefinition>;
    async fn execute(&self, call: ToolCall<'_>) -> ToolResult;
}

Default Stack

PluginStack is the app-facing plugin list. The preset constructors include the runtime defaults; the raw builder stays explicit for hosts that want to own every factory.

Start From Runtime Defaults

use std::sync::Arc;
use lash::{plugins::PluginFactory, LashCore};

let core = LashCore::rlm()
    .provider(provider)
    .model("gpt-5.4", None)
    .max_context_tokens(200_000)
    .configure_plugins(|plugins| {
        plugins.push(Arc::new(AppPluginFactory) as Arc<dyn PluginFactory>);
    })
    .build()?;

Replace Or Remove

use std::sync::Arc;
use lash::{
    plugins::ToolOutputBudgetPluginFactory, LashCore, ModeId, ModePreset, PluginStack,
};

let plugins = PluginStack::runtime().configure(|plugins| {
    plugins.replace(Arc::new(ToolOutputBudgetPluginFactory::new(config)));
    plugins.remove("some_optional_plugin");
});

let core = LashCore::builder()
    .install_mode(ModePreset::rlm())
    .default_mode(ModeId::rlm())
    .provider(provider)
    .model("gpt-5.4", None)
    .max_context_tokens(200_000)
    .plugins(plugins)
    .build()?;

Use .plugin(...) to append one factory, .plugins(...) to replace the full stack, and .configure_plugins(...) to mutate the current stack. PluginStack::runtime() currently installs the built-in ToolOutputBudgetPluginFactory, which budgets oversized tool outputs for state, model context, and history projection.

A Worked Example

The plan-mode plugin (lash-plugin-plan-mode/src/update_plan.rs) is a good single-file model. It exposes one tool (update_plan), keeps a session-local state shared between the tool and an after-tool hook, gates itself off in non-root sessions, and emits a TUI surface event when the plan changes. The skeleton:

use std::sync::{Arc, Mutex};
use lash::plugins::{
    PluginError, PluginFactory, PluginRegistrar, PluginSessionContext, SessionPlugin,
};
use lash::tools::{ToolCall, ToolDefinition, ToolProvider, ToolResult};

const PLUGIN_ID: &str = "update_plan";

// 1. Factory — per-core, cheap to build.
pub struct UpdatePlanPluginFactory;

impl PluginFactory for UpdatePlanPluginFactory {
    fn id(&self) -> &'static str { PLUGIN_ID }

    fn build(
        &self,
        ctx: &PluginSessionContext,
    ) -> Result<Arc<dyn SessionPlugin>, PluginError> {
        Ok(Arc::new(UpdatePlanPlugin {
            // Gate by context: only enable in root sessions.
            active: ctx.is_root_session(),
            state: Arc::new(Mutex::new(PlanState::default())),
        }))
    }
}

// 2. SessionPlugin — per-session, wires hooks during register().
struct UpdatePlanPlugin {
    active: bool,
    state: Arc<Mutex<PlanState>>,
}

impl SessionPlugin for UpdatePlanPlugin {
    fn id(&self) -> &'static str { PLUGIN_ID }

    fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
        if !self.active { return Ok(()); }
        reg.tools().provider(Arc::new(UpdatePlanTool {
            state: Arc::clone(&self.state),
        }))?;
        Ok(())
    }
}

// 3. ToolProvider — definitions() advertises, execute() runs.
struct UpdatePlanTool { state: Arc<Mutex<PlanState>> }

#[async_trait::async_trait]
impl ToolProvider for UpdatePlanTool {
    fn definitions(&self) -> Vec<ToolDefinition> {
        vec![ToolDefinition::raw(
            "update_plan",
            "Publish or replace the current plan.",
            serde_json::json!({ "type": "object", "properties": { /* … */ } }),
            serde_json::json!({}),
        )]
    }

    async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
        // Read call.args, validate, mutate self.state, emit ToolResult::ok(...).
        ToolResult::ok(serde_json::json!({ "generation": 1 }))
    }
}

Real plugins typically register multiple hooks in the same register() call (e.g. a tool plus an after-tool hook that emits a UI event, plus a prompt contribution describing the tool). See lash-plugin-plan-mode/src/update_plan.rs:280-330 for the full version.

What You Can Register

The PluginRegistrar exposes hook namespaces. Each returns a small builder you push closures into. The most common groups:

MethodWhat you register
reg.tools()One or more ToolProvider implementations.
reg.prompt()Async PromptContribution producers — system/environment/tool-doc blocks composed into the model prompt.
reg.surface()Tool-surface overrides — change which tools are visible / callable / showcased per session or per turn.
reg.discovery()Hints for the tool-discovery ranker (lexical, embedding, schema indexing).
reg.turn()Before-turn / after-turn / checkpoint hooks.
reg.tool_calls()Before-tool / after-tool hooks. Useful for emitting UI events or mutating session state in response to specific tools.
reg.output()Hooks on assistant streaming output and final responses.
reg.tool_results()Tool-output budgeting and projection. Exclusive — only one plugin can register a projector per session.
reg.session()Runtime-event observers and config mutators.
reg.actions()Plugin actions — typed or raw RPC-shaped APIs the host can invoke without going through tool calls.
reg.monitors()Monitor specs (long-running watches that the plugin maintains across turns).
reg.history()History transforms and rewrites that run as part of compaction / rolling history.
reg.mode()Mode-plugin capabilities. Most users don't touch this; modes are built into lash-mode-standard and lash-mode-rlm.

The Cheap-Build Contract

PluginFactory::build() is on the hot path. It runs for every fresh session, every subagent spawn, every fork, every compaction child. Specifically, do not:

  • Perform I/O — disk reads, HTTP requests, DB queries.
  • Compile regexes, JSON schemas, templates, or anything else that can be done once on the factory.
  • Open network or process pools.
  • Load large models or parse large config files.
  • Block on long mutexes or run anything resembling work.

Put expensive state on the factory struct (wrap it in Arc), clone the Arc into the SessionPlugin at build time, and clone again into hook closures. The factory lives once per core; sessions are cheap shells.

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,                // 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 materialized by the mode's before-tool hook before schema validation and before execute(); ordinary tool code should not know about the internal projection marker. Projection-aware RLM control tools preserve only root seed entries when they intentionally carry a projected source into a child or successor session.

context.plugin_input::<MyPlugin>("plugin_id") reads typed per-turn input passed via TurnBuilder::with_plugin_input (see the Lash API 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 searchable tool catalog, dynamic tool availability, child-session control, background-task control, or a direct completion.

MethodUse it for
context.session_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.session_snapshot().await? / context.snapshot_current_session().await?Read the current session snapshot when a tool truly needs more than model selection.
context.snapshot_session(session_id).await?Read another session snapshot through the tool context when the host grants that runtime capability.
context.tool_catalog().await?Read the current session's projected tool catalog for discovery/ranking tools.
context.set_tools_availability(...).await?Promote, hide, or reset dynamic tool availability for the current session.
context.sessions()Cloneable child-session and turn control: create_session, close_session, start_turn_stream, await_turn, and cancel_turn.
context.tasks()Cloneable background-task registry control for tools that spawn long-lived work visible through task/async-handle surfaces.
context.direct_completion(request, source).await?Run a one-shot LLM request. Missing session_id and originating_tool_call_id are filled from the current tool call for tracing and usage attribution.
use lash::direct::{DirectOutputSpec, DirectRequest};

async fn rank(call: ToolCall<'_>) -> ToolResult {
    let model = match call.context.session_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,
        stream_events: None,
        session_id: None,                 // filled by ToolContext
        originating_tool_call_id: None,   // filled by ToolContext
    };

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

There is no public ToolContext::host() escape hatch. Runtime bridge traits such as ToolHookHost remain internal lash-core wiring, not plugin-facing API. Import direct-call request/result types from lash::direct; plugin authors should not depend on provider internals for one-shot model calls.

Wiring It In

Pass your factory to LashCoreBuilder::plugin(...). It's an Arc<dyn PluginFactory> so the same factory instance can be shared across cores if needed.

let core = LashCore::builder()
    .provider(provider)
    .model("anthropic/claude-sonnet-4.6", None)
    .max_context_tokens(200_000)
    .plugin(Arc::new(UpdatePlanPluginFactory) as Arc<dyn PluginFactory>)
    .build()?;

For tools that aren't part of a plugin (no state, no lifecycle hooks), you can skip the factory and pass a ToolProvider directly to .tools(Arc::new(MyTools)). Both forms compose: provider-style tools live alongside plugin-registered tools on the same surface.

Reference Examples In The Workspace

If you want a plugin to model your work on, these are good starting points:

Minimal hook only

lash-plugin-ui-activity/src/lib.rs — ~50 lines, one after-turn hook, emits a desktop-notification surface event. No tools, no state. Good template for "I just want to react to turn lifecycle."

Tool + state + UI event

lash-plugin-plan-mode/src/update_plan.rs — single tool with session-local state, surface-event emission on tool success, root-session-only gating. Good template for "I want to expose one tool that the user can see in the TUI."

Prompt contribution + factory state

lash-plugin-prompt-context/src/lib.rs — captures an instruction source on the factory, projects it into prompts via reg.prompt().contribute(...). Good template for "I want to inject something into the system prompt."

Per-core connection pool

lash-plugin-mcp/src/plugin.rs — factory owns an Arc<McpConnectionPool> shared across every session, plus runtime attach_server / detach_server methods. Good template for "I'm wrapping an external service with one persistent connection."