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.

PathWho calls itHow it authenticates
/The admin — any path the router doesn't claimSession
/api/…The admin's whole surfaceSession bearer token
/content, /siteYour websitesX-Api-Key
/preview/:tokenWhoever you sent a share link toThe signed token
/media/file/…Anything rendering an imagePublic
/ext/…Plugin routesVaries by plugin
/realtimeThe WebSocketA 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:

FieldHolds
listA repeater. Nests, and validates recursively — a section list whose items each have their own fields.
media / galleryOne image or many. Comes back expanded on delivery, with URL, alt text, and dimensions.
referenceA link to entries of a declared type, which is enforced on save.
Field keys are camelCase

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.

What a key can never see

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.

TopicWhat it covers
siteSettings 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.

ProviderWhat to enter
ClaudeA key from console.anthropic.com, or sign in over OAuth. The recommended one, and what the assistant is tuned against.
OpenAIA 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 CloudA 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 sayInky 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.
What Inky cannot do

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.

Every tool it has is a read

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>
SettingWhat it does
PersonaWho it is and what the site is. Prepended to every answer.
GuardrailsYour 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 contentReturned word for word instead of letting the model guess.
Sites allowed to use the bubbleComma-separated origins. With none listed the bubble answers nobody — that is the default, and it is deliberate.
Content types to searchNarrow what it reads. Empty means everything published.
Questions per hour per visitorThe ceiling per address. 30 by default.
Two agents, two jobs

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:

Seven ship with it, each demonstrating a different extension point:

PluginWhat it shows
seoA delivery filter that adds computed metadata to every response.
redirectsA plugin-owned content type plus a public lookup route.
formsA plugin with its own table, via plugin-scoped migrations.
commerceContent type, taxonomy, settings, and a convenience route together.
analyticsCookieless traffic collection and a dashboard panel.
assistantPublic answers grounded in published content.
socialA 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.

Connecting is not yet posting

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 wantRun
Sites with their own settings, menus, and originOne instance per site — a database each
Sites that are one property, sharing a team and a modelOne 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.

One instance per process

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:

  1. Set a real SECRET, at least 32 characters. Inkling refuses to boot in production otherwise.
  2. Set PUBLIC_URL to the real origin — media URLs resolve against it.
  3. Set TRUSTED_PROXIES if 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.