Member Rooms
Member rooms let ordinary members create and run their own Discord channels — text or voice — that clean themselves up afterwards. A server admin defines a preset; members create rooms from it, and each creator manages only their own room.
/room create name:Study Session→ a voice channel appears, its creator can rename it, lock it, invite people, and it disappears 15 minutes after the last person leaves.
The two entry points
/room is a built-in command family. A guild enables it like any other
built-in command; per-guild enable/disable and the Discord permission gate come
from built_in_command_overrides. Its subcommands are create, rename,
invite, kick, mute, unmute, lock, unlock, limit, transfer,
extend and delete.
create also takes visibility and voice. Both are optional: an omitted one
falls through to the preset default, and the preset decides whether the member's
choice counts at all.
A join-to-create lobby needs no command. Set lobby_channel_id on a preset
and joining that voice channel makes the member a room and moves them into it.
The gateway holds no database, so it asks the app
(POST /api/rooms/{guildId}/lobby), which decides and creates; the gateway only
performs the move. Each reply carries the guild's lobby channel ids, which the
gateway caches for five minutes — otherwise every voice join in every guild
would cost a database read.
Presets
Presets live in channel_presets and are edited under Server → Member
Rooms. One guild can have several. The preset is read at run time, not frozen
at creation, so changing it governs rooms that already exist.
| Field | What it does |
|---|---|
channel_type |
2 for voice, 0 for text |
category_mode |
own, per_user, or existing |
category_name / parent_id |
What to call its category, or which one to use |
default_visibility / allow_visibility_choice |
public or private, and whether members may pick |
default_voice_mode / allow_voice_mode_choice |
open, ptt or listen, and whether members may pick |
name_pattern |
Supports template variables, e.g. {user.name}'s room |
lobby_channel_id |
Join-to-create trigger channel (voice only) |
allow_role_ids / deny_role_ids |
Who may create a room |
lifetime_mode |
idle, fixed or manual |
ttl_minutes / idle_minutes / grace_minutes |
The clocks |
extend_minutes / max_extensions |
What /room extend buys |
max_per_user / max_per_guild / max_renames |
Caps |
owner_can |
Verbs delegated to the creator |
owner_allow / everyone_deny |
Permission overwrites applied at creation |
Why it is not called a template
commands.source_template_key already means integration command template,
which is cloned into a guild with no live link — editing the template never
touches the command. A preset is the opposite: it is a live policy the verbs and
the reaper read every time. Two words, two meanings, deliberately kept apart.
Where rooms go
category_mode decides. On existing the room is created under the
category the admin picked in parent_id, or at the top level if they picked
none.
On own the bot keeps its own categories for that preset. It creates one
named after category_name (or the preset) the first time a room is needed,
reuses it, and makes another — Rooms 2, Rooms 3 — once the first is at
Discord's 50-children cap. The ids it has made are appended to
managed_category_ids, which is deliberately not writable from the
dashboard: room creation trusts that list for the overflow rollover, and a
hand-supplied id would let the dashboard fill somebody else's category to the
cap. A category deleted by hand is skipped rather than resurrected.
On per_user each member gets a category of their own. It is named from
category_name with {user} swapped for the member's name ({user}'s rooms
when blank), and the room row records it in user_category_id. Their next room
on the same preset goes in the same category while they still have one open
there. When a room closes — /room delete, the reaper, or a hand delete in
Discord — the category is deleted once no open room points at it and Discord
shows nothing else under it. A channel somebody moved in by hand keeps it alive.
A transferred room keeps the old owner's category until it closes.
Resolving the category is best-effort. If listing or creating fails, the room is still created without one — the member asked for a room, not for filing.
Visibility and voice mode
Both are decided at creation and written into the permission overwrites, so a
private room is never briefly visible to @everyone and a listen-only room
never has a moment where anyone can talk.
Visibility owns VIEW_CHANNEL and CONNECT on the @everyone overwrite.
everyone_deny stays as the admin's own baseline of extra denies on top.
A public room has to undo an inherited deny, not merely decline to add one.
Voice mode applies to voice rooms only:
open— Discord's normal behaviour.ptt— deniesUSE_VAD, so Discord requires push-to-talk. This applies to the owner too: it is a property of the room, not a privilege.listen— deniesSPEAKfor everyone but the owner, who hands out the microphone with/room unmute.
listen is how "everyone arrives muted" is done. It is a permission overwrite
rather than a real server mute, which means it survives a bot restart, cannot be
shrugged off by the member, needs no gateway state machine, and never collides
with a moderator's own server mute of that person elsewhere in the guild.
/room mute and /room unmute read the member's existing overwrite and change
only the SPEAK bit. An overwrite is written with PUT, which replaces the
pair outright, so muting somebody the owner had already invited must not
quietly revoke their VIEW_CHANNEL as well.
/room lock and /room unlock are the same axis as visibility: locking makes
the room private, and unlocking returns it to the preset's own default rather
than to public — so a preset that only ever makes private rooms cannot be
talked into exposing one.
Who may create a room
Two gates, and both must pass:
- The command's own permission gate.
/roomcarries whateverdefault_member_permissionsthe guild sets on it. - The preset's role lists. An empty
allow_role_idsmeans "anyone who got past the first gate". A role indeny_role_idsalways loses.
The second gate exists because Discord's per-command role permissions cannot be set with a bot token — that endpoint needs a user Bearer token with Manage Guild — so role-level control has to be enforced in our own code.
What the creator can do
Only the verbs in owner_can, and only against their own room. Every verb
resolves the caller's room out of managed_channels rather than trusting a
channel id from the interaction, so /room kick cannot reach a channel the
caller does not own. A member with Manage Channels can run any verb on any
room in the guild.
The creator is never granted MANAGE_CHANNELS on their room. It is the
tempting shortcut for rename and user-limit, and it hands the member powers
owner_can cannot take back: editing the channel's permissions freely, and
deleting the channel out from under its ownership row. The permission is
stripped server-side even if the preset form is tampered with. Every verb runs
through the bot instead.
Lifetimes
idle— a voice room closes once it has been empty foridle_minutes. Occupancy is counted fromlive_voice_states, excluding bots, so a music bot alone in an abandoned room does not keep it alive.fixed— the room closesttl_minutesafter it was created, whether or not anyone is in it.manual— the room never closes on its own.
A text room has no occupancy to measure, so idle falls back to the fixed TTL
rather than inventing a "last message" scan.
grace_minutes protects a brand new room: it is never reaped inside that
window, so a room cannot vanish between being created and its creator finishing
the voice handshake.
The reaper
reap_managed_channels runs every minute as a superadmin workflow, beside
scheduled message dispatch. It is written to cost nothing when nothing is open:
- Read active rows from
managed_channels. No rooms means the tick ends here — one indexed read. - Read human occupancy for the voice rooms among them, in one query.
- Record transitions: a room seen empty for the first time gets
empty_since; a room somebody rejoined has it cleared. - Delete what is due, then close the row. A
404means somebody deleted the channel by hand — that is a success, and the row still closes.
Idle is edge-recorded, not measured backwards: the countdown starts from the first scan that saw the room empty. That makes the interval predictable regardless of when the last person actually left.
Events.ChannelDelete also reports to POST /api/rooms/{guildId}/channel-deleted,
so a hand-deleted room stops counting against its owner's cap immediately
rather than at the next scan.
Discord limits worth knowing
- Renames are throttled to 2 per 10 minutes per channel. The REST client
would sit on the 429 until the interaction expired, so
max_renamesrefuses with an explanation instead. - A guild caps at 500 channels, a category at 50 children.
max_per_guildis what keeps rooms from walking into that wall; past it, room creation reports the server is out of channel slots. - A bot cannot grant permissions it does not hold. The default invite is Administrator, but a guild that installed SpaceBot with a narrower bitfield will see room creation fail with a permissions message.
user_limitis 0–99, where 0 means unlimited.
Where the code is
| Piece | File |
|---|---|
| Policy (pure decisions) | src/lib/discord/managed-channel-policy.ts |
| Room operations | src/lib/automation/managed-channels.ts |
| Tables | src/lib/db/managed-channels.ts, migrations/0060_managed_channels.sql, migrations/0064_room_visibility_voice_category.sql, migrations/0068_room_per_user_category.sql |
/room command |
migrations/0061_room_built_in_command.sql, migrations/0065_room_command_shape_options.sql |
| Action types | CREATE_MANAGED_CHANNEL, MANAGE_MANAGED_CHANNEL in src/lib/db/automations.ts |
| Reaper | src/lib/server/managed-channel-reaper.ts |
| Lobby + reconciliation | src/routes/api/rooms/[guildId]/, src/lib/discord/gateway.ts |
| Dashboard | src/routes/admin/[serverId]/rooms/ |