Shared core
One LashCore holds provider, model defaults, RLM mode, store factory, and trace sink for the whole app.
The runnable browser example in examples/agent-service shows the app-facing lash API in a realistic host: Axum routes, OpenRouter, RLM mode, typed plugin input, app tools, semantic streaming, per-chat model selection, SQLite runtime stores, and separate product persistence.
The example uses OpenRouter through the OpenAI-compatible provider. Environment variables define defaults; the browser UI can override model and variant per chat.
OPENROUTER_API_KEY=... cargo run -p agent-service
# then open http://127.0.0.1:3000
OPENROUTER_MODELopenai/gpt-5.5.OPENROUTER_MODEL_VARIANTmedium; the UI offers low, medium, and high.AGENT_SERVICE_DATA_DIR.agent-service.This is the best end-to-end reference when checking whether an app is using the lash facade correctly.
One LashCore holds provider, model defaults, RLM mode, store factory, and trace sink for the whole app.
Each request opens a LashSession from the chat id and store. The app does not keep live runtime sessions in a process map.
The product DB stores model and model_variant; turns apply the current selection through TurnBuilder::model(...).
The board state is validated as typed per-turn input, then read by prompt hooks and app tools without string parsing.
The browser renders TurnEvents for assistant prose, reasoning deltas, code blocks, tool cards, usage, and submitted terminal values.
lash-sqlite-store persists runtime state; the app database owns chat rows, board snapshots, model choice, titles, and rendered activity rows.
Build one core for the process. The example installs RLM explicitly and uses a SQLite session-store factory so every chat id maps back to durable runtime state.
let provider = ProviderHandle::new(
OpenAiCompatibleProvider::new(api_key, OPENROUTER_BASE_URL)
.with_options(ProviderOptions {
thinking: ProviderThinkingPolicy { expose: true },
..ProviderOptions::default()
})
.into_components(),
);
let core = LashCore::builder()
.install_mode(ModePreset::rlm())
.default_mode(ModeId::rlm())
.provider(provider)
.model(model.clone(), Some(model_variant.clone()))
.max_context_tokens(200_000)
.store_factory(store_factory)
.trace_sink(Some(trace_sink))
.trace_level(TraceLevel::Extended)
.build()?;
The route stores the user message and board snapshot first, opens the Lash session, then runs a turn with the chat's model selection and plugin input.
let session = state.open_session(&chat_id).await?;
let turn = session
.turn(TurnInput::text(text))
.model(model_selection.model, model_selection.model_variant)
.with_board(request.board)
.require_submit()?;
let output = turn.collect_with(&ui_events).await?;
let assistant_text = assistant_text_for_persistence(
&output,
&turn_state.lock().expect("turn state lock").assistant_prose,
);
.with_board(...) is a small app-owned extension trait over TurnBuilder::with_plugin_input. It keeps route code focused on product terms while still using the typed plugin input API.
The UI treats model choice as product state. New chats inherit defaults from /api/settings, the sidebar displays each chat's selected model, and edits are saved to /api/chats/{chat_id}/model.
function selectedModel() {
return {
model: modelInput.value.trim() || settings.default_model,
model_variant: variantInput.value || null
};
}
await api(`/api/chats/${activeChat}/messages`, {
method: 'POST',
body: JSON.stringify({
text,
board: currentBoard(),
...selectedModel()
})
});