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.
Explaining itself
The person filling in your content usually did not build the site. They were handed a login and a job, and the field in front of them says API field name. So every control that is not self-evident carries a ?, and pressing it opens a plain-language explanation rather than a tooltip that vanishes when the pointer moves.
Each one answers the three questions that actually come up:
| Part | What it says |
|---|---|
| What it is | A sentence somebody would say out loud, without CMS vocabulary. |
| For example | One concrete case, because an example teaches faster than a rule. |
| Worth knowing | What it affects elsewhere, or what cannot be undone. This is the part nobody writes down and the reason most of these exist. |
It is a modal rather than a hover tooltip on purpose: a hover reveals
nothing on a phone, and a tooltip has room for a caption rather than an
explanation. The text lives in one file — src/web/help.ts —
keyed by id and checked at build time, so a control pointing at help that
does not exist fails the build instead of opening an empty box.
Email, Password, and Confirm password do not, and will not. A ? beside a field whose label already says everything teaches people that the mark is decoration, and then they stop pressing the ones that matter.
Fields you define yourself are covered separately: whatever you type into a field's Help text appears under the control while somebody is filling it in, in full rather than behind a mark, because help written for your own colleagues should be read without going looking for it.
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 page shapes, your pages, your media, your categories, your navigation, your site details, your plugins, your people, your delivery keys and webhooks, and your social setup — works out which page, or which missing piece of setup, 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", "I want to post this to Instagram" — and Inky works out what that means here 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. |
| "File this under Announcements" | Your categories and tags, and which ones a page carries. |
| "Describe this photo for screen readers" | The alt text on a file already uploaded. |
| "Our new designer needs to publish" | Somebody's role — never above your own. |
| "The marketing site needs to read our blog" | A delivery key, scoped to the types you name. Shown once. |
| "Help me post to Instagram" | Reads which of the three setup steps is missing, then walks you through that network's console. |
| "Where do I upload a logo?" | Nothing — it takes you to the media library instead of describing the route. |
Setting things up
Half of what Inky is asked is not a change to content at all — it is somebody trying to get something working for the first time, stuck on a console built for people who write software for a living. Social posting is the hard case, and it needs three things in order: a developer app registered with the network, its client ID and secret saved here, and then an account connected by pressing Connect. Inky reads which of the three is missing and says so plainly, then walks you through that network's console a step at a time — the real button names, the honest timing, and the one step everybody gets wrong — waiting for you at each one rather than pasting a wall of instructions.
A client ID is fine to say in conversation. A client secret is a password, and a chat window carries it further than it needs to go — so Inky takes you to the field on Social → Settings and lets you paste it there. If you have already pasted one into the chat it will use it rather than making you do it twice, and the diff masks it.
What still needs your hands
Four things, and Inky takes you to the screen rather than describing the route: uploading a file, creating an account (a password has to be typed), pressing Connect on a social network (the consent screen is on the network's own domain), and pasting a client secret. Everything else it can propose.
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. The one exception acts rather than proposes, and changes nothing: moving you to a screen.
Every tool names the permission its proposal will need, and the list is filtered before Inky ever sees it. An author is not shown the site settings, an editor is not shown delivery keys, and neither is left confidently queueing a change that dead-ends on a greyed button. If one does appear greyed, it is a change somebody more senior has to apply.
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 has no tools. The admin's assistant runs a loop that can read the whole site and propose changes; this one gets a single completion with a block of published text in front of it, so there is no write path to abuse and no call it can make at all. Follow-up questions work because the conversation is held here for a couple of hours and the browser holds only an opaque id — a client that carried its own transcript could forge what the assistant already said and steer the next answer with it.
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>Whatever the persona and guardrails say, the assistant will not claim that anything treats, prevents, cures, or relieves a condition, will not suggest a dose, will not say whether anything is legal, will not give medical, legal, financial, or veterinary advice, and will not promise a price, stock, a delivery, or a refund. Those rules are added last and say plainly that they outrank the site owner's — because the person carrying the liability for a bad claim is the same site owner on a worse day. Nothing typed into the settings turns them off.
| Setting | What it does |
|---|---|
| What it specialises in | The assistant in a sentence or two — what it knows about and who it is talking to. "A CBD, hemp and cannabinoid specialist for this shop, answering retail customers." A description, not a licence: see the built-in rules below. |
| 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, and outranked by the built-in rules below. |
| 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 it may answer from | Comma-separated type names. Only published entries of these kinds ever reach the model. Empty means it answers nothing — an assistant nobody has scoped is not one that should be reading everything. |
| Questions per hour per visitor | The ceiling per address. 30 by default. A site-wide ceiling of 500 answers a day sits above it and is not configurable — it is what bounds the bill when a thousand addresses each stay politely under their own limit. |
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.
Eight 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. |
google | Google Analytics and Ads, behind a setup walkthrough that ticks itself off as you go. |
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.
google is the one to read if you are setting a site up for
somebody who does not write software. It is deliberately two halves.
Pasting a Measurement ID under Google → Setup puts Google
Analytics on the site — five minutes, no Google Cloud project, nothing to
approve — and Inkling generates the snippet for you, or serves it to your
front end from /ext/google/tag. Connecting a Google account,
so the traffic and ad numbers appear in Inkling's own panels instead of on
Google's site, is a separate part marked optional, and nothing in the first
half mentions it.
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.