A headless CMS with a plugin system and an agent that can work the whole of it, built on Atlas.
Content lives here; your websites read it over an HTTP delivery API. One process on one port, running on Postgres, with no build step for the API.
Two things set it apart from the rest of the field. Inky sits in the corner of every admin screen and knows which screen that is — describe a change in ordinary words and it reads your site, works out what you meant, and hands you a diff. And a visitor bubble you can add to the public site with one script tag, answering only from what you have published, under rules you write.
Documentation is at inkling.wess.dev.
bun install
cp .env.example .env
bun run devOpen http://localhost:4300. That is the whole thing — one process, one port.
On the first visit, the admin asks you to create the owner account — the first
person through the door becomes the owner. After that the same screen becomes
the normal sign-in form, the setup route closes permanently, and there is no
public signup. BOOTSTRAP_EMAIL and BOOTSTRAP_PASSWORD remain available for
unattended deployments.
If that owner ever loses their password, bun run password sets a new one from
the machine that runs the database. Nothing in the browser can help there:
POST /auth/password needs the current password, changing someone else's needs
an admin, and setup is closed.
There is no separate admin server, no proxy, and no bundler running alongside. One process serves everything, and the URL says which audience a request belongs to:
| Path | Who calls it |
|---|---|
/ |
The admin. Any path the router doesn't claim is an admin screen |
/api/… |
Everything that needs a session — the admin's whole surface |
/content, /site |
Your websites, with an API key |
/preview/:token |
Whoever you sent a share link to |
/media/file/… |
Anything rendering an image |
/ext/… |
Plugin routes |
/realtime |
The WebSocket |
The split is by audience rather than by module: a feature with both a public and
a session-gated route exports two arrays instead of being mounted twice. That is
what keeps /settings — an admin screen — from colliding with /api/settings,
the API, and six other paths with it.
Point DATABASE_URL at a Postgres database — the same engine in development
as in production, so a dialect difference cannot wait until deploy day to
appear. The test suite runs on in-memory SQLite, so bun test still needs no
database of its own.
- Content types you define in the admin — 18 field types including nested repeaters, media pickers, and cross-entry references
- Entries with draft / review / scheduled / published states, full revision history with version previews, soft-delete and restore, per-locale slugs, and configurable links to the live page
- Media library with an S3-compatible or local-disk driver
- Taxonomies, menus, and site settings
- Delivery API — key-authenticated, published-only, with media and references expanded inline
- Realtime — one WebSocket that pushes content changes to the admin (with presence, so you can see who else is in a record) and to consuming sites, so caches invalidate without polling
- Shareable previews — a signed, hour-long link that shows an unpublished entry to someone who has no account
- Bulk actions and one-click duplication across a selection
- An editorial assistant — drafting, rewriting, summarizing, titles, and metadata that know your content model, on the fields where you are already working. Connect it with an API key or by authorizing an account over OAuth
- Inky, an agent with the run of the site, and a visitor bubble for your public pages. Both optional, both off until you connect a provider
- Webhooks on content events, HMAC-signed
- Activity history for sign-ins, edits, publishing, and media changes
- Plugins that add content types, routes, settings, admin panels, and their own database tables
- Nontechnical admin for building content models, organizing categories, writing rich text, nesting menus, scheduling releases, restoring trash, managing users safely, and searching or paging through large libraries
Connect a provider under Settings → AI — Claude, OpenAI, Ollama on your own
machine, or Ollama Cloud — and Inky appears in the corner of every admin screen.
Nothing in the admin mentions AI until you do; the credential is sealed with
AES-GCM under a key derived from SECRET, kept in its own table, and never
returned by the API.
It is built for the person who did not build the site. You describe what you want the way you would say it out loud, and Inky works out whether that is a change to what a page says, to what a page is made of, to your navigation, or to your site details:
you We need a page about our new roastery, and put it in the menu.
inky read your page shapes … 3 kinds
read two existing pages, for the voice
drafted "Our Roastery" — intro, body, hours
queued a menu item under Visit
Both are waiting for you to look at. Nothing is live yet.
Because the dock travels with you, "make this shorter" on an open post has no ambiguity about this — the screen you are on is handed over with the question.
Every tool it has is a read. Inky has thirteen tools: six read your site, and seven record a proposal. None of them writes, and no setting makes them able to. The admin renders a proposal as a diff, and applying it sends the change through the same route your own edit takes — so revisions, field validation, slug uniqueness, reference checks, and the audit trail all keep working, and the history names the person who approved it rather than a machine nobody can ask.
What it will not do is pretend. Inkling stores content; it does not render your site. Colours, fonts, spacing, and layout live in your own code, which Inky cannot see. Ask for something visual and it finds the content-shaped version of the request, then tells you which part belongs to whoever builds the site.
The public-facing assistant is the assistant plugin rather than core, because
it is the one AI surface that spends your money on behalf of anonymous
strangers — that should be a deliberate decision with a switch. Enable it, write
your house rules into Guardrails, list the origins allowed to embed it, and
add one line to your layout:
<script src="https://cms.yoursite.com/ext/assistant/widget.js" defer></script>That is the whole integration — a bubble drawn inside a shadow root, so it cannot
collide with your CSS and your CSS cannot reach it. It borrows the provider you
already connected, answers from published content only, grounds itself in the
page the reader is on, and returns a line you wrote rather than guessing when the
answer is not there. With no origins listed it answers nobody, which is the
default. If you would rather draw your own, POST /ext/assistant/public-ask
returns the same answer as JSON.
Drop a directory into plugins/ and enable it in the admin. Seven ship with it:
| Plugin | Demonstrates |
|---|---|
seo |
A delivery.entry filter adding computed metadata to every response |
redirects |
A plugin-owned content type plus a public lookup route |
forms |
A plugin with its own table via plugin-scoped migrations |
commerce |
Content type + taxonomy + settings + a convenience route |
analytics |
Cookieless traffic collection, and a stats panel that renders as a dashboard |
assistant |
A public, page-aware assistant answering from published content only |
social |
Social media management — a queue, a calendar, a performance report, and OAuth account connections, built out of four content types and two of its own tables |
import { definePlugin } from "../../src/plugins/define.ts"
export default definePlugin({
name: "hello",
version: "1.0.0",
settings: [{ key: "greeting", label: "Greeting", type: "text", default: "Hi" }],
panels: [{ id: "hello", label: "Hello", kind: "settings" }],
routes: ctx => [
get("/greet", async c => json(c, 200, { message: await ctx.getSetting("greeting", "Hi") })),
],
register: ctx => {
ctx.filter("delivery.entry", ({ payload, type, raw }) => ({
payload: { ...payload, greeted: true },
type,
raw,
}))
},
})Routes land at /ext/hello/greet and are live the moment the plugin is enabled
— no restart. A plugin can observe any core event without being able to break
it: emit hook failures are isolated, and a throwing filter degrades to a
no-op rather than blanking the payload.
Mint a key in the admin under API keys, then:
curl http://localhost:4300/content/product \
-H "x-api-key: ink_…"{
"data": [{
"id": "…", "slug": "blue-lotus", "title": "Blue Lotus Refresher",
"publishedAt": "2026-07-27T21:16:36.141Z",
"data": {
"price": 7.5,
"image": { "url": "http://localhost:4300/media/file/…", "alt": "…", "width": 800 }
}
}],
"meta": { "type": "product", "total": 1, "page": 1, "limit": 20 }
}Media and reference fields arrive expanded, so rendering a page takes one
request. ?include=terms attaches taxonomy terms; GET /content lists the
types a key may read along with their field shapes.
Rather than polling, hold a socket. Exchange your key for a short-lived ticket, connect, and subscribe to the types you render:
const { ticket } = await fetch("http://localhost:4300/realtime/delivery/ticket", {
method: "POST",
headers: { "x-api-key": process.env.INKLING_KEY },
}).then(r => r.json())
const socket = new WebSocket(`ws://localhost:4300/realtime?ticket=${ticket}`)
socket.onopen = () => socket.send(JSON.stringify({ action: "subscribe", topic: "content:product" }))
socket.onmessage = event => {
const { event: name, data } = JSON.parse(event.data)
if (name === "entry.published") revalidate(`/products/${data.slug}`)
}Frames carry the id, slug, and type — never the content. Re-read the entry
through /content when you get one, so scopes and publication status are
enforced on the way out. A key hears only about published content, and only for
types it is scoped to.
Nothing about this dictates how your site is built. The sites running on Inkling today take every word, product, and image from the delivery API while their markup and CSS stay entirely hand-written.
Inkling is single-tenant, and three things in the schema say so: core settings
all live under one site scope, menu names are globally unique, and PUBLIC_URL
is one origin per process. A delivery key's scopes partition content types
and nothing else.
So the unit of separation is the database:
| You want | Run |
|---|---|
| Sites with their own settings, menus, and origin | One instance per site — a DATABASE_URL each |
| Sites that are one property, sharing a team and a content model | One instance, a scoped key per site |
Three separate sites is three DATABASE_URLs. They can share a Postgres server
and a bucket; what they cannot share is a schema. Each instance needs its own
SECRET — rotating one invalidates that instance's sessions and stored AI
credentials, and there is no reason for that blast radius to cross sites.
A site that would rather not deploy a second service can mount Inkling in its own process. Install it from GitHub — there is no npm release:
bun add github:wess/inkling#v0.6.0 # pin to a release
bun add github:wess/inkling # or follow mainPin a site you care about. The unpinned form resolves to whatever main is at
install time, which is right for trying it and wrong for a site in production.
Nothing else is needed to make it resolve: Inkling imports Atlas as bare
atlas/<pkg> specifiers that go through Atlas's own exports map, so no
tsconfig.json paths entry is involved. (An aliased @atlas/<pkg> would not
survive the trip — Bun does not apply a consuming project's tsconfig paths to
files under node_modules.)
createInkling returns a handler rather than a server:
import { createInkling } from "inkling"
const inkling = await createInkling({ adminBase: "/admin", siteKeyName: "site" })
Bun.serve({
fetch: async (request, server) => {
if (request.headers.get("upgrade") === "websocket") {
if (inkling.upgrade(request, server)) return undefined as unknown as Response
}
// null means no Inkling route claimed the path — keep routing.
return (await inkling.fetch(request, server)) ?? myOwnRouter(request)
},
websocket: inkling.websocket,
})adminBase confines the admin to a prefix and makes fetch return null
everywhere else, so Inkling never swallows a path it does not own. siteKeyName
mints a delivery key for the site sharing the process, derived from SECRET so
it is the same key on every boot — an in-process consumer has no browser in which
to visit the admin and copy one.
This is still one instance per process: config and the database connection are
module-level, so mounting twice gives you two route sets over the same data.
bun run dev |
Everything, hot-reloading |
bun run start |
Production entry |
bun run test |
Test suite |
bun run typecheck |
tsc --noEmit |
bun run tidy |
Biome format + lint with fixes |
bun run password |
Set a user's password from the host — the way back in when the only owner is locked out |
Postgres tests run automatically when a Postgres is reachable at
TEST_POSTGRES_URL (default postgres://postgres:postgres@localhost:5432/inkling_test)
and skip otherwise, so bun test stays zero-setup.
- inkling.wess.dev — the site
- Get set up — empty database to a live site
- Guide — the model, delivery, realtime, previews, AI, plugins, and running more than one site
- Tutorials — a blog end to end, writing a plugin, three sites at once, mounting it inside a site
- Reference — every route, field type, variable, and command
docs/ARCHITECTURE.md— module layout, data model, plugin system, realtime, previews, AI, and the dialect-portability rulesllms.txt— the same ground in one pass, written to be read by an agent before it touches the code.env.example— every configuration variable
docs/ is the site. Anything committed there publishes to the gh-pages branch
on push (.github/workflows/pages.yml), so llms.txt lands at the site root
where agents look for it.
Sites install Inkling from GitHub and pin it by tag; nothing is published to
npm. CHANGELOG.md is written for the one person who needs it —
someone deciding whether to move a site from one tag to the next.
RELEASING.md is how a release is cut and how it reaches a site.
MIT. See LICENSE.