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.

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.

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

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.

ToolKindNeedsDoes
list_content_typesreadcontent.readThe model — every type and its fields.
list_entriesreadcontent.readEntries of a type, filtered and paged.
search_sitereadcontent.readFind a page or a file by name across every type at once.
get_entryreadcontent.readOne entry in full, including what it is filed under.
list_mediareadcontent.readThe library, so it can pick an existing image.
list_taxonomiesreadcontent.readCategories, tags, and every term in each.
get_site_settingsreadcontent.readTitle, tagline, description, logo, social image.
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 what a page says.
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_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.jsThe 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/callbackThe 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
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 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.