Observers
Runtime focus, clipboard, keyboard, screen-share, webcam, and face signals.
On this page
Observers are runtime monitors that emit integrity events while the proctored assessment is active. Keep observer policy aligned with the assessment's saved configuration.
The raw SDK defaults browser-only DOM observers on (focus, visibility, fullscreen, network, pointer, clipboard metadata, and idle). Permissioned media observers such as screen share and webcam are opt-in. Each observer emits typed events to your dashboard.
Focus and visibility
Emits: focus.lost, focus.gained, tab.hidden, tab.visible, fullscreen.entered, fullscreen.exited.
Detects when the candidate switches windows, hides the tab, or leaves fullscreen. Always on — no permissions needed. The dashboard uses these events as review signals rather than automatic evidence of misconduct.
observers: {
focus: true, // window focus/blur (focus.lost / focus.gained)
// visibility and pointer are independent boolean toggles:
visibility: true, // page-visibility (tab.hidden / tab.visible)
pointer: true, // pointer-leave (pointer.left-window / pointer.returned)
}focus, visibility, and pointer are each plain booleans (all default on) — there is no nested options object.
Clipboard
Emits: clipboard.copy, clipboard.cut, clipboard.paste, contextmenu.opened.
Captures clipboard actions. Set captureContent: true to record the actual text (with consent only). Add data-proctoring-allow-clipboard (on the element or any ancestor) to fields that should opt back in to paste — useful for letting candidates paste into their own answer textarea while paste stays blocked everywhere else. The attribute exempts paste only; copy and cut blocking is global when enabled.
observers: {
clipboard: {
captureContent: true,
block: true, // disable copy/cut/paste/contextmenu
maxBytes: 4_000, // truncate captured content
},
}captureContent is privacy-sensitive. Surface the policy in your test runner's UI before the candidate starts. The default customer-safe posture is event metadata without content.
Keyboard shortcuts
Emits: keyboard.blocked, screenshot.attempted.
Blocks common print, save, find, devtools, and view-source shortcuts at the JS layer, and emits a keyboard.blocked event. Screenshot attempts (PrtSc on Windows, Cmd-Shift-3/4 on macOS) emit a dedicated event when detectable. Treat events as review signals and show them chronologically in the timeline.
observers: {
keyboard: true,
screenshot: { blurOnSuspicion: true },
}When the screenshot observer is enabled, ProctorKit also attempts to attach
image evidence to screenshot.attempted, clipboard.copy,
clipboard.cut, clipboard.paste, contextmenu.opened, focus.lost, and
tab.hidden. An active screen-share stream is captured first. If screen share
is unavailable, the SDK makes a best-effort capture of the visible assessment
page, removes active media/embedded content, and masks password inputs.
Browser security does not let that fallback see another tab, another
application, or the operating-system desktop. The dashboard labels it Page
capture (estimated) rather than presenting it as a true desktop screenshot.
The image is linked by the event's stable browser-generated id. Its upload
retries independently from event batching, and a clean client.end() gives
in-flight captures a bounded drain period.
Screen share
Emits: screen-share.started, screen-share.recording.started, screen-share.stopped, screen-share.declined, screen-share.wrong-surface, screen-share.chunk-uploaded.
Asks the candidate for a screen share at session start. Raw-SDK integrations should call requestScreenShare() directly from the candidate's action and pass the returned stream through observers.screenShare.stream; this prevents worker or network startup from consuming the browser's transient user activation. If the candidate picks "Window" or "Tab" instead of "Entire screen", the SDK rejects it and emits screen-share.wrong-surface. Recording is chunked — default 10s — so a network blip never corrupts the whole session.
onChunkReady(chunkNumber, blob, timing) receives capture-clock metadata with recordingStartedAt, capturedStartAt, and capturedEndAt epoch milliseconds. These values describe when the media was produced; they are preserved through retries and are intentionally separate from server upload time.
In the Vue wrapper, screen-share permission is handled on its own preflight step instead of the attestation-only acknowledgement step. That step shows the sharing instructions. After a valid entire-screen share is active, the final screen enables Resume test; that click enters fullscreen, starts MediaRecorder, and only then renders the assessment slot. If the candidate stops sharing mid-test, the wrapper locks the assessment behind a blurred modal and calls client.restartScreenShare() from the recovery button so the same session continues.
Browser APIs require a direct user gesture, so start screen sharing from a candidate click, not from an effect or timer.
observers: {
screenShare: {
enforceEntireScreen: true,
timesliceMs: 10_000, // 10s chunks
},
}Webcam
Emits: webcam.started, webcam.stopped, webcam.photo.captured, webcam.recording.chunk-uploaded.
Two modes: random snapshots (lightweight, ~30 KB each) or continuous recording (~225 MB/hour). The dashboard shows the snapshot grid by default and the stitched video on demand. Upload cadence should be bounded to control bandwidth, storage, and review burden.
photos.onPhotoReady(photoNumber, blob, timing) receives timing.capturedAt, stamped immediately before the video frame is drawn. recording.onChunkReady receives the same recording timing shape documented for screen share. Existing two-argument callbacks remain compatible.
observers: {
webcam: {
photos: { minIntervalSeconds: 20, maxIntervalSeconds: 60 },
recording: { timesliceMs: 10_000 },
},
}Recording durability (surviving a refresh)
Applies to screen-share and webcam recordings on hosted (S3) deployments.
Recordings upload to S3 as a multipart object. S3 requires every part except the last to be at least 5 MB, so the SDK buffers MediaRecorder output until it fills a part, then uploads it. That buffer used to live only in memory: a page refresh or crash discarded the un-flushed tail — and for a short or low-bitrate window (5 MB is ~80 s at 500 kbps) that tail was the entire pre-refresh recording. The resumed session then started a fresh recording that silently omitted it.
The SDK now persists un-uploaded recording bytes to IndexedDB (the same durability the event queue has). On resume it restores the un-uploaded tail and continues the same multipart, so the pre-refresh recording is preserved with no duplicate bytes. It's on by default — no configuration needed.
new ProctoringClient({
// …
durableRecordingBuffer: true, // default. false = legacy in-memory-only buffer.
});
// <ProctoredAssessment> inherits this; no separate prop.Two telemetry events surface buffer health for your dashboard: sdk.recording.buffer-degraded (IndexedDB unusable — see limitations) and sdk.recording.buffer-evicted (a cap dropped bytes under sustained failure).
Limitations — what this does and does not guarantee:
- Recovery is on the next load. Bytes are restored when the candidate's page comes back and the recording resumes. If they never return, the tail is swept, not uploaded — an abandoned session keeps only what already reached S3.
- It needs the same attempt. Recovery continues the open multipart for
(session, kind, attempt). A resume that starts a new attempt (e.g. a different device) begins a new recording object. - A resumed recording is two segments in one file. The pre-refresh stream and the fresh post-refresh MediaRecorder stream each carry their own WebM header; concatenated, some players seek only within the first segment. Playback of the whole thing works; frame-accurate scrubbing across the seam may not.
- Graceful degradation, not a guarantee. In private mode, where IndexedDB is denied, or under a sustained upload failure that exhausts the browser storage quota, the buffer falls back to in-memory-only (the previous behaviour) and emits
sdk.recording.buffer-degraded. A hard crash in that state can still lose the un-flushed tail. - No effect on the local-dev (POST) path, which already persists each chunk to the server as it's produced.
Face detection
Emits: face.lost, face.returned, face.multiple, face.multiple-cleared, face.gaze-off-screen, face.gaze-restored, face.identity-mismatch, face.detector-ready, face.detector-failed.
Face checks are handled through the preflight deep-camera check and post-session media analysis. In the policy layer, any active webcam.mode enables webcam.faceAnalysis; lost face, multiple faces, gaze, and identity mismatch default to on. The browser SDK does not expose a separate runtime face observer option.
engineOptions: {
media: { camera: true },
deepCamera: { detectFace },
}
// During-test webcam evidence is compiled from webcam.mode:
observers: {
webcam: { photos: true },
}Runtime checkpoint
A checkpoint re-verifies the live environment against the state that passed preflight, and reports what has drifted. It is detect-and-emit: it never pauses, locks, or fails the session — you decide what to do with the result.
It runs two tiers of checks:
Tier 1 — configuration drift (cheap, always runs)
permission-lost— a microphone/camera permission that was granted at preflight is now denieddevice-unavailable— the microphone/camera/speaker the candidate selected is no longer connected (unplugged, bluetooth dropped, USB pulled)external-monitor-connected— preflight passed on a single display; a second monitor has since appeared
Tier 2 — capture liveness (reads the streams the session already holds — no new prompt, no second camera light; skipped when liveness: false)
camera-track-ended/camera-black/camera-frozen— the webcam track ended, the frame is all-black (covered lens / virtual feed), or the browser stopped presenting decoded frames (frozen feed)mic-track-ended/mic-muted— the audio track ended, or the browser marked it muted (device unplugged, OS mute, grabbed by another app)
Camera freeze detection follows browser-owned decoded-frame metadata, not pixel movement. A motionless candidate or an unchanging background therefore stays healthy as long as the camera continues delivering frames. The camera probe has a shared 1.5-second start-and-advance budget; when the browser cannot expose frame-presentation callbacks or the first usable frame cannot be established, the verdict is unknown, never a manufactured freeze failure.
Mic liveness is track-state only — it reads the browser's
readyState/mutedflags, which are loudness-independent. A candidate sitting silently in a quiet room always reads healthy; the checkpoint never waits for or requires sound.Mic liveness needs a live audio track. The webcam observer is video-only, so by default the only live audio is an in-flight clip recording — mic liveness runs during those windows (piggybacked, no extra capture) and is otherwise reported as unavailable, not a failure. For continuous coverage, set
checkpoint.micProbe: true: when no live audio track exists and the mic permission is already granted, a checkpoint briefly acquires the mic, reads its state, and releases it (a momentary mic-indicator flicker; never prompts). Off by default.Speaker has no liveness check — whether audio is actually audible can't be verified passively (it needs a replayed tone + candidate confirmation, i.e. a preflight-style check). The checkpoint covers the speaker by presence + the mic-permission it depends on, not liveness.
Each browser check degrades gracefully — Firefox has no permissions.query for mic/camera and no screen.isExtended, so those return unknown and simply contribute no drift, never a false failure.
What's checked follows the policy. Permission tracking is scoped to what the session depends on: camera permission is tracked when the camera is captured; microphone permission when the mic is captured or a speaker is used (browsers require a mic grant to enumerate/route audio outputs, so a lost mic permission breaks the speaker too). A policy that uses neither — e.g. camera-only — never tracks the mic permission, so it can't false-flag one it doesn't need. Device-presence checks likewise skip a device kind the browser can't enumerate (ids censored without a media-input grant, or Safari never listing audiooutput): an unreadable list is treated as unknown, not as "the device was unplugged."
Call it manually
client.checkpoint() is read-only and idempotent — call it as often as you like (a button, on tab-return, before submit). In a Vue app you don't have to thread the client around: useProctoringClient() from @a4anthony/proctorkit-vue returns the active client as a ref.
import { useProctoringClient } from "@a4anthony/proctorkit-vue";
const { client } = useProctoringClient(); // client: ShallowRef<ProctoringClient | null>
const result = await client.value?.checkpoint(); // full check
// const result = await client.value?.checkpoint({ liveness: false }); // Tier 1 only
if (result && !result.ok) {
onCheckpointDrift(result.changes); // your handler
}Gating a manual run through the integrity hold. By default a manual checkpoint() is detect-only — it returns the result and does nothing else, even if you also configured onDrift for scheduled runs. Pass { notifyDrift: true } to route a failing manual run through your configured onDrift too, so a checkpoint you fire yourself (e.g. at a section boundary, or before submit) raises the <ProctoredAssessment> integrity hold exactly like a scheduled one:
// Fails → onDrift fires → the wrapper raises the hold + shows the recovery panel.
const result = await client.value?.checkpoint({ notifyDrift: true });Action only ever happens on failure: a passing notifyDrift run fires nothing (it never clears a standing hold), and onCheckpoint — the scheduled auto-recovery hook — is never called on a manual run regardless of the flag. Combine with { liveness: false } as usual for a Tier-1-only gated check.
What result.changes contains. Each entry is a CheckpointChange (import the type from @a4anthony/proctorkit-sdk):
interface CheckpointChange {
tier: 1 | 2;
kind:
| "permission-lost"
| "device-unavailable"
| "external-monitor-connected" // Tier 1
| "camera-track-ended"
| "camera-black"
| "camera-frozen" // Tier 2
| "mic-track-ended"
| "mic-muted"; // Tier 2
device?: "microphone" | "camera" | "speaker"; // absent on external-monitor-connected
label?: string
confidence
Sample result.changes values:
[] // ok: true — nothing drifted
[{ tier: 1, kind: "permission-lost", device: "camera", confidence: "hard" }]
[{ tier: 1, kind: "device-unavailable", device: "microphone", label: "AirPods Pro", confidence: "hard" }]
tier kind confidence
tier kind device confidence
tier kind device confidence
tier kind device label confidence
tier kind confidence
tier kind device confidence
A handler that branches on kind (note device is optional and label is only on device-unavailable):
import type { CheckpointChange } from "@a4anthony/proctorkit-sdk";
function onCheckpointDrift(changes: CheckpointChange[]) {
for (const c of changes) {
switch (c.kind) {
case "camera-black":
case "camera-frozen":
$clabel
Or let the SDK schedule it
Pass a checkpoint config and the SDK owns the triggers, callbacks, and their teardown:
new ProctoringClient({
// …
checkpoint: {
liveness: true, // Tier 2 on (default). false = Tier 1 only.
micProbe: false, // opt-in continuous mic liveness (see note above)
on: ["visibility", "interval", "devicechange"], // tab-return + heartbeat + device add/remove
intervalMs: 60_000,
onCheckpoint: (result) => audit(result), // every run
onDrift: (result) => lockAssessmentresultchanges
The triggers in on:
"visibility"— re-check when the tab is refocused."interval"— a heartbeat everyintervalMs(default 60s)."devicechange"— re-check when a media device is added or removed. This is the event-driven catch for a microphone or speaker (e.g. a Bluetooth headset) disconnecting mid-exam: those have no continuous media track, so nothing else fires when they drop. The SDK debounces the burst ofdevicechangeevents a single plug/unplug produces.
Omit checkpoint and the feature is dormant except that client.checkpoint() still works on demand. Every run — manual or scheduled — also emits one session.checkpoint event as an audit record.
Recovering a dropped device
When capture drops mid-session, the underlying MediaStreamTrack ends and stays dead — the device coming back does not revive it. Re-acquire with a fresh call:
client.restartScreenShare()— re-open the screen-share picker (needs a user gesture — wire it to a button).client.restartWebcam()— re-acquire the camera in place, no new attempt and nosession.ended. Returnstruewhen the camera is live again,falsewhen it's still gone. (The mic/speaker need no restart — they're acquired on demand for clips and re-apply automatically.)
Mid-session integrity gate
A dropped camera is obvious — the video freezes. A dropped microphone or speaker is silent: a Bluetooth headset disconnecting mid-exam has no live media track, so nothing fires, and the candidate keeps going with no audio capture. The integrity gate closes that window. <ProctoredAssessment> runs a checkpoint whenever a device is added or removed (it always enables the devicechange trigger); if that checkpoint fails, it raises a hold and blocks the exam until the environment is fixed.
You get this for free — no config. What you optionally wire in is pausing your own exam timer for the duration of the hold.
What a hold does
When a mid-session checkpoint fails, the wrapper:
- Hands the candidate to the resume wizard — the same screen, probe, and device pickers a mid-assessment page refresh uses. The checkpoint only detects drift; the resume wizard recovers it. There is deliberately no second recovery path, so a fix that works after a refresh works here identically.
- Blocks locally, instantly. The gate is up the moment drift is detected, independent of any network call — so a flaky connection can never leave a candidate un-gated.
- Recovers the same way a refresh does. The candidate reconnects the device, re-grants the permission, unplugs the monitor — or picks a different device from the row's dropdown — and each row re-verifies against a fresh probe. When everything passes, the candidate resumes and the hold lifts.
What raises a hold is exactly what the checkpoint reports: a disconnected camera / microphone / speaker, a revoked permission, a newly-connected external monitor, or a dead / black / frozen camera.
A hold is raised by any SDK-scheduled checkpoint that fails (always the devicechange trigger, plus any others you enable), and by a manual client.checkpoint({ notifyDrift: true }) that fails — see Call it manually. A plain manual checkpoint() (no flag) is detect-only and never raises the hold, so you can inspect the environment without gating the candidate.
The pause/resume contract
Two optional props let you pause and resume your own exam timer around the hold. They mirror submitCandidateIntake: async functions you supply, which the wrapper awaits — so they can make an API call to freeze the clock on your backend.
import type { IntegrityHoldInfo, IntegrityClearInfo } from "@a4anthony/proctorkit-vue";
// info.holdId — stable id; the SAME value arrives on the matching clear
// info.reason — why the gate went up (e.g. "device-drift")
// info.changes — the raw CheckpointChange[] that failed, if you want to branch
// info.resolvedMs — (clear only) how long the hold was openFour rules the wrapper enforces so an async, failable API call is safe here:
- Local enforcement, remote bookkeeping. The candidate-facing gate is already up before
onIntegrityHoldruns. Your call only pauses your timer — a slow or failed request never affects whether the candidate is blocked. - Serialized. A candidate can reconnect in two seconds, before your pause request has even returned. The wrapper waits for
onIntegrityHoldto settle before it callsonIntegrityCleared, so a resume can never land ahead of its pause. - Correlated. Both calls carry the same
holdId— use it as the idempotency key to reconcile the pause and resume server-side. - Fail-safe held. If either callback rejects, the candidate stays held and the gate offers a retry. The wrapper never auto-resumes into an exam your server thinks is paused.
The one gotcha, same as intake: fetch() does not throw on a 4xx / 5xx — it resolves with res.ok === false. Throw on a failed pause so the fail-safe engages:
async function pauseExamTimer(info: IntegrityHoldInfo) {
const res = await fetch(`/your-backend/attempts/${attemptId}/pause`, {
method: "POST",
body: JSON.stringify({ holdId: info.holdId, reason: info.reason }),
});
if (!res.ok) throw new Error
info
res
method
body holdId infoholdId
resok
You do not manage the gate UI, the re-check, or the recovery — the wrapper owns all of it. You own only what happens to your timer and records.
Wiring it up
<ProctoredAssessment
app-id="pk_live_xxx"
:correlation-id="attemptId"
:candidate="candidate"
preset="standard"
:on-integrity-hold="pauseExamTimer" <!-- awaited — freeze your clock -->
:on-integrity-cleared="resumeExamTimer" <!-- awaited — resume it -->
@integrity-hold="({ holdId, changes }) => track('hold', holdId, changes)"
@integrity-cleared="({ holdId }) => track('clear', holdId)"
>
<template #default
your exam
template
The @integrity-hold / @integrity-cleared events are fire-and-forget — for logging and analytics that don't need to be awaited. The awaited action belongs on the props above.
Every hold is also recorded on the session timeline as a session.integrity-hold / session.integrity-cleared pair (correlated by holdId), so a reviewer sees exactly when and why the exam was paused.