An account, and a key
You sign in with a username and password. The password unlocks a key pair that stays on your device; the server keeps only a sealed copy, so you can sign in again somewhere else.
A friendly guide
Chill API gives you chat rooms, groups, direct messages, encrypted posts and a form inbox — with end-to-end encryption built in. It is the server behind a terminal-style chat client, a console, an inbox, a kindness app and a CLI. This page explains what it does in plain English, with pictures.
The 30-second version
You sign in with a username and password. The password unlocks a key pair that stays on your device; the server keeps only a sealed copy, so you can sign in again somewhere else.
Public rooms are open and plaintext. Private rooms, direct messages and groups are encrypted, and you get in when somebody already inside lets you in.
Posts have their own keys and folders. A form on your website can drop messages into a group queue that the whole team works through.
This page is a guide, not a spec. Every example works against a local
api-go, and the two case studies — Chill Mail and Kindness — are real apps in
this repo, built on exactly these endpoints.
How it fits together
Each app is a client. None of them talk to each other; they all talk to the same server. That means a feature added to the API is immediately available to the web client, the CLI, the Android app, and anything else you write.
The browser, CLI and mobile apps hold the keys and turn messages into ciphertext before sending. The server never has the missing half.
Who may join, who may broadcast, who may read a queue, how large a form may be, how often you may retry: all decided server-side, in one place.
Accounts, membership, roles, wrapped key copies and ciphertext. Tables are created at startup, so there are no migration files to run first.
Two ways in
REST is a normal HTTP API: you ask, it answers, done. The WebSocket is a live connection: you ask, it answers, and it also tells you when something happens. Most methods exist in both places, and the REST mirrors return the same envelope.
| Aspect | REST | WebSocket |
|---|---|---|
| Best for | Sign-in, setup, lists, uploads, form submissions | Live conversation and events |
| Shape | Ordinary HTTP + JSON | JSON frames with a type |
| Auth | Authorization: Bearer <token> | Same header, or ?token= for browsers |
| Examples | /auth, /room/list, /post/create | room_send, group_say, dm_open |
| Answer | Socket-style envelope for mirrors | The same envelope, plus broadcasts |
Sign in over REST, then use the token for everything else. The token is a JWT that lasts about ten days; the same token authenticates REST and opens the socket.
curl -s https://api.example/auth \
-H 'content-type: application/json' \
-d '{"username":"ada","password":"correct horse battery staple"}'
{
"token": "eyJhbGciOiJIUzI1NiIs…",
"created": "2026-10-30T09:14:02Z",
"expiry": "2026-11-10T09:14:02Z",
"user": {
"id": "0f3c…",
"username": "ada",
"publicKey": "MIIBIjANBg…",
"encryptedPrivateKey": "k3Jv…"
}
}
Note the case change: you send room_list, and the answer's
type is ROOM_LIST. An error has no type at all — only
responseType: "error" and a numeric code.
On the socket, every message is a JSON object with a type. Requests look like
{"type":"room_list"}. Answers arrive in an envelope; responseType
tells you whether it worked. An error carries a numeric code and a message.
{
"type": "ROOM_LIST",
"resourceId": null,
"data": [ { "id": "9f2c…", "name": "lobby", "encrypted": false } ],
"timestamp": 1793351642000,
"responseType": "success"
}
// and when it does not:
{ "data": null, "timestamp": 1793351642000, "code": 4027,
"message": "This room has been deleted and is not accessible.", "responseType": "error" }
Do not demultiplex on order. The broadcast is written before the answer, and some successful calls also broadcast to you because you are a member. A client that treats the first frame as its answer will hand one caller another request's payload.
Encryption, without the maths
You do not have to implement any of this to use the API — the clients in this repo already do. But it explains why rooms work the way they do, and why some things cannot be undone.
Three ideas do all the work: a key pair for your account, a fresh key for each thing you protect, and wrapping — encrypting a key with somebody else's public key so only they can open it.
The server keeps a sealed copy of your account key so you can sign in on a new device. It can hand the envelope back at sign-in, but it cannot open it.
A room, group or post key is encrypted for each member's account public key. Any browser signed in to that account can open its copy, with no other device online.
Removing somebody rotates the key so they cannot read what comes next. It cannot take back what they already received — no system can.
| Resource | Protection | Who gets in | Good for |
|---|---|---|---|
| Public room | Plaintext | Anyone may join | Open community chat |
| Private room | E2EE, rotating key | Admitted by an owner or moderator | A closed team or project |
| Direct message | E2EE | Both sides accept the conversation | One-to-one conversations |
| Group | E2EE, one key | Invite code, or open subscription | Broadcasts, shared queues |
| Post | E2EE, one key per post | Owner, named people, groups, or a link | Documents and notes |
| Form | Plaintext or encrypted | Anyone with the capability URL | A website inbox |
No key, no reading — even for the person running the server. A member admitted without a key arrives with access but nothing readable until somebody inside shares one. That is a feature, not a bug: the server has nothing to hand over.
Two related details are worth knowing up front. Private rooms have no passwords; you are admitted, or you are not. And a room's encryption is chosen when it is created and cannot be switched afterwards, because publishing a confidential history in the clear cannot be undone.
What's in the box
You do not need all of this at once. Most integrations use two or three surfaces: an account, one place to put things, and one way to get them out.
Good for: live chat with a clear boundary.
Good for: private one-to-one conversation.
Good for: announcements and shared work.
Good for: documents, notes and drafts.
Good for: one-off handoffs.
Good for: support, contact and intake.
Groups also have a small automation language. A function is a short pipeline of pre-approved steps — the server checks and snapshots the plan, then the caller's client does the encryption. There is no arbitrary code to sandbox and no script to host.
# one random member, one random pooled post, then share it
pick_random(type: "user")
pick_random(type: "post")
share_post(path: "{{post.path}}", owner: "{{post.ownerId}}")
The allow-list is pick_random, pick_many, share_post,
announce and notify. That is deliberately tiny: a function can
choose recipients, share something, or say something — not run forever or reach outside
the group.
Examples
Chill Mail and Kindness are running in this repo. They use the same endpoints as everything else, and they are deliberately different shapes: one is a team inbox, the other is a one-job community app.
A support inbox that is really a group.
Chill Mail is a mail client for a group. The group is the team, each form is a sub-inbox, and each submission is a message. A visitor fills in a form on your site and it lands in the shared queue. The visitor needs no account, no password and no key.
The form posts to a capability URL: an unguessable address that allows exactly one thing — sending a message to that form. It is not an API token and grants nothing else.
Members open the inbox with their own password. If the form encrypts, each member's account key opens their copy of the submission key, and the browser decrypts the message. Read receipts are per person, so the team can see who has already picked it up, and deleting is a soft move to the trash.
Good for: support, contact, bug reports and intake.
// The owner runs this once. The returned capability goes in the form's action.
await fetch(`${API}/group/submit/enable`, {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify({
groupId,
name: "Contact",
allowedOrigins: ["example.com"],
allowedFields: ["email", "subject", "message"],
summary: { from: "email", subject: "subject", preview: "message" },
returnUrl: "/thanks",
}),
});
<!-- The schema drops any field you did not declare. -->
<form action="https://api.example/group/submit/CAPABILITY" method="post">
<input name="email" type="email" required>
<input name="subject" required>
<textarea name="message" required></textarea>
<button>Send</button>
</form>
Encryption is opt-in per form. A form that sets requireEncryption refuses
plaintext, which is what you want when the subject is sensitive. Otherwise both modes
share one queue, and the inbox labels each row.
One group with one job: pass a note to a stranger.
Kindness is deliberately small. Everyone who registers is automatically subscribed to a
single group called kindness; there are no rooms, no other groups and no
terminal. A member writes an encrypted note, and the note goes into a pool.
Then the group's pass-it-on function runs: it picks one random member and
one random note, and sends that note to their inbox. They find it there and can open
it. The server never sees the note's text.
# Set up once by the group owner:
# /group.create("kindness")
# /group.option{group:"kindness", option:"USER_CAN_ADD_POST", value:true}
# /group.fn_create("kindness", "pass-it-on")
pick_random(type: "user")
pick_random(type: "post")
share_post(path: "{{post.path}}", owner: "{{post.ownerId}}")
None of these needs a new API. Each one is a shape the existing surfaces already support.
A bot opens a private room, adds the responders and posts the timeline as things happen. Because the room is encrypted, the postmortem stays out of reach of the host.
Good for: on-call, security, anything sensitive.
A form with requireEncryption on a group only the trust team can read. For a
one-off document, mint a share link instead and send the URL.
Good for: whistleblowing, feedback, anonymous contact.
A group function pairs members at random and sends each pair a private notice. No matching table, no scheduler UI, no extra service to keep alive.
Good for: communities, mentoring, team rituals.
An ESP32 posts temperature and soil readings into a private group every few minutes. The board is a normal member; the history is a normal encrypted chat.
Good for: sensors, home automation, remote sites.
An open group where any member can broadcast, with an invite-only door. Keep it out of search if you want it to be found only by word of mouth.
Good for: societies, classes, neighbourhoods.
A form with a schema and attachments, landing in a queue the studio works through with read receipts and a trash can. The same shape as Chill Mail, different fields.
Good for: agencies, freelancers, applications.
Build something
You need a running backend and a token. The console app is the easiest place to watch the wire; the CLI is the fastest way to try a command.
# 1. sign in and keep the token
TOKEN=$(curl -s https://api.example/auth \
-H 'content-type: application/json' \
-d '{"username":"ada","password":"…"}' | jq -r .token)
# 2. every REST mirror takes it as a bearer token
curl -s https://api.example/room/list \
-H "authorization: Bearer $TOKEN"
REST mirrors answer with the same envelope the socket does, so a client can share one parser.
// Browser handshakes can carry the token in the query string.
const ws = new WebSocket(`wss://api.example/ws?token=${token}`);
ws.addEventListener("open", () => {
ws.send(JSON.stringify({ type: "room_list" }));
});
ws.addEventListener("message", (event) => {
const frame = JSON.parse(event.data);
if (frame.responseType === "error") {
console.error(frame.code, frame.message); // e.g. 4027, room deleted
return;
}
if (frame.type === "ROOM_LIST") {
console.log(frame.data); // null when you have no rooms
}
// Anything else is a broadcast: a message, an invite, a key event.
});
Keep a queue keyed by the response type you expect. A broadcast can arrive before your answer on the same socket.
<form action="https://api.example/group/submit/CAPABILITY" method="post">
<input name="email" type="email" required>
<input name="subject" required>
<textarea name="message" required></textarea>
<button>Send</button>
</form>
For the bot-resistant path, mint a one-time token with
GET /group/submit/token and post to
/group/submit/session/<token>. The token is single-use, short-lived
and pinned to the minting host.
# in the go/ directory
CHAT_API_URL=http://localhost:8089 \
CHAT_WS_URL=ws://localhost:8089/ws \
go run ./cmd/chat-go
/user.login("ada")
/room.create{name: "lobby", public: true}
/room.use("lobby")
hello, room
Every command starts with a slash. Anything else is chat text for the active room — so a typo cannot accidentally run a command.
# API + WebSocket on :8089
cd api-go && bash run.sh
# the console, pointed at it
cd app-ui
NEXT_PUBLIC_API_URL=http://localhost:8089 \
NEXT_PUBLIC_WS_URL=ws://localhost:8089/ws \
npm run dev
The backend needs JWT_SECRET and the DB_* variables. Everything
else has a sensible default, including the port and automatic table creation.
JWT_SECRET — signs account tokensDB_HOST, DB_USERNAME, DB_PASSWORD, DB_DATABASEPORT — default 8089DB_AUTO_MIGRATE — default trueBefore you ship
Most of these are consequences of the design rather than bugs. Knowing them early saves an afternoon.
nullGo nil slices serialise as null. Normalise before you map, or the first run on a new account will throw.
Socket envelopes stamp Unix milliseconds. The auth payload's dates are RFC 3339 strings. Do not feed one to the other's parser.
Answers are matched by type, not by an id, and a broadcast can arrive first. Keep a queue of expected types.
A public room cannot become private, and the reverse is worse. Create it with the right flag the first time.
Room names are unique ignoring case, so Lobby and lobby resolve to the same room.
A deleted room is inert for everyone but stays listed for its members, flagged. An administrator can restore it for 30 days.
Changing your password re-seals the account key. Losing the password means the recovery code plus the PEM backup — nothing reopens old messages without it.
Sign-in and the public form routes are limited per address. Treat a refusal as a pause, not a retry storm.
The long version is in the repo. design-document.md is the
source of truth for every message type and command; app-ui/README.md documents
the wire contracts the console exercises against a live server.