lash guide

writing/plugins

A plugin is the unit of extension in lash. Plugins register tools, contribute to prompts, observe turn lifecycle hooks, apply tool-output budgets, register monitors, mutate history, or expose typed RPCs the host can invoke directly. 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 (crates/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 crates/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()Typed or raw RPC-shaped entry points the host can invoke directly, 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.

Snapshots And Durability

A session that uses a SessionStoreFactory (see Persistence) survives process restarts: the runtime parks its in-memory state into the store on shutdown and rehydrates it on the next open. Plugins participate by implementing two optional SessionPlugin methods:

fn snapshot(&self) -> Result<Option<Vec<u8>>, PluginError> { Ok(None) }
fn restore(&self, _bytes: &[u8]) -> Result<(), PluginError> { Ok(()) }

snapshot() serialises whatever session-local state the plugin holds — counters, accumulated context, pending state machines — and the bytes go into the same SQLite checkpoint blob the runtime uses for its own state. restore() is called on a fresh session plugin after register() when the runtime opens an existing session id. Treat the byte format as private to your plugin; version it inline if you anticipate schema drift.

Plugins that hold no per-session state at all (most prompt contributors, observers, pure tool providers) can skip both methods — the defaults are correct. Plugins that hold mutable per-session state (counters, plan documents, ongoing observations) must implement snapshot/restore to remain consistent across restart; without them, the session resumes with the runtime graph intact but the plugin's view reset to factory defaults.

Snapshot bytes live in the session store, so they share its durability story: every commit is one SQLite transaction with optimistic CAS. There is no separate flush of plugin state — your snapshot lands in the same atomic write as the turn that produced it.

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

ToolContext exposes no public 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.

Tool Execution Modes

When the model emits multiple tool calls in one assistant turn, the runtime can dispatch them concurrently or one-at-a-time. The choice is declared per tool via ToolDefinition::execution_mode; the runtime groups the batch and runs each group accordingly.

ToolExecutionMode::Parallel (default)

The tool's execute() may run concurrently with other parallel tools from the same batch. The dispatcher schedules them through FuturesUnordered and collects results as each completes. Right for pure reads, network fetches, file scans — anything safe to interleave.

ToolExecutionMode::Serial

The tool blocks the batch: the runtime runs serial tools one at a time in the order the model emitted them, and only after all parallel tools in the batch have finished. Right for tools that mutate shared state (write to a file, update a database, advance a state machine) where overlapping calls would corrupt outcomes.

use lash::tools::{ToolDefinition, ToolExecutionMode};

ToolDefinition::raw("write_file", "Replace a file's contents.", schema, examples)
    .execution_mode(ToolExecutionMode::Serial)

Mixing parallel and serial tools in one batch is supported and common: a read-heavy plan emitting five read_file calls plus one write_file gets the reads concurrently and the write afterward. There is no model-visible distinction — the model sees the same JSON tool list regardless of dispatch mode.

Tool-Output Budgeting

Large tool outputs blow up provider context, model context windows, and persistent history all at once. ToolOutputBudgetPluginFactory sits in PluginStack::runtime() by default and projects oversized outputs into shorter forms at three different sites — so the dispatch result, the model's next request, and the session graph each see an appropriately budgeted view.

use lash::plugins::{
    ToolOutputBudgetConfig, ToolOutputBudgetPluginFactory,
};

let config = ToolOutputBudgetConfig {
    max_bytes: 32 * 1024, // default: 16 * 1024
    max_lines: 800,        // default: 400
    ..ToolOutputBudgetConfig::default()
};

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

Defaults are 16 KiB and 400 lines per tool output. Outputs under both caps pass through untouched; outputs over either cap are truncated with a "… N more bytes / lines elided …" trailer.

The plugin registers a tool-result projector that applies at three points:

  • Dispatch projection — what the runtime returns to the calling code as the tool's immediate result.
  • Model projection — what gets serialised into the next provider request's tool-result message.
  • History projection — what's written to the session graph for replay and audit.

RLM mode print observations go through the same projector — a Lashlang program that prints a 200 KiB blob sees the same trimmed view its next observation would carry. Replace the factory rather than appending; reg.tool_results() is exclusive (one projector per session). Plugins that need different projection logic should build on ToolOutputBudgetConfig rather than fight it.

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

crates/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

crates/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

crates/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

crates/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."