The bet ledric is making is that once an agent can read your content model with a single tool call, it has everything it needs to design the schema, draft the content, write the consumer site that renders it, and wire up the inline editor โ without you handing it a spec document, type definitions, or example payloads. The schema is the spec.
This page walks through what that looks like end-to-end. The prompts
are written the way you'd actually paste them. The resulting code is
real โ it's committed in examples/astro-blog/.
The agent's tool-call flow is representative of how Claude (Sonnet
or Opus) actually behaves over MCP, not a literal recording.
What's authentic here: the prompts, the committed code, the shape of Claude's tool calls. What's illustrative: specific response wording and turn counts will vary by model and session.
If "type", "entry", "slug", "version", or "ref" are unfamiliar words on a ledric page, Concepts is the five-minute primer that grounds the rest.
- Before you start
- Setting up the schema
- Seeding some content
- Building the frontend
- Making it editable in place
- What just happened
- Other stacks
Architecture: ledric and your consumer site are separate processes
Before any code: ledric runs as its own process. Your consumer site (Astro, Next.js, plain HTML, whatever) is a different project that fetches via ledric's HTTP URL. They share content, not codebases.
[ ./ledric-content/ ] [ ./my-site/ ]
โ runs โ runs
[ npx ledric serve ] โ HTTP โ [ npm run dev (astro/next/...) ]
(one process) (another process)
exposes :3000 fetches LEDRIC_API_URLConcretely: don't add ledric to your consumer site's
package.json. Doing so would drag better-sqlite3 + sharp + libvips
(~50MB native binaries) into every Vercel build for no reason. The
consumer needs the URL, not the binary.
For local dev convenience you can colocate both directories and launch them with one command (see the example in this page's section on dev scripts), but they're still two processes.
Before you start
A 60-second setup so the rest of the page makes sense:
mkdir my-content && cd my-content # โ ledric's directory
npx -y ledric init
# accept defaults โ patches .mcp.json, mints keys, writes .gitignore
npx ledric serve --gui &
# admin GUI at http://localhost:3000/admin
claude # start Claude Code in this directoryBecause init patched ./.mcp.json, Claude Code picks up the
ledric MCP server automatically when you start a session in this
directory. No further config.
In the Claude session, confirm it's connected:
What MCP tools do you have access to right now?
It should list the 20 ledric tools. If it doesn't, restart Claude Code (the MCP discovery happens at session start).
When you scaffold the consumer site later in the session, it lives
in a sibling directory (../my-site/ or wherever) โ not inside
my-content/.
Setting up the schema
This is the conversation that creates the content model. Notice that
the prompt doesn't specify field types or options โ the agent picks
them by calling describe_model first to learn the field-type
catalogue, then making sensible choices.
Set up a blog. Posts have a title (required, capped at 120 chars), a slug derived from the title, a markdown body, an optional hero image, an author reference, a short summary, a published date, and a tags string array. Title is what shows in admin lists; show title, author, and published_at in summary views.
Then add an author type with name (required), bio (markdown), and avatar (image asset).
What Claude does, in order:
describe_modelโ to see whether the types already exist and to remind itself of the field-type catalogue.create_typeforauthorfirst (becauseblog_postreferences it โ author has to exist for the reference to validate).create_typeforblog_postwith thereferencesfield pointing atauthor.
The actual create_type call for blog_post looks like:
{
"name": "blog_post",
"fields": {
"title": { "type": "string", "required": true, "max": 120 },
"slug": { "type": "slug", "required": true, "from": "title" },
"summary": { "type": "string", "max": 280 },
"body": { "type": "markdown", "required": true },
"hero": { "type": "asset", "kinds": ["image"] },
"author": { "type": "references", "to": ["author"], "min": 1, "max": 1 },
"published_at": { "type": "date" },
"tags": { "type": "array", "of": { "type": "string" } }
},
"opts": {
"display_field": "title",
"summary_fields": ["title", "author", "published_at"]
}
}You didn't specify min/max on the author reference, but the agent
inferred "1" because you said "an author" (singular, implied
required). If that's wrong it's one prompt to correct
("authors should be optional, and posts can have multiple"). The
schema is cheap to alter.
Verify with the CLI:
npx ledric ls
# {
# "db": "./ledric.db",
# "types": [
# { "name": "author", "version": 1, "entries": 0 },
# { "name": "blog_post", "version": 1, "entries": 0 }
# ]
# }Seeding some content
You need something to render. Ask the agent to make it up.
Draft three blog posts. Pick realistic titles, write 3-paragraph bodies, give them sensible published dates from the last few months. Authors can be made up โ create one or two
authorentries first if you need to.
Claude:
- Drafts one or two
authorentries. - Drafts three
blog_postentries, each referencing one of the authors. - Asks if you want them published or left as drafts.
Tell it to publish:
Publish all three posts.
The agent calls publish once per entry. They're now live.
(If a draft fails validation โ say Claude tried to set body: null
when it's required โ it gets back a VALIDATION_FAILED error with
the exact field path. It corrects and retries on its own; you don't
need to debug.)
Building the frontend
Now switch contexts. The schema and content are in place; you want a site that renders them.
Build me an Astro project in this directory that consumes the blog from ledric. Index page lists posts (title, summary, hero image, date). A
posts/[slug].astropage renders the full post โ title, hero, body as rendered markdown, author, tags. Use the@ledric/sdkpackage for reads. Server-rendered, no client JS.
Claude does:
describe_modelagain โ confirms the schema is what it thinks it is (this is cheap and it always re-grounds when switching contexts).- Scaffolds Astro โ
pnpm create astro@latest, picks the minimal template, agrees to TypeScript. - Adds
@ledric/sdkandmarkedfor reads + markdown rendering. - Writes
src/lib/ledric.tsโ a thin client module that exports the SDK instance and a few helpers. - Writes
src/pages/index.astroโ callsclient.find('blog_post', {...})and maps results into a list view. - Writes
src/pages/posts/[slug].astroโ callsclient.read('blog_post/${slug}')and renders the post. - Adds a
Base.astrolayout because the two pages share chrome.
The actual files Claude produces look like (these are what's in
examples/astro-blog/):
src/lib/ledric.ts โ the thin shared module:
import { createLedricClient } from '@ledric/sdk';
const baseUrl = process.env.LEDRIC_API ?? 'http://localhost:3000';
export const client = createLedricClient({ baseUrl });
export { refAttrs, refAttrsHtml } from '@ledric/sdk';
export function isAssetId(v: unknown): v is string {
return typeof v === 'string' && /^[0-9a-f]{32}$/i.test(v);
}
export function formatDate(s: unknown): string {
if (typeof s !== 'string') return '';
return new Date(s).toLocaleDateString(undefined, {
year: 'numeric', month: 'short', day: 'numeric'
});
}src/pages/index.astro โ the list view, abridged:
---
import Base from '../layouts/Base.astro';
import { client, isAssetId, formatDate } from '../lib/ledric';
const result = await client.find('blog_post', { limit: 20 });
const posts = result.results.map((p) => {
const f = p.fields as Record<string, unknown>;
return {
slug: p.slug,
title: typeof f.title === 'string' ? f.title : '(untitled)',
summary: typeof f.summary === 'string' ? f.summary : '',
publishedAt: formatDate(f.published_at),
heroId: isAssetId(f.hero) ? f.hero : null,
tags: Array.isArray(f.tags) ? (f.tags as string[]) : []
};
});
---
<Base title="The latest from the team">
<ul class="post-list">
{posts.map((p) => (
<li class="post-card">
{p.heroId && <img src={client.assetUrl(p.heroId)} alt={p.title} />}
<h2><a href={`/posts/${p.slug}`}>{p.title}</a></h2>
<div class="meta">{p.publishedAt}</div>
{p.summary && <p>{p.summary}</p>}
{p.tags.length > 0 && (
<div class="tags">{p.tags.map((t) => <span>{t}</span>)}</div>
)}
</li>
))}
</ul>
</Base>src/pages/posts/[slug].astro โ the detail view, abridged:
---
import Base from '../../layouts/Base.astro';
import { client, isAssetId, formatDate, refAttrs } from '../../lib/ledric';
import { marked } from 'marked';
const { slug } = Astro.params;
const post = slug ? await client.read(`blog_post/${slug}`) : null;
if (!post) return new Response('Not found', { status: 404 });
const f = post.fields as Record<string, unknown>;
const title = typeof f.title === 'string' ? f.title : '(untitled)';
const heroId = isAssetId(f.hero) ? f.hero : null;
const bodyHtml = marked.parse(typeof f.body === 'string' ? f.body : '', { async: false }) as string;
---
<Base title={title}>
<div {...refAttrs(post)}>
{heroId && <img class="hero" src={client.assetUrl(heroId)} alt={title} {...refAttrs(post, 'hero')} />}
<div class="meta" {...refAttrs(post, 'author')}>{formatDate(f.published_at)}</div>
<article class="prose" set:html={bodyHtml} {...refAttrs(post, 'body')} />
</div>
</Base>Three things worth pausing on:
The agent never asked you for the schema. It called
describe_modeland read it. The shape of the loop inindex.astrodirectly reflects the field types โtitle: string,summary: string,hero: asset(resolved throughassetUrl),tags: array.The reads are typed at the runtime boundary, not by codegen. The
as Record<string, unknown>plus the per-fieldtypeof/isAssetIdchecks is the agent being defensive about the wire format. There's no TS schema codegen step โ the agent chose runtime narrowing because it's simpler and won't drift.The body is just markdown.
marked.parse()and you're done. No proprietary AST, no SDK lock-in. If you'd rather usemarkdown-itor render server-side React from MDX, the same string flows in.
Run it:
pnpm dev
# astro dev server at http://localhost:4321Live data from the live ledric instance. Edit a post in the admin
GUI at :3000/admin, hit publish, refresh :4321 โ change shows up.
Making it editable in place
The site renders fine. Now wire up the inline editor so you (or anyone with the admin key) can click-edit on the actual rendered page.
Add the inline editor. I should be able to hover anything and get a pencil icon that opens the edit drawer.
Claude:
- Drops the script tag into the layout
(
<script src={${baseUrl}/admin/inline.js} defer></script>). - Confirms
refAttrs(post)is already on the post wrapper in[slug].astroโ it was, because Claude wrote it there originally knowing the inline editor would want it. - Adds field-scoped
refAttrs(post, 'title')to the heading,refAttrs(post, 'body')to the article, etc.
Reload the page. Hover the title. Pencil appears. Click it. Drawer slides in with the form pre-scrolled to the title field. Edit, save, page reloads with the new published content.
This entire feature is two lines of HTML and one <script> tag. The
agent already knew about it from describe_model's features
section.
What just happened
A few claims about the experience that should now be concrete:
The schema is the API. You wrote zero type definitions in your
frontend code. The agent read them once via describe_model and
that was the whole spec.
describe_model is the discovery primitive. Claude called it
twice: once before creating types (to check what's there + remind
itself of the catalogue), once when starting the frontend (to ground
on the same model the renderer would consume). That single
in-context handoff is what lets the conversation jump from
"I want a blog" to "here's a post detail page" without a
specification document in between.
Validation drives correctness. When Claude's first draft of a
post failed validation (it omitted summary, which you'd specified
as having a max length, and it tried to set body: null for an
empty post), the structured error told it which field and why. It
fixed and retried without you debugging.
Tokens stay cheap. The list page used find with the default
summary budget โ Claude got title, author, published_at per
result, not the full body of every post just to render a list.
The detail page used read with expand_assets: false (the
default) and built the URL from the asset id manually via
assetUrl(). No wasted bytes.
Inline editing is plumbing-free. refAttrs(post, 'field') plus
one script tag. The drawer is the same form the admin GUI uses, so
validation and version conflict handling are identical.
You can run all of this against the committed
examples/astro-blog/ โ it's the same
shape, with a couple of extra niceties (locale switcher,
_redirect handling, light styling). Treat it as the artifact this
walkthrough produces; the agent session is just the path that gets
you there.
Other stacks
The pattern is the same wherever you can speak HTTP or import a JS client:
Next.js / Remix / React Router โ @ledric/sdk works the same.
Use it from getServerSideProps, route loaders, or RSC server
components. refAttrs() returns a Record<string, string> which
spreads into JSX exactly as it does in Astro.
Plain HTML + htmx โ agent generates a tiny PHP / Node / Python
backend that calls client.find() and templates the result. The
inline editor doesn't care about your stack โ it walks the rendered
DOM at runtime.
PHP โ composer require ledric/sdk and use Ledric\LedricClient
the same way. Agent will pick it up if your project has a
composer.json and a php file or two. Full walkthrough in Build with an agent โ PHP.
Static-site builds (11ty, Hugo via shell, etc.) โ npx ledric get type/slug --json returns one entry's render shape on stdout. A
build script can iterate ledric ls type --json and emit one file
per result. The site's static; the editor still works (it doesn't
care that the page was prebuilt).
The thread connecting all of these: the agent learns your model
once via describe_model, picks the right SDK or HTTP shape for
your stack, and writes code that consumes the same flat-JSON shape
you'd see at GET /entries/blog_post/why-kysely. There's nothing
stack-specific to teach it.