Posts to Slack when someone opens our Discord "virtual office" voice channel, and again when it empties out, so people know when they can jump in. On weekdays it also posts a reminder to come hang out, and a few bits of AI-written banter about what's been going on in the office. When someone mentions it or replies under one of its posts, it can answer, react or stay out of it. It also records what happens in the office channel to a Postgres database.
- Features
- What it posts
- How it works
- Getting started
- Configuration
- Development
- Deployment
- Troubleshooting
- Privacy
- Contributing
- License
- Announces the edges of a session only. The first person in opens the office and the last person out empties it. Joins and leaves in between aren't announced, to keep the Slack channel quiet.
- Recaps each session with how long the office was open and how many different people came by.
- Weekday reminder at 11:15 UTC+2.
- Office banter. Up to five times a working day, at random, an AI model reads what happened in the office and posts something playful about it.
- Slack cards with a button that opens the office in Discord, the opener's avatar, and times shown in each reader's own time zone.
- AI-written headlines from free models on OpenRouter or Cloudflare Workers AI, falling back to fixed phrasings.
- Answers in Slack, but only when an answer is wanted. Mentions always get a reply; for replies under its posts, Clef decides whether to reply, react or ignore. It's switched on and tuned in the database, and capped per day and per thread.
- Restart-safe. It counts whoever is already in the channel at startup, so a restart mid-session doesn't announce the office opening again.
- Keeps going when Slack doesn't. Failed posts are retried with backoff and logged, and never crash the process.
- Records voice activity in the office channel to a database: joins, leaves, mute, deafen, camera and streaming, exactly as Discord reports them, plus soundboard sounds and emoji reactions. The database can never stop the announcements.
- Optional Sentry monitoring of errors, traces and logs.
Each post is a Block Kit card. With the fixed phrasings, they look like this:
🎙️ Ada opened the virtual office — everyone's welcome to join!
🎧 Join the office🔊 Opened on Discord at 4:05 PM
🪑 The virtual office is empty right now — jump in and get it going!
Open for 2h 14m · Stopped by 5 people
🎧 Jump in🔇 Emptied at 6:19 PM
⏰ Daily reminder: come hang out in the virtual office!
🎧 Join the office
When an AI model writes the headline and button label, the card adds a ✨ Headline by <model> line. See AI headlines.
Banter is always written by an AI model, from what's actually happened in the office:
🎬 Wednesday's crew arrived like a flash mob: Barbara, Alan, Grace and Linus all joined within 8 minutes, cameras snapped on in unison, then off together 40 minutes later.
🚪 Join the ensemble✨ Banter by nvidia/nemotron-3-ultra-550b-a55b:free · 🤖 AI is a hallucination machine.
| Post | Trigger |
|---|---|
| Opened | The first person joins the empty office channel. Moving in from another voice channel counts as joining. |
| Emptied | The last person leaves. Moving to another voice channel counts as leaving. The recap is left out for a session that was already under way when the bot started, since it missed the beginning. |
| Reminder | Weekdays at 11:15 UTC+2. It's a fixed offset, so it doesn't shift with daylight saving. Skipped if the office channel wasn't found at startup, since there's nothing to link to. |
| Banter | At random, between 1 and 2½ hours apart, from 09:00 to 18:00 UTC+2 on weekdays, and at most 5 a day. See Banter. |
| Reply | In the thread, when someone mentions the bot or Clef decides a reply under one of its posts wants an answer, 09:00 to 18:00 UTC+2 on weekdays. See Answering in Slack. |
It only watches the one office channel and ignores bots, itself included. Mute, deafen and video changes, soundboard sounds and emoji reactions aren't announced, only recorded.
The fixed opened and emptied headlines are picked at random from a few phrasings. The reminder rotates through its phrasings by date, so each one comes up once before any repeats.
The original design is in issue #1.
Discord only pushes voice-state changes over its Gateway WebSocket, so this is an always-on Node.js process rather than a webhook or serverless function. It's written in TypeScript with Effect v4, uses discord.js to receive voiceStateUpdate events, and posts to Slack with a bot token through chat.postMessage. It listens to Slack over a Socket Mode WebSocket, so the VM needs no public endpoint either.
flowchart LR
Discord[Discord Gateway] -->|voiceStateUpdate| Gateway[DiscordGateway]
Gateway -->|Opened / Emptied| Program
Schedule[Weekday schedule] -->|Reminder / Banter| Program
Schedule <-.->|activity and banter count| Postgres
Program --> Notifier[SlackNotifier]
Notifier <-->|headline + label| Writer[HeadlineWriter]
Writer <-.->|optional| AI[OpenRouter / Workers AI]
Notifier -->|chat.postMessage| Slack[Slack]
Slack -->|Socket Mode events| Responder
Responder <-.->|reply, react or ignore| Clef[Clef on Workers AI]
Responder <-->|reply| ReplyWriter
ReplyWriter <-.-> AI
Responder -->|replies and reactions| Slack
Responder <-.->|settings, caps and decisions| Postgres
Gateway -->|voice observations and effects| Recorder[ActivityRecorder]
Program -->|join / leave the office| Gateway
Recorder -->|in order, in the background| Log[ActivityLog]
Log --> Postgres[(Supabase Postgres)]
Events are handled one at a time, so one that arrives while a Slack post is being retried waits its turn and messages stay in order. Each call to Slack's Web API times out after 10 seconds and is retried up to 4 times with jittered exponential backoff from 1 second, or after Slack's Retry-After when rate-limited (ratelimited or a 429). Slack answers 200 with ok: false when it refuses something, so failures are told apart by Slack's error code: invalid_auth, token_revoked, account_inactive, channel_not_found and not_in_channel mean the bot has lost access, and anything else means it rejected what was sent. Neither is retried. Either way the failure is logged and the next event is handled as normal.
Answering in Slack runs in its own fiber too, so nothing on the Slack side can hold up or stop the announcements. See Answering in Slack.
Recording runs alongside, in its own fiber, so a slow Slack post never holds it up. See Database for how it handles the database being down.
Discord only sends soundboard sounds and emoji reactions (voiceChannelEffectSend) to clients in the voice channel. So while anyone is in the office, the bot is too: it joins when the office opens and leaves when it empties, already muted and deafened. It only sends the gateway's voice-state message and never opens an audio connection, so it can't hear or send anything. It also leaves an empty office at startup, in case an earlier run left it behind. Whenever its Discord connection comes back after a drop, it goes back to where it should be. If someone disconnects it by hand, it's back the next time the office opens.
| Module | Responsibility |
|---|---|
src/main.ts |
Entry point. Provides the layers and runs the program. |
src/Program.ts |
Merges office events with reminders and posts each one to Slack, and records voice activity alongside. |
src/DiscordGateway.ts |
Logs in to Discord, finds the office channel and turns voice-state updates into office events and observations to record. |
src/OfficeEvent.ts |
The event types and the occupancy tracking that decides when the office opens or empties. |
src/Reminder.ts |
The weekday reminder schedule. |
src/Banter.ts |
When to post banter, which stretch of activity it covers, and the log the AI model reads. |
src/OfficeHistory.ts |
Reads the recorded activity and who's in, and keeps count of the banter posted. |
src/Storage.ts |
Sets up the database once for recording and reading, or turns both off without DATABASE_URL. |
src/HeadlineWriter.ts |
Asks the AI providers for a headline or banter and a button label, and checks the reply. |
src/SlackMessage.ts |
Builds the Block Kit message, including the fixed phrasings. |
src/SlackClient.ts |
Slack's Web API: posting, reacting, reading threads and names, with retries and error classification. |
src/SlackNotifier.ts |
Posts the announcements, reminders and banter, and records each post. |
src/SlackSocket.ts |
The Socket Mode connection: acks every event at once, drops redeliveries and reconnects. |
src/SlackEvent.ts |
Decodes Slack events and decides which ones the bot considers. |
src/Responder.ts |
Waits for messages per thread, handles them one at a time, applies the limits, and replies, reacts or ignores. |
src/ReplyDecider.ts |
Asks Clef whether to reply, react or ignore, and applies the confidence threshold. |
src/CloudflareDecisionModel.ts |
An Effect DecisionModel for Clef on Workers AI. |
src/ReplyWriter.ts |
Asks the AI providers for a reply, and checks it. |
src/AiWriting.ts |
The model fallback, timeouts and daily-limit handling shared by both writers. |
src/SlackHistory.ts |
Reads the responder settings and keeps the Slack posts, messages, decisions and reactions. |
src/VoiceObservation.ts |
Decides which voice-state updates are worth recording. |
src/VoiceEffect.ts |
Soundboard sounds and emoji reactions, and which ones are worth recording. |
src/BotPresence.ts |
Whether the bot should be in the office, from who's there at startup and the office opening and emptying. |
src/ActivityRecorder.ts |
Buffers observations and writes them in order, retrying outages and setting aside rows the database rejects. |
src/ActivityLog.ts |
The repository: the SQL that writes sessions, members, snapshots and effects, and tells outages from bad rows. |
src/Database.ts |
Connects to Postgres and runs the migrations in the background, retrying until it works. |
src/migrations.ts |
The tables and the voice_activity and office_activity views, bundled into the app. |
src/SupabaseCa.ts |
Supabase's root certificate, which Node doesn't trust by default. |
src/Config.ts |
Environment variables and the default model lists. |
src/Observability.ts |
Sends errors, traces and logs to Sentry when it's configured. |
You'll need Node 26 (see .nvmrc), pnpm, a Discord bot and a Slack app.
git clone https://github.com/push-based/virtual-office-notifier.git
cd virtual-office-notifier
pnpm install
cp .env.example .env # then fill in the values
pnpm devOnce it logs Watching the virtual office, join the office channel and the post should show up in Slack.
Warning
Use a separate Discord bot and Slack app for local runs while production is running. Two copies watching the same channel post every announcement twice.
All configuration comes from environment variables. Copy .env.example to .env for local runs. .env is git-ignored and must never be committed.
| Variable | Required | Description |
|---|---|---|
DISCORD_BOT_TOKEN |
Yes | Bot token from the Discord Developer Portal. Secret. |
DISCORD_OFFICE_CHANNEL_ID |
Yes | ID of the office voice channel (Developer Mode → right-click the channel → Copy Channel ID). |
SLACK_BOT_TOKEN |
Yes | The Slack app's bot token (xoxb-…). Secret: anyone with it can post as the bot. |
SLACK_APP_TOKEN |
Yes | The Slack app's app-level token (xapp-…) with connections:write, for Socket Mode. Secret. |
SLACK_CHANNEL_ID |
Yes | ID of the Slack channel announcements, reminders and banter go to, and the only one the bot answers in. |
OPENROUTER_API_KEY |
No | OpenRouter API key for AI headlines. Secret. |
OPENROUTER_MODELS |
No | Comma-separated OpenRouter models to try, preferred first. Defaults to DEFAULT_OPENROUTER_MODELS in src/Config.ts. |
OPENROUTER_BANTER_MODELS |
No | Comma-separated OpenRouter models to try for banter, preferred first. Defaults to DEFAULT_OPENROUTER_BANTER_MODELS in src/Config.ts. |
CLOUDFLARE_ACCOUNT_ID |
No | Cloudflare account ID, for AI headlines from Workers AI. Needs CLOUDFLARE_API_TOKEN too. |
CLOUDFLARE_API_TOKEN |
No | Workers AI API token. Secret. |
CLOUDFLARE_MODELS |
No | Comma-separated Workers AI models to try, preferred first. Defaults to DEFAULT_CLOUDFLARE_MODELS in src/Config.ts, all within the free daily allocation. |
DATABASE_URL |
Deploys only | Supabase session pooler connection string, for the database. Secret. Optional locally, where leaving it out records nothing; the deploy fails without it. |
SENTRY_DSN |
No | Sentry project DSN. Without it, nothing is sent to Sentry. |
SENTRY_ENVIRONMENT |
No | Environment to tag Sentry data with. Defaults to development; the deploy sets production. |
SENTRY_RELEASE |
No | Release to tag Sentry data with. The deploy sets it to the commit SHA. |
The process exits at startup with an error naming the variable if a required one is missing. A blank optional key, account ID or database URL counts as unset.
OPENROUTER_MODELS, OPENROUTER_BANTER_MODELS and CLOUDFLARE_MODELS aren't passed through the deploy, so production always uses the defaults. Change those in src/Config.ts.
- Create an application at https://discord.com/developers/applications and add a bot. Revealing the token requires the account password.
- Under Bot, turn off Public Bot. No privileged intents are needed; the bot only uses
Guilds,GuildVoiceStatesandGuildExpressions(to keep the server's soundboard sound names up to date). - Under OAuth2 → URL Generator, pick the
botscope with the View Channels and Connect permissions, open the URL and add the bot to the server. Without Connect it can't join the office, so soundboard sounds and emoji reactions aren't recorded, and it logs a warning at startup saying so.
- Create an app at https://api.slack.com/apps → From scratch → pick the workspace, or open the app that had the old webhook.
- Settings → Socket Mode → turn it on, and create an app-level token with the
connections:writescope. That'sSLACK_APP_TOKEN. - Features → OAuth & Permissions → Bot Token Scopes: add
chat:write,reactions:write,reactions:read,app_mentions:read,channels:historyandusers:read. If the channel is private, addgroups:historytoo. - Features → Event Subscriptions → turn it on and subscribe to the bot events
app_mention,message.channels,reaction_addedandreaction_removed, plusmessage.groupsfor a private channel. With Socket Mode on, there's no request URL to fill in. - Leave Interactivity off. The join buttons only open a link.
- Install to Workspace, and copy the Bot User OAuth Token into
SLACK_BOT_TOKEN. - Invite the bot to the channel (
/invite @your-bot), and copy the channel ID (channel details → About, at the bottom) intoSLACK_CHANNEL_ID.
Optional. With neither provider configured, every post uses the fixed phrasings.
Every post (opened, emptied and the reminder) gets a fresh headline and join button label from a free AI model, and the card credits the model that wrote them.
What the model sees and returns
- The model never sees anyone's name. For an opened office it writes a
{name}placeholder that's filled in afterwards, and the other posts don't name anyone. - The reply must be exactly two lines: a headline of up to 160 characters and a button label of up to 30. The headline has the placeholder exactly once for an opened office and not at all otherwise, and neither line may contain Slack link syntax. The reply is escaped before it's posted.
- Nothing checks that what the model says is true, and it does get things wrong, so every bit of banter says it's AI-written and may be made up.
Which model writes it
- Each post picks OpenRouter or Workers AI at random to go first and falls back to the other, using whichever are configured.
- Within a provider, models are tried one at a time in a random order. The first in its list goes first at least half the time (
openrouter/freeby default, which lets OpenRouter choose any free model that's up). - A model that fails, takes longer than a minute, or replies with something unusable is skipped for the next one, and each skip is logged with the model and the reason.
- If OpenRouter says the key's daily free limit is used up (50 requests a day without credits, 1,000 with at least $10 of credit), the rest of OpenRouter's models are skipped, since they'd all be refused too, and Workers AI is tried if it hasn't been already.
- If every model fails, the post goes out with the fixed headline and label.
Create a key at https://openrouter.ai/settings/keys and put it in OPENROUTER_API_KEY.
Free models come and go on OpenRouter. To change the list, pick from the free models and set OPENROUTER_MODELS, preferred model first.
- Sign up at https://dash.cloudflare.com/sign-up/workers-and-pages. New accounts are on the Workers Free plan.
- Go to Workers AI → Use REST API → Create a Workers AI API Token, and copy the token into
CLOUDFLARE_API_TOKEN. - Copy the Account ID from the same page into
CLOUDFLARE_ACCOUNT_ID.
Workers AI includes 10,000 Neurons a day for free, which covers a few hundred headlines on the default models. Clef and Slack replies come out of the same allowance, so replies try OpenRouter first. When Cloudflare says the day's allowance is used up, the rest of its models are skipped, like OpenRouter's daily limit.
Needs the database and at least one AI provider. Without the database, every chance is skipped with a Skipped banter warning.
When it posts
- After a random wait of 60 to 150 minutes, it checks whether it's 09:00 to 18:00 UTC+2 on a weekday, and whether fewer than 5 have gone out since midnight UTC+2. If so, it posts; either way, it waits again.
- Each one is stored in
banter_postsbefore the AI writes it, so the daily limit holds across restarts, and one the AI couldn't write still counts. That keeps it to at most 5 attempts' worth of AI requests a day. - If every model fails, nothing is posted. There's no fixed fallback.
What the model sees and returns
- It picks one stretch of time at random: today so far, yesterday and today, this week so far (from Monday), or last week.
- The model gets the time, who's in the office right now, and every entry of the
office_activityview in that stretch, as is, with times in UTC+2:Wed 30 Sep 15:00 Barbara CameraOnorWed 30 Sep 15:03 Alan PlayedSound airhorn. A busy stretch is cut to its latest 400 entries, and the model is told how many were left out. - People are named with their
real_namewhen it's filled in, or their Discord display name. Who's in right now comes from what this run of the bot recorded. - The prompt asks for something playful and warm, never mean, that only says what the log shows. The reply must be exactly two lines: a message of up to 300 characters and a button label of up to 30, with no Slack link syntax. The reply is escaped before it's posted.
- Nothing checks that what the model says is true, and it does get things wrong, so every bit of banter says it's AI-written and may be made up.
Which model writes it
The same way as the headlines, except OpenRouter uses OPENROUTER_BANTER_MODELS. Its defaults leave out the openrouter/free router, because it often picks a small model that misses the point of the log or makes things up.
Trying it on made-up data
scripts/try-banter.ts runs the scenarios in scripts/banter-scenarios.ts: a busy Friday, an empty office, a mute marathon, a lone regular, last week's early bird and a camera party. It never posts to Slack or touches the database.
node --env-file=.env scripts/try-banter.tsOn its own it only prints the prompts. With --call-ai it asks one OpenRouter model once per scenario, with no fallback, so a run is at most 6 requests. BANTER_MODEL picks the model (the first of the banter defaults otherwise) and BANTER_SCENARIO runs just one scenario by name.
node --env-file=.env scripts/try-banter.ts --call-aiNeeds the database, and stays off until it's switched on there. Mentions also need at least one AI provider, and replies under the bot's posts need Cloudflare for Clef. The design is in issue #18.
What it considers
- Only messages in the
SLACK_CHANNEL_IDchannel. - Mentions of the bot. These always get a reply, within the limits below, in the thread they're in, or in a new thread under a mention that isn't in one.
- Replies in threads under the bot's own posts: announcements, reminders, banter and its own replies. Clef, a decision model on Workers AI, decides whether to reply, react with an emoji or ignore it.
- Nothing else: other channels, DMs, top-level messages that don't mention it, other bots and itself, edits, deletions and joins. A mention inside a thread under its post is handled once, as a mention.
How it decides
- Clef gets the bot's post, the last few messages in the thread and the new one, each cut to 1,000 characters. It answers with a choice and a confidence for the action (
reply,reactorignore) and for the emoji. The bot acts only when the action's confidence is at leastmin_confidence. Every request is stored with Clef's answer, so the threshold can be tuned on real data later. - The reply is written by the banter models, OpenRouter first, in the same warm and gently teasing voice. The model gets the thread (up to the last
writer_thread_messagesmessages), who's in the office right now and this week'soffice_activity, at most 400 entries. The reply must be one message of up to 600 characters with no Slack link or mention syntax. It's escaped and posted in the thread with a line crediting the model and saying it's AI. - Slack text is untrusted. The model has no tools, and what it writes is checked and escaped, so a message that tries to give it instructions can only change what it says, not what it does.
- People in the thread are shown by their Slack name, or by their office name when they're linked:
office.members.slack_user_idfirst, then a case-insensitive match of their Slack real name againstreal_name. The model is told not to assume anyone else in the thread is anyone in the log.
Limits
- Only 09:00 to 18:00 UTC+2 on weekdays, mentions included.
- At most
max_replies_per_dayreplies andmax_reactions_per_dayreactions per office day, andmax_replies_per_threadreplies in any one thread. Mentions count. When it couldn't act anyway, Clef isn't asked. A reply that's over a limit is never turned into a reaction. - Each thread keeps only its latest waiting message: an older one is skipped as
superseded. Mentions wait separately, so chatter can't push one out. Threads take turns, one message at a time, in the order they first spoke, and a message that waited more thanstale_after_secondsis skipped asstale. - Every message it considers is recorded with what it did and why:
replied,reactedorignored, because ofclef,mention,low_confidence,daily_cap,thread_cap,outside_hours,stale,superseded,clef_failedorno_model. - If Clef fails or every writing model fails, it stays quiet. Both are logged and reported as warnings.
Settings
Everything is in the single row of office.responder_settings, read for every message, so a change in Supabase applies to the next one. The database refuses values that can't work.
| Column | Default | What it does |
|---|---|---|
enabled |
false |
Switches answering on. While it's off, nothing is decided, posted or recorded, reactions included. |
clef_model |
@cf/cloudflare/clef |
The Clef model. @cf/cloudflare/clef-flash is faster but weaker on messages that fit none of the choices. |
min_confidence |
0.7 |
How sure Clef must be, from 0 to 1. |
max_replies_per_day |
20 |
Replies per office day. |
max_reactions_per_day |
30 |
Reactions per office day. |
max_replies_per_thread |
3 |
Replies in any one thread. |
stale_after_seconds |
120 |
How long a message can wait for its turn. |
clef_thread_messages |
10 |
Earlier thread messages Clef sees. |
writer_thread_messages |
50 |
Earlier thread messages the reply's writer sees. |
reaction_emoji |
joy, eyes, raised_hands, heart, +1, saluting_face |
The emoji Clef picks from, as Slack names them. At least two. |
UPDATE office.responder_settings SET enabled = true, updated_at = now();The bot records voice activity in the office channel to a Postgres database on Supabase. It's collected now, to decide later what to use it for. The design is in issue #10.
- Create a project at https://supabase.com/dashboard.
- Click Connect, pick the Session pooler connection string, and put it in
DATABASE_URLwith the database password filled in. Percent-encode any special characters in the password.
Use the session pooler, not the direct connection: the direct one is IPv6-only unless the project has the IPv4 add-on, and GCP VMs only have IPv4 by default. The transaction pooler (port 6543) doesn't support the prepared statements the client uses.
Locally, leave DATABASE_URL out unless you mean to record. pnpm dev with the production URL writes into the production data.
Everything lives in an office schema, out of the public schema that Supabase serves through its REST API.
| Table | What's in it |
|---|---|
voice_snapshots |
One row per Discord voice-state update touching the office: joining, leaving, moving in or out, and every change inside. It has the old and new value of every field Discord sends (channel, self and server mute and deafen, camera, streaming, suppressed, request to speak and voice session), as Discord sent them. Bots are left out. source is update, or startup for someone already in the office when the bot started. |
voice_effects |
One row per soundboard sound or emoji reaction someone sent in the office, as Discord sent it: the sound's ID, volume and name (looked up from the server's sounds and Discord's default ones, empty when it couldn't be found), the emoji, and the animation. Bots are left out. |
members |
One row per Discord user seen, with their latest display name and when they were first and last seen. real_name and slack_user_id are yours to fill in by hand; the bot never writes them. They link people in Slack threads to the office. |
bot_sessions |
One row per run of the bot, with when it started and stopped. stopped_at stays empty when it didn't stop cleanly, so gaps in the data can be told apart from an empty office. |
rejected_updates |
Anything the database refused to store, as JSON with the error, so it can be fixed and replayed by hand. |
banter_posts |
One row per bit of banter, with when and which stretch of time it covered, so the daily limit holds across restarts. |
slack_posts |
Every message the bot posts to Slack: channel, ts, thread, kind (Opened, Emptied, Reminder, Banter or Reply) and the model that wrote it. |
slack_messages |
Every Slack message the bot considered: who wrote it, their linked office member if any, the text, whether it was a mention and when it arrived. |
slack_decisions |
One per considered message: Clef's model, the exact request, its choices with their probabilities and confidence, how long it took, the settings in effect, and what the bot did and why. |
slack_reactions |
Every reaction added to or removed from the bot's posts, as Slack sent it, for judging how the posts land. |
responder_settings |
The settings for answering in Slack. |
The voice_activity view turns the snapshots into readable events with names: AlreadyThere, Joined, Left, Muted/Unmuted, Deafened/Undeafened, ServerMuted/ServerUnmuted, ServerDeafened/ServerUndeafened, CameraOn/CameraOff and StreamStarted/StreamStopped. Anything already on when someone arrives counts as switched on, so joining muted is Joined and Muted. Deafening in Discord also mutes, so it shows as Deafened and Muted.
The office_activity view is voice_activity plus the effects: PlayedSound with the sound's name in detail, Reacted with the emoji, or SentEffect for anything else. It's what banter reads.
SELECT observed_at, coalesce(real_name, display_name) AS who, event, detail
FROM office.office_activity
ORDER BY observed_at DESC
LIMIT 50;Nothing is ever deleted. Migrations live in src/migrations.ts, run once each on the first successful connection, and only ever add.
The database can never stop the announcements:
- Connecting happens in the background after startup, so the bot runs normally while it's down. Once connected, it logs
Connected to the database. - Observations are buffered in memory and written in order by a single writer, up to 50,000 at a time. When the buffer is full, new ones are refused and logged in full.
- If it can't connect, run the migrations or write, it retries in bursts of 5 attempts 1, 2, 4, 8 and 16 seconds apart, then waits an hour before the next burst. The entry being written keeps its place. Each failed attempt is logged with what went wrong.
- A row the database rejects (a data or constraint error) goes to
rejected_updatesinstead, so it can't block everything behind it. If that fails too, it's logged in full. - On shutdown it spends up to 10 seconds writing what's left, including the session's
stopped_at. Anything still unwritten is logged in full.
Anything that can't be stored is logged with an entry holding the full JSON, so it can be found in the logs and replayed by hand.
The connection is encrypted and checked against Supabase's own root certificate in src/SupabaseCa.ts, which expires in April 2031.
| Command | What it does |
|---|---|
pnpm dev |
Run from source with .env. |
pnpm typecheck |
Type-check with TypeScript 7 and the Effect diagnostics. |
pnpm lint |
Lint with oxlint. pnpm lint:fix applies the automatic fixes. |
pnpm test |
Run the tests with Vitest. |
pnpm build |
Bundle to dist/main.js with a source map. |
pnpm start |
Run the bundle with .env. |
CI runs typecheck, lint, test and build on every push and pull request.
The tests run the program against fakes for Discord, Slack and the AI models, so they need no tokens or network access. DiscordGateway.layerTest feeds voice-state updates from a queue through the same occupancy tracking the real gateway uses. The database tests run the real migrations and SQL against an in-process Postgres (PGlite), so they need no database either.
TypeScript is pinned to the exact version @effect/tsgo supports, so upgrade the two together. The prepare script patches it on install.
The lint rules in tools/oxlint/anti-slop are vendored from dmmulroy/anti-slop. UPSTREAM.md records the commit they were copied from.
Runs on a GCP Always Free e2-micro VM under systemd. There must be exactly one running copy, otherwise every announcement is posted twice.
Deploys only happen from CI: merging a pull request into main runs the checks and then deploys. There are no manual deploys.
-
Create the VM (Debian 12,
e2-micro,us-central1):gcloud compute instances create virtual-office-notifier \ --machine-type=e2-micro --zone=us-central1-a \ --image-family=debian-12 --image-project=debian-cloud
-
SSH in (
gcloud compute ssh virtual-office-notifier --zone=us-central1-a) and install Node 26 from NodeSource:curl -fsSL https://deb.nodesource.com/setup_26.x | sudo bash - sudo apt-get install -y nodejs -
Create the service user and app directory:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin notifier sudo mkdir -p /opt/virtual-office-notifier
The deploy writes the env file and installs the systemd unit, so there's nothing else to do on the VM. Set up CI below; the first deploy starts the service.
The deploy job in .github/workflows/ci.yml authenticates to GCP through Workload Identity Federation, so there's no service account key to leak, and only pushes to main on this repo can get a token.
One-time setup, from the repo root with gcloud on the right project and gh logged in to the repo owner's account:
scripts/setup-ci.shThis creates a github-deploy service account and a Workload Identity pool trusting this repo's main branch, turns on OS Login for the VM, and grants the account SSH and sudo there. It then stores these repository secrets:
| Secret | Source |
|---|---|
GCP_WORKLOAD_IDENTITY_PROVIDER |
The Workload Identity provider created above |
GCP_SERVICE_ACCOUNT |
The github-deploy service account email |
DISCORD_BOT_TOKEN, DISCORD_OFFICE_CHANNEL_ID, SLACK_BOT_TOKEN, SLACK_APP_TOKEN, SLACK_CHANNEL_ID |
Your local .env |
DATABASE_URL |
The Supabase session pooler connection string. See Database. |
OPENROUTER_API_KEY, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN, SENTRY_DSN |
Optional, your local .env |
GitHub secrets are the source of truth for the app's configuration. To change a value, update the secret and re-run the latest CI workflow on main:
gh secret set SLACK_BOT_TOKENThe deploy job runs scripts/deploy.ts, which:
- Writes the env file from the configuration variables into a temporary directory, so secrets never land in the working tree.
- Bundles the app.
- Copies
dist/main.js, its source map, the systemd unit and the env file to the VM. - Installs them under
/opt/virtual-office-notifierand/etc, reloads systemd and restarts the service. - Waits 15 seconds and fails, printing the last log lines, if the service isn't running.
The unit runs as the unprivileged notifier user with systemd's sandboxing turned on. It restarts 30 seconds after a crash and gives up after 5 failed starts in 10 minutes, so a bad token doesn't keep hammering Discord's login.
gcloud compute ssh virtual-office-notifier --zone=us-central1-a -- journalctl -u virtual-office-notifier -fWith SENTRY_DSN set, the app sends errors, traces and logs to Sentry through @sentry/effect. Everything still goes to the journal as well.
- Issues. A crash, including at startup, is an error. A Slack call that only failed after retries is a warning; lost access or a rejected payload is an error. Discord client errors are errors, and every AI model failing to write a headline or reply is a warning. So are Clef failing and the Slack Socket Mode connection dropping. For the database, every retry while it's unreachable and every update it rejects is a warning, and an update that could only be logged, or a database client that couldn't be created, is an error. These never include the update itself.
- Traces. Each announcement is its own trace: the Slack post with each attempt, and the AI model calls. HTTP client spans are turned off, so request details never leave the VM.
- Logs. Every log line, linked to the trace it was logged in. Only the message is sent, not the annotations.
To turn it on, create a Node.js project in Sentry and set its DSN as a secret, then re-run the latest CI workflow on main:
gh secret set SENTRY_DSN| Symptom | Cause |
|---|---|
| Exits at startup naming a variable | It's missing or empty. Check .env locally, or the GitHub secret in production. |
Office voice channel not found in the logs |
DISCORD_OFFICE_CHANNEL_ID is wrong, or the bot isn't in the server or can't view the channel. It keeps running in case the bot is added later, but reminders stay off until it restarts. |
| Every announcement shows up twice | Two copies are running, for example pnpm dev with the production tokens. |
Slack rejected the post with AccessRevoked |
The token was revoked, the app uninstalled, or the bot isn't in the channel. Reinstall the app or invite the bot, and update the secrets. |
| The bot never answers in Slack | Check office.responder_settings.enabled, then office.slack_decisions for why. No rows at all means the events aren't arriving: look for Lost the Slack connection, reconnecting warnings and check the app's Socket Mode, events and scopes. |
| No banter shows up | Look for Skipped banter warnings: the reason says why the database couldn't be read. Without the database there's no banter at all. If every AI model failed, the log says Nothing to announce, no AI model wrote the banter. |
| Posts always use the fixed headlines | Every AI model is failing. Look for AI model couldn't write it warnings and their reason. DailyLimit means the OpenRouter key has used up its free requests for the day. |
Couldn't set up the database, retrying in the logs |
The bot can't reach the database or run the migrations. The reason says why, for example a wrong password in DATABASE_URL or a paused Supabase project. Announcements carry on, and observations are buffered until it connects. |
Couldn't record to the database, retrying in the logs |
The database went away after connecting, or a write took over 10 seconds. Writing resumes where it left off once it's back. |
The database rejected an update in the logs |
Postgres refused a row. It's in office.rejected_updates with the error. |
logging this instead or logging it instead in the logs |
Something couldn't be stored at all: the buffer was full, the bot was shutting down, or setting it aside failed too. The entry annotation has the full JSON. |
| The service has stopped and isn't restarting | systemd gave up after 5 failed starts in 10 minutes. Check the logs, then merge a fix, or update the secret and re-run the latest CI workflow on main. |
This broadcasts people's presence to a wider audience. The opening post shows who opened the office, with their Discord display name and avatar, and the empty post shows how long it was open and how many people came by. Tell the team before turning it on.
For headlines, the AI providers only get the instructions and a few example headlines, never names or anything else from Discord.
Banter is different: the model gets the recorded office activity for up to a week, with everyone's name, and can name people in what it writes. The defaults are free models, and providers of free models may keep and train on what they're sent. Tell the team before turning it on.
The database keeps a detailed record of each person's activity in the office channel: when they joined and left, when they muted, deafened, turned on their camera or streamed, and every soundboard sound and emoji reaction they sent, with their Discord user ID and display name, and a real name if one is filled in. Nothing is ever deleted. Tell the team it's being recorded before turning it on.
When answering in Slack is on, the bot reads mentions of it and replies under its posts in the office Slack channel. Those messages, the thread around them, people's Slack and office names and this week's office activity go to Clef on Cloudflare and to free AI models, which may keep and train on them. Every message it considers is stored with what it did, and every reaction to its posts is stored, with Slack user IDs. Nothing is ever deleted. Tell the team before switching it on.
While anyone is in the office, the bot shows up in the channel too. It's muted and deafened and never opens an audio connection, so it doesn't hear or record anything anyone says.
Sentry, when it's configured, gets error reports, timings, database queries without their values, and log messages without their annotations, so no names or Discord user IDs. User IDs are also redacted in the announcement logs; an update the database couldn't store is logged in full, but only to the journal.
Issues and pull requests are welcome. See CONTRIBUTING.md for how to set up, what the checks are and what a pull request needs. To report a security problem, follow SECURITY.md instead of opening an issue.
0BSD. Use it for anything, with no conditions. It comes with no warranty, and I'm not liable for anything that comes of using it.