New
Apps 1,273Makers 1,271This week 1,273
Promote your app−30%
Loupe screenshot 1
0views in
the last 30 days

Loupe

EXPERIMENTAL — drive and inspect UI (web, Mac apps, iOS simulators) without taking over the...

Built with Claude Code

by cocoanetics · added Oct 29, 2026
no ratings0 comments

Warning

EXPERIMENTAL. Loupe works, and it is young: the API, the CLI verbs and the on-disk formats will change without notice, and the simulator bridge in particular has been exercised against a handful of apps rather than many. It drives real applications on your Mac — read what a command does before running it. Issues and findings are welcome; compatibility promises are not implied.

Loupe

Look at and drive UI — web pages, Mac apps, iOS simulators — from a CLI or an MCP tool call, and get before/after proof images out the other end.

Loupe never takes over your screen. Mac windows are read and driven through the accessibility API and captured with ScreenCaptureKit, so they work while occluded, minimized, or hidden, and the app is never brought to the front. Web pages render in a web view that is never ordered on screen. Simulators run headless — Simulator.app never has to open. You keep using your Mac while an agent works.

A loupe is what you bring to the object to inspect it closely. You never move the object.

# hold a target open, then step through a flow
loupe use "mac:MyApp#main" --session
loupe describe
loupe set login.username admin
loupe set login.password '{{APP_PASSWORD}}'
loupe press login.submit
loupe wait 'Dashboard|!Invalid password'
loupe capture --out after.png

# what can I point it at?
loupe list

# screenshot anything
loupe capture https://example.com
loupe capture mac:Safari
loupe capture sim:booted

# read the element tree instead of guessing at pixels
loupe describe mac:TextEdit --filter Save

# drive it
loupe act https://example.com --press "More information" --out after.png

# prove a fix, across two invocations and a rebuild
loupe before issue-517 mac:MyApp
#   …fix the bug, rebuild, relaunch…
loupe after  issue-517
#   → ~/.loupe/sessions/issue-517/compare.png

after writes a side-by-side image, BEFORE next to AFTER, with the regions that actually changed boxed in red, plus a one-line summary of how much changed. That image is the thing you attach to an issue or a merge request.

Scripting it in the XCUITest API

A flow with real control flow — retries, branches, conditions — is awkward as a sequence of CLI calls and expensive as a sequence of agent turns. Write it as one script instead, in the API the app's own UI tests already use:

import XCUIAutomation

let app = XCUIApplication(bundleIdentifier: "com.example.MyApp")
app.launch()
app.textFields["username"].typeText("admin")
app.secureTextFields["password"].typeText(env("APP_PASSWORD"))
app.buttons["Sign In"].tap()
XCTAssertTrue(app.staticTexts["Dashboard"].waitForExistence(timeout: 10))
loupe script login.swift --screenshots ./proof

That file is not a Loupe dialect — it is a UI test. import XCUIAutomation resolves to Apple's framework when the file is compiled into a test target, and to Loupe's drivers when it is interpreted here, so a flow proven by hand pastes into a test target unchanged. Unlike a real test it needs no runner and no test bundle, starts in under a second, and drives whatever is already running.

See Docs/xcui-scripts.md for the covered API, the additions worth having (waitForExistence(timeout:orFailure:), waitUntil(labelContains:), setText, scrollToVisible), and every place the surface knowingly departs from Apple's — also printed by loupe script --divergences.

Driving your own app while you build it

If you are an agent (or working with one), SKILL.md is the short version: how to check UI work by driving the running app instead of guessing — look, change, look again — and how to produce proof that a fix worked.

Examples/TaskDemo is a small universal SwiftUI app with the check written the way an agent would write it, running unchanged on iPhone and iPad.

Why it exists

Agents that fix UI bugs cannot show their work. They can describe a change, but "trust me, the totals line up now" is not evidence. Loupe gives an agent the two screenshots and the diff, in a form that pastes into a code-review conversation.

The constraint that shapes everything: it has to work while a human is using the same Mac. An automation tool that steals focus, moves the cursor, or blocks the screen can only run when nobody is there.

Targets

One syntax across all three surfaces:

Target Meaning
https://example.com a page in an offscreen web view (bare URLs imply web:)
web:<url> the same, spelled out
mac:Safari an app's frontmost window, by name
mac:com.apple.Safari …or by bundle id
mac:pid:4711 …or by process id
mac:Safari#2 that app's window at index 2
mac:MyApp#main …or the window whose title or identifier contains this
sim:booted the booted simulator
sim:iPhone 17 Pro a simulator by name
sim:6805ED2E-… …or by udid
@portal a live session held open in its own process

Looking, deciding, acting

An agent fixing a UI bug does not know its steps in advance: it looks, decides, acts, and looks again. Mac apps and simulators support that with no ceremony — the app itself holds the state and outlives your commands:

loupe describe mac:Calculator --depth 12 --all   # what can I press?
loupe act mac:Calculator --press Seven           # decide, act
loupe describe mac:Calculator --filter "Edit field"   # look again

A web page has no such owner: its web view dies with the process, so every command would reload it. Open a live session and it stays put:

loupe session open http://localhost:3000/login --as portal
loupe describe @portal                                  # find the fields
loupe act @portal --step 'set:username=…' --step 'set:password=…' --step 'press:Login'
loupe describe @portal                                  # still logged in
loupe session close portal

Secrets

Never put a password on the command line — ps shows argv to every process on the machine, and your shell writes it to history. Reference the environment instead:

export PORTAL_PASSWORD='…'
loupe act @portal --step 'set:username={{USERID}}' --step 'set:password={{PORTAL_PASSWORD}}' --step 'press:Login'

Braces, not $, for two reasons: the shell never touches {{…}} in any quoting style, so the secret cannot leak into argv by accident; and $3.94 is an ordinary value in a tool built for checking totals. Values that came from the environment are also scrubbed from everything Loupe prints or returns — otherwise protecting the input and then echoing "set password to hunter2" would be theatre.

A missing variable is an error, never a passthrough: substituting the literal {{NAME}} would type it into the field, and the resulting failure would look like a bad selector rather than a missing secret. When a value is genuinely optional, say so — {{NAME:-fallback}}, or {{NAME:-}} for "blank is fine".

Best of all, for repeat runs: log in once into a named --profile or a live session, and later runs never touch credentials at all.

Each verb is its own command — press, set, type, key, open, tap, reveal, wait — taking its arguments as arguments, and defaulting to the target set by loupe use. That is the shape for stepping through a flow, where you decide the next move from what the last one showed.

loupe act --step 'press:Save' says the same thing, packing the verb and its argument into one shell word. That is the right shape for an ordered batch (one process, state preserved between steps, good for scripts) and the wrong shape for a single step, where it is a small language living inside argv with its own quoting and nested delimiters. Both exist; use whichever fits.

A session is one helper process, not a system daemon: started on demand, gone after 15 idle minutes, and the same signed binary, so it needs no separate permissions. loupe use <target> --session opens one and remembers it in a single step, naming it after the target (@login, @safari, @iphone-17-pro) so the same target always maps to the same session.

Sessions are not web-only — they hold a Mac app or a simulator just as well. There the app already outlives your commands, so a session buys less, but it keeps a resolved window and a warm accessibility tree, and --session <name> routes a single command to one without disturbing whatever use remembers:

loupe describe --session login       # this one call goes to @login

Precedence is most-specific-wins: an explicit target, then --session, then the remembered one.

Relaying to a host

A host process — for example the MissionControl daemon — can hold the Screen Recording and Accessibility grants and run Loupe's tools for you. Point the CLI at its MCP endpoint and loupe <verb> and loupe mcp forward there instead of driving the UI in-process. Unset, nothing changes.

Variable Meaning
LOUPE_HOST The host's MCP URL, http:// or https://. Anything else is an error.
LOUPE_HOST_PREFIX Prefix the host put on Loupe's tool names (default none).
LOUPE_HOST_TOKEN Bearer token, sent as Authorization: Bearer … on every request. Never logged.
LOUPE_HOST_TOKEN_FILE Path to a file holding the token, used when LOUPE_HOST_TOKEN is unset.

Can be relayed: capture, describe, act, list, doctor, the single-step verbs press, set, type, key, open (and mcp, which relays the tool traffic as-is). Images come back downscaled, as they do over MCP.

Cannot be relayed (they fail with an error rather than run locally): use, at, session, sessions, script, before, after, compare, sim, window, reveal, wait, and capture --annotate.

Secrets: {{ENV}} values are expanded on this machine and sent to the host, so they are only sent to a loopback host or over https://. With a non-loopback http:// host, any command that uses {{…}} is refused.

What each surface can do

web mac simulator
Screenshot ✅ ✅ (even occluded/minimized/hidden) ✅
Full-page screenshot ✅ — —
Element tree ✅ (DOM) ✅ (accessibility, incl. menu bar) ✅ (XCUITest) ²
Press / click ✅ ✅ ✅ ²
Set value ✅ ✅ (full Unicode, atomic) ✅ ²
Type / keys ✅ ✅ ✅ ²
Swipe / scroll ✅ ✅ ✅ ²
Navigate / deep link ✅ ✅ ✅
Launch app — ✅ (without activating) ✅
Run JavaScript ✅ — —
Live session (@name) ✅ ✅ ¹ ✅ ¹
Never disturbs the user ✅ ✅ ✅

¹ Supported, but rarely needed: a Mac app and a simulator already outlive your commands.

² Through a UI-test bundle Loupe builds once and installs on the device, which then answers over loopback. simctl exposes no elements at all, and the accessibility Simulator.app forwards to macOS is flattened past usefulness — a tab bar arrives as a childless group — so XCUITest is the only complete view. Because a UI-test bundle needs no test host, one runner drives every installed app on every booted device. First use costs a build (~40 s, then cached in ~/.loupe/bridge); a session starts in about three seconds. See Docs/xcui-scripts.md.

Apps with poor accessibility

Poor accessibility almost never means missing elements. A button is an AX element with a role, a frame and AXPress whether or not anyone gave it a label — what is missing is a name to address it by. So the fix is not to abandon accessibility for pixels; it is to identify the element visually and then act on it through accessibility as usual.

loupe capture mac:MyApp --annotate --out ui.png
#   1. textfield "Username"      → w0/g0/g0/f4
#   2. button "Show password"    → w0/g0/g0/b6
#   3. button                    → w0/b1
loupe act mac:MyApp --step 'press:w0/b1'

--annotate boxes and numbers every actionable element and prints a legend, so a model reads "3" off the picture and acts on a handle from the tree. The perception can be approximate; the action is exact. Containers are skipped (their boxes would swallow the controls inside), disabled controls are shown and marked, and the menu bar is left out since it is not in a window capture.

To check your aim before committing to anything:

loupe at mac:MyApp n:0.56,0.63
#   button
#     handle: w0/b1
#     actions: AXPress
#     frame: x=316 y=229 w=16 h=16

Loupe also reads AXRoleDescription when a control has no title, which recovers names like "full screen button" and "scroll area" that would otherwise show as nameless — worth knowing before reaching for coordinates at all.

Clicking by coordinate

Sometimes you have a point rather than an element. click: still goes through accessibility — it hit-tests the coordinate and presses (or selects) whatever is there, needing no label and moving nothing on screen. The hard part is saying which coordinates, so the space is explicit:

loupe act mac:MyApp --step 'click:120,340'          # window points (default)
loupe act mac:MyApp --step 'click:px:240,680'       # pixels of a full-res capture
loupe act mac:MyApp --step 'click:n:0.5,0.33'       # fraction of the window
loupe act mac:MyApp --step 'click:screen:1500,900'  # global screen points

Use n: when working from a screenshot you were handed. It is the only space that survives rescaling — and images delivered over MCP are downscaled to keep the token cost sane, so their pixels are neither window points nor capture pixels. Pixel space uses the actual display's backing scale, not an assumed 2, because a window on a non-Retina monitor would otherwise be clicked at double the offset.

A coordinate click still goes through accessibility: it hit-tests the point and presses (or selects) whatever is there, so it needs no label — only that something at that point is actionable. Nothing moves on screen.

When even that fails, cursorclick: is the last resort:

loupe act mac:MyApp --step 'cursorclick:n:0.5,0.5'

This is the one action in Loupe the user can see, so it is never a silent fallback. It does not try to activate the app first: macOS ignores activation requests from a background process — NSRunningApplication.activate() returns true and setting AXFrontmost returns success while nothing happens — so that cannot be made reliable from a CLI. It is also unnecessary, since clicking a visible window focuses it exactly as a user's click would.

What it does insist on is that the target really is the topmost window at that point, and refuses otherwise:

Error: refusing to click at (1519, 860): the topmost window there belongs to Finder,
not a real SwiftUI Mac app. A real click goes to whatever is visible, so this would have hit
the wrong app.

Without that check, a click aimed at a covered window lands on whatever is over it — the user's own app, doing something nobody asked for. Only normal windows (layer 0) count as occluders; the Dock owns a full-screen window at layer 20 that would otherwise make every click look blocked. The pointer is moved and put back; focus stays where the click left it, because that is what a click does.

Scrolling

Two verbs, and the second is usually the one you want:

loupe act mac:MyApp --step 'scroll:0,-400'      # by a distance, in points
loupe act mac:MyApp --step 'scrollto:Save'      # reveal a specific element

scrollto (alias reveal) asks the app to bring an element into view — AXScrollToVisible on macOS, the action VoiceOver uses to follow focus, and scrollIntoView on the web. That beats guessing a distance, and it works inside nested scrollers where a distance-based scroll would move the wrong one. Where an app does not implement the action, Loupe falls back to computing the scroll-bar position from the element's own frame.

Distance scrolling on macOS positions the scroll bar directly rather than synthesizing wheel events: macOS delivers scroll wheels only to the frontmost app, which would take the user's focus. It reports how far it actually moved, because a scroll that silently does nothing leaves you screenshotting the same view and concluding the content is not there:

scrolled vertically by 340 points of the 400 requested (scroll bar 0.120 → 0.395 of a 1236 point range)

Simulators swipe instead: the bridge sends a real drag through XCUICoordinate, so momentum and rubber-banding behave as they would under a finger.

Waiting for the right thing

settle watches pixels, which is blunt: a spinner never settles, and a screen that paints in two passes looks settled between them. Wait on the element instead — and, crucially, race it against the failures you expect:

loupe act mac:MyApp \
  --step 'set:login.username=admin' --step 'key:tab' --step 'type:{{APP_PASSWORD}}' \
  --step 'press:login.submit' \
  --step 'await:Health Dashboard|!Invalid|!failed@20'

await:A|B returns as soon as either appears. A ! prefix marks a failure condition: if it matches, the step throws immediately, quoting what the app said. Measured on a real login, a wrong password failed in under a second with "Invalid username or password." — where waiting for the dashboard alone would have burned the full 20 seconds and then reported a timeout, the least useful description of what happened.

await also requires the element to be enabled, not merely present, since acting on a disabled control is a no-op that reports success. gone:Loading waits for something to disappear. @30 overrides the timeout.

Secure fields need typing

setValue writes straight to an element's accessibility value: atomic, full Unicode, no focus required, and it does reach a SwiftUI @State binding — a form's validation re-runs and dependent buttons enable, verified on a real SwiftUI login screen.

The exception is secure text fields. macOS refuses to expose or set their value, so the write appears to succeed and the field still reads back empty. Loupe treats that as a failure rather than reporting success:

Error: AXValue on AXTextField 'Password' was accepted but the element now reads ''
instead of '…' — the app reformatted or rejected the value.

Type into those instead — real key events go through the normal input path:

loupe act mac:MyApp --step 'set:login.username=demo' --step 'key:tab' --step 'type:{{APP_PASSWORD}}'

This is also why setValue verifies by reading back: without it, a password field would silently stay empty and the failure would surface much later as a rejected login.

Windows

loupe window close mac:Safari#0
loupe window minimize mac:Preview
loupe window raise mac:Notes        # forward within its app, without activating it

Loupe never sends Apple Events. macOS Automation consent is per-application and blocks on a user prompt — so the first osascript call against a new app either stops for a dialog or, headless, hangs until it times out. That makes AppleScript unusable for anything unattended, which is most of what this tool is for. Everything here is accessibility plus NSWorkspace, neither of which prompts. If you see a "wants access to control" dialog while using Loupe, it did not come from Loupe.

A related lesson worth keeping: when accessibility and AppleScript disagree about what windows exist, accessibility is usually right. Safari can keep an empty phantom window object that its AppleScript dictionary still lists and that close silently ignores; loupe list correctly does not show it.

Making comparisons trustworthy

A diff tool that cries wolf is worse than none. Loupe defaults to:

  • Settling before every capture — it waits for

Description taken from the repository README.

Ratings

–
0 ratings
5 0
4 0
3 0
2 0
1 0

Rate this app

Sign in to rate

Comments 0

Sign in to comment

No comments yet.

Related apps

#ui-automation · #macos · #mobile