tempo
Research software

Tracking, Engagement, Messaging & Participant Outreach

A survey platform holds the roster, a survey link per participant, date fields, and a completion flag on every form. Messaging APIs handle SMS and email. TEMPO connects the two: it reads the survey platform, works out which participants are due a message and when, sends it, and logs the result.

Once the schedule is declared, it runs unattended. This covers reminders due on a given day and EMA prompts that have to fire at an exact minute, 25 times across a sampling period.

How it works

Dates in, messages out

Each message is declared once. The pipeline expands the declarations against the current export into dated rows. Senders drain that queue on a clock.

1 · PullExport records, date fields and completion flags. Resolve each participant's survey link.
2 · ComputeTurn each declaration into a timestamp: a date field plus an offset, gated on a completion flag.
3 · SendSend what is due over SMS and email, with the participant's link substituted in.
4 · RecordAppend to a ledger so the same message cannot go twice.

The build here uses REDCap, OpenPhone and Gmail SMTP. Swapping any of them means reimplementing a few calls — Qualtrics, Twilio and SES expose the same primitives.

Integration pointWhat TEMPO needs from it
Survey platformA record export, date fields, per-form completion flags, and a per-participant survey link
SMS providerA send endpoint. Optionally a message-history endpoint, used to arbitrate against duplicates
EmailSMTP credentials, or any HTTP send API
SchedulerAnything that can invoke a URL on a schedule
Sample code

What it looks like in practice

Field names below are placeholders for your own event and instrument names.

Declaring a message

One entry: when it is owed, when it fires, where it goes, what it says.

{
  instrument: "Follow-up reminder",

  // Owed only while the form is still incomplete.
  condition: "[followup_arm_1][followup_1_complete]<>2",

  // 14 days after the baseline date, then every 7 days, at most 3 times.
  sendDateSpec: "14 days after [baseline_arm_1][baseline_date] " +
                "repeat every 7 days up to 3 times",

  // A phone field implies SMS, an email field implies email.
  destinationSpec: "[baseline_arm_1][participant_phone]\t[baseline_arm_1][participant_email]",

  // [event][field] fills per participant; [survey-link:form] becomes
  // that participant's own URL, resolved at send time.
  message: "Hi [baseline_arm_1][first_name], your follow-up is open: " +
           "[followup_arm_1][survey-link:followup_1]",
}

Computing the send time

Resolved per participant against their own dates. Repeats stop when the completion flag flips, so responders are not chased.

const anchor = participant.fields["baseline_date"];   // "2026-03-04"
const { offsetDays, everyDays, maxTimes } = parseSpec(alert.sendDateSpec);

const sends = [];
for (let n = 0; n < maxTimes; n++) {
  if (isComplete(participant, alert.condition)) break;   // responded, stop
  sends.push(atStudyTime(addDays(anchor, offsetDays + n * everyDays), "17:00"));
}
// → 2026-03-18T17:00, 2026-03-25T17:00, 2026-04-01T17:00

Sending a text

await fetch("https://api.openphone.com/v1/messages", {
  method: "POST",
  headers: { "Content-Type": "application/json", Authorization: API_KEY },
  body: JSON.stringify({ content: body, from: FROM_NUMBER, to: [e164] }),
  signal: AbortSignal.timeout(30_000),
});

Sending an email

const mailer = nodemailer.createTransport({
  host: "smtp.gmail.com", port: 587, secure: false,
  pool: true, auth: { user: SMTP_USER, pass: SMTP_PASS },
});

await mailer.sendMail({ from, to: recipient, subject, text: body });

Not sending it twice

Sending cannot be undone, so at-most-once is enforced three separate ways.

MechanismWhat it catches
Append-only ledger
pid|alert|time|channel|recipient
The ordinary case: a rerun, a retry, an overlapping schedule
Claim before transmitTwo runs hitting the same slot; only one wins the claim
Provider history checkA send that happened but whose ledger write was lost

Other guards: quiet hours, an opt-out list checked at queue time and again at send time, a per-run cap, no send if the survey link did not resolve, dry-run by default, and a kill switch that takes effect without a redeploy.

Momentary assessment

Prompts on the minute

EMA prompts ask what someone is doing right now, so they have to arrive at the minute they were scheduled for. Late is not useful.

A sampling grid is a set of fixed weekday and clock-time slots. It gets anchored to each participant's start date and expanded into dated rows, each carrying their phone number and survey link.

// A sampling grid: slots repeat across the period, anchored per participant.
const GRID = [
  { day: "Mon", at: "08:10" }, { day: "Mon", at: "13:45" }, { day: "Mon", at: "19:20" },
  // … 25 slots across the sampling period
];

for (const slot of GRID) {
  schedule.push({
    pid: participant.id,
    sendAt: atStudyTime(nextWeekday(startDay, slot.day), slot.at),
    phone: participant.fields["prompt_phone"],
    surveyLink: await resolveLink(participant, slot.form),
  });
}

Schedulers are not punctual, so the sender does not rely on being invoked at the right moment. It runs on a coarse clock, picks up any slot inside its look-ahead window, and waits in-process until the exact second. It claims the slot before transmitting, so two overlapping runs cannot both send. Past the grace window a prompt is recorded as skipped rather than sent late.

// Wait out the remainder in-process, then claim and fire.
const waitMs = slot.sendAt - Date.now();
if (waitMs > 0) await sleep(waitMs);

if (await claim(slot.key)) {            // lost the race? another run has it
  await sendSMS(slot.phone, render(slot));
  await ledger.append({ ...slot, status: "sent", latencySec: 2 });
}

Measured latency in production is 2–4 seconds past the scheduled second. It is written to the ledger per message.

The server

A dashboard over the whole thing

The dashboard reads the same files the senders write. No second database. Screens below run on synthetic data.

Overview screen showing participant totals, messages due in the next seven days, and a per-wave completion table
Totals, what is due, and completion by phase.
Outgoing queue grouped by day, listing scheduled messages per participant
Scheduled messages, grouped by day.
Sent log listing each delivered message with channel, recipient and status
What already went out, with channel, recipient and status.
Momentary assessment tracker showing per-participant prompt completion against the sampling grid
EMA completion against the grid, per participant.
Participant roster with per-participant status
The roster with each participant's progress.

Access is a shared password behind a signed cookie, checked in middleware on every route. A daily job reconciles what was scheduled against what the ledger recorded and flags anything missing.