Protocol
How things talk to a Kern server. Useful if you run one, debug one, or build something against one — and necessary reading before changing either side.
Two surfaces
Three different clients reach a Kern server, across two surfaces:
| Client | Surface | Who it is |
|---|---|---|
| Kern, the desktop app | /kern/v1/* | A person with an account, syncing their own spaces |
| Kern Platform, the management app | /kern/v1/admin/* | An administrator of the server |
kernctl, the CLI | /kern/v1/admin/* | The same administrator, headless |
The two surfaces are deliberately separate. The app surface can read and write one person's work and nothing about the instance; the management surface can read everything about the instance and none of anybody's work. Neither is a superset of the other, and a leaked token on one side doesn't become the other.
Everything is JSON. Errors are { code, message }.
The handshake
The first request an app makes about an address it has been given. Public, unauthenticated, before any account exists:
GET /kern/v1/instance
{
"service": "kern-cloud",
"protocol": 1,
"name": "Acme Labs",
"apiUrl": "https://kern.example.com/kern/v1",
"registration": "invite",
"notice": "Maintenance Sunday 02:00 UTC",
"contact": "admin@example.com",
"features": {
"workspaces": true,
"assets": true,
"streams": true,
"shares": true,
"inboundEmail": false,
"email": true
}
}
It answers three questions, and nothing else:
-
Is this a Kern server?
service: "kern-cloud". Anything else and the app says so plainly: something replied, but it isn't Kern — usually a proxy's error page or the wrong port. -
What will it let me do?
registrationdecides whether the sign-in screen offers to create an account, or explains that this server is invitation-only or closed.features.emailbeing false is why the screen says codes come from the administrator rather than an inbox. -
What should I call it?
name, on the sign-in screen and in the server list.
protocol is the contract. An app refuses a server whose protocol is higher
than the one it implements, naming both numbers, rather than failing later in a way nobody
can diagnose. It is raised only for a change that would break an older app — adding an
endpoint doesn't count. The server's number and the app's must change together.
This endpoint is a free description of the host to anyone who asks, so it deliberately holds no counts, no component versions and nothing about who has an account there.
Choosing a server
Which server a copy of Kern talks to is not an ordinary setting, and the reason is worth stating plainly: the session token is sent as a bearer to whatever address the backend resolves, and that request is made in Rust, where the window's content security policy does not apply. An address the interface could write would therefore be a way for anything running in the webview to hand the session token to a host of its choosing.
Three things guard it, in order of how much they actually matter:
- A token belongs to one origin. The credential-store entry is keyed by host, so pointing Kern at another server does not carry the existing session there — the new server simply has no token and asks for a sign-in. This is the part that defuses the attack: even a silent switch yields nothing.
-
KERN_CLOUD_URLwins and locks the choice. Whoever starts Kern — a managed deployment, a development run — keeps the final say, and the interface cannot override it. Settings shows the choice as locked and says why. - Adding a server asks the person, in a native dialog naming the host. The webview can propose a server; it cannot accept one.
Where a token may be sent at all:
| Address | Allowed | Why |
|---|---|---|
https:// anything | yes | Encrypted, and the host's name is checked |
http://127.0.0.1, ::1, localhost | yes | The same machine, or an SSH tunnel to it |
http://192.168.x, 10.x, .local, .internal | yes, with a warning | A home or office network |
http:// a public address | no | The session would cross the internet in the clear |
Addresses are normalised on the way in: a bare host gets https://, and
/kern/v1 is appended unless it is already there. So
kern.example.com and https://kern.example.com/kern/v1/ are the
same server.
A different server means a different account and a different set of spaces, so when the choice changes the interface starts again rather than showing one server's data under another's name.
Signing in
Better Auth at /kern/v1/auth/*, with
the bearer plugin: no cookies, no redirects, no web pages. Email and
password, with emailed one-time codes for verification, code sign-in and recovery.
POST /kern/v1/auth/sign-up/email
POST /kern/v1/auth/sign-in/email
POST /kern/v1/auth/email-otp/send-verification-otp
POST /kern/v1/auth/sign-in/email-otp
GET /kern/v1/auth/list-sessions
A successful sign-in or verification carries the session in a
set-auth-token response header. The app's backend reads it there, puts it in
the operating system's credential store under the entry for that server, and sends it as
Authorization: Bearer <token> from then on. The session token never
reaches the front end.
A 401 anywhere else means the session has ended — suspended, revoked, deleted
or expired. The app deletes the stored token, sets its status to signed out, and says so
once rather than retrying.
Email verification is required, which is why a server with no SMTP needs
kernctl users verify <email> to get the first person in.
Sync
Each space — the personal space, or a workspace — has a local replica with the same schema as the ordinary local database, so every feature works the same and keeps working with no connection.
Only core records sync: projects, boards, tasks, issues, milestones, notes, whiteboards and the rest. Files, folders, repositories, terminals and environment settings always stay on the computer. This is not a setting; the server refuses anything else.
Pushing
Field-level patches — only the columns that changed, or the full row for a new record:
POST /kern/v1/spaces/:space/sync/push
{
"conn": "<realtime connection id, so the echo is recognised>",
"changes": [
{ "op": "upsert", "t": "tasks", "id": "…", "data": { "title": "New title" } },
{ "op": "upsert", "t": "tasks", "id": "…", "data": { … }, "full": true },
{ "op": "delete", "t": "notes", "id": "…" }
]
}
The server merges each patch into the stored row — last writer wins per field, so two people editing different fields of the same task both keep their edit — assigns each change the space's next sequence number, and replies with the merged rows. A client learns what others changed from its own push reply. A patch for a record deleted elsewhere is rejected: deletion wins.
Pulling
GET /kern/v1/spaces/:space/sync/pull?since=<cursor>&limit=<n>
{ "changes": [ { "seq": 41, "t": "tasks", "id": "…", "op": "upsert", "data": { … } } ],
"cursor": 41, "head": 41, "hasMore": false, "role": "member" }
Sequence numbers are allocated under a row lock on the space, so they are contiguous
within a push and commit in order. That is what makes the fast path safe: a client that
has applied everything up to N can apply a broadcast starting at N+1
directly, and otherwise pulls. since=0 skips deleted rows — a first sync has
no tombstones to care about.
A rejected patch doesn't consume a sequence number, so the block stays contiguous. One background worker pushes and pulls.
Realtime
The socket is opened by the interface, not the backend, so it must not need the session token. It uses a ticket instead — short-lived and single-use:
POST /kern/v1/realtime/ticket -> { "ticket": "…", "expiresIn": 60 }
Then the WebSocket, at the API base with the scheme swapped:
wss://kern.example.com/kern/v1/realtime?ticket=…
The client subscribes to the spaces it has open. The server pushes:
| Message | What it is |
|---|---|
changes | Records committed by somebody's push — durable |
presence | Who is where, cursors and selections — ephemeral, never stored |
ephemeral | Relayed live interaction, such as a whiteboard drag |
space | Workspace events: updated, members changed, you were removed, deleted |
invite | A new invitation for the signed-in user |
stream | A stream delivered a submission into the space |
notice | A message from the administrator (kernctl notice "…") |
signed_out | This session has ended: suspended, revoked or deleted |
Ephemeral traffic is rate-limited per connection with a token bucket. State is per process, which suits one server; running several instances would need shared pub/sub.
If a reverse proxy doesn't pass Upgrade and Connection,
sync works and live collaboration silently doesn't. It is the most common
self-hosting mistake — see
no live collaboration.
The management API
A headless server is not a lesser citizen. Kern Platform and kernctl speak
only /kern/v1/admin/*, and the same surface for both is what
guarantees it: anything the desktop app can do, the CLI can do.
Three rules hold throughout:
- Nothing here reads anybody's work. It counts records, measures spaces and lists who belongs to what. It never returns the contents of a task, note or whiteboard. An administrator keeps a server running without reading what people put in it — and a leaked management token must not be a way to read the data either.
- Destructive actions name what they will destroy. Deleting an account or purging a workspace takes the name or email as confirmation, so a mistyped id can't take out the wrong one.
- Everything that changes something is recorded in the audit trail, with the token that did it.
Two gates
1. Where the request came from. By default loopback only.
That is what makes "manage it from the same machine, or over an SSH tunnel" the normal way
in: nothing on the network can even attempt a token. Opening it up takes
ADMIN_LOCAL_ONLY=false and an explicit
ADMIN_ALLOW_FROM list, because a management API on a public port is a
different risk from a sync API.
The client address comes from the socket, not from a header. A proxy header is attacker-controlled and trusting one here would undo the gate entirely. So behind a reverse proxy it is the proxy that is on the allow list, and you restrict who reaches it there instead.
2. A management token, as Authorization: Bearer kern_….
A refusal says which gate stopped you. admin_local_only means the address
list, not the token — an administrator who has just opened a port needs to know the
difference.
Management tokens
kern_<16 hex id>_<secret>
Only the SHA-256 of the secret half is stored, so a stolen database yields no working tokens; the id half lets a lookup find one row instead of comparing against every token. The whole token is shown exactly once, when it is created.
Scopes are full and read. A read token can look at
everything and change nothing, which is what a monitoring check should hold.
On first start, when no tokens exist, one is minted automatically and
written where the installer said — ADMIN_TOKEN_FILE, by default
/var/lib/kern/admin-token. That is the whole mechanism behind
"kernctl on the server's own machine needs no setup": it reads the file. Keep
it readable only by the service account.
Pairing Kern Platform
The desktop management app needs an address and a token. Typing a token by hand is
error-prone, so kernctl mints one and encodes both into a single line:
$ kernctl pair "Kern Platform"
Acme Labs is ready to manage.
Server http://127.0.0.1:8787/kern/v1
Token kern_a1b2c3d4e5f60718_…
Or paste this single line into Kern Platform:
kern-platform:eyJ1cmwiOiJodHRwOi8vMTI3LjAuMC4xOjg3ODcv…
The line is kern-platform: followed by unpadded base64url of
{"url":…,"token":…,"name":…}. Kern Platform decodes it in Rust, vets the
address by the same rules the app uses, and stores the token in the operating system's
credential store.
Over a tunnel, forward the port first — the address in the line is the one the server knows itself by:
ssh -L 8787:127.0.0.1:8787 you@server
kernctl pair --json gives the same three values as JSON, for a script.
What a headless server offers
Every one of these is a management API call, so Kern Platform shows the same thing in a window. The kernctl reference covers each in detail.
kernctl status # what this instance is, and how much is in it
kernctl health [--fix] [--fix=ID] # checks by group, and the repairs that are safe
kernctl heal # repair everything, then check again
kernctl doctor # this machine, and the path to the server
kernctl logs [-f] [--level warn] # the server's in-memory ring buffer
kernctl audit [N] # what management tokens have done
kernctl query "SELECT …" # a read-only look at the database
kernctl users list|show|hold|release|verify|revoke-sessions|delete
kernctl allow list|add|remove # who may sign up when REGISTRATION=invite
kernctl spaces list|show|delete
kernctl invites list|revoke
kernctl streams list|enable|disable
kernctl settings # every setting, and where it comes from
kernctl set <key> <value> # change one live; "default" falls back to .env
kernctl notice "TEXT" [--warn] # tell everyone currently connected something
kernctl token list|new|revoke
kernctl service status|start|stop|restart|logs|upgrade
kernctl backup [FILE] | restore <FILE>
kernctl use <url> [--token T]
kernctl is POSIX sh and curl, nothing else
required. Formatted tables use python3 when it is there;
--json always works and needs neither, which is what you want
in a script or a monitoring check. Exit codes are meaningful:
0 | Fine |
1 | Refused by the server |
2 | Called wrongly |
3 | Couldn't reach the server |
4 | health found a failure |
Settings changed with kernctl set are live: registration, the
notice, whether publishing is on. Apps see the difference on their next sign-in screen with
no restart, because /instance and the sign-up checks read the live value
rather than the environment. kernctl settings shows where each value is coming
from — a live override, .env, or the built-in default.
Adding to either side
If you are changing Kern or its server, these are the rules that keep the two sides honest.
A new app endpoint. Add the route on the server, and the call in Kern's cloud domain. No protocol bump: an app that doesn't know the endpoint doesn't call it. If the app can't work without it, that is a protocol bump — both numbers, together.
A new synced table. Add it to the server's synced-table list and to the replica schema on the app side. The server refuses tables it doesn't know, so a one-sided change fails closed, which is the right way round. Also register it with the interface's table domains so the view refreshes when a collaborator changes one.
A new management capability. Add the route to the server's admin routes,
then the command in Kern Platform and the subcommand in kernctl. Adding it to
only one client breaks the promise that a headless server can do everything a windowed one
can. If it changes something, give it an audit entry; if it destroys something, make it
name what.
A new live setting. Register it with the server's live settings so
kernctl set and Kern Platform can change it without a restart, and read the
live value rather than the start-up configuration wherever it is used.
The authoritative version of this page, with the source files named, is
docs/PROTOCOL.md in the
Kern-Cloud-Self-Host
repository.