EMA Forge
EMA FORGE SERVERLESS · OPEN-SOURCE · PRIVACY-FIRST Build, preview, and deploy completely self-contained EMA studies. Serverless Deployment Camera PPG & ePAT Integrated Dashboard

Overview & Architecture

EMA Forge is an open-source toolkit for building, deploying, and analyzing Ecological Momentary Assessment (EMA) and digital-phenotyping studies. It is end-to-end serverless: your study is compiled into a static web app, participants complete it in their mobile browser, and response data is generated, stored, and downloaded entirely on the participant's device.

There are three things you'll interact with as a researcher, and one thing your participants will interact with:

🛠 Study Builder
Where you design the study — questions, schedule, tasks. Hosted at emaforge.keeganwhitacre.com.
📊 Analysis Dashboard
Where you load participant data files for compliance checks and quick stats. Same site.
📦 Exported Study Bundle
What the Builder produces — a small set of files you host somewhere (e.g. GitHub Pages) so participants can reach it.
📱 The Participant App
What your participants actually open on their phone. Generated automatically from the bundle above; no install needed.

You do not host the Builder yourself. Go to emaforge.keeganwhitacre.com, design your study there, click Export, and host the resulting bundle for participants. The Builder runs entirely in your browser — nothing you type gets sent to the server — so using the hosted version does not compromise privacy in any way.

No user account, no database, no backend. The entire study lifecycle — build → export → host → distribute → collect → analyze — runs without a server component maintained by you.

Why Serverless?

Traditional EMA platforms require standing up a backend server, authenticating users, and handling data-in-transit through a database that becomes a named component of every IRB protocol. The tradeoffs EMA Forge makes to avoid that:

  • Zero backend infrastructure. Host on GitHub Pages, Netlify, Vercel, Cloudflare Pages, or any institutional static web server. No runtime, no DB, no SSL cert to renew.
  • Data stays local until the participant hands it over. Response data is written to the participant's device as a file. You collect it via whatever channel your IRB has already approved for data return (secure email, REDCap upload, institutional SFTP, etc.).
  • Offline-tolerant. Once the study app is loaded in the browser, it keeps functioning without network. Completed sessions queue locally and download when the participant taps "Save."
  • Full transparency. Every line of runtime code is visible in the exported HTML. Reviewers, participants, and IRBs can inspect exactly what runs on a participant's device.

Tradeoffs to be honest about (see Known Limitations):

  • There is no central "dashboard" of live enrollment — data is only visible once files are returned to the researcher.
  • EMA Forge does not send notifications itself. You need an external scheduler/SMS service to deliver links at the right time. Twilio helpers are on the roadmap (see below).
  • The static host can see request metadata (IP, timestamp of page load). This is the same constraint as any web page — it's not a data transmission, but it's not nothing.

Feature Status Matrix

Keep this in front of you when planning a study. Anything marked BETA has shipped but hasn't yet been through external pilot validation; anything marked WIP or PLANNED is not yet usable.

FeatureStatusNotes
Study Builder (questions, schedule, theme)STABLESchema v1.5.0.
Single-file & static-bundle exportSTABLEBoth include config.json.
Onboarding / consent flowSTABLERich-text consent, progress bar.
EMA question types (slider, choice, multi, text, number)STABLESee Questions Tab.
Affect Grid (valence × arousal)STABLEStored as {valence, arousal} in [−1, 1].
Skip logic (compound AND/OR)STABLERules on any prior non-multi question.
Phase sequencing (multi-task windows)STABLEArbitrary ordered EMA/Task/HR steps.
Conditional tasks (e.g. run ePAT only if HR > 80)STABLESee Conditional Tasks.
Session locking + crash recoverySTABLELocalStorage per-phase resume.
Dashboard (compliance, watchlist, CSV export)STABLERuns locally in browser.
Heart-rate capture question type (PPG)BETARequires rear camera + torch. iOS works; desktop no.
ePAT task (heartbeat perception)BETAShares PPG core with HR capture.
Response latency in DashboardWIPNeeds external prompt-delivery timestamps — see Sending Prompts.
Twilio SMS helpersWIPPlanned; see Roadmap.
Webhook auto-upload on session completeWIPPlanned; POST to REDCap / S3 / custom endpoint.
Stroop / IAT / additional task modulesPLANNEDThe task system is built to be extended; these are next in line.

System Requirements

For you (researcher)

  • Any modern desktop browser. Chrome, Edge, Firefox, or Safari (from 2022 onward). The Builder and Dashboard run entirely in your browser at emaforge.keeganwhitacre.com.
  • A way to host your exported study. GitHub Pages is the path of least resistance and free for researchers. Any institutional static-file server works too. (See Hosting Your Study — it is genuinely ten clicks.)
  • A way to send participants links at the right times — an SMS service, institutional email scheduler, Twilio, or similar. EMA Forge generates the links; something else delivers them.
  • R or Python (optional). The Dashboard handles most day-to-day compliance monitoring in the browser. You'll want R or Python for real analysis.

For participants

  • iOS Safari 14.5+ or Chrome for Android 90+ (roughly anything from 2021 onward).
  • For the HR / ePAT modules only: a rear-facing camera with a controllable torch (flashlight). All modern iPhones and most Android devices qualify; tablets without rear cameras do not. Desktop browsers will refuse to launch these modules.
  • A working browser link. That's it — no app install, no account.

iOS quirk worth flagging during onboarding: Camera/torch access on iOS requires the page to be launched from Safari (not from inside the Messages app preview or from an SMS link-preview bubble). Your participant instructions should explicitly say "tap the link to open it in Safari" for studies using HR or ePAT.

Quick Start — 5 minutes to a working pilot

You do not need to install anything. The Builder and Dashboard are hosted and ready to use.

  1. Go to emaforge.keeganwhitacre.com and click Builder.
  2. In the Study tab, name your study.
  3. In Questions, click + Slider and add one mood item. Keep the default anchors.
  4. In Schedule, set study length to 3 days and keep the default morning/afternoon/evening windows.
  5. Click ExportSingle HTML file. You'll get a file you can email to yourself.
  6. Open that file on your phone with ?id=1&day=1&session=w1 appended. You're running a study.
  7. When you finish the session, your phone downloads a CSV. Go back to emaforge.keeganwhitacre.com, open the Dashboard, import the folder, and you'll see your own compliance data.

That round trip tells you whether your device can open sessions and whether your data is landing where you expect. Always do this before enrolling a participant.

Please use the hosted version at emaforge.keeganwhitacre.com rather than cloning and self-hosting the Builder. The hosted site is actively maintained and always on the current schema version. Everything happens in your browser — nothing you type into the Builder is transmitted to the server, so "hosted" here just means "the files are served from one canonical place." You'll still self-host the exported study (see Hosting Your Study), because that's the file participants actually open.

Study Tab

Global study configuration. Set here:

  • Study name & institution — appears in participant app title and CSV metadata.
  • Theme & accent color — OLED (pure black, recommended for battery), dark, or light. Accent color inherits into the participant app.
  • Response format — CSV (one row per answered question) or JSON (full structured payload). Both are downloaded; JSON is always included when a session contains high-density signal data (PPG/ePAT).
  • Greetings — per-window header text ("Good Morning", "Check-In", etc.).
  • Completion lock & crash recovery — see Runtime Behavior.

Onboarding & Consent

The Onboarding flow runs exactly once per participant, on their first link (?id=N&session=onboarding, Day 0). It covers:

  • A progress-barred multi-screen intro.
  • A rich-text consent screen that requires scroll-to-bottom before the "I agree" checkbox activates. Consent text accepts HTML so you can paste in your IRB-approved language with headings.
  • An optional schedule-sanity screen letting the participant confirm they'll be available during each window.
  • For studies with ePAT enabled: a two-phase practice (tone-to-tone, then tone-to-heartbeat) before the real trials start.

If onboarding is disabled, Day 0 is skipped entirely and participants land directly in their first EMA window.

Questions Tab

The Questions tab is where you write your survey items. Each question appears as a card — click to expand it, drag to reorder. Changes show up live in the phone preview as you type.

Question types

TypeStored asNotes
slidernumberMin, max, step, unit, two anchor labels.
choicestringSingle-select from an option list.
checkboxarray of stringsMulti-select. Serialized as a;b;c in CSV.
textstringFree-form text input.
numericnumberNumber input with keypad on mobile.
affect_grid{valence, arousal}2D tap target. Both axes in [−1, 1]. Serialized as valence;arousal in CSV.
heart_rate{bpm, sqi, ibi_series}Camera PPG for a configurable duration. The BPM number is what participates in skip logic and conditional tasks. See HR Capture.
page_breakSplits questions onto separate screens. Not a question per se.

Per-question settings

  • Show In (Phase): whether this question appears pre-task, post-task, or both within a phase-sequenced window.
  • Active Sessions: restrict the question to specific time windows (e.g. only morning).
  • Required: blocks advancement until answered.
  • Skip Logic: compound AND/OR conditions on any prior question. Operators: eq, neq, gt, gte, lt, lte, includes. When a condition evaluates false, the question is silently skipped and not recorded.
  • Response piping: insert {{q_id}} in a later question's text to substitute the participant's prior answer. Useful for contextualizing follow-ups ("You said your mood was {{q1}}. What was the cause?").
Skip logic semantics — edge cases worth knowing

Conditions reference the raw response value, which means:

  • For sliders, comparisons are numeric. q1 gt 70 works as expected.
  • For single-choice, comparisons are string equality against the option label (not index).
  • For checkbox (multi-select), comparisons use includes. eq on a multi-select will nearly always be false because the stored value is an array.
  • For Affect Grid, skip-logic is disabled in the UI (the value is a compound object, not a scalar).
  • For Heart Rate, comparisons hit the bpm field directly, so q_hr_1 gt 80 does what you'd expect.
  • If a question referenced by a condition was itself skipped or not-yet-presented, the rule evaluates false. Design accordingly.

Schedule & Phase Sequencing

A study consists of N days, each day containing K time windows, each window containing an ordered phase sequence of steps.

Days & windows

  • Study length: 1–365 days.
  • Days of week: restrict to weekdays only, etc.
  • Windows: each has an ID, a display label, and a start/end time. The ID (e.g. morning) is what becomes the session= URL parameter.

Phase sequence

Inside a window, you build an ordered list of steps. A step is one of:

Step kindMeaning
ema (pre)The pre-task EMA block: all questions whose "Show In" = Pre or Both.
ema (post)The post-task EMA block: all questions whose "Show In" = Post or Both.
taskA task module (currently: epat). Optionally gated by a condition.
hrA standalone heart-rate capture step with configurable duration.

Steps run top-to-bottom. The same task can appear multiple times; you can have Pre-EMA → HR → ePAT → Post-EMA, or two tasks sandwiched between three EMA blocks — whatever the protocol calls for.

Response window timing: the expiry_minutes setting is enforced at runtime by comparing against a t= URL parameter (a millisecond timestamp added by your SMS/scheduler). If t is absent, links never expire — convenient for piloting, but turn this on before real enrollment.

Tasks Tab

The Tasks tab is where you turn optional task modules on or off. Toggling a task makes it available to drop into your schedule — it doesn't insert it anywhere automatically; you still choose where in the session sequence it runs (see Schedule).

Currently available: ePAT. Additional cognitive tasks (Stroop, IAT) are on the roadmap.

Live Preview

The right-hand iframe in the Builder is a fully functional participant app, re-stitched on every edit. It runs in preview mode (__PREVIEW_MODE__ = true), which disables link expiry and session locking so you can walk through the same session repeatedly. Clicking Reset clears the preview's local storage without touching your real project.

Preview is the fastest way to catch issues before you export anything. Touch-test it on your own phone by opening the Builder's URL on your device — the preview iframe works over any local network you're on.

Export Options

OptionWhat you getWhen to use
Single HTML file One self-contained .html with config, JS, and CSS inlined. Pilots, QA, or emailing a direct file to one participant. Config is baked in — any edit means re-exporting.
Static-hosting bundle RECOMMENDED A .zip containing index.html, config.json, css/, and js/. Real deployment. Because config.json is separate, you can fix a typo in a question without re-exporting. Just edit the JSON on the host.

Hosting Your Study

This section is about hosting the exported study bundle — the thing participants will open. You are not hosting the Builder itself (that already lives at emaforge.keeganwhitacre.com).

The simplest option for most academic labs is GitHub Pages. It is free, reliable, and requires no command-line work. The minimal path:

  1. Create a free GitHub account if you don't have one.
  2. Create a new repository (e.g. my-ema-study). Make it public or private — both work with GitHub Pages on free accounts.
  3. Click Add file → Upload files, and drop in the entire contents of your extracted Export zip (the index.html, config.json, and the css / js folders).
  4. Go to Settings → Pages. Under "Source," choose main branch, root folder.
  5. Wait a minute. Your study is now live at https://<your-username>.github.io/my-ema-study/.

That URL is the base you give to the Builder's Deployment tab to generate participant links. You're done.

Prefer the command line, or deploying via another host?

Any static host works unchanged — Netlify, Vercel, Cloudflare Pages, S3 + CloudFront, or an Apache/Nginx directory on institutional infrastructure. No build step, no server runtime.

The git-native GitHub Pages recipe:

# From your extracted bundle folder
git init
git add .
git commit -m "study v1"
git branch -M main
git remote add origin https://github.com/your-lab/your-study.git
git push -u origin main

# Then in GitHub: Settings → Pages → Source = main branch, root
# Your study is live at https://your-lab.github.io/your-study/

Participant Routing

Because there is no backend, the participant's session is entirely determined by the URL they open. Four query parameters drive this:

ParameterRequiredDescription
idyesParticipant identifier (any string, usually numeric).
dayyes (except onboarding)Study day, typically 1N.
sessionyesWindow ID defined in the Schedule tab, or the literal string onboarding for Day 0.
trecommendedMillisecond Unix timestamp of when the link was sent. Used for expiry enforcement. Your scheduler should inject this at send-time.
forcenoWhen set to 1, overrides session locking. For researcher use only (QA, rescues). Do not put this in participant links.
https://your-lab.github.io/study/?id=104&day=2&session=morning&t=1732812000000

Generating Links in Bulk

The Builder's Deployment tab takes a base URL and a participant-ID range, and emits a CSV with one row per (participant × day × session). Each row includes a Phase_Sequence column so you can eyeball at a glance what each link will do.

Participant_ID,Day,Session,Phase_Sequence,URL
104,0,Setup,Onboarding,https://.../?id=104&session=onboarding
104,1,Morning,Pre-EMA → ePAT → Post-EMA,https://.../?id=104&day=1&session=w1
104,1,Evening,Pre-EMA,https://.../?id=104&day=1&session=w2
...

This CSV is the glue between EMA Forge and your delivery mechanism of choice — drop it into a Qualtrics contacts list, a Twilio scheduled-SMS campaign, an institutional email merge, or a lab-built scheduler. Your scheduler is responsible for appending a t= timestamp at send-time if you want expiry enforcement.

Sending Prompts WIP

Delivery is currently out-of-scope — you bring your own SMS/email/push mechanism. The current recommendation is Twilio Programmable Messaging with scheduled-send. Native integration is on the roadmap.

Session Lifecycle

When a participant opens a link, the runtime:

  1. Parses URL parameters.
  2. Checks expiry (if t and expiry_minutes are both set).
  3. Checks the completion lock — has this (id, day, session) tuple already been submitted on this device?
  4. Checks for an in-progress resume state for this session.
  5. Renders the first step of the session (usually a pre-task EMA block).
  6. On each phase completion, writes the phase's responses to local storage and advances.
  7. When the last phase finishes, the full session payload is assembled and the "Save Local Copy" screen appears. Tapping it triggers a file download.

Expiry & Grace Period

  • Expiry window (default 60 min): after this many minutes past t, the link renders a "Link Expired" screen and the Start button is disabled. Recorded as a missed ping by the Dashboard.
  • Grace period: a secondary buffer primarily used for downstream compliance calculations; the link remains functional during grace, but responses inside the grace window can be flagged during analysis.

Session Locking

By default, a participant who completes a session and then re-clicks the same link sees a "Session Complete — thanks, come back at the next prompt" screen. This prevents accidental double-submissions.

Researchers can override with ?force=1 appended to the URL (useful for QA walkthroughs or for rescuing a participant whose submission didn't land). Do not include force in participant-facing links.

Crash Recovery

If a participant's browser crashes mid-session (tab killed by iOS memory pressure, phone restart, app switch past timeout), reopening the same link will:

  • Restore all completed phases (pre-EMA, finished task trials, etc.) — that data survives.
  • Restart the current phase from the beginning. Partial data within the phase is discarded to avoid ambiguity.

This is intentionally conservative. A half-finished phase with an unknown number of missing questions is worse than a clean re-run.

Schema Versioning & Migrations

Every study carries a schema version (currently 1.5.0). When the Builder loads a saved study — whether from your browser's local storage or a backup file you imported — it checks that version:

  • Equal → load as-is.
  • Older → forward-migrate silently (new fields get sensible defaults).
  • Newerrefuse to load. A newer schema might include fields this runtime doesn't know how to preserve; loading would silently corrupt them. Export a backup from the newer Builder or reset.

This is worth knowing when rolling out an EMA Forge update mid-study: finish in-progress waves on the old version, then upgrade.

What Participants Save

Each completed session produces one or two files:

  • ema_data_[ID]_[DAY]_[SESSION].csv — long-format, one row per question answered. Default output when the session contains only standard EMA responses.
  • ema_data_[ID]_[DAY]_[SESSION].json — full structured payload, included automatically whenever the session contains high-density signal data (PPG samples, ePAT trial-level data). Written alongside the CSV, not instead of it.

Participants return these files via whatever channel your IRB approved. The Dashboard expects a folder of JSON files for analysis; a CSV-only workflow is also viable for simpler studies.

CSV Schema (Dashboard export)

When you use the Export Master CSV button in the Dashboard, you get one row per (session, question) pair in long format. The columns:

ColumnDescription
participant_idFrom the URL ?id=.
dayFrom the URL ?day=.
session_idUnique per-session identifier generated by the runtime.
window_idMatches the Schedule-tab window ID (e.g. morning).
window_labelHuman-readable window label.
blockpre, post, or blank. Which EMA block within the phase sequence the question belonged to.
session_started_atISO-8601 timestamp of session start.
session_submitted_atISO-8601 timestamp of final session save.
phase_started_atISO-8601 timestamp — when the block containing this question began.
phase_submitted_atISO-8601 timestamp — when the block was submitted.
question_idStable ID (e.g. q1, q_hr_1).
question_textThe question text at the time of export.
question_typeOne of the types listed in Questions Tab.
presentation_order1-indexed order the question was shown in, accounting for skip logic.
response_valueThe raw response, serialized (checkbox as a;b;c, Affect Grid as valence;arousal).
response_numericNumeric cast of the response if possible; blank otherwise. Convenient for sliders.
response_latency_msMilliseconds from phase start to this response.

JSON Schema (per-session)

The raw JSON payload is what the Dashboard reads and is the source of truth. Shape (abbreviated):

{
  "participantId": "104",
  "sessionId": "s_a1b2c3",
  "day": 2,
  "type": "morning",
  "status": "complete",
  "startedAt": "2026-04-22T08:03:11Z",
  "completedAt": "2026-04-22T08:06:48Z",
  "data": [
    {
      "type": "ema_response",
      "block": "pre",
      "windowId": "w1",
      "startedAt": "...",
      "submittedAt": "...",
      "presentationOrder": [["q1", "q2"], ["q3"]],
      "responses": {
        "q1":    { "value": 72,                        "respondedAt": "..." },
        "q2":    { "value": "Working",                 "respondedAt": "..." },
        "q3":    { "value": {"valence": 0.4,
                             "arousal": -0.2},         "respondedAt": "..." },
        "q_hr_1":{ "value": { "bpm": 74,
                              "sqi": 0.82,
                              "ibi_series": [810, 795, ...] },
                   "respondedAt": "..." }
      }
    },
    {
      "type": "epat_response",
      "trials": [ { "trial": 1, "phase_ms": 342, "confidence": 3, "sqi": 0.91 }, ... ],
      "summary": { "valid_trials": 18, "mean_abs_phase_ms": 218, "..." }
    }
  ]
}

Analysis in R

Because the Dashboard's CSV export is long-format, getting from a folder of per-session files to a tidy dataset is short. A typical starter pipeline:

library(tidyverse)
library(jsonlite)

# 1. Ingest a folder of per-session JSON files
files <- list.files("data/", pattern = "\\.json$", full.names = TRUE)
sessions <- map(files, ~ fromJSON(.x, simplifyVector = FALSE))

# 2. Flatten to long format (or just use the Dashboard's master CSV)
df <- read_csv("ema_master_dataset_2026-04-22.csv")

# 3. Compliance per participant
compliance <- df |>
  distinct(participant_id, day, session_id) |>
  count(participant_id) |>
  rename(sessions_completed = n)

# 4. Mean mood by time-of-day, handling the long format
mood <- df |>
  filter(question_id == "q1") |>
  group_by(participant_id, window_label) |>
  summarize(mean_mood  = mean(response_numeric, na.rm = TRUE),
            sd_mood    = sd(response_numeric,   na.rm = TRUE),
            n          = n(),
            .groups    = "drop")

# 5. Affect Grid (stored as "valence;arousal" in response_value)
affect <- df |>
  filter(question_type == "affect_grid") |>
  separate(response_value, into = c("valence", "arousal"),
           sep = ";", convert = TRUE)

Dashboard

dashboard.html is a local analysis UI. Drop in a folder containing your study's config.json and all returned participant JSON files; it parses locally and renders:

KPIs

  • Avg. Compliance Rate — completed sessions / expected sessions, scoped by filters.
  • Total Pings Delivered — expected sessions up to the current study day.
  • Signal Noise / Invalid % — proportion of sessions flagged as "speeding" (total session duration < 30 seconds). A rough heuristic; tune in your own analysis.
  • Avg. Time to Complete — mean session duration.

Filters & views

  • Aggregate vs. Per-Participant segmented view.
  • Date range (by study day).
  • Exclude missed prompts toggle (affects denominator of compliance).
  • Filter rapid responders (<30s) toggle.
  • Attention Required watchlist — participants whose compliance is below threshold, ready for a check-in text.
  • Export Master CSV — flattens all imported sessions into one long-format file (schema above).

Latency is currently placeholder. The Dashboard has fields for "latency" (time from prompt delivery to link open), but with no server, the runtime cannot know when the link was sent. This field is zero-filled until the Twilio/webhook integration lands, which will give the parser an external prompt-delivery timestamp to merge in.

Heart-Rate Capture (Camera PPG)

EMA Forge ships a lightweight photoplethysmography (PPG) pipeline that recovers heart rate from the participant's rear-facing smartphone camera, with the torch on as an illumination source. It powers two user-facing features:

  • A heart_rate question type you can drop into an EMA block anywhere.
  • A standalone HR step in a window's phase sequence (identical capture, structural placement differs).
How the PPG pipeline works (click to expand)

The runtime's ePATCore module handles PPG end-to-end. In brief:

  1. Camera acquisition. getUserMedia requests the rear camera with the torch constraint. A hidden <video> element receives the stream.
  2. Sampling. A hidden <canvas> draws each video frame at ~30 Hz, averaging pixel intensity (primarily the red channel) over a central region of interest. This produces a 1D time series of blood-volume-driven reflectance change.
  3. Filtering. The raw signal is high-pass filtered to remove slow drift (respiration, finger micro-shifts) and low-pass filtered to remove high-frequency sensor noise, leaving the cardiac band (roughly 0.7–3 Hz, i.e. 40–180 BPM).
  4. Signal-quality index (SQI). Each sliding window yields a perfusion-index estimate. The sqi_threshold setting (default 0.3) is the floor below which a trial/capture is rejected. This is what the Sensor Warning overlay enforces — if the finger lifts or lighting changes, SQI drops and the warning reappears.
  5. Beat detection. Peaks are identified in the filtered signal using a JavaScript port of the WABP algorithm (Zong et al., 2003) — originally designed for arterial blood pressure onset detection, which transfers cleanly to the PPG domain. Inter-beat intervals (IBIs) are the ms-differences between successive detected onsets. The camera acquisition approach follows the pattern established in Richard Moore's open-source web heart-rate-monitor. Full citations in Acknowledgments.
  6. Output. BPM is 60000 / median(IBIs) over the capture window. Stored alongside the full IBI series for downstream HRV analysis, plus the mean SQI as a quality annotation.

This is not a medical-grade measurement. For research use where the construct is "participant's approximate HR at this moment under ecological conditions", the technique is well-established in the digital biomarker literature. For clinical interpretation, don't.

ePAT — Ecological Phase Assessment Task

The ePAT is a cardiac interoceptive-accuracy task adapted for in-the-wild, mobile-first use. It is directly descended from the Phase Adjustment Task (PAT) and its refined successor PAT 2.0 — see Acknowledgments & Prior Work for primary sources. Participants align an auditory tone with their own felt heartbeats by rotating a dial until the tone "feels like it's landing on" each beat. Their phase offset (in ms, relative to ground-truth peaks detected by the camera PPG) is the dependent measure.

Task flow, trial by trial (click to expand)
  1. Baseline calibration. 10–20 s of still finger-on-camera capture establishes the participant's current HR and verifies that SQI is above threshold before any trials begin.
  2. Two-phase practice (optional, default on). First, tone-to-tone alignment (no heartbeat involved — teaches the dial mechanic). Second, tone-to-heartbeat alignment at a slow scaffolded pace.
  3. Trial block. trials valid trials (default 20), each trial_duration_sec seconds long (default 30). During a trial:
    • Live camera PPG streams in the background.
    • A tone plays at a predicted time, offset by a randomized phase.
    • The participant rotates the rotary dial to shift the tone earlier or later until it subjectively aligns with their beat.
    • On "Confirm Timing", the offset is recorded relative to the ground-truth peak from the PPG.
  4. Per-trial confidence (optional): 1–5 rating of how sure they were the tone matched their heartbeat.
  5. Body map (optional): after each trial, where did they feel the beat? Chest / fingers / neck / ears / abdomen / legs / head / nowhere.
  6. Retry budget. retry_budget (default 30) is the maximum number of attempts. A trial can fail for low SQI, excessive movement, or participant cancelation. Once valid trials hits the target, the task ends; if the budget exhausts first, the task ends with whatever valid trials were collected.

High-precision timing. Audio stimulus scheduling uses the Web Audio API's AudioContext (not setTimeout), which gives sample-accurate timing across browsers. This is the difference between a task that has <2 ms jitter and one that has ~20 ms jitter, which matters a great deal when your DV is measured in milliseconds.

Configuration knobs (Builder → Tasks → ePAT)
SettingDefaultPurpose
trials20Target number of valid trials.
trial_duration_sec30Max seconds per trial before auto-cancel.
retry_budget30Hard cap on total attempts (valid + failed).
sqi_threshold0.3Minimum perfusion index for trial acceptance.
confidence_ratingsonAsk for 1–5 confidence after each trial.
two_phase_practiceonInclude tone-to-tone + tone-to-heartbeat practice.
body_maponShow the post-trial body-location picker.

ePAT is flagged BETA. The algorithm is stable and the task is usable, but the published-psychometrics validation of the ecological adaptation is still in progress. If you publish ePAT data collected via EMA Forge, please cite both the underlying PAT lineage (Plans et al., 2021; Palmer et al., 2025) and this repository — full citation list in Acknowledgments.

Conditional Tasks

A task step in a window's phase sequence can carry a condition that gates whether it runs at all:

{ kind: "task", id: "epat",
  condition: { question_id: "q_hr_1", operator: "gt", value: 80 } }

At runtime, EMA Forge evaluates the condition against the participant's responses collected earlier in the same session. If the condition is false, the step is silently skipped — as if it were never in the sequence. Typical use cases:

  • "Only run ePAT if resting HR is elevated" — gate on a prior HR capture's BPM.
  • "Only show the post-task stress items if the participant reported feeling stressed beforehand" — gate on a slider threshold.
  • "Skip the cognitive task on the 3rd daily window" — gate on window ID.

Privacy Model

Written plainly, because IRBs will ask you to restate this in your protocol:

  1. Response data never transits a central server. It is generated, stored, and downloaded entirely on the participant's device. You receive it via whatever channel the participant uses to return files.
  2. The static host sees page-load requests. Your GitHub Pages / Netlify / institutional host will receive HTTP requests when the participant opens a link. These requests include timestamp, IP, User-Agent, and the full URL (including ?id=). If participant ID alone is identifiable, choose an unlinkable ID (short opaque strings, not names or MRNs).
  3. No third-party analytics, no CDN fetches, no font-provider calls. The exported study has zero outbound network calls at runtime (after initial page load). You can verify this in Chrome DevTools → Network.
  4. Camera / microphone access (HR, ePAT) is handled entirely through the browser's getUserMedia permission prompt. The media stream never leaves the device; only the derived signal (BPM, IBIs, phase offsets) is stored.
  5. Consent is captured as a boolean plus a timestamp in the onboarding JSON payload. The full consent text as the participant saw it is logged alongside.

IRB Boilerplate

Language you can adapt for the "data management" section of your protocol:

Participant response data will be collected via a custom web-based application (EMA Forge, an open-source static web tool). The application operates entirely in the participant's mobile browser: response data is generated and stored on the participant's device and is not transmitted to any central server in the course of normal operation. Participants return completed session files to the research team via [INSTITUTIONAL SECURE CHANNEL — e.g., REDCap file upload, institutional SFTP]. No protected health information or direct identifiers are included in the study data; participants are identified only by an opaque study ID. Physiological measurements (heart rate via photoplethysmography) are derived from the participant's smartphone camera locally; raw video is not stored or transmitted. The application source code is open and available for review at [YOUR REPO URL].

Known Limitations

  • No live enrollment dashboard. Data is only visible once files are returned.
  • Missed-ping detection is derived, not direct. The app cannot know a prompt was sent unless you record that externally.
  • Device heterogeneity. HR/ePAT quality depends on camera sensor, torch brightness, and case thickness. Pilot across a range of devices before locking your protocol.
  • Browser storage can be cleared. A participant who "clears website data" mid-study loses their in-progress resume state (but not already-downloaded session files). The completion lock also resets.
  • Single-file exports don't let you patch typos. Use the static-hosting bundle if you anticipate edits after deployment.
  • Accessibility is a work in progress. Screen-reader support for sliders and the affect grid is incomplete. Audit against your accessibility requirements before broad enrollment.

Roadmap

🔗 Webhook auto-upload WIP
Optional POST of the session JSON to a configured endpoint on save, so files land in REDCap / S3 / a custom ingest without participant action.
📱 Twilio helpers WIP
Generate Twilio-ready scheduled-message batches directly from the Deployment CSV, with auto-injected t= timestamps.
⏱ Live latency PLANNED
Once Twilio integration lands, the Dashboard will merge delivery timestamps to compute true ping-to-open latency.
🧠 Stroop / IAT modules PLANNED
Additional cognitive tasks slotting into the existing module registry.
♿ Accessibility pass PLANNED
Full ARIA landmarking, screen-reader-friendly slider + affect grid, text-scale respect.
🔒 Optional AES-encrypted payloads PLANNED
Client-side encryption of returned files against a lab-held public key, so return-channel compromise is not a data exposure.

Twilio & Webhooks WIP

These are the two most-requested integrations and are the next items on the build queue. A sketch of what's coming:

Twilio integration

  • A new Deployment sub-tab that converts the routing CSV into a Twilio Messaging API batch (CSV or direct API call).
  • Auto-injection of t= at Twilio's scheduled send-time, so expiry enforcement just works.
  • Opt-in support for Twilio's Scheduling feature (up to 7 days ahead) or deferred to a lab-side cron for longer horizons.
  • Optional two-way SMS for unprompted "I want to check in now" responses, mapped to a designated emergency window.

Webhook auto-upload

  • A Study-tab field for a webhook URL the runtime POSTs the session JSON to when the participant taps "Save".
  • Fallback to local download if the POST fails — never drops data silently.
  • Templates planned for: raw REDCap File Repository, S3 presigned URLs, and generic JSON endpoints.
  • Crucially, this stays optional. The serverless guarantee is the headline feature and shouldn't be silently downgraded.

If you're planning a study that depends on either of these, reach out — usage patterns from real deployments shape what ships first.

Under the Hood

You do not need any of this to run a study. It's here for the small subset of users who want to audit the code, contribute a new task module, or fork the project for a deeply custom use case.

Repository layout

The repository is split into three concerns: (1) what the researcher uses (Builder + Dashboard), (2) what gets compiled into the participant's study (templates/), and (3) shared styling.

ema-forge/
├── index.html             # Landing page
├── builder.html           # Study authoring environment
├── dashboard.html         # Local analysis dashboard
├── readme.html            # This file
│
├── css/
│   └── studio.css             # Shared design tokens + component styles
│
├── js/
│   ├── state.js               # Central state + schema (SCHEMA_VERSION)
│   ├── storage.js             # LocalStorage + project import/export + migrations
│   ├── export.js              # Compiles study to HTML / zip bundle
│   ├── preview.js             # Live iframe preview
│   │
│   ├── tabs/                  # Builder tab controllers
│   │   ├── study.js
│   │   ├── onboarding.js
│   │   ├── questions.js
│   │   ├── schedule.js        # Windows + phase_sequence editor
│   │   ├── tasks.js           # Pluggable module registry
│   │   └── deployment.js      # Routing CSV generator
│   │
│   └── dashboard/
│       ├── parser.js          # Ingests participant JSON folder
│       └── dashboard.js       # Charts, filters, CSV export
│
└── templates/                # Source files stitched into the exported study
    ├── epat-core.js           # PPG pipeline + BeatDetector
    ├── study-base.js          # Runtime skeleton
    ├── module-onboarding.js
    ├── module-ema.js          # EMA block + inline HR capture
    └── module-epat.js         # ePAT task

Key boundary: js/ powers the Builder/Dashboard (what the researcher interacts with). templates/ is the code that gets stitched into the participant's study by js/export.js. Never edit templates hoping to change Builder behavior, or vice versa.

Adding a new task module

The task registry in js/tabs/tasks.js exposes a SETTINGS_RENDERERS object. Each entry is { html(mod), bind(card, mod) }html() returns the settings-panel HTML for the Builder, bind() attaches event listeners. To register a module:

  1. Add an entry to state.modules in js/state.js with id, label, desc, enabled, and a settings object.
  2. Add a matching renderer in SETTINGS_RENDERERS in js/tabs/tasks.js.
  3. Create a runtime module file in templates/module-<id>.js that defines a lifecycle (start, teardown, getData) and push it into the export pipeline in js/export.js.

ePAT is the reference implementation; use it as a template.

Self-hosting the Builder (not recommended)

The Builder is hosted at emaforge.keeganwhitacre.com and that is the recommended way to use it. The hosted version is kept on the current schema and is always up to date; self-hosting introduces version-skew risk (see Schema Versioning) that can corrupt saved projects across researchers working on the same study.

That said, if you need to self-host — institutional policy forbids external tools, you want to run an older schema version indefinitely, or you're developing against the code — clone the repo and serve it with any static-file server:

git clone https://github.com/keeganwhitacre/emaforge.git
cd emaforge
python -m http.server 8000
# then open http://localhost:8000

Camera access requires HTTPS, so for HR/ePAT work you'll need a real certificate — file:// and plain HTTP over non-localhost will both fail.

Credit & Citation

EMA Forge was created by Keegan Whitacre at the Affective Science Lab, The Ohio State University. If you use EMA Forge in a published study, please cite:

Whitacre, K. (2026). EMA Forge: A serverless toolkit for
ecological momentary assessment and digital phenotyping.
Affective Science Lab, The Ohio State University.
https://github.com/keeganwhitacre/emaforge

For ePAT-specific methods, also describe the implementation (PPG pipeline + AudioContext-scheduled stimulus + dial-alignment response) in your Methods section. Issues, pull requests, and replication reports are welcome.

License

EMA Forge is released under the MIT License — free for academic and commercial use, modification, and redistribution. See LICENSE in the repository for the full text.

Acknowledgments & Prior Work

EMA Forge stands on a substantial body of prior work. The ePAT task in particular is not a novel invention but an ecological adaptation of an established psychophysical paradigm. Proper attribution here matters both scholarly and practically — if you publish using these modules, these are the citations your Methods section owes.

The Phase Adjustment Task (PAT) lineage

The core psychophysical logic of the ePAT — aligning an auditory tone to felt heartbeat via a continuous-adjustment response — is adapted from the Phase Adjustment Task developed by Plans, Ponzo, Morelli, Cairo, Ring, Keating, Cunningham, Catmur, Murphy & Bird, with subsequent refinements in PAT 2.0.

  • Plans, D., Ponzo, S., Morelli, D., Cairo, M., Ring, C., Keating, C. T., Cunningham, A. C., Catmur, C., Murphy, J., & Bird, G. (2021). Measuring interoception: The phase adjustment task. Biological Psychology, 165, 108171.
  • Palmer, C., Murphy, J., Bird, G., et al. (2025). Refinements of the Phase Adjustment Task (PAT 2.0). Preprint. doi:10.31219/osf.io/4qtwv.
  • Original reference implementation (Swift/iOS): huma-engineering/Phase-Adjustment-Task.

The ePAT's contribution is specifically the ecological framing: porting the paradigm to a participant-owned smartphone, using camera PPG rather than a dedicated pulse oximeter, scheduling it within an EMA protocol rather than as a discrete lab session, and treating the resulting phase-offset trajectory as a time-varying individual-difference signal rather than a single-point measurement.

Beat detection — WABP

The PPG peak-detection routine is a JavaScript port of the WABP (Waveform Analysis for Blood Pressure) algorithm, originally developed for arterial blood pressure onset detection and released on PhysioNet. The algorithm generalizes well from arterial pressure to PPG because both signals share the characteristic upstroke-dominant morphology the algorithm was designed around.

  • Zong, W., Heldt, T., Moody, G. B., & Mark, R. G. (2003). An open-source algorithm to detect onset of arterial blood pressure pulses. Computers in Cardiology, 30, 259–262.
  • Reference C implementation: PhysioNet wabp.c.

Camera-based PPG acquisition

The browser-side camera acquisition approach — sampling the red channel of a rear-camera video stream under torch illumination — was informed by Richard Moore's open-source demonstrator, which established the feasibility of the pattern in a pure web environment.

Conceptual framework

The decision to operationalize interoceptive accuracy as an ecological, time-varying construct — and to embed it within a broader affective-science EMA protocol — is grounded in the Theory of Constructed Emotion and contemporary work on interoceptive predictive processing. The ePAT is one instrument within that larger program; it is not itself a complete theory.

If you use the ePAT in a published study, please cite the PAT and PAT 2.0 papers alongside EMA Forge. The ePAT is an implementation and ecological adaptation, not an independent paradigm — its validity inherits from that lineage.

EF
EMA Forge · Schema v1.5.0 · MIT License
Maintained by Keegan Whitacre, OSU Affective Science Lab.