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) and GET /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 — direct psycopg pool 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:

  1. Domain function (items.create_item, tasks.transition_task, qa.assign_qa, …) validated by JSON-schema when item_type is strict (task, qa_check).

  2. A row in the target table (items, work_units, links, …).

  3. An audit_log entry with actor_id, action, and a payload that describes what changed. Mutating functions always require an actor_id parameter — this is enforced in the function signatures.

  4. Where applicable, an items_history row capturing the semantic_impact diff (see Semantic impact categories).

  5. Optionally, a sync_jobs row 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