Skip to content

Protocol model

OriginChats clients talk to a server over one WebSocket, using JSON text frames. This page describes the rules that apply to every frame. Read it once before you use the command reference.

Every frame, in both directions, is a JSON object with a cmd field:

type Frame = {
cmd: string;
listener?: unknown;
[field: string]: unknown;
};

There is no envelope around it. Requests, responses, and events all use the same shape:

  • A request is a frame your client sends, such as messages_get.
  • A response is a frame the server sends back to the connection that made the request.
  • An event is a frame the server pushes without a request, such as another user’s message. See server events.

The server also has HTTP routes on the same host and port for uploads, images, and webhooks. They use their own authentication.

  1. The client opens a WebSocket to the server’s root path, for example wss://chat.example.com/.
  2. The server sends handshake right away.
  3. The client signs in with auth, login, or register.
  4. The server answers auth_success, then ready.
  5. The client can now send any command its permissions allow.

Every 30 seconds the server also sends {"cmd": "ping"} to every open connection. No reply is needed.

The handshake tells you who the server is, how to sign in, and what it supports. It is the first frame on every connection.

{
"cmd": "handshake",
"val": {
"version": "1",
"server_time": 1788177600.253,
"signing_url": "https://chat.example.com",
"server": {
"name": "Example Chat",
"icon": "https://chat.example.com/server-assets/7f1c2d3e-4b5a-6978-8a9b-0c1d2e3f4a5b.png",
"banner": "",
"url": "https://chat.example.com",
"owner": { "name": "alice" }
},
"limits": {
"post_content": 2000,
"search_results": 30,
"max_reactions_per_message": 20,
"max_members": 0
},
"uploads": {
"emoji_allowed_file_types": ["gif", "jpg", "jpeg", "png", "webp", "bmp", "avif", "tiff"],
"sticker_allowed_file_types": ["gif", "jpg", "jpeg", "png", "webp", "bmp", "avif", "tiff"],
"uploads_per_minute": 10
},
"attachments": {
"enabled": true,
"max_size": 104857600,
"allowed_types": ["image/*", "video/*", "audio/*", "application/pdf"],
"max_attachments_per_user": -1,
"permanent_tiers": ["pro", "max"]
},
"validator_key": "originChats-https://chat.example.com-4c1f0a9b2e7d45f8a3b6c9d0e1f2a3b4",
"auth_mode": "cracked",
"capabilities": ["ping", "capabilities", "channels_get", "message_new", "auth", "login", "register"],
"permissions": [
"manage_server",
"manage_channels",
"manage_users",
"manage_roles",
"manage_emojis",
"manage_config",
"manage_access",
"create_invite",
"review_applications",
"review_reports",
"view_audit_log"
],
"access_control": { "mode": "open", "has_password": false }
}
}

The capabilities array above is shortened. A real server lists about 130 entries.

Field Type Description
version string Protocol version. "1" for the current server.
server_time number The server’s clock, in Unix seconds with a fraction. Use it to start a server clock.
signing_url string Server identity used inside signed payloads. Copy it exactly.
server object The server’s public identity. See below.
limits object Message and membership limits. See below.
uploads object Emoji and sticker upload rules. See below.
attachments object Attachment upload policy. See below.
validator_key string A new key for this connection, used to get a Rotur validator for auth and for HTTP uploads. Its form is originChats-<server.url>-<32 random characters>. Check the URL before you use it.
auth_mode string Which accounts the server accepts: rotur, cracked, or cracked-only. See authentication.
capabilities string[] What this server supports. See capabilities.
permissions string[] The administrative permission names the server knows about, always the eleven shown above. This is not the list of permissions you hold.
access_control object How new members get in. See below.
Field Type Description
name string Display name. Don’t use it to identify the server.
url string The configured public URL. It matches the URL in validator_key.
icon string Icon image URL, or "" when there is none.
banner string Banner image URL, or "" when there is none.
owner.name string The owner’s username. Clients use it to allow Web Push from this owner.
Field Type Description
post_content number Most characters allowed in a message’s content. Default 2000.
search_results number Most results one messages_search returns. Default 30.
max_reactions_per_message number Most different emoji one message can have as reactions. Default 20.
max_members number Member limit. 0 means no limit.
Field Type Description
emoji_allowed_file_types string[] File extensions accepted by emoji_add.
sticker_allowed_file_types string[] File extensions accepted by sticker_add.
uploads_per_minute number Always 10. The real attachment rate limit is the operator’s attachments.uploads_per_minute setting, which can differ.
Field Type Description
enabled boolean Whether attachment uploads work.
max_size number Largest upload in bytes.
allowed_types string[] Accepted MIME types, such as image/*. An empty list accepts any MIME type.
max_attachments_per_user number Unexpired uploads one account can hold. -1 means no limit, 0 blocks uploads.
permanent_tiers string[] Reserved. No upload is permanent on this server.
Field Type Description
mode string open, whitelist, password, application, or invite.
has_password boolean Whether a server password is set, even when the mode isn’t password.

Access control explains each mode and what a new member must send.

capabilities lists what the server supports. Check it before you use an optional feature, and hide features the server doesn’t list.

Most entries are command names. If a command is in the list, the server accepts it. The rest are features: event formats, HTTP routes, or rules that change how commands behave.

Capability Advertised Meaning
auth Unless auth_mode is cracked-only Rotur sign-in with auth works.
login When auth_mode is cracked or cracked-only login works for server accounts.
register With login, when the operator allows registration register can create server accounts.
local_attachment_auth_v1 With login Server accounts can upload attachments with ready.local_upload_token.
server_account_avatars_v1 With login Server accounts can upload their own avatar with POST /avatar/{username}.
attachment_upload Always POST /attachments/upload exists. It is an HTTP route, not a WebSocket command.
message_signatures_v1 Always Messages, edits, and embeds can carry signatures.
slash_signatures_v1 Always Rotur accounts must sign slash_call, and result messages carry the signed interaction.
user_metadata_v1 Always User objects carry clients, devices, and bot, and the server sends user_clients events.
unreads_update Always After unreads_ack, the server sends an unreads_update event to every connection of your account, so they stay in sync. It is an event, not a command.
server_side_embeds When link embeds are on The server adds link previews to messages. Connections that report this capability receive the message_edit events that add them.
message_react_add, message_react_remove Always Old names for reaction_add and reaction_remove. See legacy compatibility.

Each of these names a command the server accepts.

Area Capabilities
Connection and session ping, capabilities, typing
Server administration config_get, config_update, server_update, server_stats, server_maintenance
Webhooks webhook_create, webhook_get, webhook_list, webhook_update, webhook_delete, webhook_regenerate
Channels channels_get, channel_get, channel_create, channel_update, channel_move, channel_delete
Messages message_new, message_get, messages_batch, message_replies, messages_get, messages_around, messages_search, message_edit, message_delete, message_pin, message_unpin, messages_pinned
Pings pings_get
Polls poll_vote, poll_end, poll_get
Unreads unreads_get, unreads_ack
Threads threads_get, thread_create, thread_get, thread_delete, thread_update, thread_join, thread_leave, thread_pin, thread_unpin
Users and presence users_online, users_list, users_banned, user_ban, user_unban, user_kick, user_timeout, user_update, user_leave, status_set, status_get
Roles roles_list, role_create, role_update, role_delete, role_reorder, user_roles_get, user_roles_set, self_role_add, self_role_remove, self_roles_list, self_roles_reorder
Reactions reaction_add, reaction_remove
Emoji emoji_add, emoji_delete, emoji_list, emoji_get, emoji_update
Stickers sticker_add, sticker_delete, sticker_list, sticker_get, sticker_update
Attachments attachment_get, attachment_list, attachment_delete
Slash commands slash_register, slash_list, slash_call, slash_response
Voice voice_join, voice_leave, voice_mute, voice_unmute, voice_state
Reports report_create, report_list, report_resolve
Audit log modlog_get, modlog_summary
Access control access_mode_set, access_mode_get, whitelist_add, whitelist_remove, whitelist_list, server_password_set, application_submit, application_list, application_review, application_delete, invite_create, invite_list, invite_delete
Plugins plugin_list, plugin_get, plugin_install, plugin_uninstall, plugin_enable, plugin_disable, plugin_reload, plugin_file_list, plugin_file_get, plugin_file_put

Until a connection signs in, the server accepts only auth, login, register, and application_submit. Any other command gets an error with val: "Authentication required".

auth_mode Accepted commands
rotur auth
cracked auth, login, and register if advertised
cracked-only login, and register if advertised

A signed-in connection must also belong to a current member. If the account left or was removed, commands fail with You are not currently a member of this server.

Sending a sign-in command on a connection that is already signed in switches it to the new account. Connection lifecycle lists the frames that follow.

listener is a value you choose. The server copies it onto every direct response to that request, so you can match them up:

{ "cmd": "message_get", "channel": "general", "id": "b2d4f6a8-1c3e-4a5b-8d7f-0e9c1b2a3d4e", "listener": "request-42" }
{
"cmd": "message_get",
"channel": "general",
"message": { "id": "b2d4f6a8-1c3e-4a5b-8d7f-0e9c1b2a3d4e", "content": "Hello!" },
"listener": "request-42"
}

Rules to rely on:

  • Use a unique string. Other JSON values work, but strings are easiest to track.
  • A request can produce several frames, one frame, or none. Some responses use a different cmd from the request. Each command page says what to expect.
  • Events never carry a listener. When a response is also broadcast, the copy other connections get has the listener removed.
  • Always time out requests you wait for.

A failed command returns one frame with cmd: "error":

{ "cmd": "error", "val": "Channel not found", "src": "messages_get", "listener": "request-42" }

val is text you can show. src names the command that failed.

Failures fall into these groups:

  • Bad JSON. A frame that isn’t a valid JSON object gets {"cmd": "error", "val": "Invalid message format"}, with no src or listener.
  • Not signed in, or not a member. Authentication required or You are not currently a member of this server.
  • Missing permission. Access denied: 'manage_channels' permission required, or Access denied: 'view_audit_log' or 'manage_users' permission required when either of two permissions works.
  • Invalid fields. When a request doesn’t match a command’s schema, val has the form <path>: <message>. The path names the field, using dots for nested objects and [n] for array items. When the problem is the whole request, the path is value. For example:
    • channel: Required
    • limit: Expected integer
    • name: Expected string, received number
    • value: Expected a value for at least one of [id name] (from role_update with neither field)
  • Command-specific failures. Each command page lists its own val strings.
  • Sign-in failures use auth_error instead of error. See common frames.
  • Timeouts and slow mode use rate_limit. See timeouts.

An unknown command from a signed-in connection gets no response at all.

Some commands need an administrative permission before the server even looks at the request:

Permission Commands
manage_channels channel_create, channel_update, channel_move, channel_delete
manage_emojis emoji_add, emoji_update, emoji_delete, sticker_add, sticker_update, sticker_delete
manage_server server_update, server_stats, server_maintenance, attachment_list, every webhook_* command, every plugin_* command
manage_users users_banned, user_ban, user_unban, user_kick, user_timeout, user_update
manage_roles role_create, role_update, role_delete, role_reorder, user_roles_set
manage_access access_mode_set, server_password_set, whitelist_add, whitelist_remove, whitelist_list, invite_list, invite_delete
create_invite invite_create
manage_access or review_applications application_list, application_review
review_reports report_list, report_resolve
manage_config config_get, config_update
view_audit_log or manage_users modlog_get, modlog_summary

The server owner has every permission. So does anyone with a role that has the administrator permission.

Other commands have no permission gate here, but they still check channel permissions, authorship, and similar rules before they change anything. Roles and permissions explains channel permissions.

A moderator can put an account in timeout. The account stays a signed-in member and can keep reading, but most actions are refused until the timeout ends.

A refused action returns rate_limit instead of running:

{
"cmd": "rate_limit",
"src": "reaction_add",
"reason": "You are in timeout for 12 more seconds",
"length": 12000,
"listener": "request-42"
}

length is the time left in milliseconds.

These commands are blocked during a timeout:

  • Messages: message_new, message_edit, message_delete, message_pin, message_unpin, typing
  • Reactions and polls: reaction_add, reaction_remove, message_react_add, message_react_remove, poll_vote, poll_end
  • Threads: thread_create, thread_update, thread_delete, thread_join, thread_pin, thread_unpin
  • Slash commands: slash_register, slash_call, slash_response
  • Voice: voice_join, voice_unmute
  • Profile and roles: status_set, self_role_add, self_role_remove, self_roles_reorder
  • Moderation: user_update, user_ban, user_unban, user_kick, user_timeout, user_roles_set, report_create, report_resolve
  • Administration: every role_*, channel_*, emoji_*, and sticker_* write, every webhook_* write, plugin_install, plugin_uninstall, plugin_enable, plugin_disable, plugin_reload, plugin_file_put, server_update, server_maintenance, config_update, and attachment_delete
  • Access control: access_mode_set, whitelist_add, whitelist_remove, server_password_set, application_review, application_delete, invite_create, invite_delete

Everything else still works, including reads, searches, unreads_ack, thread_leave, voice_leave, voice_mute, and user_leave. HTTP uploads of attachments, avatars, and server images return HTTP 429 during a timeout. Downloads still work.

The server also pushes rate_limit without src or listener to every connection of the account:

  • When the timeout starts, with the full length.
  • When a moderator removes it, with reason: "Unmuted: your timeout has been removed" and length: 0.
  • When it expires, with reason: "Your timeout has expired" and length: 0.

Clear the timeout for that server when length is 0. Slow mode also uses rate_limit, but adds cooldown. See common frames.

A change can produce both a direct response and events for other connections. The connection that made the change gets the response with its listener. Other connections get the event without it, but only if they are allowed to see the change. For example, a message event goes only to connections that can view its channel.

Some events are sent to every signed-in connection, including the one that caused them. Others skip the sender. Server events says which is which.

After ready, a client can tell the server which optional features it understands:

{ "cmd": "capabilities", "val": ["channel_get", "server_side_embeds"] }

The server doesn’t respond. It uses the report in two places:

  • channel_get. When a channel changes, a connection that reported channel_get gets a single channel_get event for that channel. Other connections get a full channels_get list.
  • server_side_embeds. Only connections that reported it receive the message_edit events that add link previews.

Report only features your client actually handles. capabilities has the details.

  • Ignore fields you don’t recognise on known frames.
  • Ignore or log unknown events. Don’t close the socket.
  • Don’t send optional commands the handshake doesn’t list.
  • Keep identifiers exactly as given, including case, unless a schema says to normalise them.
  • Use version for protocol-wide changes, and capabilities for features added one at a time.
  • After a reconnect, rebuild everything tied to the connection. Capabilities and validator keys can change.

The old Python server in the originchats repository is legacy. These docs describe the current OSL server. Support older servers only if you choose to.

Legacy form Current form Notes
message_react_add, message_react_remove reaction_add, reaction_remove The OSL server still accepts the old names. Its events then use the old name too, so handle both.
originChats://<server>/emojis/<id> originChats:<emoji>//<server>/<id> The OSL server still accepts the old emoji reference in reactions.
poll_create, poll_results message_new with a poll embed, poll_get The OSL server doesn’t accept the old commands.
ping answered with pong ping answered with timing fields Keep an old heartbeat handler separate from clock code.
MessagePack frames (?format=msgpack) JSON text frames The OSL server only speaks JSON.
A global field on frames No field Older servers sent it. Ignore it if present.
Guessing bots from names bot: true with user_metadata_v1 Bot status on older servers is unknown.

cracked, cracked-only, and the USR:local_ prefix are current names for server accounts, not legacy ones. desktop and laptop are accepted as device names and become computer. New clients should send computer.

Older servers may leave out auth_mode. Fall back to Rotur-only sign-in only when you know you are talking to such a server.