this is my CMS, there are many like it, but this one is mine.
翻译准备中
取自仓库 README
正在加载完整 README。 以下为已保存的摘录。
A headless CMS with a plugin system and an agent that can work the whole of it,
built on Atlas.
The live Inkling School is at inkling.host.
It is the public test site for Inkling itself; the admin is at
/admin.
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.查看完整 README ↗Live School · Documentation · MIT License
A headless CMS with a plugin system and an agent that can work the whole of it, built on Atlas.
The live Inkling School is at inkling.host.
It is the public test site for Inkling itself; the admin is at
/admin.
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 wess.io/inkling.
bun install
cp .env.example .env
bun run dev
Open 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.
Connect a provider under Ask Inky → Providers — 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, your categories, your site details, who has an account, or a network you are trying to connect for the first time:
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.
you I want to start posting these to Instagram.
inky read your social setup … no app registered for Instagram
Instagram goes through a Facebook app, which is the part that
catches everyone out. I will walk you through it — first, are you
an admin of the Facebook Page the account is linked to?
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. And it moves you: ask how to start posting to Instagram and you end up on the right screen with the next step said in one sentence, rather than reading directions to it.
Every tool it has is a read. Inky has thirty-eight tools: thirteen read your site, twenty-four record a proposal, and one moves the admin. 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 is offered depends on who is asking. Each tool names the permission its proposal will need, and the list is filtered to what your role could actually apply — so an author is never shown the site settings and never queues a change that dead-ends on a greyed button.
Setting things up is half of what it is for. Social posting needs a developer app registered with the network, its client ID and secret saved here, and an account connected — three states, and the usual answer is that one of them is missing. Inky reads which, then walks you through that network's console a step at a time, in its own words, with the redirect URI quoted exactly. Four things still need your hands, and it takes you to them rather than describing the route: uploading a file, creating an account, pressing Connect, and pasting a client secret — a secret is a password, and Inky is told never to ask you to type one into a chat.
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.
For ordinary ChatGPT chats, add the site's HTTPS MCP URL under ChatGPT Plugins:
https://cms.yoursite.com/mcp. The person signs in with their own Inkling
account in the browser. The link follows that account's role and can be revoked
under Agent keys. No local setup or copied key is required. The
School connection lesson walks a new
editor through the desktop app.
Inky is for the person at the admin. For a coding agent in a terminal somewhere
else, bun run mcp serves the admin API as MCP tools over stdio — entries,
content types, media, menus, settings, taxonomy, search, and revisions.
It signs in with an agent key, not your password. Mint one under Agent keys in the admin, ticking only what that agent needs:
{
"mcpServers": {
"mysite": {
"command": "bun",
"args": ["run", "/path/to/inkling/scripts/mcp.ts"],
"env": {
"INKLING_URL": "https://cms.yoursite.com",
"INKLING_KEY": "inkagt_…"
}
}
}
}
A key acts as the account that minted it, but only for the boxes you ticked, only until it expires, and you can revoke it on its own without changing your password or signing anyone out. It can never do anything administrative — adding a user, minting an API key, connecting a social account, installing a plugin — whatever you tick and whatever role you hold. Demote the account and every key it minted narrows with it, immediately.
That is enforced by Inkling, not by the tool list. The MCP server reads its own grants at startup and offers only the tools they cover, so the model does not plan around a call that would be refused — but the refusal happens at the site.
One process per site, because keys are per-site. Every write goes through the
same /api route the admin calls, so revisions, validation, hooks, and the audit
trail keep working; the history shows the account, and records which key acted.
INKLING_MCP_READONLY=1 narrows further than the key does, for pointing an agent
at production to look rather than touch without minting a second key.
The agent operations guide covers
discovery order, production safety, failure handling, and reliable update
workflows.
Inkling posts to X, Facebook, Instagram, Threads, LinkedIn, TikTok, YouTube, Pinterest, and Google Business. Set a network up under Social → Settings, connect an account under Social → Accounts, write a post, aim it at one network or all nine, and send it now or at a time.
Each network gets the same caption unless you give it its own — X's 280 characters and a YouTube description are not the same piece of writing, and the composer counts both as you type. Media comes from the library everything else uses. Everything a network will refuse is checked when you save rather than when it sends, because a scheduled post that turns out to be unpostable at 6am on a Saturday is a notification nobody reads.
A post's outcome is per network. Sending records what each one did on its own row, with that network's own error next to it — so a post X took and TikTok refused reads as part posted, not as a success or a failure. Press send again and only the failures are retried; what already went out is never posted twice.
For agency workflows, the optional social plugin adds client and campaign
planning around these core posts. A plan links to one core post through
publishPostId; delivery, retries, per-network outcomes, and error state stay
owned by core, while the plan mirrors the result.
Three roles, because sending is irreversible in a way publishing a page is not: an author writes a post, an editor decides when it goes out, an admin connects the accounts.
Each network needs a developer app registered with that network against a redirect URI on your domain — there is nothing self-hosted software can ship instead. Social → Settings is where that goes: a row per network with a client id, a sealed secret, an on/off switch, and a "?" that opens a plain walkthrough of that network's console — what the buttons are actually called, how long it really takes including the waiting, and the one step everybody gets wrong. Networks are independent, so the ones you have finished work while the rest are still in review.
SOCIAL_OAUTH_<NETWORK>_CLIENT_ID and _CLIENT_SECRET still work and are read
for any network you have not set up in the admin.
Five of the nine — Facebook, Instagram, Threads, Pinterest, Google Business —
download media from your site rather than being handed it, so posts carrying
images or video need PUBLIC_URL to be an address they can reach. The settings
screen says so if it is not.
Inkling already supports plugins. Open Advanced setup → Plugins to enable an installed one; its screens appear under More tools. For installation, embedded sites, and route permissions, see the plugin guide. These plugins ship with Inkling:
| 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 | Ecommerce: existing product catalog plus shop introduction and editorial shop pages |
square | Connect a Square merchant, choose products, and create Square-hosted checkouts. Requires Ecommerce; off until enabled. Setup and storefront API |
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 | Agency social planning — clients, campaigns, a queue, a calendar, performance reporting, and a link from each plan to its core delivery post |
google | Google Analytics and Google Ads. A guide panel that walks a non-technical operator through setup and ticks itself off as they go, two stats panels, and a secret setting whose value the API can never hand back |
import { get, json } from "@wess/atlas/server"
import { definePlugin } from "inkling/plugins"
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,
}))
},
})
This example is public. For staff-only routes, use requireAuth and
requireCan from inkling/plugins, as shown in the plugin guide.
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#v1.10.0 # pin to a release
bun add github:wess/inkling # or follow main
Pin 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.
Inkling imports released Atlas libraries through @wess/atlas/<package>.
The coordinated GitHub release URLs are pinned in its manifest and lockfile;
consuming sites need no local Atlas checkout or TypeScript path aliases.
createInkling returns a handler rather than a server:
import { createInkling } from "inkling"
const inkling = await createInkling({ adminBase: "/admin", siteKeyName: "site" })
Bun.serve({
// The request-body ceiling and idle timeout. Bun buffers a whole body before
// any handler runs and defaults to 128MB, so spreading this is not optional.
...inkling.serveOptions,
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. design names the parts of the site Inky may
restyle ({ buttons: { label: "Buttons", selector: ".button" } }), so a client
can say "make every button black" and approve the result; load
GET /site/design.css (or inkling.designCss()) after your own stylesheet.
Omit it and Inky has no design tools.
visual maps content types to editable sections and field selectors in your
existing HTML. Pass the same definition to renderVisual from inkling/visual
when rendering the page, together with entry.data.__layout. The admin uses a
temporary preview to show unsaved changes; saving uses the ordinary entry route,
including validation and revisions. Related-entry pickers can store an existing
slug field, and section links lead editors to shared collections. See the
visual editor contract.
If the host caches delivery reads, compare inkling.contentVersion() before
using that cache and clear it when the value changes. The counter advances on
content changes, including scheduled publication. Avoid letting a request that
started before a change refill the new cache with an old response. Browser HTML
must also revalidate after edits, for example with max-age=0, must-revalidate.
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 docs | Validate every documentation page, local link, and anchor |
bun run password | Set a user's password from the host — the way back in when the only owner is locked out |
bun run mcp | The admin API as MCP tools, for an agent working a site from outside it |
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.
docs/ARCHITECTURE.md — module layout, data model,
plugin system, realtime, previews, AI, and the dialect-portability rulesllms.txt — the small machine-readable map
agents can use to choose only the context they needllms-full.txt — product and codebase
context for work that spans more than one focused guide.env.example — every configuration variabledocs/ 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.