A matcher, not a worksheet

A workout accountability partner, rewritten as a matcher, not a shortlist

Almost every page that currently answers this question hands the reader a worksheet. Pick someone with aligned goals. Schedule a weekly check-in. Send a thumbs-up text when you hit the gym. None of that is a matcher. This page is the other half of the picture, the half nobody writes up: the code of an actual pairing service, and the live pool it runs against today. The product it describes pairs people for a daily meditation sit rather than a barbell session, but the five matching primitives are the same ones a well-built gym buddy matcher would need.

M
Matthew Diakonov
10 min read
4.9from direct meditator feedback
Every number below was read live from waitlist_entries on 2026-04-23
Matcher code quoted verbatim from src/app/api/auto-match/route.ts
Honest redirect: if you want a lifting partner, this is a reference, not the product

What is missing from every other page on this topic

The usual answer for this question is a five-step list. Identify your goal. List the qualities you want. Scan your gym, your office, your friends. Reach out. Set a weekly check-in. The top-ranking pages are almost uniform on this, from the Peloton blog to Cohorty to Bossa to Goals and Duke Rec. They are well written and they are addressed to a reader who will assemble a partnership on their own, by hand.

None of those pages shows a matcher. None of them describes how two strangers would actually be paired by software. None of them exposes a single line of code, a single sort key, a single filter, or a single number from an actual pool. This page does. The product it runs inside of pairs people for meditation sits, not gym sessions, but the five primitives below are exactly the ones a workout buddy matcher would need, and none of them exist in the shortlist advice that dominates this topic.

The pool today, in six cards

Every card below is the result of a live query against the production waitlist_entries table, executed on the day this page was published. The pool shape is real; it is not a pitch.

52 active rows

status in ('pending', 'ready'), morning_utc IS NOT NULL. Pulled from waitlist_entries on 2026-04-23. Every other number on this page derives from this one query.

47 distinct cities

City is a free-text field on the signup form. 47 unique strings for 52 rows means the median city on this site has about 1.1 people.

19 declared timezones

Mixed IANA identifiers and hand-typed GMT strings. Every value is normalised to a UTC minute before the matcher compares.

49 of 52 are course veterans

is_old_student = 'Yes' means the row answered that they completed a 10-day residential course in the S.N. Goenka tradition. The sort keys reward both-old pairings, which makes this the pool's dominant demographic.

80 slot candidates

52 rows contribute 52 morning slots. 28 of them declared 'Twice a day', adding 28 evening slots. The matcher operates on slots, not on people directly.

23 viable pairs today

Non-overlapping, prior-match filtered, scored, and greedily picked by scripts/best-pending-pairs.mjs. The ceiling for one tick of this pool is 23 simultaneous partnerships.

Inputs, matcher, outputs

Five kinds of rows flow into the matcher. The matcher itself is stateless between ticks; it reads the pool, enumerates pairs, and writes four kinds of downstream artifacts. The reader's row is one arrow on the left. The matcher never shows the reader a candidate profile; there is no profile layer.

inputs -> matcher -> outputs

Fresh signups
Ready-status rows
Retry-eligible
Twice-a-day rows
UTC + duration fields
the matcher
23 viable pairs
Confirmation emails
Shared Meet URL
Activity-log entry

The eight things the cron does, in order

Every */30 tick runs these eight steps against the current pool. They are ordered; each depends on the previous one being finished. Nothing here is a machine-learned score or a weighted coefficient.

1

Load the eligible pool

A single Postgres query returns every waitlist_entries row in status 'ready' or 'pending' with a populated morning_utc. Rows outside that status set are invisible to the matcher.

2

Build one or two slots per row

Each row contributes a morning slot. Rows whose frequency is 'Twice a day' and whose evening_utc resolves contribute a second, independent slot. 52 rows produced 80 slots in today's pool.

3

Enumerate unordered pairs

Nested for-loops in src/app/api/auto-match/route.ts produce every unique slot pair. 80 slots means 3,160 raw pair candidates in memory before filtering.

4

Hard-filter on the 60-minute UTC window

timeDiff(slotA.utcMinutes, slotB.utcMinutes) above 60 is dropped. timeDiff = Math.min(|a-b|, 1440-|a-b|) so the clock wraps correctly across midnight UTC.

5

Apply the prior-pair guard

Any pair whose two ids already appear in the matches table (in any status other than cold-bilateral-decline) is silently removed. Nobody is matched with the same partner twice by the scheduler.

6

Sort lexicographically on four keys

readyScore (sum 0 / 1 / 2) → bothOld (true / false) → sessionMatch (true / false) → smallest UTC diff. No weighted average. No model. Purely ordered predicates.

7

Greedy pick of non-overlapping pairs

Walk the sorted list. Write a pair if neither of its two slots has been used this tick. Mark both slots used. A twice-a-day row can appear in two match rows per tick, one per slot.

8

Write match rows and send confirmation emails

Each survivor becomes a matches row with status 'confirming' and two per-person confirmation tokens. Resend fires off the two confirmation emails and the tick ends.

The filter and the sort, in real code

This block is copy-pasted out of the production repo, lightly trimmed for readability. The timeDiff function is a circular UTC-minute distance; the sort is purely lexicographic across four predicates; the greedy pick never reuses a slot.

src/app/api/auto-match/route.ts

The ten pairs the matcher could write at the next tick

Actual output from node scripts/best-pending-pairs.mjs on 2026-04-23 against production. Ten rows below, twenty-three total, all non-overlapping. Each line is a pair that the cron would be allowed to promote to a match row the next time it fires, provided neither side changes state first.

node scripts/best-pending-pairs.mjs

The cities currently in the pool

A sample of 33 of the 47 distinct city strings in waitlist_entries, as of 2026-04-23. Readers in cities not represented here still fit the pool if their UTC minute falls within sixty of someone in it; the city field is demographic colour, not a matching key.

Chicago

America/Chicago

Minneapolis

America/Chicago

Berkeley

America/Los_Angeles

Missoula

America/Denver

New York

America/New_York

Tampa

America/New_York

Bangalore

Asia/Calcutta

New Delhi

GMT+5:30

Ahmedabad

GMT+5:30

Mumbai

GMT+5:30

Dehradun

GMT+5:30

Vadodara

GMT+5:30

London

Europe/London

Prague

Europe/Prague

Amsterdam

GMT+1

Berlin

GMT+1

Lisbon

GMT+1

Pau

GMT+1

Utrecht

GMT+1

Warsaw

GMT+2

Zurich

GMT+2

Bologna

GMT+2

Copenhagen

GMT+1

Dakar

GMT+0

Montreal

GMT-4

Toronto

GMT-4

Columbus

GMT-4

Houston

GMT-5

Phoenix

GMT-7

San Diego

GMT-7

Scottsdale

GMT-7

Livermore

GMT-7

São Paulo

GMT-3

The matcher in four integers

If the whole argument collapses to numbers, these are the ones that survive on the day this page was written. They describe what the matcher is actually doing in production.

0active rows in the pool (pending + ready)
0max UTC minute distance for a viable pair
0session slots built from the 52 rows
0non-overlapping viable pairs right now

Every number was computed by running scripts/best-pending-pairs.mjs against the production database on the date in the headline.

Every parameter of the matcher, as chips

Each token below can be grepped in the repo or re-derived by running a query. None of them is a slogan.

rows: 52 active (pending + ready)cities: 47 distinct stringstimezones: 19 declared valuesold-student share: 49 / 52session durations: 15, 20, 30, 45, 60 minslots: 80 (morning + twice-a-day evening)viable pairs: 23 non-overlapping todayhard filter: timeDiff <= 60 UTC minsort: readyScore > bothOld > sessionMatch > diffprior-pair guard: oncron: */30 * * * *cool-off: 24h on fresh signups

Shortlist-framed vs. matcher-framed

FeatureCommon workout-buddy guides (shortlist-framed)vipassana.cool (matcher-framed)
What 'finding a partner' denotesA reader-side checklist: values, goals, schedule, trust, chemistry.A present-tense pool property: at this tick, does a compatible row exist.
Compatibility axesSoft traits: motivation, reliability, workout style, 'why' alignment.Four hard predicates: timeDiff <= 60, session_duration parity, is_old_student parity, prior-pair guard.
How the partner is selectedReader reads profiles, messages candidates, interviews, commits.Cron enumerates all pairs, sorts by four keys, greedily picks non-overlapping pairs, writes one row.
Check-in mechanismScheduled weekly call, app button, streak counter, push reminder.Reply to the intro email; the ImprovMX webhook advances match status automatically.
End-of-partnership signalExplicit 'archive' or 'end' button; awkward conversation; radio silence.Decline the Google Calendar invite; the daily cron flips the match to 'ended'.
Cost modelCoach retainer, paid community membership, or ads.Free; the scheduler runs on the operator's account. Dana tradition.
Upper bound on search timeUndefined. Most guides say 'keep looking' with no stop condition.30 minutes between ticks; 24-hour cool-off for fresh rows.
49 / 52

Forty-nine of the 52 active rows in the pool on 2026-04-23 answered 'Yes' to is_old_student. Experience parity is a sort key in the matcher, so the dominant pool demographic is people who have already completed a 10-day residential course.

waitlist_entries, live query, 2026-04-23

How this transfers to a true workout partner product

This site pairs meditators, not lifters. That is the honest framing. The useful claim for a reader who really is looking for a gym or running buddy is narrower: the five primitives running inside vipassana.cool are a good template for how a workout-pairing service should decide, and almost none of the existing pages on this topic describe them. If a team were building a lifting-partner matcher tomorrow, they would want: a signup capturing timezone plus session duration plus experience flag plus cadence; a */30 cron loading the pool; a circular-UTC 60-minute hard filter; a parity sort on experience and duration; a prior-pair guard; an inbound-email state advance so the thread IS the check-in; and a permanent shared room URL per pair.

The claim is not that meditators want no app. The claim is that for any recurring-partnership product where the activity itself is not screen-based, the state machine belongs in the inbox and the matching logic belongs in a small, predicate-driven cron. A gym workout is not screen-based. A 6 AM run is not screen-based. A silent sit is not screen-based. They all share the same shape, and the matcher on this site is the closest public reference implementation the author has seen.

If the reader's actual need is a human in a Google Meet room at dawn for a daily meditation sit, the pool above is already wired for them. Every question about how to actually sit, how to work with a difficulty, or how to structure a practice belongs with an authorized assistant teacher at a 10-day residential course, and not this page. This site only handles peer-pairing logistics.

The whole argument, skimmable

  • The gap: every existing guide on this topic is reader-side advice. None of them exposes a matcher.
  • This site's answer: a small predicate-driven scheduler running against a 52-row pool.
  • Primitives: 60-min UTC window, duration parity, experience parity, prior-pair guard, 30-min cron tick, inbound-email state advance.
  • Pool capacity today: 52 rows, 80 slots, 23 non-overlapping viable pairs in a single tick.
  • Honest scope: pairs meditators, not lifters or runners. The primitives are portable; the pool is not.
  • Operational questions about the practice itself belong with an authorized assistant teacher at a 10-day course, not here.

Two numbers, spring-animated

The pool currently holds 0 active rows. From those rows, the matcher could produce 0 non-overlapping partnerships at the next tick. That is the shape of "workout accountability partner" on this site, measured on 2026-04-23.

Want a read on what the pool looks like for your timezone?

Book a short call and we will walk through what the current pool shape would do with a row at your UTC minute and session duration.

Frequently asked questions

Is this a workout accountability partner app, strictly speaking?

No. vipassana.cool pairs people for a daily meditation sit, not a run or a lift. It is on this page because the matching primitives a workout partner app would need are the same ones this product already runs in production: a 60-minute circular UTC window, session-duration parity (15, 20, 30, 45, or 60 minutes), a prior-pair guard, and a 30-minute cron tick. If the reader is sitting 30 to 60 minutes a day and wants a real human in a Meet room at 6 AM, the pool fits. If the reader is looking for a gym buddy, it does not, but the shape of the matcher is still a useful reference implementation.

What is actually in the current pool, and how recent are these numbers?

On 2026-04-23 the pool held 52 active rows (status pending or ready, morning_utc populated) across 47 distinct city strings and 19 declared timezones. The session-duration histogram was 21 at 60 minutes, 18 at 30 minutes, 5 at 20 minutes, 5 at 15 minutes, 3 at 45 minutes. 49 of 52 rows were 10-day course veterans (is_old_student = Yes). Running scripts/best-pending-pairs.mjs against that pool produced 23 non-overlapping viable pairs in a single tick. Every figure was pulled from the production waitlist_entries table on the day this page went live.

What is the 60-minute UTC window, and why 60 rather than 15 or 120?

At src/app/api/auto-match/route.ts line 163 the matcher computes timeDiff(slotA.utcMinutes, slotB.utcMinutes) and drops the pair if the result exceeds 60. timeDiff is a circular minute distance: Math.min(|a-b|, 1440-|a-b|), so 23:30 UTC and 00:10 UTC are 40 minutes apart, not 1,400. Sixty is the operator's calibration for what a shared daily session still means. Thirty minutes starves the pool in most timezones. Two hours pairs 06:00 with 08:00 sitters and calls it the same session, which it is not. Sixty is the widest distance at which two adults in different parts of the world can plausibly dial into the same Meet at the same minute.

How does the matcher decide which pair wins when several are viable?

Four sort keys, strictly in order, no weights, no learned coefficients. First, readyScore: rows in status 'ready' outrank rows in status 'pending' (2 > 1 > 0 for sum of the pair). Second, bothOld: pairs where both rows have is_old_student = Yes outrank mixed or neither. Third, sessionMatch: pairs where the two rows declared the same session_duration outrank pairs where they did not. Fourth, the smallest UTC minute difference wins. Ties inside the fourth key are broken by iteration order, which is deterministic per tick. After the sort, a greedy walk picks non-overlapping pairs until every slot is either used or filtered out.

What is the prior-pair guard, and why does it matter?

scripts/best-pending-pairs.mjs and auto-match both build a set of (person_a_id, person_b_id) tuples pulled from matches. Any candidate pair whose two ids are already in that set is dropped before scoring. The effect on the pool is monotone: every tick shrinks the viable subset for any given member, because the set of partners they have not yet been matched with can only decrease. This is not the behavior a Focusmate-style rolling booking wants; it is the behavior a daily-partnership product wants. Once two rows are matched, they are a committed pair, and the matcher will not quietly shuffle one of them off to somebody new tomorrow.

Why is 'experience level' encoded as a boolean (old-student Yes/No) rather than a tier?

Because in the Goenka lineage the only fact that holds is whether a person has completed at least one 10-day residential course. Everything else (how many courses, how long they have sat, how many hours a day they sit now) is folk knowledge and does not correspond to a single verifiable checkpoint. The waitlist form asks is_old_student and stores 'Yes' or 'No'. A workout partner app could extend the same pattern with tiers (beginner, intermediate, advanced), but the failure mode of self-reporting would be identical, and the scheduler's sort key would still be a parity predicate, not a continuous score.

How often does the matcher run, and what happens between ticks?

vercel.json schedules '*/30 * * * *' → GET /api/auto-match, so the matcher runs at :00 and :30 of every hour. Between ticks nothing happens in the database except fresh signups accruing and the 24-hour cool-off clock ticking forward for each pending row. A signup submitted at 09:15 is eligible at 09:45 only if more than 24 hours have passed, which they have not; the earliest the row can be matched is the next tick after it crosses its 24-hour anniversary. That cool-off is a content-quality gate: it gives the operator a full day to eyeball the signup before the matcher can pair it.

Once two rows are paired, what is the weekly 'check-in' that other guides talk about?

There is no dashboard check-in. When person A replies to the intro email, the ImprovMX webhook hits /api/webhooks/improvmx, advanceMatchOnReply() runs, and the match status moves from 'pending' to 'replied'. When person B replies, the same function sees an inbound from the other side in vipassana_emails and flips the status to 'active'. That is the weekly check-in. If neither side replies for 3 days the match is marked 'expired' by /api/expire-matches; if either side later declines the recurring Google Calendar invite, /api/check-rsvp catches it and flips the match to 'ended'. Nothing is a button. Everything is an email action the person was going to take anyway.

Does any of this apply to someone looking for a gym or running buddy?

The architecture is portable, but this site is not it. A gym buddy matcher built on the same five primitives would need: (1) a waitlist form capturing timezone, session duration, experience parity, and cadence; (2) a scheduler at */30 minutes that enumerates pairs, applies a 60-minute UTC hard filter, and sorts by the parity keys above; (3) a prior-pair guard so you are not rematched with a partner who already ghosted; (4) an inbound-email state advance so the thread IS the check-in; (5) a permanent video room URL handed out once at match time. That is a usable reference implementation, but if the reader wants someone to spot them on a bench press, the matcher on this site is not the thing.

Is vipassana.cool the right product for someone searching for 'workout accountability partner'?

Honest answer: only if the reader counts a daily meditation sit as their workout. The pool currently skews 49 of 52 toward old students of the S.N. Goenka 10-day tradition, session durations cluster at 30 and 60 minutes, and every pair is for the same silent sit on Google Meet, not a shared gym session. If the reader is a meditator who has completed a 10-day course and is tired of sitting alone, the matcher is a near-perfect fit. If the reader is a lifter or a runner, the primitives on this page are genuinely useful but the pool is wrong, and a coach or a dedicated fitness app is the honest redirect.

Does this page teach meditation technique?

No. Nothing on this page is operational instruction. The 10-day residential courses at dhamma.org are where the technique is transmitted by authorized assistant teachers, and this site only handles the logistics of peer pairing for daily sits after a reader has completed such a course. Any question about how to sit, how to work with a difficulty, what to notice, or how to structure a practice belongs in the course and with an authorized assistant teacher, not here.

Related pages on adjacent topics

How did this page land for you?

React to reveal totals

Comments ()

Leave a comment to see what others are saying.

Public and anonymous. No signup.