Skip to content

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:

{"detail": {"code": "session_not_found", "message": "session not found", "hint": "..."}}

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, and node_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_router alone has no authentication or rate limits. create_app(token=...) adds token authentication, and serve() never exposes the API anonymously off loopback (Authentication). User identities, permissions, quotas, and other multi-user concerns belong in your gateway. cors_origins stays unset (no CORS) until configured.
  • Chat SSE responses are POST-initiated. Use fetch + a reader for /api/chat/stream and /api/chat/reconnect; native EventSource will not work for them. GET /api/events is the EventSource-compatible stream.
  • result in tool_result is the raw value (JSON-safe form) — the same duality as ToolCallCompleted; render result for 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