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;
}
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.
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.
| Method | What 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. |
- 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.
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.
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."