LOADING
2838 words
14 minutes
Harness Engineering for Dual-Native Mobile Development: Beyond AI Coding Assistants

Exploring how a shared engineering harness can guide AI agents to build native iOS and Android applications.

The project’s code related to this post can be found at Leafly

What if we no longer needed to share code to develop cross-platform applications? Engineering with SwiftUI and Jetpack Compose

For years, developing an application for both iOS and Android has meant making a significant choice: should we maintain two separate native implementations, or opt for a cross-platform technology that allows us to share a single codebase?

Frameworks like Flutter or React Native, along with solutions such as Kotlin Multiplatform, have addressed a specific need: reducing the cost and time required to develop two applications that, in essence, deliver the same product.

The logic behind this is straightforward. Maintaining two separate codebases means duplicating work, and often requires specialized teams for each platform. This can lead to higher costs in terms of development, coordination, and maintenance. Conversely, by choosing a cross-platform technology, we can focus much of that effort on a single codebase, managed by a single team.

From an organizational and economic perspective, this is a perfectly reasonable decision. However, compared to native development on each platform, it often entails trade-offs—such as limited access to native APIs, differences in the “feel” of the user interface (UI), or a reliance on an intermediate abstraction layer.

While we have often accepted these trade-offs—simply because the cost of maintaining two native implementations was difficult to justify—the rise of AI agent-assisted development introduces a new variable into the equation.

What if we could maintain two fully native implementations without having to duplicate the development effort to the same extent?

Imagine a scenario where an AI agent works on an iOS app using SwiftUI, and, in a separate session (or via an agent configured specifically for Android), works on an Android app using Jetpack Compose. In both cases, the agent operates based on the same specifications, business rules, and acceptance criteria, all within a shared product context. From this perspective, we would no longer necessarily need two independent teams arriving at the same product decisions and solving the same problems from scratch. We could share knowledge, specifications, context, and a significant portion of the engineering process, while the agent handles the creation of a native implementation.

The distinction is significant: until now, a primary strategy for avoiding duplicated effort has been to share code. With agents, we can begin to explore a different alternative: sharing the knowledge required to build the product, rather than necessarily the code that implements it.

This is the hypothesis I set out to explore with Leafly, a plant-care application I developed using two independent implementations: SwiftUI for iOS and Jetpack Compose for Android.

To coordinate the agent’s work and ensure that both applications did not evolve into two distinct products, I employed an engineering approach based on harness engineering: a shared framework of specifications, rules, context, and “definition of done” criteria that the agent consults before writing native code for each platform.

The real challenge isn’t writing code twice

Just because we use AI agents to develop an application for two different platforms, it doesn’t mean we automatically solve the problem of keeping them aligned. In fact, it can even introduce new issues.

For instance, one agent might interpret a specification in one way, while another interprets it differently. A feature might be completed on iOS, while the Android version is still based on an earlier iteration. Alternatively, product decisions might emerge that no one requested, or behaviors might appear that are technically sound but don’t align with what was actually expected.

Therefore, the challenge is no longer just about implementing a feature twice; it’s about ensuring that two independent implementations evolve in the same direction. That is why, before focusing on how to generate code, I realized I needed to address several other questions:

  • How can I provide the same product context to the agents?
  • How can I define what a feature should do without dictating how it’s implemented?
  • How can I prevent each platform from evolving independently?
  • How can I objectively determine when a feature is truly complete?

The solution was to build a harness that acts as the project’s single source of truth.

Harness engineering: designing the agents’ working environment

An AI model (no matter how powerful) lacks knowledge of a project’s architectural decisions, team conventions, business rules, and quality standards.

Consequently, we must provide an environment that supplies this information and sets boundaries on the decisions the model can make. We call this environment a harness.

Think of the harness as the infrastructure surrounding the model, which makes its generative capabilities genuinely useful for our project’s development. This harness encompasses context, instructions, tools, documentation, the current state of work, constraints, and validation mechanisms. There is a significant difference between working with isolated prompts and using harness engineering. In the former, each session relies heavily on how well we can explain things to the agent. In the latter, the relevant project knowledge is structured, versioned, and persistently available.

The goal is not to make the agent more intelligent; it is to ensure it needs to improvise less.

In my case, I wanted this environment to be independent of the specific programming tool being used. The rules and project knowledge needed to reside within the repository itself, rather than in a configuration specific to OpenCode, Claude Code, or any other agent.

This way, the harness can evolve alongside the product, and different tools can operate within the same context.

Leafly: One Project, Two Native Implementations, and a Shared Harness

Leafly is an app for the care of houseplants (something I personally struggle with). The MVP includes six core features:

  1. Onboarding and registering the first plant.
  2. A list of plants with their watering status.
  3. Details for each plant and a log of watering events.
  4. Local reminders.
  5. Taking photos when adding a plant.
  6. A visual history of photos.

In my view, this isn’t a particularly complex product, which made it a great scenario for experimentation. I wanted to focus exclusively on the engineering process, without introducing the complications of a distributed backend or overly extensive business logic from the outset.

The architectural decision was to maintain two independent implementations:

iOSAndroid
Swift 6Kotlin
SwiftUIJetpack Compose
SwiftDataRoom
ObservationViewModel / StateFlow

In other words, there is no shared UI layer or common domain implementation; each platform utilizes its own technologies and conventions.

What both do share is the product’s functional definition, business rules, specifications, and acceptance criteria.

The concept is to separate two distinct aspects:

  • What the application should do.
  • How it should be implemented on each platform.

The former belongs to the shared contract, while the latter pertains to each individual native implementation. ### The Harness Structure

For this experiment, I organized the project into a monorepo with the following structure:

leafly/
├── AGENTS.md
├── feature_list.json
├── progress/
│ └── current.md
├── docs/
│ ├── architecture.md
│ └── design-system.md
├── features/
│ ├── onboarding.feature
│ ├── plant-list.feature
│ ├── plant-detail.feature
│ ├── watering.feature
│ └── reminders.feature
├── ios/
│ └── AGENTS.md
└── android/
└── AGENTS.md

In this setup, each element has a specific responsibility.

AGENTS.md: Project Rules

This serves as the entry point for the harness. It defines what is being built, how the repository is organized, which technologies are used, and the rules that must be followed.

It also establishes key constraints: do not invent requirements, work on only one feature at a time, and do not consider a feature complete until it has been implemented on both platforms.

Note that, in addition to the main AGENTS.md file located at the project root, there are two specialized AGENTS.md files (one for iOS and one for Android) that incorporate conventions specific to each environment.

feature_list.json: Product Status

This file contains a structured backlog, including identifiers, descriptions, statuses, target platforms, and references to the corresponding specifications.

This allows the agent to track which features are pending, in progress, or completed, without relying on the memory of previous conversations.

progress/current.md: Continuity Between Sessions

This file summarizes the current state of the work, recent decisions, and the next steps. This is particularly useful when development spans multiple sessions or tools.

features/: Shared Functional Specifications

Each feature has its own specification, independent of the technology used to implement it. For these specifications, I used .feature files written in Gherkin to describe the expected behavior.

For example:

Feature: List of my plants with their watering status
Scenario: Viewing the list when plants are present
When the user opens the app
Then they should see a list of all their plants
And each plant displays its name
And each plant displays a watering status indicator

The advantage is that we can describe the behavior without dictating the implementation. In other words, we don’t need to instruct the agent to use a specific SwiftUI component or a particular composable. We simply need to define what should happen and which conditions must be met. Subsequently, each agent translates that contract into the conventions and capabilities of its respective platform.

Gherkin is not strictly necessary; we could use structured Markdown or another format instead. What matters is having shared specifications that are sufficiently precise and easy to consult.

Implementation

Once a feature has been defined, we proceed to implement it by issuing the appropriate command to the Agent. In this case (using OpenCode for the previously mentioned example of the plant list) I provided the following prompts (for iOS and Android):

iOS

Read the following project files:
- AGENTS.md
- ios/AGENTS.md
- docs/architecture.md
- docs/design-system.md
- progress/current.md
- features/02-plant-list.feature
- feature_list.json
Current context:
- The `onboarding-first-plant` feature is already marked as "done" (iOS + Android).
- We are now working on `plant-list` (status: in_progress).
- We are working ONLY on iOS.
- Basic navigation and plant persistence (SwiftData) are already in place.
- The design system is already established and must be reused.
Task:
Implement the `plant-list` feature on iOS, following the specification.
Required behavior:
- If plants exist, display a list containing all of them.
- Each plant entry displays:
- Name
- A visual indicator for watering status (Up to date / Water soon / Needs water)
- Watering statuses (following the same logic as iOS):
- 0–4 days → Up to date
- 5–6 days → Water soon
- 7+ days → Needs water
- Tapping a plant should navigate to a placeholder detail screen.
- There must be a clear way to add a new plant from the list.
- Adhere to the design system (rounded cards, natural green tones, a calm aesthetic, and ample spacing).
Technical requirements:
- Swift 6 + SwiftUI + @Observable
- Reuse the existing domain and repository layers.
- Calculate the watering status in a simple, clear way (you may base this on the date of the last watering).
- Ensure the code is clean, testable, and aligned with the current architecture.
Do not implement the Android version yet.
Do not mark the feature as "done."

Android

Read the following project files:
- AGENTS.md
- android/AGENTS.md
- docs/architecture.md
- docs/design-system.md
- progress/current.md
- features/02-plant-list.feature
- feature_list.json
Current context:
- The `plant-list` feature is already implemented and verified on iOS.
- We are now implementing it on Android.
- We are working ONLY on Android.
- The following are already in place: onboarding, the Plant domain, Room, the design system, and basic routing.
- The plant list must behave the same way as it does on iOS, according to the spec.
Task:
Implement the `plant-list` feature on Android.
Required behavior:
- If plants exist, display a list containing all of them.
- Each plant entry displays:
- Name
- A visual indicator for watering status (Up to date / Water soon / Needs water)
- Watering statuses (following the same logic as iOS):
- 0–4 days → Up to date
- 5–6 days → Water soon
- 7+ days → Needs water
- Tapping a plant should navigate to a placeholder detail screen.
- There must be a clear way to add a new plant from the list.
- Adhere to the design system (rounded cards, natural green tones, a calm aesthetic, and ample spacing).
Technical requirements:
- Kotlin + Jetpack Compose + ViewModel + StateFlow
- Reuse the existing Room setup and domain logic.
- Add `lastWateredAt` to the entity if it is not already present.
- Ensure clean code that aligns with the current architecture.
Do not modify the iOS codebase.
Do not mark the feature as "done."

A Common “Definition of Done”

This is perhaps one of the most important rules of the experiment, as it defines when a feature can be considered “done”:

  • It is implemented on iOS.
  • It is implemented on Android.
  • Both implementations adhere to the same specification.
  • The necessary checks have been performed.
  • There are no known behavioral differences that contradict the contract.

In other words, the status of a feature reflects the state of the entire product, not that of an individual platform. Consequently, a feature might be finished on iOS but still pending on Android; in such a case, the feature is still considered “in progress.”

While this may seem like a minor detail, it allows us to rethink how we organize the backlog and prevents one platform from artificially advancing further than the other.

My Workflow: A Development Cycle per Feature

With these components in place, the workflow is clearly defined:

  1. Select the next pending feature.
  2. Review and complete its specification.
  3. Mark it as in_progress.
  4. Implement it on iOS.
  5. Verify the result and record the changes.
  6. Implement the same feature on Android.
  7. Validate both implementations against the specification.
  8. Update the status to done.

Throughout this experiment, I followed a sequential order: iOS first, followed by Android.

Although I could have employed agents working in parallel, I chose to limit the variables and maintain more direct control over decision-making. The priority was not to maximize the number of agents running simultaneously, but rather to verify that the system could maintain consistency across two independent implementations.

I also added a small init.sh script to ensure the test harness structure was in place and to display the next recommended feature.

Finally, I documented a shared “design system” in docs/design-system.md, covering colors, typography, spacing, components, and visual direction. The intention was not to enforce a pixel-for-pixel reproduction. SwiftUI and Compose have their own conventions. The goal was to share a visual identity and let each platform manifest it natively.

What I’ve learned during the experiment

For me, beyond achieving a complete implementation on both platforms, the most interesting aspect has been observing how the development process itself changes.

The agent’s context is part of the architecture.

Architectural decisions, business rules, and conventions must be available in a structured format. Otherwise, every session carries the risk of reinterpreting decisions that have already been made.

Specifications reduce divergence.

Duplicating an implementation does not necessarily mean duplicating the behavior. If agents work from the same functional definition, and if we can verify the results, it is possible to maintain two independent implementations under a single contract.

The “Definition of Done” must reflect the product.

A feature is not done simply because the code compiles or a screen renders correctly. It is only finished when it meets the product’s requirements across all intended platforms.

Generating more code does not necessarily mean being more productive.

It is tempting to think that using two agents doubles the speed of work. However, implementation is only one part of the process; one must also define the tasks, provide context, review decisions, and verify the outcomes. The greatest benefit of the harness, at least in this experiment, has been the reduction of improvisation and coordination overhead.

I have also found that the harness requires maintenance: specifications evolve, architectural decisions shift, and the context must remain aligned with the code. If it isn’t, it can become a source of errors rather than an aid.

Is this the future of cross-platform development?

I don’t believe this experiment proves that Flutter or React Native have become obsolete. They remain excellent solutions for many products and teams. Nor do I believe that maintaining two native implementations is always the best decision.

However, I do believe that agents are shifting some of the variables that have traditionally influenced this architectural choice. If we can reduce the cost of implementing and maintaining two native applications, perhaps we should begin evaluating our alternatives from a different perspective. Instead of asking only how much code we can share, we can ask how much knowledge we can share, which decisions should remain platform-specific, and how we can ensure that two independent implementations adhere to the same contract.

In any case, there are still questions to explore: how to scale this approach for large teams, how to coordinate agents working in parallel, how to automate functional validation, and how to accurately measure the savings in time and effort compared to a cross-platform solution.

Leafly is an experiment limited to an MVP, it is not a demonstration that this model is superior in every scenario. But it has allowed me to explore a possibility that I find compelling for mobile engineering:

For years, we have tried to address cross-platform complexity by sharing code. Perhaps now we can tackle some of that complexity by sharing specifications, context, and knowledge.

This also shifts the role of the Mobile Tech Lead. Beyond defining architectures and making technology decisions, we will need to design the environments in which these agents operate, establish the contracts they must follow, and build the mechanisms that allow us to trust their outputs.

It is not about replacing native development with agents; it is about using agents to make a different approach to building and maintaining native applications a viable reality.

I believe it is worth exploring.

Harness Engineering for Dual-Native Mobile Development: Beyond AI Coding Assistants
Author
Raúl Ferrer
Published at
2026-11-12
License
CC BY-NC-SA 4.0

Some information may be outdated

Profile Image of the Author
Raúl Ferrer
Software Architect & Tech Lead. Applying software and systems engineering principles in production to build reliable, observable, and maintainable AI. Author of iOS Architecture Patterns (Apress).

Loading stats...