Legacy Windows applications with no APIs and no modern interfaces are some of the hardest automation targets — and some of the most valuable. This expert-level lesson teaches you to automate them reliably using Power Automate Desktop's UI automation engine, from accessibility tree inspection to production-hardened error recovery.

Picture this: your company's ERP system is a 1998 client-server application built in Visual Basic 6. It has no API. No database you're allowed to touch directly. No web interface. Every morning, a team of three people opens it, navigates through seven screens, copies invoice data into Excel, and then manually re-enters that same data into a newer system. It takes four hours. Every single day.
This is the world UI automation was built for. Legacy Windows applications — the ones that predate REST APIs, cloud integrations, and modern accessibility standards — are still deeply embedded in enterprise operations across manufacturing, healthcare, logistics, insurance, and government. They're not going anywhere soon, because replacing them would cost millions and risk operational continuity. Instead, you automate them. You build a robot that operates the application exactly the way a human would: by reading the screen, clicking controls, typing text, and reading output back. That's what Power Automate Desktop's UI automation engine does.
By the end of this lesson, you'll have a genuine, production-grade understanding of how to automate legacy Windows applications — not just the happy path, but the messy reality of brittle selectors, timing problems, non-standard controls, and dynamic content that breaks every naive automation within a week.
What you'll learn:
This lesson is written for expert practitioners. You should already be comfortable with:
You should have Power Automate Desktop installed with at least a Power Automate per-user or per-flow license, and administrative access to a Windows machine where you can run test applications.
Before writing a single action, you need to understand what's actually happening when Power Automate Desktop interacts with a window. This isn't just academic — the technology stack directly determines what will and won't work with a given legacy application.
Windows has accumulated several generations of accessibility technology, and legacy applications can use any one of them depending on when they were built and what framework they use.
Win32 / MSAA (Microsoft Active Accessibility) — applications built with MFC, VB6, Delphi, or raw Win32 API. These expose controls through IAccessible interfaces. MSAA was designed in the mid-1990s and is quite limited — it identifies controls by their window handle (HWND), text, and a fairly coarse role taxonomy. Most legacy line-of-business applications from the 1990s and early 2000s live here.
UIA (UI Automation) — Microsoft's successor to MSAA, introduced with Windows Vista. WPF, WinForms (from .NET 2.0+), and modern Win32 applications use UIA natively. UIA provides a much richer property model — AutomationId, ControlType, patterns like ValuePattern, SelectionPattern, GridPattern — making it vastly more reliable for automation.
Java Access Bridge — Java Swing and AWT applications expose themselves through a separate bridge that translates between Java's accessibility model and MSAA/UIA. Power Automate Desktop can reach these, but you need the Java Access Bridge enabled (it's in the JRE configuration), and selector reliability varies significantly.
Owner-drawn and custom controls — this is where things get hard. Some applications, particularly those built with toolkits like Infragistics, DevExpress, or custom C++ UI frameworks, render their own controls entirely. From the accessibility layer's perspective, these often appear as a single opaque window with no discoverable child elements. More on how to handle these later.
Power Automate Desktop's UI automation engine negotiates across all these frameworks automatically. When it encounters a control, it tries UIA first (most capable), falls back to MSAA, and uses window messages (SendMessage, PostMessage) as a last resort for truly primitive Win32 controls. The selector you build in the designer is a query against whichever tree the framework exposes.
The critical insight: the quality of your automation is bounded by what the accessibility framework exposes. If an application has garbage accessibility support, you're working against a diminished information surface, and you'll need different strategies than if you're automating a well-instrumented WinForms application.
Key insight
Before starting any automation project against a legacy application, spend 30 minutes with Accessibility Insights for Windows (a free Microsoft tool) or UIA Verify to inspect what the accessibility tree actually looks like. This diagnostic step will save you days of frustration. Applications that look simple on screen can be nightmares in the accessibility tree, and vice versa.
The single most valuable skill in legacy UI automation is knowing how to inspect an application's accessibility tree before you write any actions. Power Automate Desktop's built-in element picker is useful, but it gives you a narrow view. For serious work, you need dedicated tools.
Accessibility Insights (download from the Microsoft store or accessibilityinsights.io) gives you a live, hierarchical view of the entire accessibility tree. Open it alongside your legacy application and use "Live Inspect" to hover over controls. You'll see:
The ClassName is often your most reliable anchor for legacy Win32 apps. VB6 controls have predictable class names: VB.TextBox renders as TextBox, VB.ComboBox as ComboBox. MFC controls use standard Win32 class names like Edit, ListBox, ComboBox. These are far more stable than text-based identifiers.
Here's what a typical VB6 form inspection might reveal:
Window: "Invoice Entry - ACME ERP v3.2"
ClassName: ThunderRT6Form
└─ Panel: ""
ClassName: ThunderRT6Frame
└─ Edit: ""
ClassName: ThunderRT6TextBox
AutomationId: (none)
Name: (empty)
Index: 1
└─ Edit: ""
ClassName: ThunderRT6TextBox
AutomationId: (none)
Name: (empty)
Index: 2
└─ ComboBox: "Open"
ClassName: ThunderRT6ComboBox
Name: "Status"
Index: 0
Notice what's missing: AutomationIds. In VB6, controls were never given accessibility names by default unless the developer explicitly set them. This means you can't build selectors based on AutomationId — you'll need to use a combination of ClassName, ordinal index, and positional properties. This is exactly the kind of thing that causes automations to break when forms are resized or controls are added.
Warning
Never build a selector entirely on ordinal index (the Nth control of type X). Index-based selectors break the moment someone adds or removes a control from the form, which in legacy apps can happen during patches. Always combine index with at least one stable structural property, and prefer ClassName or Name where available.
Power Automate Desktop's element picker operates in "spy mode" — you hover and click to capture a single element at a time. This is fine for initial capture, but after capture you should always open the selector editor and verify the generated selector. The recorder tends to be conservative, adding more attributes than you actually need, which paradoxically makes selectors more brittle rather than less. You want the minimum set of attributes that uniquely identifies the control.
Selector engineering is where the real work of legacy automation happens. Let's walk through a realistic scenario: automating an invoice lookup in a VB6 ERP system.
Power Automate Desktop selectors are expressed as a tree of conditions, typically serialized like this when you inspect them:
:desktop > window[Class="ThunderRT6Form"][Name="Invoice Entry - ACME ERP v3.2"]
> window[Class="ThunderRT6Frame"]
> window[Class="ThunderRT6TextBox"][Instance="0"]
Each node in the tree corresponds to a level in the UI hierarchy. The Class attribute is the Win32 ClassName. Instance is the zero-based index of matching siblings.
The default captured selector will often include the exact window title and every intermediate container. This is a problem for legacy apps where:
Here's how to harden each level:
Window title matching: Use a partial match or regex. Instead of [Name="Invoice Entry — INV-00432"], use [Name contains "Invoice Entry"] or match on the application executable via the Process attribute: window[Process="ACME_ERP.exe"]. Combining ClassName and Process is often more stable than any text-based match.
Intermediate containers: Trim unnecessary intermediate nodes. If the TextBox is always a child of the ThunderRT6Form regardless of panel nesting, you can often collapse the selector to skip intermediate containers by using a descendant axis rather than a strict parent-child chain.
Instance indexes: When you must use Instance, document why in a comment variable, and consider building a verification step that reads back the control's current value before interacting — if you're on the wrong control, better to know immediately.
Some legacy controls are truly opaque. A common example is a custom grid control (like the Sheridan SSGrid popular in VB6 apps) that renders its own cells and exposes zero UIA children. From the accessibility tree, it looks like a single rectangle.
For these scenarios, you have several strategies:
Strategy 1: Keyboard navigation. Tab through the application exactly as a user would. Use the Send Keys action with Tab, Enter, arrow keys, and shortcut keys. This is the most reliable strategy for truly opaque controls because it doesn't depend on the accessibility tree at all.
Strategy 2: Click by coordinates relative to the window. Use Click UI Element at Coordinates or the raw Move Mouse and Click Mouse actions with coordinates calculated relative to the application window position. This works but breaks if the window is resized or moved. Mitigate this by always maximizing the window at the start of the flow and using relative coordinates.
Strategy 3: Clipboard-based data extraction. Many legacy applications support clipboard operations even if they don't expose accessibility data. Navigate to a cell using keyboard, then send Ctrl+C and read the clipboard with Get Clipboard Text. This is surprisingly reliable and fast.
Strategy 4: Screen reading with OCR. Power Automate Desktop includes OCR actions that can read text from a region of the screen. This is the method of last resort because it's slow and sensitive to display scaling, font rendering, and screen resolution. But for applications that expose literally nothing via accessibility APIs, it can be the only option.
Tip
When you're forced to use coordinate-based clicking, always start your flow by setting the application window to a known state: maximize it, then capture the window's position and size programmatically using Get Window Details. Store the top-left coordinates and calculate all click targets relative to those values. If you hardcode absolute screen coordinates, your automation will fail the first time someone runs it on a laptop with a different screen resolution.
With the theory established, let's look at the specific action patterns you'll assemble into production flows.
Legacy applications must often be launched in a specific way — sometimes with a particular working directory, sometimes with command-line arguments that set the user context or environment.
# Action: Run Application
Application path: C:\Program Files (x86)\ACME\ERP\ACME_ERP.exe
Command line arguments: /env:PROD /user:svc_rpa
Working directory: C:\Program Files (x86)\ACME\ERP
Window style: Normal
Wait for application to load: Yes (5 seconds)
Store result in: %ERPProcess%
After launch, use Wait for Window to synchronize with the application's actual ready state rather than relying on a fixed delay:
# Action: Wait for UI Element to Appear
UI element: [Invoice Entry main window]
Timeout: 30 seconds
Fail flow on timeout: Yes
If the application is already running (common in attended automation scenarios where a human is also using the machine), use Attach to Running Application instead of launching a new instance. Attaching is done by process name, window title, or PID — store the %ERPProcess% handle for later cleanup.
Win32 menu bars are generally well-exposed through MSAA, but the interaction model can be tricky. The Click UI Element action works for menu items, but you need to capture the entire menu path.
A more reliable approach for applications that support keyboard shortcuts is Send Keys:
# Navigate to File > Import > Invoice Batch
Send Keys: %{F} (Alt+F to open File menu)
Wait: 300ms
Send Keys: {I} (I for Import submenu)
Wait: 300ms
Send Keys: {B} (B for Invoice Batch)
The timing waits (300ms between key sends) are essential. Menus in old Win32 apps have animation and rendering delays that don't signal through any accessibility event. A fixed short delay after each key press is the most reliable synchronization for menu navigation.
Warning
Avoid setting Wait intervals below 200ms for menu navigation in VB6 and Delphi applications. These frameworks process keyboard messages synchronously on the UI thread, and if your automation sends the next key before the menu renders, it gets silently dropped — no error, just missed navigation. 300-500ms is a safe working default; tune down only with explicit testing.
Reading text from an Edit (text box) control is straightforward with Get UI Element Detail:
# Action: Get details of UI element
UI Element: [Invoice Number TextBox]
Attribute: Own text
Result variable: %InvoiceNumber%
For combo boxes, the Get Selected Option action gives you the currently selected text. For list boxes, you'll iterate through selections using index-based actions.
Reading from label controls is where things get interesting. In VB6, Label controls often display calculated values (totals, status text). These expose their value through the Name property in MSAA (because labels don't accept input, there's no "value" in the traditional sense). Always check which attribute actually contains the displayed text in your inspection tool before building the action.
Setting values in text boxes uses Set Text Field Value or the more general Set UI Element Value:
# Action: Set text field value
Text field: [Vendor Code TextBox]
Text to set: %VendorCode%
Under the hood, this uses the UIA ValuePattern.SetValue() or sends WM_SETTEXT via SendMessage for MSAA-only controls. Both approaches bypass normal keyboard input — which is usually what you want for speed, but can cause problems if the application has Change event handlers that fire on each keystroke (for validation as you type). If you find that SetValue triggers no validation, switch to Send Keys which simulates actual keystrokes and fires change events normally.
For ComboBoxes in legacy apps, Select List Item using the item's text value is the most reliable approach. If that fails (which happens with owner-drawn comboboxes), send the Down arrow key to open the dropdown, then search for the item using Send Keys filtering.
Legacy applications are full of modal dialogs — confirmation prompts, error messages, progress indicators. These must be handled in your flow or they'll block execution indefinitely.
The key pattern is: after every action that might trigger a dialog, use Wait for UI Element to Exist with a short timeout, checking for both the expected next state AND any known dialog conditions. Use an If UI Element Exists check:
# After clicking Save button:
# Check if success confirmation appeared
If UI Element Exists: [Save Confirmation Dialog] →
Click [OK Button in Confirmation]
Else If UI Element Exists: [Duplicate Invoice Warning] →
Log "Duplicate detected for %InvoiceNumber%"
Click [Cancel Button]
Set %ProcessingError% = "Duplicate"
Else If UI Element Exists: [Main Form — next state indicator] →
# Save succeeded without a dialog — proceed normally
Continue
Else:
# Neither appeared within timeout — something is wrong
Raise custom error "Unknown state after Save"
This pattern — check multiple possible outcomes explicitly — is what separates production-grade automation from demo automation. The happy path check alone will work in testing and fail in production on the first unexpected dialog.
Key insight
Build a library of dialog-handling sub-flows for each legacy application. Every popup that can appear — validation errors, session timeouts, network warnings, license messages — should have an explicit handler. Start by running the application manually for a week while logging every dialog you encounter. You'll be surprised how many edge cases appear that you'd never think to test.
Timing and synchronization are the #1 source of failures in legacy application automation. The application doesn't know it's being automated, so it provides no signals about when it's ready for the next input. You have to figure this out yourself.
# BAD: Don't do this
Click [Search Button]
Wait: 5 seconds
Click [First Result Row]
Fixed delays are the most common mistake. Five seconds works most of the time, but when the database query takes six seconds (because it's a Monday morning and twenty people just logged in), your flow clicks into an empty grid and fails. When the query takes one second (cached result), you've wasted four seconds multiplied by every iteration.
Power Automate Desktop provides several waiting primitives:
Wait for UI Element to Appear: Blocks until a specific element becomes visible or enabled. Use this after actions that navigate to a new screen.
Wait for UI Element to Vanish: Blocks until an element disappears. Perfect for progress bars, "Please wait..." overlays, and spinning indicators.
Wait for Window: Waits for a window matching title/class criteria to become active.
Get UI Element Detail in a loop: For applications where the "ready" signal is a label changing text (e.g., "Processing..." → "3 records found"), poll the label's value in a loop with a short sleep:
# Wait for search results with polling
Set %WaitCount% = 0
Set %MaxWait% = 60 # 60 iterations × 500ms = 30 second max
Loop Until %ResultStatusLabel% <> "Searching..." OR %WaitCount% >= %MaxWait%:
Get UI Element Detail: [Status Label] → %ResultStatusLabel%
Wait: 500 milliseconds
Set %WaitCount% = %WaitCount% + 1
If %WaitCount% >= %MaxWait%:
Raise error "Search timed out after 30 seconds"
This approach adapts to actual application performance while giving you a defined maximum wait time and a meaningful error when that limit is exceeded.
Legacy applications — especially client-server ones connected to mainframes or remote databases — will time out user sessions during long-running automations. Your flow must handle this mid-execution.
The pattern: before every major interaction sequence, perform an "liveness check." This can be as simple as checking if the expected window is in focus, or reading a known element's property. If the check fails, execute a re-login sequence.
Build the re-login sequence as a reusable sub-flow:
# Sub-flow: ReLoginERP
# Called whenever session state is uncertain
Wait for UI Element to Appear: [ERP Login Dialog] within 5 seconds
If element appeared:
Set Text Field: [Username Field] → %ERPUsername%
Set Text Field: [Password Field] → %ERPPassword% # Read from secure variable
Click: [Login Button]
Wait for UI Element to Appear: [ERP Main Menu] within 30 seconds
If element did not appear:
Raise error "Re-login failed — manual intervention required"
Credential storage for unattended scenarios should use Windows Credential Manager via the Get Credential action, not hardcoded strings. For enterprise deployments, consider using the pattern described in Integrating Power Automate with Azure Key Vault and Managed Identities for centralized secrets management that works alongside Desktop flows.
Data extraction from legacy application grids is one of the most complex challenges you'll face. Let's work through the main scenarios.
Modern WinForms DataGridView and ListView controls expose GridPattern and TablePattern through UIA, giving you row/column access. Power Automate Desktop's Extract Data from Table action handles these directly and produces a DataTable variable — exactly what you want.
Verify pattern support in Accessibility Insights: look for GridPattern, TablePattern, or TableItemPattern on the grid control. If they're present, the built-in extraction will work.
VB6 grids (MSFlexGrid, SSGrid), Delphi StringGrids, and many third-party grid controls don't expose GridPattern. For these, your options are:
Option A: Row-by-row navigation with keyboard.
Click the first row, use arrow keys and Tab to navigate cells, read each cell's content via clipboard (Ctrl+C), advance to the next cell, repeat. This is reliable but slow — fine for tens of rows, painful for hundreds.
# Extract grid data row by row
Create empty DataTable: %ExtractedData% with columns [InvoiceNo, Date, Amount, Status]
Set %RowIndex% = 0
Set %EndOfGrid% = False
Loop Until %EndOfGrid% = True:
# Navigate to first column of current row
Send Keys to [Grid Control]: {HOME}
Wait: 100ms
# Extract each column via clipboard
Send Keys: ^{c} # Ctrl+C
Wait: 100ms
Get Clipboard Text → %Col1Value%
Send Keys: {TAB}
Wait: 100ms
Send Keys: ^{c}
Wait: 100ms
Get Clipboard Text → %Col2Value%
# ... repeat for each column ...
Add DataTable row: [%Col1Value%, %Col2Value%, ...]
# Try to advance to next row
Send Keys: {DOWN}
Wait: 100ms
# Check if we're at the same row (end of grid)
Send Keys: ^{c}
Get Clipboard Text → %CheckValue%
If %CheckValue% = %Col1Value%:
Set %EndOfGrid% = True
Set %RowIndex% = %RowIndex% + 1
Option B: Export to file from within the application.
Many legacy applications have an export or print-to-file function that you may not have thought to use. Even ancient ERP systems often have "Export to CSV" buried in a menu somewhere. Navigate to it with your automation, trigger the export, read the resulting file with Read CSV File, and you've bypassed the grid interaction problem entirely. This is dramatically faster and more reliable than cell-by-cell navigation.
Option C: Screen-region OCR.
Configure OCR extraction with Extract Data from Image against a captured screen region. This works but requires careful region sizing, consistent fonts, and tends to struggle with gridlines interfering with character recognition. Use only when A and B are impossible.
Tip
Always investigate the export option before committing to cell-by-cell extraction. Open every menu in the legacy application systematically and look for "Export," "Print to File," "Save As," or "Report." Finding a CSV export function might cost you 20 minutes of exploration but save you two weeks of brittle grid automation code.
Let's assemble everything into a realistic, production-oriented flow. The scenario: extract invoice records from a VB6 ERP system and enter them into a modern web-based accounting system.
Main Flow
├── Sub-flow: InitializeApplication
│ ├── Launch or attach to ERP
│ ├── Handle login if required
│ └── Navigate to Invoice Search screen
│
├── Sub-flow: ExtractInvoices
│ ├── Set search criteria (date range, status filter)
│ ├── Execute search
│ ├── Wait for results
│ └── Extract grid data → DataTable
│
├── Sub-flow: ProcessInvoiceBatch
│ ├── For each row in DataTable:
│ │ ├── Sub-flow: ValidateInvoiceData
│ │ ├── Sub-flow: EnterInvoiceInWebSystem
│ │ └── Sub-flow: HandleEntryResult
│
└── Sub-flow: CleanupAndReport
├── Close ERP application
├── Write processing log to Excel
└── Send summary email via cloud flow trigger
Notice the decomposition into sub-flows. This isn't organizational preference — it's essential for a flow of this complexity. Each sub-flow can be independently tested, has a clear error boundary, and can be called conditionally. For more on this pattern, see Desktop Flows: Automate Legacy Applications with RPA in Power Automate.
# Sub-flow: InitializeApplication
# Check if already running (attended scenario)
If Application Running "ACME_ERP.exe":
Attach to Running Application: "ACME_ERP.exe" → %ERPProcess%
Else:
Run Application: "C:\Program Files (x86)\ACME\ERP\ACME_ERP.exe"
Arguments: "/env:PROD"
Store Process → %ERPProcess%
# Wait for main window — up to 60 seconds for slow network launch
Wait for UI Element to Appear: [ERP Main Menu Window]
Timeout: 60 seconds
On Timeout: Raise error "ERP failed to start — check network connectivity"
# Handle login dialog (appears after splash screen)
If UI Element Exists: [Login Dialog]:
Get Credential from Windows Credential Manager:
"ERP_Service_Account" → %Credential%
Set Text Field: [Username] → %Credential.Username%
Set Text Field: [Password] → %Credential.Password%
Click: [Login Button]
Wait for UI Element to Vanish: [Login Dialog]
Timeout: 30 seconds
# Maximize window for consistent coordinate-based operations
Set Window State: [ERP Main Window] → Maximized
Wait: 500ms
# Navigate to Invoice module
Send Keys: %{I} # Alt+I → Invoice menu
Wait: 300ms
Send Keys: {S} # S → Search Invoices
Wait for UI Element to Appear: [Invoice Search Form]
Timeout: 15 seconds
# Sub-flow: ExtractInvoices
# Input variables: %SearchFromDate%, %SearchToDate%
# Set date range
Set Text Field: [From Date Field] → %SearchFromDate%
Send Keys: {TAB} # Trigger date validation
Set Text Field: [To Date Field] → %SearchToDate%
Send Keys: {TAB}
# Set status filter
Select List Item: [Status Dropdown] → "PENDING"
# Execute search
Click: [Search Button]
# Wait for results with polling (results label changes)
Set %WaitIterations% = 0
Loop Until:
Get UI Element Detail: [Results Count Label] → %ResultsText%
%ResultsText% does not contain "Searching"
OR %WaitIterations% >= 120:
Wait: 500ms
%WaitIterations% = %WaitIterations% + 1
If %WaitIterations% >= 120:
Raise error "Invoice search timed out"
# Parse result count (e.g., "47 invoices found")
Parse Number from %ResultsText% → %InvoiceCount%
If %InvoiceCount% = 0:
Set %ExtractedInvoices% = (empty DataTable)
Exit sub-flow
# Extract grid — try accessibility first, fall back to keyboard
If UI Element Exists: [Invoice Grid with GridPattern]:
Extract Table Data from UI Element: [Invoice Grid]
→ %ExtractedInvoices%
Else:
# Fall back to keyboard-based extraction
Call Sub-flow: ExtractGridViaKeyboard
Input: %InvoiceCount%
Output: %ExtractedInvoices%
One of the most important design choices in any multi-record processing flow is: what happens when a single record fails? The answer is almost never "abort the entire flow." Instead, log the failure, skip the record, and continue.
# In ProcessInvoiceBatch, wrapping each record:
For Each %InvoiceRow% in %ExtractedInvoices%:
On Error:
# Capture error details
Set %FailureLog%[%CurrentIndex%] = %LastErrorMessage%
Set %FailureLog%[%CurrentIndex%].InvoiceNo = %InvoiceRow%[0]
# Attempt to recover application to known state
Call Sub-flow: RecoverERPState
# Continue to next record
Continue to next loop iteration
# Process the record
Call Sub-flow: ValidateInvoiceData
Call Sub-flow: EnterInvoiceInWebSystem
# Increment success counter
%SuccessCount% = %SuccessCount% + 1
The RecoverERPState sub-flow attempts to press Escape several times, navigate back to the main menu, and re-open the invoice search form. This handles the most common failure mode: the application got into an unexpected dialog state because of a data error.
MDI applications — where multiple child windows open inside a parent frame — present unique selector challenges because child window titles and positions change as documents are opened and arranged. The accessibility tree exposes MDI children under the MDI client area.
The strategy: always interact by activating the specific child window before operating on its controls. Use Set Window Focus with a title-based match that includes the specific document (e.g., "Invoice — INV-00432"). If multiple child windows might match a partial title, iterate through Get All Windows results and use property comparison to find the right one.
Many "legacy" systems are actually IBM 3270, 5250 (AS/400), or VT100 terminal sessions running in a Windows-based emulator (Reflection, RUMBA, PuTTY, MobaXterm). The emulator window may expose the terminal content as a single text block or as a grid of character cells.
For modern terminal emulators (Micro Focus Reflection, IBM Personal Communications), dedicated UIA providers often exist that expose field positions. For others, keyboard navigation is your primary tool — the 3270 protocol was designed for keyboard-only operation, and your flow can mimic that faithfully.
Read the screen by capturing the entire terminal window as text (many emulators support Ctrl+A, Ctrl+C to copy all visible text), then parse the fixed-width text output with string manipulation. Terminal screens use fixed column positions — field values are always in the same character position on a given screen.
# Read 3270 terminal screen
Click: [Terminal Window]
Send Keys: ^{a} # Select all
Send Keys: ^{c} # Copy
Get Clipboard Text → %TerminalScreenText%
# Parse fixed-width fields (example: Customer ID at columns 15-24 of line 3)
Split %TerminalScreenText% by newline → %ScreenLines%
Get Item %ScreenLines%[2] → %Line3% # 0-indexed, line 3 = index 2
Substring %Line3% from position 14, length 10 → %CustomerID%
Trim %CustomerID% → %CustomerID%
Note
When working with terminal emulators, check whether the vendor provides a dedicated API or COM automation interface. IBM Personal Communications, Micro Focus Reflection, and Attachmate RUMBA all have COM object models that can be called from Power Automate Desktop using the Run VBScript action. This gives you programmatic access to field values by row/column without any screen-scraping at all, and is dramatically more reliable than clipboard parsing.
Some legacy applications grab global keyboard shortcuts (a terrible practice, but it happened). If your flow sends Ctrl+C thinking it's copying text, but the application intercepts it as "Clear current record" — chaos ensues.
Test this early by running the application alongside a text editor and seeing whether your normal clipboard shortcuts behave normally. If they don't, use the Click UI Element approach for data interaction rather than keyboard shortcuts, and use the Set UI Element Value action to write values directly to controls.
This exercise builds a complete working automation against a freely available legacy Windows application: the classic Windows XP-era Notepad paired with a simulation of the data entry pattern. If you have access to an actual legacy application in your environment, substitute it.
Scenario: You have a list of customer records in an Excel file (CustomerID, Name, Email). Your task is to open each record's detail page in a legacy Windows application (simulated with a WinForms sample app available from the Microsoft docs repository), verify the data, update the email field if it's blank, and log the results.
Exercise Steps:
Create a new Desktop flow named "LegacyApp_CustomerUpdate"
Build the Initialize sub-flow:
Build the ReadCustomerList sub-flow:
Build the NavigateToCustomer sub-flow:
Build the UpdateEmailIfBlank sub-flow:
Build the ProcessAllCustomers main loop:
Build the WriteResults sub-flow:
Success criteria: The flow processes all 50 test records in the Excel file, produces a results log with one row per customer, recovers gracefully when 3 deliberately invalid CustomerIDs are included in the test data, and completes without manual intervention.
The built-in recorder is great for discovery, but its captured selectors are usually over-specified. It captures every visible attribute at the moment of recording, including dynamic ones like window titles containing current record IDs. Review and trim every captured selector before using it in a flow you intend to maintain.
Fix: After recording a sequence, switch to manual selector editing for every captured element. Remove any attribute that contains dynamic data (record IDs, timestamps, counts). Test the revised selector by running the flow against multiple different records, not just the one you recorded with.
On attended machines especially, something can steal focus mid-automation. A notification popup, a meeting reminder, another application's message box — any of these can cause your next Send Keys to land in the wrong window.
Fix: Before every Send Keys sequence, explicitly call Set Window Focus on the target application window. This costs ~50ms per call and eliminates an entire category of intermittent failures.
When a UI action fails (element not found, click failed), the application might be in any state. Many developers just retry the failed action, but the application might be mid-transaction or stuck in an error dialog.
Fix: Every error handler should first attempt to return the application to a known neutral state (usually the main menu or search form via Escape key presses) before retrying. Build a ResetToKnownState sub-flow and call it at the start of every error handler.
On high-DPI displays (Surface Pro, 4K monitors), coordinate-based clicking fails silently because Windows reports logical coordinates while the application renders at physical coordinates. This is especially common when the flow runs on a different machine than it was developed on.
Fix: Always use UI element-based clicking (via the accessibility tree) rather than absolute or window-relative coordinates. If you must use coordinates, test explicitly on the target machine at its target resolution and DPI setting. Set the machine's display scaling to 100% for unattended automation machines where possible.
Flows that fail in production with no diagnostic information are impossible to debug. You'll look at a failed run and have no idea whether it was a login failure, a data validation error, a network timeout, or a corrupted UI state.
Fix: Log at every meaningful state transition. Use a List variable to accumulate log entries during the run, then write them to a file or DataTable at the end. Include timestamps, which record was being processed, what action was taken, and what the application state appeared to be. For complex flows, consider writing the log incrementally so you don't lose it if the flow crashes entirely. This connects to broader flow reliability practices — see Master Error Handling and Retry Patterns in Power Automate for Bulletproof Flows for the cloud-side equivalent patterns that translate directly to desktop flow philosophy.
This is the most common production failure for legacy app automation. Causes and fixes:
| Cause | Diagnosis | Fix |
|---|---|---|
| Different Windows version | Check OS version on dev vs prod | Test on prod machine; may need to add ClassName alternatives |
| Different screen resolution/DPI | Check display settings | Use element-based not coordinate-based selectors |
| Application running as different user | Check process owner | Ensure service account has same app configuration |
| Regional settings (date formats) | Test with prod locale | Make date format handling locale-aware |
| Database has different data in prod | The selector included actual data values | Remove data-dependent attributes from selectors |
| Application version difference | Compare ERP version numbers | Capture selectors on the specific version in prod |
You now understand not just how to use Power Automate Desktop's UI automation actions, but why they work the way they do and how to make them work reliably in the real-world chaos of legacy enterprise applications. Let's recap the principles that matter most:
Technology awareness first. Know what accessibility framework your target application uses before writing a single action. The framework determines your selector strategy, your interaction patterns, and your fundamental limitations.
Selectors are engineering decisions. Every selector you build is a maintenance liability. Minimize the number of attributes, avoid dynamic values, and prefer structural properties (ClassName, AutomationId) over text-based ones.
Synchronization is explicit, not assumed. Never use fixed delays as your primary synchronization mechanism. Wait for specific UI states, poll for state changes with timeouts, and always define what "too long" means with an explicit error.
Design for failure, not just success. Production flows must handle unexpected dialogs, session timeouts, data errors, and application crashes. Build error recovery sub-flows before you consider a flow "done."
Logging is not optional. Every flow that will run unattended must produce enough diagnostic output to explain any failure after the fact.
From here, your next areas of study:
Scaling to unattended execution: Moving your desktop flows to run on dedicated machines without user sessions requires understanding machine groups, connections, and the Power Automate orchestration layer. The Desktop Flows: Automate Legacy Applications with RPA in Power Automate article covers the cloud-side orchestration piece.
Web application counterpart: If your scenario involves both a legacy Windows app and a modern web system, you'll want the full web automation skill set from Web Automation in Power Automate Desktop: Browser Actions, Form Filling, and Data Extraction.
Enterprise deployment: When you're ready to deploy these flows across an organization — managing credentials, environments, and ALM — the practices in Deploying and Managing Power Automate Solutions Across Environments apply directly to desktop flow solutions.
Legacy application automation is genuinely hard. It requires patience, a willingness to dig into decades-old technology internals, and the discipline to build defensively from day one. But when it works — when that four-hour-a-day manual process becomes a 12-minute scheduled job that runs while everyone's still getting their morning coffee — that's one of the most tangible, valuable things automation delivers.