Skip to content

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.

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, and server.url are non-empty strings.
  • server.url is a full http:// or https:// URL with a host.
  • websocket.host is a non-empty string, and websocket.port is a whole number from 1 to 65535.
  • notifications, automod, attachments.compression, auth_mode, and cracked match the types listed below.
  • Every automod rule compiles. See Automod.

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, and cracked.
  • Only the owner can change credentials, and only the S3 keys and credentials under plugins. A credential is any key named access_key_id, secret_access_key, password, password_hash, secret, token, or validator.
  • config_get and config_update responses show credentials as "***". Sending "***" or "" back for an S3 key leaves the stored key unchanged.
  • Keys under hosted, and any path listed in hosted.locked_paths, can’t be changed. See Hosting keys.

A few settings are only read at startup. Restart the server after changing them:

  • websocket.host and websocket.port
  • attachments.storage and attachments.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.

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.
{
"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.

{
"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.
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 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.

The rule file is UTF-8 text with one rule per line:

# Literal word or phrase
blockedword
blocked 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. So ass doesn’t match class. Add plurals and other forms as separate lines.
  • A line that starts and ends with / is a Go (RE2) regular expression. match_words doesn’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.

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.

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.

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.

{
"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": {
"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.

{
"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 enabled is false.
  • 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.

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.

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>.

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.

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": {
"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.

{
"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.

{
"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.

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.

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.

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.

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.