Running AI agents in a large iOS/Android codebase: project rules, context and limits

Coding agents shine in a small repo and stumble in a 100-module mobile app, and the difference is mostly how ready the repo is. Single-module build and test commands, per-module rule files, bounded context and the permissions you withhold, end to end.

Try a coding agent on a three-screen demo project and the result is impressive: you hand over the task, it builds, writes the test, opens the PR. Put the same agent into a 110-module travel booking app and it spends its first half hour grepping the whole repo, its second half hour building the entire app, and finishes by hand-editing project.pbxproj until the project no longer opens. Same model. What changed is the environment it works in.

Our position: in a large mobile codebase, agent output depends more on how ready the repo is than on which model you picked. Everything a new hire trips over in their first week (which command builds this, what may this module depend on, where do the tests live) stops the agent again in every session, because every session starts with its memory wiped. This article covers how we prepare a repo, what we show the agent, what we keep away from it, and which kinds of work should not go to an agent yet. The running example is Rota, a hypothetical travel app, and its fare alert feature (FareAlerts).

Why a large mobile repo wears an agent down

Start with a realistic "before". The task is one sentence and the repo has no agent-specific preparation:

Task: "Add a 'pause alert' action to the fare alerts screen."

The agent's session:
1. Searched for "FareAlert" from the root: hundreds of matches, dozens of files read
   (half of them a legacy Objective-C screen and a generated API client)
2. xcodebuild -workspace Rota.xcworkspace -scheme Rota build
   → the whole app compiled, tens of thousands of log lines poured into context
3. Hand-edited project.pbxproj to add the new Swift file to the target
4. Build broke, the agent set about "fixing" the signing settings
5. Ran tests together with the UI test target, the simulator never booted
6. Result: 14 files changed, two in another feature module, one in generated
   code; the PR description says "all tests pass"

Each step has a cause that is specific to mobile.

The feedback loop is slow. A backend service's test command returns in seconds. A clean build of a large iOS app takes minutes, UI tests need a simulator, Android instrumented tests need an emulator. An agent works by trial and error, and when each trial is expensive it either tries less or declares victory without checking.

The build system lives outside the code. The Xcode project file was never designed for hand editing, and signing settings are tied to a machine and an account. On the Gradle side, convention plugins, the version catalog and build variants determine task names; nobody who does not know the repo can guess whether to run testDebugUnitTest or testStagingDebugUnitTest.

Generated code is everywhere: OpenAPI clients, resource accessors, KSP and Hilt output, protobuf classes. The agent takes these for ordinary sources, reads them, and sometimes edits them. The next build erases the edit.

Context is finite. Even with a large window, once build logs and sixty irrelevant files have been poured in, less attention is left for the actual task, and rule adherence drops as the context gets crowded.

The most expensive cause: the conventions live in people's heads. "Feature modules never import each other", "new screens are SwiftUI but checkout is still UIKit", "strings are entered by hand for tr and en only, the translation team fills in the rest". These are passed on verbally in review. The agent does not attend review, so it knows none of them.

Building and testing one module from the command line

The first investment is being able to build and test a single module on its own, without opening an IDE, with a predictable command. You wanted that for humans anyway; the agent just makes its absence obvious.

iOS

If your modules are Swift packages there are two routes. For packages with pure logic and no UIKit or SwiftUI dependency, swift build and swift test are the fastest loop:

swift test --package-path ios/Modules/FareAlertsDomain --filter PauseRuleTests

For packages that depend on the iOS SDK, swift test tries to compile for the host platform (macOS) and usually fails. For those we run xcodebuild in the package's own directory against a simulator destination. We wrap it in a script so the agent does not reinvent the command every time:

#!/usr/bin/env bash
# scripts/ios/test-module.sh <Module> [TestClass[/testMethod]]
set -euo pipefail

MODULE="$1"
FILTER="${2:-}"
ROOT="$(git rev-parse --show-toplevel)"        # inside a worktree this is the worktree root
OUT="$ROOT/.agent-build/$MODULE"
SIM_ID="${AGENT_SIMULATOR_ID:?AGENT_SIMULATOR_ID is not set, run scripts/ios/bootstrap.sh}"

mkdir -p "$OUT"
rm -rf "$OUT/result.xcresult"

ARGS=(
  test
  -scheme "$MODULE"
  -destination "platform=iOS Simulator,id=$SIM_ID"
  -derivedDataPath "$ROOT/.agent-build/DerivedData"
  -resultBundlePath "$OUT/result.xcresult"
  -quiet
)
if [[ -n "$FILTER" ]]; then
  ARGS+=("-only-testing:${MODULE}Tests/${FILTER}")
fi

cd "$ROOT/ios/Modules/$MODULE"
if xcodebuild "${ARGS[@]}" > "$OUT/xcodebuild.log" 2>&1; then
  echo "PASSED: $MODULE"
else
  echo "FAILED: $MODULE (full log: $OUT/xcodebuild.log)"
  grep -E "error:" "$OUT/xcodebuild.log" | head -30 || true
  if [[ -d "$OUT/result.xcresult" ]]; then
    xcrun xcresulttool get test-results summary --path "$OUT/result.xcresult" --compact
  fi
  exit 1
fi

Three properties of this script are deliberate. It writes output to a file and shows the agent only the errors, because a ten-thousand-line build log is the first thing that fills the context. It keeps DerivedData inside the repo (or the worktree), so two agents running in parallel do not corrupt each other's builds. It selects the simulator by identifier, so the result does not change when two devices happen to be called "iPhone 17". Do not guess scheme names, check them with xcodebuild -list; packages with more than one product get a different auto-generated scheme name.

If your packages use macros or build tool plugins, headless builds ask for a trust confirmation. The xcodebuild flags -skipMacroValidation and -skipPackagePluginValidation skip it, and the tool's own help text labels both as a security risk. Turn them on knowingly, and only for dependencies you trust whose versions are pinned by Package.resolved.

Android

Gradle already has per-module tasks. What is usually missing is one place that says which of them is the right one:

# One module's unit tests, narrowed to a single class
./gradlew :feature:farealerts:testDebugUnitTest \
  --tests '*FareAlertsViewModelTest' --console=plain

# Lint for the same module (checks such as NewApi are caught here)
./gradlew :feature:farealerts:lintDebug --console=plain

# Compile check only
./gradlew :feature:farealerts:assembleDebug --console=plain

The --tests filter is a documented Gradle option, it accepts wildcards and can be passed more than once. With the configuration cache on, the configuration phase is skipped on the second and later invocations; since an agent runs the same command dozens of times in a row, you feel the difference. For tests that need an emulator, define Gradle Managed Devices: the device is described in the build file, the task name follows the <deviceName><Variant>AndroidTest pattern, and the agent never has to deal with the question of which emulator is running.

The rule is the same on both platforms: the command is one line, asks for no interaction, reports success and failure through its exit code, and prints little. Where those commands get written down is the subject of the next section.

Rule files: one at the root, one in every module

For the instruction files agents read at session start, the de facto shared format today is AGENTS.md: plain Markdown, no required fields, stewarded by the Agentic AI Foundation under the Linux Foundation. The format's monorepo rule is simple: you put files in subdirectories too, and the agent goes by the one closest to the file it is working on. The tools' loading behaviour is not identical, though, and the differences matter once you design per-module files. This is what we confirmed in their documentation as of October 2026:

Tool Files it reads How subdirectory files load
OpenAI Codex AGENTS.md, AGENTS.override.md Concatenates the files from the Git root down to the working directory; 32 KiB combined limit by default
Claude Code CLAUDE.md; AGENTS.md when no CLAUDE.md exists in the working directory or above (v2.1.277 and later); .claude/rules/*.md Working directory and ancestors at launch, subdirectory files when the agent reads a file in that directory
GitHub Copilot .github/copilot-instructions.md, .github/instructions/*.instructions.md (with applyTo), AGENTS.md The nearest AGENTS.md takes precedence
Cursor .cursor/rules/*.mdc (description, globs, alwaysApply), AGENTS.md Nested AGENTS.md files are supported
Gemini in Android Studio AGENTS.md Scans the current directory and its parents; a GEMINI.md in the same directory takes precedence

Our verdict: make AGENTS.md the single source of truth, one at the root and one in every module. Add tool-specific files only when you must, and only as a thin layer that points back at AGENTS.md (in Claude Code, a one-line @AGENTS.md import inside CLAUDE.md does that). The day you start maintaining the same rule by hand in two files, one of them goes stale. One practical consequence of the table: because Codex reads the files down to its working directory, starting the session in the module directory guarantees the module file is loaded.

The root file has to be short. The Claude Code documentation tells you to target under 200 lines per file and says longer files reduce adherence; what we observe points the same way. The root file holds what is true for every module:

# Rota: rules for agents

## Repo layout
- ios/Modules/<Name>/        Each one is a Swift package. App target: ios/App
- android/feature/<name>/    Feature modules. android/core/<name>/ is shared code
- Every module has its own AGENTS.md. Read it before working in that module.

## Commands
- iOS module tests:      scripts/ios/test-module.sh <Module> [TestClass]
- Android module tests:  ./gradlew :feature:<name>:testDebugUnitTest --console=plain
- Android lint:          ./gradlew :feature:<name>:lintDebug --console=plain
- Do NOT build the whole app. CI does that.

## Boundaries
- The task names a module. If you need to change a file outside it, stop and ask.
- *.xcodeproj, *.pbxproj, signing settings, Package.resolved, gradle/libs.versions.toml,
  build-logic/ : do not touch. If a change is needed, say so in the PR description.
- Generated code (ios/Modules/*/Generated, android/**/build/, *.pb.swift): do not edit.
- Do not add third-party dependencies.

## Before you say done
- Run the tests of the module you changed and paste the command output into the PR description.
- If you could not run something, write "could not run" explicitly.

Example for an iOS feature module

The module file holds what someone working in that module hears most often in review:

# FareAlerts (iOS)

Fare alert list, creation and pause screens.

## Dependencies
- Allowed: FareAlertsDomain, DesignSystem, Navigation, Analytics (interface)
- Forbidden: any other Feature module, Networking (go through the repository)

## Architecture
- Swift 6 language mode. This package compiles with defaultIsolation(MainActor.self).
- Screens are SwiftUI, state lives in an @Observable view model. No ObservableObject.
- View models take the FareAlertRepository protocol through init. No .shared.
- Files to imitate: Sources/FareAlerts/List/FareAlertListViewModel.swift
  and Tests/FareAlertsTests/FareAlertListViewModelTests.swift

## Platform
- Deployment target is iOS 17. Wrap anything newer in #available.
- Do not add @unchecked Sendable or nonisolated(unsafe). If it seems necessary, stop and ask.

## Resources
- Strings live in Resources/Localizable.xcstrings. Enter tr and en only,
  leave other languages empty. In code use String(localized:bundle: .module).
- Colours and fonts come from DesignSystem only.

## Tests
- Swift Testing (@Test, #expect). Fake repository: Tests/Support/FakeFareAlertRepository.swift
- Command: scripts/ios/test-module.sh FareAlerts

Example for an Android feature module

# :feature:farealerts (Android)

## Dependencies
- Allowed: :core:designsystem, :core:navigation, :domain:farealerts, :core:analytics-api
- Forbidden: other :feature:* modules, :core:network (go through the repository)

## Architecture
- UI is Compose. Per screen: a Route (binds the ViewModel) and a Screen (takes state, previewable).
- ViewModels expose StateFlow<UiState>. In the Route use collectAsStateWithLifecycle().
- DI: Hilt. New bindings go in di/FareAlertsModule.kt. Do not add @Singleton.
- Files to imitate: list/FareAlertListRoute.kt, list/FareAlertListViewModel.kt

## Platform
- minSdk 26. Newer APIs need a Build.VERSION.SDK_INT check; lint's NewApi catches this.
- No GlobalScope, no runBlocking. Long work goes in viewModelScope.

## Resources
- Text lives in src/main/res/values/strings.xml (en) and values-tr. Leave other values-* directories alone.
- No user-visible text is written in Kotlin code.
- Colours and typography come from RotaTheme. Do not write Color(0x...).

## Tests
- JUnit 5 + Turbine. Fake repository: src/test/.../FakeFareAlertRepository.kt
- Command: ./gradlew :feature:farealerts:testDebugUnitTest --console=plain
- Before finishing: ./gradlew :feature:farealerts:lintDebug --console=plain

What stays out of these files matters just as much. Essays on architectural philosophy, the module's history, unverifiable advice like "write clean code", and formatting rules the linter already enforces do not belong. Our test: every line should describe something the agent would get wrong without it. A line that does not is just taking up room.

One more warning: an instruction file is context, and nothing enforces it. The Claude Code documentation says so explicitly, and the same holds for the other tools. Writing "feature modules never import each other" into the file gets the agent to comply most of the time. What gets it to comply every time is a check that fails CI on the same rule. We describe how to set that up in architecture guardrails in the age of AI.

Context strategy: show the interface and an example, use the graph as the boundary

We tell the agent what to look at instead of asking it to "understand the repo". A good task description has three things: the module's name, the interface to honour, and an example to imitate.

Module: ios/Modules/FareAlerts
Task: Add a "pause alert" action to the list.
Interface: FareAlertRepository.pause(_:) in FareAlertsDomain already exists, do not change it.
Example: Do it the way the delete action is done on the same screen (FareAlertListViewModel.delete).
Acceptance: On success the row moves to the "paused" state; on failure the existing
            error banner is shown. Write a test for each.
Out of scope: Android, analytics events, design changes.

The "Example" line does the most work. Agents are very good at copying an existing pattern. Left to pick the pattern themselves, they have a real chance of finding the oldest and worst specimen in the repo.

This is where a modular codebase pays a second time. The dependency graph mechanically bounds what the agent needs to read: if FareAlerts depends on only four modules, the agent has no reason to look at anything beyond the public interface of those four. The manifest already states this in a form the agent can read:

// swift-tools-version: 6.2
// ios/Modules/FareAlerts/Package.swift
import PackageDescription

let package = Package(
    name: "FareAlerts",
    defaultLocalization: "en",
    platforms: [.iOS(.v17)],
    products: [.library(name: "FareAlerts", targets: ["FareAlerts"])],
    dependencies: [
        .package(path: "../FareAlertsDomain"),
        .package(path: "../DesignSystem"),
        .package(path: "../Navigation"),
        .package(path: "../AnalyticsInterface"),
    ],
    targets: [
        .target(
            name: "FareAlerts",
            dependencies: ["FareAlertsDomain", "DesignSystem", "Navigation", "AnalyticsInterface"],
            resources: [.process("Resources")],
            swiftSettings: [.defaultIsolation(MainActor.self)]
        ),
        .testTarget(name: "FareAlertsTests", dependencies: ["FareAlerts"]),
    ]
)
// android/feature/farealerts/build.gradle.kts
plugins {
    id("rota.android.feature")        // convention plugin from build-logic
}

android {
    namespace = "app.rota.feature.farealerts"
}

dependencies {
    implementation(projects.core.designsystem)
    implementation(projects.core.navigation)
    implementation(projects.domain.farealerts)
    implementation(projects.core.analyticsApi)

    testImplementation(projects.core.testing)
}

The compiler enforces the same boundary: if the agent tries to import a forbidden module the build breaks, and the error message tells it why. A single-target app has no such feedback; every file can see every file, and so can the agent. We discussed what modularization costs in a separate article; working with agents adds a new line to the income side of that calculation.

Our rule for task size: one task, one module. Work that has to touch two modules is split into the interface change first (by a human, or as a separate small PR) and then one task per module.

Giving the agent eyes and hands, and keeping the keys

To verify its own work the agent has to see more than code. What we give it, in order of importance:

Build and test commands, through the scripts above. That alone moves the agent from "I wrote it, I think it works" to "I wrote it, the tests pass".

A simulator or emulator. On iOS, xcrun simctl boots a device, installs and launches the app, opens a deep link and takes a screenshot, all from the command line. On Android, adb does the same jobs. For a model that can read images, a screenshot is how it sees the bugs tests miss, such as an empty-state label wrapping onto two lines.

What the IDE knows. Apple exposes Xcode's capabilities to outside agents over MCP: you turn on "Allow external agents to use Xcode tools" in Intelligence settings and connect the agent with the xcrun mcpbridge command. According to Apple's documentation the project has to be open in Xcode for the agent to work, so this route is useful on a developer's machine, and a headless CI runner still needs the xcodebuild scripts. On the Android side, Google's Android CLI (which reached 1.0 in 2026) plays a similar role: it gathers jobs like searching current documentation with android docs search, managing emulators with android emulator, grabbing the screen with android screen capture and finding usages in the open project through the android studio subcommands into one command-line interface. The subcommand and tool lists of both change between releases; check the list in your own installation.

Current documentation. The model's training data can be older than the SDK version you ship against. A tool that can search documentation (the ones above, or a documentation server connected over MCP) is the cheapest way to reduce invented APIs.

What we withhold is a shorter list, and it is not negotiable:

  • Distribution certificates, provisioning profiles, keystore files and their passwords.
  • App Store Connect and Play Console API keys, fastlane credentials.
  • Production API keys and anything containing real user data.
  • Push rights to main and release branches, and the right to merge PRs.

There is a concrete reason. With a cloud-hosted model, every file the agent reads and the output of every command it runs travels to the provider as part of the prompt; how long it is retained depends on your contract and plan settings. A .env file the agent can read, or a token that shows up in a build log, takes that trip too. Second, the agent does not take instructions only from you: an issue it reads, a dependency's README or a web page can contain text written to look like a command. Tricking an agent that holds no signing key and no push rights is a far cheaper incident than tricking one that does.

The tool's permission settings are one layer of this. In Claude Code, at project level, it looks like this (other tools have their own permission and sandbox settings, the pattern is the same):

{
  "permissions": {
    "allow": [
      "Bash(./scripts/ios/test-module.sh *)",
      "Bash(./gradlew *)",
      "Bash(xcrun simctl *)"
    ],
    "deny": [
      "Bash(git push *)",
      "Bash(./gradlew *publish*)",
      "Bash(fastlane *)",
      "Read(.env)",
      "Read(*.jks)",
      "Read(*.p12)",
      "Read(keystore.properties)"
    ]
  }
}

The tool's own documentation says Bash rules match the command text and should not be treated as a security boundary. The real boundaries are elsewhere: the keys are never on the machine the agent runs on, signing happens only in CI, main is protected on the server side, and the token the agent uses can write only to its own branch.

Work to give an agent, and work to keep

Kind of work Why it fits or does not Verdict
Mechanical migration (ObservableObject to @Observable, LiveData to StateFlow, module by module) Clear pattern, each module verified independently Hand it over, one PR per module
Writing tests for existing code Interface and an example test exist, the result is verified by running it Hand it over, and check in review that the test actually tests something
Rolling a new internal API out across modules A human does one module, the rest repeats the pattern Hand it over, write the first example yourself
Reproducing a bug "Write a failing test that shows this crash" is a verifiable goal Hand it over, make the fix a separate step
A small feature in one module with the design settled Boundary and acceptance criteria are clear Hand it over
Cross-module redesign (replacing navigation, switching DI framework) Many decisions, and verification is only possible on the whole app A human designs it, the agent takes the mechanical steps only
Vague tickets ("checkout is slow", "clean up the code") No definition of done, the agent sets its own scope Keep it; measure first and narrow the ticket
Signing, release, store configuration Mistakes are expensive and it needs privileges Keep it

The common criterion in that table is a finish line a machine can verify. "Tests pass and lint is clean" is a finish line. "It got better" is not one, so the agent interprets it in its own favour.

Running in parallel: worktrees, CI as the judge, and review load

When the work is module-sized and independent, several agents can run at once. Each one gets its own working copy through git worktree:

git worktree add ../rota-wt-farealerts -b agent/farealerts-pause
git worktree add ../rota-wt-search     -b agent/search-observable

# A separate simulator per worktree
xcrun simctl create "agent-farealerts" "iPhone 17"   # prints the device identifier
export AGENT_SIMULATOR_ID=<the identifier above>

On mobile a worktree alone is not enough, because three more resources are shared. If DerivedData stays common, two builds corrupt each other (our script solves that by keeping it inside the worktree). If two agents install the app on the same simulator, tests see each other's state; create a device per agent with simctl create. On Android every emulator shows up under its own serial, and adb takes its target from the ANDROID_SERIAL environment variable. The fourth resource is the machine itself: every concurrent Xcode build and every Gradle daemon wants a serious amount of memory. Do not guess how many agents can run in parallel; time two, three and four parallel module builds on your own machine and stay one below the point where the timing falls apart.

CI is the judge. The agent's statement that the tests passed is a claim, and the green check on the PR is the evidence. CI should not treat agent PRs any differently from human PRs: same tests, same lint, same architecture checks.

The real bottleneck is review. Five agents can open twenty PRs a day, and your team cannot read twenty PRs with care. Sizing parallelism to what the machine can take is a mistake; size it to what review can take. Keeping PRs small and single-module helps here as well, because a small diff can actually be read. The particular habits that reviewing agent output requires are covered in code review with AI.

The upper limit on parallel agents is your review capacity.

Failure modes we see often on mobile

Editing project files

In project.pbxproj every file appears in several places under unique identifiers. When an agent edits it by hand, the result is either a project that does not open or a diff that does not merge with any other branch. The fixes, most effective first: keep modules as Swift packages (adding a file does not even require a manifest change), use the folder-synchronized groups that arrived with Xcode 16, or generate the project file with a generator (Tuist, XcodeGen) and never commit it. The Android counterpart is libs.versions.toml and build-logic: the agent "fixes" a compile error by bumping a library version and affects the whole app. Both are on the do-not-touch list in the root file.

Inventing APIs for the wrong OS or SDK version

This comes in two forms. In the first the API does not exist at all; the compiler catches it, the agent fixes it, little is lost. In the second the API is real but newer than your deployment target. On iOS the compiler catches that as an availability error. On Android compilation succeeds; lint's NewApi check catches it, and only if lint is run. That is the reason for the "before finishing, run lint" line in the module file. We see the opposite direction too: because its training data is full of examples, the agent writes the old API (ObservableObject, LiveData) into new code. No compiler catches that. The "files to imitate" line in the module file does.

Ignoring main-actor and lifecycle rules

The following is typical of what agents produce. The @unchecked Sendable added to make it compile is the first thing to look for in review:

// What the agent produced: it compiles and its test passes
final class FareAlertsViewModel: ObservableObject, @unchecked Sendable {
    @Published var alerts: [FareAlert] = []
    private let repository: any FareAlertRepository

    init(repository: any FareAlertRepository) { self.repository = repository }

    func pause(_ alert: FareAlert) {
        Task.detached {
            try? await self.repository.pause(alert.id)
            let updated = (try? await self.repository.alerts()) ?? []
            DispatchQueue.main.async { self.alerts = updated }
        }
    }
}

The error is swallowed, isolation is hidden from the compiler, and there is no testable async surface. The version that follows the module's rules is shorter:

@MainActor
@Observable
final class FareAlertsViewModel {
    private(set) var alerts: [FareAlert] = []
    private(set) var errorMessage: String?
    private let repository: any FareAlertRepository

    init(repository: any FareAlertRepository) { self.repository = repository }

    func pause(_ alert: FareAlert) async {
        do {
            try await repository.pause(alert.id)
            alerts = try await repository.alerts()
        } catch {
            errorMessage = String(localized: "fareAlerts.pause.failed", bundle: .module)
        }
    }
}

On Android the member of the same family is lifecycle:

// What the agent produced: it compiles and looks right in the preview
@Composable
fun FareAlertsRoute(viewModel: FareAlertsViewModel = hiltViewModel()) {
    val state by viewModel.state.collectAsState()

    LaunchedEffect(Unit) { viewModel.load() }

    if (state.alerts.isEmpty()) {
        Text("You have no fare alerts yet", color = Color(0xFF6B7280))
    } else {
        FareAlertList(state.alerts, onPause = viewModel::pause)
    }
}

collectAsState() keeps collecting while the app is in the background, LaunchedEffect(Unit) reloads every time the user returns to the screen, and the text and colour are hardcoded. The version that follows the module rules:

@Composable
fun FareAlertsRoute(viewModel: FareAlertsViewModel = hiltViewModel()) {
    // Loading lives in the ViewModel: state is produced with stateIn(WhileSubscribed)
    val state by viewModel.state.collectAsStateWithLifecycle()

    if (state.alerts.isEmpty()) {
        RotaEmptyState(text = stringResource(R.string.fare_alerts_empty))
    } else {
        FareAlertList(state.alerts, onPause = viewModel::pause)
    }
}

In both examples the first version compiles and passes its unit test. That is why we write these rules into the module file and turn the ones we can into lint rules.

Resource and localization files

A String Catalog (.xcstrings) is one large JSON file. If the agent rewrites the whole file to add one key, translation states can be lost and conflicts with parallel branches are guaranteed. The second risk is well-intentioned: the agent translates all fourteen languages itself and nobody on the PR checks the Polish. Our rule is that it enters the source languages only and leaves the rest to the translation process. SPM modules have one more trap: forget bundle: .module and the key is looked up in the app bundle, so the user sees the raw key on screen; unit tests do not catch this, a screenshot does. The Android counterpart is text added to only one of the values-* directories or written straight into Kotlin code.

Where to stop

Not every project needs all of this. In a single-target, five-screen app, a short AGENTS.md at the root and a working test command are enough; per-module files, a worktree setup and a simulator pool are maintenance burden there. In the opposite case, a large monolith with no module boundaries, rule files help only so much, because nothing structural narrows what the agent has to read; invest in modularization first. The rule files themselves need upkeep too: when you say a rule in review for the third time, add it to the file, and when a rule is now enforced by lint, delete it from the file.

To tell whether the preparation is working, ignore model comparisons and look at your own PRs: how many agent PRs are green on the first CI run, how many spilled outside the module named in the task, how many drew a "we don't do it that way here" comment in review. If those three numbers are improving, the repo is becoming agent-ready.

Readiness checklist

  • Every module can be built and tested with one command without opening an IDE; the command asks for nothing and prints little.
  • iOS: the test script keeps DerivedData inside the working copy and selects the simulator by identifier.
  • Android: per-module unit test and lint tasks are known; managed devices are defined for tests that need an emulator.
  • The root has an AGENTS.md under 200 lines: repo layout, commands, files that must not be touched.
  • Every frequently changed module has its own AGENTS.md: allowed dependencies, files to imitate, platform version, resource rules, test command.
  • How each tool you use loads these files has been checked in its own documentation; no rule is maintained by hand in two files.
  • The most critical architecture rules in the files are also enforced in CI.
  • The task template includes module, interface, example, acceptance criteria and out-of-scope; one task touches one module.
  • The environment the agent runs in has no signing keys, store credentials or production keys; main is protected on the server side.
  • Project files, the version catalog and generated code are on the do-not-touch list.
  • In parallel runs every agent has its own worktree, its own DerivedData and its own simulator or emulator.
  • The number of parallel agents is capped by review capacity.
  • You track how many agent PRs are green on the first CI run, spill outside their module, or draw a convention comment.

Need support on this?

Let's review together whether these controls are set up correctly in your app. We're here with security audits and advisory.

Comments and questions

Disagree with something, or stuck on this in your own project? Write to us and let's talk.