Home / Articles

Writing Down the Loops an AI Agent Runs on a Mobile App

Writing down the loops AI agents run on a mobile app: four questions each loop must answer, six nested loops from tap to slice, and checks that catch nothing.

Publisher: REACTAPP.TOP9 min read2122 words

This article is published in English.

Sources: Loop Engineering on a Mobile App: What My AI Agent Runs, What It Reads, What Ends It, and When It Asks Me

Tell an agent only to make the login test pass, and it designs the loop on its own, in the moment. One of two things tends to follow. It retries until anything at all goes green, including a test that now verifies less than before. Or it reads its own log, spots no error and announces the job finished. Both behaviors are loops, and in both the model picked the design based on whatever it happened to read.

Loop engineering removes that design choice from the model. A person decides in advance, in writing, what runs, what it reads to make a decision, what ends it and when it has to stop and ask a human. Here it's applied to a mobile app.

The setup

The worked example is a voice-messaging app for Android and iOS, produced largely by steering Claude Code. Each session gets a separate git checkout, called a worktree. Within it the agent builds the app, installs it on an emulator, operates the UI through Maestro, a YAML test runner that reads the screen's accessibility tree (the labeled controls the OS exposes), and commits its work. Nothing in the loops depends on this specific app. All they need is an agent that can act and then look.

The harness is the tooling, files, devices and rules that determine what the agent sees and does. Loops cover a different matter, namely how the agent behaves with that harness as time passes.

The four questions

Consider the tiniest loop: tap a button, read the accessibility tree and check whether the expected label appeared. If not, wait and read once more, and fail after ten seconds. Whatever the size of a loop in an agent setup, up to "is this feature done", it must answer four questions:

  1. What runs?
  2. What does it read in order to decide?
  3. What ends it?
  4. When does it halt and ask a person?

Without all four answers, there's no loop, only a script that runs once and hopes. Answering them for every loop the agent runs is the whole discipline. The rest is examples.

Loop 3, sign-in, shows the four answers marked on a real helper. Every state-dependent test flow begins with it:

- launchApp:
    stopApp: true                    # 1. what runs: a cold launch, every time
- retry:
    maxRetries: 3                    # 3. what ends it: Home on screen, or three tries
    commands:
      - runFlow:
          when:
            visible:
              text: "Continue"       # 2. what it reads: the Login screen's button
          file: login.yaml           #    -> sign in only if it's showing
      - runFlow: assert-home.yaml    #    -> then prove Home is on screen
# 4. when it asks a human: never. A fourth failure is red, and red is the answer.

A physical cause explains the retry. Just after a cold start the splash screen covers both Login and Home for roughly three seconds, so the first look may find neither. The limit is three attempts. A bare "retry until Home appears" would never end on a broken build, whereas three attempts absorb a slow boot and still let a real failure turn red.

Six loops that nest

The six loops run from smallest to largest, and each sits inside the next. Loop 1 executes dozens of times inside every test, each test runs inside a two-device run, each run inside a commit's checks and each commit inside a slice of work.

Build the innermost loop first, since all the outer ones rely on it. Loops 1, 3 and 6 are the tap loop, sign-in and device leasing. Three of the others merit explanation.

Loop 2 is a bounded retry. On empty caches, the first cold build in a new worktree can crash inside the Kotlin compiler. The agent's memory file holds a one-line rule: retry once before suspecting the change being tested. The limit matters. Unlimited retrying until green would mask a real break, but a single retry turns a known flake into nothing and makes a second failure meaningful.

Loop 4 is the pre-commit hook. No CI exists in this repo, so the hook is the lone loop that triggers on every change by itself. It fails closed: when the secret scanner gitleaks is absent, the commit is refused. It also runs the gates' own tests ahead of the gates, because a gate unable to locate its inputs just prints OK. Shortened, it reads:

#!/bin/sh
set -u
if ! command -v gitleaks >/dev/null 2>&1; then
    echo "pre-commit: gitleaks not found — refusing to commit without a secret scan." >&2
    exit 1
fi
gitleaks git --pre-commit --staged --redact --no-banner || exit 1

The second half looks like this:

# The gates' own tests run first: a discovery bug produces no answer,
# after which every gate iterates an empty list and prints OK.
python3 ./scripts/test_gates.py >/dev/null 2>&1 || exit 1for gate in check_string_parity audit_hardcoded_strings; do
    [ -x "./scripts/$gate.sh" ] && { "./scripts/$gate.sh" || exit 1; }
done

Loop 5 is the outer loop around each unit of work. A slice is a small shippable portion of a feature, and it passes through four steps. Propose writes the change record with the what, the why and acceptance criteria. Apply carries it out. Verify checks the result against that record. Archive syncs the specs and closes the slice. Verify is a step of its own with separate output, not a trailing remark from the implementer that tests pass, so the agent that wrote the code isn't the only one vouching for it.

Human checkpoints sit in a written table showing which decisions the agent makes alone and which need a question first. One sentence governs the table: ask first if reversing the change would cost more than about a day of rework. An agent that skips a step has to stop, explain and get approval.

Memory is what passes information between loops and across sessions: a set of short notes that Claude Code rereads whenever a session begins. This project holds 52, each a rule shaped like the loop 2 one. Without them, a fresh session repeats the earlier diagnosis from zero.

Checks that can't fail

All these loops end when some check says "done". A check that says done while catching nothing breaks the loop silently. That happened five ways in this repo.

A gate that could not fail. A localization gate was a script that blocks a commit when English text is hardcoded in the UI and not read from the string catalog. It contained a filter named filter_animation_labels, meant to discard only hits that looked like animation labels. The filter tested window ~ tagbuilder inside awk, but no tagbuilder was ever passed to awk. An unset awk variable is an empty string, and window ~ "" matches everything, so the filter threw away every hit it ever found. The gate had never fired once.

The filter buried 49 hardcoded strings across 14 files in the app's emergency feature, among them an English contentDescription on the SOS button sitting beside an unused translation. On-device screenshots revealed every real localization defect in that area, and the gate revealed none. Serving as loop 4's end condition, it examined every commit for two days and caught nothing.

A gate that ran nowhere. Specs demanded that the build fail, yet nothing enforced it, as there's no CI. The hook's first draft also used exec gitleaks, which swaps out the shell and so prevented any later line from executing.

A hook that fired on everything. An if: pattern can restrict Claude Code hooks to certain commands. A hook intended for gh pr create only used Bash(gh pr create *), one word too narrow. Patterns beyond a bare command name fail open when they meet $(), $VAR or heredocs. The remedy was if: Bash(gh *), with the script itself filtering the command read from stdin.

A suite that tested nothing. For three weeks the iOS test flows died at their first tap and nothing went red, since nothing was running them. The step supposed to hold the SOS button reported COMPLETED while doing nothing, so one SOS test gave identical passes on fixed code and broken code.

A check true of the wrong thing. Three green runs had been against an APK installed by another session. A spec validator approved 182 of 182 specs although one described a screen deleted months before. A PATCH returned 200 from a backend that hadn't received the migration and quietly discarded the field.

Before a check earns trust, bring back the exact defect it was built to catch and verify it goes red, then take the defect out and verify green, all in the session where the check was written. The docs-audit script here followed that recipe: flipping a status, adding a dangling link or stripping a delta each turn it red. Its design note puts the principle in one line, that a check which can't fail is decoration. It keeps no baseline file either, since silencing 292 known findings to reach green reads as coverage.

Where to begin

Start by writing the four answers before any code, as a comment atop the flow, script or rule. A missing answer then surfaces immediately and not inside a run that silently passes.

Next comes the smallest loop, the tap loop, which has two requirements. Controls must carry labels: contentDescription or testTag for Android, accessibilityLabel for iOS. And the agent needs a standing rule for every interaction with the screen. Here the rule lives in the repo's instruction file, the one Claude Code loads at the start of each session, and boils down to this:

Driving the emulator:
- Before every tap: `adb shell uiautomator dump`, and find the label you intend to tap.
- Tap the centre of that node's bounds. Never reuse a coordinate from an earlier dump.
- After the tap: dump again and confirm the expected label appeared. Poll up to 10 s,
  then stop and report red — do not guess a second tap.
- If the app won't launch: `pm dump <pkg> | grep enabled=` before suspecting the build.

Those four sentences are a loop the agent runs. What runs is dump, tap, dump. It reads the tree. It ends on the label appearing or after 10 seconds. It never asks a human, because red is the answer.

Finally, before trusting the check that closes the loop, deliberately break it. Rename the label and see whether the loop turns red. If it stays green, nothing is being read.

The full setup comes down to six pieces:

  1. A tap loop, written as the rule above inside the agent's instruction file. Everything else presumes the agent can act and then see.
  2. A pre-commit hook that fails closed, committed to the repo and put into .git/hooks by a brief setup script, so every checkout runs the committed version. Without a scanner there's no commit.
  3. A mutation test per gate, done on the day it's written: defect present gives red, defect gone gives green. Do it in that same session or it never happens.
  4. A bounded retry per known flake, such as a cold Gradle build, simulator boot or splash screen, kept as one line in memory or the instruction file: retry once, then blame the change.
  5. A verify step apart from implementation, with written output, meaning a checklist against the change record and not a sentence saying tests pass.
  6. A one-sentence human checkpoint in the instruction file: ask first if reversing costs more than about a day of rework.

In all, that adds up to a worktree that builds, an account that can sign in, a screen the agent can read, a device held for the run and loops that finish on checks someone has seen fail.