Architecture¶
Kosmos is one Django project (config/) over twenty-one apps under
apps/, rendered server-side and driven in the browser by HTMX and
Alpine. One PostgreSQL database holds the records, the search indexes, the
task queue and the AI status cache. A second process, the Django-Q worker,
runs queued and scheduled work. This page is the map: where things are,
how a request moves, and where state and background work live. Each
subsystem has its own page under subsystems/.
The apps¶
Every app in apps/ is listed in INSTALLED_APPS in config/settings.py
and mounted in config/urls.py. They are grouped here the way the
subsystem pages group them.
| Group | Apps | What they hold |
|---|---|---|
| Platform and config | config/, utils/, apps/management |
Settings, URL root, middleware, health checks; the shared helpers (utils/); the session-held filter, selection and pagination helpers, the schedule registry and the static_v template tag. |
| Identity and access | apps/accounts |
CustomUser with its perm_* flags and is_admin, the emailed sign-in code, access.py (matter membership) and the two project middlewares. |
| Matters | apps/matters, apps/contacts, apps/folders |
Matters, proceedings, rates, ledger, settlement; contacts and relationships; the contact folders (Folder is also the task folder). |
| Tasks and calendar | apps/tasks, apps/checklists, apps/calendar, apps/dash |
Tasks and the daily digest; checklist templates; events and the Google Calendar sync; the dashboard. |
| Time and billing | apps/activity, apps/invoicing, apps/reports |
Time, expense and flat-fee entries with their categories; invoices, credits, payment requests; the reports. |
| Trust and payments | apps/trust, apps/invoicing/pay, apps/invoicing/processors |
The trust ledger; the public payment page, the processor adapters (LawPay, Stripe, Confido, a fake for tests) and webhook reconciliation. |
| Case building | apps/case, apps/drive, apps/search |
Documents and OCR, highlights, facts, witnesses, labels, the case search tab and the saved case law; the Google Drive mirror; the global search modal. |
| AI | apps/case/ai |
The context builders, the chat surfaces, the agent tool loop (with its CourtListener research tools), the semantic index. |
| Notes and drafts | apps/notes, apps/drafts |
The notes editor, folders and library; draft links and the LibreOffice companion (companion_src/). |
| Email and intakes | apps/mail, apps/intakes |
Gmail sync onto matters; intakes, the inbound Mailgun webhook, the client forms. |
| Settings | apps/settings |
One package per Settings page (firm/, users/, permissions/, appearance/, integrations/, claude/, and so on) and the Firm record. |
Three apps are thin: apps/dash, apps/reports and apps/search have
no models of their own (apps/search holds the watson registrations in
search_config.py, the search modal's views and the search/api/ JSON
endpoint in api.py that the MCP server's search_kosmos tool calls).
None of
the directories under apps/ is vestigial: every one is installed and
routed. There is no billing, lab or research app; billing lives in
apps/invoicing and case-law research in the agentic chat's tools
(apps/case/ai/agent_tools.py) over the CourtListener client in
apps/case/courtlistener.py. Three packages have no urls.py:
apps/mail and apps/drafts have their views mounted from
apps/case/urls.py, and apps/drive has no views at all (it is driven
by signals, the worker and management commands).
config/¶
| File | What it does |
|---|---|
config/settings.py |
Reads config/.env through django-environ, defines Q_CLUSTER, the two caches, storage, email and logging. Explained in Platform and config. |
config/urls.py |
The health endpoints, the sign-in views, then one include() per app at the root, including the two public, tokenized URL sets (the payment page under apps/invoicing/pay/urls.py and the client intake form under apps/intakes/client_forms/public_urls.py). |
config/health.py |
/health/live/, /health/ready/ and /health/worker/. |
config/context.py |
Two context processors: env (the ENV value, which the templates use to show the development banner) and integrations (lazy ai_enabled and caselaw_available, which hide the optional AI and saved case-law surfaces). |
config/helpers.py |
Small utilities used across apps: normalize_phone(), dictfetchall(), MultipleOrderingFilter for django-filter, and the dump* debugging helpers. |
config/tests/ |
Tests for the health endpoints, media routing, rate limiting, safe Markdown and the generated reference pages. |
The middleware order is fixed in MIDDLEWARE: Django's own stack, then
DailyDashCheckMiddleware, CurrentUserMiddleware,
HtmxLoginRedirectMiddleware, PermissionMiddleware and
simple_history's HistoryRequestMiddleware. What each does is on the
platform page.
The two shells¶
A user works in one of two shells, both wrapped by templates/base.html.
The Practice shell is the firm-wide one: templates/sidebar.html lists
Dash, Matters, Calendar, Tasks, Contacts, Activity, Library and, by
permission flag, Invoicing, Intakes and Reports, with Search, Settings and
Shortcuts below. The sidebar's list is hx-boosted with
hx-target="#main-content" and hx-select="#main-content", so a click
fetches the full page and swaps only the content column; the sidebar
itself is never re-rendered by navigation. Which item is active comes
from the app context variable each view sets.
The matter workspace opens from a matter and has two modes, switched
by the pills in templates/matters/includes/mode-pills.html:
- Detail, under
/matters/<id>/..., is the matter's record: Overview, Contacts, Rates, Activity, Events, Tasks, Proceedings, Settlement and Ledger (templates/matters/includes/detail-nav.html). - Case, under
/case/<id>/..., is the matter's working file: Documents, Highlights, Timeline, Witnesses, Notes, Emails, Labels, Search, AI and Research (templates/case/includes/case-nav.html).
Both modes extend templates/matters/matter-base.html, which places the
matter header (matters/includes/header.html: the matter switcher, the
mode pills, the action buttons) above a #mode-content block. Switching
mode swaps that block through matters:mode-content or
case:mode-content; switching tab inside a mode swaps .detail-body
through matters:tab-content or case:tab-content. The tab views
(apps/matters/views.py and apps/case/views.py) gather the tab's data
through one get_<tab>_data() function per tab, shared by the full-page
view and the partial, so a tab renders the same either way. The case
shell remembers the last matter and the last tab per matter in the
session (last_viewed_matter, case_tab_<id>), which is how /case/
reopens where the user left off.
The templates directory follows the shells: templates/base.html and
templates/sidebar.html are the Practice shell; templates/matters/ and
templates/case/ are the workspace; every other app has a directory of
its own. templates/components/ holds the pieces several pages include:
the form-field renderers (form-fields-template*.html, selected through
FORM_RENDERER in settings), the HTMX modal shell, the brand marks and
gradients, the user chips and the format toolbar. templates/base-minimal.html
is the no-sidebar base for the sign-in pages, the public payment and
intake-form pages, the notes editor and the AI prompt windows.
How a request moves¶
A typical page is rendered once in full and then updated in parts.
- Full render. The browser requests
/tasks/.tasks_indexinapps/tasks/views.pyreads the user's filter, selection and page from the session, builds the list, and renderstasks/tasks.html, which extendsbase.htmland includes the list partial. - Partial swap. Every control on the page is an
hx-getorhx-postto a view that renders only the partial it changes: a row, a list, a modal (#htmx-modal-container, whichtemplates/modals.htmlprovides). Alpine handles the purely client-side state (open menus, modal visibility) through the components instatic/js/alpine-components.js. - Events. A view that changed data sets
HX-Triggeron its response (tasksListChanged,eventsChanged,wipChanged, and so on). Containers that depend on that data listen withhx-trigger="tasksListChanged from:body"and re-fetch themselves, so a modal that saves a task closes and the list behind it refreshes without the view knowing about the list. The trigger names are constants in the views (TASKS_TRIGGERinapps/tasks/views.py). - Toasts.
utils/toasts.pyadds anHX-Toastheader to any response;static/js/toasts.jsshows it. - Logged out mid-page. A session that expires makes a partial request
receive a login redirect;
HtmxLoginRedirectMiddlewareturns that into anHX-Redirectso the whole page goes to the sign-in form instead of a form being swapped into a table cell.
The patterns, including idiomorph swaps and the hx-swap-oob navigation
updates, are on the HTMX and Alpine page.
Where state lives¶
- The database holds everything durable, including the Django-Q queue
and schedules, the watson search index, the pgvector chunks, the
simple_historytables, and theai_status_cachetable. - The session holds the user's working state: list filters
(
FilterManagerinapps/management/filter_manager.py), multi-select selections (apps/management/selection.py), the current page (apps/management/pagination.py), the user chips, and the last matter and tab of the workspace. The session is saved on every request (SESSION_SAVE_EVERY_REQUEST) and lasts eight weeks. See Session state. - The per-process cache (
CACHES["default"], LocMem) holds CourtListener opinion payloads and the public-page rate-limit counters. It is private to each gunicorn worker. - The browser keeps the theme, the sidebar collapsed flag and similar
preferences in
localStorage(static/js/theme.js,sidebar.js).
Background work¶
Two mechanisms run work outside a request, and they are not interchangeable.
The Django-Q worker (python manage.py qcluster, the qcluster.service
unit in production) runs everything that is queued with
django_q.tasks.async_task() and the recurring schedules in
apps/management/schedules.py: OCR, Drive, Gmail and Calendar sync,
document and note summaries, semantic re-indexing, payment webhook
reconciliation, the daily digest, the chat purge. The worker is configured by Q_CLUSTER in
settings and explained on the operations page.
In-process daemon threads run the interactive AI chats. send_message
in apps/case/ai/views.py saves the user's message, seeds a status entry
and starts threading.Thread(target=process_ai_request, daemon=True)
(apps/case/ai/tasks.py) inside the gunicorn worker that took the
request; the intake chat (apps/intakes/chat.py) does the same. The browser polls case:ai-status
every second. The code does not record why chats run on threads rather
than the queue; the consequence it does record is that a restart of the
web process ends every reply in flight, and the status cache below exists
to report that honestly.
The two meet in the status cache. apps/case/ai/status.py defines
status_cache, the ai_status cache from settings: a DatabaseCache
over the ai_status_cache table, created by manage.py createcachetable
and not by a migration. It has to be in the database because production
runs several gunicorn workers and a poll usually lands in a worker other
than the one running the thread; the per-process LocMem cache gave every
worker a private view and the poller fabricated "server restarted"
replies (recorded in the docstring, 2026-08-17). Liveness is by TTL:
RunHeartbeat re-touches the entry every 30 seconds while the thread's
process lives, so a deploy that kills the process lets the entry expire
and the poller reports the interruption within RUNNING_TTL.
Storage¶
Uploaded files go through Django's storage API, never to a path. The
backend is chosen by STORAGE_BACKEND in config/.env: local writes
under media/ in the checkout, s3 uses django-storages against an
S3-compatible bucket (the settings are named DIGITAL_OCEAN_*). Nothing
under media/ is routed in production except media/company/ (the firm
logo); documents are streamed by serve_document in
apps/case/documents/views.py after the access checks. The routing
rules are the two functions at the bottom of config/urls.py, and
config/tests/test_media_security.py pins them.
Search¶
Two indexes, both in PostgreSQL:
- Keyword search is
django-watson. Models register inapps/search/search_config.py(matters, contacts, intakes) andapps/case/search_config.py(documents, highlights, facts, notes, emails). The case search tab (apps/case/search/views.py) callswatson.search(); the agent'ssearch_materialstool (apps/case/ai/agent_tools.py) querieswatson_searchentrydirectly withwebsearch_to_tsquery, scoped to one matter.DocumentandHighlightalso carry their ownSearchVectorField, kept current by the OCR task andupdate_search_vectors. - Semantic search is
apps/case/ai/semantic.py: material text is chunked, embedded with Gemini (apps/case/ai/embeddings.py, 768 dimensions) and stored asMaterialChunkrows with a pgvector column and an HNSW index.semantic_entries()returns cosine neighbours, and the agent tool fuses them with the keyword hits. Saves enqueue re-indexing on the worker whenSEMANTIC_AUTO_INDEXis on and a Gemini key is configured;build_semantic_indexbackfills. Without a Gemini key semantic results are simply empty and search is keyword-only.
static/¶
| Path | What it is |
|---|---|
static/js/main.js |
Page-wide behaviour: the confirm-link handler, CSRF helper, clipboard, the space-bar leader key and the command palette, matter switcher and nav switcher it opens. |
static/js/alpine-components.js |
The Alpine.data() components: dropdown, modal, confirmModal and the chart toggles. |
static/js/toasts.js, htmx-focus.js, sidebar.js, theme.js, nav-layout.js |
The shell: toasts, focus after swaps, the sidebar, themes and the painted favicon, the horizontal layout. |
static/js/<app>.js, static/js/notes/ |
Per-app scripts; the notes editor is split into modules under notes/ and loaded as ES modules. |
static/js/vendor/tiptap.bundle.js |
The notes editor's Tiptap build (@tiptap/* 2.27.2, bundled with esbuild). Its build sources are not in the repository: the bundle was rebuilt by hand and committed (2026-08-15, "table support with pipe-markdown round-trip"), and the build.mjs that produced it was retired. To add an extension, rebuild the bundle the same way and commit the result. |
static/css/palette.css, colors.css |
The colour ramps and the semantic tokens for the seven themes; see CSS theming. |
static/css/*.css, static/css/apps/ |
One stylesheet per component and per app, every one linked from base.html through static_v, which appends the file's modification time so a changed file is fetched again. |
static/pdfjs/ |
The PDF.js viewer, refreshed by scripts/update_pdfjs.py. |
HTMX, idiomorph, Alpine, Flatpickr, FullCalendar, Chart.js, Dropzone and
SortableJS are loaded from CDNs in base.html, not vendored. In
production nginx serves /static/ from the checkout with a 30-day cache,
except /static/js/, which is no-cache because the ES module imports
are bare paths that static_v cannot version (deploy/nginx/kosmos.conf).
The deployment¶
browser
| HTTPS
v
nginx (deploy/nginx/kosmos.conf: TLS, rate limits, /static/)
| unix socket /run/law.sock
v
gunicorn (law.service, N workers) qcluster (qcluster.service)
Django: config.wsgi Django-Q worker, 2 task slots
| AI chat runs: daemon threads | queued tasks + schedules
| |
+----------------+------------------------+
|
v
PostgreSQL
records, watson + pgvector indexes, django_q queue,
ai_status_cache, sessions (prod), simple_history
|
+-------------+--------------+
v v
object storage external services
STORAGE_BACKEND=s3 Google (Calendar, Contacts, Drive, Gmail)
(or media/ on disk) Anthropic and Gemini (chat, summaries,
embeddings)
CourtListener (research, citations)
LawPay, Stripe, Confido (payments,
webhooks back to /webhooks/)
Mailgun (SMTP out; inbound route posts
intake email to the webhook)
LibreOffice (local, headless; drafts)
Both Django processes read config/.env themselves, so the systemd units
carry no environment file, and a change to .env needs both restarted.
The templates in deploy/ are rendered by scripts/install.sh --prod;
the operator's view of the same layout is in
Install and Background worker.