Skip to content

Deploy

By the end of this page the bot runs in Docker on your server, redeploys on every push to main, and you know how to check on it and roll it back.

Follow the page in order from Create the database to First start. Each section from Reminders on the first run to Voice notes on the first run covers one optional feature: read only the ones you switch on.

One Docker container (the bot and everything it needs, packaged together), started with Docker Compose (a tool that starts a container from one settings file).

  • It connects out to: Discord and your Neon database (a hosted Postgres service). Gemini (Google’s AI service) and Linear (the issue tracker) when their keys are set. With voice notes on, also Discord’s voice servers and, over HTTPS, the transcription hosts.
  • It listens on: only the health endpoint, a web address that answers whether the bot is up (/health on port 3000).
  • Who can reach it: only the server itself. The port is published on the loopback address (127.0.0.1), so nothing is reachable from the internet.
FactValue
ImageMulti-stage build. The final stage is node:24-bookworm-slim, running as the non-root node user. It holds the compiled bot and the SQL migrations, and no .env.
Commandnode dist/index.js
SettingsCompose loads /opt/dl-discord-bot/.env and then sets PORT=3000 and NODE_ENV=production, so those two lines in .env have no effect in Docker.
Restart policyunless-stopped. The bot comes back after a crash or a server reboot; only docker compose stop keeps it down. A crash loop keeps restarting, so check docker compose logs if it does.
Stop graceHow long Docker waits after asking the bot to stop: 30 seconds. On stop the bot first saves a running voice session (up to 8 seconds), then waits up to 10 seconds for in-flight work, and forces an exit with an error after 25 seconds in total.
Health checkEvery 15 seconds, 5 second timeout, 30 second start period, 3 retries.
NeedDetail
GitHub accessAn account with admin on dogelabsvr/doge-discord-bot, for the deploy key (a read-only key that lets the server download the code) and the Actions secrets (private settings that GitHub’s automatic deploy uses). The repo is private, so gh on your laptop must be logged in as that account (gh auth status, then gh auth switch or gh auth login).
A serverA fresh Ubuntu 22.04 or newer server with a public IP, root SSH access and your SSH key on it. With a sudo user such as ubuntu instead of root, see the note under step 1 of the server setup.
Your laptopssh, ssh-keygen and the GitHub CLI gh, in bash or zsh.
DiscordAn account with Manage Server in the server the bot will join.
NeonAn account at console.neon.tech. Neon hosts the Postgres database the bot stores everything in.
OptionalA Gemini key from AI Studio, and a Linear key. See Turn a feature on or off.

Without a Gemini key the bot still stores messages and sends reminders, but asking, summaries, ticket prompts, meeting transcripts and voice notes stay off. You can add the key later.

Work in this order: the Discord application (you leave with an application id, a bot token, your server id and your own user id), the database, the server, the first deploy, then deploy on every push. Keep the values in a scratch file outside the repository, because the first deploy asks for them. The bot token is a password: do not paste it anywhere public.

  1. Create the project. Open console.neon.tech, choose New project, name it doge-discord-bot, pick Postgres 18 (CI and the tests run on 18) and the region closest to your server.

  2. Copy both connection strings. On the project dashboard press Connect and keep the default branch, database and role. Turn Connection pooling on and copy the string, whose host contains -pooler: that is DATABASE_URL_POOLED, which the running bot uses. Turn it off and copy the string again: that is DATABASE_URL, the direct connection that only migrations use (a migration is a script that creates or changes the bot’s tables). Paste both exactly as shown, including ?sslmode=require&channel_binding=require.

  3. Leave the extension to the first deploy. The vector extension (pgvector) lets Postgres store the numbers the bot uses to search messages by meaning. Migration 0006 runs CREATE EXTENSION IF NOT EXISTS vector as that role. Only if you use a role that cannot create extensions, run that statement once in the Neon SQL Editor first.

  4. Leave the time zone at UTC. The bot stores timestamps without a time zone and reads them as UTC. Neon’s default is UTC, so change nothing.

Neon’s free tier is 0.5 GB. See AI and embeddings before you embed a large history.

Both steps run on your laptop and take about a minute. Use one terminal tab, because SERVER is only set in that tab.

  1. Install Docker and prepare the server. Replace the address on the first line, then paste the whole block.

    Terminal window
    export SERVER=203.0.113.10
    ssh root@$SERVER 'bash -s' <<'EOF'
    set -euo pipefail
    export DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a
    ag() { apt-get -y -q -o DPkg::Lock::Timeout=300 "$@"; }
    ag update && ag install ca-certificates curl git
    if ! command -v docker >/dev/null; then
    install -m 0755 -d /etc/apt/keyrings
    curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
    chmod a+r /etc/apt/keyrings/docker.asc
    cat > /etc/apt/sources.list.d/docker.sources <<SRC
    Types: deb
    URIs: https://download.docker.com/linux/ubuntu
    Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
    Components: stable
    Architectures: $(dpkg --print-architecture)
    Signed-By: /etc/apt/keyrings/docker.asc
    SRC
    ag update
    ag install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
    fi
    [ -e /etc/docker/daemon.json ] || { mkdir -p /etc/docker; echo '{"log-driver":"json-file","log-opts":{"max-size":"10m","max-file":"3"}}' > /etc/docker/daemon.json; }
    systemctl enable docker containerd && systemctl restart docker
    id deploy >/dev/null 2>&1 || adduser --disabled-password --gecos "" --shell /bin/bash deploy
    usermod -aG docker deploy
    install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
    install -d -o deploy -g deploy /opt/dl-discord-bot
    [ ! -f /root/.ssh/authorized_keys ] || cat /root/.ssh/authorized_keys >> /home/deploy/.ssh/authorized_keys
    sort -u -o /home/deploy/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys
    chown deploy:deploy /home/deploy/.ssh/authorized_keys && chmod 600 /home/deploy/.ssh/authorized_keys
    runuser -l deploy -c '
    set -euo pipefail
    [ -f ~/.ssh/dl_discord_bot_deploy ] || ssh-keygen -q -t ed25519 -N "" -C "dl-discord-bot deploy key" -f ~/.ssh/dl_discord_bot_deploy
    grep -qs dl_discord_bot_deploy ~/.ssh/config || printf "Host github.com\n IdentityFile ~/.ssh/dl_discord_bot_deploy\n IdentitiesOnly yes\n" >> ~/.ssh/config
    grep -qs "^github.com ssh-ed25519" ~/.ssh/known_hosts || echo "github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl" >> ~/.ssh/known_hosts
    chmod 600 ~/.ssh/config ~/.ssh/known_hosts
    docker info >/dev/null && docker compose version'
    echo "bootstrap ok"
    EOF

    It installs Docker Engine and the Compose plugin, caps container logs at 3 files of 10 MB (Docker keeps them forever by default), creates the deploy user (bash shell, docker group, your existing SSH key) and /opt/dl-discord-bot, and makes a read-only GitHub deploy key with its SSH config. GitHub’s published ed25519 host key goes into known_hosts, so the first git clone never stops to ask. It is safe to run again, and ends with a Compose version line and bootstrap ok.

    The first time you connect, SSH asks whether to trust the server: type yes. If your SSH user is not root, use ssh ubuntu@$SERVER 'sudo bash -s' <<'EOF' for the second line. If the server only accepts a password for root, run ssh-copy-id root@$SERVER first.

  2. Add the deploy key to the repository. This adds the server’s public key as a read-only deploy key.

    Terminal window
    k=$(mktemp) && ssh deploy@$SERVER 'cat ~/.ssh/dl_discord_bot_deploy.pub' > "$k" && gh repo deploy-key add "$k" -R dogelabsvr/doge-discord-bot -t "vps $SERVER" && rm -f "$k"

    Check it with ssh deploy@$SERVER 'ssh -T git@github.com'. GitHub greets the repository by name and closes the connection, which is normal.

Log in to the server as the deploy user. From here on you are typing on the server, not your laptop, until the section that says otherwise. Then paste the second block.

Terminal window
ssh deploy@$SERVER
Terminal window
cd /opt/dl-discord-bot && { [ -d .git ] || git clone git@github.com:dogelabsvr/doge-discord-bot.git .; } && (
umask 077
keys="DISCORD_TOKEN DISCORD_GUILD_ID DATABASE_URL_POOLED DATABASE_URL REMINDER_ALLOWLIST GEMINI_API_KEY LINEAR_API_KEY"
grep -vE "^($(tr ' ' '|' <<<"$keys"))=" .env.example > .env
for k in $keys; do
read -rsp "$k: " v; echo " (${#v} chars)"
printf '%s=%s\n' "$k" "$v" >> .env
done
for k in DISCORD_TOKEN DISCORD_GUILD_ID DATABASE_URL_POOLED DATABASE_URL REMINDER_ALLOWLIST; do
grep -q "^$k=." .env || { echo "$k is required" >&2; exit 1; }
done
) && bash scripts/deploy.sh && docker compose ps && curl -fsS localhost:3000/health

The block clones the repo, asks for each value, writes .env (the file that holds your settings) readable only by you, stops if a required value is empty, then builds, migrates, starts the bot and checks health. What you type is hidden, so after each answer it prints how many characters it got, for example (72 chars): (0 chars) means the paste did not work. Enter leaves the two optional keys empty.

PromptAnswer
DISCORD_TOKENThe bot token.
DISCORD_GUILD_IDThe server id.
DATABASE_URL_POOLEDThe pooled URL.
DATABASE_URLThe direct URL.
REMINDER_ALLOWLISTYour own user id, comma separated if more. This is the list of people the bot may message on its own. It is required here because reminders are on by default and would otherwise DM everyone.
GEMINI_API_KEY, LINEAR_API_KEYEnter to skip. Add them later under Turn a feature on or off.

The build takes a few minutes. Running the block again asks for every value again and replaces .env. If the block stops after .env is written (a build or migration error), fix the cause and run only cd /opt/dl-discord-bot && bash scripts/deploy.sh.

deploy.sh stops at the first failure:

  1. Builds the image.
  2. Runs the migrations in a one-off container with the direct database URL. A failure aborts the deploy, and the old container keeps running.
  3. Starts the bot. The old container gets the stop signal and its 30 second grace.
  4. Waits up to 120 seconds for the container to report healthy. If it reports unhealthy or time runs out, it prints the last 200 log lines and fails.

When it works you see ==> bot is healthy., the container shows as healthy, and the health check answers {"status":"ok"}. Then type /whoami in any channel of your server. The bot answers with your member profile, which only you can see; if it does, the bot works. If no command appears, see Troubleshooting.

On the server, in /opt/dl-discord-bot:

Terminal window
docker compose ps
docker compose logs --tail 100 bot
docker compose logs -f bot
curl -fsS localhost:3000/health

The first shows whether the container is running and healthy. The second prints the last 100 log lines. The third follows the log live (Ctrl+C stops following, not the bot). The fourth asks the health endpoint. GET /health answers 200 once the bot is logged in to Discord and 503 with {"status":"starting"} before that. It does not test the database.

A normal start reaches [bot] registered N guild commands and then one line per feature saying enabled, disabled or off: needs .... Scripts and health lists every line. If something does not come up, see Troubleshooting.

Features are switched on in .env, one NAME=true line each. Run these on the server (ssh deploy@$SERVER). Each line sets the switch, deploys again and prints the line the bot should log. Always deploy again after editing .env: a plain docker compose restart does not pick up the change. To change any other value, edit /opt/dl-discord-bot/.env with nano and run bash scripts/deploy.sh; Configuration lists every setting. Add the keys first, once. The value is typed hidden:

Terminal window
cd /opt/dl-discord-bot && read -rsp 'GEMINI_API_KEY: ' v && echo && sed -i "s|^GEMINI_API_KEY=.*|GEMINI_API_KEY=$v|" .env && bash scripts/deploy.sh && sleep 10 && docker compose logs --tail 300 bot | grep '\[ai\]'
cd /opt/dl-discord-bot && read -rsp 'LINEAR_API_KEY: ' v && echo && sed -i "s|^LINEAR_API_KEY=.*|LINEAR_API_KEY=$v|" .env && bash scripts/deploy.sh

Then the switches. Each one needs GEMINI_API_KEY. Ticket prompts also need LINEAR_API_KEY, and summaries need it to be set up. Meeting transcripts and voice notes work without it, but then send no action item messages and link no tickets.

Terminal window
# Ask the bot (/ask and tag questions)
cd /opt/dl-discord-bot && sed -i 's/^ASK_ENABLED=.*/ASK_ENABLED=true/' .env && bash scripts/deploy.sh && sleep 10 && docker compose logs --tail 300 bot | grep '\[ask\]'
# Project summaries and interest digests
cd /opt/dl-discord-bot && sed -i 's/^SUMMARIES_ENABLED=.*/SUMMARIES_ENABLED=true/' .env && bash scripts/deploy.sh && sleep 10 && docker compose logs --tail 300 bot | grep '\[summaries\]'
# Ticket prompts
cd /opt/dl-discord-bot && sed -i 's/^TICKET_PROMPTS_ENABLED=.*/TICKET_PROMPTS_ENABLED=true/' .env && bash scripts/deploy.sh && sleep 10 && docker compose logs --tail 300 bot | grep '\[tickets\]'
# Meeting transcripts
cd /opt/dl-discord-bot && sed -i 's/^MEETING_TRANSCRIPTS_ENABLED=.*/MEETING_TRANSCRIPTS_ENABLED=true/' .env && bash scripts/deploy.sh && sleep 10 && docker compose logs --tail 300 bot | grep '\[meetings\]'
# Voice notes (also turns on meeting transcripts; the bot needs Connect, which `permissions=274879040512` already includes)
cd /opt/dl-discord-bot && sed -i -e 's/^MEETING_TRANSCRIPTS_ENABLED=.*/MEETING_TRANSCRIPTS_ENABLED=true/' -e 's/^VOICE_NOTES_ENABLED=.*/VOICE_NOTES_ENABLED=true/' .env && bash scripts/deploy.sh && sleep 10 && docker compose logs --tail 300 bot | grep '\[voice\]'

To switch one off, run the same line with false. If a sed line changes nothing, the key is missing from your .env: add it with echo KEY=true >> .env and deploy again. The sections below say what each one sends, keeps and logs.

On the first start the bot reads your whole message history so it can search and summarise it later. The log calls this the backfill. This is expected and can take hours on a large server.

  • It scans three channels at a time and logs [backfill] progress lines (the list). [backfill] complete marks the end. Channels it cannot read are listed in [backfill] cannot read: ...; see Discord application.
  • Restarts carry on from saved positions, and each start re-reads the last 7 days to catch edits and deletes it missed.
  • A failed channel is retried once after 30 seconds, then on the next start or Discord reconnect. A reconnect that re-identifies runs the catch-up again.
  • The bot answers commands while the backfill runs.

Watch it with docker compose logs -f bot | grep backfill. See Messages and backfill.

Reminders are direct messages that nudge members who have not posted in a channel yet (see Channel reminders). They are on by default. The allowlist is REMINDER_ALLOWLIST, the user ids the bot may message on its own. A tick is one pass of the reminder timer (every 15 minutes by default). With an empty allowlist the bot DMs everyone who has set a timezone and working hours, and sends a one-time setup DM to every member who has not (up to 25 per tick). The first deploy already required REMINDER_ALLOWLIST. It also limits summary, ticket prompt and meeting action item DMs. Clear it only once the output looks right:

Terminal window
cd /opt/dl-discord-bot && sed -i 's/^REMINDER_ALLOWLIST=.*/REMINDER_ALLOWLIST=/' .env && bash scripts/deploy.sh

Check the line with docker compose logs bot | grep reminders. Look for [reminders] enabled, tick 15 min, late limit 60 min, allowlist N members. allowlist 0 members means everyone. A channel reminder more than the late limit overdue is skipped, so REMINDER_LATE_LIMIT_MINUTES must be at least REMINDER_TICK_MINUTES, or the bot refuses to start while reminders are on. See Rolling out the bot.

Tagging the bot follows the same settings:

  • A follow-up is set only while reminders are on and only for authors the allowlist lets through. The bot needs Send Messages in the channel to confirm in it; without that the follow-up is still saved. Follow-ups have their own timer and go out to the second, with a minimum lead of 10 seconds.
  • With GEMINI_API_KEY set, the text of each tagged message, without the mention, goes to Gemini to decide whether it is a follow-up request, a question or neither. The limits are 20 checks per author per rolling hour and MENTION_MAX_CHECKS_PER_DAY (2000) across everyone. The daily count is in memory and resets on restart. Nothing is logged.
  • Without the key, a tag on a reply is a follow-up with no time (the time picker arrives by DM) and any other tag is ignored.
  • A question is answered, as a public reply, only while asking is on, whatever the allowlist says.

Summaries are off by default, so a first deploy sends none. Switch them on with the line under Turn a feature on or off. They need GEMINI_API_KEY, and LINEAR_API_KEY for setup: without it /summary-admin create and /summary-admin map-channel refuse to run. REMINDER_ALLOWLIST also limits who gets scheduled summary DMs, so keep it set to your test ids.

Look for [summaries] enabled, ... allowlist N members, linear on. Nothing is sent until an admin maps a channel, creates a summary and adds recipients with /summary-admin. See Summary configs. The same switch turns on /interests and the personal digests.

TopicDetail
Sent to GeminiText of messages from channels the recipient can see, found by search while a summary is written, and with a Linear key the titles and details of the tickets in the mapped team. None of it is logged. A tick logs counts only: [summaries] tick: sent=... failed=... skipped=... notimezone=....
LimitsSUMMARY_MAX_GENERATIONS_PER_DAY per rolling 24 hours (counted per recipient), a 60 second cooldown on /summary now and /interests now, 3 on-demand runs at once, and 5 /interests now runs per member per 24 hours.
TimingA scheduled summary is sent up to 60 minutes after its time and is dropped after that. Daily summaries skip Saturday and Sunday in the recipient’s timezone.
RetentionStored summary text is deleted after 30 days. This runs once at each start, even with the feature off, and then daily while the scheduler is running.
Switching offSet SUMMARIES_ENABLED=false and redeploy. /summary, /interests and /summary-admin are removed at the next start.

Asking is off by default, so a first deploy answers nothing. Switch it on with the line under Turn a feature on or off; it needs GEMINI_API_KEY.

Look for [ask] enabled, 50/member/day, cap 500/day, retention 30 d. REMINDER_ALLOWLIST does not limit who can ask, so the two daily limits (ASK_MAX_PER_MEMBER_PER_DAY and ASK_DAILY_CAP) are how you bound spend. Answers use Discord messages and meetings only; Linear is not a source. Try /ask with a fact you know is in a public channel. See How asking works.

TopicDetail
PermissionsA tag question is answered only in a channel where the bot has View Channel, Send Messages (Send Messages in Threads in a thread), Embed Links and Read Message History. Without all four it stays silent and logs [follow-up] cannot answer in channel ... missing permissions. /ask replies through the interaction and needs none of these.
Sent to GeminiThe question, and the text of the messages and meeting summaries the bot reads to answer it, limited to what the asker can see. A public answer is limited further to what every reader of that channel can see. None of it is logged.
LimitsASK_MAX_PER_MEMBER_PER_DAY per member and ASK_DAILY_CAP across the server, per rolling 24 hours, enforced when the question is claimed. A tag also spends the tag checks (20 per author per hour and MENTION_MAX_CHECKS_PER_DAY). A member can have one question running at a time, and four run at once across the server.
RetentionEach question is logged with its search terms and the ids of what it found, never message text. Rows are deleted after ASK_LOG_RETENTION_DAYS days, once at each start and then daily, even with asking off.
Switching offSet ASK_ENABLED=false and redeploy. /ask is removed at the next start and tags stop being answered. Feedback buttons on old answers keep working.

Ticket prompts are off by default, so a first deploy sends none. When a member pitches a feature in a channel and Linear has no matching ticket, the bot DMs them a prompt with a draft title. Pressing Create ticket opens a prefilled form, and submitting it creates the Linear ticket. To try it:

  1. Set the allowlist first. Put a few test user ids in REMINDER_ALLOWLIST. Messages from anyone else are never checked or sent to Gemini. Widen the list once the prompts look right.
  2. Make a Linear key. Use a service account key with Read and Create issues, limited to the teams members may file into. Linked members can also file into the private teams they belong to, so the key must be able to create there too. The key’s owner is recorded as the creator of every ticket. Cycles and the team suggestion need only Read.
  3. Switch it on. Use the line under Turn a feature on or off. It needs both keys. Look for [tickets] enabled, cap N/day per author, max N checks/day, allowlist N members; [tickets] off: needs GEMINI_API_KEY and LINEAR_API_KEY means a key is missing.
  4. Link your members. With LINEAR_API_KEY set, the bot matches Discord members to Linear users by exact name at start, once the member list has loaded. Look for [linear] auto-linked N members, M unsure, K unmatched, then run /linear-admin list and fix the unsure ones, as described in Linking members to Linear. A linked member’s ticket is assigned to them and filed in their Linear team. This works even with TICKET_PROMPTS_ENABLED off.

What to know before you do:

TopicDetail
Sent to GeminiThe text of each new message of 8 or more words (links, emoji, mentions and code do not count) from a member the allowlist lets through (everyone when it is empty), plus the titles and descriptions of the Linear tickets found by search. When a message is judged to be a proposal and its author can file into two or more teams, the message text and those teams’ names and one-line descriptions go too, so the model can suggest a team. When a member submits the form, the ticket title, the first 1000 characters of the description and the names of the labels that team can use, so the model can pick up to three. Log lines carry message ids, ticket identifiers and error types, never message or ticket text. Old and edited messages are never checked.
Team suggestionA suggestion only puts a team first in the form, with a note on the Team field. It never files anything, and the member can change it. A failed suggestion means no suggestion. When a member submits the form the log counts only: [tickets] team suggestion kept, [tickets] team suggestion changed or [tickets] team no suggestion.
LimitsTICKET_PROMPT_DAILY_CAP prompts per member per rolling 24 hours, TICKET_PROMPT_MAX_CHECKS_PER_DAY checks across everyone, 20 checks per author per hour, and a one minute pause when Gemini is down.
RetentionThe draft title, description and suggested team are cleared when the ticket is created, the prompt is dismissed or fails, or the message or its channel is deleted, and after 30 days (a clean-up that runs once at each start, even with the feature off). The record of the prompt stays.
VisibilityA prompt is a DM only the author can see. A created ticket, and the Discord link inside it, is visible to everyone in your Linear workspace. Private Linear teams are never searched. A private team is offered only to a linked member who belongs to it.
LabelsThe model chooses only from labels that already exist for the team or the workspace. It never creates a label.
AssigneeA linked member’s ticket is assigned to their Linear user. An unlinked member’s ticket is unassigned.
Switching offSet TICKET_PROMPTS_ENABLED=false and redeploy. No new prompts go out. Prompts already sent keep working while LINEAR_API_KEY is set.

See Ticket prompts for how a prompt is decided.

Meeting transcripts are off by default, so a first deploy reads nothing. When a member uploads a .txt, .vtt, .srt or .md file to the meetings channel, the bot posts a summary there and DMs each action item owner a way to file tickets. To try it:

  1. Set the allowlist first. Put a few test user ids in REMINDER_ALLOWLIST. It limits who is sent action item DMs. It does not stop anyone from uploading, so tell your testers who may.
  2. Decide on Linear. LINEAR_API_KEY is optional. With a service account key that has Read and Create issues, summaries link existing tickets and owners can file. To let an action item change an existing ticket (its status, or a note on it) the key also needs Write and Create comments; Write alone covers both, so one key with Write is enough. Without a key, the summary posts with no links, no action item messages are sent and no tickets can be created.
  3. Switch it on. Use the line under Turn a feature on or off. It needs GEMINI_API_KEY. Look for [meetings] enabled, #<channel>, max N/day, linear on|off, allowlist N members.
  4. Create the channel. The bot does not create it at startup. With voice notes off, create a text channel with the configured name by hand, or uploads are ignored. With voice notes on, the first voice meeting that goes to the shared channel creates it (see the next section).

What to know before you do:

TopicDetail
PermissionsIn the channel: View Channel, Send Messages and Read Message History. Manage Channels in the server only if you want a voice meeting to create the channel.
VisibilityThe channel, when the bot creates it, has no permission overwrites, so everyone in the server can read the summaries. This assumes one server.
Sent to GeminiThe transcript text, up to 400,000 characters (longer text is cut, and text over 200,000 characters is read in parts and merged), and with a Linear key the titles and descriptions of candidate tickets. When the transcript names no team and has new work, the meeting title, decisions, action item titles and the names and descriptions of the public teams, so the model can suggest a team for the picker. When an owner presses Create selected, the ticket title, description and the names of the team’s labels. None of it is logged. Applying an update to an existing ticket sends nothing to Gemini.
Team suggestionA suggestion only moves a team to the front of the picker, labelled Suggested from the meeting. It is never filed on its own. The log counts only: [meetings] team suggestion kept or [meetings] team suggestion changed.
InputOne .txt, .vtt, .srt or .md file per message. Pasted text, or several files, gets a reply asking for a single file, at most once an hour per member. Typed text and the bot’s own post are stored like any message, and typed text is embedded; the uploaded file is not. The same file uploaded twice is summarised once.
LimitsMEETING_MAX_PER_DAY transcripts across everyone, 5 per member per day, 10 uploads per member per hour, MEETING_MAX_FILE_KB per file, at most 15 action items per meeting, at most 10 people messaged per transcript (the rest of the items go to the uploader), and a one minute pause when Gemini is down. The counts live in memory. A run that ends because Gemini was down, or the bot shut down, gives its slot back.
RetentionThe transcript text is deleted after MEETING_TRANSCRIPT_RETENTION_DAYS days, at each start and then daily, even with the feature off. The summary, attendee names and action items stay.
Switching offSet MEETING_TRANSCRIPTS_ENABLED=false and redeploy. No new upload is read. Action item messages already sent keep working while LINEAR_API_KEY is set.

See Meeting transcripts for how a transcript is processed.

Voice notes are off by default, so a first deploy never joins a voice channel. A member in a voice channel runs /notes start, the bot joins muted, and /notes stop turns what was said into a summary and action items like an uploaded transcript. To try it:

  1. Set up meeting transcripts first. Voice notes need MEETING_TRANSCRIPTS_ENABLED=true and GEMINI_API_KEY. Set the allowlist as for meeting transcripts.
  2. Give the bot the permissions. The invite link in Discord application already includes Connect (274879040512). If you invited the bot with the five-permission integer, invite it again with that link or add Connect to the bot’s role. In the voice channel the bot needs View Channel, Connect and Send Messages. Add Manage Channels in the server if you want it to create the summary channel.
  3. Switch it on. Use the voice line under Turn a feature on or off. ASR_HOSTS is optional and the defaults are the PlaySafe hosts. Look for [voice] enabled, N ASR hosts, max N min, explicit invite only; [voice] off: needs MEETING_TRANSCRIPTS_ENABLED and GEMINI_API_KEY means one is missing.
  4. Try it. In a voice channel run /notes start, say a few sentences, then run /notes stop. A channel called ai-meeting-transcripts is created the first time if the voice channel is open to everyone.

What to know before you do:

TopicDetail
InvitationThe bot never joins by itself. Only a member who is in the voice channel can start it, and only one meeting runs at a time.
ConsentThe bot posts one notice in the voice channel’s text chat when it joins. If it cannot post it, it leaves and does not start.
Where it goesOpen voice channel: the shared summary channel. Any other: that voice channel’s own text chat. Decided live when the meeting starts.
SpeechEach utterance goes to one transcription host, chosen at random from ASR_HOSTS (by default four PlaySafe hosts), with failover to the others and ASR_TIMEOUT_SECONDS per attempt. The server needs outbound HTTPS to them. The bot pauses transcription for 30 seconds after three failures in a row and queues at most 60 utterances. No audio is written to disk and nothing falls back to Gemini for speech.
Sent to GeminiThe Name: words transcript, up to 400,000 characters, the same as an upload. None of it is logged.
LimitsCounts with transcripts: MEETING_MAX_PER_DAY across everyone and 5 per member who starts notes. A meeting ends by itself after VOICE_NOTES_MAX_MINUTES, when the last person leaves, or when the bot is disconnected. A transcript under 200 characters is dropped and nothing is posted.
RetentionThe transcript text is deleted after MEETING_TRANSCRIPT_RETENTION_DAYS days, as for uploads.
RestartA running meeting is saved at shutdown and finished at the next start, once.
Gemini downThe transcript is kept, the bot says so, and it writes the notes up on its own about a minute later.
Blocking/notes-admin exclude stops members from starting notes in a channel. Manage Server can still start there.
Switching offSet VOICE_NOTES_ENABLED=false and redeploy. /notes is removed at the next start. /notes-admin stays while meeting transcripts are on.

See How voice notes work and Voice notes for admins.

Check whether the key was picked up:

Terminal window
docker compose logs bot | grep "\[ai\]"

An embedding is a list of numbers that stands for what a message means, so the bot can search by meaning and not only by words. With GEMINI_API_KEY set, the bot embeds stored message text through Gemini and logs [embeddings] enabled, sweep every 3 h. Each model call logs one line with token counts only, such as [ai] model=... rounds=... tools=... in=... cached=... out=... ms=.... Without the key the bot logs [embeddings] disabled: GEMINI_API_KEY is not set, summaries stay idle, and ticket prompts, meeting transcripts, voice notes and asking stay off. Everything else works.

New messages are embedded within a few seconds. The sweep catches the rest: it first runs a minute after the start, then every EMBEDDING_SWEEP_HOURS hours, embeds at most 2,000 messages per run and logs [embeddings] sweep: embedded=... failed=... remaining=.... A big history takes many sweeps.

To embed old history sooner, run the resumable backfill once from your laptop, not the server (the production image has no tsx). It needs Bun, Node 24 (curl -fsSL https://bun.sh/install | bash) and a checkout of the repository. The two values are the pooled URL and your Gemini key, in single quotes because the URL contains &:

Terminal window
git clone git@github.com:dogelabsvr/doge-discord-bot.git && cd doge-discord-bot && bun install && DATABASE_URL_POOLED='<pooled url>' GEMINI_API_KEY='<gemini key>' bun run embeddings:backfill

It prints embedded=N failed=N remaining=N after each round and ends with embed-backfill: done, N embedded. Press Ctrl+C to stop, and run it again to resume. If it ends with stopped, no progress, check the key and your Gemini quota.

Embedded message text goes to Gemini and is never logged. So does the text of a message that tags the bot, whether or not embeddings are on. A release that changes the embedding model makes the sweep re-embed every message over time; until it finishes, meaning-based search covers only the re-embedded rows and word search keeps working. Run the backfill to finish sooner.

This is optional, and without it you redeploy by hand with bash scripts/deploy.sh on the server. The Deploy workflow (a GitHub Actions automation defined in the repository) runs on every push to main, and by hand. It first runs the same checks as the CI workflow: typecheck, unit tests, migrations on a pgvector database (Postgres 18), integration tests, a migration drift check and bun audit --audit-level=high. If any fails, nothing is deployed. If they pass, it connects to your server over SSH, resets /opt/dl-discord-bot to the pushed commit and runs scripts/deploy.sh. A manual run deploys only from main; from any other branch it runs the checks and stops. .env is not tracked by git, so it survives. Two deploys never run at once, and the SSH step times out after 20 minutes.

Do this after the first deploy works, because until the server exists every push to main fails its deploy job. Run this block on your laptop in the tab where SERVER is set. It makes a throwaway key for GitHub Actions, authorises it on the server, stores the secrets and deletes the local key even if a step fails:

Terminal window
( d=$(mktemp -d) && trap 'rm -rf "$d"' EXIT \
&& ssh-keygen -q -t ed25519 -N "" -C github-actions -f "$d/ci" \
&& ssh deploy@$SERVER 'cat >> ~/.ssh/authorized_keys' < "$d/ci.pub" \
&& gh secret set VPS_HOST -R dogelabsvr/doge-discord-bot -b "$SERVER" \
&& gh secret set VPS_USER -R dogelabsvr/doge-discord-bot -b deploy \
&& gh secret set VPS_SSH_KEY -R dogelabsvr/doge-discord-bot < "$d/ci" )
SecretValue
VPS_HOSTThe server host.
VPS_USERdeploy.
VPS_SSH_KEYThe private half of the key made for CI.
VPS_PORTOptional, defaults to 22. If SSH listens elsewhere, run gh secret set VPS_PORT -R dogelabsvr/doge-discord-bot -b 2222 with your port.

To run the whole pipeline once and watch it:

Terminal window
gh workflow run deploy.yml -R dogelabsvr/doge-discord-bot --ref main && sleep 5 && gh run watch -R dogelabsvr/doge-discord-bot --exit-status "$(gh run list -R dogelabsvr/doge-discord-bot -w deploy.yml -L 1 --json databaseId -q '.[0].databaseId')"

From your laptop. Replace <good-sha> with the commit id of the last version that worked (the commit list on GitHub shows it, or run git log --oneline in a checkout). The next push to main deploys main again, so also revert the bad commit there.

Terminal window
ssh deploy@$SERVER 'cd /opt/dl-discord-bot && git fetch origin && git reset --hard <good-sha> && bash scripts/deploy.sh'

The deploy script only applies migrations. It does not undo them.