Subreddit Config Schema¶
The per-subreddit toolbox config (removal reasons, mod macros, ban macros) is stored as JSON on a wiki page. There are two schema versions, split across two wiki pages:
Schema |
Page |
Written for |
|---|---|---|
v1 |
|
6.x clients (legacy / compatibility mirror) |
v2 |
|
7.x clients (NXG layout) |
The in-memory model is always v2. normalizeConfig up-converts anything it reads (v1 pages, hand-edited pages, cached configs) and encodeClassicConfig (extension/data/util/wiki/schemas/config/codec.ts) down-converts on every write to the legacy page. Subs that could not be moved onto the NXG layout (the viewer lacks mod or wiki permissions, or setup failed) read and write only the legacy page, still in v1 on the wire; migrated subs with 6.x compatibility enabled double-write.
Schema reference¶
Top-level config object¶
{
"ver": 2,
"removalReasons": { ... },
"modMacros": [ ... ],
"banMacros": { ... },
"showRetiredUsernoteShards": false,
"requireUsernoteType": false,
"requireUsernoteText": true,
"requireUsernoteLink": false,
"usernoteRequirementOption": "leave",
"trainingMods": [],
"guardedActions": ["approve", "remove"],
"proposalRetentionDays": 14
}
Field |
Type |
Description |
|---|---|---|
|
integer |
Schema version; |
|
|
Removal reasons configuration block; always present |
|
|
Mod macro entries; empty array when none configured |
|
|
Ban form defaults; |
|
boolean |
NXG-only. When |
|
boolean |
NXG-only. When |
|
boolean |
NXG-only. When |
|
boolean |
NXG-only. When |
|
string |
NXG-only. How the three |
|
|
NXG-only. Usernames of moderators in training mode for this subreddit; their in-scope moderation actions are captured as proposals for review instead of being performed. Compared case-insensitively. Defaults to |
|
|
NXG-only. Optional allowlist narrowing which captured action types are guarded for this subreddit’s trainees. Absent (the default) guards every action type; a present array guards only the listed types (trainees take any other action directly); an empty array guards nothing. Recognized proposal action discriminants are |
|
integer |
NXG-only. How many days a resolved proposal is retained before pruning, unless its proposer dismisses it sooner. Clamped to an integer in |
The toolbox-nxg page also carries a Toolbox.Utils.compatibilityWrites boolean, the subreddit’s 6.x compatibility flag, written on every NXG config save. It is layout metadata rather than config and is stripped from the v1 mirror. When compatibility is turned off, the legacy toolbox page is replaced by the tombstone {"Toolbox.Utils.wikiLayout": "nxg"}.
Note
Domain tags and usernote type colors were previously stored here as domainTags: DomainTag[] and usernoteColors: UserNoteColor[]. Both now live on dedicated wiki pages — domain tags on toolbox-nxg/domain-tags (with a richer schema including approval/removal counts, glob patterns, notes, and alert thresholds; see Domain Tags Schema), and usernote types in the usernotes manifest’s types array (see Usernotes Schema). NXG strips both legacy fields from the config page on load so they don’t round-trip back. The v1 mirror still carries them for 6.x: encodeClassicConfig re-injects domainTags (name, color, note only) and usernoteColors (key, text, color only) from their NXG sources.
RemovalReasonsConfig¶
{
"reasons": [ ... ],
"header": "---\n\n*I am a bot...*",
"footer": "",
"pmsubject": "Your {kind} was removed from /r/{subreddit}",
"logsub": "moderationlog",
"logtitle": "Removed: {kind} by /u/{author}",
"logreason": "",
"removalOption": "suggest",
"typeReply": "reply",
"typeStickied": false,
"typeLockComment": false,
"typeCommentAsSubreddit": false,
"typeAsSub": false,
"autoArchive": false,
"typeLockThread": false,
"editableReasonsEnabled": false
}
Field |
Required |
Type |
Description |
|---|---|---|---|
|
yes |
array |
List of configured removal reasons |
|
no |
string |
Markdown prepended to every removal message |
|
no |
string |
Markdown appended to every removal message |
|
no |
string |
Name of another subreddit whose removal reasons to use instead |
|
no |
string |
Subject line template for removal PMs; supports substitution tokens |
|
no |
string |
Subreddit to post the removal log to |
|
no |
string |
Title template for the removal log post; supports substitution tokens |
|
no |
string |
Default reason text pre-filled in the log post; supports substitution tokens |
|
no |
string |
How delivery settings apply to other mods: |
|
no |
string |
Default reply type: |
|
no |
boolean |
Whether the reply is stickied by default |
|
no |
boolean |
Whether the reply locks the removed comment by default |
|
no |
boolean |
Whether the reply is sent as the subreddit by default |
|
no |
boolean |
Whether the removal message is sent via modmail as the subreddit by default |
|
no |
boolean |
Whether modmail threads are auto-archived after sending by default |
|
no |
boolean |
Whether the target thread is locked after removal by default |
|
no |
boolean |
When true, moderators may edit reason text before sending |
|
no |
|
NXG-only. Maps report text to removal reasons that are pre-selected in the removal overlay when a queue item’s report matches. Dropped entirely when empty; stripped from the v1 mirror. |
|
no |
|
NXG-only. State for the opt-in one-way import of Reddit’s native removal reasons. Dropped entirely when empty; stripped from the v1 mirror. |
RemovalReason¶
{
"id": "abc12345",
"title": "Rule 1: No spam",
"text": "Your post has been removed. Which rule was broken?\n\n{choice#rule}\n- Rule 1: No spam\n- Rule 2: Be civil\n\nPlease review our rules.",
"removePosts": true,
"flairText": "Removed",
"flairCSS": "",
"flairTemplateID": ""
}
Field |
Required |
Type |
Description |
|---|---|---|---|
|
no |
string |
Stable 8-character base-36 identifier; assigned by NXG, absent in v1 mirrors |
|
yes |
string |
Display title shown in the removal overlay |
|
yes |
string |
Markdown body of the removal message; may contain substitution and interactive tokens (v2) |
|
no |
boolean |
When |
|
no |
boolean |
|
|
yes |
string |
Post flair text to apply after removal; empty string for none |
|
yes |
string |
Post flair CSS class to apply; empty string for none |
|
yes |
string |
Post flair template ID to apply; empty string for none |
|
no |
boolean |
When true, the moderator may edit this reason’s text before sending |
|
no |
string |
Default usernote text pre-filled when this reason is selected |
|
no |
string |
Key of the usernote type ( |
|
no |
string |
NXG-only. Reddit’s id for the native removal reason this reason mirrors; registers the reason in Reddit’s mod log on removal, and links the reason for syncing. Reasons carrying it are excluded from the v1 mirror entirely |
Interactive tokens in reason text¶
v2 reason text embeds fill-in fields as brace tokens (v1 used limited HTML; see Schema Changes for the full syntax and conversion rules):
Token |
Field |
|---|---|
|
Single-line text field, inline |
|
Multi-line text field, inline |
|
Pick-one field as a block: the marker on its own line, each list item an option |
|
Pick-one field inside a line (mid-sentence or as a link’s text); |
|
Optional slug-safe id ( |
Older v2 pages stored pick-one fields as a selects array on the reason, referenced from the text as {select:name}. Normalization folds each definition into the text as a {choice} block and drops the field; it is never written.
NativeReasonSyncState¶
NXG-only. Stored in RemovalReasonsConfig.nativeSync; stripped from the v1 mirror. Tracks the opt-in, one-way import of a subreddit’s native removal reasons (those configured in Reddit’s own Mod Tools) into its toolbox reasons.
Reddit owns each imported reason’s title and text, which are overwritten on every sync. Everything else on the reason – flair, usernote defaults, removePosts/removeComments, editable – stays under moderator control and is preserved. A reason deleted upstream is removed from the toolbox list; a reason deleted in the toolbox editor is added to ignored so it is not re-imported.
Imported reasons are not written to the legacy toolbox mirror at all, since 6.x cannot register a native reason id and rebuilds reason objects wholesale on save. A subreddit whose reasons are all imported therefore writes an empty reasons list to the mirror, and moderators still on 6.x will not see them.
{
"enabled": true,
"fingerprint": "3a7f21c49b0e8d55",
"lastSyncedAt": 1700000000000,
"ignored": ["a1b2c3d4-..."]
}
Field |
Required |
Type |
Description |
|---|---|---|---|
|
no |
boolean |
Whether the sync is on for this subreddit. Only ever stored as |
|
no |
string |
Order-independent digest of the native reason set as of the last applied sync, used to skip the merge when nothing upstream has changed |
|
no |
number |
Epoch milliseconds of the last applied sync; displayed in the config editor |
|
no |
|
Native reason ids a moderator deleted locally; never re-imported. Dropped entirely when empty |
SuggestedReasonMapping¶
NXG-only. Stored in RemovalReasonsConfig.suggestedReasons; stripped from the v1 mirror. Each mapping links report text to one or more removal reasons. When a queue item carries a report whose text contains the mapping’s pattern (case-insensitive substring), the referenced reasons are pre-selected when the moderator opens the removal overlay. Reports filed by any moderator or bot are matched by default; user reports are matched only when includeUserReports is set. See Removal Reasons → Suggested removal reasons.
{
"id": "sug00001",
"pattern": "low effort post",
"includeUserReports": true,
"reasonIds": ["abc12345"]
}
Field |
Required |
Type |
Description |
|---|---|---|---|
|
no |
string |
Stable 8-character base-36 identifier; assigned by NXG, may be absent in hand-edited configs |
|
yes |
string |
Report text to look for, matched as a case-insensitive substring; an entry with an empty pattern is dropped |
|
no |
boolean |
When |
|
yes |
|
Ids of the |
MacroConfig¶
Mod macros are stored in modMacros as an array of objects. All fields except text are optional.
{
"id": "xyz98765",
"title": "Lock and warn",
"text": "This thread has been locked, {author}, because it has run its course.",
"remove": false,
"lockthread": true,
"distinguish": true,
"sticky": true,
"contextpost": true,
"contextcomment": false,
"contextmodmail": false
}
Field |
Required |
Type |
Description |
|---|---|---|---|
|
no |
string |
Stable 8-character base-36 identifier; absent in v1 mirrors |
|
yes |
string |
Macro reply text; supports markdown and substitution tokens |
|
no |
string |
Display title shown in the macro picker |
|
no |
boolean |
Remove the target post or comment |
|
no |
boolean |
Approve the target post or comment |
|
no |
boolean |
Mark the target as spam |
|
no |
boolean |
Ban the target post/comment author |
|
no |
boolean |
Unban the target post/comment author |
|
no |
boolean |
Mute the target author in the subreddit |
|
no |
string |
Flair template ID to apply to the author |
|
no |
string |
Display text for the flair template |
|
no |
boolean |
Lock the target post or comment thread |
|
no |
boolean |
Lock the reply posted by this macro |
|
no |
boolean |
Sticky the macro reply (only effective on top-level comments) |
|
no |
boolean |
Archive the modmail thread after sending |
|
no |
boolean |
Highlight the modmail thread after sending |
|
no |
boolean |
Distinguish the macro reply as a moderator comment |
|
no |
boolean |
Post the reply as the subreddit ModTeam account via official removal message |
|
no |
boolean |
Show this macro in post contexts; defaults to |
|
no |
boolean |
Show this macro in comment contexts; defaults to |
|
no |
boolean |
Show this macro in modmail contexts; defaults to |
BanMacros¶
Ban form defaults stored in banMacros. The field is null when not configured.
{
"banNote": "Permanent ban: repeated rule violations",
"banMessage": "You have been permanently banned for repeatedly violating our rules.",
"defaultBanPermanent": true,
"defaultBanDuration": 0,
"banDurationPresets": [3, 7, 30]
}
Field |
Required |
Type |
Description |
|---|---|---|---|
|
yes |
string |
Internal mod note pre-filled into the ban form |
|
yes |
string |
Ban message pre-filled into the ban form (sent to the banned user) |
|
yes |
boolean |
Whether the ban defaults to permanent |
|
yes |
integer |
Default temporary ban duration in days; |
|
yes |
|
Quick-select duration buttons in the ban form (days, 1–999) |
Substitution tokens¶
Removal reason text, headers, footers, log titles, and macro text support substitution tokens. These are bare {word} tokens with no colon — distinct from the interactive brace tokens described in v2 vs v1.
Token |
Replaced with |
|---|---|
|
Username of the post or comment author |
|
Name of the subreddit |
|
|
|
Title of the post |
|
Permalink to the removed post or comment |
|
Domain the post links to |
|
Username of the moderator sending the removal |
|
Body of the removed item, quoted as markdown (lines prefixed with |
|
Reddit fullname of the removed item (e.g. |
|
Short base-36 id of the removed item |
|
URL the post links to |
|
Body without markdown quoting |
|
URL-encoded body, for use inside markdown links |
|
URL-encoded title, for use inside markdown links |
Unknown {…} content (no colon, no match) is left in the text untouched.
v2 vs v1¶
No encoding. v1 stores removal reason text, the removal header/footer, and macro text escape()-encoded because 6.x unescape()s them unconditionally on read. v2 stores every string as plain text. (normalizeConfig only URI-decodes configs with ver < 2, so a literal %20 in v2 text survives.)
Interactive tokens instead of limited HTML. v1 reason text could embed literal <input>, <textarea>, and <select>/<option> elements that the removal dialog turns into fill-in fields. v2 replaces them with brace tokens, which can’t collide with reddit markdown and sit naturally beside the existing substitution tokens ({subreddit}, {author}, …, which are unchanged):
{input: placeholder text}
{textarea: placeholder text}
{choice#rule}
- Your post breaks rule 1 | see [the rules](https://example.com/rules)
- Your post breaks rule 2
This is a [{choice: repost | duplicate}](https://example.com/wiki/reposts).
A pick-one {choice} field comes in two forms, and its options always live in the reason text. The block form puts the marker on its own line with a markdown list of options below it; a | in a block option is plain text. The inline form, {choice: a | b}, sits inside a line (mid-sentence, or as a link’s text) and separates its options with |, with \| for a literal pipe. Either way the picked option replaces the field exactly where it sits.
Each token may carry an optional stable id used to persist the entered or chosen value between overlay opens: {input#flightnum: Flight number}, {choice#rule}, {choice#kind: a | b}. A choice’s id maps to the HTML id attribute of its <select> on the v1 mirror. Token content cannot contain braces (the serializer substitutes parens), and option line breaks collapse to spaces because legacy options are single-line.
The codec for both directions lives in extension/data/util/wiki/schemas/shared/tokens.ts. Conversion notes:
<br>becomes a paragraph break (\n\n); the v1 mirror keeps plain newlines, which 6.x handles fine.A
<select>on a line of its own becomes a{choice}block; one that shares its line with other text becomes an inline{choice: ...}in the same place. Both down-convert to a<select>where they stand.A legacy
<option value="x">label</option>keepsx(the value is what 6.x inserted into the removal message); the label is dropped.The older v2
selectsarray (referenced as{select:name}) is folded into the text as{choice}blocks on normalize and never written.
Stable entry ids. Every removal reason, mod macro, and suggested-reason mapping carries an id (eight base-36 characters, unique across all three lists). Ids are assigned by the editing UI on create and backfilled by ensureStableIds during normalization; they are stripped from the v1 mirror (6.x rebuilds entries wholesale on save, so they wouldn’t survive there anyway — they’re re-backfilled on the next normalize). They exist so reordering and cross-references don’t have to rely on array indexes.
Versioning¶
ver on the page selects the schema. configSchema / configMinSchema / configMaxSchema in extension/data/util/wiki/schemas/config/schema.ts define what a build writes and accepts. configMigrations is a registry of in-place upgrade steps keyed by the version each upgrades from; it is currently empty because the v1 → v2 upgrade has no discrete migration — its string decode and HTML-to-token conversion run unconditionally in normalizeConfig (the conversion doubles as self-healing for v2 pages). To bump the schema again, add a migration keyed by the version it upgrades from, raise configSchema/configMaxSchema, and extend encodeClassicConfig if the new fields need a v1 representation.