A friendly guide

One small API behind a lot of friendly apps.

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.

REST + WebSocket End-to-end encrypted Go + Postgres No account needed to send a form

The 30-second version

Three things, and you have the idea.

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.

Somewhere to talk

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.

Somewhere to keep things

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

Every client, one API.

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.

Five clients connect to one Chill API, which stores data in Postgres The terminal web app, console, Chill Mail, Kindness app, and CLI, Android and ESP32 clients reach the API over REST and a WebSocket. The API stores accounts, membership, wrapped keys and ciphertext in Postgres. Clients Terminal web app app/ Console app-ui/ Chill Mail mail/ Kindness chill/ CLI · Android · ESP32 go/ · android/ · esp32/ REST · requests WebSocket · live Chill API Go · net/http + gorilla/websocket Auth & JWT account tokens only Rate limits per route and address Rooms & DMs membership & keys Groups broadcasts + functions Posts folders, replies, shares Forms capabilities & queues Rules, not content Who may join, who may post, what is stored and what is refused. The server cannot open a room key. Postgres accounts membership wrapped keys ciphertext form queues SQL All clients use the same endpoints. There is no separate mobile or web API.
Clients reach the API two ways: ordinary REST calls for requests, and a WebSocket for anything that should arrive live. For encrypted resources, what lands in Postgres is ciphertext and wrapped keys — not the words.

Clients do the encryption

The browser, CLI and mobile apps hold the keys and turn messages into ciphertext before sending. The server never has the missing half.

The API does the rules

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.

Postgres keeps the facts

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 and WebSocket are for different jobs.

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.

AspectRESTWebSocket
Best forSign-in, setup, lists, uploads, form submissionsLive conversation and events
ShapeOrdinary HTTP + JSONJSON frames with a type
AuthAuthorization: Bearer <token>Same header, or ?token= for browsers
Examples/auth, /room/list, /post/createroom_send, group_say, dm_open
AnswerSocket-style envelope for mirrorsThe 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.

Sign in
curl -s https://api.example/auth \
  -H 'content-type: application/json' \
  -d '{"username":"ada","password":"correct horse battery staple"}'
The answer
{
  "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.

A socket answer
{
  "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" }
One socket request produces a broadcast and an answer Your client sends room_send to the API. The API first broadcasts ROOM_MESSAGE to the other members, then answers your client with ROOM_MESSAGE_SENT. Your client Chill API Everyone else 1 room_send roomId · ciphertext · epoch 2 ROOM_MESSAGE the broadcast, written first 3 ROOM_MESSAGE_SENT your answer One request, two frames Match on the type, never on arrival order.
A send is the common shape: the API pushes the broadcast to the other members first, then answers you on the same socket. There is no request id on the wire, so clients match each answer to the type they asked for.

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

The server sees locked boxes.

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.

What stays on your device and what the server can store On your device, the account key pair, resource keys and plaintext stay put. Only a sealed account key, wrapped key copies and ciphertext cross to the server. On your device On the server Account key pair private half stays in memory Seal with your password PBKDF2-SHA256 · AES-GCM Resource key AES-256-GCM · one per resource Wrap for each member RSA-OAEP to their public key Plaintext a message, a post, a note Encrypt AES-256-GCM with that key sealed account key cannot be opened without your password wrapped key copies one per member, opened on their device ciphertext bytes the server cannot read The unwrapped key and the plaintext stay on this side. It also knows names, membership and roles.
The account private key is opened with your password on your device and kept in memory. Each protected resource has its own AES key; that key is wrapped once for every member's account public key. The server stores the wrapped copies and the ciphertext.

Your password is the only way in

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.

Keys travel, wrapped

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.

Removal changes the future

Removing somebody rotates the key so they cannot read what comes next. It cannot take back what they already received — no system can.

ResourceProtectionWho gets inGood for
Public roomPlaintextAnyone may joinOpen community chat
Private roomE2EE, rotating keyAdmitted by an owner or moderatorA closed team or project
Direct messageE2EEBoth sides accept the conversationOne-to-one conversations
GroupE2EE, one keyInvite code, or open subscriptionBroadcasts, shared queues
PostE2EE, one key per postOwner, named people, groups, or a linkDocuments and notes
FormPlaintext or encryptedAnyone with the capability URLA 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

Six surfaces, one token.

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.

Rooms

  • Public (plaintext) or private (encrypted)
  • Topics, history policy, moderators
  • Private rooms rotate their key weekly

Good for: live chat with a clear boundary.

Direct messages

  • Invite-only; pending until accepted
  • Encrypted with a key for both accounts
  • Leaving hides it for you, not for them

Good for: private one-to-one conversation.

Groups

  • Owner broadcast, or open to all members
  • Invite-only or open to subscribe
  • Custom functions for small automations

Good for: announcements and shared work.

Posts and folders

  • One key per post, folders to organise them
  • Share with a person, a group, or a link
  • Replies and threads with their own keys

Good for: documents, notes and drafts.

Share links

  • Works for somebody with no account
  • Capability in the URL, key in the fragment
  • Revocable, with an optional passphrase

Good for: one-off handoffs.

Form inbox

  • A capability URL you can embed anywhere
  • A schema, with fields mapped to a header
  • Plaintext or encrypted, per form
  • Per-member read receipts and a trash

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.

A group function · pass-it-on
# 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

Two real apps, and six things you could build.

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.

Real app mail/

Chill Mail

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.

A website form submits into a group queue that the team reads A visitor posts to a capability URL. The API stores the submission in a group queue. Members of the group list and decrypt it. The owner sets the form up once. Visitor no account needed fills in a form Group queue plaintext JSON, or an encrypted envelope members can list it Team inbox decrypts with the submission key submit read One-time setup by the group owner 1 · mint a capability a secret URL for the form 2 · make a keypair public half to the form 3 · wrap the private half for every member's account
The visitor posts to a capability URL, not to your API token. The form may encrypt in the browser before it posts; either way the queue is readable only by the group.
Create a form · owner
// 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",
  }),
});
Embed it · no JavaScript
<!-- 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.

Real app chill/

Kindness

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.

The Kindness loop: write, pool, pick, deliver A member writes a note, drops it in the kindness group pool, the pass-it-on function picks one member and one note, and only that member can open it. 1 · Write a note a normal encrypted post encrypted in the browser 2 · Add it to the pool linked to the kindness group 3 · pass-it-on runs one random member, one random note 4 · It lands in their inbox sent to one member by the function run one note one person
The loop is the whole app. The only privileged step is delivering the note, and that happens inside the function run.
The whole function
# 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}}")

Six more ideas

None of these needs a new API. Each one is a shape the existing surfaces already support.

An incident room in one click

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.

room_create { public: false }

Good for: on-call, security, anything sensitive.

A tip line

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.

POST /group/submit/<capability>

Good for: whistleblowing, feedback, anonymous contact.

Weekly coffee pairs

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.

pick_many(type: "user", count: 2)

Good for: communities, mentoring, team rituals.

A greenhouse that reports in

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.

{ "type": "group_say" }

Good for: sensors, home automation, remote sites.

A club noticeboard

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.

group_mode → open

Good for: societies, classes, neighbourhoods.

A studio brief intake

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.

allowedFields: ["name", "brief"]

Good for: agencies, freelancers, applications.

Build something

The shortest path from zero to a message.

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.

Sign in, then call a mirror
# 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.

Open the socket
// 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.

The simplest public form
<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.

The CLI client
# in the go/ directory
CHAT_API_URL=http://localhost:8089 \
CHAT_WS_URL=ws://localhost:8089/ws \
go run ./cmd/chat-go
Then, at the prompt
/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.

Run the backend

Terminal
# 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

What you need set

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 tokens
  • DB_HOST, DB_USERNAME, DB_PASSWORD, DB_DATABASE
  • PORT — default 8089
  • DB_AUTO_MIGRATE — default true

Before you ship

Good to know, honestly.

Most of these are consequences of the design rather than bugs. Knowing them early saves an afternoon.

Empty lists are null

Go nil slices serialise as null. Normalise before you map, or the first run on a new account will throw.

Two clocks

Socket envelopes stamp Unix milliseconds. The auth payload's dates are RFC 3339 strings. Do not feed one to the other's parser.

No request id

Answers are matched by type, not by an id, and a broadcast can arrive first. Keep a queue of expected types.

Encryption is fixed at creation

A public room cannot become private, and the reverse is worse. Create it with the right flag the first time.

A name has one spelling

Room names are unique ignoring case, so Lobby and lobby resolve to the same room.

Delete is soft

A deleted room is inert for everyone but stays listed for its members, flagged. An administrator can restore it for 30 days.

Keep the recovery code

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.

Rate limits are real

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.