Skip to Content
BindingsFacial scans

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"], });
FieldTypeWhat it means
userUserOptional. From ctx.USER.getUser. The scan is added to that user’s scans once it completes.
metadataRecord<string, unknown>Saved on the session. Server-only by default.
publicMetadatastring[]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.
redirectUrlstringWhere to send the person when results is off. An http or https URL.
averagesPartial<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.

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:

reasonwhat happenedwhat helps
not_openedthe link was never opened before the wait endedsend the link again, at a time they said they are free
browser_blockedthe browser that opened it cannot run the scan engineask them to open the link in Chrome or Safari, not inside WhatsApp
engine_not_loadedthe page opened but the engine never loadeda fresh link on a better connection
measurement_stalledthe engine loaded but the measurement never startedask them to sit still in good light and try once more
results_not_savedthe measurement finished but the results never reached the runnothing to redo: the next run finds the results
measurement_rejectedthe numbers were not physically possible for a bodya 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.

Last updated on