The mental model behind ledric, in one page. Everything else in the docs assumes you've internalised these.
If you've used Contentful, Sanity, Payload, or Strapi, most of this will sound familiar โ there's a content type, there's an entry, there's a slug, there's an asset. What's worth paying attention to is the shape of each one: ledric's are flatter, more verbatim, and more LLM-legible than you might be used to.
- Types and entries
- Identifiers: id, slug, ref
- Versions
- References: structural and inline
- Assets
- Locales
- Environments
- The wire format
For the field-type catalogue and validation rules, see schema.md. For the actual tool calls that operate on these concepts, see mcp-tools.md.
Types and entries
A type is a content shape: a name plus a map of fields. blog_post is a type. author is a type. pricing_table_section is a type. A type is just a row in the types table with a JSON schema attached to it.
An entry is one instance of a type: one specific blog post, one specific author. It's a row in the entries table that points at its type and carries a JSON content blob shaped according to the type's fields.
Two practical consequences:
- The schema is enforced at write time. A
draftorpublishthat doesn't match the type's field rules comes back as a structuredVALIDATION_FAILEDerror with field paths โ not a 500. - Types evolve in place. You don't rewrite a type to add a field; you
alter_typeit with a JSON Merge Patch. ledric classifies the change assafe,needs_backfill, ordestructiveand lets youmigrate_entriesif the existing rows need to catch up. See Schema โ Evolving a schema.
Throughout the API and tools, types are referred to by name (blog_post) and entries are addressed by type/slug (blog_post/hello-world).
Identifiers: id, slug, ref
Every entry has two identifiers. They mean different things and are useful in different places.
id โ UUIDv7
The immutable primary key. Time-ordered (so a btree index on it clusters by creation time). Survives any number of slug renames.
018f2d40-2b18-7d92-9cf1-1b2934a7e9b3Use ids in:
- Machine-to-machine integrations. A webhook payload, a build pipeline, a foreign system that should keep working when an editor renames a post.
- Anywhere a slug rename would silently break. Saved searches, analytics events, audit logs.
slug โ mutable URL alias
The human-readable identifier. Lowercase, alphanumeric, hyphens โ shaped to go in a URL. Unique within a type within a locale.
hello-world
why-we-built-ledricSlugs are mutable: rename a post and ledric retires the old slug into slug_history and starts redirecting reads of the old slug to the new entry (with a 301 + a _redirect sidecar in the response). You don't lose the inbound link.
Use slugs in:
- URLs. They're literally what the URL contains.
- Prompts and diffs.
blog_post/hello-worldis legible in a conversation; the UUID isn't. - Anywhere an LLM is editing. Slugs read like words; ids look like noise. The difference shows up in token efficiency and in how often the model picks the right entry.
ref โ what the API accepts
Every tool that takes "an entry" accepts a ref: any of the following resolves to the same row.
| Form | Example | When |
|---|---|---|
type/slug |
blog_post/hello-world |
Default. Most ergonomic. |
id |
018f2d40-2b18-7d92-โฆ |
Stable across renames. |
| Object | { type: "blog_post", slug: "hello-world" } |
When you've already split the parts. |
| Object | { id: "018f2d40-โฆ" } |
Same, with id. |
The TS and PHP SDKs accept all four. read('blog_post/hello-world') and read({ type: 'blog_post', slug: 'hello-world' }) are interchangeable.
Slug history
When you rename blog_post/foo to blog_post/bar:
- The entry's
slugbecomesbar. - A row goes into
slug_history:(type, slug=foo, entry_id, retired_at). - Reads against
blog_post/foolook upslug_history, find the entry, and return it with_redirect: { to: "bar" }. Over HTTP that comes with a301 Moved Permanentlyand aLocationheader pointing at/entries/blog_post/bar. - The redirect lasts forever by default. Old slugs aren't recycled.
Per-type policy lets you opt out: on_slug_change: 'redirect' | 'error' | 'silent'. The default (redirect) is what you almost always want.
Versions
Every write to an entry creates a new version. Nothing is overwritten in place.
entries entry_versions
โโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ blog_post / hello โ โโ> โ v1 { title: "Hello", body: "..." }โ
โ current_version: 4 โ โ v2 { title: "Hello!", body: ... โ
โ published_version: 3โ โ v3 { title: "Hello, world", ... โ โ published_version
โโโโโโโโโโโโโโโโโโโโโโโโ โ v4 { title: "Hello, world", ... โ โ current_version
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโThree things flow from this:
1. Drafting and publishing are decoupled
draft writes a new version. The entry's current_version advances. The published_version pointer stays where it was. Public reads keep returning the previously-published shape.
publish moves the published_version pointer. That's it โ no copy, no separate "published table". Publishing is a pointer move and unpublishing is the same move in reverse.
2. Reads can target a version
read({ ref: 'blog_post/hello', version: 'published' }) // default for published reads
read({ ref: 'blog_post/hello', version: 'current' }) // latest draft
read({ ref: 'blog_post/hello', version: 7 }) // specific historical versionSame shape, different content. Handy for restoring an older revision (diff the JSON, copy the bits you want, draft them back) and for admin tooling that wants to render history.
3. parent_version is optimistic concurrency
Every mutating tool that touches an existing row takes parent_version. If the row's actual version doesn't match, the write is rejected with a VERSION_CONFLICT error. Two agents editing the same entry don't silently clobber each other โ the second one gets told to re-read and try again.
{
"tool": "draft",
"args": {
"ref": "blog_post/hello",
"parent_version": 4,
"fields": { "title": "Hello, world" }
}
}If the entry is at version 5 by the time this lands:
{
"error": {
"code": "VERSION_CONFLICT",
"current_version": 5,
"your_parent_version": 4
}
}Schema-version stamping
Every entry version also records the schema version it was written under. After an alter_type, old entries keep reading correctly against the version of the schema they were written for; the migrate_entries tool re-validates them against the new shape and backfills as needed.
What's not exposed yet
The original spec describes read_version, revert, diff, and list_versions as MCP tools. Those aren't shipped. You can pass version: N to read to fetch any historical version; everything else (diffing, reverting, listing) is HTTP-callable but not yet surfaced as a first-class tool. See roadmap.md.
References: structural and inline
Two ways one entry can point at another. They have different rules and different jobs.
Structural references โ schema fields
A references field type, declared in the schema, with a target type and cardinality.
field.references({ to: ['author'], min: 1, max: 1 }) // exactly one author
field.references({ to: ['post', 'page'], max: 6 }) // up to 6 related entriesProperties:
- Validated at write time. The target entries must exist (or the write is rejected with
REFERENCE_NOT_FOUND). - Queryable. You can
findentries by their references with the HTTP filter DSL. - Reverse-indexable. Soft-deleting a referenced entry surfaces the dependents in the structured
REFERENCE_TOMBSTONEDerror.
Use structural references for app-logic dependencies: a post's author, a page's section list, a product's category.
Inline references โ :::ref{โฆ}::: directives
A directive embedded inside any markdown field, resolved at read time.
For background, see :::ref{to="blog_post/why-sqlite"}:::.
Pricing details: :::ref{to="section/pricing-table"}:::The directive is just a string in the body. It's parsed and optionally resolved on read:
const post = await client.read('blog_post/hello-world', { resolveRefs: true });
post._refs; // [{ to: "blog_post/why-sqlite", found: true, url: "...", entry: {...} }]Properties:
- Resolved at render time, not write time. Dangling refs warn, they don't block the write. The
ledric refs checkCLI command will lint a whole content set for danglers. - Not queryable. They live inside opaque markdown.
- Pinnable.
:::ref{to="blog_post/hello" version=42}:::freezes the reference to a specific version. Useful when published content shouldn't drift if a referenced entry changes later.
Use inline references for editorial links in flowing prose: the "see also" in a paragraph, an embedded section block in the middle of a long-form post.
Quick rule of thumb
If a renderer has to know about it to lay out the page, it's a structural ref. If it's something a writer typed in the body, it's inline.
Assets
Uploaded files โ images, PDFs, videos, anything else. Two kinds of identifier, for two different reasons.
id โ the asset's identity
A 32-char hex string. Stable for the lifetime of the asset.
019dc0b5553477e894374b563cd4e633This is what gets stored in an asset field on an entry. Replace the bytes (re-upload to the same id) and every entry that points at it picks up the new bytes automatically.
ref_key โ the version-pinned bytes locator
A separate token tied to a specific upload. Changes when you replace the bytes.
abc123def456...Asset URLs use the ref_key, not the id:
/assets/abc123def456...?w=800&fit=crop&auto=formatThis is deliberate. CDNs and browser caches key on URL. If the URL contained the id, replacing the bytes wouldn't invalidate caches โ visitors would see stale images for hours. Because the URL contains the ref_key, replacing the bytes mints a new URL, and caches re-fetch automatically. Old URLs keep serving old bytes (if you haven't pruned them) โ historical pages stay stable.
Image transforms
Asset URLs accept imgix-style query parameters: w, h, fit (crop or clip), q, fm (jpg/png/webp/avif), auto=format, dpr. sharp (libvips) does the work; transformed bytes are cached on disk by (ref_key, params_hash).
/assets/<ref_key>?w=800&fit=crop&auto=format
/assets/<ref_key>?w=400&h=400&fit=crop&fm=webpThe SDKs build these for you:
client.assetUrl(refKeyOrId, { w: 800, fm: 'webp', auto: 'format' });Backends
Asset bytes go in one of two places:
- In the database (default in dev): bytes live in an
asset_blobstable next to everything else. Backups are one file. No filesystem to maintain. - On disk: a directory of files keyed by
ref_key. Picks up a CDN cleanly in front of/assets/.
External-bucket adapters (S3, R2) are planned, not shipped โ see roadmap.md. The backend interface exists; the implementations don't.
Asset versions
Same model as entries: every replacement creates a new version. The HTTP GET /assets/:id always serves the current ref_key's bytes; direct GET /assets/<old_ref_key> keeps serving the historical bytes until a future cleanup phase (not yet automated).
For the full asset model โ uploads, transforms, in-place replacement, the cache โ see assets.md.
Locales
Multi-language content as a built-in. A type opts in by declaring locales; individual fields opt in with localized: true. The default-locale value lives at the top level of the entry's content; other locales go in a _locale sidecar keyed by locale code.
defineType('blog_post', {
title: field.string({ required: true, localized: true }),
body: field.markdown({ required: true, localized: true }),
slug: field.slug({ required: true, from: 'title' })
}, {
locales: ['en', 'fr', 'es'],
default_locale: 'en',
fallback: { fr: 'en', es: 'en' }
});What an entry's content looks like in storage:
{
"title": "Hello",
"body": "# Hi there",
"slug": "hello",
"_locale": {
"fr": { "title": "Bonjour", "body": "# Salut" },
"es": { "title": "Hola" }
}
}On read, pass ?locale=fr (HTTP) or { locale: 'fr' } (SDK). ledric merges the right values onto the top-level shape, walking the fallback chain when a translation is missing for the requested locale. Spanish here would resolve body from en because es is missing it and es โ en in the fallback chain.
Slugs can be locale-specific too โ a French post can have bonjour where the English one has hello. Every locale's slug lives in slug_history separately.
For the full localization story โ locale-specific slugs, fallback chains, recipes โ see localization.md.
Environments
The storage schema reserves environment columns (env_id, parent_env) on every type, entry, and asset row. Originally this was to support full-environment branching: fork "production" into "staging", edit, merge back.
Today the API to fork, edit, and merge environments isn't exposed. Every read and write happens in the default environment. You can't ask ledric for "what would change if I merged staging into production" because there's no staging.
If you need staged content right now: use the draft / publish distinction. Drafts don't appear in published reads. That's a much narrower mechanism than environment branching, but it covers the "work-in-progress that shouldn't go live yet" case for most content workflows.
Branching is on the post-v1 roadmap. See roadmap.md.
The wire format
One last thing worth internalising: an entry on the wire is flat.
{
"id": "018f2d40-2b18-7d92-9cf1-1b2934a7e9b3",
"slug": "hello-world",
"type": "blog_post",
"version": 4,
"fields": {
"title": "Hello, world",
"slug": "hello-world",
"body": "# Hello\n\nFirst post.",
"hero": "019dc0b5553477e894374b563cd4e633",
"author": [{ "type": "author", "slug": "j" }],
"tags": ["greetings"]
}
}Compare to Contentful, where every field is wrapped in a locale envelope and the entire response sits inside a sys / fields / metadata envelope of its own. ledric responses don't carry that overhead by default. There's an optional _meta block (version, schema_version, content_hash, request_id) that you can opt into per call with include_meta: true, but it's off by default.
This matters mostly because of how LLMs read and write content. Token cost on a list of 20 entries is the field bytes, not the field bytes plus 20 envelopes' worth of structural noise. The model can pattern-match the shape after one example.
For the full response shape โ _redirect sidecar, _locale, _refs from resolveRefs: true, expanded assets โ see http-api.md.
Where to go next
- Build a site with an agent โ what these concepts feel like in practice, end to end.
- Schema โ the field-type catalogue and validation rules.
- MCP tools โ the tool surface that operates on everything above.
- HTTP API โ the same surface over plain HTTP, plus every query parameter.