# Inkling > A content platform. You define the shapes your content has; editors fill them > in; your sites read the result over one HTTP API. Content is stored, not > rendered — Inkling never produces your markup. This is the full-context companion to `/llms.txt`. Read the smaller routing file first, then use this one when a task spans the product and codebase rather than one focused guide. ## Orientation - **One process, one port.** The admin, the API, delivery, media, and the socket share an origin and are separated by path. No proxy, no second server, no dev server. - **Assembly and port ownership are two files.** `src/app.ts` (`createInkling`) builds Inkling and returns a handler; `src/server.ts` is the twenty lines that give it a port. Add a route in `app.ts`; `server.ts` only owns `Bun.serve`. - Everything session-gated is mounted through `prefixed("/api", …)`. Everything public keeps a root path, because those paths live in other people's code. Whatever the router does not claim is the admin — so `/settings` is a screen and `/api/settings` is the API. - Mount by audience, not by module: a feature with both kinds of route exports two arrays (`mediaRoutes` / `mediaFileRoutes`, `previewRoutes` / `previewPublicRoutes`, `realtime.routes` / `realtime.publicRoutes`). - Two surfaces. **Admin API**: session bearer token, role-gated, everything. **Delivery API**: `X-Api-Key`, read-only, published content only. - Three credentials, told apart by prefix in `requireAuth` and never interchangeable: a session JWT (a person), an **agent key** `inkagt_…` (a program, `src/agents`), and a delivery key `ink_…` (a website). An agent key is refused on `/content`; a delivery key is refused on `/api`. - A delivery key can never see a draft, a user's email, or a deleted row. Reference expansion re-checks publication status *and* the key's type scopes, so a reference cannot leak content the key could not have fetched directly. - Two deliberate exceptions, both narrow: a **preview token** names one entry and expires in an hour; the **realtime socket** tells a key holder that published content moved, carrying ids and never payloads. - Postgres is the store. SQLite exists only so the test suite runs in memory. - **Single-tenant.** 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. Separate sites means a database each — `config` and the db connection are module-level, so `createInkling` twice in one process is two route sets over the same data. - No build step for the API. Verify with `bun run typecheck`, `bun run test`, `bun run check`, and `bun run docs` — all four must be clean. ## Delivery API (what a website calls) | Route | Returns | |---|---| | `GET /content` | Types this key may read, with field shapes | | `GET /content/:type` | Published entries; `?term=`, `?locale=`, `?sort=`, `?include=terms,author` | | `GET /content/:type/:slug` | One published entry (`/single` for single-entry types) | | `GET /site/settings` | Site settings, with media ids resolved to URLs | | `GET /site/menus/:name` | One menu tree | | `GET /preview/:token` | One entry at any status, until the token expires | | `POST /realtime/delivery/ticket` | A 30-second ticket to open the socket | Media and reference fields arrive expanded, so a page render is one request. Embedded hosts can compare `inkling.contentVersion()` to invalidate delivery caches after content changes. Do not store an in-flight response if the counter changed while fetching it. It is a local counter, not a durable revision. ## Visual editing and previews `createInkling({ visual })` accepts a map from content-type names to sections, field selectors, optional related-entry pickers, and shared-collection links. The host applies `renderVisual(html, definition, entry.data.__layout, editing)` from `inkling/visual`. Layout is `{ order: string[], hidden: string[] }` and travels with ordinary entry saves and revisions. Public rendering applies order and hiding; editing rendering annotates fields and keeps hidden sections present. `POST /api/entries/:id/preview` accepts optional `{ title, slug, data }` for an unsaved snapshot. No body reads the saved entry. Both return a signed token, expiry, API URL, and site URL. The host substitutes the token's one entry only inside that render request and sends no-store/noindex. Enable visual annotations only for a validated preview with `visual=1`. Snapshot previews leave database content unchanged, expire within an hour, and disappear on restart or eviction. The store holds 100 snapshots/16 MiB total, at most 1 MiB each. ## Realtime Open `ws://host/realtime?ticket=…` after exchanging a credential for a ticket (`POST /api/realtime/ticket` for a session, `POST /realtime/delivery/ticket` for a key — the key route stays at the root because consuming sites call it). A browser cannot set headers on a handshake and query strings reach logs, so the ticket is single-use and dies in 30 seconds. Client frames: `{"action":"subscribe"|"unsubscribe","topic":…}` and `{"action":"ping"}`. Topics: `site`, `content:`, `entry:`. Server frames: `{topic, event, data}` plus `ready` / `subscribed` / `pong` / `error`. Entry topics also carry `presence`. Keys are refused entry topics entirely and hear only `entry.published`, `entry.unpublished`, `entry.deleted`. Frames carry `id`, `slug`, `type` — never content. Re-read through `/content` when one arrives. ## Admin API (shape only — see the source for the full list) All under `/api`: `/api/auth/*`, `/api/users`, `/api/types`, `/api/types/:type/entries`, `/api/entries/:id`, `/api/entries/:id/{publish,unpublish,status,duplicate,preview,revisions}`, `/api/entries/bulk`, `/api/media`, `/api/taxonomies`, `/api/menus`, `/api/settings`, `/api/keys`, `/api/agents`, `/api/webhooks`, `/api/search`, `/api/stats`, `/api/audit`, `/api/plugins`, `/api/visual`, `/api/ai/*`, `/api/realtime/ticket`. A new session-gated route goes inside the `prefixed("/api", […])` block in `src/app.ts`. At the root it would shadow an admin screen — `/settings`, `/types`, `/media`, `/menus`, `/plugins`, `/keys`, `/users`, and `/webhooks` are all admin URLs. Roles are a ladder: `viewer < author < editor < admin < owner`. Capabilities are predicates in `src/auth/roles.ts` — routes guard with `requireCan(can.x, "…")` rather than comparing role strings. Each predicate also carries a **scope name** (`content.publish`, `settings.manage`), which is the vocabulary agent-key grants are written in; `requireCan` reads it off the predicate, so route code is unchanged and cannot forget. **Agent keys** (`src/agents`) are how a program signs in — an MCP server, a build script — instead of holding somebody's password. Four rules: - Effective permission is the grant list ∩ the account's *live* role, re-read per request. Demote the account and every key it minted narrows with it. - `GRANTABLE_SCOPES` is content only. `users.manage`, `keys.manage`, `webhooks.manage`, `plugins.manage`, `ai.manage`, `ai.use`, and `social.manage` are **not grantable** — no key exists that reaches them, so neither escalation nor reaching outside the install is available from a token. - Revocable on its own; expiry required, 90 days by default and 365 at most. - Minting is human-only and re-asks for the password, so a stolen session cannot be traded for a longer-lived credential and a key cannot renew itself. When adding a route, do not read `can.x(identity.role)` directly — call `allows(identity, can.x)`, or the grant is silently bypassed. `requireGrant(scope)` covers the case where a route's role bar is enforced some other way (an ownership rule), as in soft-deleting an entry. `GET /api/agents/me` reports what the calling credential may do; `scripts/mcp.ts` uses it to publish only the tools its grants cover. That is presentation — the refusal happens at the route. The MCP process supports current `2026-07-28` discovery and per-request metadata, plus the `2025-11-25` and `2025-06-18` initialization handshakes. Independent calls run concurrently; cancellation suppresses a late result. ## AI Optional and absent until an operator connects a provider in the admin. Keys are stored encrypted (AES-GCM under a key derived from `SECRET`) in their own table, never in `settings`, and are never returned by the API. - `POST /ai/assist` — editorial assistant, SSE. Intents: `draft`, `rewrite`, `shorten`, `expand`, `summarize`, `titles`, `seo`, `translate`, `ask`. - `POST /ai/agent` — the agent, named **Inky** (`src/ai/agent.ts`), a tool loop over `src/ai/tools/`, streamed over SSE. Holds no server-side state: the transcript rides back and forth with the browser and is refused, not truncated, when it outgrows its cap. Runs on any of the four providers: two wire formats, Claude's own and OpenAI's, with Ollama taking the OpenAI path because it serves a compatible `/v1` endpoint locally and as Ollama Cloud (separate entries; the cloud one has a fixed endpoint so no URL can be mistyped). It reaches entries, content types, media, categories, menus, site settings, plugins, people, delivery keys, webhooks, and the social setup — 45 tools, of which 19 read, 25 propose, and one (`open_screen`) moves the admin. Its system prompt is most of the feature: it assumes a non-technical asker, translates outcomes into model changes, and states the boundary that Inkling stores content while the consuming site renders it. The host can expose design surfaces for style changes and visual pages for section order and visibility. `get_page_layout` reads those sections; `propose_entry_update` carries their reviewed changes as `data.__layout`. Content-model field order does not change rendered section order. The transcript accepts `role: "tool"` (OpenAI carries tool results as their own messages) but never `system`. The admin mounts it as a dock in the corner of every screen (`InkyDock` in `src/web/app.tsx`); `describe(route, types)` turns the current route into one sentence of context that rides along with the question, which is why "make this shorter" resolves without the user naming the entry. **No tool writes.** A `propose_*` tool records an intention; the admin renders a diff and applies it through the ordinary admin route, so there is one write path and the history names the person who approved it. **The list is filtered by role.** Each tool declares the capability its proposal needs at apply time, `toolsFor(role)` drops what the asker could not apply, and each proposal carries its scope so the panel can grey one card. `POST /ai/agent/status` returns `{ configured, supported, provider, model, mayUse, scopes }`. **Setup guidance is a first-class job**, especially social: `get_social_setup` reports app / credentials / connected account per network with the exact redirect URI, and `get_social_guide` hands over `src/social/guides.ts` so Inky can walk someone through a developer console. Inky is told to ask for a client ID but never a client secret; the admin masks one in the diff if it arrives. Four things need a person and are reached with `open_screen` rather than described: uploading a file, creating an account, pressing Connect, pasting a secret. - `POST /ext/assistant/ask` — the optional public assistant (a plugin), answering from published content only, grounded in the page the visitor is on. Requires an API key, for a site relaying its own visitors' questions server-side. - `POST /ext/assistant/public-ask` — the same answer with **no credential**, reached straight from a visitor's browser, because a key shipped to a browser is a key given to everyone. What stands in for it: an operator-set origin allowlist (empty by default, so it answers nobody), a per-address hourly ceiling, and the plugin being disabled until someone turns it on. The origin is checked before the body is read. - `GET /ext/assistant/widget.js` — a self-contained bubble (`plugins/assistant/widget.ts`), rendered into a shadow root, that reads its own endpoint off the tag it was loaded by. 403s while the `widget` setting is off. Note the content-type is set *after* `text()`, which would otherwise leave it `text/plain` — and `nosniff` makes a browser refuse that outright. **No agent tool writes, and adding one is a bug.** Tools either read, record a proposal, or move the admin. The admin renders proposals as diffs and applies an approved one through the ordinary route a human edit takes, so revisions, validation, slug uniqueness, relation checks, hooks, and the audit trail keep working. `tests/aiagent.test.ts` pins that boundary. Prompt caching (`src/ai/agent.ts`) marks the tool schemas and system prompt once and rolls at most two breakpoints across the transcript, because the render order is tools → system → messages and Anthropic allows four. `clearBreakpoints` runs before `roll` so a long conversation cannot accumulate them; `tests/aicache.test.ts` asserts the count stays at or below two. Two ways to connect a provider. An **API key** is pasted into the admin. **OAuth** (`src/ai/oauth.ts`) is authorization-code with PKCE: `POST /api/ai/oauth/:provider/start` returns a consent URL and the provider redirects to `/ai/oauth/callback` — public, because the browser arrives by top-level navigation with no bearer token. The `state` parameter stands in for the session: sealed rather than stored, ten-minute expiry, and the role is re-read on the way through. `AI_OAUTH__CLIENT_ID` and friends are the only environment variables the AI feature has; without one the admin offers only the key path. Content is fenced in `` / `` tags and the model is instructed to treat it as material, never as instructions. ## Plugins Use the public `inkling/plugins` export for definePlugin, manifest and panel types, and route guards (auth, requireAuth, requireCan, can, requireApiKey). Plugin routes are not automatically authenticated. Installation and lifecycle details: https://wess.io/inkling/plugins.md. A plugin is a plain object from `definePlugin()` at `plugins//index.ts`. It may add content types, taxonomies, settings, admin panels, routes, its own migrations, and hook listeners. Routes are declared relative and namespaced to `/ext//…`, resolved per request — enabling one needs no restart. `emit` hooks observe and can never break the core path. `filter` hooks transform, and a throwing filter degrades to a no-op rather than blanking the payload. Panels are declarative — the SPA is bundled before a plugin exists, so a plugin describes panels rather than shipping React. Six kinds: `settings`, `collection`, `table`, `stats`, `connections`, `guide`. A `connections` panel is a list of authorizable accounts; the SPA owns three verbs (`POST //start` for a consent URL, the return leg, `DELETE /`) and the plugin owns every word on a row. `ctx.adminBase` exists solely so a plugin's OAuth return leg — a top-level navigation — can land back in the admin. A `guide` panel is a setup walkthrough that knows how far along it is. The payload comes in `parts` (a cheap half and an expensive half read as one eleven-step list otherwise), each step optionally carrying `done`, `copy`, `link`, `input`, `choices`, or `connect` — so a value is collected where it is asked for rather than on another screen. The plugin owns every word and the whole notion of "done". `PluginStats` also carries an optional `note`, so a dashboard can explain its own emptiness. A plugin setting declared `type: "secret"` is sealed with the same AES-GCM as every other stored credential. `src/plugins/settings.ts` is the only file that knows: the plugin reads plaintext from `getSetting`, the API and the assistant both read four characters, and every write — route, guide step, or approved assistant proposal — goes through `writePluginSettings`, where `null` clears, `""` or the mask leaves it alone, and anything else is sealed. Bundled: `seo`, `redirects`, `forms`, `commerce`, `analytics`, `assistant`, `social` (agency planning — four content types, an `entry.beforeSave` filter, two `stats` panels, a `social_results` table, and a link from each plan to its core delivery post), `google`, `square`. `commerce` is labeled Ecommerce and retains the original catalog unchanged. It adds `shopproduct` (editorial pages) and `shop` (introduction/policies). `square` requires `commerce`, is opt-in, and uses Square as the price, stock, product, and order authority. Its browser-bound OAuth stores encrypted tokens; its server-to-server storefront API requires a delivery key scoped to shopproduct. `GET /ext/square/products` reads published bound pages plus current Square data. `POST /ext/square/checkout` accepts item IDs, quantities, and an idempotency key, validates them, and creates a Square-hosted payment link. No card data enters Inkling. Supports fixed-price, whole-quantity physical products with flat shipping; stock checks do not reserve stock. Operator environment configuration and a real Sandbox checkout are required before opening a live shop. See https://wess.io/inkling/commerce.md for the full contract and limits. Posting is not a plugin; see Social below. `google` is deliberately split in half, because Google sells it as one thing and it is two. Pasting a Measurement ID under Google → Setup puts Analytics on the site: five minutes, no Cloud project, nothing to approve, and `GET /ext/google/tag` serves the generated snippet to a front end holding a delivery key. Reading those numbers back into Inkling's own panels is the other half — a Cloud project, an OAuth client, and a consent screen — and it is marked optional everywhere it appears. Ads reporting needs one credential more (a developer token, issued against a manager account, starting on test access) and the `adwords` scope is only requested once that token exists. The generic authorization-code + PKCE machinery is `src/oauth/index.ts` — sealed `state` rather than a pending-flows table, ten-minute expiry, form-encoded token request with a JSON retry, Basic auth for the providers that demand it. `src/ai/oauth.ts` and `src/social/oauth.ts` are both thin adapters over it. Three optional fields on `OAuthClient` exist for TikTok alone — `clientParam` (`client_key`), `scopeSeparator` (`,`), and `basicAuth: false` — so the module still knows nothing about what is being authorized. ## Social `src/social` connects accounts and posts to **X, Facebook, Instagram, Threads, LinkedIn, TikTok, YouTube, Pinterest, Google Business**. Core rather than a plugin for two reasons: a plugin's `setInterval` outlives its own disable switch (so it cannot own the 60s send sweep), and the admin bundle is built before any plugin exists (so it cannot ship a composer). A network is in `src/social/networks.ts` only if it has a publisher in `src/social/publishers/`; a test asserts the two lists match. Networks are set up in the admin (Social → Settings), not in the environment: `social_apps` holds a client id, a sealed secret, and an `enabled` switch per network, with `SOCIAL_OAUTH__*` read as a fallback for any network with no row. `apps.ts#clientFor` is the only thing that knows there are two sources. Set up and switched on are separate states, because an operator mid-review wants credentials saved and the network not yet offered. Each row's "?" opens the walkthrough in `src/social/guides.ts`, shipped in the payload rather than the bundle so a moved console is a server-side fix. Four tables, four lifetimes. `social_apps` holds the developer app. `social_accounts` holds sealed OAuth tokens (same AES-GCM helper as AI credentials; `accounts.ts#accessToken` is the only door that opens one — it renews inside a ten-minute window, records the network's own error when that fails, and returns null rather than throwing). `social_posts` is the copy. `social_targets` is one row per (post, account): the wording that network got, what it did with it, and where it landed. That split is the design. A post's `status` is a *roll-up* of its targets, and `partial` exists because "X took it, TikTok did not" is the common real outcome and neither success nor failure describes it. `publish.ts#send` runs each target independently, skips any already `posted` (so a retry never double-posts), and records the network's own error text verbatim — that sentence is the whole diagnosis and a summary of ours would be strictly worse. Transient failures remain pending with persisted attempt counts and backoff times; permanent failures wait for an operator. `network` is denormalized onto the target so a disconnected account cannot take the record of where a post went with it. `publishDue` runs every 60s: scheduled posts whose time has come, plus anything stuck in `publishing` for 15 minutes by a process that died. Manual publish can override a target's backoff, while automatic sweeps respect it. `claim` is the lock — an UPDATE matching on the status it expects to replace. Per-network in `src/social/publishers/`: X chunks and polls a video transcode (`media.write` scope, or every attachment fails); Facebook posts to a *Page* (the Page token is traded for at connect time); Instagram needs a Business account on that Page and is container-then-publish, a carousel being that per child plus once for the album; Threads is the same shape on a different host from a separately registered app; LinkedIn registers an upload slot per attachment and 426s without `x-restli-protocol-version`; TikTok uses `FILE_UPLOAD` (pulling needs a domain verified in their console) and answers `error.code === "ok"` on success; YouTube uses a resumable session and wants its own title; Pinterest pushes video bytes to S3 with Pinterest's policy fields and needs a cover still it will not generate — the only network where an image beside a video means anything (`coverImage` on the media rule); Google Business posts to one *location* on the legacy v4 host while discovery runs on the modern ones. Five of the nine *fetch* media rather than being handed it — Facebook, Instagram, Threads, Pinterest, Google Business — so they are the publishers a localhost `PUBLIC_URL` breaks, checked explicitly rather than left to read as the network's fault. Three permissions rather than two, because sending is irreversible in a way publishing an entry is not: `writeSocial` (author) writes, `publishSocial` (editor) sends, `manageSocial` (admin) connects. Everything a network will refuse is checked at *save* time, not send time. ## Gotchas that cost real bugs 1. **Never use camelCase SQL column names.** Identifiers are emitted unquoted; Postgres folds them to lowercase and SQLite does not, so the same column comes back under two different keys. Field *keys* inside an entry's `data` JSON are a different thing and are camelCase — they never touch SQL. 2. **Don't hand a whole multi-statement `.sql` file to `db.execute`.** SQLite runs only the first statement and reports success. `src/migrate` splits them. 3. **Aggregates and joins need the string-table form** (`from("entries", "e")`), through `rows()` / `one()` / `countRows()` in `src/db/dialect.ts`. Avoid `.distinct()` — it compiles to Postgres-only syntax. 4. **`parseMultipart` yields `{ fields, files }`**, not one flat body. 5. **Media must set `Cross-Origin-Resource-Policy: cross-origin`**, or every `` on a consuming site fails silently on a valid 200. 6. **`await` inside a `.where()` callback is a syntax error** — the predicate is synchronous. 7. **Don't collect ids into an `IN (…)` list** for anything unbounded. One bound parameter per row means a popular filter eventually exceeds the driver's parameter ceiling. Join instead. 8. **The admin fallback keys on Atlas answering an unmatched path with a plain-text 404**, while every `HttpError` renders as JSON. Change that and the admin stops loading; `tests/routing.test.ts` asserts it. 9. **`withSecurityHeaders` is not just headers** — it stashes the socket peer on the request, and `src/security#clientIp` reads only that. Unwrap it, or fail to pass Bun's `server` into `inkling.fetch`, and `clientIp` returns `""` for every request: the per-IP login limit becomes one global bucket that any single client can exhaust for every account. `tests/security.test.ts` asserts it. ## Files - [Architecture](https://github.com/wess/inkling/blob/main/docs/ARCHITECTURE.md) — module layout, data model, dialect portability, plugins, realtime, previews, AI - [README](https://github.com/wess/inkling/blob/main/README.md) — quick start and a worked delivery example - [Agent operations](https://wess.io/inkling/agent-guide.md) — MCP setup, discovery order, production safety, and failure handling - [Delivery API](https://wess.io/inkling/delivery.md) — published-content routes, response shapes, caching, and realtime invalidation - [.env.example](https://github.com/wess/inkling/blob/main/.env.example) — every configuration variable, documented in place