Everything is set through environment variables (named settings the bot reads when it starts), normally in a .env file in the bot’s folder. Create it with cp .env.example .env; the example file lists every variable with a comment on where its value comes from. Four are required. The rest have defaults.
A smallest working .env has just the four required lines (replace each placeholder; the sections below say where each value comes from):
DISCORD_TOKEN=<bot token>
DISCORD_GUILD_ID=<server id>
DATABASE_URL_POOLED=<pooled database URL>
DATABASE_URL=<direct database URL>
Changes apply at the next start. In Docker, run bash scripts/deploy.sh again: a plain docker compose restart does not pick up .env changes.
The bot checks all of them at start. If any is invalid it exits with one line per problem and never prints secret values:
[app] fatal: Error: Invalid environment:
<variable>: <message>
A stack trace follows. Problems are listed together, but two checks run only once the rest is fixed, so you may need a second start. The late limit rule under Reminders is left out while a required variable is missing or NODE_ENV, PORT or a true/false variable is invalid. A bad ASR_HOSTS is reported only when everything else is fine.
Keep every value bare. A # comment after a value becomes part of the value in Docker’s env_file, so put comments on their own line.
Boolean variables accept true or false in any letter case. Anything else is rejected.
Terms used below:
Master switch: the *_ENABLED variable of a feature. When it is false the feature does nothing.
Tick: one check pass of a timer, for example the reminder engine looking for due reminders.
Rolling 24 hours: the 24 hours counted back from now, not since midnight.
Discord id (also called a snowflake): a number of 17 to 20 digits that names a server, member or channel. In Discord, turn on Developer Mode (User Settings, Advanced), then right-click the item and choose Copy Server ID or Copy User ID.
The bot token from the Discord Developer Portal (your app, Bot, Reset Token); Discord application shows the steps. It is shown once, so copy it when you reset it. Any non-empty text passes validation. Discord decides if it works.
DISCORD_GUILD_IDtextrequired
The Discord id of your server (Discord calls a server a guild). Slash commands are registered here, and the bot ignores messages from any other server. Invalid values fail with must be a Discord snowflake, which means the value is not a 17 to 20 digit id.
The pooled database URL, used by the running bot. The database is a Neon Postgres database, and a pooled URL goes through Neon’s connection pooler, which shares connections between clients; its host contains -pooler. Create the database shows where to find both URLs. It keeps up to 10 connections, holds idle ones open and opens three at start, because a new Neon connection can take about 2 seconds and the ticket form must answer within Discord’s 3.
DATABASE_URLURLrequired
The direct, non-pooled URL of the same database. Migrations (the changes that create and update the tables) use it, and bun run embeddings:backfill falls back to it when DATABASE_URL_POOLED is not set. The running bot never connects with it but still refuses to start without a valid one. A pooled URL here makes the migration fail.
A key for Gemini, Google’s AI service, from Google AI Studio (Get API key). Without it embeddings, summaries, asking, ticket prompts and meeting transcripts turn themselves off or idle (voice notes go off with meetings), each with its own startup line, and everything else carries on. /summary now answers Summaries are not available: AI is not configured. The bot logs [ai] gemini configured or [ai] not configured (GEMINI_API_KEY unset) at startup. See AI and embeddings.
Default: unset
LINEAR_API_KEYtextoptional
A Linear (issue tracker) API key, made under Settings, Account, Security and access. The bot starts without it, and these features need it.
Summaries. Without it /summary-admin create and /summary-admin map-channel stop with Linear is not connected, so I cannot validate the team. Ask an admin to set LINEAR_API_KEY. With it, summaries add ticket activity from their linked Linear team and /summary-admin can list and check Linear teams and projects. If you remove the key later, existing summaries keep running from channel messages only.
Ticket prompts. The key needs Read and Create issues, because the bot creates a ticket when a member submits the form. The key’s owner is recorded as the ticket’s creator, so use a service account limited to the teams members may file into. Private teams are never searched, but a member linked to a Linear user is offered the private teams they belong to, so the key must be allowed to create issues there too.
Member linking. The bot links Discord members to Linear users by exact name, reads their names, team memberships and labels, keeps only the Linear user id, and adds /linear-admin. See Linking members to Linear.
Meetings. Summaries link existing tickets, the owner of an action item can file it, and a person an admin linked to a Linear user on the ticket’s team can apply a meeting update, which sets its status to Backlog, Todo, In Progress or In Review, or adds a comment. That needs a key that may update and comment as well as read and create. The bot never sets Done or Canceled.
Without the key, ticket prompts do nothing, /linear-admin is not registered, buttons on prompts already sent answer Ticket prompts are turned off., and meeting summaries post with no ticket links and no ticket can be created from them.
With reminders on, a REMINDER_LATE_LIMIT_MINUTES smaller than REMINDER_TICK_MINUTES stops the bot at start with:
REMINDER_LATE_LIMIT_MINUTES: REMINDER_LATE_LIMIT_MINUTES must be at least REMINDER_TICK_MINUTES or due reminders are silently dropped
REMINDERS_ENABLEDbooleanoptional
The master switch. When false the engine does not start and the bot logs [reminders] disabled.
Default: true
Allowed: true, false
REMINDER_TICK_MINUTESnumberoptional
How often the reminder engine looks for due channel reminders (one tick). It also runs once at start. Follow-ups have their own timer and are sent at the second they are due; the tick only caps how far ahead that timer sleeps.
Default: 15
Allowed: 1 to 60, whole numbers
REMINDER_LATE_LIMIT_MINUTESnumberoptional
A channel reminder more than this many minutes late is skipped, so a reminder missed while the bot was down is not sent afterwards. With REMINDERS_ENABLED true it must be at least REMINDER_TICK_MINUTES (equal passes), so 0 is rejected and with the default tick the lowest value is 15. Follow-ups ignore it. With reminders off, any value from 0 to 720 passes.
Default: 60
Allowed: 0 to 720, whole numbers; at least REMINDER_TICK_MINUTES when reminders are on
REMINDER_ALLOWLISTtextoptional
An allowlist: comma separated Discord user ids, for example 123456789012345678,234567890123456789; spaces and empty entries are ignored. When set, only these members get a DM the bot starts itself: reminders, follow-ups, setup, scheduled summaries, ticket prompts, meeting action items and the notices to whoever started a voice meeting. For ticket prompts it limits whose messages are checked at all, so a message from anyone else is never sent to Gemini. It also decides whose tag of the bot is read. It does not limit /summary now or who may upload a meeting transcript. Each entry must be 17 to 20 digits, or startup fails with REMINDER_ALLOWLIST.N: must be Discord snowflakes, where N counts from 0.
Default: empty, which means everyone
Tagging the bot sends the tagged message’s text, without the mention and cut to 2,000 characters, to Gemini to decide whether it is a follow-up request, a question or neither. It needs GEMINI_API_KEY. It happens while REMINDERS_ENABLED is true (for authors the allowlist lets through) and while asking is on (for any author). The limits are 20 readings per author per rolling hour and MENTION_MAX_CHECKS_PER_DAY across all members. A failed reading logs only [follow-up] mention reading failed: <error class>, never the text. After a failed reading, without a key, or at a limit, a tag on a reply still starts a follow-up with no time and any other tag is ignored.
The master switch. When false the bot does not register /summary, /interests or /summary-admin, the scheduler does not start, and the bot logs [summaries] disabled. When true it also needs GEMINI_API_KEY; without it the scheduler logs [summaries] AI is not configured, scheduler idle and sends nothing. The three commands appear at the next start. See Summaries.
Default: false
Allowed: true, false
SUMMARY_TICK_MINUTESnumberoptional
How often the scheduler looks for due summaries. A scheduled summary normally goes out within this many minutes of its send time. One that is held back is retried on later ticks for up to 60 minutes.
Default: 15
Allowed: 1 to 60, whole numbers
SUMMARY_MAX_GENERATIONS_PER_DAYnumberoptional
The daily limit on summaries over any rolling 24 hours, which bounds Gemini spend. It counts every summary sent or failed, one per recipient, whether scheduled, /summary now or /interests now, and a personal digest that found no matches after calling Gemini. At the limit the scheduler logs [summaries] daily generation cap reached and stops for that tick, and /summary now answers Daily summary limit reached, try again later. Room opens as older summaries pass 24 hours. A scheduled summary held back this way is retried for up to 60 minutes after its send time, then dropped for that period.
Default: 200
Allowed: 1 to 10000, whole numbers
Summaries send message text and Linear ticket text to Gemini. Scheduled summaries arrive as DMs, limited by REMINDER_ALLOWLIST. /summary now answers with a reply only the asker can see. Neither is posted in a channel. Set REMINDER_ALLOWLIST before you first switch summaries on.
The master switch. It also needs GEMINI_API_KEY. The startup line says which case you are in: [ask] disabled, [ask] off: needs GEMINI_API_KEY, or [ask] enabled, 50/member/day, cap 500/day, retention 30 d. From the next start it adds /ask and lets a tag that asks a question get a public reply. See Asking the bot questions.
Default: false
Allowed: true, false
ASK_MAX_PER_MEMBER_PER_DAYnumberoptional
The most questions one member can ask in any rolling 24 hours. A question counts once the bot starts answering it, including one that fails.
Default: 50
Allowed: 1 to 1000, whole numbers
ASK_DAILY_CAPnumberoptional
The most questions across all members in any rolling 24 hours. It bounds spend, because every question calls Gemini several times. It is enforced when the question is claimed, so simultaneous questions cannot pass it.
Default: 500
Allowed: 1 to 100000, whole numbers
ASK_LOG_RETENTION_DAYSnumberoptional
How many days a question record is kept: the question, the search arguments and result ids, the references cited, the feedback, the model and the token counts, but no text of the messages the bot found. Older rows are deleted at every start and then daily, even with asking off.
Default: 30
Allowed: 1 to 3650, whole numbers
MENTION_MAX_CHECKS_PER_DAYnumberoptional
The most tags sent to Gemini to be read, for follow-ups and questions alike, in any rolling 24 hours across all members. It bounds spend. Over it, a tag falls back as when the author’s 20 readings an hour are used up (see Reminders). The count lives in memory, so a restart resets it.
Default: 2000
Allowed: 1 to 100000, whole numbers
REMINDER_ALLOWLIST does not limit who can ask, and Linear is not a source, so LINEAR_API_KEY makes no difference. Each question sends the question and the message and meeting text the bot’s searches return to Gemini. A /ask answer is private to the asker unless the asker sets public. The answer to a tag is always a public reply to the tagging message, which needs View Channel, Send Messages (Send Messages in Threads in a thread), Embed Links and Read Message History in that channel. Asking the bot questions explains how a public answer is limited to what that channel’s readers can see.
The master switch. It also needs GEMINI_API_KEY and a LINEAR_API_KEY with Read and Create issues. The startup line says which case you are in: [tickets] disabled, [tickets] off: needs GEMINI_API_KEY and LINEAR_API_KEY, or [tickets] enabled, .... Switching it off stops new prompts only; prompts already sent keep working while LINEAR_API_KEY is set. It adds no slash commands (/linear-admin follows the Linear key). It takes effect at the next start. See Ticket prompts.
Default: false
Allowed: true, false
TICKET_PROMPT_DAILY_CAPnumberoptional
The most prompts one member can get in any rolling 24 hours. Every prompt counts, including one whose DM could not be delivered; a message that matches an existing ticket sends no prompt and does not count. The DM tells the member this number.
Default: 3
Allowed: 1 to 50, whole numbers
TICKET_PROMPT_MAX_CHECKS_PER_DAYnumberoptional
The most messages sent to Gemini in any rolling 24 hours, across all members. It bounds spend. A message that stops at the member’s daily cap does not count. When it is used up, checks are skipped without a log line. The count lives in memory, so a restart resets it.
Default: 2000
Allowed: 1 to 100000, whole numbers
The bot also applies limits you cannot change: 20 checks per author per rolling hour, at most 4 checks running at once, 200 checks waiting or running in total and 10 waiting or running per author (more are dropped), and a one minute pause on new checks when Gemini is unavailable.
Ticket prompts send the text of a stored live message of 8 or more words to Gemini, with the title and description of the Linear tickets found by search. If the message looks like a proposal and its author may file into two or more teams, a second call sends the message text (cut to 4,000 characters) with those teams’ names and short descriptions, only to preselect the team on the form. Old and edited messages are never read. At creation, the ticket title and description and the names of the labels that team can use also go to Gemini so it can pick labels. A prompt is a DM only the author can see, and only a submitted form writes to Linear. Set REMINDER_ALLOWLIST before you first switch ticket prompts on.
The master switch. It also needs GEMINI_API_KEY; LINEAR_API_KEY is optional. The startup line says which case you are in: [meetings] disabled, [meetings] off: needs GEMINI_API_KEY or [meetings] enabled, #<channel>, max N/day, linear on|off, allowlist N members. Switching it off stops new transcripts only; action item messages already sent keep working while LINEAR_API_KEY is set. It adds no slash commands. It takes effect at the next start. See Meeting transcripts.
Default: false
Allowed: true, false
MEETING_CHANNEL_NAMEtextoptional
The default name of the one channel the bot watches for uploads and posts voice meeting summaries in. An admin can change it with /notes-admin summary-channel set (registered whenever meeting transcripts are on), and the stored name then wins. The bot creates the channel the first time a voice meeting needs it, which needs Manage Channels, and the channel is visible to everyone in the server. With voice notes off nothing creates it, so create a text channel with this name yourself. The bot needs View Channel, Send Messages and Read Message History in it.
Default: ai-meeting-transcripts
Allowed: 1 to 50 characters: lowercase letters, digits and hyphens
MEETING_MAX_FILE_KBnumberoptional
The largest transcript file the bot accepts, in kilobytes. It reads .txt, .vtt, .srt and .md files and uses at most the first 400,000 characters. A larger file is refused with a reply giving the limit in KB below 1 MB (the default reads 512 KB) and in whole megabytes from 1 MB up.
Default: 512
Allowed: 1 to 2048, whole numbers
MEETING_MAX_PER_DAYnumberoptional
The most transcripts the bot reads in any rolling 24 hours, across all members. It bounds spend. A refused upload is answered with The daily limit for transcripts has been reached. Please try again tomorrow. The count lives in memory, so a restart resets it. A run that ends because Gemini was unavailable or the bot shut down gives its slot back; any other failure keeps it.
Default: 20
Allowed: 1 to 1000, whole numbers
MEETING_TRANSCRIPT_RETENTION_DAYSnumberoptional
How long transcript text is kept. The clean-up runs at every start, even with the feature off, then daily. The summary, attendee names and action items are kept.
Default: 90
Allowed: 1 to 3650, whole numbers
The bot also applies limits you cannot change: 5 transcripts per person in any rolling 24 hours (a voice meeting counts for the person who started it), 10 uploads per person per rolling hour, at most 10 people messaged per transcript (the rest of the items go to the uploader), and a one minute pause on new uploads when Gemini is unavailable. Without LINEAR_API_KEY no action item message is sent.
Meeting transcripts send an uploaded transcript’s text to Gemini. If a meeting has new work and no stated team, a second call sends the meeting title, decisions and action item titles with the names and short descriptions of the public Linear teams, only to order the team picker. Anyone who can post in the channel can upload, so set REMINDER_ALLOWLIST before you first switch this on: it limits who gets action item DMs, not who can upload. Text typed in the channel is stored and embedded like any message.
Voice notes also need MEETING_TRANSCRIPTS_ENABLED and GEMINI_API_KEY. Daily limits, retention and the summary channel are the meeting variables above. Every number below is checked at start even with voice notes off, and a value outside its range stops the bot.
VOICE_NOTES_ENABLEDbooleanoptional
The master switch. The startup line says which case you are in: [voice] disabled, [voice] off: needs MEETING_TRANSCRIPTS_ENABLED and GEMINI_API_KEY or [voice] enabled, N ASR hosts, max N min, explicit invite only. It adds /notes at the next start. The bot never joins a voice channel unless a member runs /notes start. See Voice notes.
Default: false
Allowed: true, false
ASR_HOSTSJSONoptional
The ASR (automatic speech recognition, meaning speech-to-text) hosts. A request goes to one host picked at random by weight, and the others are tried in turn if it fails. For example [{"model":"my-model","url":"https://asr.example.com","weight":1}]. It is parsed only while VOICE_NOTES_ENABLED is true. A bad value stops startup with ASR_HOSTS: must be valid JSON or ASR_HOSTS: must be a non-empty JSON array of {model, https url, positive weight}, and is never printed. The model string is stored with each transcript.
Default: unset, which means the four built-in hosts with equal weights
Allowed: a non-empty JSON array of {model, https url, positive weight}
VOICE_NOTES_MAX_MINUTESnumberoptional
A meeting ends by itself after this many minutes, and the notes are written.
Default: 180
Allowed: 5 to 720, whole numbers
ASR_TIMEOUT_SECONDSnumberoptional
How long one host gets to answer one utterance before the next is tried. Keep it under about 100, where the service’s edge drops requests.
Default: 60
Allowed: 5 to 90, whole numbers
VOICE_MIN_UTTERANCE_MSnumberoptional
The shortest piece of speech that is sent to transcription, in milliseconds. Shorter sounds are dropped.
Default: 800
Allowed: 200 to 5000, whole numbers
ASR_CONCURRENCYnumberoptional
How many utterances a session sends to transcription at once. A clip takes roughly 5 to 10 seconds and the shared fleet sheds load above its capacity, so raise it with care.
Default: 5
Allowed: 1 to 8, whole numbers
The bot also applies limits you cannot change: one meeting at a time, up to 60 utterances waiting for a free transcription slot (ASR_CONCURRENCY run at once), one host tried at most once per utterance, and a 30 second pause on transcription after three utterances in a row fail on every host. Nothing falls back to Gemini for speech.
Voice notes send each speaker’s audio, held only in memory and never written to disk, to the transcription hosts, and the resulting text to Gemini. Everyone who speaks is transcribed, so use /notes-admin exclude for channels where that is not acceptable.
The master switch for meaning-based search vectors. It also needs GEMINI_API_KEY. The startup line says which case you are in: [embeddings] disabled, [embeddings] disabled: GEMINI_API_KEY is not set or [embeddings] enabled, sweep every N h.
Default: true
Allowed: true, false
EMBEDDING_SWEEP_HOURSnumberoptional
How often the sweep embeds waiting messages. The first sweep runs a minute after start. Each sweep handles at most 2,000 messages, so a large backlog takes several.
Default: 3
Allowed: 1 to 24, whole numbers
When active, message text is sent to Google’s Gemini API for embedding. Summaries and /ask read the vectors to search by meaning; members have no search command. See Messages and backfill for what is stored.
The direct URL of a throwaway database. Only bun run test:integration uses it; the app never reads it. It is required for those tests and must be in the command’s environment, because the tests do not read .env: run TEST_DATABASE_URL=<url> bun run test:integration. Local setup shows how to prepare the database.