HTTP API¶
The bundled chat page and JSON + SSE API are independent. Disable the page when
you want to keep the endpoints and provide your own front end. A running server
exposes the interactive schema at /api/docs.
| Integration | Use it when |
|---|---|
create_app(agent, ui=False) |
You want a standalone service without the bundled page |
create_app(...) |
You want a complete ASGI app but will start the server yourself |
build_api_router(...) |
An existing FastAPI app already owns middleware and lifecycle |
app.mount(prefix, create_app(...)) |
An existing app should host a complete lovia app under a prefix |
Serving the API without the UI¶
Two depths. Turn the page off:
from lovia.web import create_app
app = create_app(agent, ui=False) # no GET / and no /static — API only
…or mount the router into your own FastAPI app (your middleware, your auth, your lifecycle):
from fastapi import FastAPI
from lovia.web import ChatStore, RouterDeps, build_api_router
deps = RouterDeps(agents={"bot": agent}, store=ChatStore.in_memory())
app = FastAPI(lifespan=deps.lifespan)
app.include_router(build_api_router(deps))
RouterDeps is a plain dataclass — only agents and store are required;
run settings (max_turns, budget, retry, tracer, approval_timeout,
max_background_runs, scheduler_poll, title options) are fields with the
same defaults create_app uses.
deps.lifespan runs the machinery behind the API: on startup it settles runs
a dead process left running and starts the schedule poller; on shutdown it
winds live runs down to resumable checkpoints and closes chat workspaces with
their background processes. Without it, schedules never fire. If your app
already has a lifespan, enter it inside yours:
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
# your own startup
async with deps.lifespan():
yield
# your own shutdown
app = FastAPI(lifespan=lifespan)
Agents carrying the Subagents Plugin also need wire_subagents(deps)
once (see Web UI).
Mounting a create_app() app¶
An app built by create_app() can also be mounted under a prefix of yours.
Starlette runs only the outer app's lifespan — a mounted app's never starts,
and nothing reports it — so enter it from yours:
lovia_app = create_app(agent)
@asynccontextmanager
async def lifespan(app: FastAPI):
async with lovia_app.state.deps.lifespan():
yield
app = FastAPI(lifespan=lifespan)
app.mount("/lovia", lovia_app) # page at /lovia/, API at /lovia/api/...
Authentication¶
With create_app(token=...), every business route registered by
build_api_router requires authentication. serve(agent) on a non-loopback
host generates one when none was supplied.
Plain requests, POST /api/chat/stream, and POST /api/chat/reconnect send
Authorization: Bearer <token>. GET /api/events uses EventSource, which
cannot set custom headers, so the bundled UI authenticates it with the
lovia_token cookie. GET /healthz is served by create_app outside the
guard, so it stays open under token and auth alike. Missing or invalid credentials
return 401 with code server_token — distinct from a model provider
rejecting its API key, which arrives as a run error (provider_auth).
Apps mounting build_api_router themselves must add their own dependency,
such as token_dependency(token) from lovia.web.auth or any FastAPI
authentication dependency — and their own health probe, since /healthz is
not part of the router. /api/docs and /api/openapi.json belong to the
FastAPI app rather than this router and remain public by default.
Errors¶
Every error an API route raises has one body:
Branch on code, never on message wording; hint, when present, is a
suggested fix. Two exceptions keep FastAPI's own shapes: request validation
(422, detail is a list) and a custom auth= dependency (whatever it
raises). apiError(response) in the bundled client
normalizes all three.
| Error code | Status | Meaning |
|---|---|---|
invalid_request |
400 / 422 | a malformed or empty input the route rejected |
server_token |
401 | missing or invalid server token |
local_origin_required |
403 | a config write without auth from a non-local Host |
path_denied |
403 | the workspace policy refuses the path |
agent_not_found |
404 | no served agent by that name |
session_not_found |
404 | no such chat |
schedule_not_found |
404 | no such schedule |
model_not_found |
404 | no such model profile |
file_not_found |
404 | no such workspace file or directory |
turn_not_found |
404 | rewind or fork named a user turn the chat doesn't have |
image_not_found |
404 | no servable image at that tool-result index |
ui_data_not_found |
404 | no component data under that ref in this chat |
approval_not_found |
404 | no pending approval matches |
question_not_found |
404 | no pending ask_human question matches |
process_not_found |
404 | no such background process |
run_not_found |
404 | no live or resumable run to cancel or reconnect to |
feature_unavailable |
404 / 501 | the server or agent lacks it (no workspace, memory, checkpointer, question channel, or rewind) |
run_active |
409 | a run owns this chat |
run_stopping |
409 | a stopped run is still winding down; retry shortly |
run_unfinished |
409 | the chat's last run was interrupted and can still resume; a whole-chat fork waits for it |
nothing_to_fork |
409 | the fork would be empty: no finished history before that point |
run_cancelled |
409 | a blocking turn's run was stopped (cancel, chat deletion, or shutdown) |
agent_unregistered |
409 | the interrupted run's agent is no longer served |
schedule_not_fired |
409 | the schedule couldn't fire right now |
model_exists |
409 | a model profile with that id exists |
model_in_use |
409 | the default chat model can't be deleted |
file_too_large |
413 | over the workspace read or upload limit |
unsupported_file_type |
415 | not previewable inline, or an extension uploads don't allow |
too_many_runs |
429 | at the concurrent-run cap |
run_failed |
500 | a blocking turn's run failed; message is its error |
Endpoints¶
| Method & path | Purpose |
|---|---|
GET /healthz |
liveness (served by create_app, not build_api_router) |
GET /api/info |
title, agents, default agent, version, feature flags |
GET /api/agents · GET /api/agents/{name} |
agent introspection (instructions, tools, capabilities) |
POST /api/chat |
one blocking turn → {output, session_id, usage}: a supervised Run, waited for (details) |
POST /api/chat/stream |
SSE: start a run, or attach to the session's live run (injecting the new message) |
POST /api/chat/reconnect?session_id= |
SSE: re-attach after refresh, or resume an interrupted checkpoint |
POST /api/chat/approve |
resolve a pending approval: {session_id, call_id, decision} |
POST /api/chat/cancel?session_id= |
stop the live run (completed turns are kept) |
POST /api/chat/inject / uninject |
queue / withdraw a steering message for the live run |
GET /api/sessions?q=&limit=&offset= |
list / search chats (pinned first, paged); DELETE clears all |
GET /api/runs |
live supervised runs. A run's run_id is the one its lifecycle events and history record carry, kept across auto-chained legs and a resume |
GET /api/runs/history?session_id=&source=&since=&run_id=&limit=&offset= |
persisted run records (outcome, error, duration, token usage); since filters to runs finished after that timestamp, run_id picks one run |
GET /api/events |
SSE: subscribe to process-wide lifecycle events (no history replay) |
GET / PATCH / DELETE /api/sessions/{id} |
transcript · rename/pin · delete (with its subagent tasks and the schedules bound to it). Each user message carries a ref — "<run_id>:<n>", stable across reloads and rewinds; null until its run has finished. The answer that ends a run carries its duration in seconds, and timestamps are real where the run recorded its timing. A chat that came from another carries origin — kind is "fork" (a branch) or "spawn" (started by that chat's agent with start_session) — and the list's origin_kind says the same |
GET /api/sessions/{id}/todos |
current Todo list, rebuilt from the Transcript |
POST /api/sessions/{id}/followups |
{followups: [...]} — questions the user might ask next; [] when the feature is off or nothing fits. POST because it spends model tokens |
POST /api/sessions/{id}/rewind |
drop everything from a user message onward, named by its ref or by user_turn, its zero-based index among the user messages; 404 for a ref no longer in the chat, 409 while a run is live, 501 if the store lacks rewind |
POST /api/sessions/{id}/fork |
{ref?} → {session_id}: copy the chat's finished history into a new, independent chat — up to just before the user message ref names, or whole. The new chat's detail carries origin: {kind: "fork", session_id, ref, title, exists}; deleting either chat leaves the other. A fork copies the conversation, not the workspace: files stay as they are now. A whole copy is refused (409) while a run is live, stopping, or resumable |
GET /api/sessions/{id}/export?format=md\|json\|txt |
export a chat |
GET /api/sessions/{id}/ui-data/{ref} |
a UI component's full props, stored by put_ui_data during a run of this chat (details) |
GET / POST /api/schedules, GET / PATCH / DELETE /api/schedules/{id}, POST .../run |
scheduled runs: list, create, retime/pause, delete, fire now |
GET /api/schedules/{id}/runs |
a schedule's fire history (its run records, newest first) |
GET /api/workspace · /files · /recent · /file · /raw |
read-only file panel over the agent's workspace |
POST /api/workspace/upload?agent= |
save one file under the workspace's uploads/, within the extension allowlist and size limit |
GET / PUT /api/memory?agent= |
read / replace the Memory notes ({content, used, budget, dreamed_at}) |
POST /api/memory/dream?agent= |
dream the notes now → {content, used, budget, dreamed_at, before, after} |
GET /api/config · POST /api/config/models · PUT/DELETE /api/config/models/{id} · PUT /api/config/roles · PUT /api/config/search · PUT/GET /api/config/skills · POST /api/config/test |
UI-internal, serving the CLI's built-in settings UI and not part of the stable API: the CLI default agent's model configuration and skill directories — present only when /api/info reports features.model_config. Keys are write-only (null keeps, "" clears; reads carry {set, hint}); writes validate the whole document, persist it, and hot-swap the served agent. GET /api/config/skills reports per-directory discovery |
POST /api/chat and /api/chat/stream take
{message, session_id?, agent?, attachments?, context?}, and POST /api/schedules
takes a context too. context is a JSON object of at most 64 KB — the client
context create_app(run_deps=...) receives as trigger.client_context (see
Run deps). A message that joins a live run
doesn't change that run's context.
/api/chat/stream also takes busy: what a message for a chat with a live
run does. "inject" (the default) steers it into that run; "reject" answers
409 run_active instead and never injects — for a message that must start a
run of its own, such as a follow-up queued until the current run ends. A chat
already busy on arrival is refused before anything runs or is written; a run
that starts meanwhile is still refused, with no injection and no new run.
Lifecycle events¶
GET /api/events uses GET + EventSource to publish:
| Lifecycle event | Payload |
|---|---|
run_started |
{session_id, run_id, agent, source} |
run_finished |
{session_id, run_id, status, error, source} |
session_created |
{session_id, agent, title} |
session_retitled |
{session_id, title} |
config_changed |
{configured, model, profile_id, name} — refetch /api/config |
It does not replay history. On every connection or reconnection, fetch current state from
/api/sessions and /api/runs before processing new events. The server closes
subscribers that fall behind; recover them with the same snapshot-first flow.
To find Runs that finished while disconnected, query /api/runs/history with
since.
Other behavior¶
- Starting a chat stream for a Session with a live Run attaches to that Run instead of starting a second one or failing.
- Workspace routes always access the workspace in read-only mode, regardless
of the Agent's mode, and preserve its
denied_paths. They hide regenerable files such as__pycache__,*.pyc,venv, andnode_modules; dotfiles stay hidden too.
Blocking turns¶
POST /api/chat runs its turn as the same supervised Run a stream starts,
and answers once that Run ends. So the Run holds the chat for the whole
turn: another /api/chat for it gets 409 run_active, while a stream's
message, a subagent's report, or a scheduled fire for the chat is injected
into it — output is then the Run's last answer.
/api/chat/cancel, deleting the chat, or shutdown stops it (409
run_cancelled); a failure answers 500 run_failed; it counts toward the
concurrent-run cap (429). A client that disconnects mid-turn doesn't stop
it: the Run finishes into the chat, as a detached stream's does.
No client sees a blocking turn's approval requests, so its gated Tool calls
don't wait for one: the Agent's approval_handler decides, else the call is
denied — as for Runner.run.
Chat SSE streams¶
POST /api/chat/stream and /api/chat/reconnect return a text/event-stream
of event: / data: pairs: the Runner's
typed events, with JSON-encoded data.
? marks a field that may be absent.
| SSE event | Payload |
|---|---|
session |
{session_id, run_id} — first frame of every stream: the chat, and the run it follows (the id its lifecycle events carry) |
snapshot |
{session_id, status, entries} — re-attach prologue: the completed turns so far |
text_delta |
{delta} |
reasoning_delta |
{delta} |
output_discarded |
{} — clear the current turn's rendered deltas |
message_completed |
{message} — one assistant turn, assembled |
user_injected |
{content, turn} |
tool_call |
{id, name, arguments} — arguments is the raw JSON string |
tool_result |
{id, name, result, is_error, images?} — images lists {index, mime_type} stubs served by GET /api/sessions/{id}/tool-images/{call_id}/{index} |
todo |
{call_id, name, todos} — a structured todo update, in place of that call's tool_result |
approval_required |
{id, name, arguments} → answer via POST /api/chat/approve |
handoff |
{from, to} |
turn_started |
{turn, agent} |
retrying |
{attempt, max_attempts, delay, message} — the model call failed transiently; attempt attempt + 1 follows in delay seconds |
context_compacted |
{session_id, reason, reactive, summary, tokens_before, tokens_after, detail} — a compaction notice |
error |
{type, message, code, status_code?, retryable?, hint?} — tool-scoped, or terminal when the stream then ends |
refs |
{refs} — the refs of the user messages the run just finished holds, in order; precedes done. Absent without a checkpointer, or when the run holds a user entry the stream never showed (a structured-output repair prompt): the next load of the chat names them |
timing |
{finished_at, duration} — the run just finished, as its segment recorded it (epoch seconds; seconds), so a live answer reads as on reload; precedes done. Absent when the segment went untimed |
done |
{output, usage} — terminal success |
An error event's code classifies the failure; status_code and
retryable come from a provider error, hint from any lovia error carrying
one:
| Run error code | Meaning |
|---|---|
tool_error |
a tool call failed; the model sees the error and usually carries on |
provider_auth |
the provider rejected the credentials (401 / 403) |
rate_limited |
the provider rate-limited the request (429) |
overloaded |
the provider is overloaded (503 / 529) |
timeout |
the provider request timed out |
network |
the connection to the provider failed |
provider_error |
any other provider failure |
context_overflow |
the prompt exceeds the model's context window |
budget_exceeded |
a RunBudget limit tripped |
max_turns |
the run hit max_turns without finishing |
cancelled |
the run was cancelled |
guardrail |
a guardrail rejected the input or output |
output_invalid |
the output didn't parse into the agent's output_type |
internal |
anything else |
Chat streams do not use Last-Event-Id. After a disconnect, POST
/api/chat/reconnect again to receive the latest snapshot, a replay of the
in-flight Turn (including any pending approval_required), and then live
events. The same recovery applies when the server disconnects a slow client.
Comment lines (:) are keep-alives and should be ignored.
The bundled browser client¶
lovia/web/static/js/api.js is a dependency-free client for chat, Session,
scheduling, Workspace, Memory, and related endpoints. Methods that decode JSON
reject with an Error carrying status, code, and hint. The streams
(streamChat, reconnect) and the bodiless actions (approve, answer,
cancel, deleteSession, deleteAllSessions) resolve to the raw Response
instead; turn a failed one into the same Error with apiError(response),
which is exported for your own fetch calls too. readSSE(response) is an
async generator over a stream's {event, data} pairs:
import { api, readSSE } from "./api.js";
const res = await api.streamChat({ message: "hello" });
for await (const { event, data } of readSSE(res)) {
if (event === "text_delta") render(data.delta);
}
Copy it into your own front end, or read it as the reference implementation for any language — it is intentionally small. Like the page's other JavaScript modules it is not part of the stable surface.
ChatStore¶
ChatStore is the storage bundle behind the API: a Session for transcripts,
a metadata table for ChatMeta rows (titles, timestamps, pins, and the
resumable active_run_id), a checkpointer, and schedule and run-record tables.
ChatStore.sqlite(path, wal=True) keeps everything in one file, in WAL mode so
the stores sharing it do not queue behind each other (see
Web server);
ChatStore.in_memory() is for tests and demos; ChatStore(session=...,
meta_path=...) wraps a custom Session backend while keeping the
metadata features.
Sharp edges¶
build_api_routeralone has no authentication or rate limits.create_app(token=...)adds token authentication, andserve()never exposes the API anonymously off loopback (Authentication). User identities, permissions, quotas, and other multi-user concerns belong in your gateway.cors_originsstays unset (no CORS) until configured.- Chat SSE responses are POST-initiated. Use
fetch+ a reader for/api/chat/streamand/api/chat/reconnect; nativeEventSourcewill not work for them.GET /api/eventsis theEventSource-compatible stream. resultintool_resultis the raw value (JSON-safe form) — the same duality asToolCallCompleted; renderresultfor structure, fall back to strings.- Snapshots are per-turn, not per-token. A re-attach mid-sentence
replays that sentence's deltas from the turn buffer; your renderer must
tolerate re-seeing deltas it already drew (idempotent rendering by turn,
or just clear on
snapshot).
See also¶
- Web server — the server around these routes
- Streaming — the in-process form of the same events
- Example:
27_web_api.py