A Windows automation tool whose if: expressions survive a paste from Slack
Every automation framework advertises conditional steps. Only one has a line of code dedicated to the Unicode characters your word processor quietly substitutes into your text. Here is what the parser behind Terminator's if: field actually does with your workflow YAML.
What every roundup of this topic misses
Search around for a while and you will find the same list repeated everywhere: Power Automate Desktop, UiPath, AutoHotkey, Blue Prism, Automation Anywhere. The comparisons are about licensing, low-code vs pro-code, which arcane DSL each one uses. Nobody ever gets around to the question that actually matters on a Monday morning: what does your conditional syntax do when the user pastes a condition from a Slack thread?
macOS and iOS substitute straight quotes for curly ones by default in every text field. Google Docs does it. Word does it. Notion does it. ChatGPT does it when it renders a string. The moment a human writes a workflow by reading a chat message and pasting the relevant line into a YAML file, their expression is no longer ASCII.quote_type == ‘Face Amount’ looks right, but the apostrophes are U+2018 andU+2019, and the space between the operator and the string might be U+00A0 instead of 0x20.
Terminator is a developer framework. It gives your AI coding assistant a Playwright-shaped API for every app on your desktop. But it is also a framework that expects humans to paste things. The expression interpreter that evaluates everyif:field in a workflow starts with ten lines that nobody else bothered to write.
Anchor fact
Eight characters, rewritten before the parser runs
Open crates/terminator-mcp-agent/src/expression_eval.rs. The first function in the file, above every evaluator, above every tokenizer, above every comparison operator, isnormalize_expression. It does one thing. It replaces eight specific Unicode code points with ASCII equivalents.
This is the kind of detail you do not write unless you have watched real users copy-paste real conditions out of real chat tools. Every other Windows automation framework either rejects the expression with a parse error or quietly mis-evaluates it because the string literal on the right no longer matches the string on screen.
What it looks like when the input is dirty
A quick demo. We paste the same logical condition twice: once typed, once pasted straight out of a chat thread. Terminator runs both.
Three shapes of input, one clean expression
What the tiny grammar actually gives you
Six capabilities. No embedded scripting language, no eval, no sandbox to configure. The grammar is small on purpose: if you need a real programming language inside a step, you can drop into arun_javascript tool. Theif:field stays predictable.
Boolean composition
&&, ||, and ! with left-to-right evaluation. Nest freely: !contains(user_roles, 'Premium') && env.retry_count < 3.
Four data functions
contains, startsWith, endsWith, coalesce. Enough to express real business rules without an embedded scripting language.
Smart type coercion
'42' == 42 is true. true == '1' is true. String-as-number parsing is automatic on > < >= <=. No explicit casts.
Undefined is safe
Reference a variable that does not exist yet and the expression returns a sensible default instead of throwing. Great for first-run gates.
Dot-notation paths
env.user.plan, policy.product_types, response.headers.content_type. Nested JSON works the way you already read it.
Paste-safe literal parsing
Eight Unicode rewrites happen before tokenization. Smart quotes, backticks, and three flavors of non-breaking space all normalize to ASCII.
The walkthrough
Six stages, no surprises. This is what happens insideevaluate between the moment your workflow scheduler reads anif: field and the moment it decides whether to run the step.
Raw expression enters
The user's YAML has if: “quote_type == ‘Face Amount’” pasted from Slack. The Rust string literal may contain curly quotes, a backtick, and a non-breaking space.
normalize_expression rewrites eight characters
Curly quotes collapse to straight quotes. Backticks collapse to single quotes. Three flavors of Unicode space all become ASCII space. The expression is trimmed.
Negation and logical operators branch
evaluate_internal strips a leading ! if present and recurses. Otherwise it searches for the first && or ||, splits on it, and recurses on each side. This gives left-to-right evaluation with simple precedence.
Function calls route to a named handler
Expressions of the form name(arg1, arg2) are routed to contains, startsWith, endsWith, coalesce, or always. Each takes arguments straight out of the normalized string, no tokenizer needed.
Binary expressions do smart comparison
For ==, !=, the lhs is looked up in the variables JSON and compared via compare_values_smart, which handles String-to-String, Bool-to-literal, and Number-to-numeric-string without explicit casts.
Undefined variables return safe defaults
If a variable is missing, == returns false, != returns true, < returns true, > returns false. The workflow never crashes because someone forgot to seed a field.
Read one real condition
A fragment of a live insurance-quote workflow. Two steps. Two different authoring styles. Same evaluator.
coalesce in the wild
Why coalesce exists
Workflows are not pure functions. Variables get populated by earlier steps, by environment lookups, by user prompts. On the first run of a recurring workflow, half the variables are empty. You want conditions that gracefully treat an empty value the same as a missing value. That iscoalesce.
Zero is falsy. Empty string is falsy. Null is falsy. False is falsy. The evaluator returns the first truthy argument, falling through to the final argument as a literal if nothing is truthy. Socoalesce(env.retry_count, 0) < 3 is true on the first run where retry_count does not exist yet, and on the second run where it is still zero.
From YAML string to boolean verdict
if: string
read from YAML step
normalize
8 Unicode chars rewritten
evaluate_internal
!, &&, || split
router
function vs binary
bool
run step or skip
How this lands against the usual suspects
A generic comparison is easy. The specifics below are how Terminator differs from the tools people usually mean when they search this.
| Feature | Typical RPA studio | Terminator |
|---|---|---|
| Conditional step syntax | Point-and-click designer | Inline if: expression in YAML |
| Smart-quote handling on copy-paste | Silent parse error | Normalized at line 6 of expression_eval.rs |
| Non-breaking space tolerance | Parse error | U+00A0, U+2009, U+202F all rewritten to ASCII space |
| Undefined variable in a comparison | Throws, workflow halts | Returns false for ==, true for !=, no crash |
| Driven by an AI coding assistant | Not supported, designer is GUI-only | Runs as an MCP server over 35 tools |
| Source you can read | Closed-source, ~gigabyte install | MIT licensed, ~450 lines for the whole parser |
| Install footprint | Installer, license, reboot | One npx command, zero license |
“The entire expression parser is 458 lines, MIT licensed, and has a test module covering null handling, zero-as-falsy, coalesce with empty strings, and mixed-type comparisons.”
crates/terminator-mcp-agent/src/expression_eval.rs
Want to see a real workflow paste-proofed live?
Book a 20-minute walkthrough. We will open expression_eval.rs, run a workflow with pasted curly quotes, and show you how it wires into an AI coding assistant via MCP.
Frequently asked questions
What makes Terminator different from other Windows automation tools?
The short answer is who the user is. Power Automate Desktop, UiPath, Blue Prism, and AutoHotkey are aimed at business users, RPA developers, or power users writing scripts by hand. Terminator is a developer framework. You install it as an npm package, a pip package, a Rust crate, or an MCP server that Claude Code, Cursor, Windsurf, or VS Code can drive. The product ships with a Playwright-shaped selector API, a workflow engine with typed variables, a recorder that emits YAML, and a tiny expression language for conditional steps. The expression language, documented below, is one of the least-discussed differentiators and the reason human-authored workflows survive copy-paste.
Which file contains the expression parser for if: conditions?
crates/terminator-mcp-agent/src/expression_eval.rs, 458 lines. The public entry is fn evaluate(expression: &str, variables: &Value) -> bool at line 34. It calls normalize_expression first, then evaluate_internal on the cleaned string. The parser handles &&, ||, !, ==, !=, >, <, >=, <=, and four built-in functions: contains, startsWith, endsWith, and coalesce. There is also an always() function that takes no arguments and returns true, used by GitHub-Actions-style step gates.
What exactly does normalize_expression do?
It rewrites eight Unicode characters into their ASCII equivalents before the parser ever runs. Curly single quotes U+2018 and U+2019 become straight '. Curly double quotes U+201C and U+201D become straight ". Backticks become single quotes. Non-breaking space U+00A0, thin space U+2009, and narrow no-break space U+202F all become regular ASCII space. Then the expression is trimmed. The function is seven lines of chained .replace() calls at the top of the file. You can read it in under 30 seconds.
Why does this matter for real users?
People copy expressions out of Slack threads, Notion docs, Google Docs, Word, and ChatGPT conversations. Every one of those sources will silently replace your straight quotes with curly quotes, especially on macOS where the default input substitution does it without asking. An expression like if: quote_type == 'Face Amount' will look identical on screen but the character codes are different. Terminator's interpreter normalizes the input so the workflow runs. Most other automation tools' conditional syntaxes fail with a cryptic parse error and leave the user to hunt for an invisible character.
How do I actually write a conditional step in a Terminator workflow?
In YAML, attach an if: field to any step. Example: - tool_name: click_element; arguments: { selector: 'role:Button && name:Save' }; if: "contains(env.product_types, 'FEX') && env.retry_count < 3". The expression is evaluated against the current workflow variables before the step runs. If it returns false, the step is skipped and the next step in the sequence is tried. The if: field is inspected by helpers.rs at lines 281 and 340, which call expression_eval::evaluate(inner_str, variables). Real workflow fixtures in the repo use exactly this pattern, including if: contains(product_types, 'FEX') at helpers.rs line 1214.
What functions are supported inside if: expressions?
Four data functions plus one sentinel. contains(array_or_string, 'value') tests for membership or substring. startsWith(string_var, 'prefix') and endsWith(string_var, 'suffix') are self-explanatory. coalesce(x, y, z, default) returns the first truthy value from its arguments, or the last argument as a literal if nothing is truthy. always() with no arguments returns true and is used as a step gate. Logical operators && || ! compose these. Binary comparison ==, !=, >, <, >=, <= works on any JSON value with smart type coercion between strings, numbers, and booleans.
What happens when a variable referenced in an expression does not exist?
Terminator handles undefined variables explicitly at lib lines 183 through 191. For equality operators, undefined is never equal to anything, so undefined == 'x' returns false and undefined != 'x' returns true. For numeric comparisons, undefined is treated as less than any value, so undefined > anything is false and undefined < anything is true. This makes defensive expressions like if: env.retry_count < 3 work on the first run when retry_count does not exist yet. Other platforms often throw, which means you have to wrap every step in a try/catch or pre-seed every variable.
Is Terminator open source and can I fork the parser?
Yes. The whole project is MIT licensed on GitHub at mediar-ai/terminator. The expression_eval.rs file is ~450 lines, has no external dependencies beyond serde_json and tracing, and ships with a full test module starting at line 351 covering coalesce with nulls, missing variables, empty strings, zero-as-falsy, and more. If you want to fork the parser for your own tool, the file is self-contained and easy to extract.
How is Terminator's approach different from Power Automate Desktop?
Power Automate Desktop is a drag-and-drop designer built on the Robin expression language, aimed at business analysts. Conditions are authored in a property grid. Terminator is a code-first framework: you write a YAML workflow (or drive it from TypeScript, Python, Rust, or an MCP tool call) and your LLM coding assistant can read and edit it the same way it edits source files. The if: field is the same whether you are building interactively, recording a session, or having Claude generate the workflow. The expression interpreter is optimized for paste-friendly developer input, not for a designer surface.
Where should I start if I want to try it?
If you already use Cursor, Claude Code, VS Code, or Windsurf, the fastest path is the one-line MCP install: claude mcp add terminator "npx -y terminator-mcp-agent@latest". Your coding assistant can then open apps, fill fields, click buttons, and run multi-step workflows. If you want to build automations programmatically, the NodeJS and Python SDKs are both on npm and pypi under the terminator name. If you want to embed the engine in a Rust binary, the crate is terminator-rs on crates.io. Everything reads the same if: expression format.