AppleScript vs Accessibility API: Four App Categories Where AppleScript Stops Working
Apple's Technical Q&A QA1888 puts it on the record: System Events is just a wrapper around public [Obj]C system APIs, so you could bypass AppleScript and call those APIs directly. That answers the casual version of the question. The interesting version is the inverse: which controls actuate via AXUIElementSetAttributeValue but not via tell application "System Events".
AppleScript GUI scripting (System Events) is a high-level wrapper around the Accessibility API. So the API can do everything AppleScript GUI scripting can. The interesting question is the inverse, and the answer is four concrete app categories where AppleScript silently stops while direct AX calls still actuate:
- Catalyst right-pane controls: synthetic clicks dropped,
AXUIElementPerformActionstill actuates. - Sandboxed calling processes: AppleEvents blocked without a scripting-targets entitlement; direct AX only needs Accessibility permission.
- Secure-input contexts (password fields, Touch ID prompts): synthetic typing dropped,
kAXValueAttributewrites through. - Selection-bearing rows that expose
kAXSelectedAttributebut no AXPress action: no AppleScript verb maps to AXSelected; onlyAXUIElementSetAttributeValueworks.
What Most Comparisons Get Right (And Where They Stop)
Pick almost any article comparing the two and you will get the same first-pass summary. AppleScript is a high-level scripting language with a per-app dictionary. The Accessibility API is a lower-level Objective-C interface for reading and acting on UI elements. AppleScript GUI scripting (the tell application "System Events" path) requires Accessibility permission. AppleScript also has application-level scripting where the app exposes its data model via SDEF; the Accessibility API only sees the UI tree.
Everything in that paragraph is correct. None of it explains why AppleScript-driven automation breaks on shipping macOS apps in 2026 while direct AX still works. The answer is in the gap: the wrapper relationship is not lossless. AppleScript exposes a subset of AX attributes and actions, and the AppleEvent boundary it routes through has its own failure modes that direct AX does not have.
macos-use is a Swift MCP server that drives macOS apps from Claude Code, Cursor, and other MCP clients. It does not call osascript anywhere. The reason is in the four cases below, each of which is a category of real shipping app where the wrapper stops working.
Where Each Path Wins And Loses
One row per failure category, plus the cases where AppleScript still has the upper hand. This is not a marketing comparison. Application-level scripting (the SDEF dictionary route) is a genuine AppleScript win that AX cannot replicate. The point of the table is to draw the boundary, not to declare a winner.
| Feature | AppleScript GUI scripting | macos-use (direct AX) |
|---|---|---|
| Catalyst right-pane button (e.g. Messages compose, Settings search) | synthetic click frequently dropped, no error returned | AXUIElementPerformAction with kAXPressAction actuates directly |
| Catalyst row selection (Messages conversation list, sidebar list) | select row N silently no-ops, AXSelected has no AppleScript verb | AXUIElementSetAttributeValue on kAXSelectedAttribute selects the row |
| Sandboxed calling process driving another app | blocked unless scripting-targets or temporary-apple-events entitlement is granted | direct AX calls only need standard Accessibility permission, no AppleEvent hop |
| Secure-input context (password fields, Touch ID prompts) | keystroke command dropped at the input event layer | AXUIElementSetAttributeValue on kAXValueAttribute writes through AX provider |
| Error semantics on a control the action does not support | silent no-op, no exception, script continues | kAXErrorActionUnsupported / kAXErrorAttributeUnsupported, branchable in code |
| Permission model required from the user | Accessibility permission plus per-target Automation permission per app | Accessibility permission once on the calling Swift binary |
| App-level data model (Mail messages, iCal events, Finder selections) | rich SDEF dictionaries, often the right tool when the app supports it | no equivalent; AX only sees UI elements, not the app's object graph |
| Discoverability of available actions on a target | Script Editor's Library opens the SDEF dictionary for browsing | AXUIElementCopyActionNames / CopyAttributeNames lists what the element supports |
Application-level AppleScript (talking to an app's SDEF dictionary, e.g. Mail's outgoing message object) is out of scope here. AX cannot do that. This page is about GUI scripting only, the part of AppleScript that goes through System Events.
Same Task, Two Worlds
Select the third row in the Messages conversation list. Messages ships as a Catalyst app on every recent macOS. The row exposes kAXSelectedAttribute but no AXPress action. There is no AppleScript verb that targets AXSelected directly, and the synthetic click that select row N issues is dropped on Catalyst rows. The two snippets below are the literal same operation done two ways. The left one returns with no error and the row stays where it was. The right one actuates.
Selecting row 3 in Messages: AppleScript silent no-op vs direct AX
-- Same task, the AppleScript way: select the third row in
-- the Messages conversation list (Messages is a Catalyst app).
-- This compiles, runs, and returns no error. The row does not select.
tell application "System Events"
tell process "Messages"
tell window 1
-- AppleScript has no verb for kAXSelectedAttribute.
-- "select" maps to a click, which is dropped on
-- Catalyst rows. The AX tree exposes AXSelected
-- but System Events cannot reach it.
select row 3 of table 1 of scroll area 1
end tell
end tell
end tell
-- Result on macOS 14.x and 15.x: no error, no selection.
-- The row stays where it was. The next command in the
-- script runs as if the selection succeeded. This is the
-- worst failure mode for automation: silent.The Worst Failure Mode: Silent No-Op
One agent, one mistake. Same target row in Messages. The agent chooses to wrap an AppleScript call vs calling direct AX. Watch what the agent gets back from each path.
An LLM agent selecting a Catalyst row
An agent issues a JSON-RPC call wrapping tell application System Events to select row 3 of table 1 of group 1 of window 1 of process "Messages". The osascript subprocess returns exit code 0 in 180ms. The script's last expression is missing value. The agent's next traversal still shows row 1 selected. The agent assumes the action succeeded based on the zero exit code, then types a message that lands in the wrong conversation thread. There is no log line, no exception, and no recoverable signal anywhere in the chain.
- osascript exit code 0, looks like success
- Last expression is missing value
- Row is not selected, agent does not know
- Next call lands in the wrong conversation
The Four Categories, By Name
Each one is a real category of shipping macOS app, not a theoretical edge case. macos-use ships a dedicated MCP tool for each, and the docstring on that tool literally names the failure mode it was added for. You can grep the source: the strings below are inside Sources/MCPServer/main.swift.
Catalyst right-pane controls
Catalyst apps embed a UIKit hit-test path inside an AppKit window. Synthetic mouse CGEvents posted at coordinates in the right pane sometimes never become UIControl actions, while the kAXPressAction action on the same element still actuates. macos-use_press_ax_and_traverse at main.swift:1459 is described as "Often the only path that actuates buttons in those apps". Real targets: Messages compose buttons, Settings search field, Reminders detail pane.
Selection-bearing rows that expose kAXSelectedAttribute
Catalyst tables and outline views expose kAXSelectedAttribute on each row. There is no AppleScript verb that maps to that attribute, and the click that AppleScript synthesizes for select row N is the same click that gets dropped. AXUIElementSetAttributeValue actuates. macos-use_set_selected_and_traverse at main.swift:1477 calls the docstring out by name: "where regular click is dropped and press_ax errors with kAXErrorActionUnsupported". Real targets: Messages conversation list, Mail mailbox list, sidebars across many shipping apps.
Secure-input contexts
macOS sets a process-wide secure-input flag when a password field is focused. CGEventCreateKeyboardEvent characters are silently dropped, and AppleScript's keystroke uses the same path. AXUIElementSetAttributeValue on kAXValueAttribute asks the AX provider to set the string directly, which works because the AX provider sits above the input event filter. macos-use_set_value_and_traverse at main.swift:1442 names the case: "Bypasses the input event tap entirely".
Sandboxed calling processes
An app sending an AppleEvent to System Events crosses a sandbox boundary. Without com.apple.security.scripting-targets (with the right access group for the target) or a temporary apple-events exception, the AppleEvent is rejected at the OS layer. Direct AX calls do not cross that boundary; they require only Accessibility permission for the calling process. This is why macos-use can drive an arbitrary set of apps from a single one-time grant, while an AppleScript-based equivalent would need a permission per target.
The Three MCP Tools That Exist Because Of These Failures
macos-use exposes nine MCP tools total. Six of them are the common case (open, click, type, press, scroll, refresh). The other three exist because of the four categories above. Each one is a thin wrapper around a single direct AX call.
macos-use_set_value_and_traversemain.swift:1440Calls AXUIElementSetAttributeValue with kAXValueAttribute on the element under (x, y). Used when synthetic typing is dropped.
Docstring: "Writes a string into the AX element under (x,y) via kAXValueAttribute. Bypasses the input event tap entirely. Use when typing fails (Catalyst right-pane fields, sandboxed/secure-input contexts)."
macos-use_press_ax_and_traversemain.swift:1457Calls AXUIElementPerformAction with kAXPressAction on the element under (x, y). Used when a synthetic mouse click is dropped.
Docstring: "Performs kAXPressAction on the AX element under (x,y). Use when a synthetic mouse click is dropped (Catalyst right-pane buttons, sandboxed apps). Often the only path that actuates buttons in those apps."
macos-use_set_selected_and_traversemain.swift:1475Calls AXUIElementSetAttributeValue with kAXSelectedAttribute. The primitive AppleScript cannot reach.
Docstring: "Sets kAXSelectedAttribute on the AX element under (x,y). Right primitive for Catalyst table rows, sidebar list entries, outline rows, and other selection- bearing controls that expose AXSelected but no AXPress action (where regular click is dropped and press_ax errors with kAXErrorActionUnsupported)."
Inside One Tool Call
Four moments. The agent does not pick the right primitive on the first try; it has to discover that this row has no AXPress action and fall back to AXSelected. That branching is impossible with AppleScript because the wrapper does not return the underlying error code.
Tool dispatch decides which AX entry point to use
main.swift's case block at line 831 picks set_value_and_traverse, line 839 picks press_ax_and_traverse, line 845 picks set_selected_and_traverse. The agent chooses based on the element's AXRole and AXActions in the previous traversal. The choice is data-driven, not hard-coded.
AXUIElementCreateApplication, then walk to the target
Source: main.swift:244-265. The tree is fetched fresh per call (with a 5-second AXUIElementSetMessagingTimeout per node), the target element is located by (x, y) hit-test or by traversal coordinates passed from the previous call, and the AX provider's attributes are read.
Direct AX action runs, error code is captured
Either AXUIElementSetAttributeValue (set_value, set_selected) or AXUIElementPerformAction (press_ax). The AXError return value is mapped to a JSON-RPC error string. Unlike AppleScript's silent no-op, kAXErrorActionUnsupported is a real signal the agent can branch on.
Tree is re-traversed, diff is returned
Whatever the action did or did not do, the next traversal captures the new state. The diff (added, removed, modified, attribute changes) is the agent's ground truth. If the row did not select, the diff is empty and the agent knows to fall back. If the field did not fill, the AXValue diff is empty.
What The Server Logs During The Fallback
One agent call on a Messages conversation row. The agent tried press_ax first (the cheapest action), got kAXErrorActionUnsupported, and switched to set_selected. The whole sequence is visible in stderr, which matters because an AppleScript-equivalent run would have produced no log at all.
“System Events is just a wrapper around public [Obj]C system APIs, so you could bypass AppleScript and call those APIs directly.”
Verify The Four Cases On Your Own Machine
None of this needs trust. Open Script Editor and Accessibility Inspector, both ship with macOS. Eight steps, two minutes per case, all four failure modes reproducible on a stock Mac.
Reproduce each failure on a clean Mac
- Open Script Editor on macOS 14 or later
- Run: tell application "System Events" to set value of text field 1 of window 1 of process "Messages" to "hello"
- Confirm the field stays empty and no error is thrown (silent failure)
- Open Accessibility Inspector, point at the same field
- Set its AXValue attribute to "hello" via the inspector
- Confirm the field fills
- Repeat with the conversation list rows in Messages: AppleScript select row N silently no-ops; setting AXSelected via the inspector selects the row
- Repeat with a password field on the system Lock Screen Settings page: AppleScript keystroke is dropped; AXValue write fills
When AppleScript Is Still The Right Answer
Application-level scripting. If the app you are driving has an SDEF dictionary, AppleScript talks to the data model directly, not the UI. You can tell application "Mail" to make new outgoing message and the request hits Mail's scripting interface, not the accessibility tree. AX has no equivalent. There is no way to reach Mail's outgoing message object from AXUIElement because it is not in the UI tree.
For Mail, Finder, Numbers, Pages, Music, BBEdit, and OmniFocus, an SDEF-based AppleScript is faster, more readable, and more robust than poking at the UI. Anything that has a published scripting interface should use it.
The case macos-use is making is narrower: when the agent has to drive an app whose data model is not exposed via SDEF, or whose UI was built with Catalyst, or whose process is sandboxed, or whose current focus is a secure-input field, AppleScript GUI scripting alone is not enough. Direct AX is the layer that fills those gaps. The right stack on macOS in 2026 is application-level AppleScript where available, plus direct AX everywhere else.
Hitting one of these four cases in your own automation?
Walk through your specific app and target with the macos-use maintainers. Bring the AppleScript that is silently failing and we will trace it to the AX primitive that fixes it.
Frequently asked questions
Is AppleScript GUI scripting just a wrapper around the Accessibility API?
Yes, on the part that drives UI. Apple's Technical Q&A QA1888 puts it plainly: 'System Events is just a wrapper around public [Obj]C system APIs, so you could bypass AppleScript and call those APIs directly.' Specifically, tell application System Events to click button X resolves to AXUIElementCopyAttributeValue and AXUIElementPerformAction calls under the hood. AppleScript also has language-level features (events, dictionaries, scripting additions) that are orthogonal to AX, but for GUI scripting the wrapper claim is accurate.
If AppleScript wraps the Accessibility API, why would it ever fail when direct AX calls succeed?
Three reasons. One: AppleScript GUI scripting routes every command through the System Events process, so the request crosses an AppleEvent boundary that can be denied (sandbox without scripting-targets entitlement, missing apple-events entitlement, no automation permission for the calling app). Two: AppleScript exposes only a subset of AX attributes and actions; kAXSelectedAttribute, kAXValueAttribute writes on read-only-via-keyboard fields, and several Catalyst-specific attributes have no AppleScript verb. Three: AppleScript GUI scripting often resolves clicks to synthetic events that go through the same input event tap that secure-input or Catalyst right-pane controls drop, while AXUIElementPerformAction(element, kAXPressAction) bypasses the input layer and asks the control directly.
What is a Catalyst right-pane control and why does it drop synthetic clicks?
Catalyst apps are iPad apps recompiled for macOS. They embed a UIKit view in an AppKit window. AppKit owns the title bar, sidebar, and toolbar; the right pane is a hosted UIKit surface. Synthetic CGEvents posted at coordinates inside that pane sometimes never become UIControl actions because the UIKit hit-testing path is reached through a different chain than the AppKit one. The control still appears in the accessibility tree because Catalyst bridges UIAccessibility into AX, and the kAXPressAction action still exists on it. So AXUIElementPerformAction works where a CGEventCreateMouseEvent does not. macos-use_press_ax_and_traverse at main.swift:1459 was added specifically because of this. Its docstring reads 'Often the only path that actuates buttons in those apps'.
Does sandboxing affect direct AX calls the same way it affects AppleScript?
No. The two paths cross different trust boundaries. AppleScript GUI scripting requires an AppleEvent from the calling process to System Events, then from System Events to the target app. A sandboxed calling process needs the com.apple.security.scripting-targets entitlement (with a per-app access group) or the temporary com.apple.security.temporary-exception.apple-events entitlement to make that first hop. The target also has to have published an SDEF or be reachable via System Events. Direct AX calls (AXUIElementCreateApplication, AXUIElementCopyAttributeValue, AXUIElementPerformAction) only require the standard Accessibility permission for the process making the calls. macos-use runs as a local Swift binary the user grants Accessibility permission to once, so the calling process is on the right side of that boundary. There is no AppleEvent hop.
What is a secure-input context and why does it drop typed characters?
macOS sets a process-wide secure-input flag when a password field, a Touch ID prompt, or a screen-locked keychain dialog is focused. While that flag is set, characters posted via CGEventCreateKeyboardEvent are silently dropped before reaching the focused control. AppleScript's keystroke command goes through the same path and silently drops too. AXUIElementSetAttributeValue with kAXValueAttribute writes through a different code path: it asks the AX provider to set the underlying string directly. So macos-use_set_value_and_traverse at main.swift:1442 can fill a text field whose synthetic typing was being dropped. The docstring is explicit: 'Bypasses the input event tap entirely. Use when typing fails (Catalyst right-pane fields, sandboxed/secure-input contexts).'
What is kAXSelectedAttribute and why is it the cleanest example of a primitive AppleScript cannot reach?
kAXSelectedAttribute is a boolean attribute on selectable controls (table rows in Catalyst tables, sidebar list entries, outline rows). The AX provider exposes it; setting it to true selects the row. There is no AppleScript verb that maps to it directly. tell application System Events to select row 3 of table 1 reaches a different code path that often does not actuate, and on Catalyst tables specifically the click that AppleScript synthesizes gets dropped. AXUIElementSetAttributeValue(rowElement, kAXSelectedAttribute, kCFBooleanTrue) actuates. macos-use_set_selected_and_traverse at main.swift:1477 exists for this case. Its docstring names the failure mode: 'Right primitive for Catalyst table rows, sidebar list entries, outline rows, and other selection-bearing controls that expose AXSelected but no AXPress action (where regular click is dropped and press_ax errors with kAXErrorActionUnsupported).'
If AppleScript is so much weaker, why has it survived this long?
Because most apps that ship on macOS are not Catalyst, are not sandboxed, do not use secure input, and do not have selectable Catalyst rows. For everything else, AppleScript is faster to write, has a higher-level vocabulary (tell application Mail to make new outgoing message), and a discoverable dictionary via Script Editor's Library. The AppleScript ecosystem also supports application-level scripting, where the app exposes its data model via SDEF and AppleScript talks to that model rather than the UI. AX cannot do that. The argument here is not that AppleScript is dead. It is that for an LLM driving arbitrary apps on a user's Mac, the bottom 5 percent of cases that AppleScript cannot reach is exactly the long tail an agent will hit, so the agent has to have direct AX as a fallback.
Does macos-use ever call AppleScript or osascript?
No. The server is pure Swift on top of ApplicationServices (the framework that ships AXUIElement) and CoreGraphics (for synthetic events when those still work). There is no NSAppleScript instance, no osascript shell-out, no AEPathDesc construction, no apple-events entitlement requested in the binary's Info.plist. The repo's Sources/MCPServer directory has 2056 lines in main.swift and 355 in InputGuard.swift, and none of them touch the AppleEvents framework. You can verify with grep AppleScript or grep osascript on the repo and you will get zero hits.
What does the failure look like when AppleScript silently no-ops on a Catalyst row?
tell application System Events to select row 3 of table 1 of group 1 of window 1 of process "Messages" returns without an error. The row visibly does not get selected. There is no exception and no error string in the result. The next AppleScript command runs against the original row. This is the worst possible failure mode for an automation: silent, no signal, and the script keeps going as if the action succeeded. Direct AX surfaces an error code (kAXErrorAttributeUnsupported, kAXErrorActionUnsupported, or kAXErrorCannotComplete) that the calling code can branch on. macos-use treats those error codes as a recoverable signal: press_ax_and_traverse errors with kAXErrorActionUnsupported tell the agent to switch to set_selected_and_traverse instead.
How do I verify the four failure cases on my own machine?
Open Script Editor and try tell application System Events to set value of text field 1 of window 1 of process "Messages" to "hello". On Messages (Catalyst), the command returns without throwing and the field stays empty. Now run AXUIElementSetAttributeValue on the same field via Accessibility Inspector or the macos-use binary. The field fills. Same setup with the search field in Settings (Catalyst right pane), with any password input (secure-input context), and with the conversation list rows in Messages (Catalyst rows that expose AXSelected but no AXPress). Two minutes per case from a fresh Script Editor window. The error of omission is the whole signal.
Is this why the macos-use server is built on Swift instead of a Python AppleScript wrapper?
Yes. The Swift MCP server has direct access to AXUIElement APIs, can post CGEvents with the right source and stateID, can install a CGEventTap to arbitrate input, and can fall back to AXUIElementSetAttributeValue when CGEvents are dropped. None of that is reachable from Python through AppleScript. py-applescript and appscript are wrappers around osascript, so they inherit every AppleEvent-boundary limitation. PyObjC could call AX directly but it carries the AppleEvents-and-NSWorkspace assumption stack with it. Swift on top of ApplicationServices is the smallest layer that can do the four cases above.
Comments (••)
Leave a comment to see what others are saying.Public and anonymous. No signup.