Skip to content

Build a client

This guide walks you through a minimal OriginChats client. By the end it connects to a server, signs in with a Rotur account, reads a channel’s history, sends a message, and shows messages from other people as they arrive.

The examples use browser JavaScript, but any language with a WebSocket library and a JSON parser works. A client can be a web app, a native app, a terminal program, or a bot.

Every frame in both directions is one JSON object with a cmd field. There are three kinds:

  • Requests that your client sends, such as message_new.
  • Responses that answer one of your requests.
  • Events that the server pushes on its own, such as a message from someone else. The server events page lists them all.

Each server is independent. It has its own users, channels, and permissions, so a client that talks to several servers keeps separate state for each one.

A server’s WebSocket lives at the root path of its URL. For a server at https://chat.example.com, connect to wss://chat.example.com/. Use ws:// for a plain HTTP server.

const socket = new WebSocket("wss://chat.example.com/");
socket.addEventListener("message", ({ data }) => {
handleFrame(JSON.parse(data));
});

handleFrame is your own function. You’ll fill it in over the next steps.

As soon as the socket opens, the server sends a handshake frame. Here it is with most fields left out:

{
"cmd": "handshake",
"val": {
"version": "1",
"server": {
"name": "Example Chat",
"url": "https://chat.example.com",
"icon": "https://chat.example.com/server-assets/icon.png",
"banner": "",
"owner": { "name": "alice" }
},
"auth_mode": "rotur",
"validator_key": "originChats-https://chat.example.com-4c1f0a9b2e7d45f8a3b6c9d0e1f2a3b4",
"capabilities": ["ping", "channels_get", "messages_get", "message_new", "auth"]
}
}

Save the whole val object. You need auth_mode and validator_key to sign in, and capabilities tells you which commands this server supports. The handshake reference describes every field.

auth_mode says which accounts the server accepts:

auth_mode Accounts Command to send
rotur Rotur accounts only. auth
cracked Rotur accounts or accounts stored on this server. auth, login, or register
cracked-only Accounts stored on this server only. login or register

“Cracked” is the protocol’s name for a local server account. This guide uses Rotur. Server accounts covers login and register.

Rotur is the identity service behind OriginChats accounts. To prove who you are, you ask Rotur for a one-time validator that is bound to this connection’s validator_key, then send it to the server.

First check that the key names the server you meant to reach. The key has the form originChats-<server URL>-<random>, and a server that puts a different URL there is asking you to sign in somewhere else. Validate server identity explains the check.

Then generate the validator with rotur-sdk and send auth:

import { Rotur } from "rotur-sdk";
const rotur = new Rotur({ token: roturToken });
const { validator } = await rotur.validators.generate(handshake.validator_key);
socket.send(JSON.stringify({
cmd: "auth",
validator,
client: "mychat",
device: "computer",
}));

client and device are optional labels that other users can see. client names your app and device describes the machine, such as computer, mobile, or terminal. See auth for the allowed values.

If sign-in works, the server sends two frames:

{ "cmd": "auth_success", "val": "Authentication Successful" }
{
"cmd": "ready",
"user": {
"username": "alice",
"nickname": null,
"roles": ["owner", "user"],
"color": "#f5a623",
"cracked": false,
"clients": ["mychat"],
"devices": ["computer"],
"status": { "status": "online", "text": "" }
}
}

ready.user is you, in the user format. Wait for ready before you send anything else. Until then the server answers every other command with Authentication required.

If sign-in fails, you get auth_error instead. Its val is a message you can show, such as Invalid authentication.

{ "cmd": "channels_get", "listener": "channels-1" }

The response’s val is the ordered list of channels you can see (shortened here):

{
"cmd": "channels_get",
"val": [
{
"name": "general",
"type": "text",
"description": "Say hello",
"last_message": 1788177512.08,
"last_message_id": "9f3c2a1e-6b1d-4f7a-9c2e-3d4b5a6c7e8f"
},
{ "name": "voice-lounge", "type": "voice", "voice_state": [] }
],
"listener": "channels-1"
}

Channels have a type. Text and voice channels hold messages directly. Forum channels hold threads. Separators are only visual dividers in the list.

{ "cmd": "messages_get", "channel": "general", "limit": 50, "listener": "history-1" }

The response’s val holds the newest 50 messages, oldest first. messages_get explains how to page further back.

{ "cmd": "message_new", "channel": "general", "content": "Hello!", "listener": "send-1" }

The server saves the message and answers with the stored copy, including its new id and timestamp (shortened here):

{
"cmd": "message_new",
"channel": "general",
"message": {
"id": "b2d4f6a8-1c3e-4a5b-8d7f-0e9c1b2a3d4e",
"type": "message",
"user": "alice",
"content": "Hello!",
"timestamp": 1788177600.412
},
"listener": "send-1"
}

Everyone else who can see general receives the same frame, without the listener.

Route every frame by its cmd. A small client needs these cases:

function handleFrame(frame) {
switch (frame.cmd) {
case "handshake":
handshake = frame.val;
signIn();
break;
case "ready":
me = frame.user;
loadChannels();
break;
case "message_new":
addOrReplaceMessage(frame.channel, frame.message);
break;
case "message_edit":
addOrReplaceMessage(frame.channel, frame.message);
break;
case "message_delete":
removeMessage(frame.channel, frame.id);
break;
case "error":
console.warn(`${frame.src} failed: ${frame.val}`);
break;
default:
// Ignore frames you don't handle yet, including the server's heartbeat "ping".
break;
}
}

Use the message id to add or replace a message. Your own message_new response and a live event for the same message would otherwise show up twice.

listener is any value you choose. The server copies it onto its direct response, so you can tell which request a frame answers. Events pushed to other users never carry it.

Some commands answer with a different cmd, answer with several frames, or don’t answer at all. Each command page documents its response. Failures come back as:

{ "cmd": "error", "val": "Channel not found", "src": "messages_get", "listener": "history-1" }

src names the command that failed. Always set a timeout on requests you wait for, because the server sends nothing back for commands it doesn’t recognise.