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
SessionPluginwhenever a session opens. SessionPlugin- Per-session. Registers hooks through
PluginRegistrarand 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.
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.
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.
| Task | Register | Canonical guide |
|---|---|---|
| Add host tools | LashCoreBuilder::tools(...) or reg.tools().provider(...) | Tools |
| Add prompt context | reg.prompt() | Prompts and bindings |
| Change visible tools | reg.tool_catalog() | Tools |
| React before/after turns | reg.turn() | Runtime plugins |
| React before/after tool calls | reg.tool_calls() | Runtime plugins |
| Expose host actions | reg.operations() | Runtime plugins |
| Shape prompt context and compact frames | reg.context() | Prompts and bindings |
Failure Modes
Most plugin bugs come from lifetime confusion, expensive factories, duplicate exclusive hooks, or state that was not snapshotted.
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.
Session-local plugin state must implement snapshot/restore if it should survive persistence. Otherwise the plugin restarts with default in-memory state after reopen.
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.