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.
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.
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.
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.
| Tool | Kind | Does |
|---|---|---|
list_content_types | read | The model — every type and its fields. |
list_entries | read | Entries of a type, filtered and paged. |
get_entry | read | One entry in full. |
list_media | read | The library, so it can pick an existing image. |
get_site_settings | read | Title, tagline, description, logo, social image. |
list_menus | read | Your navigation trees. |
propose_entry_update | propose | Change what a page says. |
propose_entry_create | propose | Draft a page that does not exist yet. |
propose_entry_status | propose | Publish, unpublish, or return something to draft. |
propose_type_update | propose | Add or change a field — which affects every page of that kind, and it says so. |
propose_type_create | propose | A new kind of page altogether. |
propose_settings_update | propose | Site details. |
propose_menu_update | propose | Navigation. |
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 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.
| Route | Returns |
|---|---|
GET /ext/social/accounts | Every connectable network, whether an app is registered, and what is held. |
POST /ext/social/accounts/:network/start | 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. |
GET /ext/social/oauth/callback | The return leg. Public, because the browser arrives by top-level navigation with no session. |
DELETE /ext/social/accounts/:id | Disconnect. |
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
| 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 load balancer. |
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 | Read 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_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 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.