Web server¶
The Web package is built on FastAPI. create_app() builds the ASGI
application and holds every serving option; serve() runs it under uvicorn.
from lovia import Agent
from lovia.web import create_app, serve
agent = Agent(name="assistant", model="<model>")
serve(create_app(agent, db_path="lovia.db"), host="127.0.0.1", port=8000)
create_app() and serve()¶
serve(target, *, host="127.0.0.1", port=8000, **uvicorn_kwargs) runs an app
from create_app(), or builds one with the defaults when given an Agent (or a
{name: agent} mapping) directly — serve(agent) is the quickest start.
Server options such as log_level, ssl_certfile, workers, and root_path
(behind a proxy) pass through to uvicorn. To
run the app under another ASGI server, skip serve().
create_app(agent_or_agents, ...) options:
| Option | Default | Description |
|---|---|---|
agent_or_agents |
required | One Agent or a {name: agent} mapping |
db_path / store / session |
./.lovia/<agent>.db |
Transcript and chat metadata storage |
max_turns / budget / retry / context_policy |
— | Settings applied to every served Run |
tracer |
None |
Span recorder for served Runs |
titles / title_model |
True / Agent model |
Generate conversation titles in the background |
followups / followup_model |
False / Agent model |
Suggest follow-up questions after a reply (see below) |
approval_timeout |
None |
Auto-deny unresolved approvals after N seconds |
max_background_runs |
8 |
Concurrent supervised Runs; excess starts return 429 |
ui |
True |
False serves the API only; a ChatUI customizes the bundled page (below) |
cors_origins |
None |
Allowed browser origins; unset sends no CORS headers |
token / auth |
None |
Bearer-token guard for business API routes, or your own FastAPI dependency (see below) |
lifespan |
None |
Your own resources' lifespan, as for FastAPI(lifespan=...) (see below) |
run_deps |
None |
Builds each run's RunContext.deps from the page's context and the request's identity (see Run deps) |
title |
"lovia" |
Page title and the API's reported title |
create_app sets the app's FastAPI lifespan itself, to start and stop its
scheduler and wind down live runs. Pass your own as lifespan= to open
resources your tools use — a database pool, an HTTP client — with the app.
Yours wraps lovia's: the resources are up before the first run and close only
after live runs wound down. State you yield reaches request.state.
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app):
await db.connect()
yield
await db.close()
app = create_app(agent, lifespan=lifespan)
ChatUI options, for ui=ChatUI(...):
| Option | Default | Description |
|---|---|---|
empty_title / empty_description |
lovia's copy, in the user's language | Blank chat state copy; the description may be a list of short lines |
empty_examples |
() |
Clickable starter prompts on the blank chat state (clicking fills the composer) |
login_url |
None |
Where a signed-out user goes under auth= (see Authentication) |
assets |
None |
Your UI directory: an index.html override, stylesheets, images, scripts (see Customizing the page) |
scripts |
() |
JS modules the page imports before it starts, e.g. component renderers (see Extension scripts) |
styles |
() |
Stylesheets loaded after the bundled one |
logo / favicon |
None |
An image replacing the sidebar's title / the tab icon |
messages |
{} |
UI strings per language: overrides, extra languages, and keys for your own template (see Wording and languages) |
default_lang |
None |
The language for a user who hasn't picked one in Settings; None follows the browser |
from lovia.web import ChatUI, create_app
app = create_app(
agent,
title="Acme",
ui=ChatUI(empty_title="Ask Acme", empty_examples=["Summarize today's tickets"]),
)
Transcripts, chat metadata and run checkpoints share one SQLite file, opened in
WAL mode so the UI's reads never wait behind a checkpoint write. An existing
database migrates on first open. Its <name>.db-wal and <name>.db-shm
companions exist only while a connection is open, and the last one to close
folds the WAL back into the database — so copy all three from a running server,
but a stopped one still leaves a single file. On a filesystem without shared
memory (some network mounts) SQLite declines WAL and logs a warning, keeping the
old journal mode; pass store=ChatStore.sqlite(path, wal=False) to opt out up
front.
For endpoint contracts and the ChatStore interface, see
HTTP API.
Customizing the page¶
Keep your files in one directory and point ChatUI(assets=...) at it.
lovia web init-ui ui starts one: an index.html listing every block, a
theme.css listing every token with its default, and a type-checked
extension.js — all commented out, so the scaffold alone changes nothing.
ui/
├── index.html # optional: extends the bundled page, fills its blocks
├── theme.css
├── logo.svg
├── favicon.png
└── acme.js
from lovia.web import ChatUI, create_app
app = create_app(
agent,
title="Acme",
ui=ChatUI(
assets="ui",
styles=["theme.css"],
logo="logo.svg",
favicon="favicon.png",
scripts=["acme.js"],
),
)
lovia serves the directory at <app>/ui/. Every file in it is public except
templates (*.html) and dotfiles, and each load revalidates it, so an edit
shows on the next reload without a restart. Each name in scripts, styles,
logo and favicon is one of:
- a file in
assets—"theme.css"; - a path on the app —
"/brand/theme.css", for files you serve yourself; it keeps working under a path prefix; - a URL —
"https://cdn.example.com/theme.css".
Beyond those files, you build on the page through a few public surfaces, each with its own job:
| Surface | What it's for |
|---|---|
| Template blocks | Your own markup at fixed places: links, a control by the composer, a notice |
| Design tokens | Colors, fonts, radii, spacing |
data-part attributes |
Targeting a region from your CSS: hiding or restyling it |
data-i18n keys |
Wording and languages |
window.lovia |
Your scripts; talking to the API |
Everything else — class names, element ids, the JavaScript modules — is internal and may change between releases.
Two runnable examples put these together: examples/32_web_branding.py brands
the page (logo, tokens, blocks, wording, an added language), and
examples/33_web_extensions.py extends its behavior through window.lovia
(tool views, events, actions).
Template blocks¶
An index.html in assets replaces the page. Extend the bundled one and fill
only the blocks you need:
{# ui/index.html #}
{% extends "lovia/index.html" %}
{% block sidebar_nav %}
<a href="/help" data-i18n="acme.help">Help center</a>
{% endblock %}
{% block welcome %}
{{ super() }}
<p class="acme-notice">Scheduled maintenance tonight at 22:00.</p>
{% endblock %}
{% block sidebar_footer %}
<a class="signout" href="/logout">Sign out</a>
{% endblock %}
| Block | Where it renders |
|---|---|
head |
End of <head>: meta tags, preloads |
sidebar_header |
Top of the sidebar; wraps the title (or logo), so filling it replaces both — a logo with a name beside it, say |
sidebar_nav |
End of the sidebar's navigation, under Settings: links of your own |
sidebar_footer |
Bottom of the sidebar: a user menu, a sign-out link |
topbar_actions |
Start of the top bar's button group |
composer_actions |
In the composer, after the message box: a mode switch, a picker |
welcome |
The blank chat state's content; {{ super() }} keeps the default title, description and examples. The page restores it on "New chat" |
body_end |
After the app's script: scripts of your own |
Links in sidebar_nav draw like the bundled items, and the one to the open
view shows as current. To reference another file from assets in a template, use
{{ url_for('ui-assets', path='banner.png').path }} — it carries the path
prefix.
Styling¶
The bundled styles.css sits in a cascade layer named lovia. Any rule in
your own stylesheet (outside a layer) beats every bundled rule, whatever the
selectors' specificity — no !important needed.
Retheme by overriding the design tokens at the top of styles.css. Each theme
has its own values, so set yours per theme; a token set on :root applies to
both. --lv-accent-hover, --lv-accent-soft and --lv-accent-text derive from
--lv-accent, so a new brand color is usually just --lv-accent — override a tint
only when it needs to differ:
/* ui/theme.css */
[data-theme="light"] { --lv-accent: #6d28d9; }
[data-theme="dark"] { --lv-accent: #a78bfa; }
:root { --lv-sans: "Inter", system-ui, sans-serif; --lv-radius: 8px; }
| Tokens | What they set |
|---|---|
--lv-bg, --lv-surface, --lv-surface-2, --lv-surface-3, --lv-border, --lv-border-strong |
Canvas, panels, cards, hover, and their borders |
--lv-sidebar-surface |
The sidebar's background |
--lv-ink, --lv-ink-2, --lv-muted, --lv-muted-2 |
Text, from primary to faint |
--lv-accent, --lv-accent-hover, --lv-accent-soft, --lv-accent-text |
The brand color and its tints (the last three derive from --lv-accent) |
--lv-user-surface, --lv-user-border |
The user's message bubble |
--lv-chart-1 … --lv-chart-8 |
Chart series colors |
--lv-warn, --lv-danger, --lv-ok |
Status colors |
--lv-sans, --lv-mono |
Font stacks |
--lv-radius-xs … --lv-radius-xl |
Corner radii |
--lv-sidebar-w, --lv-content-w |
Sidebar width, the transcript's reading width |
To target a region, select its data-part:
[data-part="export"] { display: none; } /* no Export menu */
[data-part="tool"][data-tool="read_process_output"] { display: none; } /* hide one tool's cards */
[data-part="turn"][data-role="user"] .lv-bubble { border-radius: 4px; }
The page's own class names all start with lv- and its tokens with --lv-, so
a utility framework in your extension (Tailwind's .table or --radius-sm,
say) can't restyle the page by accident. The class names themselves stay
internal; select regions by data-part.
| Region | data-part |
|---|---|
| Sidebar | sidebar, sidebar-header, agent-switcher, sidebar-nav, new-chat, memory, schedules, settings, sessions-label, sessions-search, sessions |
| Top bar | topbar, chat-title, topbar-actions, tasks, export, files-toggle |
| Views | view (the container an open view draws in) |
| Conversation | transcript, welcome, todo-panel, turn (with data-role="user" or "assistant"), tool (with data-tool="<tool name>" and data-status: running, awaiting the user — a question — then done or error), activity (two or more consecutive reasoning / tool steps folded under one summary line; it closes once the reply's text starts), message-actions (a message's actions; each button has data-action="<id>") |
| Composer | composer, attach, context-ring |
| Elsewhere | files-panel; in Settings, language (the language row) |
Hiding a tool's cards never hides an approval request: those render apart from the card.
Wording and languages¶
The UI ships English and Chinese. ChatUI(messages=...) changes its wording,
adds languages, and labels your own template's elements:
ui = ChatUI(
default_lang="zh",
messages={
"en": {"composer.placeholder": "Ask Acme anything…", "acme.help": "Help center"},
"zh": {"composer.placeholder": "问问 Acme…", "acme.help": "帮助中心"},
"ja": {"composer.placeholder": "何でも聞いてください…", "nav.newChat": "新しいチャット"},
},
)
- Keys are the ones in
lovia/web/static/js/i18n.js(nav.newChat,composer.placeholder, …). A key you set under one of the UI's own prefixes that the UI doesn't have — a typo, or a string a release renamed — is logged as a warning in the browser console. - A language the UI doesn't ship (
jaabove) becomes selectable in Settings; a string it lacks falls back to English. - Keys under a prefix of your own (
acme.help) label your template's elements throughdata-i18n(text),data-i18n-title,data-i18n-ariaanddata-i18n-placeholder. The element's own text stays when the active language has no string for its key. default_langapplies until the user picks a language in Settings, where "Automatic" returns to it. To keep users on one language, set it and hide the picker:[data-part="language"] { display: none; }.
Extension scripts¶
ChatUI(scripts=[...]) lists ES modules the page imports before it starts,
so whatever they register — a component renderer, a
tool view — is in place for the first render. They reach the
page through window.lovia, its one public script API; the other modules are
internal.
window.lovia |
Description |
|---|---|
version |
The lovia version serving the page, for feature checks |
ready |
A promise that settles once the app's start is over: the page is wired up, the first chat-list request and the chat in the URL have loaded or failed, and the view in the URL is open. It stays pending if the page finds the user signed out |
state |
A frozen snapshot: sessionId (null for a new chat), agent, theme, lang, busy (a run is streaming in), view (the open view, or null). Read it again for the current one |
on(event, fn) |
Listen for an event; returns a function that stops listening |
send(text) |
Send text as the user; while a run streams it is queued for the next turn |
setDraft(text) |
Put text in the composer for the user to edit and send |
newChat() / openSession(id) |
Show a new chat / chat id, closing any view |
toast(text, { type }) |
A transient notice; type is "info" (default) or "error" |
fetch(path, init) |
fetch for an app route — yours, or one of the HTTP API's: path starts with / and gets the path prefix; a 401 gets the page's sign-in handling |
url(path) |
The URL of an app path, path prefix included — for <img src>, EventSource, links |
t(key, params) |
A UI string in the page's language, including your own keys |
registerRenderer(kind, renderer) / getRenderer(kind) |
UI components |
registerToolView(name, view) |
Tool views |
registerMessageAction(id, action) / refreshMessageActions() |
Message actions |
registerView(id, view) / openView(target) / closeView() |
Views |
provideContext(key, fn) |
Add key to the context runs start with, read from fn() at send time — see Run deps |
Every register* call, and provideContext, returns a function that undoes
it, like on. Undoing brings back what it replaced under the same key (a
built-in renderer you wrapped, an earlier view), whatever order the undos
come in.
// ui/acme.js
const { lovia } = window;
lovia.ready.then(() => {
console.info(`Acme extensions on lovia ${lovia.version}`);
});
// A button the template put in the composer_actions block.
document.getElementById('acme-summarize')?.addEventListener('click', () => {
lovia.send(lovia.t('acme.summarizePrompt'));
});
Don't await lovia.ready at the top level of a script: the page waits for your
script before it starts, so the two would wait on each other until the
5-second limit below. A script that fails to load shows an error toast, and the
page starts without it. One still loading after 5 seconds stops holding up the
start; it keeps loading and registers when it lands. Scripts are your own code,
with the page's full privileges, so self-host third-party code in assets
rather than loading it from someone else's CDN.
For editor completion and type checks, reference the declarations shipped with
lovia (the path is under your environment's site-packages):
Events¶
Events describe what the page on screen does. A listener that throws is logged and doesn't stop the others.
| Event | Payload | When |
|---|---|---|
session |
{ sessionId } |
Another chat is on screen (null: a new one); also when a new chat gets saved |
agent |
{ agent } |
The user picked another agent |
run-start |
{ sessionId } |
A run began streaming into the chat: sent, or reattached after a reload |
run-end |
{ sessionId, status, usage } |
The chat stopped streaming it. status: done (answered), error, cancelled (stopped by the user), or detached (the user switched chats or the connection dropped; the run may go on server-side). usage is the answered run's token usage, else null |
theme |
{ theme } |
Light or dark took effect |
view |
{ view } |
A view opened ('exec/perf'), or the chat came back (null) |
lovia.on('run-end', ({ status, usage }) => {
if (status === 'done') analytics.track('reply', { tokens: usage?.total_tokens });
});
Tool views¶
Every tool call shows as a card: its name and arguments on one line, the
parameters and result inside. registerToolView changes how a tool's cards
read — useful for the tools you don't control (MCP servers, say), and for
end-user deployments where order_status(id: …) means nothing to the reader.
lovia's own tools already read this way — "Read file a.md", "$ ls -la",
"Web search “…”" — and a view you register, the '*' fallback included, takes
precedence over theirs:
// One line in place of name(args), for every tool without a view of its own;
// null hands over to the next view (lovia's line for its own tools), and in
// the end keeps name(args).
const labels = { order_status: 'Looking up an order' };
lovia.registerToolView('*', { summary: (tool) => labels[tool.name] ?? null });
lovia.registerToolView('web_search', {
summary: (tool) =>
tool.status === 'done' ? `Searched “${tool.args?.query}”` : `Searching “${tool.args?.query}”…`,
// Draw the card's body; called again on every change of `tool`.
render(el, tool) {
if (tool.status !== 'done') return;
const list = document.createElement('ol');
for (const hit of JSON.parse(tool.result).results ?? []) {
const link = document.createElement('a');
link.href = hit.url;
link.target = '_blank';
link.textContent = hit.title; // never innerHTML: tool output is untrusted
const item = document.createElement('li');
item.append(link);
list.append(item);
}
el.replaceChildren(list);
},
});
view field |
Description |
|---|---|
summary(tool) |
The card's one-line summary instead of name(args); null or undefined falls through to the '*' view, then lovia's own line, then name(args). Hovering shows the call it stands for |
render(el, tool, ctx) |
Draw the card's body into el. Same contract as a component renderer (ctx, an optional cleanup function), and the raw result stays one "Source" toggle away |
snapshot(tool, ctx) |
Static HTML for the chat's HTML export |
toolis a frozen snapshot of the call:id,name,args(parsed; absent when over 256 KB or unparsable),status(running,awaitingthe user,done,error),result(the output text, once settled),isError,images. A view's functions run again whenever it changes.- The view for a tool's name wins over the
'*'view, field by field: a view with onlysummarystill gets the'*'view'srender. - A result that is itself a UI component keeps drawing as
that component; the view's
summarystill applies. - Hiding a tool's cards is CSS's job (
[data-part="tool"][data-tool="…"], see Styling), not a view's. - Kinds starting with
lovia.are lovia's own: don't register renderers under them.
Message actions¶
registerMessageAction(id, action) adds a button to the messages' footers,
beside copy: rate an answer, report it, save it to notes. lovia keeps none of
that data — the action calls a route of your own:
const ratings = new Map(); // ref → 'up' | 'down', for the chat on screen
lovia.registerMessageAction('rate-up', {
label: 'Good answer',
icon: '👍',
when: (msg) => msg.role === 'assistant' && msg.ref !== null,
pressed: (msg) => ratings.get(msg.ref) === 'up',
async run(msg) {
const res = await lovia.fetch('/feedback', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ session_id: msg.sessionId, ref: msg.ref, rating: 'up' }),
});
if (!res.ok) throw new Error('Could not save the rating'); // shown as a toast
ratings.set(msg.ref, 'up');
lovia.refreshMessageActions(); // draw it pressed
},
});
action field |
Description |
|---|---|
label |
The tooltip and accessible name |
icon |
A built-in icon name, SVG markup, or text such as an emoji; the label when absent |
when(msg) |
Whether msg shows the action; every message when absent |
pressed(msg) |
Draw the button pressed (aria-pressed), e.g. a rating already given |
run(msg) |
Called on a click; may be async, and the button is disabled until it settles. An error it throws shows as a toast |
msgis a frozen snapshot:sessionId,role(userorassistant),text(Markdown, what copy takes), andref.refnames a user message —"<run_id>:<n>"— and a reply carries therefof the message it answers, so(sessionId, ref)is the key for data you keep about a message. It survives reloads, and it never names another message: rewinding cuts a message's ref out for good, so an edited question or a regenerated answer gets a new one, and an old rating never lands on new text.refisnulluntil the server names the message: when its run finishes, or on the next load of the chat for a run that failed or was stopped (and for every run when the chat store keeps no checkpoints).when: (msg) => msg.ref !== nullholds an action back until then; one that needs no ref (quote, translate) shows at once.- Actions show on settled messages: none on a reply still streaming in or a message still queued.
whenandpressedare asked when a footer is drawn. After the state they read changes (a rating saved, ratings fetched for the chat that came on screen), calllovia.refreshMessageActions(). Registering under anidagain replaces the action; the functionregisterMessageActionreturns removes it, bringing back the one it replaced.
examples/35_web_feedback.py is a complete version: 👍 / 👎 on answers kept
in SQLite by its own routes, a report page, and a Quote action.
Views¶
A view is a page of your own in the main area, in place of the chat: a dashboard, a settings form, a list of records. Register it, then link to it — a plain link opens it:
lovia.registerView('dashboard', {
title: 'Dashboard',
async render(el, ctx) {
const res = await lovia.fetch('/acme/stats');
el.replaceChildren(drawStats(await res.json(), { tab: ctx.path }));
const off = lovia.on('theme', redrawCharts);
return off; // called when the view closes or draws again
},
});
view field |
Description |
|---|---|
title |
The top bar's title while the view is open |
render(el, ctx) |
Draw the view into el, on each opening and each change of its path. May be async; return a cleanup function. ctx.path is what follows the id in ?view=<id>/<path> ('' when nothing does) |
- A view covers the chat, it doesn't leave it. The chat stays current
underneath (
state.sessionId), a run keeps streaming, and the view's state lives in the URL beside the chat's:?session=…&view=dashboard. - Links. A plain click on a link to
?view=<id>[/<path>]opens the view in place; a middle or ⌘/Ctrl click opens it in a new tab, which starts on that view.?view=(empty) returns to the chat. Write them relative, as above, so they hold under a path prefix. Links inside messages are left alone: model output doesn't navigate. - Back to the chat. Clicking any chat — the current one too — starting
a new chat,
lovia.openSession,lovia.newChatandlovia.closeView()close the view.lovia.senddoesn't: the message goes to the chat underneath. While a view is open and the chat starts waiting on the user (an approval, a question), the page says so. On a phone, opening a view or a chat closes the sidebar drawer. - What shows. The top bar stays, with the view's title and your
topbar_actions; the parts that belong to the chat hide. The view draws in[data-part="view"], which you may style. A link insidebar_navto the open view getsaria-current="page"(compared by id, so?view=execstays current onexec/perf). - Lifecycle. The previous cleanup runs before each draw. A render that
throws shows a notice in the view; an async one that lands after the view
was left is cleaned up at once. Registering under an
idagain redraws it if open; the functionregisterViewreturns removes it, and an open view then shows the view it replaced, or closes. A theme switch doesn't redraw a view (it would lose form input and scroll), so listen forthemeif yours follows it. openView(target)throws for an id with no view; a link to one, or one in the URL at start, shows a notice instead. Ids usea-z,0-9and-; ids startingloviaare reserved.
With a component framework¶
render(el, ctx) hands you an element and takes back a cleanup, so any
library that mounts into an element can draw the view — still with no build
step. Put the library's ES module files in your assets directory, map their
bare names in the head block, and import them in your script. With Preact
and htm (JSX-like templates in plain JS):
{% block head %}
<script type="importmap">
{ "imports": {
"preact": "{{ url_for('ui-assets', path='vendor/preact.js').path }}",
"preact/hooks": "{{ url_for('ui-assets', path='vendor/preact-hooks.js').path }}",
"htm": "{{ url_for('ui-assets', path='vendor/htm.js').path }}",
"htm/preact": "{{ url_for('ui-assets', path='vendor/htm-preact.js').path }}"
} }
</script>
{% endblock %}
import { html, render as mount } from 'htm/preact';
import { useState } from 'preact/hooks';
function Stats({ stats }) {
const [tab, setTab] = useState('day');
return html`…`;
}
lovia.registerView('dashboard', {
title: 'Dashboard',
async render(el) {
const res = await lovia.fetch('/acme/stats');
mount(html`<${Stats} stats=${await res.json()} />`, el);
return () => mount(null, el); // unmount: the components' effects clean up
},
});
url_for('ui-assets', …)keeps the URLs right under a path prefix.- Load the data in
renderand pass it down: a failed load then shows the view's notice, as it would without a framework. - The library owns
eland nothing else: draw the rest of the page throughwindow.lovia, not into its elements. - Lit and Vue's
vue.esm-browser.prod.jswork the same way. A framework that needs a compiler (Svelte, JSX) works too, if you build it yourself and list the output inscripts. - Tailwind (or shadcn/ui on it) can style a view: lovia's
lv-names never meet its utilities or theme variables. Leave out Preflight, its global reset, which would restyle the whole page; scope a reset to the view's element instead.
examples/33_web_extensions.py draws
its docs view this way.
UI components¶
A tool result can render as UI — a sortable table, a KPI strip — instead of
text. The tool returns ui_result(kind, props), and a renderer registered for
kind draws it inside the tool card, live and when the chat is reopened.
from lovia import tool
from lovia.web import ui_result
@tool
def top_products(n: int = 10) -> str:
"""Best sellers, shown to the user as a table."""
rows = query_top_products(n)
return ui_result("table", {"columns": ["sku", "units"], "rows": rows})
Three kinds are built in: table (above, see Tables),
chart (see Charts), and html, a sandboxed page (see
Pages). The numbers in that table come straight from the
query; the model never writes them out.
The envelope is the tool's output, so the model sees the props as well. When
the call's own arguments already are the data (a presentational tool), pass
from_args=True — or a field name — instead of props, so the data isn't
echoed back a second time. Bulk data stays out of the model's context with
put_ui_data. Keep an envelope under the Agent's max_tool_output_chars
(200K characters by default): a longer output is truncated, which breaks its
JSON, and the card shows it as text instead.
For a kind of your own — say return ui_result("kpis", {"items": [...]}) —
write a renderer: a JS module loaded with ChatUI(scripts=[...]):
// ui/kpis.js
window.lovia.registerRenderer('kpis', {
render(el, props, ctx) {
for (const item of props.items) {
const card = document.createElement('div');
card.className = 'kpi';
card.textContent = `${item.label}: ${item.value}`; // never innerHTML
el.append(card);
}
// Optionally return a cleanup function (observers, timers, chart instances).
},
});
The renderer gets the element to draw into, the props, and a context:
ctx field |
Description |
|---|---|
theme / lang |
'light' or 'dark'; 'en' or 'zh'. On a theme switch the component is cleaned up and rendered again |
basePath |
The mount prefix, for requests to your own endpoints |
sessionId / callId |
The chat and the tool call the component belongs to |
live |
true when the result just arrived, false when a reopened chat replays it, e.g. to hold back something costly until a click |
send(text) |
Post a user message, e.g. from a "drill into March" button. It works only inside a real click or keypress, and returns false otherwise |
render may be async. If it throws, the card says so, and the raw output
stays behind the card's Source toggle. A kind with no renderer shows a notice
until one registers, so load order doesn't matter. Registering a kind again
replaces its renderer.
The chat's HTML export is a standalone file with no scripts, so render
can't draw there. Add a snapshot(props, ctx) that returns the component as
static HTML (sync or async; the same ctx, without send). The export
sanitizes it. Without a snapshot, the export shows the raw output. The
built-in table exports every row as a plain table, and the chart exports as
an SVG image.
window.lovia.registerRenderer('kpis', {
render(el, props, ctx) { /* … */ },
snapshot(props) {
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
return props.items.map((i) => `<p><b>${esc(i.label)}</b>: ${esc(i.value)}</p>`).join('');
},
});
Props are untrusted
Props come from the model, tools, MCP servers, or remote content. Write
them with textContent (or sanitize with the page's DOMPurify), never
as HTML.
Registering a built-in kind replaces it: your own chart renderer can draw
charts with another library. examples/31_web_components.py replaces the
built-in table with a version that adds an "Ask" button to each row, which
posts a follow-up through ctx.send.
Bulk data¶
The envelope is part of the model's context, turn after turn. For a result too big for that — a 10,000-row query — store the full props with the chat, and put a preview in the envelope:
from lovia import tool
from lovia.web import put_ui_data, ui_result
@tool
async def run_query(sql: str) -> str:
"""Run a read-only query and show the result as a table."""
columns, rows = await db.fetch(sql)
ref = await put_ui_data({"columns": columns, "rows": rows})
preview = {"columns": columns, "rows": rows[:20], "row_count": len(rows)}
return ui_result("table", preview, data=ref)
The model reads only the preview, so give it what it needs to talk about rows
it hasn't seen, such as a row count or totals. The page fetches the full props
from GET /api/sessions/{id}/ui-data/{ref}, behind the same auth as the rest
of the API, and hands them to the renderer. Any kind works this way, built-in
or your own, with no change to its renderer, and the HTML export draws the
full data too.
- The data lives as long as its chat: deleting the chat deletes it. If it is
gone, the card shows a notice and then the preview (only the notice when
the preview is
None). - Outside a
lovia webrun, such as a script or an eval,put_ui_datareturnsNone, and the envelope is just the preview. - A stored value is limited to 8 MiB of JSON. Larger values raise a tool error, so the model can narrow the query.
Wrapping a renderer¶
window.lovia.getRenderer(kind) returns the renderer registered for kind —
its { render, snapshot } — or undefined, so one renderer can build on
another. The built-in renderers are registered before your scripts run. For
example, a compact kind of your own can be drawn by the built-in chart, live
and in the export:
const chart = window.lovia.getRenderer('chart');
// {label, points: [[x, y], ...]} → an ECharts option.
const option = (p) => ({
title: { text: p.label },
dataset: { source: p.points },
xAxis: { type: 'category' },
yAxis: {},
series: [{ type: 'line' }],
});
window.lovia.registerRenderer('trend', {
render: (el, props, ctx) => chart.render(el, option(props), ctx),
snapshot: (props, ctx) => chart.snapshot(option(props), ctx),
});
The renderer you get is the one registered at the time of the call, so a
wrapper may also replace the kind it wraps — re-register chart around the
built-in one, say — without calling itself.
Follow-up suggestions¶
After a Run produces an answer, the built-in suggester makes a separate small model call. The main Transcript never sees that request, and the call does not delay the original answer.
Off by default: unlike a title, this costs one model call per run, so the
serving layer doesn't presume it. followup_model points that call at a
cheaper model than the Agent's own. The bundled lovia web CLI opts in on its
own behalf — disable it with --no-followups or LOVIA_FOLLOWUPS=0, and point
it at a small model by assigning that model the
aux role.
Pass a callable to replace the built-in suggester entirely — back it with a curated FAQ, a vector store, or the same generator under a different prompt:
from lovia.web import FollowupRequest, create_app, generate_followups
async def pricing_only(request: FollowupRequest) -> list[str]:
return await generate_followups(
request, model="<model>", instructions="Ask only about pricing."
)
create_app(agent, followups=pricing_only)
A suggester receives the session's conversation (session_id, agent, and the
chat-shaped messages) and returns any number of strings. Returning [] shows
nothing, which is also what a raising suggester degrades to — the chips are
never load-bearing.
Authentication¶
Loopback binds need no credentials. serve() is safe by default beyond that:
an Agent served on a non-loopback host gets a generated token, printed once
together with a ready /?token=... UI link, and an app built with neither
token nor auth is refused there. The business API is never exposed
anonymously on a non-loopback address.
serve(create_app(agent, token="s3cret"), host="0.0.0.0") # fixed token
serve(agent, host="0.0.0.0") # generated + printed
The token guards the business routes registered by build_api_router.
/healthz, /api/docs, /api/openapi.json, the UI shell, and static assets
remain public. Clients can supply the token in two ways:
- Plain API requests and chat SSE send
Authorization: Bearer <token>. Chat streams usefetch, so headers work. - The bundled UI stores the token in a cookie.
/api/eventsusesEventSource, which cannot set custom headers, so it authenticates with the cookie;<img>previews and download links use it too. The UI reads the token from a/?token=...link or asks for it after a 401.
For sessions, OAuth, or per-user identity, replace the built-in check with any FastAPI dependency — it guards the same routes:
async def my_auth(request: Request) -> None:
if not valid(request):
raise HTTPException(status_code=401)
serve(create_app(agent, auth=my_auth), host="0.0.0.0")
It guards exactly what the token would, which has three consequences:
- The bundled UI needs a cookie.
EventSource,<img>previews, and download links cannot send custom headers, so a dependency that reads onlyAuthorizationlocks them out. Accept your session cookie as well. - Routes you add are yours to guard. Include them with
dependencies=[Depends(my_auth)]. The UI shell and/api/docsstay public; to gate the whole site, use middleware or your reverse proxy. - A front end on another origin that authenticates with cookies needs
credentialed CORS, which
cors_originsdoes not enable (see below).
For that cross-origin case, leave cors_origins unset and add the middleware
yourself:
from fastapi.middleware.cors import CORSMiddleware
app = create_app(agent, auth=my_auth)
app.add_middleware(
CORSMiddleware,
allow_origins=["https://chat.example.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
If the two origins are different sites (not just subdomains of one), the
cookie must be SameSite=None, so defend against CSRF yourself: several
endpoints (cancel, run a schedule now, upload) are body-less or multipart
POSTs — "simple" requests that skip the CORS preflight. The token cookie is
SameSite=Strict and unaffected.
Signing in. The bundled page can't show your login form, so tell it where
the form lives. With ui=ChatUI(login_url="/login?next={next}"), an API call
your dependency answers with 401 sends the browser there, {next} replaced by
the URL-encoded path (and query) of the current page. Without login_url the
page only says the user is signed out. A burst of 401s acts once, and the
token check's own 401 (code server_token) keeps its token prompt.
create_app() alone enables no authentication; under another ASGI server,
pass token or auth yourself. serve() only judges apps built by
create_app() — an app of your own that mounts build_api_router keeps its
own middleware and is run as is.
Run deps¶
Tools often need to know who is asking, and what the user picked on the page —
the knowledge base in a dropdown by the composer, say. create_app(run_deps=fn)
builds each run's RunContext.deps
from both:
from dataclasses import dataclass
from fastapi import HTTPException, Request
from lovia import RunContext, tool
from lovia.web import RunTrigger, create_app
async def auth(request: Request) -> None:
request.state.user = await users.from_session_cookie(request) # your identity
@dataclass
class Deps:
user: str
kb: str
def run_deps(trigger: RunTrigger) -> Deps:
user = trigger.request.state.user if trigger.request else "scheduler"
kb = trigger.client_context.get("kb", "docs")
if kb not in KNOWLEDGE_BASES:
raise HTTPException(400, f"unknown knowledge base {kb!r}")
return Deps(user=user, kb=kb)
@tool
async def search(ctx: RunContext[Deps], query: str) -> str:
"""Search the user's knowledge base."""
return await KNOWLEDGE_BASES[ctx.deps.kb].search(query, user=ctx.deps.user)
app = create_app(agent, auth=auth, run_deps=run_deps)
On the page, an extension script supplies the client side with
lovia.provideContext(key, fn); fn() is read each time a message (or a new
schedule) is sent:
const kb = document.getElementById('acme-kb'); // a <select> in the composer_actions block
window.lovia.provideContext('kb', () => kb.value);
run_deps is called once per run — sync or async — and what it returns is
the run's ctx.deps, shared by every leg of an auto-continued run. It runs
before anything about the run is written, so raising an HTTPException
refuses the run and leaves nothing behind: a chat message gets the error, with
no new chat created and an interrupted run still resumable; a schedule's fire
is recorded as a failed run, and the schedule moves on to its next time. Keep
it quick — every message waits for it before its run starts. trigger
carries:
| Field | Description |
|---|---|
client_context |
The page's context (provideContext's keys): UI state. The browser controls it — never derive identity or permissions from it. Not request.client, which is the client's network address |
request |
The request that started the run, after auth= ran: identity lives here (request.state). None when nobody requested the run |
source |
Why the run started: user (a message, or a reconnect resuming a run), schedule:<id>, subagent:<session>, subagent-report:<task> |
resumed |
A reconnect is resuming the run from its checkpoint |
session_id, agent |
The chat, and the agent about to run |
Every run gets a client context, including the ones nobody sent:
- Resume after a restart. A checkpoint keeps no context, so the server
stores the run's
client_contextbeside its checkpoint pointer and clears it when the run ends; the resume gets it back, with the reconnectingrequest. - Schedules. A schedule is a deferred message: one made from the
Schedules dialog stores the page's context at that moment, one the agent
makes with
schedule_runstores its own run's. Each fire gets it, withrequest=None. - Subagent tasks and their reports get the latest context of the chat
they work for, with
request=None.
A client context is capped at 64 KB of JSON. Without run_deps, runs get no deps
(as with the core Runner), and a request that carries a client context logs
a warning.
Serving under a path prefix¶
Behind a reverse proxy that serves the app at /lovia/ and strips the prefix
before forwarding, tell uvicorn the prefix:
The page takes the prefix from each request, so its assets, API calls, event
stream, and token cookie all stay under it; two instances under one host keep
separate tokens. Asset URLs carry no scheme or host, so a proxy that
terminates TLS never turns them into blocked http:// loads. The server
itself still answers at the root: open the page through the proxy.
To host a create_app() app inside an existing FastAPI app instead, mount it
(HTTP API); the prefix works the same
way.
Supervised Run lifecycle¶
Chat Runs — streamed or blocking — are server-owned tasks. An SSE subscriber
may disconnect and reattach without cancelling work, and a blocking
/api/chat client that disconnects leaves its Run to finish into the chat.
- User cancellation finalizes completed turns into the Session, removes dangling Tool calls, and clears the checkpoint.
- Server shutdown cancels Runs cooperatively but keeps checkpoints, so a client can reconnect and resume after deployment.
- Capacity is bounded by
max_background_runs; new starts receive HTTP 429 while full. - Blocking
/api/chatwaits for its Run and answers with the outcome (details). With no client to ask, its gated Tool calls go to the Agent'sapproval_handler, else are denied.
Live Runs, approvals, and SSE hubs are process-local. Run one worker. SQLite data uses WAL and survives restarts, but it does not make in-memory supervision safe across multiple workers.
Scheduling¶
The Web package stores durable schedules and supports three trigger forms:
| Trigger | Value |
|---|---|
at |
One ISO-8601 timestamp or epoch time |
every |
Interval in seconds |
cron |
Cron expression, matched against the server's local time; croniter ships with lovia[web] |
Scheduling(store) contributes the approval-gated schedule_run Tool. The
model can propose a future run, but it is not created until a user approves the
Tool call. With continue_session=True, a fire joins the same chat and injects
its instruction into an active Run; deleting that chat deletes the schedule
with it. With continue_session=False, each fire
starts a new Session and is skipped rather than queued while the previous fire
is still running. After downtime, multiple missed fire times coalesce into one.
A repeating schedule can carry a stop condition (until, natural language):
each fire is then told to evaluate it after doing the task and cancel the
schedule once it is met — "check the log every minute until it says ready".
Deterministic safety nets (max_fires, expires_at) deactivate the schedule
even if the condition is never met; the tool requires one when until is set.
The plugin also contributes list_schedules and cancel_schedule, which need
no approval: cancelling deactivates the schedule without deleting its record,
and self-cancel must work inside a clientless scheduled run, where an
approval request would be auto-denied.
For work that should start now but in its own chat, the plugin also
contributes start_session(instruction, title=None). It opens a new top-level
chat that runs the current chat's agent on instruction at once, in the
context of the run that called it, and returns {session_id, title, status}.
The new chat links back to the one that started it (origin.kind == "spawn")
but belongs to no one: deleting either chat leaves the other, and its answer
is not reported back. It needs no approval, since the chat is visible right
away and max_background_runs bounds it; at that limit the call fails and
leaves no chat behind.
Security checklist¶
- Keep
host="127.0.0.1"for personal use; non-loopback binds are token-guarded automatically, but the token then protects everything — treat it like a password. - Restrict or disable writable Workspace access for untrusted users: anyone holding the token can make the agent edit files or run shell commands.
- Configure
approval_timeoutso abandoned dialogs do not occupy capacity. - Use one worker and back up the SQLite database.
- For real multi-user exposure add TLS, per-user auth (
auth=), and rate limiting — a shared token is single-user security. - The chat page sets no Content-Security-Policy, so your blocks and extension
scripts may embed iframes from other sites. Untrusted content lovia renders
(
htmlpages, the Files panel's HTML preview) runs in frames that carry their own guard: an opaque origin that can't navigate itself away. A policy of your own (set at your proxy or in middleware) is inherited by thosesrcdocframes, and the page relies on inline scripts, so itsscript-srcmust allow them;frame-srcdoesn't affectsrcdocframes.
See the complete Deployment guide before production use.
See also¶
- Web UI — built-in browser experience and CLI
- HTTP API — endpoints, SSE wire format, and
ChatStore - Tools: approval — server-side approval flow