Records what you do on a Windows, macOS, or Linux desktop: mouse, keyboard, and
screen, written into one local session where the events and the video frames
share a clock. openadapt-flow
reads that session and compiles it into a replayable program.
If you want to record, compile, and replay a workflow, you want Flow, and this package comes along as an extra. Install it on its own when you need the recorder as a library.
Documentation · Flow · Window capture · Authentication handoffs
pip install "openadapt-flow[capture]"
openadapt-flow record --backend windows --out recording --task "Describe the workflow"
# Do the task, then press Ctrl-C.
openadapt-flow compile recording --out bundle --name my-workflowUse --backend rdp to record inside an RDP client's pixel space. Replay setup
and per-substrate requirements are in the
desktop recording guide.
pip install openadapt-capturecapture record ./my-capture --description "Describe the workflow"
# The ready message prints the session ID.
capture info ./my-capturecapture status, capture stop, capture install-ffmpeg, and the
status_recording / stop_recording Python contract below are on main and
ship in 1.3.0. They are not in the published 1.2.2 wheel, so install from
source until 1.3.0 reaches PyPI:
pip install "openadapt-capture @ git+https://github.com/OpenAdaptAI/openadapt-capture"# From another terminal, against a running capture:
capture status --session-id SESSION_ID
capture stop --session-id SESSION_IDOr drive it from Python:
from openadapt_capture import CaptureSession
with CaptureSession.load("./my-capture") as capture:
for action in capture.actions():
print(action.timestamp, action.type, action.x, action.y)
print(action.structural_observation)
frame = action.screenshotA launcher or an embedded client should use the public contract rather than reading recorder internals:
from openadapt_capture import status_recording, stop_recording
current = status_recording(session_id)
completed = stop_recording(session_id, timeout=60)
assert completed.complete and completed.integrity_verifiedA finished capture looks like this:
my-capture/
├── authentication-handoffs.json
├── capture-state.json
├── recording.db
├── oa_recording-*.mp4
└── profiling.json
capture stop is bound to the exact session ID, process ID, and process start
identity. It waits for the producer and the writer to shut down, reconciles
producer counts against committed rows, checks database integrity, validates
the replay-relevant events, and writes atomic terminal metadata before it
returns success. A timeout, a worker failure, an invalid capability, a stale
process, or an ambiguous set of active sessions all return non-success. Repeat
calls share one finalization result.
The control endpoint listens on IPv4 loopback only, and every request carries
an authenticated session capability that lives in an owner-only runtime file
(0700/0600 on macOS and Linux, a protected current-user DACL on Windows).
On macOS it also removes extended ACL entries and verifies they are absent. The
capability never reaches command arguments, logs, or the capture directory.
Authenticate before recording when the demonstration does not need the login. When login belongs inside the workflow, start an authentication handoff. Capture then drops screen, input, structural, window, browser-category, and microphone content at the source boundary. It stores a sealed marker with method classes and booleans. It has no field for a credential or account identifier.
handoff = recorder.begin_authentication(
methods="password_manager",
requires_user_presence=True,
saved_account_selected=True,
)
# Select the saved account and finish any MFA prompt.
marker = recorder.end_authentication(handoff, outcome="completed")The end call returns after Capture retains a new exact frame. Input stays
blocked if that frame fails. A launcher can use the authenticated
begin_authentication_handoff() and end_authentication_handoff() process
control functions for the same contract. See
the authentication handoff contract.
Recording video needs an FFmpeg executable, and capture install-ffmpeg gets
you one. Video is the default evidence format, so most people need it.
capture install-ffmpegThat downloads a single pinned build for your platform, checks its SHA-256
against a digest compiled into this package, and installs it under your user
data directory. Nothing is made executable until every digest matches. Run it
with --dry-run to print the exact URL, digest, and destination without
fetching anything, and capture uninstall-ffmpeg to remove it again. Builds
are pinned for macOS on Apple silicon and Intel, Linux x86-64, and Windows
x86-64. On anything else, supply your own executable.
The wheel and the source distribution carry no FFmpeg bytes, and Capture
downloads nothing unless you run that command. Licensing is the reason. This
package is MIT and FFmpeg is not, so shipping FFmpeg inside it would relicense
the package. The pinned build is LGPL-2.1-or-later, configured with
--disable-gpl, --disable-nonfree, and --disable-version3. FFmpeg's own
LICENSE.md covers the GPL components it leaves out: "None of these parts are
used by default." The install puts that licence text beside the binaries, and
writes a receipt naming the archive, its digest, and the matching upstream
source tarball.
An FFmpeg you already configured keeps priority. Capture resolves, in order,
Recorder(ffmpeg_path=...) or OPENADAPT_FFMPEG_PATH, then
OPENADAPT_DESKTOP_FFMPEG_PATH, then Desktop's ffmpeg.json provision
manifest, then PATH, and the installed runtime last. Point
OPENADAPT_FFMPEG_PATH at the installed one to move it to the front.
Recording runs a real encode-and-decode probe first, and refuses before the
input listeners start if the executable, the codec, or the PNG verification
path is missing. A minimal runtime has to supply raw-video input through a
pipe, the chosen video encoder, MP4 demuxing and muxing, PNG decoding and
encoding, the image2pipe muxer, and the select video filter. The pinned
build and Desktop's runtime both cover that closure.
Frames stream from memory straight to FFmpeg. A missing integer PTS slot reuses the preceding frame, so the encode is deterministic no matter what the scheduler does, and a compact MP4 metadata box keeps the logical capture-frame timestamps that nearest-frame extraction needs. No intermediate screenshot sequence gets written.
Beside each action the recorder can retain a versioned native accessibility
observation, from Windows UI Automation, macOS Accessibility, or Linux AT-SPI.
Where the provider exposes them it records the target identifier, the role,
the name, the bounds, the supported actions, the process and window identity,
and the ancestry. Windows UIA also records how many candidates matched inside
the top-level window. A value the provider doesn't expose stays absent: Capture
never infers a structural field from coordinates or pixels. The provider name
is windows_uia, macos_ax, or linux_atspi, and the field is optional, so
an older recording still loads unchanged.
It's on by default wherever a provider exists. Turn it off with
Recorder(..., capture_structural_observations=False), or inject your own
read-only observer through the StructuralObserver protocol.
Accessibility text is capped at 512 characters per field, and a longer value is omitted rather than truncated, so the compiler never sees half a string and treats it as exact identity evidence.
macOS needs Accessibility permission. Linux needs a live AT-SPI bus and the
system typelib, so install your distribution's PyGObject packages and
gir1.2-atspi-2.0 first (the
PyGObject install guide has
the current names), then:
pip install "openadapt-capture[linux]"The provider describes the local accessibility tree. It cannot see controls on the far side of an RDP or Citrix pixel boundary, and those demonstrations fall back to window-scoped pixels and coordinates for Flow's visual compiler.
By default the recorder takes the whole virtual desktop. Window-scoped mode
records a single window in that window's own pixel space, which is what
remote-display demonstrations need, because Flow's rdp_window replay drives
the client window's pixels directly:
from openadapt_capture import Recorder
with Recorder(
"./my-capture",
task_description="Demonstrate the workflow",
window={"owner": "Parallels", "title": None}, # substring match
) as recorder:
input("Perform the task, then press Enter...")Coordinates are translated at capture time into the frame's pixel space, the window is re-resolved every frame, movement and resize are handled by scaling into a fixed viewport, and a lost window fails the session rather than producing media that looks complete but has an evidence gap. The full contract, including the multi-monitor rules and the coordinate-space flags converters must respect, is in docs/WINDOW_CAPTURE.md.
On current macOS, exact-window capture keeps one ScreenCaptureKit stream bound to a desktop-independent window filter. The window can be occluded or on another Space. It does not need to be frontmost or reported as on screen. The recorder accepts complete frames and proven idle frames. A failed provider is disabled for the rest of that recording, so each frame does not repeat a slow failure. The recorder still needs a logged-in desktop session. A minimized window is refused because macOS can expose stale backing pixels for it. For an off-Space window, macOS Accessibility must confirm that the exact window is not minimized.
Linux window mode needs X11 with EWMH and XComposite. It refuses to start under native Wayland or XWayland-only.
A raw capture can hold everything visible on screen and everything typed: credentials, personal data, protected health information. Keep the whole directory inside its approved local boundary and give it a retention policy.
An authentication handoff prevents the named login interval from entering the raw capture. Content retained before or after that interval remains sensitive.
Capture doesn't upload a session. The sharing command and the profiling
transfer are the only two transfers and both are explicit. Installing the
privacy extra does not scrub anything by itself.
Microphone narration (capture record --audio, or RECORD_AUDIO) is off
by default and it's the riskiest thing this package can observe. Speech has no
structure to scrub against the way a screen field does, and a voice is itself
biometric identifying data, Safe Harbor identifier #16. openadapt-privacy has
no audio modality and openadapt-flow refuses audio artifacts, so a waveform
has no sanitized derivative and can never be cleared for egress. These
boundaries are in code, not convention:
- Transcription is on-device. There is no remote backend, and with no local
backend installed,
--audioandcapture transcriberefuse with an install hint instead of reaching for a network recognizer. --audiorefuses before the microphone opens if it couldn't transcribe locally, so you never capture a session you then have to throw away.- The waveform is discarded after transcription unless
RECORD_AUDIO_RETAIN_WAVEFORMis set. - The transcript is never logged. Narration carries names, dates of birth, and diagnoses.
- The HTML viewer doesn't embed audio by default, because that file is self-contained and easy to forward.
--audioprints a microphone notice before recording starts.
Transcript text is unscrubbed free text. Treat it as at least as sensitive as the rest of the capture. Nothing downstream reads it today.
Structural observations carry control names, window titles, and accessibility
ancestry, and stay in the same local boundary. Flow still refuses desktop
--secret authoring until its source-time field-redaction contract can prove
the sensitive value was never retained.
The Chrome extension in this repository is a prototype. It's excluded from the published wheel and source archive, it has no source-time secret exclusion, and its development bridge speaks to an unauthenticated local WebSocket. Don't run it in a sensitive browser profile. Why the supported browser recorder lives in Flow instead: docs/BROWSER_EXTENSION_BOUNDARY.md.
- Native recording needs a logged-in user session plus the operating system's screen-recording and input-monitoring permissions.
- Accessibility evidence appears only where the application and the local provider expose it. An opaque remote application still needs Flow's visual and OCR bindings.
- The Flow adapter compiles left and right clicks, left-button drags, typed text, named keys, modifier chords, and scrolling. Anything else, including middle clicks, non-left-button drags, malformed shortcuts, and unmapped keys, is rejected rather than compiled into an incomplete workflow.
- Display hot-plug, rotation, resolution change, and scale change all end a recording, because one media stream has one fixed virtual-desktop viewport. Start a new recording afterwards.
- The browser extension is not a supported recorder and its artifacts are not published.
An exact Capture release reaches Production only through an active signed, expiring, revocable release admission. Missing, expired, revoked, mismatched, or unverifiable, and the release is not actively admitted. The validator won't fall back to an older admission or hand out a lifecycle label instead. The current state is machine-readable in the signed production record.
Evidence behind what each substrate can claim: the repository lifecycle registry and Flow's product status.
| Extra | Adds |
|---|---|
transcribe-fast |
Local faster-whisper transcription |
transcribe |
Local openai-whisper transcription |
privacy |
The openadapt-privacy dependency, for explicit integrations. No automatic scrubbing. |
share |
Explicit Magic Wormhole transfer |
linux |
Native Linux AT-SPI structural observation |
all |
Everything in this table |
Neither transcription extra is vendored into this MIT wheel; you install them
into your own environment. Watch transcribe-fast: it pulls PyAV, whose
published wheels bundle GPL-licensed libx264 and libx265 binaries. Fine for
a user-side pip install, not fine frozen into a first-party installer,
sidecar, or image. Packaging work that bundles a transcription backend should
prefer an MIT backend with a clean dependency tree, and should inspect the
built archive.
uv sync --dev
uv run pytest -m "not slow"The slow native-capture tests need a visible session and OS permissions:
uv run pytest -m slowThe Linux X11 window check needs an explicit target:
OPENADAPT_CAPTURE_LINUX_WINDOW_QUALIFICATION=1 \
OPENADAPT_WINDOW_SMOKE_OWNER=citrix \
uv run pytest tests/test_window_capture_linux.py -m slow