Facial scans
ctx.FACIAL_SCAN is the face scan storage for one run. A run has at most one scan session. Create it, wait for the person to finish, then read the results or download the PDF.
When any file in the project calls ctx.FACIAL_SCAN.create, the session is made as soon as the run is triggered, and the trigger response includes its scanUrl. create then sets it up.
Create
await ctx.FACIAL_SCAN.create({
user,
metadata: { name: "Thandi", phoneNumber: "+27821234567" },
publicMetadata: ["name"],
});| Field | Type | What it means |
|---|---|---|
user | User | Optional. From ctx.USER.getUser. The scan is added to that user’s scans once it completes. |
metadata | Record<string, unknown> | Saved on the session. Server-only by default. |
publicMetadata | string[] | Keys of metadata safe to show on the results page. |
steps | { consent?: boolean; intake?: boolean | { fields?: IntakeField[] }; results?: boolean } | Turn off the consent notice, the intake form, or the results page, or limit the intake form to some fields. All on by default. |
redirectUrl | string | Where to send the person when results is off. An http or https URL. |
averages | Partial<Record<"hrv" | "heart_rate" | "stress" | "breathing" | "blood_pressure" | "bmi" | "smoking" | "diabetes", number>> | Override the Wellness Score weights for this scan, as decimals. bmi: 0.05 is 5%. |
status | "ready" | "limit_reached" | ... | Defaults to "ready". "limit_reached" shows a limit message instead of the scanner. |
IntakeField is "age", "gender", "heightCm", "weightKg", "smoker", "diabetes", or "hypertensionTreatment".
ctx.FACIAL_SCAN.scanSessionId holds the session id once it exists.
Send the link yourself
const url = await ctx.FACIAL_SCAN.scanUrl();
await ctx.SMS.send({ to: phoneNumber, content: `Your 30 second scan: ${url}` });scanUrl() is the scan page for this session, the same link the trigger response reports. Send it on the channel you choose: an SMS link opens in the phone’s own browser and runs the scan first time, while the same link sent on WhatsApp opens inside WhatsApp, where the scan engine cannot start and the person has to be walked through opening it in Chrome or Safari. Use WHATSAPP.send or EMAIL.send when those are the right channel, and keep the URL so a nudge or a resend does not need a new session.
Pass user whenever the loop should remember whose scan it was. A scan created without it is not attached to anyone: user.getScanCount(), user.getScans() and user.getLatestScan() never include it, so a one-scan-per-person check reads zero forever and a member’s scan history stays empty. The run log records a warning when a scan is created without a user right after ctx.USER.getUser().
Intake
If you collect age and gender yourself, turn off the intake form and save the answers.
await ctx.FACIAL_SCAN.create({ steps: { intake: false } });
await ctx.FACIAL_SCAN.setIntake({ age: 46, gender: "female" });Or keep the form and ask only for what you need:
await ctx.FACIAL_SCAN.create({
steps: { intake: { fields: ["age", "gender", "smoker"] } },
});Wait and read
step.waitForScan pauses the run until the scan finishes or the timeout passes. It generates the summary and the PDF for you, then returns a Scan with id, results, pdf(), and pdfUrl(). Pass the shape of the results you read:
type ScanVitals = {
wellness_score?: number;
heart_rate_bpm?: number;
breathing_rate_bpm?: number;
systolic_blood_pressure_mmhg?: number;
diastolic_blood_pressure_mmhg?: number;
stress_index?: number;
height_cm?: number;
weight_kg?: number;
};
const scan = await step.waitForScan<ScanVitals>({ timeout: "30 minutes" });A timeout marks the session timed out and throws. Inside a step, read the stored results directly:
const { status, results } = await ctx.FACIAL_SCAN.getResults();When the scan doesn’t happen
Roughly two in five scan links are never measured, and the reason matters: a link nobody opened needs a nudge, a browser that cannot run the engine needs a different browser. step.waitForScan throws a named error so the loop can tell them apart instead of the run just dying:
import { scanNotTaken } from "@loops/sdk";
const scan = await step.waitForScan<ScanVitals>({ timeout: "10 minutes" }).catch(
(error) => scanNotTaken(error),
);
if (scan && "reason" in scan) {
return { outcome: "scan_not_taken", reason: scan.reason, stage: scan.stage };
}reason is one of:
| reason | what happened | what helps |
|---|---|---|
not_opened | the link was never opened before the wait ended | send the link again, at a time they said they are free |
browser_blocked | the browser that opened it cannot run the scan engine | ask them to open the link in Chrome or Safari, not inside WhatsApp |
engine_not_loaded | the page opened but the engine never loaded | a fresh link on a better connection |
measurement_stalled | the engine loaded but the measurement never started | ask them to sit still in good light and try once more |
results_not_saved | the measurement finished but the results never reached the run | nothing to redo: the next run finds the results |
measurement_rejected | the numbers were not physically possible for a body | a fresh scan, not a repeat of the same one |
stage, blocker and sessionId come back with it. The same fields land in the run’s wait-for-scan step, so the run timeline and the loop’s own events show why a scan did not happen. A scan whose results landed after its event was lost comes back as a normal Scan, not an error.
Download the PDF
const pdf = await ctx.FACIAL_SCAN.getPdf();
const url = await ctx.FACIAL_SCAN.pdfUrl();getPdf returns the bytes as a Uint8Array. pdfUrl returns a public link, for channels that fetch a hosted file, such as WhatsApp media or email.
Regenerate
await ctx.FACIAL_SCAN.generateSummary();
await ctx.FACIAL_SCAN.generatePdf();Call these to rebuild the summary or the PDF against fresh results. ctx.FACIAL_SCAN.timeout() marks the session timed out yourself.