- 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.
fn snapshot(&self, _writer: &mut dyn SnapshotWriter) -> Result<PluginSnapshotMeta, PluginError> { /* default: empty meta */ }
fn restore(&self, _meta: &PluginSnapshotMeta, _reader: &dyn SnapshotReader) -> 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 checkpoint blob the runtime uses for its own state, persisted through the configured session store. 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 mutating session path runs under the session execution lease, and every commit is one atomic store write that verifies the lease fence plus the head CAS backstop. There is no separate flush of plugin state; your snapshot lands in the same atomic write as the turn that produced it.
use std::sync::Arc;
let core = lash::LashCore::standard_builder()
.provider(provider)
.model(
lash::ModelSpec::from_token_limits("anthropic/claude-sonnet-4.6", None, 200_000, None)
.expect("valid model metadata"),
)
.effect_host(Arc::new(lash::durability::InlineEffectHost::default()))
.attachment_store(Arc::new(lash::persistence::InMemoryAttachmentStore::new()))
.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.
Tool + state + runtime event
lash-plugin-plan-mode: single tool with session-local state, semantic runtime-event emission on tool success, root-session-only gating. Good template for "I want to expose one stateful tool and let hosts choose the presentation."
Prompt contribution + factory state
lash-cli's PromptContextPluginFactory: 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: factory keeps 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."