How Maestro Parses YAML Flows: From File to MaestroCommand

Maestro converts YAML flow files into executable MaestroCommand objects through a structured pipeline using Jackson YAML mapping and a custom MaestroFlowParser class in Kotlin.

When you write a mobile UI test in Maestro, the framework must transform your human-readable YAML into runtime instructions. According to the mobile-dev-inc/Maestro source code, this transformation happens in maestro-orchestra/src/main/java/maestro/orchestra/yaml/MaestroFlowParser.kt through a series of discrete parsing stages that handle configuration, command lists, and error recovery.

The Jackson YAML Mapper Configuration

Before parsing begins, Maestro initializes a singleton ObjectMapper configured with YAMLFactory. This mapper specifically disables the --- start marker requirement and registers a custom deserializer for YamlFluentCommand.

The initialization happens in the MAPPER companion object at lines 52-59 of MaestroFlowParser.kt:

private val MAPPER: ObjectMapper = ObjectMapper(YAMLFactory()).apply {
    registerModule(
        SimpleModule().addDeserializer(
            YamlFluentCommand::class.java,
            YamlCommandDeserializer()
        )
    )
    // Disable YAML start marker requirement
    configure(JsonParser.Feature.ALLOW_YAML_COMMENTS, true)
}

This configuration allows Maestro to parse both simple string commands and complex object commands within the same YAML structure.

The Parsing Pipeline: From File to Commands

The entry point parseFlow at lines 61-73 orchestrates the entire transformation. It receives a file Path and the raw YAML string, then executes a three-stage pipeline:

  1. Parse the config (parseConfig)
  2. Parse the command list (parseCommands)
  3. Convert to runtime commands (toCommands)

The method signature and core logic look like this:

fun parseFlow(flowPath: Path, yaml: String): List<MaestroCommand> {
    val parser = MAPPER.createParser(yaml)
    
    // Stage 1: Config parsing
    val config = parseConfig(parser)
    
    // Stage 2: Command list parsing
    val yamlCommands = parseCommands(parser, flowPath)
    
    // Stage 3: Conversion and environment application
    return yamlCommands
        .flatMap { it.toCommands(config) }
        .withEnv(config.env)
}

Step 1: Parsing the Config Section

The parseConfig method (lines 95-104) expects the first JSON token to be START_OBJECT. If the YAML does not begin with an object (the appId and other settings), it immediately throws a ParseException with a clear error message.

This validation ensures that every flow file begins with proper configuration metadata before proceeding to the command array.

Step 2: Parsing the Command List

After the config section, parseCommands (lines 69-92) validates that the next token is START_ARRAY. The parser iterates over this array, deserializing each element as a YamlFluentCommand using the custom YamlCommandDeserializer.

This stage catches structural errors such as missing separators or malformed array entries, wrapping them in FlowParseException objects that include file location and context.

Step 3: Custom Deserialization Logic

The YamlCommandDeserializer (embedded at lines 106-122) distinguishes between two command shapes:

  • String commands: Simple entries like launchApp or stopApp
  • Object commands: Complex entries like tapOn: { text: "OK", optional: true }

For string commands, the deserializer looks up the command in a predefined stringCommands map. For object commands, it reads the field name, finds the matching constructor parameter, and uses Jackson to bind the nested options to the appropriate data class (such as YamlTapOn or YamlInputText).

Command Factories and String Commands

The stringCommands map (lines 59-89) holds lambdas that create YamlFluentCommand instances for every command that can be expressed without additional options. This includes common actions like:

  • launchApp
  • stopApp
  • clearState
  • hideKeyboard

When the deserializer encounters a string command, it retrieves the corresponding lambda from this map, which returns a YamlFluentCommand with default-filled option objects. Object-style commands bypass this map and use the generic object-parsing path instead.

Error Handling and Diagnostics

All parsing entry points wrap their logic in try/catch blocks that feed into the wrapException method (lines 67-94). When parsing fails, this analyzer examines the root cause and constructs a user-friendly FlowParseException containing:

  • The exact location in the file
  • The offending file path
  • A concise title and markdown-formatted message
  • Optional documentation links for common mistakes

For misspelled commands, the parser uses Levenshtein distance calculations in suggestCommandMessage (lines 19-28) to recommend similar valid commands. Additionally, the checkSyntax family of methods provides lightweight validation that the Studio UI and CLI can invoke without executing the flow.

Practical Usage Examples

Parse a Complete Flow from File

This is the standard CLI usage pattern:

import maestro.orchestra.yaml.MaestroFlowParser
import java.nio.file.Paths

val flowPath = Paths.get("myFlow.yaml")
val yaml = flowPath.readText()
val commands = MaestroFlowParser.parseFlow(flowPath, yaml)

The parseFlow method returns a List<MaestroCommand> ready for the execution engine.

Parse a Single Command Inline

Used by the "Run Command" endpoint or REPL interfaces:

val singleCommand = "- tapOn: { text: \"Submit\" }"
val commands = MaestroFlowParser.parseCommand(
    flowPath = Paths.get("/tmp/inline.yaml"),
    appId = "com.example.app",
    command = singleCommand
)

Validate Syntax Without Execution

For IDE integration or pre-commit hooks:

try {
    MaestroFlowParser.checkSyntax(yamlContent)
    println("YAML syntax is valid")
} catch (e: FlowParseException) {
    println("Syntax error at ${e.location}: ${e.message}")
}

The checkSyntax method selects the appropriate validator (checkFlowSyntax or checkCommandSyntax) based on the JSON tree shape.

Key Source Files

Understanding Maestro's YAML parsing requires familiarity with these specific files in the mobile-dev-inc/Maestro repository:

Summary

  • Maestro uses a Jackson YAML mapper with custom configuration to parse flow files without requiring --- start markers.
  • The MaestroFlowParser.parseFlow method orchestrates a three-stage pipeline: config parsing, command list parsing, and conversion to MaestroCommand.
  • String commands (like launchApp) resolve through a factory map, while object commands use Jackson's data binding to populate command-specific data classes.
  • Comprehensive error handling via wrapException provides file locations, suggestions for misspelled commands, and markdown-formatted error messages.
  • Syntax validation methods allow tools to check YAML correctness without executing tests, supporting IDE integrations and CI pipelines.

Frequently Asked Questions

How does Maestro handle YAML syntax errors in flow files?

Maestro catches parsing exceptions in the wrapException method and converts them into FlowParseException objects. These exceptions contain the exact file location, the path to the offending file, and a markdown-formatted message. For misspelled command names, the parser uses Levenshtein distance calculations to suggest the closest valid command.

What is the difference between string commands and object commands in Maestro YAML?

String commands are simple YAML strings like launchApp that require no parameters; Maestro resolves these through the stringCommands factory map to create commands with default options. Object commands use YAML mapping syntax like tapOn: { text: "OK" }; the custom YamlCommandDeserializer reads the field name and uses Jackson to bind the nested properties to type-specific data classes such as YamlTapOn.

Can I validate a Maestro YAML file without running the test?

Yes. The MaestroFlowParser class provides checkSyntax and related methods (checkFlowSyntax, checkCommandSyntax) that parse and validate YAML structure without creating executable commands. These methods throw FlowParseException on validation failures, making them suitable for IDE linting, pre-commit hooks, and Studio UI feedback loops.

Where does the conversion from YAML to runtime commands happen?

The conversion occurs in maestro-orchestra/src/main/java/maestro/orchestra/yaml/MaestroFlowParser.kt. Specifically, the toCommands extension method transforms the list of YamlFluentCommand objects (produced by Jackson deserialization) into MaestroCommand instances that the Maestro execution engine can run. This happens after parseConfig and parseCommands complete their work.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →