Warning: This codebase is almost entirely generated by AI, and most of the code hasn't been read. Use at your own risk.
HuddleFM is a self hosted music bot for Slack huddles. Invite it to a Huddle and participants can search for songs, albums, playlists from YouTube, or add supported media links; build and reorder a shared queue; and control playback, seeking, volume, autoplay, loop, and permissions directly through Slack.
- Search for songs, albums, and playlists from YouTube
- Add supported media links (e.g. YouTube, SoundCloud, Navidrome shares) to the queue
- Build and reorder a shared queue, or shuffle the pending queue in one click
- Control playback, volume and settings, directly through Slack
- Set permissions for who can control playback and queue
- View album art, progress, and read lyrics streamed as a camera feed
- Automatically scrobble to Last.fm and Listenbrainz
- Autoplay: Related (YouTube up-next) or Huddle mix (Last.fm, ListenBrainz, and songs people have added)
- Play something: one click on an idle player starts Huddle mix, falling back to a currently popular song when there is no listening history yet
- 💖 whatever is playing to hear more like it: a one way nudge to your own recommendations that nobody else in the huddle sees, with an undo
- Save whatever is playing: the bot DMs you a link to it, through song.link for YouTube and SoundCloud so you can find it on your streaming service
- Loop: repeat the current track or cycle the finished queue
- Auto-duck: lower the music while someone is speaking in the huddle, and bring it back when they stop
- Video mode: watch YouTube videos together on a screen share in the huddle, with captions and chapters; links from other sites yt-dlp supports work too
- Live streams: lofi radio and other live YouTube or Twitch streams play as they arrive, as audio in music mode and as video in video mode
- Jellyfin: link your own Jellyfin servers with Quick Connect to search, browse shows and play from them, with resume points, chapters, audio languages and captions
- Support for simultaneous huddles
- End of session recaps & a statistics canvas
- Auto leave when inactive
- Self hosting with a single Docker container
Invite the bot to a huddle or ping it in the thread. It will join the huddle and send a UI in the thread to add songs to the queue and control playback. You'll be the host by default. Managers of the huddle's channel and workspace admins can end the session from Settings, even when they aren't in the huddle. On the Channel tab of Settings, channel managers can save the session settings they can see as the defaults every new session in that channel starts with, or clear them, and delete sessions saved to the channel; whoever runs the bot can change how much they may do with CHANNEL_MANAGER_PERMISSIONS and WORKSPACE_ADMIN_PERMISSIONS. If the UI gets buried by conversation in the thread, mention the bot with nothing else to bring it back to the bottom, or keep it at the bottom by enabling anchor in the settings.
Anyone in the huddle can open Your data from the You tab of Settings to see what HuddleFM keeps about them (sessions saved to their account, the songs they added, heard, liked and skipped, and their scrobbles) and delete any of it, or all of it at once. The audit log is never deleted.
The host can switch the session to video mode on the Session tab of Settings, to watch YouTube videos together. The bot shares its screen in the huddle for as long as video mode is on, so it needs one of Slack's two screen share slots free. Search finds YouTube videos, and songs already queued play as their official video when there surely is one, or as audio with the artwork card when there is not. Autoplay, Huddle mix, lyrics, crossfades, ducking and scrobbling pause until the host switches back to music, and their settings are left as they were. The player gains Jump to (a video's chapters, or a time) in place of 💖, and a CC button for captions, which are off by default. In music mode a YouTube video link still plays as audio; the add dialog says so, and offers the host a button to add it and switch, or anyone else a button to add it and ask the host, who gets a direct message to switch or say not now (which stops asking for 15 minutes).
Pasting a live stream adds it as 🔴 LIVE. It plays as it arrives until someone skips it or it ends, so the queue waits behind it and autoplay doesn't kick in. It can't be seeked, resuming it after a pause joins it where it is now, and it is never scrobbled. If it drops, the bot picks it up again twice before skipping it.
To watch from Jellyfin, paste a link to a movie, show or episode from Jellyfin's web app (the address bar on the item's page) into Add to queue. The first time, the bot shows a Quick Connect code and a button that opens your Jellyfin with it filled in; once you approve it, the item is queued, and the dialog says so. You can also link servers, see them, and unlink them under Settings › You. After that the add dialog has a YouTube/Jellyfin source picker, shows open their seasons and episodes, part-watched items ask whether to resume, Play something in video mode suggests what you were watching and what was added recently, and CC picks the audio language and captions. Items stream from your server, converted to fit the video limits when they don't already, and your watch progress is reported back to your account. Only you can use the servers you link; others in the huddle see only the title. The server has to be reachable from the internet, and HuddleFM shows up in its dashboard but takes no remote control; removing the HuddleFM device there stops it working until you link the server again. Jellyfin's "Copy stream URL" links contain your sign-in, so the bot refuses them.
With OPENROUTER_API_KEY set, you can also @mention the bot with a request (for example @HuddleFM add something by Radiohead, @HuddleFM skip, or @HuddleFM turn autoplay on). It uses a cheap AI model through OpenRouter and the same permissions you already have in the player UI. Replies are ephemeral. This also works in the original huddle thread when the session uses a forced companion controls channel (where the bot cannot post publicly).
HuddleFM ships as a single Docker image (ghcr.io/ingoau/huddlefm:latest). You need a dedicated Slack user for the bot, plus both a Slack app (Socket Mode) and browser session credentials from that same user so it can join Huddles.
- Put
compose.yamlin a folder:
curl -fsSL -o compose.yaml https://raw.githubusercontent.com/ingoau/huddlefm/main/compose.yaml- Create
.envnext to it with the required credentials:
SLACK_WORKSPACE_URL=https://example.slack.com
SLACK_XOXP=xoxp-... # Slack app user token (Web API)
SLACK_XAPP=xapp-... # Slack app-level token (Socket Mode)
SLACK_XOXC=xoxc-... # Browser session token for joining Huddles
SLACK_XOXD=xoxd-... # Browser `d` cookie for that sessionSLACK_XOXP and SLACK_XOXC/SLACK_XOXD must belong to the same Slack user. HuddleFM checks this on startup.
On Enterprise Grid the client token can be set as SLACK_ENTERPRISE_XOXC instead of SLACK_XOXC; it takes precedence when both are set. Either way one of the two is required.
- Start it:
docker compose up -d- Invite the bot to a Huddle (or mention it in the Huddle thread). Session state and logs live under
./data.
That is enough for a working deploy. Optional features (scrobbling, analytics, AI @mentions, timeouts, and more) are listed under Configuration. .env.example has the same options as commented defaults.
| Variable | Purpose |
|---|---|
SLACK_WORKSPACE_URL |
Workspace URL, e.g. https://example.slack.com |
SLACK_XOXP |
User OAuth token used for Slack Web API calls |
SLACK_XAPP |
App-level token used for Socket Mode |
SLACK_XOXC |
Client token from the bot user's browser session |
SLACK_ENTERPRISE_XOXC |
Enterprise Grid client token; used instead of SLACK_XOXC when set |
SLACK_XOXD |
d cookie from the same browser session |
| Variable | Default | Purpose |
|---|---|---|
MANAGER_USER_ID |
unset | User who is always treated as a host |
CHANNEL_MANAGER_PERMISSIONS |
end |
What managers of a huddle's channel may do without joining: none, end the session, or everything a host can |
WORKSPACE_ADMIN_PERMISSIONS |
end |
The same for Slack workspace admins and owners. Replaces WORKSPACE_ADMINS_AS_MANAGERS=true, which still means host until it is set |
EXCLUDED_USER_IDS |
unset | Comma/space-separated users ignored for participation, hosting, permissions, and scrobbling |
FORCE_COMPANION_CHANNEL_IDS |
unset | Channels that always get a separate HuddleFM controls channel |
SLACK_TEAM_ID |
unset | Workspace for companion channel creation; required for Enterprise Grid credentials |
SLACK_CANVAS_ID |
unset | Canvas updated with live sessions and all-time listening stats |
CANVAS_SECTIONS |
all | Comma-separated canvas sections to show, in order: summary, active sessions, top artists, top tracks, top videos, top video channels, top channels, controls, integrations |
FOOTER |
unset | Optional mrkdwn footer under the queue controls |
| Variable | Default | Purpose |
|---|---|---|
LASTFM_API_KEY / LASTFM_SHARED_SECRET |
unset | Enables Last.fm account linking for scrobbling |
OPENROUTER_API_KEY |
unset | Enables @mention AI controls via OpenRouter |
POSTHOG_API_KEY |
unset | Enables PostHog analytics and error tracking |
POSTHOG_HOST |
https://us.i.posthog.com |
PostHog ingestion host |
LOCAL_CONTROL_TOKEN |
unset | Bearer token for local /join, /leave, and /tone routes; leave unset for typical Docker deploys |
| Variable | Default | Purpose |
|---|---|---|
QUEUE_LIMIT |
50 |
Maximum tracks in the queue |
TRACK_DURATION_LIMIT_SECONDS |
1200 |
Maximum track duration |
TRACK_DOWNLOAD_LIMIT_BYTES |
100000000 |
Maximum download size |
INITIAL_VOLUME |
0.5 |
Starting volume as a fraction of max |
DUCKING_MODE |
gentle |
Auto-duck default: off, gentle, strong |
LYRICS_OFFSET_MS |
0 |
Shift the lyrics' timing; positive shows them later, for when they run ahead of the audio |
LOUDNESS_NORMALIZATION |
false |
Set to true to level songs and videos to -14 LUFS |
TRACK_PREPARATION_CONCURRENCY |
2 |
Tracks downloaded and analysed at once, across all Huddles |
MEDIA_CACHE_LIMIT_BYTES |
1000000000 |
Disk kept for prepared tracks under data/cache/media; 0 disables and clears the cache |
MEDIA_CACHE_MAX_AGE_DAYS |
30 |
Re-download cached tracks older than this; 0 keeps them until evicted for space |
ALONE_TIMEOUT_MS |
120000 |
Leave when alone for this long |
IDLE_TIMEOUT_MS |
600000 |
Leave when idle for this long |
PAUSED_TIMEOUT_MS |
600000 |
Leave when paused for this long |
CHIME_MEDIA_REGION |
ap-southeast-2 |
AWS region for Chime media |
| Variable | Default | Purpose |
|---|---|---|
VIDEO_DURATION_LIMIT_SECONDS |
3600 |
Longest video allowed |
VIDEO_DOWNLOAD_LIMIT_BYTES |
1500000000 |
Largest video download |
VIDEO_MAX_HEIGHT |
720 |
Height videos are downloaded and shared at, from 240 to 1080; the share is 16:9 |
VIDEO_MAX_FPS |
30 |
Frame rate videos are downloaded and shared at, up to 30 |
VIDEO_SESSION_LIMIT |
2 |
Huddles that may be in video mode at once; each takes about a CPU core. 0 turns it off |
VIDEO_CACHE_LIMIT_BYTES |
5000000000 |
Disk kept for prepared videos under data/cache/video, apart from the song cache; 0 disables and clears it |
JELLYFIN_ENABLED |
true |
Lets users link their own Jellyfin servers; false turns it off |
| Variable | Default | Purpose |
|---|---|---|
BIND_ADDRESS |
127.0.0.1 |
HTTP bind address (loopback is fine; Compose does not publish ports) |
PORT |
3210 |
HTTP port |
LOG_LEVEL |
info |
Minimum operational log level (debug, trace, …) |
LOG_FILE |
data/logs/huddlefm.jsonl |
Rotated JSON log path; set empty to disable file logging |
LOG_FILE_SIZE |
10m |
Maximum size of each log file |
LOG_FILE_COUNT |
7 |
Rotated files retained in addition to the active file |
HuddleFM joins each Huddle's Chime meeting itself. Each Huddle's media runs in its own Bun process, so a crash ends only that session. The video tile is drawn at 540×540 and 24 fps, with a default and a lyrics layout, syllable-synced lyrics, a moving backdrop while a track plays, and songwriter credits after the last line. Lyrics with no timing fall back to the default layout.
In video mode the bot also joins as its own screen share, the way Chime's content share does, and sends the video and the music there instead of the camera tile; the picture follows the audio's position, and captions are burned in when they are on. If the share cannot start or is lost, the camera tile and the bot's own audio take over, and whoever added the video hears why.
Live streams, YouTube videos in video mode, and Jellyfin items are not downloaded first: the media process reads them as they arrive, through the same public-network proxy as every download, and lines up a live stream's picture with its sound by the timestamps each joined at. A YouTube video starts within seconds however long it is, and downloads into the video cache behind it, so it plays from disk the next time. The bot fetches a streaming YouTube video in pieces, retrying any that fail, and hands it to the media process over loopback: read directly, YouTube sends it at only about twice the playback rate and now and then refuses it, which froze playback.
With LOUDNESS_NORMALIZATION on, songs are re-encoded at -14 LUFS as they download. Videos are not: their loudness is measured instead, from the sound alone for a streamed video, and the bot turns them to the same level as they play (lifting a quiet one by at most 6 dB). A video waits up to 3 seconds for its measurement, and one that takes longer starts as it is and glides to its level when the measurement arrives. Each video's loudness is kept for good, so it is levelled from its first second every time after that, even once its file has left the cache.
Chime media needs outbound UDP 3478 or TLS on port 443 to *.chime.aws.
docker compose pull
docker compose up -dActive sessions should restore after a short restart.
HuddleFM writes structured JSON logs to stdout and, by default, rotated files under data/logs. Those files survive container recreation because data is bind-mounted from the host. Use LOG_LEVEL=debug for detailed API and queue activity, or trace for playback-position events.
Logs can include Slack user, channel, session, and track identifiers. Credentials and known token fields are redacted. Rotation keeps the active file plus LOG_FILE_COUNT older files.
Set POSTHOG_API_KEY to send product events and sanitized errors to PostHog. Events use Slack user IDs as distinct IDs and never include Slack names, messages, search text, lyrics, audio, credentials, or raw API payloads. Active users get current-state person properties for their Last.fm and ListenBrainz connections, enabled settings, and scrobbling mode. HuddleFM keeps a random system identity in data/posthog-installation-id. Analytics is best-effort and disabled when the key is absent.
I Codex built this for the Hack Club Slack because we didn't really have any music bots. We did have a bot that could join huddles, but it was more for video, and required one of the two precious screen sharing slots to be used.
I decided to build a bot that was really intuitive for music, and would just work. I started planning it, going back and forth with Codex and making sure it was on the right track. I was happy with the plan and told it to implement it. One hour later I had something that mostly worked!
I worked on it for a few days, fixing bugs, adding lots of features, and I'm mostly happy with the state of it now! I even got Claude to review it and it said it wasn't just classic AI slop so I guess that's good?