Facial scan
The facial scan tool (ctx.FACIAL_SCAN) is a built-in tool available inside any loop. It handles the full lifecycle of one scan per run: provisioning a session at trigger time, exposing a URL the scanner can open, and returning the scan results to your loop once they’re ready.
This page covers the lifecycle model, why the platform creates a scan session for you up front, and the full tool API.
The scan session
Every facial scan is represented by a scan session — a single record that everything in the lifecycle attaches to: the URL the scanner opens, the scan token, the results, the timeout state.
| Field | Description |
|---|---|
scanSessionId | The session’s identifier. Returned in the trigger response and used internally to thread state across the scan’s lifecycle. |
status | Where the session is in its lifecycle. See the states table below. |
metadata | Whatever you pass to FACIAL_SCAN.create is stored on the session and echoed back when results arrive — use it for per-run context. |
results | The scan output, populated once status is completed. |
Lifecycle states
| Status | When |
|---|---|
pending | The session was created at trigger time. The scanner can open the URL but can’t start scanning yet. |
ready | Your loop called FACIAL_SCAN.create. A scan token is attached and the scanner can begin. |
incompatible | The scanner’s browser failed the capability check, or the scan engine couldn’t start. Not terminal — the session flips back to ready when the link is opened in a capable browser. See When a browser can’t run the scan. |
completed | The scan finished. Results are available; an in-flight step.waitForScan resolves with them. |
failed | The scan failed mid-flow. |
timeout | The step.waitForScan timeout fired before the scan completed. The session is closed and results are empty. |
limit_reached | The loop set this status (via create({ status: "limit_reached" })) because the user hit a scan cap — e.g. after checking user.getScanCount(). The scanner shows a limit message instead of the camera. |
Why the session is provisioned at trigger time
The trigger response carries a scanUrl for the scanner to open — the consent screen and scan UI:
curl -X POST https://api.ollie.health/loop/$ORG_ID/$LOOP_ID \
-H "Authorization: Bearer $LOOPS_API_TOKEN" \
-d '{ "patientId": "abc" }'
# → {
# "runId": "run_…",
# "scanSessionId": "scan_…",
# "scanUrl": "https://scan.ollie.health/<orgId>/<loopId>/<scanSessionId>"
# }The caller usually wants that URL immediately — to text it to a patient, redirect a kiosk, hand it to a clinician. If the platform waited until the loop’s run() reached FACIAL_SCAN.create before generating a session, the caller would have to poll for the URL after triggering — clunky and slow.
So the platform short-circuits that: at trigger time, it inspects the loop’s deployed code, detects FACIAL_SCAN.create, and provisions a session row in pending state before run() is invoked. The scanSessionId is minted at that point and threaded into the workflow as a parameter, so by the time your loop calls ctx.FACIAL_SCAN.create, the session already exists. That call moves the session from pending to ready and attaches the scan token — it doesn’t create new state.
This is why the trigger response can return both the runId and the scanUrl in the same payload: the session is real, even though the loop hasn’t started running yet.
Tool API
ctx.FACIAL_SCAN exposes three methods. All of them must be called inside a step.do callback — ctx only exists there. See Step context for the surrounding API.
type IntakeField =
| "age"
| "gender"
| "heightCm"
| "weightKg"
| "smoker"
| "diabetes"
| "hypertensionTreatment";
type FacialScanStepsConfig = {
consent?: boolean;
intake?: boolean | { fields?: IntakeField[] };
};
type WellnessScoreAverages = Partial<
Record<
| "hrv"
| "heart_rate"
| "stress"
| "breathing"
| "blood_pressure"
| "bmi"
| "smoking"
| "diabetes",
number
>
>;
type FacialScanTool = {
/** The provisioned scan session id for this run, or null if create() was never called. */
scanSessionId: string | null;
create(input: {
expiresIn?: number;
singleDevice?: boolean;
metadata?: Record<string, unknown>;
publicMetadata?: string[];
steps?: FacialScanStepsConfig;
/** Override Wellness Score calculation averages for this scan. */
averages?: WellnessScoreAverages;
/** Assign this scan to a tracked user (from ctx.USER.getUser). */
user?: User;
/** Override lifecycle status — e.g. "limit_reached" to block the scanner. */
status?: "ready" | "timeout" | "completed" | "failed" | "limit_reached";
}): Promise<void>;
setIntake(intake: Record<string, unknown>): Promise<void>;
timeout(): Promise<void>;
getResults(): Promise<{
status:
| "pending"
| "ready"
| "incompatible"
| "timeout"
| "completed"
| "failed"
| "limit_reached";
results: unknown;
}>;
/** Generate the AI health summary and store it on the session. waitForScan calls this for you. */
generateSummary(): Promise<void>;
/** Render the results page to a PDF and upload it. waitForScan calls this for you. */
generatePdf(): Promise<void>;
getPdf(): Promise<Uint8Array>;
pdfUrl(): Promise<string>;
};| Method | Description |
|---|---|
create | Move the pre-provisioned session from pending to ready and attach a scan token. Pass metadata to carry per-run context; it’s echoed back in results. |
setIntake | Write intake data onto the session yourself — use when you’ve collected demographics elsewhere and skipped the hosted intake form with steps: { intake: false }. |
timeout | Mark the session as timeout. step.waitForScan calls this automatically when its own timeout fires; you rarely need to call it directly. |
getResults | Read the session’s current state without waiting. Most loops prefer step.waitForScan, which suspends until the scan completes. |
generateSummary | Generate the AI-authored health summary for the current scan. step.waitForScan calls this internally; use directly to regenerate against fresh results. |
generatePdf | Render the results page to a PDF and upload it. step.waitForScan calls this internally; use directly to regenerate a stale PDF. |
getPdf / pdfUrl | Fetch the generated PDF bytes or a public download URL. step.waitForScan resolves with a Scan handle that exposes these via scan.pdf() / scan.pdfUrl(). |
create
| Option | Type | Default | Description |
|---|---|---|---|
expiresIn | number | 3600 | Seconds the scan token is valid for. |
singleDevice | boolean | true | Whether the scan must be completed on the same device that opened the URL. |
metadata | Record<string, unknown> | {} | Arbitrary JSON stored on the session and returned to your loop with the results. Server-only by default — see publicMetadata. |
publicMetadata | string[] | [] | Keys of metadata to expose on the (unauthenticated) results page the scanner sees. Everything else stays server-side. |
steps | FacialScanStepsConfig | both consent + intake on | Which scanner-facing steps to show before the scan. See below. |
averages | WellnessScoreAverages | platform defaults | Optional per-scan Wellness Score calculation weights. Omit a component to keep the default for that component. |
Exposing metadata to the results page
metadata is stored on the session and echoed back to your loop, but it never reaches the browser — the results page the scanner opens is unauthenticated, so a phone number or patient ID you stash there stays server-side. List the keys that are safe to show with publicMetadata:
await ctx.FACIAL_SCAN.create({
metadata: { phoneNumber, name },
publicMetadata: ["name"], // only `name` reaches the results page
});The results page then receives { name } and nothing else; phoneNumber is omitted.
Configuring steps
The scanner walks through a short sequence — by default, a consent notice, a quick demographics form, then the scan itself, and a confirmation page. Use steps to skip stages or trim the intake form to just the fields you need.
| Step | Configurable as | Default |
|---|---|---|
consent | consent: boolean | true |
intake | intake: boolean | { fields?: IntakeField[] } | true (all fields) |
| Scan | always shown | — |
| Success | always shown | — |
// Skip the intake form entirely
await ctx.FACIAL_SCAN.create({
steps: { intake: false },
});
// Keep intake but only ask for age and gender
await ctx.FACIAL_SCAN.create({
steps: { intake: { fields: ["age", "gender"] } },
});
// Skip both prefatory steps — scan opens immediately
await ctx.FACIAL_SCAN.create({
steps: { consent: false, intake: false },
});Available intake fields: age, gender, heightCm, weightKg, smoker, diabetes, hypertensionTreatment. The values the scanner enters land in the session’s intake object alongside the scan results.
Adjusting Wellness Score averages
By default, every completed facial scan uses the platform Wellness Score averages. If a loop needs a different weighting model, pass averages to FACIAL_SCAN.create. Values are decimal weights, so 0.05 means 5%. You only need to include the components you want to change; omitted components keep the platform default.
await ctx.FACIAL_SCAN.create({
averages: {
smoking: 0.1,
bmi: 0.03,
},
});The score is still calculated by the completion API when the scan finishes. Completed results include:
| Result field | Description |
|---|---|
wellness_score | The rounded 0-100 Wellness Score. |
wellness_score_version | The calculation version used for audit history. |
wellness_score_components | Per-component scores with configured and effective weights. |
wellness_score_calculation | Arithmetic audit payload: weighted sum, weighted average, missingComponents, and contribution details. |
missingComponents lists inputs excluded because no valid value was available. Missing components are not treated as zero; the available components are normalized across the remaining configured weight.
Assign a scan to a user
Pass the User handle from ctx.USER.getUser to link the scan to a tracked user. Once the scan completes, it’s added to that user’s cross-loop scan index — so user.getLatestScan() and user.getScans() can find it later:
await step.do("Provision scan", async ({ ctx }) => {
const user = await ctx.USER.getUser({ id: event.body.patientId });
await ctx.FACIAL_SCAN.create({
metadata: { patientId: event.body.patientId },
user,
});
});Use status: "limit_reached" when user.getScanCount() exceeds your cap — the scanner shows a limit message instead of the camera:
const user = await ctx.USER.getUser({ id: patientId });
const count = await user.getScanCount();
await ctx.FACIAL_SCAN.create({
user,
status: count >= 3 ? "limit_reached" : "ready",
});setIntake
When you collect demographics on your own surface (an intake microsite, a clinic EHR, a triage chatbot) you typically pass steps: { intake: false } to create so the hosted scanner skips its form. setIntake is how you write that data onto the session yourself. Whatever object you pass is stored verbatim on the session’s intake column and echoed back with the results, alongside any intake fields the scanner did collect.
await step.do("Provision scan", async ({ ctx }) => {
await ctx.FACIAL_SCAN.create({
steps: { consent: false, intake: false },
metadata: { patientId },
});
await ctx.FACIAL_SCAN.setIntake({
age: event.body.age,
gender: event.body.gender,
heightCm: event.body.heightCm,
weightKg: event.body.weightKg,
});
});You must pass a non-empty object. Calling setIntake(undefined) or setIntake({}) throws No values to set — there’s nothing to write. The common cause is destructuring a field off the event body that the trigger payload doesn’t actually carry (TypeScript can’t catch this because event.body is whatever your caller sent). If your trigger sends a nested intake object, declare it on your payload type and verify it’s populated before forwarding:
interface IntakePayload {
patientId: string;
clinicSiteId: string;
intake: { age: number; gender: string; heightCm: number; weightKg: number };
}setIntake can be called before or after create — the session is provisioned at trigger time, so it exists for the entire run() regardless of when create runs.
Wait for the scan to finish
After calling create, suspend the run on step.waitForScan to wait for results. While suspended, the loop isn’t consuming compute.
await step.do("Provision scan", async ({ ctx }) => {
await ctx.FACIAL_SCAN.create({
expiresIn: 3600,
metadata: { patientId, phoneNumber },
});
});
const scan = await step.waitForScan({ timeout: "30 minutes" });When the scanner completes, the run resumes with a Scan handle — scan.results holds the payload, and await scan.pdfUrl() gives you a hosted link to the results PDF. If the timeout fires first, waitForScan throws and the session is marked timeout. See Step context for the full waitForScan reference.
Embed the scan in your own app
The scan URL is a normal web page. Drop it into an <iframe> to embed the consent + scan flow inside your own product — useful when you want to keep the scanner on your own domain instead of opening a new tab.
<iframe
src="https://scan.ollie.health/<orgId>/<loopId>/<scanSessionId>"
allow="camera; fullscreen; cross-origin-isolated"
style="width: 100%; height: 100svh; border: 0;"
></iframe>A few things matter:
-
allow="camera"is mandatory. Cross-origin iframes don’t inherit camera permission from the parent unless you delegate it explicitly. Without this attribute the SDK can’t open the camera and the scan never starts.fullscreenis optional but useful if you want to support a fullscreen toggle. -
allow="cross-origin-isolated"is also mandatory. Cross-origin isolation needs two things in a document: (1) the agent cluster isolated via COOP + COEP, and (2) thecross-origin-isolatedpermission policy enabled in that document. That permission’s default allowlist isself, so a cross-origin iframe doesn’t inherit it — the embedder must delegate it throughallow. Without it, both documents can send perfect COOP/COEP/CORP headers and the host can reportcrossOriginIsolated === true, yetSharedArrayBufferstays disabled inside the iframe and the SDK fails with the “Browser incompatible…” error.allow="camera; fullscreen"delegates the camera but never delegatescross-origin-isolated. -
Size the iframe like a viewport. The scan UI is designed to fill its container — the camera preview covers the canvas. Don’t size the iframe smaller than a comfortable scanning area.
-
The host page must also be cross-origin isolated. The scan SDK needs
SharedArrayBuffer, which the browser only enables when both the iframe and its parent document send the right headers. The scan page sets its ownCross-Origin-Opener-Policy,Cross-Origin-Embedder-Policy, andCross-Origin-Resource-Policy— but if your host page doesn’t, the browser disablesSharedArrayBufferinside the iframe and the scanner sees a “Browser not supported” card:This browser can't run scans because SharedArrayBuffer is disabled. If the scan is embedded in another site, that page also needs Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers.This is the most common cause of “Browser Not Supported” reports from embedded scans — the browser itself is usually fine, and the same link works when opened as a top-level tab (where the scan page’s own headers apply without the host page in the way). The session is marked
incompatibleand the failed check is recorded in your loop’s logs — see When a browser can’t run the scan.On the host page’s HTML response, add:
Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corpHow you set them depends on your stack — middleware in Express, a
headers()entry in Next.js, a_headersfile on Cloudflare Pages / Netlify, avercel.jsonblock on Vercel:// Express / Node — middleware on every HTML response app.use((_req, res, next) => { res.setHeader("Cross-Origin-Opener-Policy", "same-origin"); res.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); next(); });// Next.js — next.config.js module.exports = { async headers() { return [ { source: "/:path*", headers: [ { key: "Cross-Origin-Opener-Policy", value: "same-origin" }, { key: "Cross-Origin-Embedder-Policy", value: "require-corp" }, ], }, ]; }, };// Vercel — vercel.json { "headers": [ { "source": "/(.*)", "headers": [ { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" }, { "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" } ] } ] }If
require-corpbreaks other embeds on the host page (because it requires every cross-origin sub-resource to opt in via CORP/CORS), usecredentiallesson both the header and the iframe instead — it gives youSharedArrayBufferwithout the sub-resource opt-in:Cross-Origin-Embedder-Policy: credentialless<iframe src="https://scan.ollie.health/…" credentialless allow="camera; cross-origin-isolated" ></iframe>The
cross-origin-isolatedpermission delegation is independent ofrequire-corpvscredentialless— you need it on the iframe either way.You can verify isolation from the parent’s DevTools console — both must be
true:crossOriginIsolated; // the host page → true document.querySelector("iframe").contentWindow.crossOriginIsolated; // the iframe → trueIf the host reports
truebut the iframe reportsfalse, the headers are correct but thecross-origin-isolatedpermission isn’t being delegated — add it to the iframe’sallow. -
The iframe is open to any embedder. The scan app sends
Content-Security-Policy: frame-ancestors *, so you can embed it from any domain — including your customers’ apps if you forward the scan URL to them.
When the scan completes, the iframe navigates to its own “all done” page. If your parent app needs to react to completion (close the modal, advance its flow), watch the iframe’s URL or listen for the navigation event.
When a browser can’t run the scan
Before the scan starts, the scan page checks the capabilities the scan engine needs: SharedArrayBuffer, OffscreenCanvas, WebGL2, and WebAssembly SIMD + threads. The result of that check is reported back on every scan — pass or fail — and stored on the session’s metadata under the reserved __browser key: the user agent, platform, and a flag per capability. Since metadata is echoed back with the results, your loop can read it too.
When the check blocks the scan (or the scan engine later fails to load or initialize):
-
The session moves to
incompatible. -
A
warnentry lands in the loop’s event logs, tagged to the run, with the user agent and whichever capabilities were missing:Scan incompatible: browser failed the capability check (missing: sharedArrayBuffer) — Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 …)
So when someone reports “Browser Not Supported”, you don’t have to ask them anything — the loop’s Logs tab already has their browser and the exact check that failed.
Reading the entry:
| What the log shows | Likely cause |
|---|---|
missing: sharedArrayBuffer on an embedded scan | The host page isn’t cross-origin isolated, or the iframe doesn’t delegate cross-origin-isolated. Fix the embed — see the section above. |
missing: wasmSimd / wasmThreads | The browser is genuinely too old (e.g. Safari before 16.4). The user needs Chrome, Firefox, or an updated Safari. |
scan engine failed to load / initialize | The capability check passed but the engine couldn’t start. Older Safari versions missing newer WebAssembly features fall back to a legacy engine automatically; anything logged here got past that fallback too. |
incompatible isn’t terminal. An in-flight step.waitForScan keeps waiting, and the scan link stays valid — when the user opens it in a capable browser, the session flips back to ready and the scan proceeds. The recovery to communicate to users is simply: open the same link in Chrome, Firefox, or up-to-date Safari.
Examples
Peek at scan state without waiting
For flows that want to short-circuit when a scan has already failed, read state directly with getResults inside a step:
const { status } = await step.do("Check scan", async ({ ctx }) => {
return ctx.FACIAL_SCAN.getResults();
});
if (status === "failed") {
return { error: "scan failed before processing" };
}Carry per-run context through the scan
Anything passed as metadata to create is echoed back on completion, so use it for IDs you’ll need on the other side instead of refetching them:
await step.do("Provision scan", async ({ ctx }) => {
await ctx.FACIAL_SCAN.create({
expiresIn: 3600,
metadata: { patientId, phoneNumber, source: event.body.source },
});
});
const scan = await step.waitForScan<{ metadata: { patientId: string } }>({
timeout: "30 minutes",
});
console.log(scan.results.metadata.patientId); // same patientId you put in