Scripts and health
The package scripts, the deploy script, the health route, the container settings and the startup log lines. Environment variables are on Configuration. The log lines appear in your terminal, or in docker compose logs bot when the bot runs in a container.
Package scripts
Section titled “Package scripts”Run them from the bot’s folder (the one with package.json), after bun install, with bun run <name>. There is no lint or format script. The Runs column shows the underlying command: tsx runs TypeScript directly, tsc is the TypeScript compiler, vitest is the test runner and drizzle-kit generates database migrations.
| Script | Runs | What it does |
|---|---|---|
dev | tsx watch src/index.ts | Runs the bot and restarts it when you save a file |
build | tsc -p tsconfig.build.json && bun scripts/rewrite-aliases.ts | Compiles the code to dist/, then rewrites the @/ import shortcuts to relative paths so Node can load them. Prints rewrite-aliases: N files |
start | node dist/index.js | Runs the compiled bot |
test | vitest run | Unit tests. The integration tests are left out |
test:integration | INTEGRATION=1 vitest run integration | Integration tests against TEST_DATABASE_URL |
typecheck | tsc --noEmit | Type checks without writing files |
verify | bun run typecheck && bun run test | Typecheck and unit tests |
db:generate | drizzle-kit generate | Writes a new migration file after you change the table definitions. Needs no database |
db:migrate | tsx src/db/migrate.ts | Applies migrations (the numbered SQL files in src/db/migrations) to the database. Needs the direct database URL |
ai:smoke | tsx scripts/ai-smoke.ts | Manual check that your GEMINI_API_KEY works (Gemini is Google’s AI service). Five checks, at least 6 real API calls |
embeddings:backfill | tsx scripts/embed-backfill.ts | Embeds all stored messages at once (see embeddings:backfill) |
dms:clear | tsx scripts/clear-dms.ts | Deletes the bot’s own DMs to one member, for a clean demo or test |
Only ai:smoke, embeddings:backfill and dms:clear read .env themselves, from the folder you run them in; a missing file is fine. The bot (dev, start), db:migrate and the tests do not, so their variables must already be set in your shell. In a container, Compose sets them from the .env file (its env_file setting). In a terminal, run bun --env-file=.env run <script>, for example bun --env-file=.env run db:migrate: Bun loads the file for that one command.
test:integration
Section titled “test:integration”Needs TEST_DATABASE_URL, the direct URL of a throwaway Postgres database with the vector extension and all migrations applied; the tests do not create tables. Local setup shows how. Set it for the one command, for example TEST_DATABASE_URL=<url> bun run test:integration. Without it the run stops with TEST_DATABASE_URL is required for integration tests. Files run one at a time because they share one database.
db:migrate
Section titled “db:migrate”Reads DATABASE_URL (the direct, non-pooled URL; see Configuration) and uses one encrypted connection, after removing the channel_binding parameter from the URL. It applies only the migrations not yet applied, so running it twice is safe. Success prints [migrate] complete. A missing URL stops with DATABASE_URL is not set. A failure prints [migrate] failed: and exits with code 1.
ai:smoke
Section titled “ai:smoke”Needs GEMINI_API_KEY (a .env file is enough). It runs text, tool+schema, embed-document, embed-query and embed-batch in order and prints <name>: ok or <name>: FAIL <ErrorName>: <message> for each. A failed check does not stop the others. Exit code 1 if any failed, otherwise 0. With no key it prints GEMINI_API_KEY is not set and exits with code 1.
embeddings:backfill
Section titled “embeddings:backfill”An embedding is a list of numbers that lets search match by meaning. The running bot embeds stored messages itself, a sweep at a time. This script does the whole backlog in one go, for example right after you first enable embeddings on a server with a long history. Needs GEMINI_API_KEY and a database URL: DATABASE_URL_POOLED, or DATABASE_URL when that one is blank. It ignores EMBEDDINGS_ENABLED. It embeds in batches of 100 with a 1 second pause, and can be stopped and re-run to resume. After each pass it prints embedded=N failed=N remaining=N, where embedded and failed add up across passes. Each embedded message costs about 7.6 KB of storage.
| Outcome | Output | Exit code |
|---|---|---|
| Finished | embed-backfill: done, N embedded | 0 |
| Stopped with Ctrl-C | embed-backfill: interrupted, N left; re-run to resume | 1 |
| No progress | embed-backfill: stopped, no progress (N left, N failed); check GEMINI_API_KEY and quota, then re-run to resume | 1 |
| A required value is missing | embed-backfill: DATABASE_URL_POOLED and GEMINI_API_KEY are required | 1 |
dms:clear
Section titled “dms:clear”Needs DISCORD_TOKEN. Run bun run dms:clear <discord user id>, and add --yes to delete. To get a member’s id, turn on Developer Mode in Discord (User Settings, Advanced), then right-click the member and choose Copy User ID. Only the bot can delete its messages in a DM, so this empties that conversation before a demo. It deletes only the bot’s own messages in its DM with that member and touches nothing in the database. If Discord refuses a request, for example because the member does not accept DMs from the bot, the script stops with the error and exit code 1.
| Outcome | Output | Exit code |
|---|---|---|
Without --yes | N bot messages in that DM. Run again with --yes to delete them. | 0 |
With --yes | Deleted N bot messages. | 0 |
| No token or not a Discord user id | Usage: bun run dms:clear <discord user id> [--yes] (needs DISCORD_TOKEN) | 1 |
Deploy script
Section titled “Deploy script”scripts/deploy.sh builds, migrates, starts the bot and waits until it is healthy. CI and people on the server both use it. Run it on the server with bash scripts/deploy.sh; it changes into the bot’s folder itself and needs Docker and a .env file there. It stops on the first error. Deploy walks through a first deploy.
| Step | Prints | Does |
|---|---|---|
| 1 | ==> Building image... | docker compose build |
| 2 | ==> Running migrations (a failure aborts the deploy)... | docker compose run --rm bot node dist/db/migrate.js |
| 3 | ==> Starting bot... | docker compose up -d --remove-orphans |
| 4 | ==> Waiting for bot to become healthy (up to 120s)... | Checks the container every 2 seconds, up to 60 times |
Endings: ==> bot is healthy. exits 0. ERROR: bot container not found., ERROR: bot reported unhealthy. and ERROR: timed out waiting for bot to become healthy. exit 1. The last two also show the last 200 log lines.
Health endpoint
Section titled “Health endpoint”The bot has one HTTP route, used by the container health check. You can also call it yourself, for example curl -i http://127.0.0.1:3000/health on the server. Any other path answers 404.
| Request | Condition | Status | Body |
|---|---|---|---|
GET /health | Discord client is ready | 200 | {"status":"ok"} |
GET /health | Discord client is not ready yet | 503 | {"status":"starting"} |
Ready means only that the Discord client is ready; the database is not checked. The server listens before Discord login finishes, so a 503 right after start is normal. A bot that cannot log in stays at 503, so the container turns unhealthy.
- The app listens on
PORT, default3000. - Docker Compose (the tool that starts the container) publishes it as
127.0.0.1:3000:3000, so it is reachable from the server itself and not from outside. - The container health check always calls port 3000 and does not follow
PORT. KeepPORTat3000in containers.
Container
Section titled “Container”| Setting | Value |
|---|---|
| Image build | Multi-stage. Dependencies and build use oven/bun:1; the runner uses node:24-bookworm-slim. The runner holds dist/, the production dependencies and the migration files, and no .env |
| Runtime | node dist/index.js. Node, not Bun |
| User | Non-root node |
| Environment | .env file, with PORT set to 3000 and NODE_ENV set to production over it |
| Restart policy | unless-stopped. Docker restarts the bot after any exit, including a server reboot, until you stop it with docker compose stop |
| Init process | init: true. A tiny process runs first and passes stop signals on to the bot |
| Stop grace period | 30s |
| Health check | Every 15 seconds, 5 second timeout, 30 second start period, 3 retries. Calls GET http://127.0.0.1:3000/health and passes on a 200 |
Startup
Section titled “Startup”The bot logs these lines in order. Lines for features that are off say so. A feature’s line also tells you why it is off, so check it after you change a switch (see Configuration).
| Order | Line | When |
|---|---|---|
| 1 | [ai] gemini configured or [ai] not configured (GEMINI_API_KEY unset) | Always |
| 2 | [embeddings] enabled, sweep every N h, [embeddings] disabled or [embeddings] disabled: GEMINI_API_KEY is not set | Always |
| 3 | [http] listening on :PORT | Always, before Discord login |
| 4 | [bot] logged in as TAG, then [bot] registered N guild commands | When Discord is ready. N is how many commands the bot registered in your server. With every switch off it is 6: the 5 base slash commands and the message menu. Add 1 with asking on (/ask), 3 with summaries on, 1 with LINEAR_API_KEY set (/linear-admin), and 1 with meeting transcripts on (/notes-admin) or 2 with voice notes on as well (/notes and /notes-admin) |
| 5 | [ask] disabled, [ask] off: needs GEMINI_API_KEY or [ask] enabled, N/member/day, cap N/day, retention N d | After registration succeeds |
| 6 | [reminders] disabled or [reminders] enabled, tick N min, late limit N min, allowlist N members | After the ask line |
| 7 | [summaries] disabled or [summaries] enabled, tick N min, max N generations/day, allowlist N members, linear on (or linear off) | After the reminders line |
| 8 | [tickets] disabled, [tickets] off: needs GEMINI_API_KEY and LINEAR_API_KEY or [tickets] enabled, cap N/day per author, max N checks/day, allowlist N members | After the summaries line |
| 9 | [voice] disabled, [voice] off: needs MEETING_TRANSCRIPTS_ENABLED and GEMINI_API_KEY or [voice] enabled, N ASR hosts, max N min, explicit invite only | After the tickets line |
| 10 | [meetings] disabled, [meetings] off: needs GEMINI_API_KEY or [meetings] enabled, #<channel>, max N/day, linear on, allowlist N members (or linear off) | After the voice line |
| 11 | [members] synced N members | Once Discord is ready, and again each time the connection re-identifies. N counts the non-bot members stored |
| 12 | [backfill] N channels to scan, M unreadable, K archived-thread listings failed, then the progress lines below, ending with [backfill] complete | Once Discord is ready, in the background, and again after a reconnect that re-identifies |
| 13 | [linear] auto-linked N members, M unsure, K unmatched | Only with LINEAR_API_KEY set, once, after the first member sync |
The [backfill] lines, in the order they appear:
| Line | Meaning |
|---|---|
[backfill] cannot read: <channels> | The bot lacks View Channel or Read Message History there |
[backfill] archived-thread listing failed: <channels> | Archived threads of those channels could not be listed |
[backfill] marked N deleted channels: <channels> | Stored channels that no longer exist were marked deleted |
[backfill] #<name>: N messages scanned | Every 10 pages (1,000 messages) inside one channel |
[backfill] D/T channels (F failed) | After each channel finishes |
[backfill] <channel id>: marked N missed deletes | Messages deleted while the bot was away |
[backfill] <channel id>: history unreadable after the walk, skipping delete reconciliation | History access was lost mid-scan, so no message was marked deleted |
[backfill] complete | Every channel was scanned |
[backfill] complete, N channels failed (retry on next start or reconnect): <names> | The listed channels failed twice, 30 seconds apart |
[backfill] aborted | The bot was shutting down |
The reminder engine and the summary scheduler start only after command registration succeeds, and each runs a tick (one check pass) at once. The embedding sweeper starts with the process, and its first sweep runs a minute later. Ticket prompts and meeting transcripts have no timer: they react to new messages. If registration fails, the bot logs [bot] command registration failed: and exits with code 1, so none of them start.
The ticket button and form handler, the ask button handler and the meeting button handler are registered at every start, whatever the switches say, so prompts, answers and action item messages already sent keep working.
What the numbers in the enabled lines mean:
[tickets]:TICKET_PROMPT_DAILY_CAP,TICKET_PROMPT_MAX_CHECKS_PER_DAYand the number of ids inREMINDER_ALLOWLIST. Thedisabledline meansTICKET_PROMPTS_ENABLEDisfalse;offmeans it istruebut a Gemini or Linear key is missing.[meetings]: the channel is the stored summary channel name (theMEETING_CHANNEL_NAMEdefault until an admin sets one; it is not checked at startup), thenMEETING_MAX_PER_DAYand the allowlist count.linearisonwhenLINEAR_API_KEYis set.[voice]: the count of ASR (speech-to-text) hosts andVOICE_NOTES_MAX_MINUTES. A voice meeting that a restart interrupted is finished once Discord is ready, and[voice] recovery: N finished, M failedis logged when there was one.
Once command registration succeeds, the bot also runs four clean-ups whatever the switches say. If the table one needs is missing (the migration has not been applied), its line appears at every start and the bot carries on; run the migrations with bun run db:migrate.
[summaries] startup prune failed: ...Clears summary text older than 30 days. Needs thesummariestable (migration 0007).[tickets] startup prune failed: ...Clears ticket draft text older than 30 days. Needs theticket_promptstable (migration 0009).[meetings] prune failed: ...Deletes transcript text older thanMEETING_TRANSCRIPT_RETENTION_DAYSdays, then every 24 hours. Needs themeeting_transcriptstable from the meetings migration.[ask] prune failed: ...Deletes question records older thanASK_LOG_RETENTION_DAYSdays, then every 24 hours. Needs theask_questionstable (migration 0016).
Three more lines can appear:
[summaries] AI is not configured, scheduler idleLogged once, by the first summary tick, when summaries are on and no Gemini key is set.[voice] created the summary channel #<name>Logged when a voice meeting created the shared summary channel.[db] warm-up failed: <error name>Logged when the bot could not open its first three database connections. It carries on and opens connections as it needs them.
Fatal errors
Section titled “Fatal errors”These end the process with exit code 1.
[app] fatal: Error: Invalid environment:followed by one line per problem. See Configuration.[app] fatal: ...for any other failure while starting, including a failed read of the stored summary channel name when meeting transcripts are on (the bot will not fall back to the default name).[bot] command registration failed:when Discord refuses the commands, for example because of a wrongDISCORD_GUILD_ID.[bot] session invalidated, exitingwhen Discord invalidates the session.
A rejected promise that nothing handles is logged as [unhandledRejection] and the bot keeps running.
Shutdown
Section titled “Shutdown”On SIGTERM or SIGINT (the stop signals sent by docker stop, Docker Compose and Ctrl-C) the bot logs [app] <signal> received, shutting down and in order:
- Saves a running voice meeting, up to 8 seconds.
- Stops the reminder engine, summary scheduler, embedding runtime, ticket prompt runtime, meeting transcript runtime and the backfill, and waits up to 10 seconds. The last two drop waiting work at once and give running work up to 5 seconds before aborting it. An aborted check ends silently and files nothing. An aborted upload is marked failed with reason
abortedand posts nothing more. - Closes the HTTP server, the Discord client and the database pool, and exits with code 0.
A failure while closing is logged as [app] shutdown error: and exits with code 1. A hard exit with code 1 after 25 seconds guards against a hang, inside the 30 second grace period.
- Set the variables: Configuration.
- Ship it: Deploy.
- Startup fails or a line is missing: Troubleshooting.