How to build a Discord temp-voice (Join to Create) bot in TypeScript
Many community servers drown in empty voice channels. The "Join to Create" pattern fixes that: there is one hub channel, and when a member joins it, the bot creates a personal voice channel, moves the member in, and deletes the channel once everyone has left. This guide walks through the concept and the moving parts with discord.js v14.
1. The concept
- Hub channel: a normal voice channel the bot watches (optionally inside a category where new channels are created).
- Temp channel: created on join, owned by the member who triggered it.
- Cleanup: when a temp channel is empty, delete it, ideally after a short grace period so a quick reconnect does not destroy it.
- Owner controls: the owner can rename, set a user limit, lock/unlock, hide, permit or reject members, and transfer ownership. If the owner leaves, someone else can claim the channel.
The golden rule: only ever delete channels your bot created and recorded. Never delete "any empty channel in the category".
2. Intents
You only need two gateway intents, neither of them privileged:
Guilds– channels, roles, guild cacheGuildVoiceStates– thevoiceStateUpdateevent
No GuildMembers or MessageContent needed if you use slash commands and buttons.
3. Permissions
Keep the invite minimal. A typical temp-voice bot needs roughly:
- View Channels
- Manage Channels (create, edit, delete temp channels)
- Move Members (move the user from hub to their channel)
- Connect (required on the target voice channels for moves)
- Manage Roles / permission overwrites (for lock, hide, permit, reject)
- Send Messages (for the control panel in the channel's text chat)
Check the exact list against your own features; drop anything you do not use. Also make sure the bot's role sits high enough to edit overwrites in the target category.
4. Events: the core loop
Simplified sketch – not complete code, no error handling or rate-limit handling.
import { Client, GatewayIntentBits, ChannelType, VoiceState } from "discord.js";
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates],
});
const HUB_IDS = new Set<string>(["<hub channel id>"]);
// channelId -> ownerId; persist this (e.g. SQLite) so restarts don't lose ownership
const tempChannels = new Map<string, string>();
client.on("voiceStateUpdate", async (oldState: VoiceState, newState: VoiceState) => {
// Joined a hub → create a personal channel
if (newState.channelId && HUB_IDS.has(newState.channelId) && newState.member) {
const hub = newState.channel!;
const channel = await newState.guild.channels.create({
name: `${newState.member.displayName}'s channel`,
type: ChannelType.GuildVoice,
parent: hub.parentId ?? undefined,
});
tempChannels.set(channel.id, newState.member.id);
await newState.setChannel(channel);
}
// Left a temp channel → schedule cleanup if empty
if (oldState.channelId && tempChannels.has(oldState.channelId)) {
scheduleCleanup(oldState.channelId);
}
});5. Cleanup with a grace period
Simplified sketch.
const GRACE_MS = 10_000;
const timers = new Map<string, NodeJS.Timeout>();
function scheduleCleanup(channelId: string) {
clearTimeout(timers.get(channelId));
timers.set(channelId, setTimeout(async () => {
timers.delete(channelId);
if (!tempChannels.has(channelId)) return; // never touch foreign channels
const ch = await client.channels.fetch(channelId).catch(() => null);
if (!ch || !ch.isVoiceBased()) { tempChannels.delete(channelId); return; }
if (ch.members.size === 0) {
await ch.delete("Temp voice channel empty");
tempChannels.delete(channelId);
}
}, GRACE_MS));
}Also run a sweep on startup (ready): for every recorded channel, delete it if empty, and forget records whose channel no longer exists.
6. Owner controls
Expose a /voice command (subcommands rename, limit, lock, unlock, hide, permit, reject, transfer, claim) and/or buttons in the channel's chat. Every handler should first check: is the user in a temp channel, and are they its owner?
Simplified sketch.
// lock: deny Connect for @everyone, keep owner allowed
await channel.permissionOverwrites.edit(guild.roles.everyone, { Connect: false });
await channel.permissionOverwrites.edit(ownerId, { Connect: true });
// hide: deny ViewChannel for @everyone
await channel.permissionOverwrites.edit(guild.roles.everyone, { ViewChannel: false });
// reject: deny Connect and disconnect the member if present
await channel.permissionOverwrites.edit(targetId, { Connect: false });
await channel.members.get(targetId)?.voice.disconnect();Claim: allow it only if the current owner is no longer in the channel.
7. Limits and guardrails
- Rename cooldown – Discord rate-limits channel renames heavily (about 2 per 10 minutes per channel), so enforce your own cooldown and tell the user.
- Max channels per member and per server.
- Name length cap and a small block list for channel names.
- Limit the number of hubs per server.
8. Persistence and operations
Store hubs, temp channels and owners in a small database (SQLite is enough), log in JSON, and run the bot in Docker or with a process manager. As the operator of a bot you are responsible for data protection and youth protection on your server.
---
Want a head start?
If you would rather start from a structured codebase, the Aramac Temp-Voice (Join to Create) Bot Starter for Discord (€12 incl. VAT, sold by Aramac GmbH) covers this pattern in TypeScript: up to 10 hubs per server, cleanup after a grace period (default 10 s, 0–300 s) that never deletes channels the bot did not create, owner controls via /voice or buttons, per-server limits, 6 permissions and no privileged intents (Guilds and GuildVoiceStates only), SQLite via node:sqlite, a Dockerfile and automated tests (Discord mocked). It requires Node.js 22.13+ (or Docker), your own bot token and hosting, and is licensed for private and community servers only – not for shops, businesses or servers with paid memberships/roles.
Not affiliated with Discord Inc. "Discord" is a trademark of Discord Inc.