Internal architecture of LiteChat
2026-01-19
The following document is an architecture snapshot internal to the LiteChat team that summarizes the progress I've made in learning Clojure so far:
===
Revision 20260119
LiteChat is a web application that manages a la carte access to large language model providers.
Codebase
Clojure
LiteChat is a Clojure application. The ethos of the Clojure community is that it prefers to keep everything in pure data as much as possible. The closer a module is to data purity, the more simple, understandable, malleable, and portable it is, which makes the module much more reliable and maintainable over time. LiteChat has gone through several iterations of its core architecture to more faithfully uphold this ethos.
Data model
Generalized across all BUILD projects, there are six IAM tables:
app_userapp_profileapp_groupapp_policyapp_group_membershipapp_policy_attachment
Generalized across all BUILD projects, there is one system table:
queued_job
Specific to LiteChat, there is one "reference" table:
mimetype
Specific to LiteChat, there are five core entity tables and a number of administrative entity tables:
-
project -
project_file -
conversation -
turn -
turn_part -
billing_account -
credit_ledger_entry
Main axis
LiteChat is based on the in-house cljwebapp framework. (Technically, cljwebapp is based on LiteChat, since LiteChat came first.) The central concept of cljwebapp, which is a web framework, is that it sends web requests down a "main axis" that generates effects, such as modifying resources in the database, and a response, which is just data that is returned to the calling client.
Since LiteChat is a Clojure application, it uses the Ring specification to represent both HTTP requests and HTTP responses. In general, Ring requests and responses are just Clojure data, and they contain "just" Clojure data. (There are some notable exceptions like channels, which sometimes appear in Ring maps as vals.)
The following module types comprise the main axis of LiteChat:
routes- The routing module is the first point of contact of a Ring request with LiteChat. In the routing module, a Ring request is pre-processed through middleware and is eventually dispatched to a handler.
handlers- A handler is a function that sits between the routing module and the domain module. Its main job is to gatekeep access to the domain. In practice, this means that handlers may query the database to perform checks on login, ownership, and permissioning. Handlers should generally not have potentially-destructive side effects, i.e., handler code should not be able to write to the disk, send destructive web requests, or change tables in the database. Handlers are allowed to have observational effects, e.g., querying the database.
domain- A domain module receives a Ring request that has been vetted by the handler. It is allowed to do whatever it needs to to accomplish its goal. The only constraint on domain modules is that the domain functions must return a Ring-compatible response map. Domain modules are allowed to assume that the incoming data has been parsed and validated. In practice, this means that domain modules are written as a "happy path," which significantly reduces the size of domain modules. Note that the "happy path" assumption only applies to edge concerns such as the coercion of request parameters and correct authorization. Errors that can happen within business logic, such as failed HTTP requests, must still be handled in the domain module, whether by try/catch or some other technique.
In practice, the components of the main axis are highly sensitive to the underlying data model and the specific libraries chosen. Because ctx and database records are passed as pure data structures with little transformation, changes to the database schema or routing library tend to affect the entire call stack. This has caused two refactors, a) the refactor from compojure to reitit, and b), the data model refactor from sessions/messages to projects/conversations, to be onerous to implement. Examining the feasibility of disentangling the main axis from these decisions has been identified as an important, but not urgent, activity for the future.
I comment on the implementation details for components of the main axis below.
routes
LiteChat's routing module is based on Metosin's reitit library. reitit expresses routes and their accompanying metadata as pure data. The effective DSL of reitit is harder, in the Rich Hickey sense, than the older compojure library, but it is simpler, again in the Hickey sense.
One particularly important feature of reitit, enabled by its sibling Metosin libraries malli and muuntaja, is its support for defining schemas for both requests and responses. This enables LiteChat to filter out invalid requests at the edge of the app and coerce the data types of valid requests automatically. The schemas are renderable as a swagger.json endpoint, which dramatically reduces the friction between writing an endpoint on the main axis and integrating the endpoint with the frontend (since LLMs can read swagger.json specs easily).
handlers
Handlers are generally functions that take two args: req, which is the Ring request, and opts, which is a map. In practice, opts is never referred to in its entirety, as its only current purpose is to hold an :action upon which both the handler and, later, the domain function can dispatch.
LiteChat handlers use a module litechat.rules to vet requests. In its top-level let, a handler will gather data, bundle them into a map ctx, and pass ctx to rules. If all the checks defined for the action pass, the domain function is called; if not, an error is returned instead. This is best understood by looking at the handler code itself.
Notably, rules is written solely with data and functions. It is not effectful, and it also only does rudimentary boolean checks on context maps. Its purpose is to give semantics and organization to the checks done by the handler, not to actually perform logic.
domain
LiteChat's domain functions, called dfunc in the code, are implemented as multimethods. All actions that share an informal context share the same multimethod.
The dfuncs take two arguments: req, the Ring request received from the handler, and ctx, the assemblage of "just data" (i.e., no complex objects) derived from the req, described in the section above. ctx typically contains {:keys [user action parameters]} at minimum; parameters itself contains {:keys [body path multipart query]}. The multimethod dispatches based on (:action ctx).
As much as possible, dfuncs are meant to rely only on data from ctx, as this makes dfuncs easily REPL-testable, as ctx is easily fabricated for this purpose. There are some unavoidable exceptions, such as initializing a websocket connection, which requires the use of req.
LLM adapters
The main LiteChat component that exists outside the main axis is the litechat.ai.adapters module and its children. These are effectful in two ways. First, the adapters send out actual HTTP requests to various LLM providers. Second, the main litechat.ai.adapters module returns a Manifold stream that forwards events that are compliant with the ResponseStreamEntry schema in litechat.schemas.chat-completions.
The main litechat.ai.adapters namespace, and specifically the get-response-stream! function within it, is the only point of contact that dfuncs may have with LLMs. This is the function that returns a Manifold stream. It currently takes one arg, opts, with {:keys [conversation-id model-name request-metadata]}. These may sound like implementation details not worth going over, but despite its name, this code is not easily abstracted in the Hickey sense (drawing from some set of exemplars some essential characteristics) but instead only factorized (physically separated).
conversation-idis required to render the conversation for the adapters.model-nameis required because the LiteChat model name may differ from the provider model name. Each provider's adapter has a map of the LiteChat name to the provider name. I note that we may as well include the provider name as an attribute in the database in themodeltable. I file this as "important but not urgent," since I (as the current sole maintainer) can easily change the code, but it remains a weakness in the current codebase.request-metadatais drilled data that originally arrives at.../post-messageas a JSON-encoded string. It holds control values such as thinking effort and whether or not to use web search. It also holds LiteChat options under the:litechat-optionskey for controls such as:include-memories.request-metadatavaries per provider and is meant to adhere to at least one of the request metadata schemas defined informally inroutes.chat.v2, but this is not checked. ThedfuncJSON-decodes the metadata (and assocs:litechat-optionswith the LiteChat options data) before sending it to the adapter.
Each adapter submodule represents an LLM API host, currently [openai anthropic google mistral openrouter]. These are not the same as "providers" in the code, which instead represent the prefix part of a LiteChat model name, e.g., "openai" in "openai/gpt-5.2". This distinction only matters for OpenRouter, which is an LLM API host that supports providers [deepseek alibaba meta z-ai].
Adapter submodules each have a function send-request! that takes an opts arg with keys {:keys [ccrb model-config request-metadata]}. The new arg :ccrb stands for "Chat Completions Request Body." It is generated in the parent litechat.ai.adapters module and adheres to the ChatCompletionsRequestBody from litechat.schemas.chat-completions. It is up to the individual adapter submodules to translate the combination of {:keys [ccrb model-config request-metadata]} into a format suitable for their API and to translate the resulting response (which should be a stream) into ResponseStreamEntry records to be put into the Manifold stream. In practice, this means that each individual adapter submodule looks similar to the others but differs in ways that make copy-pasting less useful than anticipated, as the entire submodule must be reviewed and tested if a new LLM API host is added due to subtle differences in behavior.
The contents of the Manifold stream returned by the adapters is broadcast to frontend websocket listeners to the conversation (via the .../listen endpoint).
Frontend
The frontend of LiteChat is, for all intents and purposes, written by AI. The overall design of the pages' layout and styling sometimes comes from the LiteChat team, but much of the frontend design also arose serendipitously from AI making decisions that turned out to work.
Nothing about the frontend is to be considered sacred or unchangeable, because due to the relative cheapness of frontend changes and the relative costliness of backend changes, frontend changes are generally effected after changes in the backend or data model and are thus dependent on the backend changes. In practice, this means that the frontend usually has to be tested by a human before a deployment involving a frontend change is made, as we do not yet have the capability to automate frontend testing.
AI frontend development benefits greatly from "show, don't tell." It is best if you have real representative examples of the return values of some endpoints. However, since this is often time-consuming to generate, I recommend a combination of a) feeding the LLM the swagger.json output, or b) using Malli to generate examples of the schemas used to coerce the request. Some endpoints that use particularly non-standard parameters and responses will need to be documented manually before the AI knows how to handle calling them and handling their response data.
I use LiteChat to develop LiteChat. Difficult frontend tasks are given to Claude 4.5 Haiku and easy frontend tasks are given to DeepSeek v3.2. I have not yet found a reason to reach for Claude 4.5 Opus for the frontend specifically, as frontend code is both verbose and not particularly difficult to read and write (only tedious), which makes it a poor fit for Opus, which is very intelligent but also very expensive.
Note that multipart/form-data handles nested keys poorly. If you need to send nested data, send it as a JSON-encoded string and decode it on the backend. Also note that Malli may not be able to handle this on the Reitit side.