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.

RouteReturns
GET /contentThe types this key may read, with their field shapes.
GET /content/:typePublished entries, paged.
GET /content/:type/:slugOne published entry.
GET /content/:type/singleThe entry of a single-kind type.
GET /site/settingsSite settings, with media ids resolved to URLs.
GET /site/menus/:nameOne menu tree.
GET /preview/:tokenOne entry at any status, until the token expires.
POST /realtime/delivery/ticketA 30-second, single-use ticket for the socket.

Query parameters

ParameterEffect
?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,authorAttach taxonomy terms, and the author.
Expansion is automatic

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)
DefinitionMeaning
sectionsOrdered section definitions with a stable id, readable label, CSS selector, and editable fields.
sections[].movableSet to false to keep that section in its original position.
sections[].collectionOptional { type, label } linking to shared records used by the section, such as team members.
fieldsField-key-to-selector map for clicking text or pictures. $title means the entry title.
referencesOptional field-key-to-{ type, label } map. Displays a searchable entry picker and stores the chosen entry's slug in that field.
formattedOptional 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.__layoutSaved { 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" } }
TopicWho may subscribe
siteSessions 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.

TypeHolds
textA single line.
textareaPlain multi-line text.
richtextFormatted writing, stored as portable, cleaned HTML.
markdownMarkdown source, stored as written.
numberA number, with optional bounds.
booleanTrue or false.
dateA calendar date.
datetimeA moment, ISO-8601.
selectOne of a declared set of options.
multiselectSeveral of them.
mediaOne image, expanded on delivery.
galleryOrdered images, expanded on delivery.
referenceEntries of a declared target type, enforced on save.
listA repeater with its own fields. Nests, and validates recursively.
jsonAn arbitrary document, for the shape nothing else fits.
colorA colour value.
urlA URL, validated as one.
emailAn address, validated as one.
Keys are camelCase

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.

RoleCan
viewerRead.
authorWrite their own drafts.
editorPublish, and work on anyone's content.
adminShape the model, manage users and keys.
ownerEverything. 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.

CredentialHeld byReaches
Session tokenA person, in a browserEverything their role allows.
inkagt_…A programIts grants, capped by its account's role.
ink_…A websitePublished 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.

ProviderEndpointConnect with
Claudeapi.anthropic.comAPI key, or OAuth if a client is registered.
OpenAIapi.openai.comAPI key.
Ollama (local)http://127.0.0.1:11434, overridableNothing — it is your machine.
Ollama Cloudhttps://ollama.com, fixedAPI 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.

ToolKindNeedsDoes
list_content_typesreadcontent.readThe model — every type and its fields.
list_entriesreadcontent.readEntries of a type, with offset pagination and up to eight selected data fields. Publication status is separate from availability stored in data.
search_sitereadcontent.readFind a page by title or a file by its name, alt text, caption, or folder.
get_entryreadcontent.readOne entry in full, including what it is filed under.
get_page_layoutreadcontent.readDeclared visual sections, which can move, and the current order and hidden sections. Available when the host connects visual pages.
list_revisionsreadcontent.readSaved history of one entry, newest first.
list_trashreadcontent.writeDeleted entries that can be restored.
list_mediareadcontent.readSearches filenames, alt text, captions, and folders; can browse older files with an offset.
list_taxonomiesreadcontent.readCategories, tags, and every term in each.
get_site_settingsreadcontent.readTitle, tagline, description, logo, social image.
get_designreadsettings.manageHost-declared design surfaces, supported properties, and current overrides.
list_menusreadcontent.readYour navigation trees.
list_pluginsreadplugins.manageWhat is installed, what is on, and how each is configured.
list_peoplereadusers.manageEveryone with an account, and the role each holds.
list_delivery_keysreadkeys.manageWhich sites read this one, and how far each may reach.
list_webhooksreadwebhooks.manageWhere this site posts on a change, and how the last one went.
get_social_setupreadsocial.managePer network: app registered, credentials saved, account connected.
get_social_guidereadsocial.manageThe real steps through that network's console, and the step everybody misses.
list_social_postsreadsocial.writeDrafts, what is scheduled, and what already went out.
propose_entry_updateproposecontent.writeChange page content or its declared section layout through data.__layout. Unknown fields, invalid values, and unknown section IDs are refused before review.
propose_revision_restoreproposecontent.writeRestore an entry's earlier saved version.
propose_entry_untrashproposecontent.writeRestore a deleted entry from the trash.
propose_entry_createproposecontent.writeDraft a page that does not exist yet.
propose_entry_statusproposecontent.write, or content.publish to go livePublish, unpublish, or return something to draft.
propose_entry_deleteproposecontent.writeMove a page to the trash, where it stays restorable.
propose_entry_termsproposecontent.writeFile a page under categories or tags.
propose_type_updateproposetypes.manageAdd or change a field — which affects every page of that kind, and it says so.
propose_type_createproposetypes.manageA new kind of page altogether.
propose_media_updateproposemedia.manageAlt text, captions, and folders on files already uploaded.
propose_taxonomy_createproposetaxonomy.manageA new way of filing content.
propose_term_createproposetaxonomy.manageOne category or tag inside an existing set.
propose_settings_updateproposesettings.manageSite details.
propose_design_changeproposesettings.manageChange supported properties on declared surfaces, or remove an override to restore the original design.
propose_menu_updateproposemenus.manageNavigation.
propose_menu_createproposemenus.manageA second menu — a footer, a sidebar.
propose_menu_deleteproposemenus.manageRemove one outright, which is not recoverable.
propose_plugin_stateproposeplugins.manageSwitch a plugin on or off.
propose_plugin_settingsproposeplugins.manageConfigure one, within the keys it declares.
propose_person_roleproposeusers.manageMove somebody up or down the ladder, never above yourself.
propose_delivery_keyproposekeys.manageMint a key for a website. Shown once, on apply.
propose_webhook_createproposewebhooks.manageTell another system when content moves. Secret shown once.
propose_webhook_updateproposewebhooks.manageRepoint one, change its events, or switch it off.
propose_social_appproposesocial.manageSave a network's client ID and secret, so its Connect button works.
propose_social_postproposesocial.write, or social.publish to scheduleDraft a post to the accounts that are connected.
open_screenmovecontent.readTake 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

RouteAuthReturns
POST /ext/assistant/askX-Api-KeyAn answer plus its sources, for a server relaying its visitors' questions.
POST /ext/assistant/public-askOrigin allowlistThe 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.

RouteNeedsReturns
GET /api/social/overviewauthorCounts by status, what goes out next, what went out last, and every connection wanting attention.
GET /api/social/networksauthorEvery network Inkling can post to, its limits and options, and the accounts live on it. What the composer draws itself from.
GET /api/social/postsauthorPaged, filtered by ?status=.
POST /api/social/postsauthorCreates a post and one target per account. Refuses anything a selected network would — see below.
PUT /api/social/posts/:idauthorA target that already posted keeps its copy and its link; the rest are rewritten.
DELETE /api/social/posts/:idauthorSoft delete. What has already gone out stays on the networks.
POST /api/social/posts/:id/scheduleeditorTakes { at }. Refuses a time that has passed.
POST /api/social/posts/:id/publisheditorSends 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/canceleditorCalls a scheduled post back to draft.
GET /api/social/calendarauthorEverything scheduled in a window, from ?from= for ?days=.
GET /api/social/settingsadminEvery 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/:networkadminClient id, secret, enabled switch, and endpoint overrides. An absent clientSecret keeps the stored one; an empty string clears it.
DELETE /api/social/settings/:networkadminForgets the app. Accounts already connected with it keep working until their tokens lapse.
GET /api/social/accountsadminEvery network, whether an app is set up for it, and what is connected.
POST /api/social/accounts/:network/startadminA 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/:idadminDisconnect.
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.

Networks are set up in the admin

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.

An outcome is per network, not per post

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.

Plans and delivery have one boundary

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

VariableDefaultNotes
PORT4300Not 4000 — Docker Desktop binds that on IPv6 and wins localhost.
HOST0.0.0.0
PUBLIC_URLhttp://localhost:4300The one public origin. Media URLs resolve against it.
DATABASE_URLlocal PostgresAlso the unit of separation between sites.
DB_POOL_SIZE5
SECRET—Signs sessions, seals credentials. 32+ characters, or production refuses to boot.
NODE_ENVdevelopmentMust 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_PRIVATEfalseWebhook 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

VariableNotes
STORAGE_DRIVERlocal or s3.
STORAGE_LOCAL_DIRWhere blobs land on the local driver.
S3_ENDPOINT, S3_BUCKET, S3_REGIONAny S3-compatible service.
S3_ACCESS_KEY, S3_SECRET_KEYCredentials.
S3_PUBLIC_URLPublic base for objects, if it differs from the endpoint.
MAX_UPLOAD_BYTESDefault 25 MB.

Plugins, bootstrap, and AI

VariableNotes
PLUGIN_DIRWhere plugins are scanned from.
PLUGIN_AUTOENABLEComma-separated names enabled on a fresh install.
BOOTSTRAP_EMAIL, BOOTSTRAP_PASSWORD, BOOTSTRAP_NAMECreates the first owner unattended. Otherwise the first visit claims the site.
AI_OAUTH_<PROVIDER>_CLIENT_IDThe only environment variables the AI feature has. Without one, only the API-key path is offered.
AI_OAUTH_<PROVIDER>_CLIENT_SECRETPaired with the id.
AI_OAUTH_<PROVIDER>_AUTHORIZE_URLOverrides the shipped default, and is how you wire up a provider with no default at all.
AI_OAUTH_<PROVIDER>_TOKEN_URLAs above.
AI_OAUTH_<PROVIDER>_SCOPESComma-separated.
SOCIAL_OAUTH_<NETWORK>_CLIENT_IDSame 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_SECRETPaired with the id.
SOCIAL_OAUTH_<NETWORK>_AUTHORIZE_URL, _TOKEN_URL, _SCOPESOverride 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.

CommandDoes
bun installInstall dependencies.
bun run devEverything on :4300, hot-reloading.
bun run startProduction entry — the same single process.
bun run testThe test suite, on in-memory SQLite. No setup.
bun run typechecktsc --noEmit.
bun run tidyFormat and lint, with fixes.
bun run docsValidate every documentation page, local link, and anchor.
bun run passwordSet 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.