# V2 Logic Syntax Reference **Version:** 1.0 **Last Updated:** April 2026 --- OVERVIEW Merch Jar V2 Logic is a Domain Specific Language (DSL) for creating Segments of advertising entities by defining filter logic and calculating Custom Properties. Segments can be used for analysis, bulk actions, or automated Workflows. CORE CONCEPTS - Segment: Output table of items matching the formula expression - V2 Logic: the Domain Specific Language described here. Segment queries are written using V2 Logic - Custom Property: Output column generated by 'let' variable definition (starting with $) - Variable: Named value defined using 'let' (must start with '$' sigil) - Dataset: Selected source data table (Campaigns, Ad Groups, Keywords & Targets, etc.) - Workflow: Trigger and Action pair - Template: Predefined Logic + (optional) Workflow A Template defines a logic set for automated actions within a Segment. Each Template runs using Merch Jar’s V2 Logic language and can be applied to datasets such as Keywords & Targets, Campaigns, or Search Terms. DATA TYPES Number Numeric values (integers, decimals). Percentages (%) auto-convert to decimal (25% → 0.25). Literal negative numbers supported (-5, -0.5). Examples: clicks(7d) > 10, bid <= 1.50, acos(30d) < 25%, let $threshold = -0.5; Note: Percentages are automatically converted - do not manually divide by 100. Note: To negate a variable value, use multiplication rather than direct negation: Correct: -1 * $bid_adjustment // Multiply by -1 Incorrect: -$bid_adjustment // Direct negation not supported String Text values in double quotes "". String concatenation and manipulation operations are not supported. Examples: campaign name contains "Brand" Note: Strings cannot be concatenated with + operator or combined with variables. Each string must be a complete literal value. Array User-defined collection of strings using square brackets []. Used with 'contains all' and 'contains any'. Examples: let $keywords = ["retro", "vintage"]; campaign name contains any ["summer", "spring"] List Predefined string values (enums) for properties like 'state' or 'match type'. Values in double quotes. Examples: state = "enabled", state contains any ["paused", "archived"] Timestamp Point in time, stored as seconds since Unix epoch. For properties like 'last bid change', 'now()' results. Examples: last bid change < now() - interval(7d), campaign start date after "60 days ago" Time Period Duration specification used ONLY in time-based property parentheses or variable assignments. Examples: clicks(30d), let $period = 7d..14d; Date Literal date values in 'YYYY-MM-DD' format returned by certain properties. Cannot be used directly in timestamp arithmetic - requires conversion function. Examples: campaign start date = 2024-01-15 Boolean True/false result of comparisons and logical operators. Final formula must evaluate to Boolean. Examples: clicks(7d) > 0 and orders(7d) > 0 TIME PERIOD FORMATS Xd - Relative: X days back from now, including partial current day Example: clicks(7d) YYYY-MM-DD..YYYY-MM-DD - Literal Range: Exact calendar dates, inclusive Example: spend(2024-01-01..2024-01-31) Xd..Yd - Offset Relative: From X days ago to Y days ago, inclusive (Y ≥ X) Examples: sales(7d..14d) [from 7 days ago through 14 days ago] sales(3d..3d) [3 days ago only] ..Xd - Offset To Present: From now back through X days ago Example: clicks(..60d) [from now through 60 days ago] Xd.. - Offset From Past: From X days ago through earliest available data Example: spend(30d..) [from 30 days ago through earliest available data] lifetime or .. - Lifetime: Entire period of available data Example: orders(lifetime) = 0 COMMON PITFALLS Status note (May 2026): warnings in this section describe error modes observed in the V2 evaluator over time. Some have been verified against the current evaluator via black-box API testing; others describe historical behavior or edge cases that black-box tests don't reliably trigger. Engineering has confirmed the evaluator has hidden fallback paths that aren't exercised by typical workloads (one such path was confirmed for integer truncation in May 2026 — engine fix in flight). Treat warnings here as "real until proven obsolete by an authoritative engineering source" — the recommended workarounds are safe even when the warning turns out to be stale on the happy path. Integer-metric arithmetic note: clicks, impressions, orders, and pages read return integers, but ratios between them evaluate to decimals natively on the happy path — `clicks(lifetime) / impressions(lifetime)` returns the actual CTR (e.g., 0.00862), not 0. There is a known fallback path in the evaluator that does integer truncation; engineering is removing it (May 2026, in flight). Until that ships, integer truncation can still surface on edge cases. Defensive pattern: scale the numerator (`(clicks * 1.0) / impressions`) for ratio comparisons that must be correct under all paths. Ratio Thresholds — `%` Conversion: `%` in number literals auto-divides by 100 at parse time (0.3% → 0.003). Use whichever form is more readable next to the comparison; both are equivalent. Nested case Depth Limit: Nesting case statements more than 2-3 levels deep causes significant query performance degradation. Each case arm generates a conditional check evaluated against every row; deep nesting multiplies this across potentially millions of records. Limit nesting to 2 levels maximum. If deeper logic is needed, pre-calculate intermediate values into separate let variables first. Compound Conditions in case for let Assignments: Combining multiple conditions with `and` or `or` in a single case clause has historically caused load errors. May 2026 black-box API testing on MBA US (compound AND, three-way AND, AND+OR, AND+ratio) returned 200 OK in every case, suggesting the warning may be stale on the happy path — but the evaluator has known hidden fallback paths and black-box testing can't rule out edge cases. Defensive pattern: prefer nested case statements over compound `and`/`or` inside a single case arm when correctness matters under all paths. Metric Function Calls in Final Filter: Calling metric functions directly in the final filter expression has historically caused load errors. May 2026 black-box API testing (19 patterns: simple comparisons, ratios, metric arithmetic, multi-period, OR/AND combos, search_terms dataset) all returned 200 OK with sensible counts, suggesting the warning may be stale on the happy path — but the evaluator has known hidden fallback paths and black-box testing can't rule out edge cases. Defensive pattern: pre-calculate complex conditions into a `0/1` case flag (`let $flag = case(...condition... => 1, else 0); state = ... and $flag = 1`) when correctness matters under all paths. Boolean `let` Variables in Final Filter: Variables assigned from a comparison expression (e.g., `let $x = clicks(30d) > 5;`) cannot be used as direct AND/OR operands in the final filter — the evaluator stores them numerically and rejects them with "Type mismatch in AND/OR operand: number vs boolean." This reproduces in both the in-app validator and the API preview endpoint as of May 2026 — the issue is real and not yet engine-fixed. Three working patterns: (1) wrap with `= true` in the final filter (`state = ... and $x = true`); (2) use a `0/1` flag pattern with `case` (`let $flag = case(clicks > 5 => 1, else 0); state = ... and $flag = 1`); (3) use the boolean variable as the condition inside another `case` (`case($x => "yes", else "no")`). Variable Usage in Offset Relative Time Periods: For Xd..Yd format, assign complete range to single variable. Cannot construct range from separate variables. Timestamp Arithmetic with Variables: Variables holding Time Periods must use interval() function for arithmetic. Custom Property Output: Variables assigned Time Period values appear as columns but rows show blank values. Boolean Literals: Direct true/false literals not supported. Use expression results instead. // This will cause parsing errors: let $period = case(condition => 7d, else 14d); // Use this approach instead: let $grace_cutoff = case( condition => now() - interval(7d), else now() - interval(14d) ); DATASETS The selected dataset determines which properties are available and which entities will be analyzed. campaigns - Campaign-level analysis and actions Available properties: All performance metrics, budget, campaign identifiers, last budget change Actions: Change Daily Budget, Set State ad-groups - Ad group-level analysis and actions Available properties: All performance metrics, default bid, campaign/ad group identifiers Actions: Change Default Bid, Set State keywords-targets - Keywords & Targets combined (recommended for most use cases) Available properties: All performance metrics, bid, match type, last bid change, campaign/ad group identifiers Actions: Change Bid, Set State keywords - Manual keywords only (broad, phrase, exact match types) Available properties: All performance metrics, bid, match type, last bid change, campaign/ad group identifiers Actions: Change Bid, Set State targets - Auto targeting and product targeting only (close match, loose match, product exact, similar, etc.) Available properties: All performance metrics, bid, match type, last bid change, campaign/ad group identifiers Actions: Change Bid, Set State product-ads - Individual product advertisements (ASINs) within ad groups Available properties: All performance metrics, campaign/ad group identifiers Actions: Set State search-terms - Search queries that triggered ads Available properties: All performance metrics plus negated property. State property is not accepted. Actions: Create Negative Exact Match PRIMARY PROPERTIES acos (Number, Time-Based) - Advertising Cost of Sale (spend / sales) Example: acos(30d) > 40% ad group name (String) - Current name of Ad Group Example: ad group name contains "Exact Match" aov (Number, Time-Based) - Average Order Value (sales / orders) Example: aov(60d) > 15.00 bid (Number) [keywords-targets dataset only] - Current bid for Keyword or Target Example: bid > 0.75 budget (Number) [campaign dataset only]- Current daily budget for Campaign (alias: daily budget) Example: budget > 10.00 campaign name (String) - Current name of Campaign Example: campaign name contains "Brand - SP" campaign start date (Date) - Configured start date of Campaign in literal format Note: Returns 'YYYY-MM-DD' format, not timestamp. Cannot be used with now() arithmetic. Example: campaign start date = '2024-01-01' (not usable with interval() functions) clicks (Number, Time-Based) - Total number of ad clicks Example: clicks(7d) >= 5 cpc (Number, Time-Based) - Cost Per Click (spend / clicks) Example: cpc(14d) < 0.80 impressions (Number, Time-Based) - Total number of ad displays Example: impressions(14d) > 1000 last bid change (Timestamp) [keywords-targets dataset only] - Most recent bid update timestamp Example: last bid change < now() - interval(7d) max bid (Number) [All datasets except search terms] - Maximum bid limit set on the entity Aliases: max_bid, maxbid Example: max bid > 2.00 min bid (Number) [All datasets except search terms] - Minimum bid limit set on the entity Aliases: min_bid, minbid Example: min bid > 0.10 last budget change (Timestamp) [campaign dataset only] - Most recent budget update timestamp Example: last budget change < now() - interval(30d) match type (List) [keywords-targets dataset only] - Keyword match type or auto-targeting type Values: "broad", "phrase", "exact", "product exact", "similar", "close match", "loose match", "complements", "substitutes" Example: match type = "exact" WARNING — `contains` / `contains any` on `match type` is SUBSTRING-based, not exact. `match type contains any ["exact"]` matches BOTH "exact" AND "product exact" (because "product exact" contains the substring "exact"). On a real account this silently swept in 1,704 product-exact ASIN targets that the author intended to exclude — a pause/negate segment built this way would have hit entities it should never touch. Several of these values share substrings ("exact"/"product exact", "close match"/"loose match"). RULE: To scope by match type exactly, use `=` with `or`, never `contains`: CORRECT: (match type = "broad" or match type = "phrase" or match type = "exact") // manual keywords only WRONG: match type contains any ["broad","phrase","exact"] // also catches "product exact" Reserve `contains` / `contains any` for free-text properties (campaign name, ad group name, search term), not for List enums like `match type` or `state`. DSL literal vs API response enum — these differ. You write the DSL value as a lowercase, spaced literal; preview rows return an UPPERCASE prefixed enum. Confirmed in live testing (2026-06-15): DSL "close match" -> response SEARCH_CLOSE_MATCH DSL "loose match" -> response SEARCH_LOOSE_MATCH DSL "substitutes" -> response PRODUCT_SUBSTITUTES The pattern is a SEARCH_ or PRODUCT_ prefix + the UPPER_SNAKE name (so "broad" almost certainly returns BROAD, "product exact" -> PRODUCT_EXACT, "complements" -> PRODUCT_COMPLEMENTS, "similar" -> SEARCH_SIMILAR), but only the three above are verified — confirm any other value against an actual preview row before relying on it. Always WRITE conditions using the DSL literal (lowercase, spaced); use the response enum only when READING `match type` back out of a row. orders (Number, Time-Based) - Total orders directly attributed to ads Example: orders(30d) > 1 roas (Number, Time-Based) - Return on Ad Spend (sales / spend) Example: roas(30d) > 3.0 sales (Number, Time-Based) - Total revenue from attributed orders Example: sales(14d) > 50.00 state (List) [All datasets except search terms dataset] - Current status of entity Values: "effectively enabled", "enabled", "paused", "archived" Example: state = "effectively enabled" target acos (Number) - Desired ACOS percentage goal Example: target acos < 30% negated (List) [search-terms dataset only] - Whether an exact match negative keyword already exists for this search term Values: true, false Example: negated = false search term (String) [search-terms dataset only] - The actual search query text that triggered the ad Examples: search term contains "running shoes" search term contains any ["brand name", "product category"] search term does not contain all ["competitor", "irrelevant term"] // NOTE: Use `does not contain all` for exclusions. `does not contain any` uses OR logic and will not filter correctly. See Array operators section. KDP-SPECIFIC PROPERTIES blended acos (Number, Time-Based) - Blended ACOS (spend / blended profit) Example: blended acos(60d) < 35% blended profit (Number, Time-Based) - Total estimated profit combining sales and KENP royalties Example: blended profit(30d) > 50.00 blended roas (Number, Time-Based) - Blended Return on Ad Spend (blended profit / spend) Example: blended roas(30d) > 2.0 pages read (Number, Time-Based) - KENP pages read attributed to ads Example: pages read(60d) > 2000 estimated royalties (Number, Time-Based) - Estimated royalties from KENP pages read Example: estimated royalties(30d) > 10.00 SECONDARY PROPERTIES campaign end date (Timestamp) - Configured end date of Campaign default bid (Number) - Default bid set at Ad Group level roi (Number, Time-Based) - Return on Investment ((sales - spend) / spend) cac (Number, Time-Based) - Customer Acquisition Cost (spend / orders) adjusted orders (Number, Time-Based, KDP) - Orders adjusted by Order Impact Multiplier adjusted sales (Number, Time-Based, KDP) - Sales adjusted by Order Impact Multiplier adjusted page reads (Number, Time-Based, KDP) - KENP pages adjusted by KENP Impact Multiplier adjusted estimated royalties (Number, Time-Based, KDP) - Royalties adjusted by KENP Impact Multiplier blended aov (Number, Time-Based, KDP) - Blended Average Order Value blended cac (Number, Time-Based, KDP) - Blended Customer Acquisition Cost blended cvr (Number, Time-Based, KDP) - Blended Conversion Rate blended rpc (Number, Time-Based, KDP) - Blended Revenue Per Click FUNCTIONS let $variable_name = expression; Defines named variable with $ sigil. Creates Custom Property column. Must end with semicolon. Variable names following the '$' must be one word (use underscores_for_spaces) and cannot conflict with keywords, properties, functions, or operators. Examples: let $target_rpc = 0.75; let $current_rpc = sales(30d) / clicks(30d); let $period = 7d..14d; let $unprofitable_acos_ratio = $chosen_acos / target_acos; now() Returns current timestamp. Example: last bid change < now() - interval(14d) interval(time_period) Converts Time Period (e.g., `7d`, `30d`, or a variable holding such a value like `let $p = 7d;`) to internal duration for timestamp arithmetic. Examples: last bid change < now() - interval(7d) let $grace_period = 3d; last_bid_change < now() - interval($grace_period) // Convert a single duration to seconds let $week_seconds = interval(7d); // Subtract two durations to find span between let $baseline_seconds = interval(30d) - interval(7d); let $baseline_days = $baseline_seconds / 86400; // Example use in logic let $baseline_avg = impressions(7d..30d) / $baseline_days; Note: cannot accept calculated values case(condition1 => value1, condition2 => value2, ..., else defaultValue) Implements if/then/else logic. All return values must be same data type. 'else' required. Example: let $performance = case(acos(14d) < 25% => "Good", acos(14d) < 40% => "Okay", else "Poor"); Note: cannot accept Time Period values Note: Limit nesting to 2 levels deep maximum. Deeper nesting causes significant query performance issues at scale. Maximum of 10 match arms per case statement recommended. is_null(property) Returns true if property value is null, false otherwise. Supported on the timestamp properties (last bid change, last budget change) and custom fields (custom.). Examples: is_null(last bid change) // True if never changed is_null(last budget change) // True if budget never changed is_null(custom.cf_profit_margin) // True if this field was never set on the entity ⚠️ KNOWN ISSUE — use the bare form only. Comparing is_null() to a literal (is_null(X) = true, is_null(X) = false) inside an AND chain causes the engine to silently ignore the rest of that AND chain, regardless of position. Until this is fixed: - Write null checks as bare is_null(X) — never = true. - Avoid is_null(X) = false entirely. To branch on "has a value," route the logic through case() instead: case(is_null(X) => , else ). Custom Fields (custom.) User-defined typed fields attached to entities through the Custom Fields API (see MJ_API_REFERENCE.md → Custom Fields). Once defined and populated for a profile, they are readable in segment logic on the matching dataset as custom.. - Key format: custom.cf_. The exact key is listed in the field catalog (GET /api/v5/custom-fields/catalog/:entityType) — always confirm the key there before writing DSL; it is generated from the field name, not identical to it. - Types: number, boolean, string (set at definition time). Number fields participate in math and comparisons like any numeric property. - Entities where the field is unset read as null — gate with bare is_null(custom.) (see above) so segments don't act on entities that haven't been enriched yet. Examples: // Profit-true bidding: per-entity ACOS ceiling from an enriched margin field let $margin_ceiling = custom.cf_profit_margin; acos(30d) > $margin_ceiling AND clicks(30d) > 20 // Only act on enriched entities: route the null check through case() // (avoids the is_null-comparison issue inside AND chains) let $margin_ok = case(is_null(custom.cf_profit_margin) => 0, else 1); $margin_ok = 1 AND acos(30d) > custom.cf_profit_margin let function_name(param) = expression; Defines a reusable custom function that accepts a time period parameter. Useful for applying the same calculation across multiple time periods without repeating logic. Rules: - Custom function declarations must appear at the very top of the segment, before all let $variable statements - Functions can reference $variables declared anywhere in the segment (forward references supported) - Arguments must be literal time periods (7d, 30d, etc.) — cannot pass a $variable as an argument - Same function can be called multiple times with different periods in one expression Examples: // Declare before any $variable declarations let rpc(x) = sales(x) / clicks(x); // Call with different periods for trend comparison rpc(7d) > rpc(30d) // Use in case logic let $trend = case(rpc(7d) > rpc(30d) => "improving", else "declining"); // Functions can reference $variables declared later in the segment let efficiency(x) = clicks(x) / $target_clicks; let $target_clicks = 20; efficiency(30d) > 1 BOOLEAN VARIABLES let $variable = comparison_expression; Boolean comparisons can be assigned to variables, but the resulting variable cannot be used as a direct AND/OR operand or as the standalone final filter — the V2 evaluator stores boolean lets numerically. Use one of these patterns: // 1. Wrap with `= true` in the final filter let $x = spend(30d) > 10; state = "effectively enabled" and $x = true // 2. Use a 0/1 flag with case (preferred for compound conditions) let $low_ctr = case(clicks(lifetime) >= 5 and clicks(lifetime) / impressions(lifetime) < 0.003 => 1, else 0); state = "effectively enabled" and $low_ctr = 1 // 3. Use the boolean variable as the condition inside another case let $x = spend(30d) > 10; let $tier = case($x => "high spend", else "low spend"); state = "effectively enabled" and $tier = "high spend" What does NOT work (May 2026 verification): - `state = ... and $x` where $x is a boolean comparison // Type mismatch: number vs boolean - `$x` alone as the final filter // Trigger must evaluate to a boolean - `let $both = $x and $y; ... and $both` // Same type mismatch Note: KI-005 is NOT fully resolved as of May 2026 — the prior "Resolved" status was incorrect. The patterns above are the supported workarounds. OPERATORS Math: + - * / % % is percentage conversion (25% = 0.25), not modulo Examples: bid + 0.10, now() - interval(7d), acos(30d) < 25% Logical: and or 'and' evaluated before 'or'. The `not` operator is NOT supported (returns "Unknown identifier 'not'"). For inequality use `!=`; for negative logic, restructure with `and`/`or`. Examples: clicks(30d) > 5 and state = "enabled" Comparison: = != > >= < <= Examples: state = "enabled", clicks(7d) != 10 String: contains, does not contain, starts with, ends with Case-insensitive comparisons Examples: campaign name contains "Brand", ad group name ends with "-Exact" Array: contains all, contains any, does not contain all, does not contain any For String properties vs Arrays, or List properties vs Arrays Examples: campaign name contains any ["test", "archive"], state contains any ["paused", "archived"] CRITICAL — `does not contain any` vs `does not contain all`: - `does not contain any ["X", "Y"]` = OR logic: passes if name doesn't contain X OR doesn't contain Y. Since almost any name doesn't contain at least one item, nearly everything passes. This makes exclusion filters silently useless. - `does not contain all ["X", "Y"]` = AND logic: passes only if name doesn't contain X AND doesn't contain Y. This is the correct operator for exclusion filters. Always use `does not contain all` when excluding campaigns, ad groups, or search terms by name. Date: after, before For Timestamp comparisons Examples: campaign start date after "14 days ago", last bid change before now() - interval(2d) SYNTAX NOTES Property Naming: Use exact names with spaces as defined (e.g., "target acos", "ad group name") Case Sensitivity: Properties, functions, operators generally case-insensitive String Literals: Use double quotes "" Date Literals: Write without quotes (YYYY-MM-DD). Quoted values are treated as strings and will cause type errors. Variable Names: Must start with $ and use underscores for spaces (e.g., $my_long_var displays as "My Long Var") Comments: // for single line, /* */ for blocks Final Expression: Must evaluate to Boolean after all 'let' statements Time Period Comparison: Use non-overlapping ranges (0d..13d and 14d..27d vs 14d and 14d..28d) Operator Precedence: Math (*, / before +, -), Logical ('and' before 'or'). Use parentheses for clarity. Array Creation: User-defined arrays use square brackets with comma-separated strings Currency Symbol: $ symbols ignored in numeric values Percent Symbol: % after number divides by 100 (25% = 0.25), not modulo operator Variable Negation: Direct negation of variables (-$variable) is not supported. Use multiplication instead (-1 * $variable). Range formats (Xd..Yd) can only be used in metric function parentheses, not with interval() or timestamp arithmetic operations. ERROR HANDLING Division by Zero: Operations like X/0 now return null. Null values will not match any comparisons (>, <, =, etc.) and will be excluded from filters. This provides predictable behavior without requiring explicit protection in most cases. NaN Comparison: All comparisons with NaN evaluate false except != (NaN != value is true, NaN != NaN is true) Infinity Comparison: Works as expected mathematically (Infinity > 100 is true) Null Value Detection: Use is_null() function to explicitly check for null values rather than relying on comparison behavior. Parsing Error - Undeclared variable: Using undefined variable name or misspelled property Parsing Error - Expected time period: Time-based property used without (TimePeriod) specification Parsing Error - Unbalanced parenthesis: Mismatched ( and ) count Parsing Error - Incompatible types: Operation between incompatible data types Parsing Error - Unexpected end of input: Missing ), ], or ; after let statement Parsing Error - Expected ';' after let: Missing semicolon after let statement WORKFLOW ACTIONS Change Bid (Dataset: targeting) - Increase ($): Increases current bid by fixed dollar amount - Increase (%): Increases by percentage (input as decimal: 0.07 for 7%) - Set ($): Sets bid to specific dollar amount - Decrease ($): Decreases by fixed dollar amount - Decrease (%): Decreases by percentage (input as decimal: 0.07 for 7%) - Set from variable: Uses Custom Property value for any above operations Change Default Bid (Dataset: ad-groups) Same operations as Change Bid, applies to Ad Group default bid setting Change Daily Budget (Dataset: campaigns) Same operations as Change Bid, applies to Campaign daily budget Set State (Dataset: campaigns, ad-groups, targeting, product-ads) Sets entity state to: enabled, paused, archived Create Negative Exact Match (Dataset: search-terms) Creates a negative exact match keyword in the same ad group where the search term was found Action Rounding: Bid/budget calculations with sub-cent values are rounded using account rounding strategy (default: Weighted Rounding). KNOWN ISSUES KI-003: case() function cannot return Timestamp values. Workaround: Use let statements with boolean logic. KI-005: Boolean `let` variables cannot be used as direct AND/OR operands in the final filter. The evaluator stores them numerically and the type check fails. Reproduces in both the in-app DSL validator and the API preview endpoint (May 2026). Workarounds: wrap with `= true`, use the 0/1 case pattern, or use the boolean variable as a case condition. See BOOLEAN VARIABLES section for examples. (Previously marked "Resolved" — that status was incorrect. Engineering aware as of May 2026; pending real fix.) KI-006: String literal `let` variables (e.g., `let $reason = "some text";`) do not appear in API preview responses. Only computed expressions (numeric values, boolean results, `case()` returns) are included as `___variable_name` fields in the API response. In-app, all `let` variables render as Custom Property columns regardless of type. Workaround: Define string variables using `case()` with at least one condition arm — e.g., `let $reason = case(clicks(30d) > 0 => "Has clicks", else "No clicks");` — which will appear in both in-app and API output.