Skip to main content
Blog

How we fill empty seats while you sleep

Atomcal

A tour of Atomcal's Auto-Invite V2 engine — explained like you're five, then like you're a backend engineer


Imagine you're throwing a birthday party.

You want 20 friends to come. You don't want to text everyone at once — that's spammy and weird. You also don't want to text people one by one over a week — you'd never finish. So what do you do?

You do what every good host does. You text a handful of people first. You see who says yes. Then you text a few more. If loads of people said yes, you slow down (you don't need many more!). If nobody replied, you invite a bigger group. If someone ignores you, you gently nudge them a day later. If they still ignore you, you send one last message on a different app.

That's exactly what our auto-invite system does, except it invites hundreds of people to events instead of cake.

Let's walk through how it works — first in crayons, then in code.


Part 1 — The crayon version

There are three loops running inside the system, one inside the other, like Russian dolls:

┌──────────────────────────────────────────────┐│  🎉 The Party (one per event)                 ││  ┌────────────────────────────────────────┐   ││  │  📬 The Batches (5–10 per party)        │   ││  │  ┌──────────────────────────────────┐   │   ││  │  │  🧑 Each guest (1–20 per batch)   │   │   ││  │  └──────────────────────────────────┘   │   ││  └────────────────────────────────────────┘   │└──────────────────────────────────────────────┘

The Party loop decides if we should even be inviting people right now. The Batch loop decides how many to invite next, and when. The Guest loop handles one specific person — which app to message them on, and what to do if they ignore us.

Everything else is detail.


Part 2 — Meet the four characters

Every 60 seconds, a little cron job wakes up and asks four questions:

🎪 The Campaign ("Is the party still happening?")

A single database row per event. It knows:

  • how many seats you still need,
  • whether it's in running, paused, completed, or cancelled,
  • when the next batch is due.

If your event is full, or you've already run it for 14 days, or you cancelled — it stops. No more invites, ever. It's safety-first.

📬 The Batch ("Who's going out in this round?")

Not a spam blast. A small, paced group with a specific purpose.

  • Batch 1 is always exactly 20% of your target, with a minimum of 10 people. So if you want 50 guests, batch 1 has 10. If you want 500, batch 1 has 100.
  • Within a batch, invites go out one every 5 seconds. Twenty invites take 100 seconds to send. You can literally watch the counter go up.
  • After each batch closes, the system peeks at how many people said yes and decides what to do next. This is where the magic happens →

🧠 The Adaptive Brain ("How am I doing?")

After a batch finishes, the engine looks at that batch's acceptance rate:

What happened in the last batchWhat we do next
≥ 40% said yes (🔥 people love this)
Shrink the next batch to half. Wait 5 minutes.
< 15% said yes (🥶 nobody wants to come)
Grow the next batch by 50%. Wait only 60 seconds.
Somewhere in between
Keep steady. Wait 2 minutes.

This is the difference between a robot that spams and a system that feels smart. High response → relax. Silence → try harder.

📞 The Follow-Up ("Did they ignore me? I'll try again.")

Here's where we don't give up on you, but we also don't stalk you. Each person gets at most three touches:

WhenChannel order (first win stops the chain)
Now (initial)
1️⃣ Discord DM → 2️⃣ Email
+24 hours (if no response)
1️⃣ Discord DM → 2️⃣ Email
+48 hours (last chance)
1️⃣ Email (now primary) → 2️⃣ Discord

Why does the last follow-up lead with email? Because people who didn't open Discord twice probably aren't on Discord that week. Email is the grown-up, reliable channel for the final "hey, seriously, last chance" nudge.

Important: We only send email if it's necessary. If your Discord DM landed, the email never gets sent. If Discord is the only enabled channel and it failed, email still doesn't go out (because it's disabled). Each stage is one successful delivery — not one-of-each.


Part 3 — Let's follow a real invite

Say you're running a tournament on Saturday and the system is auto-filling it. Let's ride along with one person — we'll call her Ada.

Monday 14:00 — You set guest_target = 20 and click Start.

  • A InviteCampaign row flips from draft to running.
  • next_batch_at is set to now.
  • A minute-tick job picks it up.

Monday 14:00:12 — Batch 1 fires. The engine asks the suggestions pipeline for ranked candidates (scored by attendance history + availability + social overlap — see a previous post about that), picks 10 of them, and opens InviteBatch #1.

Monday 14:00:17 — Ada's turn. The dispatcher does four things in order:

// 1. Is she a Discord bot pretending to be human? Drop her if yes.if (botFilter.isBotCandidate(ada)) return skip();// 2. Make sure she exists on Scrims as a calendar member and as a//    guest with status=INVITED. (This is new — V1 dropped this step//    and our hosts were seeing empty guest lists. Fixed.)await ensureScrimsGuest(campaign, ada, event._id);// 3. Try Discord DM first, email as fallback.const result = await deliverViaChannels(stage=0, { discord, email }, ada);// 4. Write the outcome into `Invites.delivery_log`.await Invites.findOneAndUpdate(...)

Her Discord DM lands. The email branch never runs. followup_stage = 0, next_followup_at = Tuesday 14:00:17.

Monday 14:03 — Batch 1 finishes. 10 invites sent. 4 people already accepted (Ada hasn't responded yet). Acceptance rate: 40%. The adaptive brain says "🔥 slow down". Batch 2 is scheduled for Monday 14:08 with 5 people instead of 10.

Tuesday 14:00 — Tick fires. Ada still hasn't responded. Her invite has next_followup_at <= now, so the engine calls deliverFollowup.

  • Stage 1 (Discord again) → delivered.
  • Her row now reads followup_stage = 1, follow_up_count = 1, next_followup_at = Wednesday 14:00.

Wednesday 14:00 — Still nothing from Ada. Stage 2 fires — but this time email is the primary. Email delivered.

  • followup_stage = DONE. No more follow-ups. We've tried our three touches and we respect the silence.

Wednesday 14:11 — Ada clicks the email. She's in.

  • Invites.acknowledgment = "accepted".
  • scrims.guests.update flips her status from INVITED to CONFIRMED.
  • Campaign's seats_filled++ and seats_accepted++.

If the campaign now hits seats_filled >= guest_target, the next tick will call shouldScheduleNextBatch(), get "target-reached" back, and flip the campaign to completed. Job done.

Ada doesn't know any of this happened. From her side, she just got an invite, ignored it twice, then finally clicked it on Wednesday. The engine quietly did all the choreography in the background.


Part 4 — The clever bits we learned the hard way

1. One campaign per event, settings frozen at creation

Naive version: "put default settings on the calendar; every event reads from there." Problem: change the calendar default, all your past events silently start behaving differently. Fix: when an event is first seen, we snapshot the connection defaults onto a fresh InviteCampaign document. Future changes to the calendar don't retroactively affect in-flight campaigns. One source of truth, per event, forever.

2. Adaptive beats greedy

The first cut of V2 just blasted batches of fixed size every 2 minutes. Hosts complained it felt "spammy". The adaptive bands (slow on high acceptance, fast on silence) made the whole thing feel intentional — and cut total invites sent by ~30% for events that fill quickly.

3. The bot problem

Discord guild syncs used to seed every member — including bots — into Scrims as temp members. Those bots leaked into auto-invite candidate pools and got DMs they couldn't read. We now cross-reference the DmMember.bot flag at three layers (suggestion pipeline, candidate fetch, and dispatcher) and exclude them with a clean is_bot reason the UI can render. Belt, braces, and a third strap.

4. Bootstrap the Scrims guest before sending

V1 did it. V2 initially didn't. Hosts saw invites going out with empty guest lists on Scrims. We now call scrims.guests.create(member_id, event_id, { status: INVITED }) as the first step of dispatch — so even if Discord and email both fail, the host sees the intent on their event page immediately.

5. Log everything; render the log verbatim

Every delivery attempt (success, failure, skip) gets pushed into Invites.delivery_log[] with { stage, channel, status, at, reason }. The UI doesn't infer "we tried Discord first" from timestamps — it just reads it straight out of the log. This made debugging a delight and support tickets faster to resolve.


Part 5 — The rhythm, visualised

Here's what one event's life looks like on the wire:

t=0        ┃ 📤 Batch 1 (10 invites, 5s apart) ──── 50st=3m       ┃ 📤 Batch 2 (5 invites — acceptance was hot)t=8m       ┃ 📤 Batch 3 (adaptive again)t=D1       ┃ 🔁 Follow-ups fire for non-responders from batch 1t=D2       ┃ 📧 Final email nudge for still-silent folkst=filled   ┃ ✅ Campaign → completed. No more work.

And the UI streams this to the organiser live: a progress bar filling up, a countdown ticking down to Next batch in 2 min 14 sec, and a batch history that shows Batch 2 · 5 sent · 60% accepted · 14:08.


One-line takeaway

Auto-invite V2 is three nested loops — campaign, batch, per-person follow-up — each with its own clock, its own fallback channel, and its own stop condition. The outer loop knows when to quit. The middle loop knows how fast to go. The inner loop knows how not to be annoying.

The robot sends a few invites, looks at how people reply, and decides if it should chill, speed up, or nudge once more. Then it repeats until your party is full.

That's it. That's the post.


Engineering notes: the engine lives in consistent-backend/v2/€-09-auto-invite/campaigns/. The pure scheduler math (no I/O) is in scheduler.js — unit-test your way to any future tweaks there. The dispatcher (dispatcher.js) is the only file in V2 that writes to Invites, by design, so the shape stays stable for downstream readers. If you change the follow-up timing or channel order, start at constants.js — everything else reads from there.