App state
Chat tables, accounts, frontend state, auth, transport. The example app uses SQLite plus NDJSON to the browser.
Storage boundaries, session lifecycle, process work, durable workflows, subagents, MCP servers, the advanced runtime surface, and a complete example.
Product storage stays separate from runtime storage.
Chat tables, accounts, frontend state, auth, transport. The example app uses SQLite plus NDJSON to the browser.
Pass a SessionStoreFactory to LashCoreBuilder::store_factory, or a concrete store to SessionBuilder::store, for durable runtime state across process restarts.
Mode presets install protocol and plugin defaults only. Every core build names the host-owned runtime facets explicitly: an effect host, a Lashlang artifact store, and an attachment store. Add a session-store factory when sessions must survive process restarts or when the inline process worker is enabled.
use std::sync::Arc;
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(model)
.effect_host(Arc::new(lash::durability::InlineEffectHost::default()))
.attachment_store(Arc::new(lash::persistence::InMemoryAttachmentStore::new()))
.build()?;
use std::sync::Arc;
use lash::persistence::FileAttachmentStore;
use lash_sqlite_store::{SqliteSessionStoreFactory, Store};
let store_factory = Arc::new(SqliteSessionStoreFactory::new(data_dir.join("sessions")));
let artifact_store = Arc::new(Store::open(&data_dir.join("artifacts.db")).await?);
let factory = lash::rlm::RlmProtocolPluginFactory::new(
lash::rlm::RlmProtocolPluginConfig::default(),
artifact_store,
);
let core = lash::LashCore::rlm_builder(factory)
.provider(provider)
.model(model)
.store_factory(store_factory)
.effect_host(Arc::new(lash::durability::InlineEffectHost::default()))
.attachment_store(Arc::new(FileAttachmentStore::new(
data_dir.join("attachments"),
)))
.build()?;
Two states: active (resident, ready) and parked (flushed; only id + store reference held). Opening an existing id rehydrates from the store: one read of the graph head plus referenced snapshots.
Sessions park automatically when the handle drops. Long-running servers can hold thousands of session ids cheaply. Residency policy controls in-memory trimming while a session is active:
Residency::KeepAll (default)All graph nodes stay resident. Fastest re-reads. Interactive sessions.
Residency::ActivePathOnlyOnly active-path nodes resident; orphans on disk. Lower memory, occasional store reads when crossing forks. Long-lived multi-session servers.
use std::sync::Arc;
use lash::durability::{InlineEffectHost, Residency};
use lash::persistence::FileAttachmentStore;
use lash_sqlite_store::Store;
let factory = lash::rlm::RlmProtocolPluginFactory::new(
lash::rlm::RlmProtocolPluginConfig::default(),
Arc::new(Store::open(&data_dir.join("artifacts.db")).await?),
);
let core = lash::LashCore::rlm_builder(factory)
.provider(provider)
.model(
lash::ModelSpec::from_token_limits(model.clone(), None, 200_000, None)
.expect("valid model metadata"),
)
.store_factory(store_factory)
.effect_host(Arc::new(InlineEffectHost::default()))
.attachment_store(Arc::new(FileAttachmentStore::new(
data_dir.join("attachments"),
)))
.residency(Residency::ActivePathOnly)
.build()?;
Plugin background work (tasks tracked via ToolContext::processes(), post-turn observation summaries, MCP warm-ups) continues past turn return. To drain it before returning (one-shot processes that need observations persisted before exit):
session.refresh_background_graph().await?;
Exposed by the CLI as --await-background-work on lash --print. Embedders rarely need it outside short-lived processes.
Lashlang process blocks need a process plane. The registry records process intent, handles, wakes, events, and cancellation; the process work driver consumes those records, executes background work, and owns process waits.
Call .process_registry(...). The facade builds the default inline DurableProcessWorkerConfig at build() and starts process work from captured execution environments; no origin session has to be reopened for tool or Lashlang process execution.
Call .process_work_driver(...) when a deployment worker, queue, or workflow runtime owns process execution. The driver's registry becomes the core process registry and Lash does not spawn the inline runner.
core.processes() is the single host-level surface — start, get, list, events, signal, await_output, cancel, cancel_all, transfer, prune, and request_abandon — with two distinct list filters (ADR 0019): list_granted_to is the grant lens (addressability — what a session is authorized to see) and list_originated_by is the provenance lens (origin — what a session created; a process it started then transferred away still matches here, one merely granted to it does not). core.process_registry() is the point-read/write registry for storage integrations, and session.processes() is thin grant-scoped sugar over the global surface for in-session await/cancel/list.
ProcessWorkDriver::await_terminal is the one way to wait on a started work item (ADR 0016) — an engine-native durable promise or hub-plus-backoff point reads, never a store poll loop; bound every wait with tokio::time::timeout. An optional ProcessEventSink, installed at construction (new_with_sink / LashCoreBuilder::process_event_sink), pushes appended events best-effort for a live feed — freshness, never truth (ADR 0017), so reconcile from events_after and take terminal state from await_terminal. prune_terminal_processes(cutoff_epoch_ms) is the host retention lever for terminal rows.
let factory = lash::rlm::RlmProtocolPluginFactory::new(
lash::rlm::RlmProtocolPluginConfig::default(),
artifact_store,
);
let core = lash::LashCore::rlm_builder(factory)
.provider(provider)
.model(model)
.store_factory(store_factory)
.process_registry(process_registry)
.effect_host(Arc::new(lash::durability::InlineEffectHost::default()))
.attachment_store(attachment_store)
.build()?;
Hosts that accept or ship Lashlang modules should compile through compile_module. The facade returns the persisted artifact refs, stable introspection, exported process definition identities, and structured diagnostics; trigger registration screens can run compatibility checks before saving a subscription.
use std::collections::BTreeMap;
let mut resources = lashlang::LashlangHostCatalog::new();
resources.add_trigger_source_constructor(
["app", "button"],
lashlang::TypeExpr::Object(Vec::new()),
lashlang::NamedDataType::object(
"app.ButtonPressed",
vec![lashlang::TypeField {
name: "color".into(),
ty: lashlang::TypeExpr::Str,
optional: false,
}],
)?,
)?;
let environment = lashlang::LashlangHostEnvironment {
resources,
abilities: lashlang::LashlangAbilities::default().with_processes(),
..lashlang::LashlangHostEnvironment::default()
};
let compiled = lashlang::compile_module(lashlang::ModuleCompileRequest {
source: r#"
process on_button(event: app.ButtonPressed) {
finish event.color
}
source = app.button({})
finish source
"#,
environment: &environment,
artifact_store: Some(artifact_store.as_ref()),
})
.await?;
let process = compiled
.introspection
.exported_processes
.iter()
.find(|process| process.definition.process_name == "on_button")
.expect("compiled module exports on_button");
let inputs = lashlang::TriggerInputTemplate::new(BTreeMap::from([(
"event".to_string(),
lashlang::TriggerInputBinding::Event,
)]));
let compatibility =
lashlang::check_trigger_compatibility(lashlang::TriggerCompatibilityRequest {
artifact: &compiled.artifact,
definition: &process.definition,
source_type: "app.button",
inputs: &inputs,
})?;
println!(
"compiled {} and trigger emits {}",
compiled.module_ref,
compatibility.event_type.name()
);
Standard LashCore persists at the turn boundary. For durable in-flight effects (expensive LLM calls, long tool calls, process admin, mid-turn worker migration), wire an EffectHost on the builder and run each durable handler turn with .turn_id(...).effects(&controller).run() or .turn_id(...).effects(&controller).stream_to(&sink). The facade creates the turn ExecutionScope internally, while the handler still supplies the workflow controller explicitly. The EffectHost is the stable boundary an external workflow runtime (Temporal, Restate) would wrap; the default InlineEffectHost runs in process and is not in-flight durable.
Shape: the runtime driver polls TurnMachine. For each reply-producing turn effect it builds a RuntimeEffectEnvelope with a typed RuntimeInvocation, invokes the active ScopedEffectController, and feeds the returned outcome back as the matching response. The InlineEffectHost runs locally and is not in-flight durable. A durable workflow EffectHost such as Restate records effects in workflow history, uses durable timers for sleeps, and reruns the handler with the same turn id before Lash retries the final idempotent commit.
Do not wrap a durable handler turn in another persistent submitted/running work-item state machine. The workflow key and turn_id are the durable in-flight identity; Lash's session execution lease and idempotent commit are the runtime correctness boundary; host tables should store product rows, reconnect outbox rows, cancellation handles, or terminal output.
Full contract: Architecture → Durability. Embedders without workflow orchestration use the regular session-store path.
Same SessionSpec shape as root sessions. The factory keeps the capability registry; child policy resolves from the live parent snapshot.
use std::sync::Arc;
use lash::{SessionSpec, plugins::PluginFactory};
use lash_subagents::{SubagentsPluginFactory, default_registry};
let registry = Arc::new(default_registry(&tier_models));
let subagents = SubagentsPluginFactory::new(registry)
.with_session_spec(SessionSpec::inherit().max_turns(8));
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.clone(), 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(subagents) as Arc<dyn PluginFactory>)
.build()?;
Capabilities return SessionSpec overlays. StaticCapability pins exact child authority; TierCapability backs the built-in explore and peer tiers. Do not construct SessionPolicy directly; it's the resolved runtime artifact.
exploreRead-only investigator. RLM by default. Cannot spawn further subagents. For scan / summarise / verify tasks.
peerConcurrent self. Inherits parent execution mode and tool authority, including recursive spawning. For a sibling branch of the same work.
Both tiers consult tier_models for an override, then fall back to the parent's model. Pass overrides through default_registry(&tier_models).
Capability::build_session_request(ctx) sees the live parent SessionPolicy and returns a SessionCreateRequest. The runtime composes the capability's SessionSpec against parent policy to produce effective child config. The spec is what's persisted, so a resumed subagent re-derives the same authority.
Interactive-only tools such as ask and showcase would block a subagent indefinitely; nothing is listening on the other side. Hide them from every child surface with SubagentsPluginFactory::with_hidden_tools(...) or a custom SessionToolAccess; the CLI hides its five interactive tools this way. Recursion is bounded separately: at the maximum spawn depth (5) the runtime hides spawn_agent itself.
Child sessions emit TurnEvent::ChildUsage on the parent's stream, tagged with source ("subagent", "compaction", "observer") and child session_id. TurnResult.children_usage rolls up per (source, model); total_usage() sums parent + children. Finer splits: fold ChildUsage from the stream directly.
lash-plugin-mcp wraps the rmcp SDK with three transports: stdio, streamable_http, sse. The plugin keeps a shared connection pool; stdio servers spawn once per process, not per session.
use std::collections::BTreeMap;
use lash_plugin_mcp::{McpPluginFactory, McpServerConfig};
let mut servers = BTreeMap::new();
servers.insert(
"docs".to_string(),
McpServerConfig::stdio("uvx", vec!["mcp-server-docs".into()]),
);
servers.insert(
"web".to_string(),
McpServerConfig::streamable_http("https://mcp.example.com/rpc"),
);
let mcp = McpPluginFactory::new(servers).await?;
let factory = lash::rlm::RlmProtocolPluginFactory::new(
lash::rlm::RlmProtocolPluginConfig::default(),
std::sync::Arc::new(lash::persistence::InMemoryLashlangArtifactStore::new()),
);
let core = lash::LashCore::rlm_builder(factory)
.provider(provider)
.model(
lash::ModelSpec::from_token_limits(model.clone(), None, 200_000, None)
.expect("valid model metadata"),
)
.effect_host(std::sync::Arc::new(
lash::durability::InlineEffectHost::default(),
))
.attachment_store(std::sync::Arc::new(
lash::persistence::InMemoryAttachmentStore::new(),
))
.plugin(std::sync::Arc::new(mcp))
.build()?;
Tools appear as mcp__<server>__<tool> with original schemas preserved. attach_server / detach_server mutate the live pool without rebuilding the core.
Servers connect once at McpPluginFactory::new(...). Built session plugins clone the shared Arc<McpConnectionPool>, so startup cost is one-per-process, not one-per-chat.
After attach_server, new tools are visible to sessions on their next tool-catalog refresh (start of next turn). detach_server shuts the server down and removes its tools; in-flight calls fail rather than hang.
// Hot-swap a server at runtime.
mcp.attach_server(
"new-tool".to_string(),
McpServerConfig::stdio("uvx", vec!["mcp-server-new".into()]),
)
.await?;
mcp.detach_server("old-tool").await?;
Failures are isolated: an exited child, a 500ing endpoint, or a dropped SSE stream surfaces as ToolResult::err(...) on the affected tool; other servers keep working. If a server is unreachable at new time, the call fails; use McpPluginFactory::empty() plus runtime attach_server when initial connectivity is unreliable.
.advanced() is reserved for low-level overrides: a custom PluginHost or a replaceable RuntimeHostConfig. Durable embedding concerns such as effect hosts, process registries, process work drivers, residency policy, and termination policy live on the normal LashCoreBuilder. The registry trait itself lives under lash::process::ProcessRegistry and stores state only; process waits live on the work driver. RLM/Lashlang turns that generic process plane into authored process/lifecycle abilities when the Lashlang runtime pieces are installed.
let factory = lash::rlm::RlmProtocolPluginFactory::new(
lash::rlm::RlmProtocolPluginConfig::default(),
std::sync::Arc::new(lash_sqlite_store::Store::open(&data_dir.join("artifacts.db")).await?),
);
let core = lash::LashCore::rlm_builder(factory)
.provider(provider)
.model(
lash::ModelSpec::from_token_limits("anthropic/claude-sonnet-4.6", None, 200_000, None)
.expect("valid model metadata"),
)
.store_factory(store_factory)
.effect_host(std::sync::Arc::new(
lash::durability::InlineEffectHost::default(),
))
.attachment_store(std::sync::Arc::new(
lash::persistence::FileAttachmentStore::new(data_dir.join("attachments")),
))
.residency(lash::durability::Residency::ActivePathOnly)
.build()?;
Streaming is semantic: TurnBuilder::stream emits TurnActivity and resolves to a TurnResult. Raw runtime telemetry belongs in tracing, not the app surface.
The browser example: application chat database, RLM mode, typed session plugin activation, app-owned board tools, semantic stream events, terminal rendering from TurnOutput, and optional Restate-backed turns.
OPENROUTER_API_KEY=... cargo run -p agent-service
# then open http://127.0.0.1:3000
Source: examples/agent-service. Walkthrough: Agent Service.