Configuration
Each server reads its settings from config.json in its database directory. This page lists every key, grouped by feature, with its type, default, and what it changes.
How configuration loads
Section titled “How configuration loads”config.json only needs the values you want to change. At startup the server deep-merges the file over its built-in defaults (src/config/defaults.osl). Nested objects merge key by key, so you can set attachments.max_size without repeating the rest of attachments. Arrays and other values replace the default whole.
Three values have no default and must be non-empty strings in the file:
{ "server": { "name": "Example server", "url": "https://chat.example.com", "owner": { "name": "alice" } }}init writes all three for you. See Run a server.
The server validates the merged result before it starts. If validation fails, it prints the error and exits. The checks are:
server.name,server.owner.name, andserver.urlare non-empty strings.server.urlis a fullhttp://orhttps://URL with a host.websocket.hostis a non-empty string, andwebsocket.portis a whole number from 1 to 65535.notifications,automod,attachments.compression,auth_mode, andcrackedmatch the types listed below.- Every automod rule compiles. See Automod.
Changing settings on a running server
Section titled “Changing settings on a running server”The config_update command changes settings without editing the file. It needs the manage_config permission. The server merges the update into config.json, validates the result, writes it, and swaps in the new settings. If validation or the write fails, nothing changes.
Some limits apply:
- Only the owner can change
DB,websocket,auth_mode, andcracked. - Only the owner can change credentials, and only the S3 keys and credentials under
plugins. A credential is any key namedaccess_key_id,secret_access_key,password,password_hash,secret,token, orvalidator. config_getandconfig_updateresponses show credentials as"***". Sending"***"or""back for an S3 key leaves the stored key unchanged.- Keys under
hosted, and any path listed inhosted.locked_paths, can’t be changed. See Hosting keys.
A few settings are only read at startup. Restart the server after changing them:
websocket.hostandwebsocket.portattachments.storageandattachments.s3.*(the S3 client is created once)attachments.max_size, as far as the HTTP request body limit goes (see Attachments)DB.users.default.roles, for new local accounts
Connected clients read limits and capabilities from the handshake. They see changes only after they reconnect.
Server identity
Section titled “Server identity”| Key | Type | Default | Meaning |
|---|---|---|---|
server.name |
string | none, required | Server name shown to clients. |
server.url |
string | none, required | Public URL of the server. Clients sign requests against it, and the server builds attachment, avatar, and server asset URLs from it. |
server.owner.name |
string | none, required | Username of the owner. A Rotur account with this username gets the owner role each time it logs in. |
server.icon |
string | set by init |
Server icon. A full http(s):// URL is used as is. Any other value is a file name in serverAssets/, served at <server.url>/server-assets/<name>. |
server.banner |
string | set by init |
Server banner. Resolved the same way as server.icon. |
Network
Section titled “Network”{ "websocket": { "host": "127.0.0.1", "port": 5613 }}| Key | Type | Default | Meaning |
|---|---|---|---|
websocket.host |
string | "127.0.0.1" |
Address to listen on. HTTP routes and the WebSocket share it. Use 0.0.0.0 to listen on every IPv4 interface. An IPv6 address such as ::1 is wrapped in brackets for you. |
websocket.port |
number | 5613 |
Port to listen on. A string of digits such as "5613" also works. |
Keep the default loopback address when a reverse proxy runs on the same machine. See Deployment.
Authentication and membership
Section titled “Authentication and membership”{ "auth_mode": "cracked", "cracked": { "allow_registration": true }, "limits": { "max_members": 500 }}| Key | Type | Default | Meaning |
|---|---|---|---|
auth_mode |
"rotur", "cracked", or "cracked-only" |
"rotur" |
Which logins the server accepts. rotur allows Rotur accounts only. cracked allows Rotur accounts and local accounts. cracked-only allows local accounts only. |
cracked.allow_registration |
boolean | true |
Whether new local accounts can register. Existing local accounts can still log in when this is false. Has no effect when auth_mode is rotur. |
limits.max_members |
number | 0 |
Maximum number of joined, unbanned members. 0 means no limit. Checked when someone joins or registers; existing members are never removed. |
access_control.mode |
"open", "whitelist", "password", "application", or "invite" |
"open" |
Starting access mode. The server reads it only when it creates access.json, which happens when the database has none. init always creates access.json in open mode, so on new databases this key has no effect. After that, the mode lives in access.json and changes through access_mode_set. An unknown value starts in open. See Control who can join. |
DB.users.default |
object | {"roles": ["user"]} |
Template for a new Rotur account record. Every key is copied into the record. |
DB.users.default.roles |
string array | ["user"] |
Roles given to new members. Rotur accounts read it on first login. Local accounts use the value read at startup. |
Message limits
Section titled “Message limits”| Key | Type | Default | Meaning |
|---|---|---|---|
limits.post_content |
number | 2000 |
Maximum message length in characters. Longer messages fail with Message too long. Maximum length is <n> characters. |
limits.search_results |
number | 30 |
Most results one messages_search request returns. |
limits.max_reactions_per_message |
number | 20 |
Most distinct emoji one message can carry. Adding another fails with Too many reactions on this message, cannot add more. |
The handshake sends all four limits values to clients.
Per-channel slow mode is a channel setting, not a config key. Set it with /slow or channel_update.
Automod
Section titled “Automod”Automod rejects messages that match blocked words or patterns before they are saved. It is off by default.
{ "automod": { "enabled": true, "blocked_words_file": "automod.txt", "blocked_words": [], "timeout_seconds": 60, "case_sensitive": false, "match_words": true, "bypass_roles": ["owner"] }}| Key | Type | Default | Meaning |
|---|---|---|---|
automod.enabled |
boolean | false |
Turns automod on. |
automod.blocked_words_file |
string | "" |
Path to a rule file, relative to the database directory. "" means no file. |
automod.blocked_words |
string array | [] |
Extra literal words or phrases. Each entry must be non-empty. Regex syntax isn’t supported here. |
automod.timeout_seconds |
integer, 0 or more | 60 |
Timeout applied to the sender after a blocked message. 0 blocks the message without a timeout. |
automod.case_sensitive |
boolean | false |
When false, every rule ignores case. |
automod.match_words |
boolean | true |
When true, literal rules match whole words only. |
automod.bypass_roles |
string array | ["owner"] |
Members holding any of these roles skip automod. [] checks everyone. |
Rule file format
Section titled “Rule file format”The rule file is UTF-8 text with one rule per line:
# Literal word or phraseblockedwordblocked phrase
# Regex, written between slashes/(^|[^\p{L}\p{M}\p{N}_])bl[o0]ckedw[o0]rd($|[^\p{L}\p{M}\p{N}_])/- Blank lines and lines starting with
#are skipped. LF and CRLF line endings both work, and a leading byte order mark is ignored. - A plain line is literal text. With
match_words: true, a match must sit at the start or end of the text, or next to a character that isn’t a letter, mark, digit, or underscore. Soassdoesn’t matchclass. Add plurals and other forms as separate lines. - A line that starts and ends with
/is a Go (RE2) regular expression.match_wordsdoesn’t apply to it, so write your own boundaries. Lookarounds and backreferences aren’t supported. Use inline flags such as/(?i)pattern/, not/pattern/i.
Before matching, the server removes invisible formatting characters (such as zero-width spaces) and collapses runs of whitespace into one space. It doesn’t decode HTML entities, render Markdown, or catch every creative spelling. The stored message is unchanged.
Keep the rule file in the database directory. The file is local policy and isn’t part of the server source. Review it for your community: word lists can’t tell abuse from quotation or reclaimed use.
Reloading rules
Section titled “Reloading rules”Rules load at startup and on every successful config_update. Editing the file alone changes nothing until one of those happens. To reload without a restart, send any automod update:
{ "cmd": "config_update", "updates": { "automod": { "enabled": true } }}A file that can’t be read, a regex that doesn’t compile, or a rule that matches empty text fails validation. At startup the server exits. On config_update the old rules stay active and the error is returned. The error names the problem, for example Unable to read automod blocked_words_file or Invalid automod rule on line <n>: invalid regex or empty match.
What automod checks
Section titled “What automod checks”Automod checks new messages, edits (including thread messages), incoming webhook messages, and replies sent by external slash command handlers. In each, it checks:
- message text
- embed titles, descriptions, HTML, site names, author names, footer text, provider names, and field names and values
- media alt text and HTML
- poll questions and options
- attachment names
- webhook display names and message alias names
It doesn’t check URLs, existing history, fetched link previews, built-in slash command output, system messages, profiles, or thread names.
What happens on a match
Section titled “What happens on a match”The message or edit isn’t saved or broadcast. The sender gets:
{ "cmd": "error", "val": "Message blocked by automod", "src": "message_new"}src is the command that was blocked, such as message_edit or slash_response. Webhooks get HTTP 400 instead.
If timeout_seconds is above 0, the sender also gets a timeout. It never shortens a longer timeout that’s already running, and the owner is never timed out. The sender’s connections receive a rate_limit frame whose length is in milliseconds. If the timeout can’t be saved, the error text becomes Message blocked by automod; failed to save timeout. Webhook messages never cause a timeout.
Each block adds an automod_block entry to the audit log under message_moderation. The entry records the channel and whether a timeout was applied, but not the blocked text.
Emoji and stickers
Section titled “Emoji and stickers”{ "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"] }}| Key | Type | Default | Meaning |
|---|---|---|---|
uploads.emoji_allowed_file_types |
string array | ["gif", "jpg", "jpeg", "png", "webp", "bmp", "avif", "tiff"] |
File extensions allowed for custom emoji. "*" allows every type the server recognises. |
uploads.sticker_allowed_file_types |
string array | same as above | File extensions allowed for stickers. |
The server works out the extension from the MIME type in the uploaded data URI and checks it against the list. It recognises gif, jpg, png, webp, bmp, avif, tiff, and svg. A list entry of jpeg also allows image/jpeg, and tif allows image/tiff.
The server then checks the file signature for PNG, JPEG, GIF, WebP, BMP, and TIFF. AVIF and SVG files have no signature check.
The handshake sends both lists so clients can filter their file pickers. It also sends uploads.uploads_per_minute, which is always 10 and doesn’t follow attachments.uploads_per_minute.
Attachments
Section titled “Attachments”{ "attachments": { "enabled": true, "storage": "local", "max_size": 26214400, "allowed_types": ["image/*", "video/*", "audio/*", "application/pdf"], "uploads_per_minute": 10, "max_attachments_per_user": 200, "free_tier_max_expiration_days": 30 }}| Key | Type | Default | Meaning |
|---|---|---|---|
attachments.enabled |
boolean | true |
Turns attachment uploads and downloads on. |
attachments.storage |
"local" or "s3" |
"local" |
Where file contents go. See Storage backends. |
attachments.local_dir |
string | not set | Local storage only. Directory for new files. When unset, files go in attachments/ in the database directory. |
attachments.max_size |
number | 104857600 (100 MiB) |
Largest file in bytes, checked before and after image processing. |
attachments.max_total_upload_size |
number | not set | Total bytes all unexpired attachments may use. Unset or negative means no limit. Uploads past the limit fail with Attachment storage limit reached. |
attachments.max_attachments_per_user |
number | -1 |
Most unexpired attachments one user can hold. -1 means no limit and 0 blocks uploads. Fails with Attachment limit reached. |
attachments.allowed_types |
string array | ["image/*", "video/*", "audio/*", "application/pdf"] |
Allowed MIME types. Entries can be exact (application/pdf), a family (image/*), or *. An empty array allows everything. |
attachments.uploads_per_minute |
number | 10 |
Uploads one user can make in any 60-second window. 0 or less means no limit. |
attachments.free_tier_max_expiration_days |
number | 7 |
Longest lifetime in days. A longer requested expiry is cut to this. Uploads that don’t ask for an expiry last 7 days. -1 makes every upload permanent. 0 removes the cap. |
The HTTP request body limit is set once at startup to 32 MiB or twice max_size, whichever is larger. Set your proxy’s upload limit to match. See Deployment.
The handshake sends enabled, max_size, allowed_types, max_attachments_per_user, and permanent_tiers to clients.
Image processing
Section titled “Image processing”{ "attachments": { "compression": { "enabled": true, "max_width": 1920, "max_height": 1920, "max_pixels": 25000000, "jpeg_quality": 85 } }}| Key | Type | Default | Meaning |
|---|---|---|---|
attachments.compression.enabled |
boolean | true |
Turns resizing and JPEG recompression on. |
attachments.compression.max_width |
integer, 1 or more | 1920 |
JPEG and PNG images wider than this are scaled down to fit. |
attachments.compression.max_height |
integer, 1 or more | 1920 |
JPEG and PNG images taller than this are scaled down to fit. |
attachments.compression.max_pixels |
integer, 1 or more | 25000000 |
Largest image, in pixels, the server will decode. Larger images that need processing fail with Image dimensions are too large to process safely. |
attachments.compression.jpeg_quality |
integer, 1 to 100 | 85 |
Quality used when re-encoding JPEG images. |
How processing works:
- EXIF metadata is always removed from uploads, even when
enabledisfalse. - Only still JPEG and PNG images are rotated, resized, or re-encoded. Animated images and other formats are stored as they are.
- A resized or re-encoded image replaces the original only if it’s smaller. An image rotated to match its EXIF orientation always uses the rotated version.
- An EXIF orientation on a format the server can’t rotate is rejected.
Storage backends
Section titled “Storage backends”With "storage": "local", file contents go in attachments/ in the database directory, or in attachments.local_dir if you set it. Reads check attachments/ first, then local_dir.
S3 storage
Section titled “S3 storage”With "storage": "s3", file contents go to an S3-compatible bucket, such as Cloudflare R2.
{ "attachments": { "storage": "s3", "s3": { "endpoint_url": "https://<account-id>.r2.cloudflarestorage.com", "bucket": "originchats", "region": "auto", "public_url": "https://cdn.example.com", "presigned_urls": false, "presigned_expiry": 3600 } }}| Key | Type | Default | Meaning |
|---|---|---|---|
attachments.s3.endpoint_url |
string | "" |
S3 API endpoint. Required. |
attachments.s3.bucket |
string | "" |
Bucket name. Required. |
attachments.s3.access_key_id |
string | "" |
Access key. Required. Prefer the environment variable. |
attachments.s3.secret_access_key |
string | "" |
Secret key. Required. Prefer the environment variable. |
attachments.s3.region |
string | "auto" |
Bucket region. |
attachments.s3.public_url |
string | "" |
Public base URL for the bucket. When set, attachment URLs point straight at it. |
attachments.s3.presigned_urls |
boolean | false |
When there is no public_url, hand out temporary signed bucket URLs. |
attachments.s3.presigned_expiry |
number | 3600 |
Lifetime of a signed URL, in seconds. |
Each S3 key can also come from the environment. The server uses the first non-empty value from the config key, then R2_<KEY>, then S3_<KEY>, where <KEY> is the key name in capitals. For example, R2_SECRET_ACCESS_KEY or S3_SECRET_ACCESS_KEY. Put credentials in the environment or .env, not in config.json.
If the endpoint, bucket, access key, or secret key is missing, the server prints attachments.s3.<key> is required when attachment storage is s3 and exits.
When neither public_url nor presigned_urls is set, clients download through the server at /attachments/<id>.
Reserved attachment keys
Section titled “Reserved attachment keys”These keys are in the defaults but don’t change how uploads are stored. Every upload is saved with permanent: false.
| Key | Type | Default | Meaning |
|---|---|---|---|
attachments.permanent_tiers |
string array | ["pro", "max"] |
Sent to clients in the handshake. Doesn’t make uploads permanent. |
attachments.permanent_expiration_days |
number | 365 |
Not read by the server. |
attachments.subscription_cache_ttl |
number | 300 |
Not read by the server. |
Notifications
Section titled “Notifications”The server can send push notifications through Rotur when members are mentioned or replied to.
{ "notifications": { "enabled": true, "mode": "mentions", "source": "originChats", "include_content": true, "max_preview_length": 160 }}| Key | Type | Default | Meaning |
|---|---|---|---|
notifications.enabled |
boolean | true |
Turns push notifications on. |
notifications.mode |
"mentions" or "all" |
"mentions" |
mentions notifies people who are mentioned directly, through a role, or by a reply. all notifies every member who can see the channel. |
notifications.source |
non-empty string | "originChats" |
Source name sent with each notification. |
notifications.include_content |
boolean | true |
When false, the body is always Open OriginChats to view this message. |
notifications.max_preview_length |
integer, 0 or more | 160 |
Longest message preview in characters, followed by ... when cut. With 0, the body describes the message instead, for example Sent an attachment. |
Notifications need a Rotur token. The server uses ROTUR_NOTIFICATION_TOKEN if it’s set, and otherwise ROTUR_TOKEN, which init writes to .env. At startup the server logs a warning if either the token or the .env file is missing.
The author, members who have left or are banned, members severed from the author, and members who can’t see the channel never get a notification. Delivery happens after the message is saved. A failed request is retried up to three times on network errors, HTTP 429, and 5xx responses.
Welcome messages
Section titled “Welcome messages”{ "welcome": { "enabled": true, "channel": "welcome", "message": "Welcome {username}! Thanks for joining the server.", "first_time_only": true }}| Key | Type | Default | Meaning |
|---|---|---|---|
welcome.enabled |
boolean | true |
Would post a system message when a member logs in. Not active in the current server. |
welcome.channel |
string | "welcome" |
Channel to post in. If it’s welcome and no text channel has that name, the message goes to general. |
welcome.message |
string | "Welcome {username}! Thanks for joining the server." |
Message text. {username} is replaced with the member’s username. |
welcome.first_time_only |
boolean | true |
Welcome each account once. The server remembers welcomed accounts in welcome.json. |
Older databases may have welcome settings under plugins.welcome. The server uses them only when config.json has no top-level welcome object. Move them to welcome.
Audit log
Section titled “Audit log”{ "modlog": { "enabled": true, "retention": { "default": 90, "user_moderation": 365, "role_management": 365, "channel_management": 180, "message_moderation": 90, "server_management": 180 } }}| Key | Type | Default | Meaning |
|---|---|---|---|
modlog.enabled |
boolean | true |
Records moderation and admin actions in modlog.jsonl. When false, nothing new is recorded. |
modlog.retention.default |
number | 90 |
Days to keep entries in a category without its own setting. |
modlog.retention.user_moderation |
number | 365 |
Days to keep bans, kicks, timeouts, and severs. |
modlog.retention.role_management |
number | 365 |
Days to keep role changes. |
modlog.retention.channel_management |
number | 180 |
Days to keep channel changes. |
modlog.retention.message_moderation |
number | 90 |
Days to keep message moderation, including automod blocks. |
modlog.retention.server_management |
number | 180 |
Days to keep server, config, and maintenance changes. |
Expired entries are removed from modlog.jsonl the next time the audit log is read.
Link embeds
Section titled “Link embeds”{ "embeds": { "enabled": true, "proxy_url": "https://proxy.mistium.com?url=&limit=1048576" }}| Key | Type | Default | Meaning |
|---|---|---|---|
embeds.enabled |
boolean | true |
Fetches link previews for URLs in messages and adds server_side_embeds to the handshake capabilities. |
embeds.proxy_url |
string | "https://proxy.mistium.com?url=&limit=1048576" |
Proxy used to fetch pages. If it contains {url}, the encoded link replaces it. Otherwise the link goes in the url query parameter. An empty value falls back to the default. |
Links to local and private addresses, such as localhost, 10.x.x.x, or *.internal, are never fetched. Direct image, video, and audio links are embedded without a fetch.
Plugins
Section titled “Plugins”| Key | Type | Default | Meaning |
|---|---|---|---|
plugins |
object | {} |
Settings for server plugins, keyed by plugin name. |
plugins.<name> |
object | not set | Overrides for that plugin’s own defaults. Merged over the defaults the plugin registers. |
Environment variables
Section titled “Environment variables”These come from the process environment or from .env in the database directory. Values already in the environment take priority.
| Variable | Meaning |
|---|---|
ROTUR_TOKEN |
Server’s Rotur token. init writes it. Used for notifications (as a fallback), forwarding reports to Rotur, and Rotur-linked roles. |
ROTUR_NOTIFICATION_TOKEN |
Token used for push notifications instead of ROTUR_TOKEN. |
R2_<KEY>, S3_<KEY> |
S3 settings. See S3 storage. |
ORIGINCHATS_LOG_LEVEL |
Lowest log level printed: debug, info, success, warn, or error. Default info. LOG_LEVEL is used if this is unset. |
ORIGINCHATS_SUPERVISED, ORIGINCHATS_UPDATER |
Set by scripts/run.sh. They enable remote restart and update. Don’t set them yourself. |
Hosting keys
Section titled “Hosting keys”The hosted block is for managed hosting and isn’t in the defaults. Leave it out on a self-hosted server.
| Key | Type | Meaning |
|---|---|---|
hosted |
object | When present, members can forward reports to Rotur (report_create with destination: "rotur"). Without it, that request fails with This server cannot forward reports to rotur. config_update can never change it. |
hosted.report_url |
string | Endpoint that receives forwarded reports. Defaults to https://api.host.originchats.com/hooks/report. |
hosted.locked_paths |
string array | Config paths, such as "attachments.max_size", that config_update refuses to change. Fails with These settings are managed by your hosting plan: <paths>. |
A forwarded report is posted to hosted.report_url along with the server’s ROTUR_TOKEN, name, and URL. If ROTUR_TOKEN is empty or the endpoint doesn’t answer with HTTP 200, the member gets Could not submit the report, please try again later.
Unused keys
Section titled “Unused keys”These keys are in the defaults but the server doesn’t act on them. You can leave them out of config.json.
| Key | Default | Notes |
|---|---|---|
rate_limiting.enabled |
true |
Not read. Setting it has no effect: timeouts always apply, and message pacing comes from each channel’s slow_mode_cooldown. See Moderation and audit. |
rate_limiting.messages_per_minute |
60 |
Not read. |
rate_limiting.burst_limit |
10 |
Not read. |
rate_limiting.cooldown_seconds |
30 |
Not read. |
service.name |
"originChats" |
Not read. |
service.version |
"1.0.0" |
Reported as version by server_maintenance status. Not tied to the running build. |
setup.oauth_callback_port |
56199 |
Written by init for reference. Setup always uses port 56199. |
DB.channels |
"db/channels.json" |
Legacy path. The database directory passed to use decides where files live. |
DB.users.file |
"db/users.json" |
Legacy path, as above. |
