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.
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.
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
Thirty-eight tools: thirteen read, twenty-four 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, filtered and paged. |
search_site | read | content.read | Find a page or a file by name across every type at once. |
get_entry | read | content.read | One entry in full, including what it is filed under. |
list_media | read | content.read | The library, so it can pick an existing image. |
list_taxonomies | read | content.read | Categories, tags, and every term in each. |
get_site_settings | read | content.read | Title, tagline, description, logo, social image. |
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 what a page says. |
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_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.