Status: every field below is backed by the current code. Fields that are parsed but not yet read by any consumer are flagged inline as (parsed only) so you know not to rely on them.
APP.md is a per-app description the developer writes once to give mirroir a map before exploration. It is NOT generated by the explorer — it's the developer's source of truth for what the app looks like and how it behaves.
Before the field list, a word on vocabulary. APP.md talks about a few distinct window regions, and the word "tab" is ambiguous in iOS literature. This spec uses these terms precisely:
| Term | What it means in APP.md | Examples |
|---|---|---|
| Status bar | OS chrome at the top — time, battery, signal. Never tappable by the explorer. | iOS status bar; iPadOS top strip. |
| Nav bar | Per-screen bar at the top holding the screen title and navigation chrome (back chevron, edit buttons). One per screen. | iOS UINavigationBar; "< Back Settings Edit" strip. |
| Tabs / edge controls | A cluster of primary entry points arranged along one window edge. The explorer treats them as high-priority breadth targets — first things to try, known to switch screens or trigger core actions. | iPhone bottom tab bar (Messages, Calendar, Safari); iPad sidebar; Tapo's right-side camera-control rail; Instagram's 5 bottom icons. |
| Content | Everything else inside the window. Cards, rows, charts, chat threads, etc. The explorer plans these per-screen. | Lists of chats, grids of photos, settings rows. |
When APP.md says ## Tabs or ## Tab Layout, it's describing the edge-control cluster — not strictly an iOS UITabBar. The field covers:
- A horizontal strip of 3–5 icons at the bottom (iPhone standard).
- A vertical column of icons on a window edge (Tapo camera controls, iPad sidebar).
- A top-edge toolbar with a fixed set of action buttons (some editing apps).
What it explicitly is not:
- Arbitrary chevron rows in a settings list — those are
content(table rows), detected per-screen. - The back button in the nav bar — that's chrome, not a tab.
- Transient overlay actions (floating share sheet, keyboard quick-actions) — those aren't geometry-stable enough to name.
The explorer uses the declared edge-control cluster for two things:
- Priority targets — inject into the per-screen plan as breadth-navigation items so they're visited before deeper content.
- Geometric fill-in — when OCR/YOLO only detects a subset of the expected N icons, extrapolate the missing positions along the declared axis and edge.
If your app has no edge-control cluster (full-screen game, single-view utility), you omit ## Tabs and ## Tab Layout entirely.
UTF-8 Markdown with an optional YAML front-matter block. File name must be APP.md (case-insensitive match during loading, but APP.md is canonical). Recommended location:
- Project-local:
<cwd>/.mirroir-mcp/skills/patterns/apps/<AppName>/APP.md - Global:
~/.mirroir-mcp/skills/patterns/apps/<AppName>/APP.md - Sibling skills repo:
<cwd>/../mirroir-skills/patterns/apps/<AppName>/APP.md
The loader matches the app: front-matter field against the app_name parameter of generate_skill using diacritics-insensitive, case-insensitive comparison ("Sante" → "Santé").
---
# Front matter (YAML-ish, flat key:value)
app: AppName # required
version: 1 # reserved for future versioning
locale: fr_CA # optional — prefer matching locales
archetype: dashboard # optional — screen recipe name
reset_before_explore: true # optional — force-quit before explore
obstacle_mode: auto # optional — auto | hint | off
---
## Structure # required (or at least one of Obstacles / Skip)
Free-form human prose describing the app. Becomes AI context in generated skills.
## [TabName] Tab # optional, one per tab
Per-tab description. Body is injected into the generated skill's context.
## Tabs # optional — explicit tab list, alternative to "N tabs: Tab1, Tab2"
- Tab One
- Tab Two
## Tab Layout # optional — see §Tab Layout
- orientation: vertical
- edge: right
## Obstacles # optional — auto-dismissal rules
- Permission dialog → tap "Allow"
- Paywall → dismiss via "Not Now"
## Skip # optional — element text never to tap
- Delete Account
- Sign Out
## Credentials # optional — parsed only, no consumer today
- email: ${TEST_EMAIL:-test@example.com}
- password: ${TEST_PASSWORD}
## Tips # optional — AI guidance, goes into skill context
- Dashboard cards can show empty state on test devices
- Swipe down on chart detail view to return| Field | Type | Required | Behavior |
|---|---|---|---|
app |
string | ✅ yes | Display name. Matching is diacritics-insensitive + case-insensitive. |
version |
int | optional | APP.md schema version (carried on AppDescription.schemaVersion). The only supported version is 1. A file declaring a higher version logs a warning at load time and is still parsed best-effort. Defaults to 1 when absent. |
locale |
string | optional | e.g. fr_CA. When multiple APP.md files match the same app, a locale match takes priority over a locale-less file. |
archetype |
string | optional | Name of a screen recipe (e.g. dashboard, social-feed, settings-list). When set, bypasses recipe auto-detection. |
reset_before_explore |
bool | optional | true / yes / 1 → explorer force-quits the app via App Switcher before launching. Used for apps that retain stateful overlays (TikTok, Instagram). Default: false. |
obstacle_mode |
enum | optional | auto (default): dismiss matching obstacles. hint: surface to AI but don't act. off: ignore rules. |
spotlight_name |
string | optional | Simulator-only. What launch_app types into Spotlight to reach the app in FakeMirroring. Defaults to app. Read by SimulatorSpecParser only when ## Simulator … sections are present. |
icon |
string | optional | Simulator-only. Single glyph rendered on the FakeMirroring home screen and App Switcher card. Defaults to the first character of app. Read by SimulatorSpecParser only when ## Simulator … sections are present. |
All section headings use ## (H2). H1 (#) is treated as a title and skipped. Any unrecognized ## heading is ignored unless it matches a ## [Name] Tab pattern (see Tabs).
Free-form prose. Included verbatim in the AI "App Context" block of generated skills. This is where you describe the app's overall shape: what screen appears on launch, what tabs exist, what kinds of content each tab holds.
If you use the inline convention tabs: A, B, C (or N tabs: A, B, C), mirroir extracts those names and treats them as high-priority navigation targets.
Per-tab descriptions. Any heading that ends with Tab or tab contributes its body to AI context and its name to the tabs list (e.g., ## Résumé Tab → tab name Résumé).
Explicit bullet list of tab names:
## Tabs
- Pour toi
- Explorer
- ProfilEquivalent to tabs: Pour toi, Explorer, Profil in the Structure section, but clearer.
Geometric hints for the edge-control finder. Without this section, the explorer assumes a horizontal bottom tab bar (iOS convention). Apps with a vertical side rail (Tapo camera controls, iPad sidebar) declare their geometry here.
## Tab Layout
- orientation: vertical # vertical | horizontal (default: horizontal)
- edge: right # top | bottom | left | right (default: bottom)When declared, TabTargetInjector.findTargets (Sources/mirroir-mcp/TabTargetInjector.swift) uses the layout hint to pick which window edge to sample (a 12%-wide band) and which axis the bar runs along. Text-match remains the primary strategy; the layout hint only governs the geometric fallback when OCR text matching leaves tabs unmatched. The fallback does not sort detected candidates and ordinal-map them. Instead, synthesizeAnchors (TabTargetInjector.swift:124–156) computes N evenly-spaced anchor points from the declared tab count and the bar geometry — each anchor's main-axis position is (index + 0.5) × axis / tabCount, independent of which individual icons were detected. The cross-axis position comes from the median of the band's detected (text-less) icon points, falling back to a geometric edge offset. Synthesis is gated by a presence check: the declared band must hold at least EnvConfig.tabSynthesisMinZoneEvidence elements, so a detail screen whose band happens to hold a stray element gets no phantom anchors. BFSExplorerHelpers only calls TabTargetInjector.inject, which wraps findTargets.
For the tab-synthesis fallback the axis is fixed by the declared orientation (horizontal → main axis X, vertical → main axis Y); the geometry comes from APP.md, not from inspecting the detections. The separate general icon-detection helper IconDetector.interpolateEvenSpacing does auto-detect a dominant axis from raw detections (extrapolating vertically when detections span a larger Y range than X range, otherwise horizontally), but that step backfills the icon list earlier in the pipeline and does not decide which axis synthesizeAnchors walks.
Rules for auto-dismissing interstitials (permission dialogs, paywalls, onboarding overlays):
- <trigger text> → <action text>Both → and -> are accepted as separators. Common action prefixes (tap , dismiss via , dismiss ) are stripped. Quoted action text is unwrapped.
Example:
- Health Access permission → tap "Autoriser"
- Notification permission → tap "Ne pas autoriser"
- Paywall → dismiss "Not Now"When obstacle_mode: auto (default), the explorer checks these rules after each OCR pass and taps the action before continuing. obstacle_mode: hint surfaces rules as AI context without acting; off disables the mechanism.
Explorer caveat: only the BFS explorer honors these app-specific obstacle rules — BFSExplorerExploring passes obstacles: appObstacles into ExplorerUtilities.dismissAlertIfPresent. The DFS explorer calls dismissAlertIfPresent without obstacle rules, so it dismisses generic system alerts only and ignores ## Obstacles.
Element text patterns the explorer must never tap. Merged with permissions.json.skipElements into a single global skip list for the session.
- Delete Account
- Sign Out
- Réinitialiser les donnéesKey-value pairs with ${VAR} / ${VAR:-default} substitution from the process environment. AppDescriptionLoader.resolveVariables applies the substitution at load time. SkillMdGenerator reads the resulting map and emits a ## Required Credentials section into the generated skill listing the declared key names only (sorted). Resolved env-var values never leak into the skill file — the section tells the AI what the flow needs while keeping secrets out of the artifact.
- email: ${TEST_EMAIL:-test@example.com}
- password: ${TEST_PASSWORD}Free-form exploration hints. Appended to the generated skill's context so the AI has extra guidance.
- Swipe down on detail views to return to the summary
- Some cards show "Aucune donnée" on test devices — treat as normalWhen an app is ported to the FakeMirroring multi-app simulator, its APP.md gains a declarative scene graph alongside the guidance fields above. On the mirroir-mcp side, SimulatorSpecParser.parse extracts these into a SimulatorSpec carried on AppDescription.simulator (AppDescription.swift:150), but no runtime consumer reads that field — it is parsed only there. FakeMirroring re-parses the same APP.md independently: AppPackLoader.makePack reads the file from disk and calls SimulatorSpecParser.parse again to build a runnable AppPack at startup (AppRegistry.swift:95–105). The two processes never exchange the SimulatorSpec value; each derives its own from the file. The parser returns nil — leaving simulator unset (and FakeMirroring skipping the file) — when none of these sections are present, so guidance-only APP.md files are unaffected.
Three H2 section shapes are recognized:
## Simulator— root/metadata block.- root: <screenID>names the launch screen. When omitted, the lexicographically-first declared screen ID wins (else"main").## Simulator Screen <id>— one per screen. Properties via- key: valuebullets (title,back→ back-chevron destination screen ID,tab_bar: true→ render the bottom bar). Elements via- element <kind>: …bullets, kept in declaration order. A destination is written with→or->after the text. Element kinds:row: Text -> dest(disclosure chevron),button: Text -> dest(pill, destination optional),text: Text(non-interactive),tab: Text -> dest(bottom-bar entry, destination required),textfield: "placeholder" #fieldID,slider: "label" #fieldID = 0.5(fraction clamped 0.0–1.0),placeholder: 370x175(gray media block, width×height in points).## Simulator Obstacle <id>— one per modal. Carries atitle, optionalbody, dismissalbuttons, and atrigger:on_first_describe,after_n_taps:<count>, ornever.
The spotlight_name and icon front-matter fields (above) feed the same SimulatorSpec and are only read when at least one ## Simulator … section exists.
## Simulator
- root: home
## Simulator Screen home
- title: Home
- tab_bar: true
- element row: Settings -> settings
- element tab: Home -> home
- element tab: Profile -> profile
## Simulator Obstacle welcome
- title: Welcome
- body: Thanks for trying the app.
- buttons: Continue
- trigger: on_first_describeAppDescriptionLoader.load(appName:)is called whengenerate_skill(action:"explore", app_name:"X")starts.- Search paths, in the order
AppDescriptionLoader.searchPaths()walks them (recursive file search per path):<cwd>/.mirroir-mcp/skills/**/APP.md~/.mirroir-mcp/skills/**/APP.md<cwd>/../mirroir-skills/patterns/apps/**/APP.md<cwd>/.mirroir-mcp/skills/patterns/apps/**/APP.md~/.mirroir-mcp/skills/patterns/apps/**/APP.md<cwd>/../mirroir-skills/apps/**/APP.md(legacy fallback)<cwd>/../mirroir-skills/**/APP.md(legacy fallback)
- Each file is parsed and its
app:field matched against the requested app name (diacritics-insensitive, case-insensitive). - Locale precedence:
- If any APP.md's
locale:matchesLocale.current.identifierexactly (case-insensitive), the first such match encountered during the walk wins and is returned immediately. - Otherwise the loader keeps a fallback: the first matching description is tentatively chosen, and any later locale-less description replaces a locale-specific fallback (so a generic APP.md wins over a mismatched localized one). All other cases keep the earliest fallback.
- If any APP.md's
- No match → exploration proceeds without a description (no auto-obstacles, no tab hints, no archetype override).
APP.md and permissions.json are complementary. permissions.json governs what the MCP server may do at all (which tools are exposed, which apps may be touched); APP.md describes what a specific app looks like. They overlap in exactly two places today.
| Concern | APP.md | permissions.json |
|---|---|---|
| Global MCP tool allow/deny (tap, swipe, …) | — | ✅ allow / deny |
| Per-app tool allow/deny (during explore) | — | ✅ perApp |
| App-level kill-switch (no launch / no explore) | — | ✅ blockedApps |
| Element skip patterns (global) | — | ✅ skipElements |
| Element skip patterns (per-app) | ✅ ## Skip |
— |
| Auto-dismiss obstacle rules | ✅ ## Obstacles |
— |
| Force-quit before explore | ✅ reset_before_explore |
— |
| Archetype recipe override | ✅ archetype |
— |
| Edge-control layout hints | ✅ ## Tab Layout |
— |
The three enforced bridge points:
-
Blocked-app guard — shared with
launch_app.generate_skill(action:"explore", app_name:"X")callspolicy.checkAppLaunch(appName)before doing anything with the target. Ifpermissions.json.blockedAppscontains the name, the call returns the same errorlaunch_appwould return. Any APP.md for a blocked app is loaded but never used because exploration refuses to start.- Scope note:
generate_skill(action:"start")does not call this guard today. Onlyexplore.
- Scope note:
-
Per-app tool gate at explore start.
permissions.json.perAppcan scope tool allow/deny to a specific app. Before exploration begins,policy.toolsDenied(for:requiredTools:)checks that the minimum explorer tools (tap,swipe,type_text,press_key) are permitted for the app. If any are denied, the call refuses with a message naming the specific denied tools.{ "allow": ["*"], "perApp": { "Banking": { "deny": ["type_text", "open_url"] } } }Precedence: per-app
denyalways wins, even over globalallow. Per-appallowcan open a tool that global rules would close. -
Skip-list merge with dedup. At explore start,
GenerateSkillHandlers.mergeSkipPatternsflattens three layers into oneExplorationBudget.skipPatterns:- Per-app (APP.md
## Skip) — prepended first, so its casing wins on duplicates. - Global (
permissions.json.skipElements). - Built-ins (
ExplorationBudget.builtInSkipPatterns).
Case-insensitive duplicates are collapsed; the first occurrence keeps its casing. Source counts are emitted to the debug log at session start.
- Per-app (APP.md
Outside those three points, the systems are disjoint. AppDescription.credentials has no counterpart in permissions.json; its only consumer is SkillMdGenerator, which surfaces the declared key names (never values) in the generated skill (see the Credentials section).
| APP.md field | Consumer |
|---|---|
app, locale |
AppDescriptionLoader for matching |
archetype |
GenerateSkillHandlers.handleExplore — looks up recipe by name, sets on session |
reset_before_explore |
GenerateSkillHandlers.handleExplore — force-quit via App Switcher |
obstacle_mode, obstacles |
BFSExplorerExploring — passed to ExplorerUtilities.dismissAlertIfPresent |
skipElements |
GenerateSkillHandlers.handleExplore — merged into ExplorationBudget.skipPatterns |
tabs |
TabTargetInjector.inject/findTargets — injected as high-priority breadth-navigation targets (called from BFSExplorerHelpers) |
tabLayout |
TabTargetInjector.findTargets — picks which edge to sample and which axis the bar runs along for the geometric synthesis fallback |
context |
SkillMdGenerator — written verbatim into generated skill's "App Context" section |
credentials |
SkillMdGenerator — declared key names only rendered as a ## Required Credentials section in the generated skill. Resolved env-var values never leak into the skill file. |
hints |
SkillMdGenerator — rendered as a ## Tips section in the generated skill (parsed from ## Tips; intentionally excluded from context to avoid duplicate rendering). |
simulator |
Parsed-only on the mirroir-mcp side: AppDescriptionLoader carries it on AppDescription, but no runtime consumer reads it. FakeMirroring re-parses the same APP.md independently (AppPackLoader.makePack) to build its AppPack; the two never exchange the value. nil for guidance-only APP.md files not yet ported to the simulator. |
See ../mirroir-skills/patterns/apps/ for live examples (Santé, Settings, Weather, TikTok, Instagram). A minimal example showing a vertical edge-control rail (Tapo's camera controls):
---
app: Tapo
archetype: dashboard
---
## Structure
Landscape camera-viewer app. The live feed fills the window; a vertical
column of 5 controls hugs the right edge (camera snapshot, video record,
pan joystick, mic, phone handoff).
## Tabs
- Camera
- Video
- Joystick
- Mic
- Phone
## Tab Layout
- orientation: vertical
- edge: right
## Obstacles
- Permission dialog → tap "Allow"
## Skip
- Delete deviceWhat the code does with this file:
- Text matching runs first. If any OCR element's text matches a tab name (e.g. "Camera") it becomes a breadth-navigation target.
- For names that didn't match text,
TabTargetInjector.findTargetssamples the right-edge 12% band (the declarededge: right). When that band holds at leastEnvConfig.tabSynthesisMinZoneEvidenceelements,synthesizeAnchorscomputes 5 evenly-spaced anchors down the rail — anchor i sits aty = (i + 0.5) × height / 5, with the shared rail X read from the median of the band's detected icon points. The anchors come from the declared tab count, not from sorting individual detections, so noisy 3-of-5 icon detection does not mis-assign tabs. - The
Permission dialog → tap "Allow"rule auto-dismisses the iOS privacy prompt on first launch viaExplorerUtilities.dismissAlertIfPresent.
These are observations about the current code, not commitments to change it.
AppDescription.localeis used at load time for matching the system locale but is not inspected during exploration.