Reference
Look it up
The delivery surface your sites call, the shapes your content can take, and every variable that configures it.
Delivery API
Authenticated with X-Api-Key. Read-only, published content
only. Responses are private-cacheable and vary on the credentials that
shape them.
| Route | Returns |
|---|---|
GET /content | The types this key may read, with their field shapes. |
GET /content/:type | Published entries, paged. |
GET /content/:type/:slug | One published entry. |
GET /content/:type/single | The entry of a single-kind type. |
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, single-use ticket for the socket. |
Query parameters
| Parameter | Effect |
|---|---|
?page=, ?limit= | Paging. meta carries total, page, and limit. |
?term= | Filter by taxonomy term slug, resolved through a join. |
?locale= | Filter by locale. |
?sort= | Order the list. |
?include=terms,author | Attach taxonomy terms, and the author. |
Media and reference fields arrive as full objects, not ids — a page render is one request. Referenced entries are re-checked for publication and against this key's type scopes, so a reference cannot return something the key could not have asked for directly.
Visual page editor
An embedded site opts in by passing visual to
createInkling. The keys are content-type names. The editor
uses the site's existing page templates and ordinary content fields.
Types without a definition keep the field editor.
import { createInkling } from "inkling"
import { renderVisual, type VisualPages } from "inkling/visual"
const visual = {
homepage: {
sections: [{
id: "intro", label: "Introduction", selector: "main > #intro",
fields: ["heading", "image"],
}],
fields: { heading: "#intro h1", image: "#intro img" },
},
} satisfies VisualPages
const inkling = await createInkling({ adminBase: "/admin", visual })
// In the site's page renderer, after loading the correct entry:
const body = renderVisual(html, visual.homepage, entry.data.__layout, editing)| Definition | Meaning |
|---|---|
sections | Ordered section definitions with a stable id, readable label, CSS selector, and editable fields. |
sections[].movable | Set to false to keep that section in its original position. |
sections[].collection | Optional { type, label } linking to shared records used by the section, such as team members. |
fields | Field-key-to-selector map for clicking text or pictures. $title means the entry title. |
references | Optional field-key-to-{ type, label } map. Displays a searchable entry picker and stores the chosen entry's slug in that field. |
formatted | Optional field keys storing inline HTML. The visual editor shows text with italic and line-break controls; the stored string retains only text, i, and br markup. |
entry.data.__layout | Saved { order: string[], hidden: string[] } using section ids. Saved and revised with the entry. |
renderVisual(html, definition, layout, editing = false)
returns HTML. It moves declared sibling sections and removes hidden ones
on the public page. With editing: true, hidden sections remain
visible to the editor, and fields and sections receive selection attributes.
Enable this mode only inside a valid preview request with
visual=1. The admin fetches that page into a sandboxed canvas;
it does not run the site's scripts or submit forms. An isolated, hash-pinned
editor bridge handles selection in the preview, including Safari. If its
controls cannot start, the canvas offers Reload preview.
Unsaved previews
Selecting a shared header or footer element keeps the same page open and shows its controls beside the canvas. Other page elements remain selectable. Unsaved page edits are preserved; unsaved shared edits require Save and switch, Keep editing, or Discard and switch. The sidebar lists configured singleton pages under Pages and their item lists under Content.
Double-click text or an image to open its editing controls. Right-click, or use the visible Actions button, for Edit, Ask Inky, and the selected section's available move, hide, or collection actions. These actions preserve the explicit save flow. Ask Inky opens beside the selected element on desktop and names that target in the conversation; narrow screens use the corner panel.
POST /api/entries/:id/preview requires permission to edit the
entry. Send an optional JSON body { title, slug, data } to
preview unsaved changes. Without a body, the link reads the saved entry.
The response contains token, expiresAt,
url, and siteUrl from the content type's preview
URL template. Previewing an existing entry does not save or publish it.
The host reads the preview query parameter and fetches
GET /preview/:token, substituting that single entry for this
request only. Send no-store and
X-Robots-Tag: noindex, nofollow on the rendered page.
Links last up to one hour. Unsaved snapshots also expire after a restart
or eviction: the store holds at most 100 snapshots, 16 MiB total, and
1 MiB per snapshot. Deleting the source entry invalidates its preview.
Refresh after saving
inkling.contentVersion() returns a process-local counter that
changes after content mutations, including scheduled publication. Clear
host delivery caches when it changes. Capture the version before an
asynchronous read and cache the response only if the version still matches.
Set public page cache headers to revalidate after edits. Separate processes
should use realtime or webhooks for invalidation.
Socket frames
Open ws://host/realtime?ticket=… after exchanging a credential
for a ticket. A browser cannot set headers on a handshake and query strings
reach access logs, so the ticket is single-use and dies in 30 seconds.
→ client { "action": "subscribe", "topic": "content:post" } { "action": "unsubscribe", "topic": "content:post" } { "action": "ping" } ← server { "topic": "content:post", "event": "entry.published", "data": { "id": "…", "slug": "hello", "type": "post" } }
| Topic | Who may subscribe |
|---|---|
site | Sessions and delivery keys. |
content:<type> | Sessions, and keys scoped to that type. |
entry:<id> | Sessions only — it carries presence, which is editorial signal. |
Server frames also include ready, subscribed,
pong, and error. A key hears only
entry.published, entry.unpublished, and
entry.deleted, and frames never carry content — re-read through
/content when one arrives.
Field types
Eighteen. Each one declares how it validates, what its empty value is, and what the editor renders.
| Type | Holds |
|---|---|
text | A single line. |
textarea | Plain multi-line text. |
richtext | Formatted writing, stored as portable, cleaned HTML. |
markdown | Markdown source, stored as written. |
number | A number, with optional bounds. |
boolean | True or false. |
date | A calendar date. |
datetime | A moment, ISO-8601. |
select | One of a declared set of options. |
multiselect | Several of them. |
media | One image, expanded on delivery. |
gallery | Ordered images, expanded on delivery. |
reference | Entries of a declared target type, enforced on save. |
list | A repeater with its own fields. Nests, and validates recursively. |
json | An arbitrary document, for the shape nothing else fits. |
color | A colour value. |
url | A URL, validated as one. |
email | An address, validated as one. |
Field keys live inside a JSON document and never become database columns,
so heroImage is right and hero_image is not.
Database columns are the opposite, and deliberately so.
Roles
A strict ladder. Every request re-reads the user, so a demotion takes effect on the next click rather than at token expiry.
| Role | Can |
|---|---|
viewer | Read. |
author | Write their own drafts. |
editor | Publish, and work on anyone's content. |
admin | Shape the model, manage users and keys. |
owner | Everything. The last one cannot be deleted. |
Admins cannot create, edit, or promote above their own rank. API keys are stored only as SHA-256 and the plaintext is shown exactly once.
Agent keys
How a program signs in — an MCP server, a build script, an automation — instead of holding somebody's password. Mint one under Agent keys, ticking what it may do. Anyone can mint one for themselves; it can never exceed them.
| Credential | Held by | Reaches |
|---|---|---|
| Session token | A person, in a browser | Everything their role allows. |
inkagt_… | A program | Its grants, capped by its account's role. |
ink_… | A website | Published content, read-only. |
The three are never interchangeable: an agent key is refused on the delivery API and a delivery key is refused on the admin API, so a website key that ends up in a repository is not a way in.
An agent key can do nothing administrative — no adding a user, minting an API key, connecting a social account, installing a plugin, or spending the AI provider's budget — whatever you tick and whatever role you hold. Those capabilities are not merely off by default; there is no key that can hold them. It expires (90 days by default, 365 at most), it is revocable on its own without changing a password or signing anyone out, and demoting the account narrows every key it minted on the next request. Minting asks for the password again, so a borrowed browser cannot quietly make one.
Shared website parts
Pass a website manifest to createInkling() to connect the header, footer, logo, announcement, and other shared details to their existing sources. The admin provides one Header & footer screen and selectable shared parts inside visual page previews. Inky receives the same map.
const website = {
previewUrl: "/",
parts: [{
id: "navigation", label: "Top navigation",
description: "The menu across every page", selector: "header nav",
source: {
kind: "menu", name: "main", label: "Main navigation",
defaults: [{ label: "Home", url: "/" }],
},
}, {
id: "contact", label: "Contact details",
description: "The address in every footer", selector: "footer address",
source: { kind: "entry", type: "house", fields: ["street", "email"] },
}, {
id: "logo", label: "Logo", description: "Your website logo",
selector: ".brand", source: { kind: "settings", fields: ["logoId"] },
}],
}
const inkling = await createInkling({ adminBase: "/admin", website })GET /api/website requires content read access. Entry sources require an existing singleton. Each save uses the usual entry, menu, or settings route and its permissions; only changed fields belonging to that part are sent. Shared entry drafts remain drafts. The preview is same-origin and refreshes after saving. A missing menu starts with declared fallback links and is first created when an editor saves; the template must read the declared menu name. The shared menu editor edits a flat list. Nested menus remain available in All menus for sites that render them.
Choose a page searches published entries whose content type has a preview address. Menu delivery resolves their entryId references to url using that address template, excluding draft, deleted, or out-of-scope destinations. Existing address links are preserved.
Give independently editable elements their own parts: a badge image inside an announcement can map just its image field. Selection uses the closest matching element. Switching shared parts with unsaved work offers Save and switch, Keep editing, or Discard and switch. A failed save preserves the current controls and shows its error beside the save actions.
AI providers and agent tools
Every AI surface — the field assistant, Inky, and the visitor bubble — runs on one connected provider. Credentials are entered in the admin and stored sealed; none of them is an environment variable.
| Provider | Endpoint | Connect with |
|---|---|---|
| Claude | api.anthropic.com | API key, or OAuth if a client is registered. |
| OpenAI | api.openai.com | API key. |
| Ollama (local) | http://127.0.0.1:11434, overridable | Nothing — it is your machine. |
| Ollama Cloud | https://ollama.com, fixed | API key. No URL field, because there is no choice to make. |
Model names are typed, not chosen from a closed list — the suggestions are a starting point. Inky needs a model that can call tools; the field assistant does not.
Test Inky checks streamed tool calling with a synthetic marker, without reading or changing content. It verifies the connection and transport, not the quality of every future answer. Stream failures remain in the conversation; an interrupted stream cannot silently report success. Applying a proposal refreshes a clean open editor. Unsaved manual edits must be saved or discarded before applying a proposal.
Working through the ChatGPT connector uses the editor's own ChatGPT account. Using ChatGPT plan usage to power Inky on a remotely hosted website requires an approved OpenAI integration; the local open-source sign-in flow is not a hosted website login. See the current integration requirements. A normal OpenAI API key uses separate API billing.
What Inky can do
Forty-five tools: nineteen read, twenty-five propose, one moves the admin. Nothing writes. A proposal is a record the admin renders as a diff and applies through the same route your own edit takes — so it is validated, revisioned where a revision exists, and recorded in Activity as your change rather than a machine's.
Each tool names the permission its proposal will need, and the list is filtered to what your role could actually apply. An author is never offered the site settings; an editor is never offered delivery keys. The panel greys a single card it knows will be refused rather than the whole tray.
| Tool | Kind | Needs | Does |
|---|---|---|---|
list_content_types | read | content.read | The model — every type and its fields. |
list_entries | read | content.read | Entries of a type, with offset pagination and up to eight selected data fields. Publication status is separate from availability stored in data. |
search_site | read | content.read | Find a page by title or a file by its name, alt text, caption, or folder. |
get_entry | read | content.read | One entry in full, including what it is filed under. |
get_page_layout | read | content.read | Declared visual sections, which can move, and the current order and hidden sections. Available when the host connects visual pages. |
list_revisions | read | content.read | Saved history of one entry, newest first. |
list_trash | read | content.write | Deleted entries that can be restored. |
list_media | read | content.read | Searches filenames, alt text, captions, and folders; can browse older files with an offset. |
list_taxonomies | read | content.read | Categories, tags, and every term in each. |
get_site_settings | read | content.read | Title, tagline, description, logo, social image. |
get_design | read | settings.manage | Host-declared design surfaces, supported properties, and current overrides. |
list_menus | read | content.read | Your navigation trees. |
list_plugins | read | plugins.manage | What is installed, what is on, and how each is configured. |
list_people | read | users.manage | Everyone with an account, and the role each holds. |
list_delivery_keys | read | keys.manage | Which sites read this one, and how far each may reach. |
list_webhooks | read | webhooks.manage | Where this site posts on a change, and how the last one went. |
get_social_setup | read | social.manage | Per network: app registered, credentials saved, account connected. |
get_social_guide | read | social.manage | The real steps through that network's console, and the step everybody misses. |
list_social_posts | read | social.write | Drafts, what is scheduled, and what already went out. |
propose_entry_update | propose | content.write | Change page content or its declared section layout through data.__layout. Unknown fields, invalid values, and unknown section IDs are refused before review. |
propose_revision_restore | propose | content.write | Restore an entry's earlier saved version. |
propose_entry_untrash | propose | content.write | Restore a deleted entry from the trash. |
propose_entry_create | propose | content.write | Draft a page that does not exist yet. |
propose_entry_status | propose | content.write, or content.publish to go live | Publish, unpublish, or return something to draft. |
propose_entry_delete | propose | content.write | Move a page to the trash, where it stays restorable. |
propose_entry_terms | propose | content.write | File a page under categories or tags. |
propose_type_update | propose | types.manage | Add or change a field — which affects every page of that kind, and it says so. |
propose_type_create | propose | types.manage | A new kind of page altogether. |
propose_media_update | propose | media.manage | Alt text, captions, and folders on files already uploaded. |
propose_taxonomy_create | propose | taxonomy.manage | A new way of filing content. |
propose_term_create | propose | taxonomy.manage | One category or tag inside an existing set. |
propose_settings_update | propose | settings.manage | Site details. |
propose_design_change | propose | settings.manage | Change supported properties on declared surfaces, or remove an override to restore the original design. |
propose_menu_update | propose | menus.manage | Navigation. |
propose_menu_create | propose | menus.manage | A second menu — a footer, a sidebar. |
propose_menu_delete | propose | menus.manage | Remove one outright, which is not recoverable. |
propose_plugin_state | propose | plugins.manage | Switch a plugin on or off. |
propose_plugin_settings | propose | plugins.manage | Configure one, within the keys it declares. |
propose_person_role | propose | users.manage | Move somebody up or down the ladder, never above yourself. |
propose_delivery_key | propose | keys.manage | Mint a key for a website. Shown once, on apply. |
propose_webhook_create | propose | webhooks.manage | Tell another system when content moves. Secret shown once. |
propose_webhook_update | propose | webhooks.manage | Repoint one, change its events, or switch it off. |
propose_social_app | propose | social.manage | Save a network's client ID and secret, so its Connect button works. |
propose_social_post | propose | social.write, or social.publish to schedule | Draft a post to the accounts that are connected. |
open_screen | move | content.read | Take you to a screen. The only tool that acts rather than proposes. |
What still needs your hands
Four things, and Inky takes you to the screen rather than describing the route: uploading a file, creating an account (a password has to be typed), pressing Connect on a social network (the consent screen is on the network's own domain), and pasting a client secret. Inky will ask for a client ID in conversation but is told never to ask for a secret — it is a password, and a chat window carries it further than it needs to go.
Visitor bubble
| Route | Auth | Returns |
|---|---|---|
POST /ext/assistant/ask | X-Api-Key | An answer plus its sources, for a server relaying its visitors' questions. |
POST /ext/assistant/public-ask | Origin allowlist | The same answer, straight from a browser — no key, because a key in a browser is a key given to everyone. |
GET /ext/assistant/widget.js | — | The bubble. 404s until you enable it. |
Social
Setting networks up, connecting accounts, and sending posts to them. Nine networks — X, Facebook, Instagram, Threads, LinkedIn, TikTok, YouTube, Pinterest, Google Business — each with a publisher behind it. Session-gated like the rest of the admin API, with three permissions rather than two: an author writes a post, an editor decides when it goes out, an admin sets up networks and connects the accounts.
| Route | Needs | Returns |
|---|---|---|
GET /api/social/overview | author | Counts by status, what goes out next, what went out last, and every connection wanting attention. |
GET /api/social/networks | author | Every network Inkling can post to, its limits and options, and the accounts live on it. What the composer draws itself from. |
GET /api/social/posts | author | Paged, filtered by ?status=. |
POST /api/social/posts | author | Creates a post and one target per account. Refuses anything a selected network would — see below. |
PUT /api/social/posts/:id | author | A target that already posted keeps its copy and its link; the rest are rewritten. |
DELETE /api/social/posts/:id | author | Soft delete. What has already gone out stays on the networks. |
POST /api/social/posts/:id/schedule | editor | Takes { at }. Refuses a time that has passed. |
POST /api/social/posts/:id/publish | editor | Sends now, and is also the retry — targets that already posted are skipped, transient failures back off automatically, and a manual retry overrides the backoff. |
POST /api/social/posts/:id/cancel | editor | Calls a scheduled post back to draft. |
GET /api/social/calendar | author | Everything scheduled in a window, from ?from= for ?days=. |
GET /api/social/settings | admin | Every network's developer app, where its credentials came from, and the walkthrough for its console. The secret is never in this payload — only its last four characters. |
PUT /api/social/settings/:network | admin | Client id, secret, enabled switch, and endpoint overrides. An absent clientSecret keeps the stored one; an empty string clears it. |
DELETE /api/social/settings/:network | admin | Forgets the app. Accounts already connected with it keep working until their tokens lapse. |
GET /api/social/accounts | admin | Every network, whether an app is set up for it, and what is connected. |
POST /api/social/accounts/:network/start | admin | A consent URL, not a redirect — the caller is a fetch, and a 302 to a third party would be followed by the fetch rather than the address bar. |
DELETE /api/social/accounts/:id | admin | Disconnect. |
GET /social/oauth/callback | — | The return leg. Public and root-mounted, because the browser arrives by top-level navigation carrying no bearer token. |
A post refused for a network's own rules comes back as
400 with { code: "SOCIAL_INVALID", details: { fields } },
the same shape the entry editor already marks inputs from. This is checked
when the post is saved, not when it is sent: a scheduled post that
turns out to be unpostable at 6am on a Saturday is a notification nobody
reads, and caption length, one-video-per-post, and images-or-a-video are
all knowable when it is written.
An OAuth client is registered with the network, against a redirect
URI on your domain, so nothing can be shipped in its place — but it does
not have to be an environment variable. social_apps holds a
client id, a sealed secret, and an enabled switch per
network; SOCIAL_OAUTH_<NETWORK>_* is read as a
fallback for any network with no row, so an install configured before
that screen existed keeps working.
Sending runs every target independently and records what each network did
on its own row, with that network's own error text next to it. A post X
took and TikTok refused is partial — neither a success nor a
failure, and the most common real result. The post's status
is a roll-up of its targets rather than something that is set.
The optional social plugin plans client work. Set a plan's
publishPostId to its core Social post; core owns scheduling,
delivery, retries, and target outcomes, while the plan mirrors the
resulting status and errors.
Configuration
.env is required at runtime, and
.env.example
documents every variable in place — it is the source of truth, and this
table is the summary.
Core
| Variable | Default | Notes |
|---|---|---|
PORT | 4300 | Not 4000 — Docker Desktop binds that on IPv6 and wins localhost. |
HOST | 0.0.0.0 | |
PUBLIC_URL | http://localhost:4300 | The one public origin. Media URLs resolve against it. |
DATABASE_URL | local Postgres | Also the unit of separation between sites. |
DB_POOL_SIZE | 5 | |
SECRET | — | Signs sessions, seals credentials. 32+ characters, or production refuses to boot. |
NODE_ENV | development | Must not be development in production. |
DELIVERY_ORIGINS | — | Browser origins allowed to call delivery. Server-to-server does not need it. |
TRUSTED_PROXIES | — | CIDRs whose X-Forwarded-For is believed. Set it behind a proxy — unset, every request looks like it came from the proxy, and the per-IP login limit becomes one global bucket a single client can exhaust for everybody. |
WEBHOOK_ALLOW_PRIVATE | false | Webhook targets on private, loopback, and link-local addresses are refused, and redirects are not followed. Turn it on only if your receiver really is internal. |
Storage
| Variable | Notes |
|---|---|
STORAGE_DRIVER | local or s3. |
STORAGE_LOCAL_DIR | Where blobs land on the local driver. |
S3_ENDPOINT, S3_BUCKET, S3_REGION | Any S3-compatible service. |
S3_ACCESS_KEY, S3_SECRET_KEY | Credentials. |
S3_PUBLIC_URL | Public base for objects, if it differs from the endpoint. |
MAX_UPLOAD_BYTES | Default 25 MB. |
Plugins, bootstrap, and AI
| Variable | Notes |
|---|---|
PLUGIN_DIR | Where plugins are scanned from. |
PLUGIN_AUTOENABLE | Comma-separated names enabled on a fresh install. |
BOOTSTRAP_EMAIL, BOOTSTRAP_PASSWORD, BOOTSTRAP_NAME | Creates the first owner unattended. Otherwise the first visit claims the site. |
AI_OAUTH_<PROVIDER>_CLIENT_ID | The only environment variables the AI feature has. Without one, only the API-key path is offered. |
AI_OAUTH_<PROVIDER>_CLIENT_SECRET | Paired with the id. |
AI_OAUTH_<PROVIDER>_AUTHORIZE_URL | Overrides the shipped default, and is how you wire up a provider with no default at all. |
AI_OAUTH_<PROVIDER>_TOKEN_URL | As above. |
AI_OAUTH_<PROVIDER>_SCOPES | Comma-separated. |
SOCIAL_OAUTH_<NETWORK>_CLIENT_ID | Same shape, same reason as the AI block — a network's OAuth app is registered against a redirect URI on your domain. X, FACEBOOK, TIKTOK, YOUTUBE. Callback: PUBLIC_URL + /social/oauth/callback. |
SOCIAL_OAUTH_<NETWORK>_CLIENT_SECRET | Paired with the id. |
SOCIAL_OAUTH_<NETWORK>_AUTHORIZE_URL, _TOKEN_URL, _SCOPES | Override the shipped defaults for that network. |
Provider credentials themselves are never environment variables — they are entered in the admin and stored sealed.
Commands
Bun is the runtime, package manager, and bundler. There is no node or npm step.
| Command | Does |
|---|---|
bun install | Install dependencies. |
bun run dev | Everything on :4300, hot-reloading. |
bun run start | Production entry — the same single process. |
bun run test | The test suite, on in-memory SQLite. No setup. |
bun run typecheck | tsc --noEmit. |
bun run tidy | Format and 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. Run with no arguments to list the accounts. The way back in when the only owner is locked out — every browser path needs a credential they no longer have. |
There is no build step for the API. Type-check and test are the whole verification path, and both must be clean.
Errors
Every error the API raises renders as JSON with a stable code.
{
"error": "Validation failed",
"code": "VALIDATION_FAILED",
"details": {
"fields": [{ "key": "summary", "message": "Required" }]
}
}Field errors name the key, so the editor can mark the specific input rather than showing one message for the whole form.