Skip to content

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.

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.

ScriptRunsWhat it does
devtsx watch src/index.tsRuns the bot and restarts it when you save a file
buildtsc -p tsconfig.build.json && bun scripts/rewrite-aliases.tsCompiles the code to dist/, then rewrites the @/ import shortcuts to relative paths so Node can load them. Prints rewrite-aliases: N files
startnode dist/index.jsRuns the compiled bot
testvitest runUnit tests. The integration tests are left out
test:integrationINTEGRATION=1 vitest run integrationIntegration tests against TEST_DATABASE_URL
typechecktsc --noEmitType checks without writing files
verifybun run typecheck && bun run testTypecheck and unit tests
db:generatedrizzle-kit generateWrites a new migration file after you change the table definitions. Needs no database
db:migratetsx src/db/migrate.tsApplies migrations (the numbered SQL files in src/db/migrations) to the database. Needs the direct database URL
ai:smoketsx scripts/ai-smoke.tsManual check that your GEMINI_API_KEY works (Gemini is Google’s AI service). Five checks, at least 6 real API calls
embeddings:backfilltsx scripts/embed-backfill.tsEmbeds all stored messages at once (see embeddings:backfill)
dms:cleartsx scripts/clear-dms.tsDeletes 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.

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.

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.

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.

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.

OutcomeOutputExit code
Finishedembed-backfill: done, N embedded0
Stopped with Ctrl-Cembed-backfill: interrupted, N left; re-run to resume1
No progressembed-backfill: stopped, no progress (N left, N failed); check GEMINI_API_KEY and quota, then re-run to resume1
A required value is missingembed-backfill: DATABASE_URL_POOLED and GEMINI_API_KEY are required1

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.

OutcomeOutputExit code
Without --yesN bot messages in that DM. Run again with --yes to delete them.0
With --yesDeleted N bot messages.0
No token or not a Discord user idUsage: bun run dms:clear <discord user id> [--yes] (needs DISCORD_TOKEN)1

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.

StepPrintsDoes
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.

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.

RequestConditionStatusBody
GET /healthDiscord client is ready200{"status":"ok"}
GET /healthDiscord client is not ready yet503{"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, default 3000.
  • 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. Keep PORT at 3000 in containers.
SettingValue
Image buildMulti-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
Runtimenode dist/index.js. Node, not Bun
UserNon-root node
Environment.env file, with PORT set to 3000 and NODE_ENV set to production over it
Restart policyunless-stopped. Docker restarts the bot after any exit, including a server reboot, until you stop it with docker compose stop
Init processinit: true. A tiny process runs first and passes stop signals on to the bot
Stop grace period30s
Health checkEvery 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

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).

OrderLineWhen
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 setAlways
3[http] listening on :PORTAlways, before Discord login
4[bot] logged in as TAG, then [bot] registered N guild commandsWhen 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 dAfter registration succeeds
6[reminders] disabled or [reminders] enabled, tick N min, late limit N min, allowlist N membersAfter 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 membersAfter 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 onlyAfter 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 membersOnce 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] completeOnce Discord is ready, in the background, and again after a reconnect that re-identifies
13[linear] auto-linked N members, M unsure, K unmatchedOnly with LINEAR_API_KEY set, once, after the first member sync

The [backfill] lines, in the order they appear:

LineMeaning
[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 scannedEvery 10 pages (1,000 messages) inside one channel
[backfill] D/T channels (F failed)After each channel finishes
[backfill] <channel id>: marked N missed deletesMessages deleted while the bot was away
[backfill] <channel id>: history unreadable after the walk, skipping delete reconciliationHistory access was lost mid-scan, so no message was marked deleted
[backfill] completeEvery channel was scanned
[backfill] complete, N channels failed (retry on next start or reconnect): <names>The listed channels failed twice, 30 seconds apart
[backfill] abortedThe 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_DAY and the number of ids in REMINDER_ALLOWLIST. The disabled line means TICKET_PROMPTS_ENABLED is false; off means it is true but a Gemini or Linear key is missing.
  • [meetings]: the channel is the stored summary channel name (the MEETING_CHANNEL_NAME default until an admin sets one; it is not checked at startup), then MEETING_MAX_PER_DAY and the allowlist count. linear is on when LINEAR_API_KEY is set.
  • [voice]: the count of ASR (speech-to-text) hosts and VOICE_NOTES_MAX_MINUTES. A voice meeting that a restart interrupted is finished once Discord is ready, and [voice] recovery: N finished, M failed is 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 the summaries table (migration 0007).
  • [tickets] startup prune failed: ... Clears ticket draft text older than 30 days. Needs the ticket_prompts table (migration 0009).
  • [meetings] prune failed: ... Deletes transcript text older than MEETING_TRANSCRIPT_RETENTION_DAYS days, then every 24 hours. Needs the meeting_transcripts table from the meetings migration.
  • [ask] prune failed: ... Deletes question records older than ASK_LOG_RETENTION_DAYS days, then every 24 hours. Needs the ask_questions table (migration 0016).

Three more lines can appear:

  • [summaries] AI is not configured, scheduler idle Logged 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.

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 wrong DISCORD_GUILD_ID.
  • [bot] session invalidated, exiting when Discord invalidates the session.

A rejected promise that nothing handles is logged as [unhandledRejection] and the bot keeps running.

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:

  1. Saves a running voice meeting, up to 8 seconds.
  2. 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 aborted and posts nothing more.
  3. 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.