lash guide

agent/service

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.

Run It

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_MODEL
Default model id for new chats. Defaults to openai/gpt-5.5.
OPENROUTER_MODEL_VARIANT
Default reasoning variant for new chats. Defaults to medium; the UI offers low, medium, and high.
AGENT_SERVICE_DATA_DIR
App database, runtime session stores, and traces. Defaults to .agent-service.

What It Demonstrates

This is the best end-to-end reference when checking whether an app is using the lash facade correctly.

Shared core

One LashCore holds provider, model defaults, RLM mode, store factory, and trace sink for the whole app.

Session per chat

Each request opens a LashSession from the chat id and store. The app does not keep live runtime sessions in a process map.

Per-chat model

The product DB stores model and model_variant; turns apply the current selection through TurnBuilder::model(...).

Typed plugin input

The board state is validated as typed per-turn input, then read by prompt hooks and app tools without string parsing.

Semantic stream

The browser renders TurnEvents for assistant prose, reasoning deltas, code blocks, tool cards, usage, and submitted terminal values.

Split persistence

lash-sqlite-store persists runtime state; the app database owns chat rows, board snapshots, model choice, titles, and rendered activity rows.

Core Wiring

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()?;

Turn Path

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.

Browser Controls

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()
  })
});