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 and cheaply builds a fresh SessionPlugin whenever a session opens.
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::default(),
    Arc::new(lash::persistence::InMemoryLashlangArtifactStore::new()),
);
let core = lash::LashCore::rlm_builder(factory)
    .provider(provider)
    .model(
        lash::ModelSpec::from_token_limits(model_id, None, 200_000, None)
            .expect("valid model metadata"),
    )
    .effect_host(Arc::new(lash::durability::InlineEffectHost::default()))
    .attachment_store(Arc::new(lash::persistence::InMemoryAttachmentStore::new()))
    .configure_plugins(|plugins| {
        plugins.push(Arc::new(AppPluginFactory) as Arc<dyn PluginFactory>);
    })
    .build()?;

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

Worked Example

The plan-mode 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, ToolProvider, ToolResult};

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<'_>) -> ToolResult {
        // Validate call.args, mutate state, then return a typed payload.
        ToolResult::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

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.

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 ·