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.

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.

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.

What Inky can do

Thirteen tools: six read, seven propose. Nothing writes. A proposal is a record the admin renders as a diff and applies through the same route your own edit takes.

ToolKindDoes
list_content_typesreadThe model — every type and its fields.
list_entriesreadEntries of a type, filtered and paged.
get_entryreadOne entry in full.
list_mediareadThe library, so it can pick an existing image.
get_site_settingsreadTitle, tagline, description, logo, social image.
list_menusreadYour navigation trees.
propose_entry_updateproposeChange what a page says.
propose_entry_createproposeDraft a page that does not exist yet.
propose_entry_statusproposePublish, unpublish, or return something to draft.
propose_type_updateproposeAdd or change a field — which affects every page of that kind, and it says so.
propose_type_createproposeA new kind of page altogether.
propose_settings_updateproposeSite details.
propose_menu_updateproposeNavigation.

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.jsThe bubble. 404s until you enable it.

Social accounts

The social plugin's Accounts panel, which authorizes one account per network. Tokens are sealed the same way AI credentials are, and renewed before they lapse.

RouteReturns
GET /ext/social/accountsEvery connectable network, whether an app is registered, and what is held.
POST /ext/social/accounts/:network/startA 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.
GET /ext/social/oauth/callbackThe return leg. Public, because the browser arrives by top-level navigation with no session.
DELETE /ext/social/accounts/:idDisconnect.
Connecting is not posting

These hold accounts; nothing publishes to a network yet. Each network wants a different call, a different media upload, and a different set of failures, so publishers get built one at a time — meanwhile, stage a post Posted and paste the link.

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
SECRETSigns sessions, seals credentials. 32+ characters, or production refuses to boot.
NODE_ENVdevelopmentMust not be development in production.
DELIVERY_ORIGINSBrowser origins allowed to call delivery. Server-to-server does not need it.
TRUSTED_PROXIESCIDRs whose X-Forwarded-For is believed. Set it behind a load balancer.

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_IDRead only when the social plugin is on. Same shape, same reason — a network's OAuth app is registered against a redirect URI on your domain. Callback: PUBLIC_URL + /ext/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 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.