# 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. Named `llms.txt` after the llmstxt.org convention, so agents find it at `/llms.txt` without being told. This file is the fast path: it is written to be read whole, and it links to the long documents rather than repeating them. ## 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. - 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 `bunx tsc --noEmit` and `bun test` — both 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. ## 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/webhooks`, `/api/search`, `/api/stats`, `/api/audit`, `/api/plugins`, `/api/ai/*`, `/api/realtime/ticket`. A new session-gated route goes inside the `prefixed("/api", […])` block in `src/server.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. ## 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.ts`, 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, site settings, and menus. 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 and does not render the site — so colours, fonts, and layout are the consuming site's code, not Inky's. 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. - `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. **Every agent tool is a read, and adding a write one is a bug.** The agent cannot write and no flag makes it able to. Six read — `list_content_types`, `list_entries`, `get_entry`, `list_media`, `get_site_settings`, `list_menus` — and seven record an intention: `propose_entry_update`, `propose_entry_create`, `propose_entry_status`, `propose_type_update`, `propose_type_create`, `propose_settings_update`, `propose_menu_update`. The admin renders a proposal as a diff and applies it through the ordinary route a human edit takes — so revisions, validation, slug uniqueness, relation checks, hooks, and the audit trail keep working and the history names the person who approved it. `tests/aiagent.test.ts` asserts the tool list contains nothing but reads and proposals. 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 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. Five kinds: `settings`, `collection`, `table`, `stats`, `connections`. 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. Bundled: `seo`, `redirects`, `forms`, `commerce`, `analytics`, `assistant`, `social` (four content types, an `entry.beforeSave` filter, two `stats` panels, a `connections` panel, and its own `social_results` and `social_accounts` tables). Social accounts are OAuth'd per network with clients from `SOCIAL_OAUTH__CLIENT_ID`; tokens are sealed with the same AES-GCM helper as AI credentials, and `accounts.ts#accessToken` is the only door that opens one — it refreshes inside a five-minute window, records the provider's own error when that fails, and returns null rather than throwing. **It still posts to no network**: a connection is half of publishing, and the per-network call is deliberately not built until it can be built one network at a time. 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 `plugins/social/oauth.ts` are both thin adapters over it. ## 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 - [CLAUDE.md](https://github.com/wess/inkling/blob/main/CLAUDE.md) — conventions and commands - [.env.example](https://github.com/wess/inkling/blob/main/.env.example) — every configuration variable, documented in place