Claude Code · iOS toolkit

An iOS simulator debugging toolkit

A self-contained pack of read-only Claude Code tooling for iOS work on the simulator: two subagents, a /diagnose-ui-bug command, its mitmproxy capture runner + addons, and a one-time setup doc. Everything is anonymized — swap the MyApp placeholders and it's yours.

8 files read-only macOS · iOS Simulator mitmproxy + os_log

Before you use it

Swap the placeholders — MyApp → your app's PRODUCT_NAME, com.example.myapp.* → your bundle IDs, myapp-dev / myapp-staging → your schemes, and the api.*.example.com / login.idp.example.com hosts → your backends. The /events/me and /events endpoints in the addons are meant to be edited to the screen you're pinning down. Then mirror the path shown on each block under your project's .claude/. The runner needs mitmproxy (brew install --cask mitmproxy).
Subagents read-only agents you dispatch · 2
ios-log-analyzer
log triage

Capture and analyze runtime logs from a booted simulator — crashes, auth/network failures, deep-link issues, or correlating log output with a UI symptom. Reports the exact log lines, error clusters, and a ranked root cause with file:line to inspect.

BashReadGrepGlob
.claude/agents/ios-log-analyzer.md
---
name: ios-log-analyzer
description: Capture and analyze an iOS app's runtime logs from a booted simulator. Use when diagnosing a crash, auth/network failure, deep-link issue, or when correlating log output with a UI symptom. Reports the relevant log lines, error clusters, and a ranked root cause — read-only, never edits code.
tools: Bash, Read, Grep, Glob
---

You analyze runtime logs for an iOS app (hybrid SwiftUI + UIKit, Combine/MVVM + Coordinator).

> Replace the `MyApp` placeholder below with your app's `PRODUCT_NAME`, and the `com.example.myapp.*` bundle IDs with yours.

## Logging facts for this app
- Logs are emitted with `os_log(… log: .default …)` — **no custom subsystem/category**. Filter by **process**, not subsystem. The process name equals the build's `PRODUCT_NAME`, which varies by config: **Staging → "MyApp Staging"** (bundle `com.example.myapp.adhoc`, the project default — logged-in build hitting the pre-prod backend), Dev → "MyApp Dev" (`com.example.myapp.dev`), Debug → "MyApp Debug", Release → "MyApp". The predicates below use `process CONTAINS "MyApp"` so they match whichever build is actually running — a stale build lingering on the sim won't match an exact `== "MyApp Staging"`.
- Messages are tagged inline in brackets; `[DeepLink]` is the only tag in use today. Auth/network failures currently surface as Alamofire validation errors and `ConnectionError` (`Network/Connection.swift`), not tagged `os_log`.
- ~26 `print()` call sites exist — these do **not** appear in the unified log, only the Xcode console. Call this out if you suspect a `print`-only path.

## How to capture
```bash
# Live stream — run while the symptom reproduces
xcrun simctl spawn booted log stream --level debug --style compact \
  --predicate 'process CONTAINS "MyApp"'

# Retrospective dump — no repro needed
xcrun simctl spawn booted log show --last 10m --style syslog \
  --predicate 'process CONTAINS "MyApp"'

# Narrow to a tag / keyword
#   ... --predicate 'process CONTAINS "MyApp" AND eventMessage CONTAINS[c] "[DeepLink]"'
```
If XcodeBuildMCP log tools are connected, prefer them (`start_sim_log_cap` / `stop_sim_log_cap`) for capture tied to a build session.

## What to report
1. The **exact** log lines that matter (timestamp, process, message), quoted verbatim.
2. Error clusters: HTTP status codes, `ConnectionError`, decode failures (watch the `.0000000Z` vs `.SSS` date bug), auth (401 / OIDC / the known guest-token-overrides-OIDC class of bug), deep-link routing.
3. A ranked list of **likely root causes**, each with the `file:line` to inspect (e.g. `Network/Webservice.swift`, `AuthenticationHandler`, a specific ViewModel).
4. If logs are silent, say the path is probably `print()`-only or off the `.default` log, and suggest where an `os_log` would help.

Never assert a cause you did not see evidence for in the captured output.
ui-verifier
visual check

Build, launch, and visually verify the app on a simulator after a change — confirm a UI change renders as intended, or reproduce a UI bug. Captures a screenshot + accessibility tree; every PASS/FAIL is backed by evidence, never assumption.

inherits all tools
.claude/agents/ui-verifier.md
---
name: ui-verifier
description: Build, launch, and visually verify an iOS app on a simulator after a change. Use to confirm a UI change renders as intended, or to reproduce a UI bug — it boots the sim, installs the Dev build, drives to the screen, captures a screenshot + accessibility tree, and reports whether the expected UI is present. Confirms with evidence, never assumption.
---

You verify UI behavior for an iOS app on the iOS Simulator. Every claim is backed by a screenshot + accessibility snapshot, never by assumption.

> Replace the `MyApp` / `myapp` placeholders below with your app's `PRODUCT_NAME`, Xcode project/scheme names, and bundle id.

## Environment
- Build: `-project myapp.xcodeproj -scheme myapp-dev -configuration Dev` (there is **no** `.xcworkspace`).
- Default simulator: **iPhone 17 Pro / iOS 26.3**. App & process name **"MyApp Dev"**, bundle id `com.example.myapp.dev`.
- Stack is hybrid **SwiftUI + UIKit** with Coordinator navigation — some screens are SwiftUI, some are `UIHostingController`-wrapped or pure UIKit. Inspect the **accessibility tree**, not just the pixels.

## Procedure (prefer XcodeBuildMCP tools)
1. Boot the target simulator; confirm it is booted.
2. Build + install + launch the Dev scheme (XcodeBuildMCP `build_run_sim`, or `xcodebuild` then `simctl install` / `simctl launch`).
3. Navigate to the screen under test (describe each tap; use UI-automation tools if available).
4. Capture **both** a screenshot **and** the accessibility/description snapshot.
5. Compare against the expected state given in the task.

## What to report
- PASS/FAIL for each expected element, each backed by what you observed in the screenshot or a11y tree.
- For a FAIL: the discrepancy and the likely layer — SwiftUI view, ViewModel `@Published` not delivered on main, Coordinator wiring (`setCoordinator()` / `weak var viewController`), `Pager<T>` re-entrancy, or a UIKit `UIViewController`.
- Reference the screenshot path so it can be re-opened.

If the build fails, report the compiler error and stop — do not guess at UI you could not render.
Command the entry point that ties it together · 1
/diagnose-ui-bug
slash command

Correlates three evidence streams — captured network flows, os_log output, and a screenshot + a11y snapshot — on one timeline to answer the only question that matters: did the data arrive, and if so, why didn't the UI show it? Splits the cause into H1 (network) / H2 (ViewModel never set @Published) / H3 (set, but the view didn't render).

.claude/commands/diagnose-ui-bug.md
---
description: Diagnose a MyApp iOS UI bug by correlating captured network traffic (mitmproxy captures/flows.jsonl), os_log output, and a screenshot + accessibility snapshot. Adapted from our Android network-inspect flow.
argument-hint: <short description of the UI symptom>
---

Diagnose this UI symptom in the MyApp iOS app: **$ARGUMENTS**

One question drives everything: **did the data arrive, and if so, why didn't the UI show it?**
Do **not** propose a fix until the three evidence streams line up on a timeline.

## 1. Network — capture flows (mitmproxy, local mode)
No SSL pinning / no mTLS / ATS open, so the CA alone suffices (one-time setup in `.claude/docs/proxy-and-logs.md`).
```bash
.claude/scripts/mitm-run.sh sleep 120     # reproduce the symptom during these 120s
```
Writes `captures/flows.jsonl` + `captures/assertions.json` (addons: `.claude/mitm/`). Then:
```bash
jq -c '{t: .t_resp_iso, method, path, status, ms: .duration_ms}' captures/flows.jsonl
jq 'select(.path|endswith("/events/me")) | {t_resp_iso, status, resp_body}' captures/flows.jsonl
cat captures/assertions.json     # no_duplicates:false ⇒ double-fetch (Pager re-entrancy)
```
Watch for: non-2xx; 401 (the known guest-token-overrides-OIDC bug); empty/oversized bodies; the `.0000000Z` vs `.SSS` date-decode bug; missing pagination params. If `mitmproxy-mcp` is connected, query it instead of grepping. Filter to the env hosts in CLAUDE.md → Debugging.

## 2. Logs — os_log
Dispatch the `ios-log-analyzer` subagent, or:
```bash
xcrun simctl spawn booted log show --last 10m --style syslog --predicate 'process CONTAINS "MyApp"'
```
`.default` log only, filter by process (`CONTAINS "MyApp"` matches whichever build runs — Dev/Debug/Staging), `print()` won't appear. os_log is sparse here — if the ViewModel path is silent, that itself is a signal (add a temporary `os_log` in the `@Published` setter to timestamp the emit).

## 3. UI — render state
Dispatch the `ui-verifier` subagent for a screenshot + accessibility tree of the broken screen.

## 4. Correlate: line up `t_resp_iso` (flows) with the os_log emit and the a11y render
Three outcomes (the H1/H2/H3 method from our Android `network-inspect`, mapped to iOS):

- **H1 — network**: no matching flow, non-200, or empty `resp_body`. Data never arrived → fix the API/request, not the UI.
- **H2 — ViewModel didn't set `@Published`**: flow shows 200 + real body at `t_resp`, but **no** `@Published` mutation / no os_log after `t_resp`. Decode/mapping broke (`Provider`/`Boxed`, date format, wrong branch) or the update was published off-main and lost.
- **H3 — set, but the View didn't render**: the emit happened after `t_resp`, yet the screen is empty. It's the observe side — missing `.receive(on: DispatchQueue.main)`, the view not observing the ViewModel, Coordinator wiring (`setCoordinator()` / `weak var viewController`), or a lost SwiftUI update on the SwiftUI↔UIKit bridge.

## 5. Force a flaky race deterministic (separates H2 from H3)
```bash
mitmdump -q --mode local -s .claude/mitm/log_flows.py -s .claude/mitm/modify.py --set delay_session=true &
```
Unscoped `--mode local` is safe here — `modify.py` only rewrites its specific endpoint constants, so it never touches unrelated apps. A 3s delay on `/events/me` makes "state set before the view subscribed" reproduce every run. Also: `--set empty_events=true` (empty-state path), `--set fail_session=true` (error handling). Edit endpoint constants in `.claude/mitm/modify.py` to the screen under test.

## Report
Evidence from each stream (quote the flow line + os_log line + a11y observation), the correlated root cause as `file:line`, and only then the fix.
Runner & mitmproxy addons network capture the command drives · 4
mitm-run.sh
runner

Starts mitmproxy in local mode, auto-scoped to the running app process so it captures only your app — never the Mac's global proxy, so there's nothing to leave dangling on kill -9. --reset clears orphans.

.claude/scripts/mitm-run.sh
#!/usr/bin/env bash
# mitmproxy runner — LOCAL MODE. Does NOT touch the Mac's global proxy (no kill -9 footgun).
# Pattern from Android (scoped proxy), ported to iOS: instead of 'adb ... http_proxy'
# we use 'mitmdump --mode local', because the iOS Simulator has no proxy knob of its own.
# Usage:   .claude/scripts/mitm-run.sh <command>     e.g.  .claude/scripts/mitm-run.sh sleep 120
#          .claude/scripts/mitm-run.sh --reset       # kill orphaned mitmdumps (after kill -9 / crash)
# Env:     MITM_PORT (8080)
#          MITM_TARGET — process name to scope to. Default AUTO: detects the running
#                        "MyApp *" process (Dev/Debug/Staging/Release) and captures only it.
#                        MITM_TARGET="Name" forces it; MITM_TARGET="" = ALL local traffic.
set -euo pipefail

# repo root from the script location → works from any CWD
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"; cd "$ROOT"
PORT="${MITM_PORT:-8080}"
PATTERN="mitmdump.*--mode local"

reset() { pkill -f "$PATTERN" 2>/dev/null && echo "[reset] killed mitmdump (local mode)" || echo "[reset] nothing to kill"; }
[ "${1:-}" = "--reset" ] && { reset; exit 0; }

command -v mitmdump >/dev/null || { echo "[ERR] mitmdump not found (brew install --cask mitmproxy)"; exit 1; }

# TARGET: by default auto-detect the running "MyApp *" process (clean capture — app only,
# without other Mac apps' traffic). Process name = build's PRODUCT_NAME, depends on config.
TARGET="${MITM_TARGET-__auto__}"
if [ "$TARGET" = "__auto__" ]; then
  TARGET="$(ps -Ao comm= 2>/dev/null | sed -E 's#.*/##' | grep -m1 -E '^MyApp' || true)"
  [ -n "$TARGET" ] && echo "[mitm] auto-scope → process \"$TARGET\"" \
                   || echo "[mitm] no running 'MyApp' process — capturing ALL local traffic (launch the app before capturing to scope it)"
fi

# local mode = no global state to restore; just kill mitmdump.
MITM_PID=""
cleanup() { [ -n "$MITM_PID" ] && kill "$MITM_PID" 2>/dev/null || true; }
trap cleanup EXIT INT TERM

MODE="local"; [ -n "$TARGET" ] && MODE="local:$TARGET"
echo "[mitm] --mode $MODE :$PORT  addons=.claude/mitm/*.py  → captures/flows.jsonl (+ captures/assertions.json)"
echo "       (first time, macOS asks to approve a system extension — accept it)"
mitmdump -q --mode "$MODE" -p "$PORT" \
  -s .claude/mitm/log_flows.py \
  -s .claude/mitm/assert_flows.py \
  -s .claude/mitm/modify.py \
  >"/tmp/mitmdump.$PORT.log" 2>&1 &
MITM_PID=$!
sleep 1
kill -0 "$MITM_PID" 2>/dev/null || { echo "[ERR] mitmdump failed to start — see /tmp/mitmdump.$PORT.log"; exit 1; }

echo "[run]  $*"
"$@"
# the trap kills mitmdump on error / Ctrl-C too. After kill -9:  mitm-run.sh --reset
log_flows.py
addon

Logs every request/response to captures/flows.jsonl with ISO timestamps — the correlation key against os_log.

.claude/mitm/log_flows.py
"""mitmproxy addon: log every request/response to flows.jsonl for agentic diagnosis.

Run:  mitmdump -s .claude/mitm/log_flows.py   (see .claude/scripts/mitm-run.sh)
Output: captures/flows.jsonl (one JSON object per line), relative to repo root.

The ISO timestamps (t_req_iso / t_resp_iso) are the correlation key: line them up
against os_log timestamps (`ios-log-analyzer`) to tell "data arrived" from
"data arrived but the UI never showed it".
"""
import json
import os
import time
from datetime import datetime

from mitmproxy import http

FLOWS_FILE = "captures/flows.jsonl"
MAX_BODY = 5000


def _iso(ts: float) -> str:
    return datetime.fromtimestamp(ts).isoformat(timespec="milliseconds")


def _body(message) -> str:
    if not message or not message.content:
        return ""
    return message.get_text(strict=False)[:MAX_BODY]


class LogFlows:
    def __init__(self):
        self.started = {}

    async def request(self, flow: http.HTTPFlow) -> None:
        self.started[flow.id] = time.time()

    async def response(self, flow: http.HTTPFlow) -> None:
        t_resp = time.time()
        t_req = self.started.pop(flow.id, t_resp)
        record = {
            "t_req": t_req,
            "t_resp": t_resp,
            "t_req_iso": _iso(t_req),
            "t_resp_iso": _iso(t_resp),
            "duration_ms": round((t_resp - t_req) * 1000, 1),
            "method": flow.request.method,
            "url": flow.request.pretty_url,
            "path": flow.request.path.split("?", 1)[0],
            "status": flow.response.status_code,
            "req_headers": dict(flow.request.headers),
            "resp_headers": dict(flow.response.headers),
            "req_body": _body(flow.request),
            "resp_body": _body(flow.response),
        }
        os.makedirs(os.path.dirname(FLOWS_FILE), exist_ok=True)
        with open(FLOWS_FILE, "a", encoding="utf-8") as fh:
            fh.write(json.dumps(record, ensure_ascii=False) + "\n")


addons = [LogFlows()]
assert_flows.py
addon

Tallies requests per path and flags double-fetches in captures/assertions.json (the classic Pager re-entrancy race).

.claude/mitm/assert_flows.py
"""mitmproxy addon: tally requests per path, write assertions.json on shutdown.

Run:  mitmdump -s .claude/mitm/assert_flows.py
Output: captures/assertions.json, relative to repo root.

no_duplicates == false means WATCH_ENDPOINT was fetched more than once during the
scenario — the classic double-fetch race (on iOS: `Pager.loadNextPage()` fired by
both scroll and `onAppear` before `isLoading` flips, or a view re-subscribing).
Match is by path suffix so it survives the env prefix (/api/v1/...).
"""
import json
import os
from collections import Counter

from mitmproxy import http

# The primary authenticated list fetch — edit to the endpoint you're pinning down.
WATCH_ENDPOINT = "/events/me"
ASSERT_FILE = "captures/assertions.json"


def _norm(path: str) -> str:
    return path.split("?", 1)[0].rstrip("/")


class AssertFlows:
    def __init__(self):
        self.counts = Counter()

    async def request(self, flow: http.HTTPFlow) -> None:
        self.counts[_norm(flow.request.path)] += 1

    def done(self):
        counts = dict(self.counts)
        watch = _norm(WATCH_ENDPOINT)
        watch_count = sum(c for p, c in self.counts.items() if p.endswith(watch))
        duplicated = sorted(p for p, c in self.counts.items() if c > 1)
        result = {
            "counts": counts,
            "watch_endpoint": WATCH_ENDPOINT,
            "watch_called": watch_count > 0,
            "watch_count": watch_count,
            "no_duplicates": watch_count <= 1,
            "duplicated_paths": duplicated,
        }
        os.makedirs(os.path.dirname(ASSERT_FILE), exist_ok=True)
        with open(ASSERT_FILE, "w", encoding="utf-8") as fh:
            json.dump(result, fh, indent=2, ensure_ascii=False)


addons = [AssertFlows()]
modify.py
addon

Fault injection via --set (delay / empty / fail) to turn a flaky race into a deterministic repro — cleanly separates H2 from H3.

.claude/mitm/modify.py
"""mitmproxy addon: inject faults to turn a flaky race into a deterministic repro.

Options (toggle with --set):
  mitmdump -s .claude/mitm/modify.py --set delay_session=true   # /events/me responds 3s late
  mitmdump -s .claude/mitm/modify.py --set empty_events=true    # /events returns []
  mitmdump -s .claude/mitm/modify.py --set fail_session=true    # /events/me returns HTTP 500

Why: a 3s delay makes "state emitted before the SwiftUI view subscribed" reproduce
every run — it cleanly separates H2 (`@Published` never set) from H3 (set, but the
view/`.receive(on:)`/Coordinator didn't render it). Endpoints match by path suffix
so they survive the env prefix (/api/v1/...). Edit the constants to your target.
"""
import asyncio

from mitmproxy import ctx, http

SESSION_ENDPOINT = "/events/me"   # primary authenticated fetch
LIST_ENDPOINT = "/events"         # a list endpoint (empty-state path)


def _matches(flow: http.HTTPFlow, endpoint: str) -> bool:
    path = flow.request.path.split("?", 1)[0].rstrip("/")
    return path.endswith(endpoint.rstrip("/"))


class Modify:
    def load(self, loader):
        loader.add_option("delay_session", bool, False,
                          f"Delay responses for {SESSION_ENDPOINT} by 3s (asyncio.sleep)")
        loader.add_option("empty_events", bool, False,
                          f"Return [] for {LIST_ENDPOINT}")
        loader.add_option("fail_session", bool, False,
                          f"Return HTTP 500 for {SESSION_ENDPOINT}")

    async def request(self, flow: http.HTTPFlow) -> None:
        if ctx.options.fail_session and _matches(flow, SESSION_ENDPOINT):
            flow.response = http.Response.make(
                500, b'{"error":"injected fail_session"}',
                {"Content-Type": "application/json"})
            return
        # /events/me ends with /events too — guard so empty_events only hits the list.
        if ctx.options.empty_events and _matches(flow, LIST_ENDPOINT) \
                and not _matches(flow, SESSION_ENDPOINT):
            flow.response = http.Response.make(
                200, b"[]", {"Content-Type": "application/json"})
            return
        if ctx.options.delay_session and _matches(flow, SESSION_ENDPOINT):
            await asyncio.sleep(3)


addons = [Modify()]
Setup doc one-time, read once · 1
proxy-and-logs.md
reference

Trust the mitmproxy CA on the simulator, capture in local mode, the build-identity & backend-host tables, and an os_log cheat-sheet.

.claude/docs/proxy-and-logs.md
# Debug proxy & log capture — one-time setup

Ported from our **Android** setup (`.claude/mitm/` addons + JSONL correlation),
adapted for iOS. The one real difference is routing: Android scopes the proxy to the
emulator via `adb shell settings put global http_proxy 10.0.2.2:8080`. The iOS
Simulator has **no equivalent proxy knob** — it uses the Mac's network stack — so we
use **`mitmdump --mode local`** (transparent, per-process) instead of mutating the
global macOS system proxy. No global state → nothing to leave dangling on `kill -9`.

## TL;DR — interception works with zero app-code changes
Alamofire's default shared session (`Network/Connection.swift`). Verified across the app
and `Packages/Api`: **no SSL pinning, no TrustKit / `NSPinnedDomains`, no mTLS / client
certs**, and ATS is fully open (`NSAllowsArbitraryLoads = true`). Once the proxy CA is
trusted on the simulator, all HTTPS is visible — no debug flag, no `#if DEBUG` bypass.

> **If pinning or mTLS is ever added later** (show a plan before touching app code):
> - **Pinning** — gate the evaluator behind `#if DEBUG`, or drop `NSPinnedDomains` / disable TrustKit in the Debug config only.
> - **mTLS** — `openssl pkcs12 -in client.p12 -out <host>.pem -nodes`, then `mitmdump --set client_certs=<dir>/` (file `<host>.pem` = key+cert).

## 1. One-time: trust the mitmproxy CA on the simulator
```bash
mitmdump   # run once → generates ~/.mitmproxy/mitmproxy-ca-cert.pem, then Ctrl-C
xcrun simctl boot "iPhone 17 Pro"
xcrun simctl keychain booted add-root-cert ~/.mitmproxy/mitmproxy-ca-cert.pem
xcrun simctl shutdown booted && xcrun simctl boot "iPhone 17 Pro"   # reboot so trust takes effect
```

## 2. Capture (local mode — the runner)
```bash
.claude/scripts/mitm-run.sh sleep 120     # capture for 120s while you reproduce
.claude/scripts/mitm-run.sh --reset       # after a kill -9 / crash: kill orphaned mitmdump
```
The runner **auto-scopes** to the running `MyApp *` process (so it logs only the app, not your
other Mac apps), starting `mitmdump --mode local:"<that process>"` on :8080 with all three addons and
writing `captures/flows.jsonl` (+ `captures/assertions.json`) under `captures/`. If no `MyApp *`
process is up when it starts, it falls back to capturing **all** local traffic. It touches **no** global
proxy, so its only teardown is killing mitmdump (in the trap); a `kill -9` leaves at most an orphaned
mitmdump that `--reset` clears.

- First run: macOS asks to approve a **system extension** (local redirect) — accept it once. (Already approved on this machine: `org.mitmproxy.macos-redirector.network-extension` is `[activated enabled]`.)
- Launch the app **before** the capture so auto-scope can find it; otherwise it runs unscoped and grabs everything.
- Force a name: `MITM_TARGET="MyApp Staging" .claude/scripts/mitm-run.sh sleep 120`. Force **all** local traffic: `MITM_TARGET="" .claude/scripts/mitm-run.sh sleep 120`, then filter by host in `captures/flows.jsonl`.
- **Fallback (system proxy)** if local mode won't cooperate: `networksetup -setsecurewebproxy Ethernet 127.0.0.1 8080 && networksetup -setwebproxy Ethernet 127.0.0.1 8080` — but this is global; reset with `networksetup -setsecurewebproxystate Ethernet off` (and it clobbers any saved proxy, e.g. Proxyman on :9090).

**Build identity** — the process name (used for scoping *and* the os_log predicate) equals the build's `PRODUCT_NAME`, which varies by config:

| Config | Process / os_log name | Bundle id |
|--------|-----------------------|-----------|
| **Staging** (`-scheme myapp-staging`, **project default**) | `MyApp Staging` | `com.example.myapp.adhoc` |
| Dev (`-scheme myapp-dev`) | `MyApp Dev` | `com.example.myapp.dev` |
| Debug | `MyApp Debug` | — |
| Release | `MyApp` | `com.example.myapp` |

Older builds may linger on the sim under legacy bundle ids (e.g. a stale `MyApp Debug` = `com.example.myapp.legacy`). Auto-scope and the `process CONTAINS "MyApp"` log predicate both match whichever build is actually running — don't hard-code a single name.

Backend hosts to filter for (see also CLAUDE.md → Debugging):

| Env | Primary API | Legacy API | OpenID |
|-----|-------------|-----------|--------|
| Dev | `api.dev.example.com` | `legacy.pre.example.com` | `login.idp.example.com` |
| Staging | `api.pre.example.com` | `legacy.pre.example.com` | `login.idp.example.com` |
| Release | `api.example.com` | `legacy.example.com` | `login.idp.example.com` |

## 3. Addons (`.claude/mitm/`, shared shape with Android)
- **`log_flows.py`** → `captures/flows.jsonl`: one object per response with `t_req_iso`/`t_resp_iso` (correlate with os_log), `duration_ms`, `path`, `status`, headers, bodies (capped 5000).
- **`assert_flows.py`** → `captures/assertions.json` on shutdown: per-path counts, `no_duplicates`, `duplicated_paths`. `no_duplicates:false` on `/events/me` = a double-fetch (`Pager.loadNextPage()` scroll+`onAppear`).
- **`modify.py`** fault injection (`--set`): `delay_session` (/events/me 3s late), `empty_events` (/events → `[]`), `fail_session` (/events/me → 500). Edit the endpoint constants to your target screen.

```bash
# read flows
jq -c '{t: .t_resp_iso, method, path, status, ms: .duration_ms}' captures/flows.jsonl
jq 'select(.path|endswith("/events/me")) | {t_resp_iso, status, resp_body}' captures/flows.jsonl
cat captures/assertions.json

# force a race deterministic (run mitmdump directly for --set)
mitmdump -q --mode 'local:MyApp Dev' -s .claude/mitm/log_flows.py -s .claude/mitm/modify.py --set delay_session=true &
```

## 4. Log capture cheat-sheet
```bash
xcrun simctl spawn booted log stream --level debug --style compact --predicate 'process CONTAINS "MyApp"'
xcrun simctl spawn booted log show   --last 10m    --style syslog  --predicate 'process CONTAINS "MyApp"'
```
`os_log(… log: .default …)` — no custom subsystem, filter by **process** (`CONTAINS "MyApp"` matches
Dev/Debug/Staging/Release — see the build-identity table); inline tags like `[DeepLink]`; `print()`
output is Xcode-console only. See the `ios-log-analyzer` subagent.