Automation tools for Windows that refuse to guess

Every listicle of automation tools for Windows ranks Power Automate, AutoHotkey, UiPath, and WinAppDriver by features. None of them mention the one design decision that cuts the most common flake in half. Terminator's Windows engine refuses to run a selector like role:Button && name:OK because it cannot tell you which OK, in which app, on which desktop.

M
Matthew Diakonov
8 min read
4.9from early design partners
One helper, sixteen lines, enforced in one place
Rejection lands in microseconds, not a 500ms UIA timeout
Error message prints three working process: examples inline

The cross-app ghost bug

Pick any traditional automation tool for Windows. Write a click on a button called OK. If another window on the desktop also has a button called OK (a background Outlook prompt, a Teams dialog, a Windows Update banner), the UIA root will list both. Whichever one the tree walker hits first wins. Your test passed on Tuesday and fails on Wednesday because a Slack notification shifted z-order.

This is the single most common class of flake in Windows automation, and it is the one class of flake that a query engine can rule out by design. A selector either explicitly says which process it is targeting, or it does not. If it does not, the engine should refuse.

Terminator is the only framework I know of that takes this position. The enforcement is sixteen lines of Rust at the top of its Windows engine, and the refusal is one early return inside the function every selector must go through.

The helper: selector_has_process_scope

A single recursive function over the Selector enum. It returns true only when some leaf of the expression is Selector::Process(_). Every combinator is handled explicitly so nested scopes still count.

engine.rs
1 line

if root.is_none() && !selector_has_process_scope(selector)

crates/terminator/src/platforms/windows/engine.rs line 1020. The single check that gates every find_elements call on the Windows backend.

The enforcement: find_elements

Every selector the Windows backend executes passes through find_elements. One early return at the top of the function rejects unscoped queries before any COM call crosses the process boundary.

engine.rs

What the rejection looks like

The first line fails. The error prints three ready-to-copy examples. The second line succeeds on the first try.

claude code + terminator-mcp-agent

How the walker flows

One entry point, one helper, one rejection path. The caller learns about the refusal before Windows UIA is even asked to start a search.

find_elements with an unscoped selector

Callerfind_elementsscope_helperUIAfind_elements(selector, root=None)selector_has_process_scope(selector)recurse: And/Or/Not/Chain/Has/spatialfalse (no Selector::Process found)Err(InvalidSelector) // engine.rs:1022(UIA never contacted)

The AST walk, in one picture

The helper is a recursive visitor. Each combinator decides how to forward the question to its children. One Selector::Process(_) anywhere makes the whole expression valid.

selector_has_process_scope descends into every branch

Selector::And
Selector::Or
Selector::Chain
Selector::Not
Selector::Has
Selector::RightOf et al
selector_has_process_scope
true
false
-> find_elements

What every other Windows automation tool does instead

None of these refuse an ambiguous query. Some of them do not have a query engine at all. The list is not a knock, it is a map of where this guardrail is missing.

Power Automate DesktopAutoHotkeyUiPathAutoItpywinautoWinAppDriverFlaUITestStack.WhiteRanorexWinAutomationBlue PrismTinyTask

Side by side

FeatureOther automation tools for WindowsTerminator
Engine rejects unscoped selectorsNo (walks desktop root silently)InvalidSelector at engine.rs:1020
Recursive AST check for process scopeNo AST, string matchers onlyselector_has_process_scope at engine.rs:184
Rejection before any UIA callHits UIA, then times outEarly return, microseconds
Scope nested inside Chain/And/Or still countsVaries, often not recognized.any(selector_has_process_scope) on children
Spatial anchors (rightof:, near:) require scoped anchorNot supported at allAnchor's selector is walked too
Error message includes working examplesUsually opaqueThree process:... examples inline

Rejected vs accepted selectors

The only difference between the left and right columns is whether a process: prefix exists somewhere in the AST. The shape of the query does not matter; only the presence of scope does.

engine.rs:1020 decides

// REJECTED by engine.rs:1020
//
// Matches any Button named "OK" on the whole desktop. In practice
// this picks whichever dialog the UIA root lists first, which is
// non-deterministic across runs.

role:Button && name:OK
role:Edit && name:Search
window:Login >> role:Button
(role:Button && name:Save) || (role:Button && name:Submit)
rightof:name:Filename >> role:Button

// All five have no Selector::Process(_) anywhere in their AST.
// selector_has_process_scope returns false. find_elements returns
// AutomationError::InvalidSelector before touching UIA.
-13% fewer lines

Concrete numbers from the source

0lines in selector_has_process_scope
0engine.rs line of the helper
0engine.rs line of the enforcement
0example selectors in the error message
0

Selector combinators the AST walker recurses into before falling back to false: And, Or, Not, Chain, Has, plus five spatial anchors (RightOf, LeftOf, Above, Below, Near).

0

UIA calls made when the rejection fires. The early return at engine.rs:1020 runs before any COM instance is touched, so an unscoped selector costs microseconds of pattern matching, not a UIA timeout.

0

Typed MCP tools in terminator-mcp-agent that go through this guardrail. click_element, type_into_element, validate_element, and the rest all delegate to find_elements.

The fix in your own code

The SDKs expose two escape hatches. Prefix the selector with process:, or grab the application root first and use element.locator(). Either counts as scoping to the engine.

scope-your-selectors.ts

What happens on a real click

Five steps from the MCP tool call to the UIA hit, with the guardrail sitting between steps two and three.

1

MCP tool call arrives

Claude Code sends click_element with a selector string. The MCP agent parses the string into a Selector AST (role:Button, And, Process, Chain, ...) using the Shunting Yard grammar.

2

find_elements is called

The dispatch in terminator-mcp-agent hands the Selector to WindowsEngine::find_elements(selector, root=None). Root is None because the MCP tool call did not pass an explicit container.

3

selector_has_process_scope walks the AST

The helper descends into every combinator. It returns true at the first Selector::Process(_) leaf it finds. If the walk completes without one, the helper returns false.

4

Engine decides

If the helper returned false, find_elements returns AutomationError::InvalidSelector with the three-example message. If it returned true, the engine proceeds to the UIA search.

5

UIA search runs, or does not

On a scoped selector, the engine walks the process's window tree and returns matches. On an unscoped one, the MCP tool returns the error to the client, and the model gets a one-shot correction it can act on in its next call.

Verify in the repo

Three grep lines. The helper, the enforcement, and the error string all live in the same file on mediar-ai/terminator.

zsh

Curious whether this guardrail would catch the flakes in your Windows automation?

Book 20 minutes and we will run your worst selectors through the engine together and show you the exact reject paths.

Frequently asked questions

What makes Terminator different from other automation tools for Windows?

Terminator is a developer framework, not a consumer RPA canvas. It exposes Windows UI Automation as a Playwright-shaped API plus an MCP server your AI coding assistant can drive directly. The one feature other automation tools for Windows do not have: the engine refuses to run a selector that does not include a process: scope. That one guardrail kills the most common class of flake, where a selector like role:Button && name:OK silently matches a confirmation dialog in a different process.

Where is the guardrail implemented in source?

Two places. First, the recursive helper selector_has_process_scope at crates/terminator/src/platforms/windows/engine.rs line 184. It walks the Selector AST through every combinator (And, Or, Not, Chain, Has, RightOf, LeftOf, Above, Below, Near) and returns true only when some branch is a Selector::Process(_). Second, the enforcement point at engine.rs line 1020, inside find_elements, which returns AutomationError::InvalidSelector with the message "Desktop-wide search not allowed. Selector must include 'process:' prefix to scope search to a specific application." if the helper returns false and no explicit root element was passed.

Why do other automation tools for Windows not enforce this?

Historical reasons, mostly. Power Automate Desktop and UiPath descend from RPA tooling where the user points at a control in a recorder and the runtime stores a screenshot plus a partial property path. AutoHotkey is a scripting language with window-title matchers, no query AST. WinAppDriver follows Selenium's model where every query is scoped to a session that already knows its app. Playwright-on-desktop ports and pywinauto accept bare selectors and walk the desktop root if you let them. None of these tools have a single place where the query engine can reject an ambiguous query on principle.

Can I still do a desktop-wide search when I actually want one?

Yes, but you have to be explicit. Pass a root element to find_elements (via element.locator() in the SDKs). The check at engine.rs line 1020 reads if root.is_none() && !selector_has_process_scope(selector). If root is Some, the guardrail does not fire, on the assumption that you already scoped the search by picking the root yourself. There is no desktop-walk flag; if you want to walk the desktop, you ask the desktop element for it directly.

Which selector combinators does the AST walker descend into?

All the ones that contain other selectors. Selector::And and Selector::Or iterate their children with .any(selector_has_process_scope). Selector::Not recurses into its inner. Selector::Chain does the same. Selector::Has unwraps its inner. Selector::RightOf, LeftOf, Above, Below, Near all descend into the anchor selector. Leaf selectors (Role, Name, Id, Text, Path, NativeId, Attributes, ClassName, Visible, LocalizedRole, Filter, Nth, Parent, Invalid) return false unless they are Selector::Process(_). The walker is intentionally recursive so that process:chrome >> role:Button && name:Submit, with process: nested deep inside a Chain, still counts as scoped.

What does the rejection actually look like at runtime?

You get an AutomationError::InvalidSelector. The message includes three worked examples the engine prints so you can copy and adapt: process:chrome >> role:Button && name:Submit, process:notepad >> role:Document, and process:explorer >> role:Icon && name:Recycle Bin. The error is thrown before any UIA call is made, so you pay microseconds, not a 500ms UIA timeout. On the MCP side, the error bubbles up as a tool error on the calling client, which means Claude Code, Cursor, or Windsurf see an explicit scope reminder instead of a timeout or the wrong click.

Does this hurt the AI coding assistant use case, where the agent does not know which process to pick?

The opposite. The tool description strings in crates/terminator-mcp-agent/src/server.rs tell the model the expected selector shape, and when the model forgets, the InvalidSelector error gives it a one-shot correction with three examples inline. In practice this is the fastest way to teach a model the selector grammar: one rejected call, one readable fix. Compare to tools that silently match a wrong button and then try to act on it.

terminatorDesktop automation SDK
© 2026 terminator. All rights reserved.