How the pieces fit together. This page exists for the operator, contributor, or curious reader who wants to know what's actually running when npx ledric serve --gui boots up.

If you only want to use ledric, build-with-an-agent.md will get you further faster. Come back here when you need to reason about deployment shape, debugging, or why a particular boundary exists.


The ten-thousand-foot view

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Agent (Claude Code,   β”‚            β”‚  Browser               β”‚
β”‚  Cursor, your script)  β”‚            β”‚  (admin GUI, inline    β”‚
β”‚                        β”‚            β”‚   editor)              β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜            β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚          β”‚                                β”‚
   MCP β”‚      MCP β”‚                          HTTPS β”‚
  stdioβ”‚      SSE β”‚                                β”‚
       β”‚          β”‚                                β–Ό
       β”‚          β”‚                   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
       β”‚          └────────────────────  Reverse proxy + TLS   β”‚
       β”‚                              β”‚  (in production)       β”‚
       β”‚                              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚                                           β”‚
       β–Ό                                           β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  ledric process (Node 22+)                                   β”‚
β”‚                                                              β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β”‚
β”‚  β”‚  MCP server  β”‚   β”‚  HTTP server β”‚   β”‚  Admin GUI   β”‚      β”‚
β”‚  β”‚  (stdio +    β”‚   β”‚  (REST +     β”‚   β”‚  (static SPA β”‚      β”‚
β”‚  β”‚   SSE)       β”‚   β”‚   /rpc)      β”‚   β”‚   at /admin) β”‚      β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜      β”‚
β”‚         β”‚                  β”‚                  β”‚              β”‚
β”‚         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜              β”‚
β”‚                            β–Ό                                 β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚  Core β€” schema, validation, refs, versions, transforms β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                            β–Ό                                 β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚  Storage (SQLite | Postgres | MySQL)                   β”‚  β”‚
β”‚  β”‚  + Asset backend (db | local fs)                       β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                               β”‚
                               β–Ό
                      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                      β”‚  ledric.db         β”‚
                      β”‚  (or remote        β”‚
                      β”‚   Postgres /       β”‚
                      β”‚   MySQL DB)        β”‚
                      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

One process. One file (or one external DB). Multiple ways in (MCP, HTTP, admin GUI). One core doing schema/versioning/validation. A pluggable storage layer underneath.


The packages

ledric is a pnpm monorepo. Each package has one job.

packages/
β”œβ”€β”€ schema/        β€” defineType(), field types, validation
β”œβ”€β”€ storage/       β€” SQLite/Postgres/MySQL adapters + asset backends
β”œβ”€β”€ core/          β€” read/draft/publish/find, transforms, ref resolution
β”œβ”€β”€ mcp-server/    β€” registers the 20 MCP tools, dispatches to core
β”œβ”€β”€ http-server/   β€” Express-style routes (REST + /rpc), serves the GUI
β”œβ”€β”€ gui/           β€” admin SPA (React + Vite); also serves /admin/inline.js
β”œβ”€β”€ sdk/           β€” @ledric/sdk: TS read client + refAttrs helpers
β”œβ”€β”€ proxy/         β€” @ledric/proxy: server-side proxy primitive for consumers
└── cli/           β€” ledric command (init, serve, get, ls, asset, keys, …)

clients/
└── php/           β€” ledric/sdk (Composer): PHP read client

Three things worth noticing:

  1. Core is the single dispatch point. Both mcp-server and http-server are thin shells over core β€” they parse their transport's args, call into core, and serialise the result back. Adding a third transport (gRPC, queue-based ingestion, anything) wouldn't require duplicating business logic.
  2. Storage is two layers, not one. storage has a dialect layer (SQLite / Postgres / MySQL) and an asset-backend layer (db / local). Either dimension can flex independently β€” you can run SQLite-with-on-disk-assets or Postgres-with-in-DB-assets if you want.
  3. sdk and proxy are consumer-facing. They're the packages you'll add to your consumer site's package.json, not ledric's. They don't import storage or core β€” they speak HTTP to a running ledric.

Inside the ledric process

What's actually running when ledric serve --gui boots.

The MCP server (packages/mcp-server)

Registers the 20 tools listed in mcp-tools.md. Three transports, same core dispatch:

  • Stdio (default) β€” what desktop MCP clients spawn as a child.
  • Streamable HTTP at /mcp β€” opt in with serve --http-mcp. Lets multiple local clients share one ledric daemon.
  • Streamable HTTP, public-facing at /mcp β€” opt in with serve --public-mcp. Adds the OAuth provider for claude.ai custom connectors.

The same tool surface is also available over POST /rpc (one tool per request, JSON envelope). All four paths dispatch through exactly the same core methods. There is no second implementation.

See remote-mcp.md for the local-vs-public mode split, the OAuth flow, and deployment shape.

The HTTP server (packages/http-server)

A handful of REST routes plus the catch-all /rpc:

GET  /                        β€” root: API info, endpoint list
GET  /auth/status             β€” auth posture check
GET  /types                   β€” same as describe_model over HTTP
GET  /types/:name
GET  /entries/:type           β€” list (paginated, filterable)
GET  /entries/:type/:slug     β€” read one (with version, locale, expand opts)
POST /assets                  β€” multipart upload
GET  /assets                  β€” list
GET  /assets/:id              β€” metadata
GET  /assets/:id/meta         β€” explicit metadata
GET  /assets/<ref_key>        β€” bytes (with imgix-style transforms)
POST /rpc                     β€” generic dispatch to any of the 20 tools
ANY  /mcp                     β€” Streamable HTTP MCP transport
                                (when --http-mcp or --public-mcp is on)
GET  /.well-known/oauth-*     β€” OAuth discovery (public-mcp only)
ANY  /oauth/*                 β€” OAuth provider endpoints (public-mcp only)
GET  /admin/*                 β€” static GUI files (when --gui is on)
GET  /admin/inline.js         β€” the inline editor loader script

The REST routes exist because they're more ergonomic for a browser, a CDN, or a traditional HTTP client than POST /rpc would be. The two surfaces are equivalent β€” anything you can do over REST you can also do via /rpc, and vice versa.

Auth lives here too: a simple middleware that reads Authorization: Bearer <key> (or env-var override) and routes requests to admin / reader / unauthenticated paths based on the key's role.

Core (packages/core)

The brain. Owns:

  • Reads. read, find, with all the projection options (expand_assets, resolve_references, resolve_refs, summary, locale projection, version selection).
  • Writes. draft, publish, rename_entry, delete_entry, migrate_entries. Concurrency via parent_version. Validation via schema.
  • Schema lifecycle. create_type, alter_type, delete_type. Computes change_class (safe / needs_backfill / destructive) by diffing field definitions.
  • Asset transforms. core/src/transforms.ts wraps sharp (libvips), parses imgix-style query parameters, and writes a per-(ref_key, params_hash) cache to disk.
  • Ref resolution. core/src/resolve-refs.ts parses :::ref{to="…"}::: directives in markdown fields and resolves the target entries.
  • describe_model. Walks the schema, decorates with capability flags, returns the whole content model.

Core depends on storage for persistence and schema for type validation. It doesn't know about HTTP, MCP, or the admin GUI.

Schema (packages/schema)

Pure TypeScript: defineType(), the field-type catalogue, the validator. No I/O. Imported by both consumer code (in user projects, via @ledric/schema) and by core for write-time validation.

Storage (packages/storage)

Dialect-and-adapter pattern. The same logical operations (createType, getEntry, listEntries, writeVersion, …) implemented three times (SQLite, Postgres, MySQL) on the dialect side, and twice (db-resident, local filesystem) on the asset-bytes side.

Tests run against real databases when LEDRIC_TEST_POSTGRES_URL or LEDRIC_TEST_MYSQL_URL are set; the default pnpm test is SQLite-only.

Admin GUI (packages/gui)

A React + Vite SPA. Built into static files at install time; served by http-server under /admin/. State management is plain React; data fetching is the @ledric/sdk client pointed at the same origin.

/admin/inline.js is also served by this package β€” the script is small, framework-free vanilla JS, and is loaded by consumer sites with a single <script> tag.

CLI (packages/cli)

Wraps everything. ledric init walks first-time setup; ledric serve boots the MCP server (with optional --http and --gui flags); ledric ls / get / asset are admin-side reads; ledric keys mints and revokes API keys; ledric types codegens TypeScript from your schema; ledric refs check lints inline refs across all your markdown fields.

Full CLI surface is documented per-command in ledric --help.


The two-process consumer pattern

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  ledric process            β”‚      β”‚  Consumer process          β”‚
β”‚  (the CMS)                 β”‚      β”‚  (Astro, Next, PHP,        β”‚
β”‚                            β”‚      β”‚   plain HTML, …)           β”‚
β”‚  npx ledric serve          β”‚      β”‚  npm run dev               β”‚
β”‚                            β”‚      β”‚                            β”‚
β”‚  - MCP stdio               β”‚      β”‚  - imports @ledric/sdk     β”‚
β”‚  - HTTP API                β”‚ ◄──► β”‚  - calls client.read()     β”‚
β”‚  - Admin GUI               β”‚ HTTP β”‚  - renders pages           β”‚
β”‚  - libsharp + sqlite       β”‚      β”‚  - serves /admin/inline.js β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       ./ledric.db                         ./astro.config.mjs

The consumer site is a different process in a different directory. It does not import ledric itself. It imports @ledric/sdk (TS) or ledric/sdk (PHP) β€” read clients that speak HTTP to the running ledric process.

Why:

  • Build-size hygiene. better-sqlite3 and sharp are heavy native modules. Your Vercel build (or your Lambda cold start) doesn't need them.
  • Independent scaling. ledric handles writes and admin traffic; consumer renderers handle reads-and-render. Different shapes, different machines.
  • Same SDK, many stacks. Astro, Next, Remix, plain HTML + htmx, vanilla PHP, Twig templates β€” they all hit the same HTTP surface with the same client shape. There's no framework-specific deep integration to maintain.

For local dev convenience, the two processes are usually started side by side. Production deploys put a CDN in front of /assets/<ref_key>, a reverse proxy in front of everything else, and run the consumer site on its own host fleet that fetches from ledric over the network.

When the consumer needs admin-level reach (versioned reads, asset uploads, write operations) without leaking the admin key into the browser, it mounts @ledric/proxy server-side. The proxy holds the admin key in environment variables and exposes a curated subset of ledric's surface to the browser.


Storage adapters

The storage package implements the same logical interface three times.

storage/
β”œβ”€β”€ interface.ts           β€” the methods every dialect implements
β”œβ”€β”€ dialects/
β”‚   β”œβ”€β”€ sqlite.ts          β€” better-sqlite3, WAL mode, FTS5
β”‚   β”œβ”€β”€ postgres.ts        β€” pg, tsvector FTS
β”‚   └── mysql.ts           β€” mysql2, FULLTEXT FTS
└── assets/
    β”œβ”€β”€ backend.ts         β€” the asset-bytes interface
    β”œβ”€β”€ db.ts              β€” bytes in an asset_blobs table
    └── local.ts           β€” bytes on disk, keyed by ref_key

Capability differences are reported through describe_model's capabilities block:

{
  "capabilities": {
    "vectorSearch": false,
    "nativePubSub": false,
    "fts": "fts5"
  }
}

fts is one of fts5 (SQLite), tsvector (Postgres), or fulltext (MySQL). Vector search and native pub/sub are reserved capability flags for features that aren't shipped β€” see roadmap.md.


The asset pipeline

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Upload           β”‚  POST /assets (multipart)
β”‚ (HTTP only)      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Asset record     β”‚  assets table: id, ref_key, kind, version,
β”‚                  β”‚  meta (json: width, height, mime, alt, …)
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  asset_blobs (db backend) OR
β”‚ Bytes            β”‚  ./assets/<ref_key>.bin (local backend)
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β–Ό
GET /assets/<ref_key>?w=800&fm=webp
         β”‚
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Transform cache  β”‚  ./.ledric-cache/transforms/<key>.bin
β”‚ (libvips/sharp)  β”‚  (cache miss β†’ render β†’ write β†’ serve)
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β–Ό
   Bytes back

URLs are version-pinned via ref_key. Replacing the bytes mints a new ref_key and therefore a new URL β€” CDN caches invalidate themselves. Old URLs keep serving old bytes until you prune them (no automatic GC yet).

The transform cache lives next to the database file (.ledric-cache/ by default) and is just files keyed by (ref_key, params_hash). Safe to delete; will be regenerated on next request.

For the full asset story β€” uploads, transforms, in-place replacement, the cache layout β€” see assets.md.


The inline editor

Consumer site HTML
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ <article data-ledric-ref="blog_post/hello">      β”‚
β”‚   <h1 data-ledric-ref="blog_post/hello"          β”‚
β”‚       data-ledric-field="title">Hello</h1>       β”‚
β”‚   <div data-ledric-ref="..."                     β”‚
β”‚        data-ledric-field="body">…</div>          β”‚
β”‚ </article>                                       β”‚
β”‚                                                  β”‚
β”‚ <script src="<ledric>/admin/inline.js"></script> β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  β”‚
                  β”‚ (script walks the DOM, attaches
                  β”‚  hover handlers to data-ledric-ref
                  β”‚  elements)
                  β–Ό
       Hover β†’ pencil icon β†’ click
                  β”‚
                  β–Ό
       Drawer: form for the entry, scrolled
       to the field that was clicked
                  β”‚
                  β–Ό
       Save β†’ POST through the admin API β†’
       new version β†’ publish β†’ page reloads

Three pieces working together:

  1. refAttrs(entry) / refAttrs(entry, field) SDK helpers emit the data-ledric-ref and data-ledric-field attributes on the consumer-rendered HTML.
  2. /admin/inline.js is loaded by the consumer site (one <script> tag). It walks the DOM looking for those attributes, attaches mouseenter/click listeners, and renders the floating pencil + drawer.
  3. The drawer's form is the same form gui/ uses for the admin entry editor. It's served by ledric's HTTP server, so validation, version conflict handling, and save semantics are identical between the two surfaces.

The script is small and framework-free; it works on any rendered HTML regardless of the consumer's stack. See inline-editor.md for the full attribute reference and behavioural details.


Process lifecycle

What ledric serve --gui does, in order:

  1. Read config. ledric.config.json if present; otherwise defaults (SQLite at ./ledric.db, port 3000, /admin for the GUI).
  2. Open storage. Dialect chosen from the DB connection string. SQLite opens the file (creating it if missing); Postgres / MySQL connect to the configured URL. Migrations run automatically β€” every bootup converges the schema.
  3. First-boot key minting. If no API keys exist, mint an admin and a reader key, write them to .env.local (CLI path) or print them to stdout (programmatic path).
  4. Boot the MCP server. Stdio transport wired up; tool handlers registered.
  5. Boot the HTTP server. REST routes mounted, /rpc mounted, GUI static files mounted (with --gui), /admin/inline.js served.
  6. Listen. MCP on stdio. HTTP on the configured port. Process stays up until killed.

Every request β€” whether MCP read or HTTP GET /entries/... β€” routes through core, which routes through storage, which talks to the underlying database. There's no internal queue, no worker pool, no separate process for renders. It's a Node event-loop process with the usual concurrency story.


Where to go next

  • Build a site with an agent β€” see all this in motion against a working Astro example.
  • Deployment β€” what changes in production (CDN, reverse proxy, Postgres / MySQL, backups).
  • MCP tools β€” every tool the dispatch table knows about.
  • HTTP API β€” every route the HTTP server exposes.