SOURCE: APPSTATE.SWIFT, LINES 307-530

macOS accessibility automation: the four production failure modes nobody writes about

Every guide on macOS accessibility automation explains AXUIElementCreateApplication and stops. The harder reality is the four states a real automation tool ends up in on a real Mac, none of which appear in Apple's sample code: a stale TCC cache after a macOS update, the kAXErrorCannotComplete trichotomy when the user's focused app is Qt or Python or OpenGL, the kAXErrorAPIDisabled state, and the retry-then-restart loop you need because the in-process cache cannot be invalidated without a process restart. This guide walks the exact code Fazm uses to detect and recover from each one. Every snippet is a line range you can open in the open-source repo at github.com/m13v/fazm.

M
Matthew Diakonov
11 min read
Direct answer (verified 2026-04-30)

macOS accessibility automation is built on the AXUIElement C API in ApplicationServices.framework: you read a structured UI tree from any process granted the Accessibility TCC permission, and post synthetic input via CGEvent. The API has been stable since Mac OS X 10.2. The hard part is the four production failure modes the docs do not cover: stale TCC cache after OS updates (detect via CGEvent.tapCreate probe), kAXErrorCannotComplete from Qt/Python/OpenGL apps with no AX tree (cross-check against Finder), kAXErrorAPIDisabled when system-wide AX is off, and the 3-retry restart escalation when the in-process cache desyncs. Apple's reference is axuielement_h.

The naive version Apple shows you

If you read the AXUIElement reference and the WWDC session on accessibility, this is the version of the code you walk away with. It works in the happy path. It is wrong every other day in production, and the wrong-ness is silent: the function returns, your tool acts as if everything is fine, but no actual click was registered against the target app. The pattern below is what you'll see in 80% of the public sample code, including the Apple developer forums, the Stack Overflow answers, and the Swift packages in GitHub's top results.

Naive vs production AX-permission check

// What every Apple sample shows you.
// Works in the happy path. Wrong every other day.

if AXIsProcessTrusted() {
    let app = AXUIElementCreateApplication(pid)
    var window: CFTypeRef?
    AXUIElementCopyAttributeValue(
        app, kAXFocusedWindowAttribute as CFString, &window
    )
    walk(window)
}

// Failure modes this never handles:
// 1. AXIsProcessTrusted lies after macOS auto-update.
// 2. cannotComplete on the focused app, but it just means
//    that app has no AX tree, not that we lost permission.
// 3. apiDisabled when system-wide AX is off in System Settings.
// 4. The user revoked permission mid-session and the cache
//    has not caught up yet.
-211% more states handled
2 probes

AXIsProcessTrusted() can return stale data after macOS updates or app re-signs, so we also do a functional AX test to detect the broken state.

Fazm AppState.swift, line 308 (open source)

Failure mode 1: stale TCC cache after a macOS update

The first time accessibility automation breaks for one of your users in production, this is what happened. They updated macOS overnight (or your app re-signed during an auto-update via Sparkle), they relaunched, the System Settings toggle still shows your app under Accessibility, and AXIsProcessTrusted() returns true. But every actual call to AXUIElementCopyAttributeValue comes back with kAXErrorAPIDisabled or kAXErrorCannotComplete. Your agent looks alive but does nothing. This was filed against macOS Sequoia in 2025 and it is still present in macOS 26 (Tahoe) in 2026.

The cause is that AXIsProcessTrusted reads from a per-process cache populated at first call. When TCC re-validates code signatures across an OS update, it can roll the live database forward without invalidating in-process caches in already-running processes. There is no notification API for this. Apple has not publicly acknowledged the cache as a bug; they have shipped documentation that implies AXIsProcessTrusted is the canonical check.

The recovery is to also probe the live database. The cheapest call that actually consults the live TCC state is CGEvent.tapCreate with .listenOnly options. Tap creation requires the same permission as the accessibility API (because event taps see synthetic input from other processes), and the result is not cached the same way. If the tap creates, you have the permission live; if it does not, you genuinely lost it. Invalidate the tap immediately so it does not eat events.

// AppState.swift, lines 307-371 (abridged).
// Two checks every poll cycle, not one.
// AXIsProcessTrusted alone returns stale data after macOS updates
// because the result is cached inside this process. We ALSO probe
// the live TCC database via CGEvent.tapCreate, which cannot be
// answered from a stale cache.

func checkAccessibilityPermission() {
    let tccGranted = AXIsProcessTrusted()
    let previouslyGranted = hasAccessibilityPermission

    if tccGranted {
        // AXIsProcessTrusted said yes, confirm by walking the front
        // app's window. cannotComplete or apiDisabled means the
        // cache is stale despite the TCC pane still showing granted.
        let broken = !testAccessibilityPermission()
        if broken { startAccessibilityRetryTimer() }
    } else if previouslyGranted && probeAccessibilityViaEventTap() {
        // AXIsProcessTrusted said no but live TCC says yes via the
        // event tap probe. Trust the live signal.
        log("ACCESSIBILITY_CHECK: stale cache detected")
    }
}

private func probeAccessibilityViaEventTap() -> Bool {
    let tap = CGEvent.tapCreate(
        tap: .cgSessionEventTap,
        place: .tailAppendEventTap,
        options: .listenOnly,
        eventsOfInterest: CGEventMask(1 << CGEventType.mouseMoved.rawValue),
        callback: { _, _, event, _ in Unmanaged.passRetained(event) },
        userInfo: nil
    )
    if let tap = tap {
        CFMachPortInvalidate(tap)
        return true
    }
    return false
}

Source: Desktop/Sources/AppState.swift, lines 307-371 and 487-504 in the Fazm repo. The full file in production handles three more edge cases: explicit transition logging when permission moves from granted to revoked or back, a Finder fallback for ambiguous errors (next section), and a retry timer that escalates to a restart prompt after three failed probes.

Failure mode 2: the kAXErrorCannotComplete trichotomy

You call AXUIElementCopyAttributeValue, you get back kAXErrorCannotComplete (raw value -25204). What does it mean?

One error code, three causes. If you treat them all the same way, you will either spam the user with permission alerts when they launch a Qt app, or silently fail when their permission is actually broken. The remediation is a simple cross-check: re-run the call against Finder.app, which is guaranteed to expose a full accessibility tree. Finder has shipped an AX implementation since 10.2 and Apple maintains it as part of every release.

kAXErrorCannotComplete decision tree

1

AX call fails

kAXErrorCannotComplete from front app

2

Probe Finder

Same call, com.apple.finder pid

Finder fails too

Permission is truly broken

Finder works

App-specific gap, permission fine

The third cause: the agent process holds an AX handle to a target app that has since quit or been replaced (for example, Slack relaunching during an update). The handle goes stale and you get cannotComplete on every attribute read against that handle. This one is rarer and you handle it by always re-creating the AXUIElement from the current pid before walking, never caching the handle across event-loop ticks.

The Finder cross-check in source:

// AppState.swift, lines 465-485.
// kAXErrorCannotComplete is ambiguous: either we lost permission,
// OR the front app does not implement AX (Qt apps, OpenGL canvases,
// Python apps like PyMOL). Disambiguate by re-running the same call
// against Finder, which is guaranteed to expose a tree.

private func confirmAccessibilityBrokenViaFinder(suspectApp: String) -> Bool {
    if let finder = NSRunningApplication.runningApplications(
        withBundleIdentifier: "com.apple.finder").first {
        let finderElement = AXUIElementCreateApplication(finder.processIdentifier)
        var finderWindow: CFTypeRef?
        let finderResult = AXUIElementCopyAttributeValue(
            finderElement,
            kAXFocusedWindowAttribute as CFString,
            &finderWindow
        )
        if finderResult == .cannotComplete || finderResult == .apiDisabled {
            // Both apps fail. Permission is truly broken.
            log("AX broken confirmed by Finder (app: \(suspectApp))")
            return false
        } else {
            // Finder works, suspect app does not. App-specific gap.
            log("App \(suspectApp) has no AX; permission is fine")
            return true
        }
    }
    // Finder not running, fall back to event tap probe as tie-breaker.
    return probeAccessibilityViaEventTap()
}

The known-AX-empty apps in 2026, in approximate order of how often they trip this code path: PyMOL (Python + Qt), Wireshark (Qt), Telegram desktop pre-5.0 (Qt), Inkscape (GTK via XQuartz), Blender (OpenGL canvas), most Steam games, custom Metal renderers like the Arc Browser onboarding, scientific Python apps using matplotlib standalone windows, and old Tk apps. Native AppKit and SwiftUI apps almost never end up here unless the developer explicitly opted out via isAccessibilityElement = false on every view.

Failure mode 3: kAXErrorAPIDisabled

This one is unambiguous and rare: the user has system-wide accessibility turned off. There is no per-app toggle, just a single global state in TCC. The error code is -25211. Surface it immediately with a deep link to System Settings; do not retry, do not cross-check, do not wait for the cache to clear. The user is looking at the wrong UI and the only fix is a click in the right pane.

The deep link URL changed in macOS 13. Use x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility on Ventura and newer; on Monterey and older, the legacy URL was x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility (yes, identical string; the path Apple resolves changed internally). Open it via NSWorkspace.shared.open(URL) rather than open(1) so the activation comes from your app and macOS focuses the settings window correctly.

Logging note: the apiDisabled state can be surfaced repeatedly if your tool polls accessibility on a timer. Fazm logs apiDisabled exactly once per transition (lastAccessibilityApiDisabledLogged flag at AppState.swift line 449) to keep the support log readable. A noisy log that re-prints the same warning every poll cycle hides the actual signal.

Failure mode 4: the cache desyncs and you cannot fix it from inside the process

This is the punchline. If your agent ends up in the stale-cache state from failure mode 1, there is no public API to invalidate the cache. Apple does not expose AXResetTrustedCache(). The only working fix is to quit and relaunch the process. Every accessibility-based agent on macOS hits this and every one of them has to handle it the same way: detect it, retry a few times to confirm it is real, then ask the user to restart.

Fazm's pattern is three retries spaced 5 seconds apart, then a modal alert with two buttons: Quit & Reopen, or Later. The retry interval is 5 seconds because shorter intervals do not give TCC time to settle if the desync happened during an OS-level relaunch event, and longer intervals make the user feel like the app is hung. Three retries because the false-positive rate on a single failed test is real (a transient app launch can return cannotComplete during the first 200 ms of the process), but a three-in-a-row failure across 15 seconds is essentially never transient.

The retry-then-restart escalation

1

Probe failed once

AXIsProcessTrusted said yes but testAccessibilityPermission walked the front window and got cannotComplete or apiDisabled. Could be transient (app just launched) or stale-cache.

2

Start retry timer

Schedule a re-check every 5 seconds. Reset retry counter to 0. The polling task is ~5 lines of code; do not over-engineer it with exponential backoff.

3

Retry up to 3 times

Each retry runs the full check, including the Finder cross-check and the event tap probe. If any succeeds, stop the timer; the desync was transient.

4

Show restart alert

After 3 consecutive failures (15 seconds elapsed), surface an NSAlert: Accessibility Permission Needs Restart. Buttons: Quit & Reopen, Later. Default to Quit & Reopen.

5

Quit and relaunch

If the user agrees, spawn a delayed reopen via /bin/sh -c 'sleep 1 && open <bundlePath>' and call NSApplication.shared.terminate(nil). The fresh process gets a fresh cache.

What this looks like in the logs of a healthy agent

One useful sanity check when you adopt the pattern above: the log stream from a healthy agent on macOS 26 looks like this on a poll cycle. Every line maps to a function in AppState.swift, and the ratio of lines you see in production tells you which failure mode your users are hitting most.

Console.app, filtered to com.fazm.fazm.AppState

That last line, Permission confirmed via event tap probe, is the one that catches the stale-cache case. If you see it frequently, the user updated macOS recently. If you see the Quit & Reopen alert fire, the cache desync was real and the retry loop did not save it.

The full AXError vocabulary, mapped to actions

AXError has 13 cases in the public header. Most production code treats them as a binary success/fail, which is why most production code spams users with bogus permission alerts. The right handling is to split them by what they actually mean for an automation tool:

ErrorRawAction
.success0Use the value. AX is working.
.noValue-25212App is fine, attribute is empty (e.g. no focused window). Treat as success.
.notImplemented-25208App-specific. Not a permission issue. Don't retry.
.attributeUnsupported-25205Element does not have this attribute. Skip.
.cannotComplete-25204Ambiguous. Cross-check against Finder before alerting.
.apiDisabled-25211System-wide AX is off. Open System Settings deep link.
.invalidUIElement-25202Stale handle, app likely quit. Re-resolve from pid.
.illegalArgument-25201Bug in your code. Treat as fatal in dev.

The remaining five (.invalidUIElementObserver, .cannotObserveUIElementNotifications, .notificationUnsupported, .notificationAlreadyRegistered, .notificationNotRegistered) only appear if you use the AXObserver notification API, which most automation tools do not. Fazm polls instead because the notification API has historically had its own set of bugs around process restart.

What this means if you're evaluating a tool, not building one

If you arrived here from a forum thread asking which macOS accessibility automation tool to use, the practical takeaway is this: the failure modes above are not optional. Every credible tool either handles them or quietly does not work for some of its users. Two questions to ask before you trust an agent on your machine:

  1. Is the tool open source? If yes, grep for AXIsProcessTrusted and check whether the same file also calls CGEvent.tapCreate for the live probe. If only the first appears, the tool will silently break for users on macOS 15 or 26 after an OS update.
  2. Does it expose a screenshot fallback? If the tool relies on accessibility for everything, it will be useless against your Qt and OpenGL apps. The right architecture is AX-first with a vision-based fallback that only fires when the AX tree is empty. Fazm exposes capture_screenshot as that fallback and the system prompt steers Claude to try macos-use tools first.

Want to see this in production on your Mac?

Book a 20 minute call to walk through how Fazm uses the macOS accessibility API to drive your real apps, with the full source code in front of us.

More on macOS accessibility automation

Frequently asked questions

How does macOS accessibility automation actually work in 2026?

macOS accessibility automation runs on the AXUIElement C API in ApplicationServices.framework. Your tool calls AXUIElementCreateApplication(pid) to get a root handle on a target process, then walks the tree with AXUIElementCopyAttributeValue using attributes like kAXChildrenAttribute, kAXFocusedWindowAttribute, kAXRoleAttribute, and kAXValueAttribute. To click, you build a CGEvent with CGEventCreateMouseEvent and post it. To type, you send CGEventCreateKeyboardEvent. The whole thing is gated by one TCC permission named Accessibility, all-or-nothing across every app on the Mac. The model has not changed since Mac OS X 10.2, but the failure modes are the part that catches everyone, and they are not in the API docs.

Why does my macOS accessibility automation stop working after a macOS update?

Because AXIsProcessTrusted lies. The function caches its result inside the calling process. When macOS updates and re-validates code signatures, or when an app re-signs itself across an auto-update, the system TCC database can roll forward while the cached value inside your already-running process stays at its old answer. Real AX calls then return kAXErrorAPIDisabled or kAXErrorCannotComplete even though the System Settings toggle is still on. The fix is to also probe the live TCC database via CGEvent.tapCreate(.cgSessionEventTap, place: .tailAppendEventTap, options: .listenOnly, ...). Tap creation is the cheapest call that actually consults the live database, not the cache. If the tap creates, you have permission; if it does not, you genuinely lost it. Fazm runs both checks every poll cycle.

What does kAXErrorCannotComplete mean?

It is ambiguous on purpose. It means either (a) you do not have accessibility permission, or (b) the target app does not implement the accessibility tree at all. The second case is common with Qt apps, OpenGL apps, and Python-based tools that draw their UI on a single Cocoa view without exposing children. PyMOL is the canonical example. The correct remediation is to retry the same AX call against a known-AX-compliant app like Finder. If Finder returns success, the original failure was app-specific and your permission is fine. If Finder also returns cannotComplete, you genuinely lost permission. Fazm calls this confirmAccessibilityBrokenViaFinder and it lives at AppState.swift line 468.

What is the difference between AXIsProcessTrusted and AXIsProcessTrustedWithOptions?

AXIsProcessTrusted is a pure read; it returns the cached trusted state and never prompts. AXIsProcessTrustedWithOptions takes a CFDictionary and, if you pass kAXTrustedCheckOptionPrompt as true, will surface the System Settings prompt asking the user to grant accessibility. On macOS Sequoia (15) and Tahoe (26), the prompt is no longer a modal dialog; the call returns immediately and the user has to navigate to System Settings themselves. Production tools open the Privacy and Security pane explicitly with a URL like x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility instead of relying on the prompt to do it for them.

Which macOS apps do not expose an accessibility tree?

The recurring offenders are Qt-based apps (KDE tools, some scientific software, older versions of Telegram desktop), OpenGL or Metal canvases (3D modelers, games, custom rendering surfaces), Python apps written with native bindings that draw to a single NSView (PyMOL, some Tk apps), Electron apps with accessibility disabled (some early builds turned it off for performance), and apps that ship without proper NSAccessibility annotations. For these, accessibility-based automation has nothing to read and you have to fall back to one of three options: image-recognition on the screen capture, AppleScript if the app exposes a scripting dictionary, or coordinates-only with a known-good baseline. Fazm exposes capture_screenshot for exactly this case but routes everything else through accessibility first.

Is the macOS accessibility API faster than screenshots for automation?

Yes, by roughly two orders of magnitude on a typical action. A full BFS walk of a focused window in Slack, Mail, or Chrome runs in 30 to 80 milliseconds end to end. A screenshot pipeline takes a 200 to 400 ms screen capture, sends a 1 to 5 MB image to a vision model, waits 1 to 4 seconds for inference, and parses pixel coordinates back. The AX path also gives you semantic role and label data the vision model has to infer; you skip the OCR entirely. The trade-off is the apps where AX is empty, which is why the right architecture is AX first, screenshot fallback. Fazm benchmarks the AX path at 50 ms median element lookup; the screenshot path is closer to 2500 ms.

Do I need Full Disk Access for macOS accessibility automation?

No. Accessibility and Full Disk Access are two completely separate TCC panes. Accessibility lets your tool read UI elements and post synthetic input events to other processes. Full Disk Access lets a tool read protected user data on disk: the Mail database, Messages chat history, Safari cookies, System Settings caches. An accessibility-based automation tool should never need Full Disk Access. If a tool you are evaluating asks for both during onboarding, ask why; the legitimate reasons are narrow (full-text indexing of mail, building a local Spotlight replacement) and they have nothing to do with automation.

Can I scope accessibility permission to one app instead of granting it system-wide?

No. The accessibility TCC pane is binary per granted-app: either an app has accessibility, in which case it can read every UI element in every other app, or it does not. There is no per-target scoping at the OS level. Apple Events are different and DO have per-target scoping (the OS prompts when an app first sends an event to Mail or Finder), but that is a separate API. The practical mitigation, when you want narrower scope, is to use a tool you can audit and revoke. Open source plus revocable in System Settings is the only enforcement available to you. macOS 15 added Local Network privacy scoping but accessibility was not part of that change.

What permission errors should my macOS automation tool retry vs surface to the user?

Retry kAXErrorCannotComplete once, against a known-AX-compliant app like Finder. If the second call also fails, treat as a permission problem; otherwise treat as an app-specific gap. Surface kAXErrorAPIDisabled immediately because it is unambiguous (system-wide accessibility is off). Surface kAXErrorNotImplemented and kAXErrorAttributeUnsupported as harmless app-specific gaps; do not retry. After three failed checks 5 seconds apart, prompt the user to quit and reopen the app; the in-process AX cache cannot be invalidated without a process restart. This pattern is implemented in Desktop/Sources/AppState.swift, lines 373-419.

How do I test macOS accessibility automation in CI?

You cannot, not honestly, because TCC is keyed to the binary path AND the team identifier AND the embedded provisioning profile, and CI machines do not have your developer cert nor your TCC database. Practical options: (1) maintain a self-hosted Mac mini with the binary pre-trusted in TCC, gated behind a fleet manager that locks the runner to one job at a time. (2) test the AX-walking logic against a JSON fixture of a captured tree (Fazm does this in unit tests; the macos-use binary will dump the tree as JSON to stdout for fixture capture). (3) record-and-replay against the Accessibility Inspector tool that ships in Xcode. There is no useful headless macOS accessibility runner.