The guide
How it works
Inkling stores content and hands it to your sites over an HTTP API. It does not render your pages — the markup stays yours.
One origin, split by path
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 | How it authenticates |
|---|---|---|
/ | The admin — any path the router doesn't claim | Session |
/api/… | The admin's whole surface | Session bearer token |
/content, /site | Your websites | X-Api-Key |
/preview/:token | Whoever you sent a share link to | The signed token |
/media/file/… | Anything rendering an image | Public |
/ext/… | Plugin routes | Varies by plugin |
/realtime | The WebSocket | A single-use ticket |
The split is by audience, not by module. A feature with both a public
and a session-gated route exports two sets of routes rather than being
mounted twice — which is what keeps /settings, an admin screen,
from colliding with /api/settings, the API.
The content model
A content type is a shape you define in the admin. Its
kind is either collection — many entries, like posts —
or single, meaning exactly one, like a homepage or your opening
hours.
Fields are an ordered list. There are 18 types, and three of them are worth calling out because they are what most models actually need:
| Field | Holds |
|---|---|
list | A repeater. Nests, and validates recursively — a section list whose items each have their own fields. |
media / gallery | One image or many. Comes back expanded on delivery, with URL, alt text, and dimensions. |
reference | A link to entries of a declared type, which is enforced on save. |
heroImage, not hero_image. Field keys live
inside a JSON document and never become database columns, so they follow
the convention of the code that reads them.
Field definitions are validated when the type is saved, not on every entry write — a bad schema is rejected at author time. Publishing and restoring a revision both revalidate against the current schema, so an older draft cannot slip past a newly required field. A scheduled entry is checked again when its hour arrives; if the model changed underneath it, it moves to review instead of publishing something broken.
Deletion is refused while something still points at what you are deleting — media in use by an entry, an entry referenced by another, a type another model references, or an image used as your site logo or social image.
Entries and history
An entry carries a title, a slug, a status, a locale, and a
data document validated against its type. Statuses are
draft, review, scheduled,
published, and archived.
Every save snapshots the state it replaced. That is the detail that makes history useful rather than decorative: restoring a revision restores what it replaced, and the restore is itself a save, so you can undo the undo. You can read a version before committing to it.
Entries, media, and users are soft-deleted. Every list and read filters out deleted rows, and the admin has a trash screen for putting them back. Slug uniqueness ignores deleted rows, so restoring from trash never collides with something written in the meantime.
The delivery API
Two routes cover most sites: a list and a single entry.
→ a list, paged GET /content/post?page=1&limit=20&include=terms → one entry (use /single for single-entry types) GET /content/post/hello
Media and reference fields arrive expanded — full objects, not ids
you have to go and fetch — so a page render is one request. Add
?include=terms to attach taxonomy terms, and
?term=news to filter by one.
GET /content lists the types a key may read along with their
field shapes, so a consumer can discover the model rather than hard-coding
it.
Media URLs are stored root-relative and resolved against
PUBLIC_URL when they are read, so moving to a new hostname does
not mean rewriting rows.
A draft, a user's email, or a soft-deleted row. Reference expansion re-checks both publication status and the key's own type scopes, so a reference cannot leak something the key could not have requested directly.
Live updates
One WebSocket at /realtime. It exists because the admin showed
stale lists whenever two people worked at once, and because a consuming site
had no way to learn that published content changed short of polling.
The socket grants no authority the HTTP surface doesn't. A session
sees what its role already permits; a delivery key hears only that published
content moved, filtered by the same scopes /content applies.
Frames never carry an entry's data — they say what changed, and you
re-read it through the API.
Connect with a ticket rather than a token. A browser cannot set headers on a WebSocket handshake, which leaves the query string, and query strings land in access logs — so you exchange your credential for a single-use ticket that is valid for 30 seconds and worthless by the time anything could read it back.
| Topic | What it covers |
|---|---|
site | Settings and menus. |
content:<type> | Entries of one type, as they publish and change. |
entry:<id> | One record — and who else is looking at it. Sessions only. |
A delivery key is refused entry topics entirely: activity on one record is editorial signal about work that may still be a draft.
Previews
A content type can declare a preview_url template with
{slug}, {id}, {locale}, and
{type} placeholders, so the admin can open an entry on your
real site without guessing your routing.
To show an unpublished entry, mint a share link. It names exactly one entry, lasts an hour, and is signed rather than stored — the value of a preview link is that it can be pasted to someone with no account, and a row per share is bookkeeping for something meant to be disposable. Nothing is revocable, which is exactly why the lifetime is short.
The assistant and the agent
Optional, and absent from the admin until someone connects a provider.
Credentials are sealed with AES-GCM under a key derived from
SECRET, kept in their own table rather than in settings, and
never returned by the API. Rotating SECRET invalidates them,
which surfaces as "reconnect this provider" rather than a 500.
Which provider
Four, and Inky runs on all of them — it needs a model that can call tools, which each of these can do.
| Provider | What to enter |
|---|---|
| Claude | A key from console.anthropic.com, or sign in over OAuth. The recommended one, and what the assistant is tuned against. |
| OpenAI | A key from platform.openai.com. Type the model you want — the list is a starting point, not a limit. |
| Ollama (local) | Nothing to enter. Defaults to http://127.0.0.1:11434; point it elsewhere only if your instance is. |
| Ollama Cloud | A key from ollama.com. The endpoint is fixed, so there is no URL to enter — type the model exactly as your account lists it. |
Whatever you pick, the model has to support tool calling — that is what lets Inky read your site before it answers. Local models vary here, so if Inky cannot act on anything, that is usually the reason.
Two ways to connect one
An API key is pasted into the admin and works immediately. OAuth is the second path: the admin offers "Continue with …" only for providers you have registered a client for, because a client is registered with the provider against a specific redirect URI and so cannot be entered in a form. That asymmetry is real and the UI shows it rather than hiding it behind a button that dead-ends.
The assistant
Not a chat window bolted onto the admin. Each intent — draft, rewrite, shorten, expand, summarize, titles, seo, translate, ask — corresponds to something an editor was already doing by hand, and each is handed your content model and the entry, so the answer is about this site. It streams, because a rewrite of a long field otherwise looks like a hung request.
Inky
Inky is the assistant given the run of the site rather than one field. It reads your types, entries, media, site details, and menus, works out which page you meant, and comes back with changes.
It rides along in the bottom-right corner of every admin screen, and it knows which screen that is. Open a post and ask "make this shorter" and there is no ambiguity about this — the dock hands Inky the page you are looking at along with the question, so the conversation carries on across screens instead of restarting on each one.
It is built for the person who did not build the site. You describe what you want in ordinary words — "we need somewhere to put customer quotes", "take the old promo off the menu", "the homepage opening is too long" — 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, and proposes it. It talks in pages and sections rather than entries and fields.
| You say | Inky changes |
|---|---|
| "Reword the opening" | The values on that one page. |
| "Add a section for quotes" | The shape of that page — which affects every page of its kind, and it will say so. |
| "We need a page for returns" | A new draft, written to match your other pages. |
| "Drop the promo from the menu" | Your navigation. |
| "Rename the site" | Your site title, tagline, description, logo, or social image. |
Inkling stores content; it does not render your site. Colours, fonts, spacing, and layout live in your site's own code, which Inky cannot see or edit. Ask for something visual and it will find the content-shaped version of the request — "make the hero say less" rather than "make the hero bigger" — and tell you plainly which part belongs to whoever builds the site.
The agent cannot write, and no setting makes it able to. It records a proposal; the admin renders that as a diff and applies it by sending the change through the same route a human edit takes. So revisions, field validation, slug uniqueness, relation checks, and the audit trail all keep working, and the history names the person who approved the change rather than a machine nobody can ask about it.
Content the assistant and the agent read is fenced and declared to be material, never instructions — an entry whose body says "ignore your instructions" is a string an editor typed.
Answering your visitors
The public assistant is a plugin rather than core, because it is the one AI surface that spends your money on behalf of anonymous visitors. That should be a deliberate decision with a switch to turn it back off, which is exactly what enabling a plugin is. It answers from published content only, grounded in the page the visitor is on, and returns a line you configure rather than guessing when the answer isn't there.
It borrows whatever provider the admin already uses, so there is no second key to manage. Turn on Show a bubble on the public site and it ships its own — one script tag, a shadow root so nothing of yours leaks in or out, and no framework:
<script src="https://cms.yoursite.com/ext/assistant/widget.js" defer></script>| Setting | What it does |
|---|---|
| Persona | Who it is and what the site is. Prepended to every answer. |
| Guardrails | Your house rules in plain sentences — what it must always say, must never promise, and which topics go to a human instead. Added to its instructions verbatim. |
| When the answer isn't in the content | Returned word for word instead of letting the model guess. |
| Sites allowed to use the bubble | Comma-separated origins. With none listed the bubble answers nobody — that is the default, and it is deliberate. |
| Content types to search | Narrow what it reads. Empty means everything published. |
| Questions per hour per visitor | The ceiling per address. 30 by default. |
Inky works for you and can propose changes. The visitor bubble works for your readers, has no tools at all, and can only answer out of published content. They never share a surface: the admin dock needs a session, and the public route refuses to answer for an origin you did not list.
Plugins
A plugin is a plain object in a directory. It can contribute content types, taxonomies, settings, admin panels, routes, its own database tables, and hook listeners.
Routes are declared relative and namespaced to /ext/<name>/…,
and they resolve per request — so enabling a plugin takes effect immediately,
with no restart and no rebuild.
Admin panels are declarative. The admin is bundled ahead of time, so a
plugin cannot inject its own interface into it; instead it describes panels
the admin already knows how to render. Five kinds: settings,
collection, table, stats — a
dashboard of tiles, a series, and top-N tables that the plugin aggregates
and formats, so a panel never has to guess what a number means —
and connections, a list of accounts that can be authorized,
where the admin owns the connect and disconnect buttons and the plugin owns
every word on the row.
Two kinds of hook, and the difference is the safety model:
- emit — notification. Every listener runs, failures are isolated. A plugin can observe anything without being able to break it.
- filter — transformation. Listeners chain, and a throwing filter is skipped rather than blanking the payload, so a broken plugin degrades to a no-op instead of blocking a save.
Seven ship with it, each demonstrating a different extension point:
| Plugin | What it shows |
|---|---|
seo | A delivery filter that adds 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, and a convenience route together. |
analytics | Cookieless traffic collection and a dashboard panel. |
assistant | Public answers grounded in published content. |
social | A queue, a calendar, and a performance report over four content types. |
analytics is worth a look for how it handles privacy: it stores
no address, and its visitor hash is salted with your secret and the
current date, so it counts uniques within a day and is uncorrelatable
across them. There is nothing to consent to and nothing to expire.
social plans, records, and holds the accounts. Its
Accounts panel authorizes one account per network — LinkedIn, X,
Facebook, Instagram, Threads, TikTok, YouTube, Pinterest, Google Business
— and seals the tokens the same way AI credentials are sealed, renewing
them before they lapse and saying "reconnect this" when they cannot be
renewed.
The connection is half the job. The other half is a different call per network, each with its own payload, its own media upload, and its own failures — and a plugin that quietly stopped posting would be worse than no plugin. So posts still go out by hand: stage one Posted and paste the link. Publishers are being built one network at a time on top of the connections.
Each network needs a developer app registered against your callback URL, the same asymmetry AI's OAuth path has. A network with no app configured says so on its row rather than offering a button that dead-ends.
More than one site
Inkling is single-tenant, and three things say so: core settings 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 — not sites.
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 each |
| Sites that are one property, sharing a team and a model | One instance, a scoped key per site |
Three separate sites is three databases and three configurations. They can
share a Postgres server and a bucket; what they cannot share is a schema.
Give each its own SECRET, so rotating one stops at one site.
Configuration and the database connection are read once at startup. Starting Inkling twice inside a single process gives you two sets of routes over the same data — which is not a second site. A second site is a second process.
Deploying
bun run start is the production entry: the same single process,
without hot reloading. There is no build step for the API, and the admin is
bundled at boot.
Three things to get right before you put it behind a domain:
- Set a real
SECRET, at least 32 characters. Inkling refuses to boot in production otherwise. - Set
PUBLIC_URLto the real origin — media URLs resolve against it. - Set
TRUSTED_PROXIESif you run behind a load balancer, so rate limits key on the real client rather than your proxy.
A site that would rather not run a second service can mount Inkling inside its own process instead. See mounting it inside a site.