plugins

Write a plugin when behavior needs to join the Lash runtime beyond a standalone callable tool: prompt contributions, tool-catalog policy, turn hooks, runtime events, resumable session state, or typed per-turn input.

What This Is

A plugin is a per-core factory that builds per-session behavior. It can register tools, prompts, hook closures, output transforms, runtime actions, protocol pieces, and durable plugin state. If all you need is one callable operation, implement ToolProvider directly and use the Tools guide.

PluginFactory
Per-core. Owns shared state, cheaply builds a fresh SessionPlugin whenever a session opens, and may override the default no-op shutdown() lifecycle hook to release host-visible resources after intake stops.
SessionPlugin
Per-session. Registers hooks through PluginRegistrar and optionally participates in snapshot/restore.
ToolProvider
Optional. A plugin may register one when the tool needs plugin state or hooks. Tool-only providers do not need a plugin.

Runtime Boundary

A plugin is for behavior that should participate in the runtime lifecycle. Keep ordinary host routing and ordinary tool execution outside it.

inside plugin

You need prompt contributions, stateful turn hooks, custom tool visibility, typed plugin input, runtime event projection, or session-local state that resumes with Lash. Tools belong here only when they depend on that behavior.

outside plugin

Keep auth, HTTP routing, frontend persistence, app database work, and simple tool providers in the host unless they need to appear in model context, runtime hooks, or resumable plugin state.

Minimal Example

Start from the runtime defaults and append one plugin factory. Preset builders include the default runtime stack; raw builders stay explicit.

use std::sync::Arc;

use lash::plugins::PluginFactory;

let factory = lash::rlm::RlmProtocolPluginFactory::new(
    lash::rlm::RlmProtocolPluginConfig::builder()
        .instruction_limit(lash::rlm::InstructionBound::instructions(1_000_000))
        .wall_clock(lash::rlm::WallClockBound::secs(30))
        .memory_limit(lash::rlm::MemoryBound::mebibytes(64))
        .build(),
    Arc::new(lash::persistence::InMemoryLashlangArtifactStore::new()),
);
let core = lash::LashCore::rlm_builder(lash::TurnBudget::Unbounded, factory)
    .provider(provider)
    .model(
        lash::ModelSpec::builder(model_id)
            .context_window_tokens(200_000)
            .build()
            .expect("valid model metadata"),
    )
    .effect_host(Arc::new(lash::durability::InlineEffectHost::default()))
    .attachment_store(Arc::new(lash::persistence::InMemoryAttachmentStore::new()))
    .process_env_store(Arc::new(
        lash::persistence::InMemoryProcessExecutionEnvStore::new(),
    ))
    // Start bounded; tune both limits for your backend's latency envelope.
    .commit_budget(lash::CommitBudget::bounded(1024 * 1024, 512))
    .queued_work_batching(lash::QueuedWorkBatchingConfig::new(1024))
    .configure_plugins(|plugins| {
        plugins.push(Arc::new(AppPluginFactory) as Arc<dyn PluginFactory>);
    })
    .build(crate::example_process_owner())?;

Use .plugin(...) to append one factory, .plugins(...) to replace the full stack, and .configure_plugins(...) to mutate the current stack.

Worked Example

This update-plan plugin is a compact model for a tool that really does need plugin behavior: one factory, one session plugin, a session-local state object, one tool provider, and hooks that emit UI state after tool calls.

use std::sync::Mutex;

use lash::plugins::PluginRegistrar;
use lash::tools::{
    ToolCall, ToolContract, ToolDefinition, ToolManifest, ToolOutcome, ToolProvider,
};

const PLUGIN_ID: &str = "update_plan";

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 {
            active: ctx.is_root_session(),
            state: Arc::new(Mutex::new(PlanState::default())),
        }))
    }
}

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(())
    }
}

struct UpdatePlanTool {
    state: Arc<Mutex<PlanState>>,
}

#[async_trait::async_trait]
impl ToolProvider for UpdatePlanTool {
    fn tool_manifests(&self) -> Vec<ToolManifest> {
        vec![update_plan_definition().manifest()]
    }

    fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
        (name == "update_plan").then(|| Arc::new(update_plan_definition().contract()))
    }

    async fn execute(&self, call: ToolCall<'_>) -> ToolOutcome {
        // Validate call.args, mutate state, then return a typed payload.
        ToolOutcome::ok(serde_json::json!({ "generation": 1 }))
    }
}

fn update_plan_definition() -> ToolDefinition {
    ToolDefinition::raw(
        "tool:update_plan",
        "update_plan",
        "Publish or replace the current plan.",
        serde_json::json!({ "type": "object", "properties": {} }),
        serde_json::json!({}),
    )
}

Common Tasks

The registrar namespaces keep plugin responsibilities explicit.

TaskRegisterCanonical guide
Add host toolsLashCoreBuilder::tools(...) or reg.tools().provider(...)Tools
Add prompt contextreg.prompt()Prompts and bindings
Change visible toolsreg.tool_catalog()Tools
React before/after turnsreg.turn()Runtime plugins
React before/after tool callsreg.tool_calls()Runtime plugins
Expose host actionsreg.operations()Runtime plugins
Shape prompt context and compact framesreg.context()Prompts and bindings

Tool-call composition

Plugin terminal directives join restrictively at both seams, while the tool's original outcome sits outside that join and may be replaced. Argument and result inspection remain effective on either side of one bounded replacement.

arguments

Hooks run in plugin registration order. A ReplaceToolArgs directive takes effect immediately, then every earlier hook runs once on the replacement before the remaining chain advances. That repeated invocation is reinspection only: denials and aborts are re-honored, while side-effecting directives are not re-applied. Another replacement during bounded reinspection is rejected as a typed BeforeToolCallReplacementConflict; therefore two unconditional replacers fail closed, while a conditional normalizer that reaches a fixed point is supported.

terminal precedence

Both folds join plugin terminal directives by restrictiveness: AbortTurn wins over a failed or cancelled ShortCircuitTool, which wins over a successful short-circuit. The original tool outcome is not part of this lattice, so a successful after-tool replacement can replace an original failure. Before-tool equal-strength conflicts use plugin ID as a stable tie-breaker. After-tool result replacements are first-emitted-wins after bounded reinspection. Within one plugin, the first-emitted equal-strength terminal wins. At either seam AbortTurn is a failed tool result rather than an enclosing-turn abort, so a later abort that displaces an earlier structured denial retains the denial's typed code and message.

after-tool results

Hooks run in plugin registration order. A successful ShortCircuitTool result replacement is shown once to every earlier hook before the chain advances; the first-emitted successful replacement remains effective when a later candidate passes that reinspection. A repeated hook may deny or abort, and that verdict rejoins the terminal fold, but its side-effecting directives are not applied again. Another successful replacement during the bounded pass is rejected as AfterToolCallReplacementConflict, so two unconditional result replacers fail closed. An after-tool hook may therefore run more than once for one result.

conflicts

Lash emits each inter-plugin terminal conflict through the awaited session trace seam, carrying the winning and ignored plugin IDs and directive kinds and attributing the event to the actual later plugin. One plugin's own repeated or multiple terminals do not manufacture a composition conflict. Runtime events named before_tool_call.directive_conflict and after_tool_call.directive_conflict carry the same identity-bearing payload when that secondary channel can accept it without blocking tool dispatch. CreateSession and EmitTrace errors contribute denials without stopping later directives. Invalid after-tool ReplaceToolArgs also contributes a denial; if another denial displaces its message, the ordinary conflict event still identifies the misusing plugin.

Failure Modes

Most plugin bugs come from lifetime confusion, expensive factories, duplicate exclusive hooks, or state that was not snapshotted.

slow open

PluginFactory::build() runs whenever a session opens, including children and resumed sessions. Do not perform network I/O there; keep shared clients on the factory.

lost state

Session-local plugin state must implement snapshot/restore if it should survive persistence. Otherwise the plugin restarts with default in-memory state after reopen.

surface drift

Tool manifests are prompt/search metadata; full contracts are resolved later. Keep both generated from the same source so the model does not see stale schemas.

leaked child processes

After intake stops, call LashCore::shutdown() so plugin factories can release child processes, connections, and background tasks. This releases factory resources; it does not drain or abort turns.

Where Next

This page is the canonical guide for runtime/session plugins. Use the references below for the tool contract and deeper runtime hooks.

read on ·