Architecture¶
luplo exposes three interfaces on top of one core. The interfaces stay thin — they translate user input into calls on a backend — and the core handles everything that matters: database access, search, glossary, history, audit, worker dispatch.
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ CLI │ │ MCP │ │ HTTP │
│ (typer) │ │ (stdio) │ │ (FastAPI) │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└────────┬────────┴────────┬────────┘
│ │
▼ ▼
┌────────────────────────────┐
│ core.backend.Backend │
│ (Local | Remote protocol) │
└──────────────┬─────────────┘
│
▼
┌──────────────────────────┐
│ items · work_units · │
│ search · glossary · │
│ history · audit · ... │
└──────────────┬───────────┘
│
▼
PostgreSQL
(+ optional pgvector)
The three interfaces¶
CLI — lp¶
src/luplo/cli.py — a typer app that
exposes human-facing commands: lp init, lp brief, lp items add,
lp work open, lp task start, lp qa pass, lp worker, etc. It reads
.luplo (see Configuration reference) to resolve the active project
and actor so most commands do not need flags.
MCP — stdio server¶
src/luplo/mcp.py — speaks the Model Context
Protocol over stdio. Any MCP-compatible
client (Claude Code, Claude Desktop, Cursor, Zed, custom SDK) can call
tools like luplo_brief, luplo_item_search,
luplo_save_decisions, luplo_work_open, luplo_task_start.
The surface of tools exposed to clients is deliberately small (~20 tools) — LLMs get confused with large toolboxes. See MCP tool reference for the full list.
HTTP — FastAPI¶
src/luplo/server/ — the Remote-mode server. Installed via the server
extra (uv sync --extra server). A thin HTTP adapter over the core
with no built-in authentication — every write handler reads the
attribution actor from the X-Actor header (or
LUPLO_DEFAULT_ACTOR_ID as a fallback) and the operator puts its own
auth layer in front if the server is exposed. Provides:
Item / work-unit / project / search / checks routes mirroring the core surface.
GET /health(liveness) andGET /ready(DB ping).
The HTTP server is not required for Local-mode usage. A solo developer can run CLI + MCP directly against Postgres without ever booting the server.
The core¶
Everything lives in src/luplo/core/ and is organised by domain:
core/
├── db.py connection pool, engine
├── backend/ Backend protocol (Local, Remote)
│ ├── protocol.py
│ ├── local.py
│ └── remote.py
├── items.py CRUD + supersedes chain + soft delete
├── work_units.py open / resume / close
├── tasks.py item_type='task' wrapper
├── qa.py item_type='qa_check' wrapper
├── links.py typed edges between items / systems / work units
├── systems.py system graph (dependencies)
├── projects.py project row + seed
├── actors.py attribution registry (id/name/email — no auth)
├── glossary.py strict-first glossary pipeline
├── search/ pipeline.py, tsquery.py
├── embedding/ protocol / null / local (sentence-transformers)
├── extract/ LLM-based item extraction (opt-in)
├── sync/ sync_jobs debounce queue
├── worker.py PG LISTEN/NOTIFY worker loop
├── history.py items_history writers/readers
├── audit.py audit_log writer
├── item_types.py DB-backed type registry + JSON-schema validators
├── schemas/ seed JSON schemas (decision, task, qa_check, …)
├── models.py plain dataclasses returned by core calls
└── errors.py domain exceptions
Backend protocol¶
All three interfaces depend on core.backend.Backend:
LocalBackend— directpsycopgpool against PostgreSQL. Used by Local-mode CLI, Local-mode MCP, and the HTTP server itself.RemoteBackend— HTTP client against a luplo server. Used by Remote-mode CLI and Remote-mode MCP so a team member can work against a shared server without DB credentials.
Switching modes is a .luplo change. No code in cli.py, mcp.py, or
the routes knows or cares which backend is in play.
Writes and the audit trail¶
Every write path in the core funnels through:
Domain function (
items.create_item,tasks.transition_task,qa.assign_qa, …) validated by JSON-schema whenitem_typeis strict (task,qa_check).A row in the target table (
items,work_units,links, …).An
audit_logentry withactor_id,action, and a payload that describes what changed. Mutating functions always require anactor_idparameter — this is enforced in the function signatures.Where applicable, an
items_historyrow capturing the semantic_impact diff (see Semantic impact categories).Optionally, a
sync_jobsrow that the worker drains asynchronously.
The one-way flow — **caller → core function → table + audit + history
queue** — means any record can be traced back to the command that produced it. This is how luplo stays honest about who did what.
The worker¶
src/luplo/core/worker.py — a single long-running loop that uses
PG LISTEN/NOTIFY to wake on two channels:
sync_jobs— outbound sync work (debounced per-item so a rapid edit burst collapses into one external write).Glossary term candidates — terms the LLM pipeline flagged for human review.
The worker is deliberately single-process and dependency-free (no Redis,
no Celery). In Local mode you start it with lp worker. In Remote mode
the FastAPI lifespan hook starts and stops it alongside the server.
See Running the Local worker for details.
Data plane¶
PostgreSQL is the single source of truth — schema, history, audit, sync queue, worker triggers, and the glossary all live in one database. See Data model for the twelve tables.
Next¶
Data model — the twelve tables and how items became a substrate.
Search pipeline — how retrieval really works.
Running the Remote server — running the HTTP server for a team.