Tutorials
Build something
Five walkthroughs, each one finished and running by the end. Start with the blog if you have not used Inkling before — the rest assume it.
A blog, end to end
A model, some writing, and a site rendering it. Assumes you have Inkling running from Get set up.
-
Model the post
Types → New type, named
Post, kind collection. Give it these fields:Key Type Why summarytext The card on your index page, and the meta description. bodyrichtext The writing. Stores portable, cleaned HTML. covermedia Comes back expanded, with dimensions, so you can lay it out without measuring. relatedreference → post Links to other posts, checked against the target type on save. -
Add a category taxonomy
Categories → New taxonomy, named
category, hierarchical if you want nesting. Terms are attached to entries from the editor, and come back on delivery when you ask for them. -
Tell it where the post lives
On the type, set the preview URL to
https://yoursite.com/blog/{slug}. Now the admin's "view" button opens the real page instead of guessing. -
Write two posts and publish one
Leave the second as a draft — you want something that should not appear when you read the API in a moment.
-
Render the index
Mint a key under API keys, scope it to
post, and read the list.const res = await fetch(`${process.env.INKLING_URL}/content/post?include=terms&limit=10`, { headers: { "x-api-key": process.env.INKLING_KEY }, }) const { data: posts } = await res.json() return posts.map(post => ` <article> <img src="${post.data.cover.url}" alt="${post.data.cover.alt}" width="${post.data.cover.width}"> <h2><a href="/blog/${post.slug}">${post.title}</a></h2> <p>${post.data.summary}</p> </article>`).join("")
Only the published post comes back. The draft is not filtered out on your side — it never left.
-
Render one post
GET /content/post/my-first-postrelatedarrives as full entries rather than ids, each one re-checked for publication and against your key's scopes. A draft you linked to simply is not in the array. -
Stop rebuilding on a timer
Subscribe to
content:postand revalidate the one page that changed. See Stay in sync for the socket handshake.
Write a plugin
A plugin is a plain object in a directory — no class to extend, no lifecycle to inherit. This one adds a reading time to every post the delivery API returns, computed at read time rather than stored, so it can never go stale against the writing.
-
Make the directory
The directory name and the plugin's
namemust match.mkdir -p plugins/readingtime
-
Write it
plugins/readingtime/index.ts. Thedelivery.entryfilter runs on every entry on its way out, which is where a derived value belongs.import { definePlugin } from "../../src/plugins/define.ts" // Words per minute is a setting rather than a constant because the right // number is editorial, not technical. const words = (html: string): number => html.replace(/<[^>]*>/g, " ").split(/\s+/).filter(Boolean).length export default definePlugin({ name: "readingtime", version: "1.0.0", label: "Reading time", description: "Adds an estimated reading time to delivered entries.", settings: [ { key: "wpm", label: "Words per minute", type: "number", default: 220 }, { key: "field", label: "Field to measure", type: "text", default: "body" }, ], panels: [{ id: "readingtime", label: "Reading time", kind: "settings" }], register: ctx => { ctx.filter("delivery.entry", async ({ payload, type, raw }) => { const field = await ctx.getSetting("field", "body") const wpm = await ctx.getSetting("wpm", 220) const source = (payload.data as Record<string, unknown>)?.[field] if (typeof source !== "string") return { payload, type, raw } return { payload: { ...payload, readingMinutes: Math.max(1, Math.round(words(source) / wpm)) }, type, raw, } }) }, })
-
Turn it on
Plugins in the admin, then enable it. Nothing restarts — plugin routes and hooks resolve per request, so it is live on the next call.
curl http://localhost:4300/content/post/hello -H "x-api-key: ink_…" { "slug": "hello", "readingMinutes": 4, … }
If your filter throws, its input carries forward untouched and the
response still goes out. That is the deal: filter hooks
transform and degrade to a no-op, emit hooks observe and have
their failures isolated. Neither can fail the core path.
Going further
A plugin can also declare its own content types (marked as owned by it, so
disabling retires them), its own taxonomies and settings, routes under
/ext/<name>/…, and its own database tables through
plugin-scoped migrations in plugins/<name>/migrations/.
Read the seven bundled plugins — each one demonstrates a different
extension point.
Three sites at once
Inkling is single-tenant. Core settings live under one site scope, menu names
are globally unique, and PUBLIC_URL is one origin per process —
so separate sites means a database each, not one instance with a tenant
column.
-
A database per site
They can share one Postgres server. What they cannot share is a schema.
createdb inkling_alpha createdb inkling_beta createdb inkling_gamma
-
A configuration per site
Each gets its own
SECRET, so rotating one invalidates that site's sessions and stored credentials and stops there.# alpha.env DATABASE_URL=postgres://…/inkling_alpha PUBLIC_URL=https://cms.alpha.com PORT=4300 SECRET=… # beta.env DATABASE_URL=postgres://…/inkling_beta PUBLIC_URL=https://cms.beta.com PORT=4301 SECRET=…
-
A process per site
bun --env-file=alpha.env src/start.ts bun --env-file=beta.env src/start.ts bun --env-file=gamma.env src/start.ts
Media can share a bucket — object keys are dated and randomized, so two uploads of
logo.pngnever collide, whatever site they came from.
When one instance is right
If the three "sites" are really one property — the same editorial team, one set of settings, one menu namespace — run a single instance and give each site a key scoped to the types it renders.
→ alpha's key: scopes ["post", "page"] → beta's key: scopes ["product", "page"]
Each key sees only its own types, including through reference expansion. What they still share is settings, menus, media URLs, and the user list — so this is the right shape only when sharing those is what you wanted.
Mount it inside a site
A site that would rather not deploy a second service can run Inkling in its own process. It hands back a handler rather than owning a port.
import { createInkling } from "inkling" const inkling = await createInkling({ adminBase: "/admin", siteKeyName: "site" }) Bun.serve({ fetch: async (request, server) => { // Answer the upgrade before anything returns a Response, or the // handshake is gone. 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)) ?? myRouter(request) }, websocket: inkling.websocket, })
| Option | What it does |
|---|---|
adminBase |
Confines the admin to a prefix. At "/" every unmatched path becomes the admin; anywhere else, fetch returns null off it so your own routes still run. |
siteKeyName |
Mints a delivery key for the site sharing the process — it has no browser in which to visit the admin and copy one. Derived from SECRET, so it is the same key on every boot. |
server through
It is where the real socket peer comes from, and rate-limit buckets and audit rows key on it. Drop it and every request looks like it came from the same nowhere — which turns the per-IP login limit into one global bucket shared by every account.
Connect an assistant
Optional, and absent from the admin until you connect a provider. Nothing to set in the environment for the ordinary path.
-
Paste a key
Settings → AI, choose a provider, paste the key. It is sealed with AES-GCM under a key derived from your
SECRET, kept in its own table rather than in settings, and never returned by the API — only the last four characters, so you can tell two keys apart. -
Use it while writing
The assistant appears on fields in the editor: draft, rewrite, shorten, expand, summarize, titles, seo, translate, ask. Each one is handed your content model and the entry, so it answers about your site.
-
Ask Inky for a page
Inky is in the bottom-right corner of every admin screen, and it knows which screen you are on. Ask it for something you do not have yet:
you We need a page about our return policy. inky You have a Page type with a body and a summary — I've drafted one and left it unpublished so you can read it first. + Page · "Returns" · draft summary Our 30-day policy, in short. body <p>If something isn't right…</p> [ Apply ] [ Discard ]
It reads your types, entries, media, menus, and site details before it answers, then proposes. You get a diff. Applying it is an ordinary save, so it lands in the entry's history under your name and can be restored like any other edit.
The same conversation can reshape a type ("add a place for customer quotes"), publish or unpublish something, edit your navigation, or change your site title — Inky works out which of those you meant.
-
Or connect over OAuth instead
Register a client with the provider against your callback URL, then set its id. The admin offers "Continue with …" only for providers that have one, because a client is registered with the provider and cannot be pasted into a form.
# redirect URI to register: PUBLIC_URL + /ai/oauth/callback AI_OAUTH_ANTHROPIC_CLIENT_ID=… AI_OAUTH_ANTHROPIC_CLIENT_SECRET=…
-
Answer your visitors, if you want to
Enable the
assistantplugin. It answers from published content only, grounded in the page the reader is on. It is a plugin rather than core because it is the one AI surface that spends your money for anonymous visitors — so it should have a switch.It reuses the provider you connected in step one. Write your house rules into Guardrails — this is where you say what it must never promise, and when to hand someone to a human:
# Plugins → Site assistant → Guardrails You answer questions about Ash & Ember, a ceramics studio in Columbia. Never quote a price or a lead time — those change weekly. For custom commissions, point people at the contact page. If you don't know, say so and offer the contact page.Then turn on Show a bubble on the public site, list the origins allowed to embed it — with none listed it answers nobody — 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 in the corner, drawn inside a shadow root so it cannot collide with your CSS and your CSS cannot reach it. If you would rather draw your own,
POST /ext/assistant/public-askis the same answer as JSON.