llms-full.txt@site · git:20260917.6d110b4 · 2026-09-17 · sha256 0f2915de486a0d88

llms-full.txt@site git:20260917.6d110b4B

Immutable. This exact content is served forever at /api/v1/blob/0f2915de486a0d88.

# iOS Agent Skill

Give your coding agent the Apple references, Swift source and local tools it needs to build and review an iOS app. Use it to turn an app idea into an editable starter, improve an existing Swift project, and check the result with Xcode and the simulator.

# Practical Swift & AI development guides
https://nagarjuna2997.github.io/ios-agent-skill/blog.html

The project notebookBuild with AI.

Check with evidence.Practical Swift and iOS workflows: focused reviews, simulator checks and honest setup advice.Explore all framework and feature guides →
Guided series →All
Swift reviews
Testing & Simulator
AI clients
SwiftUI
Design & assets
Agent workflowSwift reviewsReview AI-generated Swift before you trust itA focused review, a small patch and a real test beat a confident completion message.Read the guide →
Testing & SimulatorFive checks for an AI-built SwiftUI appUse persistence, search and accessible states to make “it works” a testable claim.Read the guide →
AI clientsChoose an AI client for your iOS workflowUnderstand the difference between a skill, a local MCP connection and ChatGPT web setup.Read the guide →
AI clientsClaude Code: turn a Swift finding into a tested patchA small review-to-test loop for an existing iOS project.Read the guide →
AI clientsChatGPT and Codex: plan the app, then verify it locallySeparate a browser planning session from a connected development environment.Read the guide →
AI clientsGemini CLI: connect once and review one iOS featureAvoid duplicate connections and keep setup evidence separate from app evidence.Read the guide →
AI clientsMuse Code: what our integration actually verifiesDiscovery and hooks are useful milestones, but they are not a finished app.Read the guide →
SwiftUISwiftUI: review state transitions before polishing the screenMake loading, empty, success and error states part of the implementation brief.Read the guide →
Design & assetsGenerate light and dark asset catalogs without a paid design toolTurn explicit color tokens into Assets.xcassets, then inspect the result in your app.Read the guide →
AI clientsWhich Muse Code MCP settings actually work with a local Swift review server?A reproducible Muse 1.3.0 connection check, with the exact configuration and clear limits on what discovery proves.Read the guide →
Agent workflowHow can Claude Code hooks protect generated files and verify a project before stopping?A tested generated-file guard and Stop check, with configuration, reproducible exit codes, and enforcement limits.Read the guide →
Swift reviewsHow can I catch Swift concurrency review findings before accepting an agent’s changes?A real before-and-after MCP review with file and line evidence, plus the limits of text-based concurrency checks.Read the guide →
Swift reviewsHow should I review availability guards when moving a Swift app toward iOS 27?A measured guard-review example that separates SDK availability, runtime readiness, and static-analysis limits.Read the guide →
Design & assetsHow can design tokens generate light, dark and high-contrast color assets?Generate a real xcassets catalog from explicit color tokens, inspect its four variants, and compile it with actool.Read the guide →

---

# How can design tokens generate light, dark and…
https://nagarjuna2997.github.io/ios-agent-skill/blog/asset-catalogs.html

← Back to all articlesDesign & assets · iOS Agent Skill projectHow can design tokens generate light, dark and high-contrast color assets?Generate a real xcassets catalog from explicit color tokens, inspect its four variants, and compile it with actool.On this pageWhat belongs in the token file?How do I generate the catalog?What should I inspect in Contents.json?Did Xcode accept the generated files?Do I need Figma or a paid design tool?LimitsLast verified
The workflow
01
What belongs in the token file?02
How do I generate the catalog?03
What should I inspect in Contents.json?04
Did Xcode accept the generated files?
Define each semantic color with explicit light, dark, high-contrast-light, and high-contrast-dark values, then generate a named color set from that JSON. In this walkthrough, the published CLI created a real asset catalog and Xcode's asset compiler successfully produced an 
Assets.car
 file.

What belongs in the token file?

A token name should express a role that the application can reuse. 
AccentColor
, 
Background
, and 
TextPrimary
 describe intent more clearly than names tied to a particular screen coordinate. This example uses only the required accent to keep the generated output small enough to inspect completely.

The generator accepts a versioned JSON format, not arbitrary design prose. Every color needs all four appearance values. That requirement avoids silently guessing a dark equivalent from a light color. It also makes omissions visible during generation instead of leaving the coding agent to fill them differently in several Swift files.

Here is the 
exact token input
 used for the run:

{
  "version": 1,
  "colors": {
    "AccentColor": {
      "light": "#2457DB",
      "dark": "#90B4FF",
      "highContrastLight": "#12358F",
      "highContrastDark": "#C7DAFF"
    }
  }
}

The repository documents sRGB hexadecimal values, including an optional alpha component. It also requires ASCII identifier names that are unique without regard to case. These are constraints of this generator's input contract, not a claim that every asset pipeline must use the same token format.

How do I generate the catalog?

With the published package, the command is:

npx -y ios-agent-mcp@2.7.0 assets \
  --tokens tokens.json \
  --output App/Assets.xcassets

The parent 
App
 directory must already exist. The destination catalog must not exist, because this command refuses to overwrite an existing catalog. Generate a sibling directory when evaluating a token change, inspect the differences, and merge the intended output into the project through its normal review process.

For the actual run I used an isolated installation of that exact npm version and called its entry point directly. This avoids an ambiguous 
latest
 dependency while recording evidence. The command reported two created catalog files: the catalog metadata and 
AccentColor.colorset/Contents.json
.

The resulting directory structure is small:

Assets.xcassets/
  Contents.json
  AccentColor.colorset/
    Contents.json

Apple's 
named-color format reference
 documents color-set metadata and color components. The generated 
Contents.json
 is available with this draft, rather than represented only by an illustration.

What should I inspect in Contents.json?

Check that the color set has four entries and that each entry corresponds to the intended appearance. The default entry supplies the ordinary light value. The remaining entries distinguish dark appearance, increased contrast, and their combination. Compare the normalized channel values with the input rather than assuming a successfully written file has the intended color.

Also check the semantic name. A Swift reference to a differently spelled asset will not become correct merely because the catalog itself compiles. Keep the code and token vocabulary aligned. This generator does not audit all 
Color
 calls or prove that the application actually uses the newly generated color.

For broader palettes, review foreground/background pairs together. Four present variants are a structural property. Readable contrast is a separate visual and numerical property that depends on the actual background, transparency, and surrounding interface.

Did Xcode accept the generated files?

Yes. I compiled the generated catalog with the installed simulator SDK:

mkdir -p compiled-assets
xcrun actool Assets.xcassets \
  --compile compiled-assets \
  --platform iphonesimulator \
  --minimum-deployment-target 17.0 \
  --target-device iphone \
  --output-format human-readable-text

The command returned exit code 0 and reported 
compiled-assets/Assets.car
. The 
recorded compiler output
 is included. This verifies that the asset compiler accepted the catalog under the recorded toolchain; it is stronger evidence than JSON parsing alone.

It is still not a screenshot test. The run did not launch an app, switch interface appearance, or inspect Dynamic Type. To establish the final user experience, integrate the catalog into the target, build, and inspect the relevant screens in all supported appearance states.

Do I need Figma or a paid design tool?

No. The input is ordinary JSON and can be authored in a text editor. If a designer already uses Figma, map resolved variables to the four explicit fields. Do not add a second tool solely to supply a small token file.

Icons are a separate concern. The repository can rasterize ordered SVG layers into a PNG, but a flattened PNG is not a native Icon Composer document. This color-only test generated no icon and makes no claim about Liquid Glass, required icon sizes, or App Store submission. Keep those deliverables and their verification records separate.

Limits

Successful generation does not certify accessibility, color contrast, semantic naming quality, or review acceptance. Existing catalogs are deliberately not overwritten. This run used one color and one simulator target; it did not exercise every possible alpha value, device family, or project integration. The cover image is conceptual artwork, not a rendering of these exact four swatches in an app.

Last verified

September 16, 2026. Published MCP package 2.7.0 with CLI 0.3.0; Node.js 24.15.0; Xcode 26.6 build 17F113. Asset generation and 
actool
 compilation passed. Repository source: 
docs/design/asset-generation.md
.

Example project: 
ios-agent-skill
.

---

# How should I review availability guards when moving a…
https://nagarjuna2997.github.io/ios-agent-skill/blog/availability-guards.html

← Back to all articlesSwift reviews · iOS Agent Skill projectHow should I review availability guards when moving a Swift app toward iOS 27?A measured guard-review example that separates SDK availability, runtime readiness, and static-analysis limits.On this pageWhich versions must stay separate?What did the test contain?What did the reviewer report?What happened after editing the fixtures?Which checks belong in the real review?LimitsLast verified
The workflow
01
Which versions must stay separate?02
What did the test contain?03
What did the reviewer report?04
What happened after editing the fixtures?
Guard a symbol at the OS version where it becomes available, then verify the older-system fallback and any separate runtime readiness requirements. This article ran the published availability reviewer against synthetic files, but did not compile or execute iOS 27 APIs because the installed toolchain is Xcode 26.6.

Which versions must stay separate?

A toolchain version, the SDK it contains, and an application's minimum deployment target answer different questions. The repository's compatibility matrix distinguishes them because a newly installed SDK should not automatically remove support for older devices. A guard must describe the API being used, not simply repeat the newest version number in the project documentation.

There are two useful review failures to look for. An unguarded newer API can prevent supporting an older deployment target. An unnecessarily restrictive guard can send supported devices down an older fallback path. The latter may be invisible if every local test runs on the newest OS.

Apple's 
Private Cloud Compute integration guide
 supplies a concrete iOS 27 example and discusses falling back on earlier systems. Treat that documentation as the API authority. The table below is instead a record of what this review tool detected.

What did the test contain?

I created four separate Swift source fixtures. 
Card.swift
 uses 
glassEffect()
 inside an iOS 27 guard. Three additional files contain bare symbol references for 
PrivateCloudComputeLanguageModel
, 
DynamicProfile
, and 
OCRTool
. The latter files are explicitly labeled lexical fixtures: they exercise the reviewer's matching rules and are not compiling examples of those APIs.

The distinction is especially important for nested types or APIs that require framework-specific setup. A token appearing in a source file is enough to test a lexical check, but not enough to teach correct API usage. I have kept the synthetic source downloadable so a reader can see exactly what was tested.

I called 
check_availability_guards
 through the published server's stdio MCP connection, passing the fixture directory. The 
before response
 reported three blocker findings and one serious finding across four files.

What did the reviewer report?

Fixture location

Matched item

Reported result

Cloud.swift:2

PrivateCloudComputeLanguageModel

missing iOS 27 guard

Profile.swift:2

DynamicProfile

missing iOS 27 guard

Tools.swift:2

OCRTool

missing iOS 27 guard

Card.swift:6

glassEffect()

iOS 27 guard stricter than the reviewer's iOS 26 entry

This table is a reproducible analyzer result, not a complete iOS 27 availability index. Before adopting any symbol, check its current Apple declaration, enclosing type, platform, and minor-version requirements. A short maintained pattern table cannot enumerate every API in an SDK.

For the card, the proposed change is deliberately small:

if #available(iOS 26.0, *) {
    Text("Reading").glassEffect()
} else {
    Text("Reading")
}

The corresponding 
Apple API reference
 remains the source for the modifier's contract. The fallback here preserves readable content; a production design may require more deliberate visual treatment.

What happened after editing the fixtures?

I changed the card guard from 27.0 to 26.0. For the lexical iOS 27 fixtures, I placed the references inside functions annotated with 
@available(iOS 27.0, *)
. These changes test the rule's response to the relevant annotation; they do not turn placeholder references into complete Foundation Models features.

The same tool then returned zero findings across the four files. The 
after response
 preserves that result. The before and after sources are included beside the JSON, allowing another reviewer to check that the experiment changed guards rather than removing the matched names entirely.

The clean result answers a narrow question: did the tool stop flagging these patterns? It does not answer whether the surrounding app compiles, whether every call site is guarded, or whether a selected model is available at runtime.

Which checks belong in the real review?

First identify the application's oldest supported OS. Then inspect the introduction version of each newly adopted symbol. Check the scope of the guard around the use, including helper methods and initializers; finding an unrelated guard elsewhere in a file is insufficient.

Next inspect the fallback as product behavior. Does the same action remain possible? Is disabled functionality explained? Does a view still have meaningful content? An empty branch can be syntactically acceptable while leaving an older device with a broken experience.

For model-backed features, separate API presence from model readiness. Apple's 
generation guide
 documents checking availability before starting a session. An OS-version check alone does not establish that the model can answer a request.

Limits

The reviewer uses source heuristics and can miss scope, target settings, and minor-version distinctions. It is not an SDK parser or a compiler. This draft demonstrates its current behavior, including that limitation, and does not certify iOS 27 compatibility. Runtime fallback testing on supported OS versions is still required. Never replace a compiler diagnostic with a more convenient zero-findings report.

Last verified

September 16, 2026, local time. Published 
ios-agent-mcp
 2.7.0; Node.js 24.15.0; host Xcode 26.6 build 17F113. No Xcode 27 build performed. Repository sources: 
docs/compatibility-matrix.md
 and 
docs/apple/ios-27-release-verification.md
.

Example project: 
ios-agent-skill
.

---

# ChatGPT and Codex: plan the app, then verify it locally
https://nagarjuna2997.github.io/ios-agent-skill/blog/chatgpt-codex-ios.html

← Back to all articlesAI clients · iOS Agent Skill projectChatGPT and Codex: plan the app, then verify it locallySeparate a browser planning session from a connected development environment.On this pageStart with behavior, not a screen description aloneChoose the correct connection pathMake the handoff explicitReview the completion messageDecision checkpoints
The workflow
01
App brief02
Acceptance criteria03
Local implementation04
Recorded evidence
Start with behavior, not a screen description alone
A useful app brief describes what the developer should be able to verify. For a reading list, specify adding a book, preserving it after a relaunch and returning the original list after clearing a search. Ask the agent to list unresolved decisions before generating a large implementation.
Plan a reading-list app with local persistence and search.
List screens, state transitions and acceptance criteria.
Identify which checks require a running simulator.
Do not claim any check has passed until it has run.
Choose the correct connection path
For a local Codex environment, the project documents this MCP setup:
codex mcp add ios-agent -- npx -y ios-agent-mcp@latest
ChatGPT web is a different environment. It does not run that command on your Mac. Follow the 
ChatGPT guide
 for supported skills or a separately configured HTTPS/private-tunnel connection. Availability depends on account and workspace policy; this project does not provide a public hosted Mac.
Make the handoff explicit
Carry the brief and acceptance criteria into the local project. Ask the connected agent to inspect existing code, retrieve relevant references and propose one implementation slice. Keep the actual app source and test results in the local development workflow. A plan written in a browser is not a simulator result.
Review the completion message
Require the changed files, commands run, outcomes and remaining gaps. Compilation is useful but does not prove persistence or accessible states. Use the simulator for visual evidence and tests for behavior where practical. Do not infer better token efficiency merely because references are retrieved in smaller sections.
Official Codex MCP documentation
 · 
Project setup instructions
Decision checkpoints
Situation
Action or status
Evidence or boundaryPlanning
Screens and acceptance criteria
A plan, not a running appImplementation
Connected local environment
Inspectable source changesVerification
Build, tests and simulator
Recorded evidence

---

# Choose an AI client for your iOS workflow
https://nagarjuna2997.github.io/ios-agent-skill/blog/choose-ai-client.html

← Back to all articlesAI clients · iOS Agent Skill projectChoose an AI client for your iOS workflowUnderstand the difference between a skill, a local MCP connection and ChatGPT web setup.On this pageA skill and a tool connection do different jobsClaude and Codex on your MacGemini CLI and MuseChatGPT in a browserUse the same first taskDecision checkpoints
The workflow
01
A skill and a tool connection do different jobs02
Claude and Codex on your Mac03
Gemini CLI and Muse04
ChatGPT in a browser
A skill and a tool connection do different jobs
A skill supplies instructions and references to a coding agent. An MCP connection exposes callable tools. Installing source guidance alone does not give a client permission or an executable path to build your app in Xcode.
iOS Agent Skill focuses on Claude, ChatGPT/Codex, Gemini CLI and Muse. Choose the client you already use and start with one small review before enabling build or simulator actions.
Claude and Codex on your Mac
Local coding clients can connect to the npm server. Open your app folder, follow the appropriate setup command and verify tool discovery. Claude Desktop uses a different configuration path from Claude Code; do not paste one client’s configuration into another.
Claude setup
 · 
Codex setup
Gemini CLI and Muse
The repository includes setup instructions for both. The Gemini record verifies extension validation and a stdio connection. Muse’s record verifies tool discovery and a Stop hook. These are bounded checks, not evidence that every model-driven app-building task succeeds.
Gemini CLI setup
 · 
Muse setup
ChatGPT in a browser
A browser conversation cannot use a local stdio command as if it were a terminal client. The guide separates portable skills from an HTTPS or private-tunnel MCP connection. Supported options depend on your account and workspace policy. Do not expose a local development server publicly just to make a connection work.
Read the ChatGPT connection options
Use the same first task
List the available iOS Agent tools.
Review one Swift file and show the finding locations.
Explain which checks ran and which remain unverified.
The package requires Node.js 20 or later. Xcode build, test and simulator operations require macOS and Xcode. The project is MIT-licensed, but your selected AI service may have its own costs.
When comparing clients, keep the project, prompt and acceptance criteria constant. A successful connection is a setup result; it is not a benchmark or a guarantee of a finished app.
Read the recorded client checks
Decision checkpoints
Situation
Action or status
Evidence or boundaryLocal coding client
Local stdio MCP server
Node.js; Xcode for simulator workBrowser conversation
Supported remote connection or portable guidance
Account policy and secure hostingConnection test
Tool discovery
Does not establish model task success

---

# How can Claude Code hooks protect generated files and…
https://nagarjuna2997.github.io/ios-agent-skill/blog/claude-hooks.html

← Back to all articlesAgent workflow · iOS Agent Skill projectHow can Claude Code hooks protect generated files and verify a project before stopping?A tested generated-file guard and Stop check, with configuration, reproducible exit codes, and enforcement limits.On this pageWhich problem does each hook solve?How are the three events connected?What did the worked example return?What does the Stop check establish?What can these hooks miss?LimitsLast verified
The workflow
01
Which problem does each hook solve?02
How are the three events connected?03
What did the worked example return?04
What does the Stop check establish?
Use a PreToolUse hook to reject edits to generated files, a PostToolUse hook to regenerate outputs after source changes, and a Stop hook to run deterministic checks. In this example, direct script tests blocked an instruction mirror, allowed its source, and passed repository consistency checks; a complete Claude-driven lifecycle session was not rerun.

Which problem does each hook solve?

A generated file can look like the easiest place to make an edit. The change appears to work until the generator runs again and removes it. A prompt asking the coding agent to remember the source of truth helps, but a script can detect the specific mistake earlier and return a useful explanation.

The example repository keeps its source instructions in 
SKILL.md
. Its supported client instruction files are generated mirrors. The guard protects those mirror paths and tells the agent to edit the source. This is a repository-maintenance example used in an iOS tooling project, not a claim that every iOS app should generate the same files.

The second hook synchronizes mirrors after the source changes. The final hook checks that the repository is internally consistent before the turn ends. Each check has a small, inspectable responsibility. None is a replacement for compiling an app or exercising a simulator.

Anthropic's 
hook reference
 documents event configuration and hook inputs. Keep the client reference close when adapting a hook: input fields and event behavior should come from the client, while your file-protection policy should come from your project.

How are the three events connected?

The repository configures these command handlers:

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Edit|Write|MultiEdit",
      "hooks": [{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/scripts/hooks/guard-generated-files.sh", "timeout": 10}]
    }],
    "PostToolUse": [{
      "matcher": "Edit|Write|MultiEdit",
      "hooks": [{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/scripts/hooks/sync-mirrors-on-edit.sh", "timeout": 30}]
    }],
    "Stop": [{
      "hooks": [{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/scripts/hooks/verify-repo.sh", "timeout": 60}]
    }]
  }
}

The quoted project directory matters when a checkout path contains spaces. The command scripts must exist and be executable. Inspect them before enabling the configuration, and merge the hooks with existing settings instead of overwriting permissions or other handlers.

Do not copy these paths into an unrelated app and expect them to work. An app might protect generated API models or an Xcode project derived from a specification. Its generator and verification command will differ. Start by naming the actual source file, generated outputs, and command that relates them.

What did the worked example return?

I sent the guard synthetic Edit input for 
AGENTS.md
, using the checkout's absolute path. The process returned exit code 2 and a message explaining that the file is generated from 
SKILL.md
. It named the source file and the synchronization command. No edit was performed; this test exercises the decision script directly.

I repeated the input with 
SKILL.md
. The guard returned exit code 0 with no error. That pair tests both sides of the policy: a protected target is rejected, while a permitted target can proceed. Testing only the rejection could hide a guard that blocks all development.

Then I ran the configured verification script directly:

bash scripts/hooks/verify-repo.sh

It returned exit code 0. The 
recorded inputs and results
 preserve these checks and a successful direct PostToolUse synchronization run. Read the 
guard script
, 
synchronization handler
, and 
verification script
 before enabling them; they depend on a complete source checkout. The guard message, rather than a screenshot of a green badge, is the useful evidence because it shows the corrective instruction that would be returned.

What does the Stop check establish?

The repository documentation describes mirror synchronization, instruction frontmatter, referenced documentation paths, and subagent frontmatter as consistency checks. A successful run means those checks passed for this checkout at this moment. It does not mean Swift tests ran, an app launched, or every reference page is technically correct.

For an application project, select checks that match the work. A resource-only edit might need asset compilation and a build. A persistence change needs behavior tests. Avoid describing a successful documentation script as an app acceptance result merely because both run at the same lifecycle event.

The most useful failure output identifies the check, the affected path, and the repair command. A bare failure code forces the agent to rediscover the problem. Conversely, a message claiming a test ran when the script only checked metadata creates false confidence.

What can these hooks miss?

The configured matcher covers named editing tools. It is not a filesystem security boundary. A different write path, including a shell command, needs separate consideration. Path normalization, symlinks, and platform separators also deserve tests before extending this pattern across operating systems.

A PostToolUse action occurs after a tool has run, so it cannot retroactively prevent that write. Keep generated changes reviewable and retain CI checks even when hooks work locally. Client-side feedback and remote verification serve different points in the workflow.

Limits

This article verified standalone script behavior, not the current client's complete hook dispatch. The PostToolUse script also completed successfully when invoked directly with synthetic source-edit input; it was not exercised in a model session. The example protects instruction mirrors, not arbitrary Xcode outputs. Existing documentation contains broader descriptions of generated mirrors; use the tested paths and current script when deciding what is actually protected.

Last verified

September 16, 2026. Installed Claude Code 2.1.273; direct Bash/Python hook tests on macOS; repository verification exit code 0. Repository source: 
docs/orchestration/hooks.md
; the downloadable record states the narrower tested scope.

Example project: 
ios-agent-skill
.

---

# Claude Code: turn a Swift finding into a tested patch
https://nagarjuna2997.github.io/ios-agent-skill/blog/claude-swift-review.html

← Back to all articlesAI clients · iOS Agent Skill projectClaude Code: turn a Swift finding into a tested patchA small review-to-test loop for an existing iOS project.On this pageKeep the first task deliberately smallConnect the local toolsAsk for evidence before a rewriteStop at a meaningful checkpointDecision checkpoints
The workflow
01
Connect MCP02
Review one file03
Inspect patch04
Build + test
Keep the first task deliberately small
When an agent reviews an entire app at once, it can be difficult to tell which suggestions are worth acting on. Start with the Swift file you just changed. Define the behavior it must preserve, then ask for one focused review before allowing edits.
Connect the local tools
claude mcp add ios-agent -- npx -y ios-agent-mcp@latest
Run the command in your project context, reconnect Claude Code and confirm the server is available. The local server needs Node.js 20 or later. Xcode operations additionally need a Mac with Xcode. Claude Desktop has a different configuration path; use the 
client setup guide
.
Ask for evidence before a rewrite
Review the concurrency in the Swift file I changed.
Show each finding with its location and supporting context.
Propose one minimal patch. Preserve the public behavior.
After approval, build and run the relevant tests.
Report failures and checks you could not run.
Read the suggested diff. A heuristic finding about actor isolation is a reason to inspect the surrounding code, not a reason to annotate every type. Keep unrelated refactors out of the patch so a failing test has a smaller set of possible causes.
Stop at a meaningful checkpoint
Save the changed files, commands used and remaining failures. If the build cannot run because Xcode or the scheme is unavailable, record that constraint instead of claiming success. The repository's Claude record demonstrates a bounded text-repair test; it does not establish complete app generation.
Official Claude MCP documentation
 · 
Project verification record
Decision checkpoints
Situation
Action or status
Evidence or boundaryBefore editing
Read the finding in context
Location and reasonAfter patching
Review the diff
Only intended changesBefore completion
Build and test
Commands, outcomes, remaining gaps

---

# Gemini CLI: connect once and review one iOS feature
https://nagarjuna2997.github.io/ios-agent-skill/blog/gemini-cli-ios.html

← Back to all articlesAI clients · iOS Agent Skill projectGemini CLI: connect once and review one iOS featureAvoid duplicate connections and keep setup evidence separate from app evidence.On this pageUse one installation pathRetrieve only what the feature needsDiagnose setup separatelyKnow what the project testedDecision checkpoints
The workflow
01
Project folder02
One connection03
Focused references04
Swift review
Use one installation path
The project supports a local MCP connection and also has extension guidance. Start with one path rather than installing both and assuming every duplicate tool is necessary. From your app folder, the documented direct connection is:
gemini mcp add ios-agent -- npx -y ios-agent-mcp@latest
Restart or reconnect Gemini CLI and confirm that the server appears. This workflow is for Gemini CLI, not Gemini web chat. The server requires Node.js 20 or later, and simulator tools require macOS and Xcode.
Retrieve only what the feature needs
For a persistence change, ask for the relevant local source or guide sections before reviewing the implementation. Loading an entire documentation collection can obscure the specific behavior you are trying to verify. Focused retrieval is an organization technique, not a measured cost-saving promise.
Inspect this app's persistence change.
Find relevant local references and identify assumptions.
Review only the affected Swift files.
Propose a minimal fix and a test for relaunch persistence.
Diagnose setup separately
If no tools appear, first check the configuration, command path and package availability. A failed connection is not a Swift compiler failure. If tools appear but a build fails, inspect the project, scheme and Xcode diagnostic instead of reinstalling the client repeatedly.
Know what the project tested
The Gemini CLI 0.49.0 record verifies extension validation and a stdio connection to the published package. It explicitly leaves the model session unverified. Reproduce your own task, record its results and report only what happened in that run.
Official Gemini CLI MCP documentation
 · 
Verification record
Decision checkpoints
Situation
Action or status
Evidence or boundaryTools absent
Check configuration and executable
Connection problemBuild fails
Inspect scheme and Xcode diagnostic
Project problemWrong behavior
Reproduce acceptance criterion
Implementation problem

---

# Generate light and dark asset catalogs without a paid…
https://nagarjuna2997.github.io/ios-agent-skill/blog/generate-asset-catalog.html

← Back to all articlesDesign & assets · iOS Agent Skill projectGenerate light and dark asset catalogs without a paid design toolTurn explicit color tokens into Assets.xcassets, then inspect the result in your app.On this pageMake colors explicitGenerate a new catalogChoose every appearance deliberatelyKeep icon layers editableInspect in contextDecision checkpoints
The workflow
01
Color tokens02
Four appearances03
Asset catalog04
App verification
Make colors explicit
Asset generation is a shipped workflow in ios-agent-mcp 2.7.0. It converts a strict JSON token file into a new asset catalog locally. It does not require Figma, an AI model or an API key. Start with semantic names rather than scattering literal colors through SwiftUI views.
{
  "version": 1,
  "colors": {
    "AccentColor": {
      "light": "#2457DB",
      "dark": "#90B4FF",
      "highContrastLight": "#12358F",
      "highContrastDark": "#C7DAFF"
    }
  }
}
Generate a new catalog
npx -y ios-agent-mcp@latest assets --tokens tokens.json --output App/Assets.xcassets
The parent directory must exist. The generator refuses to overwrite an existing catalog. Generate a sibling catalog when reviewing changes, compare the output and merge only the intended assets.
Choose every appearance deliberately
All four color appearances are required; the generator does not invent a dark-mode conversion. Names must be ASCII identifiers and unique ignoring case, with AccentColor present. Values use the documented sRGB hex format. Check contrast against the actual background rather than assuming a high-contrast token name guarantees readability.
Keep icon layers editable
New app scaffolds can retain editable SVG layers and render their composition as an opaque 1024-pixel PNG. Customize placeholder shapes before distributing an app. A flattened PNG is not a native Liquid Glass Icon Composer bundle, and generating it does not certify App Review acceptance.
Inspect in context
Use the named assets in the app, build it and inspect light, dark and higher-contrast appearances. The generator handles catalog structure; you still verify the visual result and accessible usage. Keep these checks in your acceptance criteria so a later design change does not silently undo them.
Full token schema and icon workflow
Decision checkpoints
Situation
Action or status
Evidence or boundaryLight and dark
Explicit token values
Inspect both appearancesHigh contrast
Two additional explicit values
Measure against real backgroundsExisting catalog
Generate a sibling output
Review and merge intentionally

---

# Which Muse Code MCP settings actually work with a local…
https://nagarjuna2997.github.io/ios-agent-skill/blog/muse-connection.html

← Back to all articlesAI clients · iOS Agent Skill projectWhich Muse Code MCP settings actually work with a local Swift review server?A reproducible Muse 1.3.0 connection check, with the exact configuration and clear limits on what discovery proves.On this pageWhat was actually tested?Which configuration should I start with?How can I repeat the example?What should I check when discovery fails?LimitsLast verified
The workflow
01
What was actually tested?02
Which configuration should I start with?03
How can I repeat the example?04
What should I check when discovery fails?
Muse Code 1.3.0 connected to the published Swift review server using 
schema_version: 1
 and the camel-case 
mcpServers
 configuration key. The recorded check discovered 36 tools and executed a Stop hook, but did not verify a model choosing tools or building an app.

What was actually tested?

This walkthrough is deliberately a connection test. The local 
echo
 provider exercises initialization without making a model request. That distinction matters: a successful tool list establishes that two programs can communicate, while a completed development task requires evidence from the model, the tool, and the resulting project.

The repository's installation guide and its existing client record describe this boundary. I repeated the discovery harness against an isolated installation of the published 
ios-agent-mcp@2.7.0
 package. The result again contained 36 tool names. Representative entries included 
review_swift_concurrency
, 
search_local_references
, 
create_app
, and 
simulator_list
. A command-based Stop hook also left the expected marker.

The record is available as 
the complete verification JSON
. It identifies the client build, server package, discovered catalog, and the checks that remain false. It contains no account tokens, personal project content, or generated-app claims.

Which configuration should I start with?

For a local installation, this is the configuration shape documented by the project:

{
  "schema_version": 1,
  "mcpServers": {
    "ios-agent": {
      "command": "/absolute/path/to/ios-agent-mcp",
      "args": []
    }
  }
}

Merge that server entry into your existing settings rather than replacing the file. The absolute executable path is a placeholder: obtain the real location from your installation. The harness used an absolute Node executable and the server's JavaScript entry point, rather than relying on shell startup files to add a command to PATH.

The repository's configuration note records camel-case 
mcpServers
 as the tested key. It does not establish that the snake-case alternative 
mcp_servers
 works, and this article makes no such claim. Likewise, the schema version belongs to Muse's configuration format; it is not the npm package version and should not be changed to match a release number.

Meta's 
configuration documentation
 is the vendor reference. That page did not expose readable content to the unauthenticated research tool during this review. The compatibility statement here therefore rests on the executable record and repository documentation, not an invented quotation from Meta.

How can I repeat the example?

Use the verification harness from a source checkout and point it at the Muse executable and the installed server entry point:

node scripts/verify-muse.mjs \
  /absolute/path/to/muse \
  /absolute/path/to/ios-agent-mcp/dist/unified.js

Both paths must exist. The harness prepares temporary configuration and a temporary workspace, places the existing skill in that workspace, starts Muse with the echo provider, and records MCP discovery. It checks several representative tools rather than accepting any nonempty response. Finally it checks the Stop marker and removes its temporary files.

For this article, the server came from a separate installation pinned to 2.7.0. A first run against the working checkout exposed 37 tools, including an unpublished feedback tool. That was useful development evidence but unsuitable as a description of the npm release. Repeating the check against the released artifact resolved the mismatch. This is why a package version string alone is insufficient when testing uncommitted builds.

A compact reading of the published-package result is:

{
  "serverVersion": "2.7.0",
  "toolCount": 36,
  "stopHookExecuted": true,
  "provider": "echo",
  "modelSessionVerified": false
}

What should I check when discovery fails?

First confirm the executable path outside the agent. Then validate the JSON and check that the server is nested under the tested key. Preserve unrelated settings when correcting either problem. A missing executable and a valid executable that never completes initialization are different failures, so keep the startup error with the client version.

Next compare the environment used by your shell with the environment the client inherits. This example avoids package fetching during startup; it does not prove an 
npx
 download will succeed under every network policy. A global installation may be convenient, but the sandboxed download workaround has not been verified by this article.

Finally, do not paste private settings into a public issue. A synthetic configuration with executable placeholders, the versions, and the failure category is usually enough to begin diagnosis.

Limits

Tool discovery does not verify tool invocation, simulator permissions, model quality, skill selection, or app completion. The Stop test does not verify PreToolUse, PostToolUse, or an observer feature. No claim about HTTP transport, pricing tiers, or training policy follows from this stdio test. Recheck compatibility after either program changes.

Last verified

September 16, 2026, America/Chicago; the JSON timestamp is September 17 in UTC. Muse Code 1.3.0 build 1.3.0-R3233.1, published MCP package 2.7.0, Node.js 24.15.0. Repository sources: 
docs/mcp/installation.md
 and 
examples/client-verification/muse-1.3.0.json
.

Example project: 
ios-agent-skill
.

---

# Muse Code: what our integration actually verifies
https://nagarjuna2997.github.io/ios-agent-skill/blog/muse-verification.html

← Back to all articlesAI clients · iOS Agent Skill projectMuse Code: what our integration actually verifiesDiscovery and hooks are useful milestones, but they are not a finished app.On this pageRead the evidence before the compatibility claimInstall outside the agent sandboxVerify in stagesKeep failures usefulDecision checkpoints
The workflow
01
Install server02
Discover tools03
Check hook04
Verify model task
Read the evidence before the compatibility claim
The repository records Muse Code 1.3.0 discovering the published MCP tools and executing a Stop hook. The record uses an echo provider. It does not verify a model-driven app-building session, pre/post hooks or observer behavior. That distinction is the starting point for trying the integration.
Install outside the agent sandbox
The project setup guide uses a global install, followed by a settings entry:
npm install -g ios-agent-mcp@latest
Follow the 
Muse setup section
 and merge its configuration with your existing settings. Preserve the schema version and other servers. If the command cannot be found, inspect the installed executable path instead of guessing a new location.
Verify in stages
Confirm the server starts and tools are discoverable.Ask for a read-only review of a small synthetic Swift file.Inspect the returned finding and its source location.Only then try a controlled edit and an available build/test step.
Record the Muse version and npm package version alongside the outcome. A successful hook invocation does not prove that the agent understood the failure or repaired it correctly.
Keep failures useful
If a step fails, distinguish an unavailable executable, an invalid configuration, a tool error and a model decision. Keep private project source out of public reports. A small synthetic reproduction is easier to inspect and safer to share.
This article describes the project's recorded test scope, not a newly verified Muse release or a claim that its background observer works with every subagent. Future compatibility claims should be backed by a new reproducible record.
Inspect the exact verification record
Decision checkpoints
Situation
Action or status
Evidence or boundaryTool discovery
Recorded
Server communicationStop hook
Recorded with echo provider
Hook invocationModel-driven app build
Not verified by this record
Requires a separate real task

---

# Review AI-generated Swift before you trust it
https://nagarjuna2997.github.io/ios-agent-skill/blog/review-swift-with-ai.html

← Back to all articlesSwift reviews · iOS Agent Skill projectReview AI-generated Swift before you trust itA focused review, a small patch and a real test beat a confident completion message.On this pageStart with one questionConnect, then narrow the taskVerify the proposed fixKeep the review reproducibleDecision checkpoints
The workflow
01
Start with one question02
Connect, then narrow the task03
Verify the proposed fix04
Keep the review reproducible
Start with one question
A broad “review my app” request can produce a long list without a clear next action. Start with the file or feature you changed. Ask your agent to distinguish a compiler error, a heuristic finding and a design suggestion.
iOS Agent Skill provides local references and file-located reviews. It does not replace the Swift compiler. A reported issue is something to investigate, and a clean result is not proof that the app is correct.
Connect, then narrow the task
For Claude Code, run this from your app folder:
claude mcp add ios-agent -- npx -y ios-agent-mcp@latest
Reconnect the client and confirm that the tools appear. For another client, use its 
setup guide
. The local server requires Node.js 20 or later.
Review the Swift concurrency in the files I changed.
Search only relevant local references.
For each finding, show the file, line and reason.
Propose the smallest fix and explain how to test it.
Separate verified results from suggestions.
Verify the proposed fix
For example, when a finding concerns UI-observed state, inspect where that state is mutated and what actor isolation the type actually has. Do not add annotations across the project simply because a heuristic suggests them. Ask for the relevant source context, then build with your target SDK.
Review the diff before accepting it. Run tests for the affected behavior and record the command and outcome. If no suitable test exists, say that explicitly instead of treating compilation as a behavior test.
Keep the review reproducible
Keep one issue and its proposed patch together.Record the package version and client used.Use a synthetic example when reporting a false positive.Never attach private app source or credentials to a public issue.
The useful result is an inspectable change with evidence. Token savings and a quality advantage over other workflows have not been established.
Inspect the review tools
 · 
Read the evidence and limits
Decision checkpoints
Situation
Action or status
Evidence or boundaryCompiler diagnostic
Build with the same scheme and SDK
A successful buildHeuristic finding
Inspect the surrounding isolation and ownership
A justified patch or documented false positiveBehavior concern
Reproduce the user action
A passing behavioral test

---

# How can I catch Swift concurrency review findings before…
https://nagarjuna2997.github.io/ios-agent-skill/blog/swift-concurrency.html

← Back to all articlesSwift reviews · iOS Agent Skill projectHow can I catch Swift concurrency review findings before accepting an agent’s changes?A real before-and-after MCP review with file and line evidence, plus the limits of text-based concurrency checks.On this pageWhy review before accepting a change?What source did the reviewer inspect?Which findings were returned?What change was made?How should I use the result in an app?LimitsLast verified
The workflow
01
Why review before accepting a change?02
What source did the reviewer inspect?03
Which findings were returned?04
What change was made?
Run a focused concurrency review on the proposed changes, inspect each finding in its isolation context, and then use the compiler and behavior tests to verify the repair. A synthetic Swift model produced two real findings in this walkthrough; a narrower, main-actor-isolated version produced none, which is evidence about the review tool rather than proof of race freedom.

Why review before accepting a change?

Agent-generated code can combine patterns from different examples. A model might use observation for UI state while also starting detached work that writes the same state. The important review question is who owns the mutable value and which execution context may access it. Counting occurrences of 
async
 does not answer that question.

The repository documents a focused tool called 
review_swift_concurrency
. It returns file locations, rule identifiers, severities, explanations, and suggested changes. That structure makes a finding easier to investigate than a broad request to improve concurrency. The tool is still heuristic: it reads source patterns rather than constructing the compiler's full isolation model.

Swift's 
concurrency migration guide
 is the primary reference for language-level diagnosis. The experiment below measures this project's reviewer. It is not a survey establishing which mistakes coding agents make most often.

What source did the reviewer inspect?

I created an intentionally small, synthetic fixture called 
FeedModel.swift
. It contains no user app code, networking, or persistence:

import Observation

@Observable
final class FeedModel {
    var title = ""
    func refresh() {
        Task.detached { self.title = "Updated" }
    }
}

The detached operation has no independent computation to perform. It exists only to assign a string. That makes the example suitable for discussing ownership without pretending that moving expensive work onto the main actor is a general performance solution.

I connected an MCP client to the published 2.7.0 server and called 
review_swift_concurrency
 with the fixture directory as its absolute 
path
 argument. This was a real tool call, not an illustration of what a response might look like. The complete response is preserved in 
the before record
.

Which findings were returned?

The response reported one blocker and one serious finding across one Swift file:

Location

Rule

Reported severity

FeedModel.swift:3

observable-without-mainactor

blocker

FeedModel.swift:7

task-detached

serious

Those are the reviewer's classifications. Do not confuse them with compiler diagnostic levels. In particular, observation alone does not establish that every observable type must have an explicit main-actor annotation. Actual ownership, callers, and project isolation settings matter. A reviewer that uses text patterns cannot infer every one of those conditions.

The second finding points to an operation whose detached execution is unnecessary in this fixture. The repair should preserve the program's intended behavior, not merely remove a keyword until the count turns green. In a real application, identify the work that should run independently, the values it returns, and the isolated state that consumes them.

What change was made?

For this UI-state example I made ownership explicit and removed the unnecessary task:

import Observation

@MainActor
@Observable
final class FeedModel {
    var title = ""
    func refresh() {
        title = "Updated"
    }
}

The change does not introduce another asynchronous wrapper. Callers now have to respect the model's actor isolation. If a real refresh loads data, keep the loading contract explicit and update the model through its isolation boundary; this tiny example does not implement that service.

I called the same reviewer against the second fixture. The 
after record
 reports zero findings across one file. Both source fixtures are included with the draft so the line references and change can be inspected. There is no hidden rewrite, model scoring step, or manually adjusted result.

How should I use the result in an app?

Start with the changed model and its callers. Determine whether isolation is explicit on the type, inherited through context, or selected by build settings. Then decide whether the suggested annotation describes the intended ownership. A correct fix in one model may be unnecessary or misleading in another.

Next compile using the application's real Swift language mode and deployment settings. The fixture's successful review does not establish that a view initializer, service, or test can call the repaired model correctly. Those dependencies are precisely where a compiler adds information that the text reviewer lacks.

Finally exercise the behavior that motivated the edit. A feed refresh should show loading, success, failure, and cancellation states as required by the app. A clean review cannot demonstrate any of them. Keep review output beside build and test results so the acceptance report does not flatten several different checks into a single pass.

Limits

This experiment did not run a coding model, compile these fixtures, or measure runtime races. It does not establish an agent error rate or a token-saving advantage. The published reviewer's explanatory wording is stronger than the evidence its pattern matching can prove; interpret findings as investigation leads. A file-level annotation can also conceal a problem in another type in the same file. Zero findings means no matching rule fired, not that all concurrency behavior is correct.

Last verified

September 16, 2026, local time. Published 
ios-agent-mcp
 2.7.0, Node.js 24.15.0, actual stdio tool calls. Repository sources: 
docs/mcp/tools.md
, 
docs/mcp/examples.md
, and 
docs/evidence-and-scope.md
.

Example project: 
ios-agent-skill
.

---

# SwiftUI: review state transitions before polishing the…
https://nagarjuna2997.github.io/ios-agent-skill/blog/swiftui-state-review.html

← Back to all articlesSwiftUI · iOS Agent Skill projectSwiftUI: review state transitions before polishing the screenMake loading, empty, success and error states part of the implementation brief.On this pageA polished screen can still hide missing behaviorDescribe the transitionsKeep ownership understandableUse reviews as a starting pointVerify more than the screenshotDecision checkpoints
The workflow
01
Loading02
Empty03
Content04
Recoverable error
A polished screen can still hide missing behavior
A preview usually shows a convenient state. Real users encounter an empty store, an unfinished request and a failed operation. Before asking an agent to polish a SwiftUI screen, list the states and the actions that move between them.
Describe the transitions
For a reading list, a fresh store starts empty. Adding an item produces content. A search with no matches is different from having no saved books. A storage failure should preserve useful context and offer an appropriate recovery action. Treat those as separate acceptance criteria.
Review this screen's state transitions.
Distinguish loading, empty library, empty search and error.
Keep previews deterministic with injected sample data.
List one observable acceptance check for each state.
Keep ownership understandable
Ask which object owns the state, which views read it and where mutations occur. Follow the project's concurrency guidance and validate isolation with the installed Swift toolchain. Do not change observation mechanisms solely because an API name sounds newer; the app's deployment target and existing architecture matter.
Use reviews as a starting point
The repository includes a SwiftUI review tool and local state/data-flow guidance. Inspect each finding in context. Heuristics do not provide a complete model of runtime behavior, and a screen that compiles may still show the wrong state after an action.
Verify more than the screenshot
Capture each important state in the simulator, then separately test transitions such as clearing a search and relaunching after saving. Inspect larger text and meaningful control labels. Visual checks and accessibility checks answer different questions; do not substitute one for the other.
Read the local state guidance
 · 
Use the five-check verification workflow
Decision checkpoints
Situation
Action or status
Evidence or boundaryEmpty library
Fresh test store
Explain how to add an itemEmpty search
Nonmatching query
Clear search without losing dataError
Controlled failure
Preserve context and offer recovery

---

# Five checks for an AI-built SwiftUI app
https://nagarjuna2997.github.io/ios-agent-skill/blog/verify-swiftui-simulator.html

← Back to all articlesTesting & Simulator · iOS Agent Skill projectFive checks for an AI-built SwiftUI appUse persistence, search and accessible states to make “it works” a testable claim.On this pageDefine “working” before generating code1. Add and reopen2. Search and recover3. Check empty and error states4. Inspect accessibility5. Keep build and test evidenceDecision checkpoints
The workflow
01
Define “working” before generating code02
1. Add and reopen03
2. Search and recover04
3. Check empty and error states
Define “working” before generating code
A screenshot of a populated list can look convincing while persistence or search is broken. Give your coding agent observable acceptance criteria before asking it to implement the feature. A reading-list app makes a useful small exercise because its behavior is easy to describe.
1. Add and reopen
Create a synthetic book, terminate the app and reopen it. Confirm the item survives. A list that only stays populated during one process has not demonstrated persistence. Keep this test separate from any preview data used for development.
2. Search and recover
Search for a known title, then a title that is absent. Clear the query and verify the original list returns. Decide whether matching should ignore case before implementing the test; do not silently change acceptance criteria to fit the output.
3. Check empty and error states
Use a fresh test store to exercise an empty library. Trigger a controlled storage failure through a test seam if available. The app should explain what happened and provide an appropriate next action. A happy-path screenshot does not verify an error state.
4. Inspect accessibility
Check meaningful labels, readable text and the layout with larger text settings. A screenshot helps reveal clipping, but it does not prove VoiceOver behavior or control semantics. Report visual and accessibility checks separately.
5. Keep build and test evidence
Run the build and tests on a configured Mac with Xcode. Capture the important simulator states and compare them with the acceptance criteria. The bundled MCP tools can build, test, install, launch and capture screens; the agent still needs to choose the right project and checks.
Build the Reading List project and run its tests.
Verify add, persistence after relaunch, search and empty state.
Capture the relevant simulator screens.
List any failed or unverified acceptance criteria.
The linked sample contains recorded project evidence; it does not prove that an arbitrary app or a new client session passed these checks. Start with the sample’s own instructions and do not overwrite real user data.
Run the Reading List demo
 · 
Set up the verification workflow
Decision checkpoints
Situation
Action or status
Evidence or boundaryPersistence
Terminate and relaunch
Previously saved item remainsSearch
Clear a nonmatching query
Original list returnsAccessibility
Inspect labels and larger text
Readable layout and meaningful controls

---

# Daily community monitor
https://nagarjuna2997.github.io/ios-agent-skill/community-monitor.html

Daily community monitorDaily community monitor
Operational instructions for the scheduled Codex task. Scheduling is in Codex, not the Pages workflow. The daily Pages job refreshes download counts; it does not search the web.
Repository workflow

Read 
site/community-mentions.json
 and the live 
community.html
 page first.
Verified mentions are stored in that JSON. Generate the isolated HTML section in 
site/community.html
 with 
python3 scripts/render-community.py
.
Run 
python3 scripts/render-community.py --check
, 
python3 scripts/render-site.py --check
, 
python3 -m unittest discover -s scripts/tests -p test_community.py
 and 
bash scripts/hooks/verify-repo.sh
.
Keep verification dates stable on no-change days. Do not commit daily reports, timestamp-only updates or inaccessible-candidate churn. Record the run report in the scheduled task.
Work from a clean current checkout; preserve unrelated work, never reset it. Commit only intended files. Push main to publish through GitHub Pages. Never publish npm or change package versions.
Initial deferred baseline links: LibHunt, PickMCP, Product Hunt and npm web page could not be opened successfully. Retry later; inaccessible does not mean removed. Agentmods has a verified alternative canonical card; do not add its hooks/tag pages as duplicate coverage.
Deduplicate language variants, mirrors on the same site and alternate canonical URLs using editorial judgment plus the renderer’s URL checks. Each standalone republication must be explicitly labelled a mirror.
This initial run found skills.rest and Web Pulse beyond the supplied baseline. Reddit is maintainer-started with independent comments, not an independent endorsement.

User-supplied monitoring specification
You are responsible for maintaining the public “Community & Mentions” section for this project:
GitHub:

https://github.com/Nagarjuna2997/ios-agent-skill
npm:

https://www.npmjs.com/package/ios-agent-mcp
Project names to monitor:
ios-agent-skill
ios-agent-mcp
iOS Agent Skill
iOS Agent MCP
Nagarjuna2997/ios-agent-skill
Run this workflow every day.
Your job is to search the public internet for new mentions of the project, verify them, compare them against the mentions already shown on the website, and update the website only when a genuinely new and useful mention is found.
Do not add duplicate links.
Do not rewrite the section every day if nothing has changed.
Do not manufacture work.
If everything already looks correct, leave the website unchanged.
SEARCH FOR MENTIONS ACROSS:
Google-indexed web pages
developer blogs
personal blogs
newsletters
Reddit
Hacker News
X/Twitter pages that are publicly indexed
LinkedIn pages that are publicly indexed
YouTube videos and descriptions
GitHub repositories
GitHub issues and discussions
MCP directories
Agent Skill directories
Claude Code directories
Codex directories
Swift/iOS developer communities
Product Hunt
DEV Community
Medium
Hashnode
npm-related pages
Apple/Swift ecosystem websites
AI coding-agent resource pages
comparison websites
curated developer-tool lists
software discovery websites
international or translated pages
Search both exact names and URLs.
Use queries based on:
"ios-agent-skill"
"ios-agent-mcp"
"Nagarjuna2997/ios-agent-skill"
"github.com/Nagarjuna2997/ios-agent-skill"
"npm ios-agent-mcp"
"iOS Agent MCP"
"iOS Agent Skill"
Also search combinations with:
Swift
SwiftUI
Xcode
Xcode 27
iOS
MCP
Claude Code
Codex
Cursor
Gemini CLI
coding agents
Apple development
AI coding
CLASSIFY EVERY RESULT
For each result determine whether it is:
INDEPENDENT COMMUNITY MENTION
Someone else discussed, recommended, reviewed, compared, commented on, linked to, or used the project.
This is the highest-value category.
DIRECTORY / INDEX
A third-party directory automatically or manually indexed the project.
This is useful for discovery but should not be described as an endorsement.
COMMUNITY DISCUSSION
A Reddit, Hacker News, GitHub, forum, or other discussion containing real comments about the project.
ARTICLE / BLOG
An independent article that discusses the project.
MY OWN CONTENT
Posts originally created by Nagarjuna Reddy / Nagarjuna2997.
These can be listed as project coverage but must not be presented as independent press.
MIRROR / REPUBLICATION
A site copying, syndicating, translating, or mirroring one of my own articles.
Do not describe this as independent praise.
OFFICIAL PROJECT PAGE
GitHub, npm, project website, Product Hunt listing, etc.
VERIFY BEFORE ADDING
Before adding a result:
Open the page.
Confirm that it genuinely mentions this exact project.
Confirm the URL still works.
Check whether it is already on the website.
Do not add search-result pages that only coincidentally contain similar words.
Do not confuse similarly named iOS MCP projects with this repository.
Do not add spam or scraped garbage pages unless they provide meaningful discovery value.
Do not claim:
“endorsed by”
“recommended by”
“partnered with”
“officially supported by”
“featured by”
unless the source explicitly supports that statement.
Use safer wording such as:
“Mentioned on”
“Discussed on”
“Indexed on”
“Discovered on”
“Listed on”
“Community discussion”
“Featured, discussed, indexed, or discovered across”
WEBSITE SECTION
Maintain a section titled:
Community & Mentions
Keep this introduction:
“Seeing ios-agent-skill shared, indexed, discussed, and discovered across the developer community means a lot to me. Thank you to everyone who has checked out the project, shared feedback, starred the repository, or helped others discover it. I’m still improving it, and every bit of support genuinely motivates me to keep building.”
Then show the verified mentions as clean cards.
Each card should contain:
Site/platform name
Short category such as:
Community
Article
Directory
Developer Resource
Discussion
Launch
Package
One short factual description
Direct external link
Do not show exaggerated marketing language.
Prefer the strongest mentions first.
Suggested ordering:

Independent human/community mentions
Independent articles/blogs
Developer resource collections
Discussions
Curated directories
Automated directories
My own posts
Mirrors

CURRENT KNOWN LINKS
Use these as the starting baseline and do not duplicate them:
Kimi

https://www.kimi.ai/resources/software-skills-for-agents
LibHunt

https://www.libhunt.com/compare-appstore-doctor-vs-ios-agent-skill
SkillsMP

https://skillsmp.com/creators/nagarjuna2997/ios-agent-skill/skill
Awesome Skills

https://www.awesomeskills.dev/en/skill/nagarjuna2997-ios-agent-skill
Awesome MCP Servers

https://mcpservers.org/servers/nagarjuna2997/ios-agent-skill
PickMCP

https://pickmcp.com/servers/Nagarjuna2997/ios-agent-skill
Agentmods

https://agentmods.dev/hooks/nagarjuna2997/ios-agent-skill
SkillWorks

https://skillworks.thecompound.tech/claude-md-examples
Reddit / r/Xcode

https://www.reddit.com/r/Xcode/comments/1v8j94w/i_got_tired_of_ai_agents_writing_2019era_swiftui/
DEV Community

https://dev.to/nagarjuna_reddy_7ca85e003/im-building-an-mcp-toolbox-for-swift-xcode-and-ios-simulator-kp9
Product Hunt

https://www.producthunt.com/products/ios-agent-mcp
npm

https://www.npmjs.com/package/ios-agent-mcp
GitHub

https://github.com/Nagarjuna2997/ios-agent-skill
Do not assume this list is complete.
Search for new mentions every day.
THANK-YOU AREA
Keep this message near the bottom:
“Thank you for supporting ios-agent-skill.”
“This started as a side project because I wanted AI coding agents to work better with real iOS development workflows. Seeing developers discover it, read the documentation, give feedback, and share it keeps me motivated to make it better.”
Then add:
“Using ios-agent-skill in a real project? I’d love to hear about it.”
Keep buttons for:
View on GitHub
Share Feedback
Star the Project
DAILY BEHAVIOR
Every daily run should follow this process:

Read the existing website section first.

Extract all URLs already displayed.

Search broadly for new mentions.

Verify every candidate.

Deduplicate against existing links.

Add only genuinely new mentions.

Preserve all good existing links.

Fix broken links if necessary.

Do not remove a mention simply because it did not appear in today's search.

Do not modify unrelated parts of the website.

Do not redesign the page unless there is an actual layout problem.

Keep the section fast, responsive, accessible, and mobile friendly.

External links should open safely in a new tab where appropriate.

If the website repository has tests or linting, run them after changes.

If the build can be run, verify it before committing.

GIT WORKFLOW
If new verified mentions are found:
Update only the necessary website files.
Run available checks.
Commit with a clear message such as:
docs: add new community mentions
or
site: update ios-agent-skill mentions
Do not create meaningless daily commits when there are no changes.
If no new mention exists:
Do not change files.
Do not create a commit.
Report:
“No new verified mentions today. Website unchanged.”
DAILY REPORT
At the end of every run report:
New mentions found:
[number]
Added to website:
[number]
Independent human/community mentions:
[number]
Directory/index mentions:
[number]
Duplicates ignored:
[number]
Questionable/unverified results ignored:
[number]
Website changed:
YES / NO
Build/check status:
PASS / FAIL / NOT REQUIRED
For each new mention show:
Platform:
URL:
Type:
Why it matters:
Added to website: YES / NO
Most important rule:
The goal is not to make the project appear more popular than it is.
The goal is to honestly document the growing public footprint of ios-agent-skill and thank the people and communities helping others discover it.
If a link is already present and correct, leave it alone.
If nothing new happened, do nothing.

---

# Community & Mentions
https://nagarjuna2997.github.io/ios-agent-skill/community.html

Public footprint
Community & Mentions
Seeing ios-agent-skill shared, indexed, discussed, and discovered across the developer community means a lot to me. Thank you to everyone who has checked out the project, shared feedback, starred the repository, or helped others discover it. I’m still improving it, and every bit of support genuinely motivates me to keep building.
Verified links, clearly labelled. Directory listings are not endorsements; maintainer posts and republications are identified separately.

Developer ResourceKimiLists ios-agent-skill among open-source software skills and links to the repository.Visit Kimi (opens in a new tab)

Discussion · maintainer-startedReddit / r/XcodeReaders discuss Xcode overlap, rule sources and the need for measured evidence.Visit Reddit / r/Xcode (opens in a new tab)

Developer Resource · indexSkillWorksIndexes the repository’s CLAUDE.md among real-world instruction-file examples.Visit SkillWorks (opens in a new tab)

DirectorySkillsMPProvides a skill listing with the source repository and installation information.Visit SkillsMP (opens in a new tab)

DirectoryAwesome SkillsLists the skill with a direct source link and installation options.Visit Awesome Skills (opens in a new tab)

DirectoryAwesome MCP ServersLists the MCP server and reproduces project documentation.Visit Awesome MCP Servers (opens in a new tab)

DirectoryAgentmodsIndexes the project’s Gemini CLI extension with links to its source.Visit Agentmods (opens in a new tab)

Directoryskills.restProvides an indexed skill page linking to this repository.Visit skills.rest (opens in a new tab)

Article · maintainer-authoredDEV CommunityThe maintainer explains the Swift, Xcode and simulator toolbox and asks for feedback.Visit DEV Community (opens in a new tab)

Mirror · maintainer articleWeb PulseRepublishes the maintainer’s DEV article and links back to the original.Visit Web Pulse (opens in a new tab)

Official project pageGitHubSource code, issues and releases maintained by the project.Visit GitHub (opens in a new tab)

Thank you for supporting ios-agent-skill.
This started as a side project because I wanted AI coding agents to work better with real iOS development workflows. Seeing developers discover it, read the documentation, give feedback, and share it keeps me motivated to make it better.
Using ios-agent-skill in a real project? I’d love to hear about it.
View on GitHub
Share Feedback
Star the Project

---

# Documentation index
https://nagarjuna2997.github.io/ios-agent-skill/docs-index.html

Documentation indexBrowse the repository’s documentation indexes and their linked source guides on GitHub.AI and Apple IntelligenceProfessional Motion and AnimationApple Framework Indexdocs/frameworks/apple-intelligence.mddocs/frameworks/core-ai.mddocs/frameworks/ml/coreml.mddocs/frameworks/core-spotlight-rag.mddocs/testing/evaluations.mddocs/frameworks/foundation-models.mddocs/frameworks/extended-apple-frameworks.mddocs/frameworks/ml/natural-language.mddocs/frameworks/ml/sound-analysis.mddocs/frameworks/ml/speech.mddocs/frameworks/ml/translation.mddocs/frameworks/ml/vision.mddocs/frameworks/authentication-services.mddocs/frameworks/cryptokit.mddocs/frameworks/device-integrity.mddocs/frameworks/local-authentication.mddocs/frameworks/avfoundation.mddocs/frameworks/photosui.mddocs/frameworks/services/passkit.mddocs/frameworks/storekit.mddocs/frameworks/activitykit.mddocs/frameworks/app-clips.mddocs/frameworks/app-intents.mddocs/frameworks/swift-charts.mddocs/swiftui/views-and-controls.mddocs/frameworks/tipkit.mddocs/uikit/uikit-essentials.mddocs/frameworks/visionkit.mddocs/frameworks/widgetkit.mddocs/frameworks/cloudkit.mddocs/frameworks/core-data.mddocs/frameworks/data-concurrency.mddocs/frameworks/foundation.mddocs/frameworks/swiftdata.mddocs/design/stunning-ui-patterns.mddocs/design/liquid-glass-adoption.mddocs/web/native-vs-web-animation.mddocs/swiftui/animations.mddocs/tooling/device-hub.mddocs/tooling/fm-cli.mddocs/tooling/foundation-models-instruments.mddocs/tooling/ios-simulator-mcp.mddocs/tooling/xcode-27-agents.mddocs/frameworks/arkit.mddocs/frameworks/metal.mddocs/frameworks/realitykit.mddocs/frameworks/scenekit.mddocs/frameworks/hardware/core-motion.mddocs/frameworks/hardware/core-nfc.mddocs/frameworks/hardware/healthkit.mddocs/frameworks/hardware/core-bluetooth.mddocs/frameworks/network-framework.mddocs/frameworks/networking.mddocs/platforms/ios.mddocs/platforms/macos.mddocs/platforms/tvos.mddocs/platforms/visionos.mddocs/platforms/watchos.mddocs/frameworks/background-tasks.mddocs/frameworks/services/contacts.mddocs/frameworks/core-location.mddocs/frameworks/services/eventkit.mddocs/frameworks/hardware/homekit.mddocs/frameworks/mapkit.mddocs/frameworks/oslog.mddocs/frameworks/usernotifications.mddocs/frameworks/services/weatherkit.mdData and PersistenceProfessional UI/UX SystemGraphics, 3D, and Spatial DevelopmentNetworking and ConnectivityPerformanceSecurity, Authentication, and PrivacyWebKit and JavaScript Interoperability

---

# App Store Submission Checklist
https://nagarjuna2997.github.io/ios-agent-skill/guides/checklists-app-store-submission.html

Authentication, Security, and Privacy · Reference guideApp Store Submission ChecklistRepository guidance for Privacy Manifest. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

App Metadata
Screenshots and Previews
App Review Guidelines Compliance

1.0 Safety
2.0 Performance
3.0 Business
4.0 Design
5.0 Legal

Privacy
App Transport Security
Required Device Capabilities
Launch Screen and App Icons
Entitlements and Capabilities
Build Configuration
TestFlight Beta Testing
Common Rejection Reasons and Fixes
Xcode Archive and Upload
Post-Submission

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Inventory app capabilities
↓2
Check privacy and signing
↓3
Exercise release build
↓4
Submit reviewed archive

02 / ArchitectureResponsibility boundariesBoundary 1
Release artifactBoundary 2
Privacy declarationsBoundary 3
Review evidenceConnected responsibilities, not a required class hierarchy or an execution trace.
A comprehensive checklist for submitting an iOS app. Work through each section before uploading your archive to App Store Connect.

App Metadata

[ ] App name finalized (30 character limit, no keyword stuffing)
[ ] Subtitle written (30 character limit, descriptive and compelling)
[ ] App description written (up to 4000 characters, most important info first)
[ ] Promotional text set (170 characters, can be updated without a new build)
[ ] Keywords optimized (100 character budget, comma-separated, no spaces after commas)
[ ] Primary and secondary categories selected
[ ] Support URL provided (must be a working webpage)
[ ] Marketing URL provided (optional but recommended)
[ ] Copyright field filled (e.g., "2026 Your Company Name")
[ ] Version number follows semantic versioning (e.g., 1.0.0)

Screenshots and Previews

[ ] Screenshots provided for 6.7" display (iPhone 15 Pro Max / 16 Pro Max -- 1290 x 2796)
[ ] Screenshots provided for 6.5" display (iPhone 11 Pro Max -- 1242 x 2688) if targeting older devices
[ ] Screenshots provided for 5.5" display (iPhone 8 Plus -- 1242 x 2208) if supporting iPhone SE
[ ] iPad Pro 12.9" screenshots (2048 x 2732) if universal app
[ ] iPad Pro 13" (M4) screenshots (2064 x 2752) if targeting latest iPads
[ ] Minimum 3 screenshots per device size (maximum 10)
[ ] Screenshots show actual app UI (not misleading)
[ ] App preview videos uploaded (optional, 15-30 seconds, no watermarks)
[ ] All screenshots and previews localized for each supported language

App Review Guidelines Compliance

1.0 Safety

[ ] No objectionable content without proper age gating
[ ] User-generated content has reporting and blocking mechanisms
[ ] No realistic violence in icons or screenshots for apps aimed at children

2.0 Performance

[ ] App is complete and functional (no beta, demo, or trial labels)
[ ] No hidden or undocumented features
[ ] App does not download additional executable code after install
[ ] App works without requiring additional hardware to review

3.0 Business

[ ] In-app purchases use StoreKit (not third-party payment for digital goods)
[ ] Subscriptions include clear pricing and terms
[ ] Subscription offers restore previous purchases
[ ] Free trials clearly state what happens when trial ends
[ ] No bait-and-switch pricing

4.0 Design

[ ] App uses standard system UI or well-designed custom UI
[ ] App functions on all supported device sizes (no black bars)
[ ] No use of private APIs
[ ] App does not mimic native iOS system UI in misleading ways
[ ] Extensions (widgets, keyboards, etc.) include sufficient standalone functionality

5.0 Legal

[ ] App complies with all local laws in each territory
[ ] Developer Program License Agreement followed
[ ] No use of copyrighted material without permission
[ ] GDPR and CCPA compliance if targeting EU/California users

Privacy

[ ] Privacy policy URL provided (required for all apps)
[ ] Privacy policy is accessible and clearly written
[ ] App Privacy labels configured in App Store Connect (Data Types questionnaire)
[ ] Each data type categorized correctly (collected vs. tracked vs. linked)
[ ] App Tracking Transparency (ATT) prompt implemented if tracking users across apps
[ ] ATT prompt shown before any tracking begins
[ ] 
NSUserTrackingUsageDescription
 added to Info.plist if using ATT
[ ] Purpose strings (usage descriptions) provided for all permission requests:
[ ] 
NSCameraUsageDescription
[ ] 
NSPhotoLibraryUsageDescription
[ ] 
NSLocationWhenInUseUsageDescription
[ ] 
NSLocationAlwaysAndWhenInUseUsageDescription
 (if applicable)
[ ] 
NSMicrophoneUsageDescription
[ ] 
NSContactsUsageDescription
[ ] 
NSCalendarsUsageDescription
[ ] 
NSBluetoothAlwaysUsageDescription
[ ] 
NSFaceIDUsageDescription
[ ] 
NSHealthShareUsageDescription
 / 
NSHealthUpdateUsageDescription
[ ] Each purpose string clearly explains why the permission is needed (in user-friendly language)

App Transport Security

[ ] ATS enabled (default in modern Xcode projects)
[ ] No blanket 
NSAllowsArbitraryLoads = YES
 in production
[ ] Any ATS exceptions are justified and documented
[ ] All API endpoints use HTTPS with TLS 1.2+
[ ] Third-party SDKs do not require ATS exceptions (or exceptions are scoped narrowly)

Required Device Capabilities

[ ] 
UIRequiredDeviceCapabilities
 in Info.plist lists only truly required capabilities
[ ] Do not require capabilities unnecessarily (this restricts compatible devices)
[ ] Common capabilities reviewed:
[ ] 
armv7
 / 
arm64
 -- processor architecture
[ ] 
camera-flash
 -- only if core feature needs it
[ ] 
gps
 -- only if precise location is essential
[ ] 
nfc
 -- only if NFC is core to the app
[ ] 
arkit
 -- only if AR is mandatory

Launch Screen and App Icons

[ ] Launch screen configured (storyboard or Info.plist configuration)
[ ] Launch screen matches initial app state (no logos per HIG unless branding)
[ ] App icon provided as a single 1024x1024 asset in the asset catalog
[ ] App icon does not contain alpha channel / transparency
[ ] App icon is not a photograph of an iPhone or iPad
[ ] App icon renders well at small sizes (no fine details lost)
[ ] Alternate app icons configured if supported (optional)

Entitlements and Capabilities

[ ] Only required entitlements are enabled in Signing & Capabilities
[ ] Push Notifications: APNs certificate or key configured, entitlement enabled
[ ] Sign In with Apple: entitlement enabled if using Apple ID sign-in
[ ] Associated Domains: configured for universal links / web credentials
[ ] App Groups: configured if sharing data between app and extensions
[ ] Background Modes: only required modes selected
[ ] HealthKit: entitlement and usage descriptions set
[ ] iCloud / CloudKit: container configured if using cloud sync
[ ] In-App Purchase: capability enabled, products configured in App Store Connect
[ ] Provisioning profile matches entitlements (no mismatch errors)

Build Configuration

[ ] Deployment target set appropriately (check analytics for user base)
[ ] Build number incremented from last upload
[ ] Release build configuration used (not Debug)
[ ] Bitcode setting matches project requirements (deprecated in Xcode 16+)
[ ] All architectures included (arm64 required)
[ ] dSYM files generated for crash reporting
[ ] No compiler warnings in Release build
[ ] No 
#if DEBUG
 code leaking into release paths
[ ] All test/staging API URLs replaced with production URLs
[ ] Logging level reduced for production (no verbose console output)

TestFlight Beta Testing

[ ] Internal testing group created (up to 100 testers)
[ ] External testing group created if needed (up to 10,000 testers)
[ ] Beta App Description written
[ ] Beta build uploaded and processed successfully
[ ] Compliance information answered (encryption export regulations)
[ ] If using non-exempt encryption, proper export compliance documentation filed
[ ] Test notes written for each build describing what to test
[ ] At least one full round of beta testing completed
[ ] Critical crash reports from TestFlight addressed
[ ] Beta feedback reviewed and acted upon

Common Rejection Reasons and Fixes

[ ] 
Crashes/bugs
: Test every user flow, including edge cases and poor network
[ ] 
Broken links
: Verify every URL in the app (support, privacy policy, terms)
[ ] 
Placeholder content
: Remove all lorem ipsum, test data, TODO comments visible to users
[ ] 
Incomplete information
: App description, screenshots, and metadata must be final
[ ] 
Login required but no demo account
: Provide demo credentials in review notes
[ ] 
Permissions without features
: Do not request permissions until the feature needs them
[ ] 
Third-party sign-in without Sign In with Apple
: If you offer Google/Facebook sign-in, you must also offer Sign In with Apple
[ ] 
Subscription issues
: Clearly disclose pricing before paywall; include restore purchases button
[ ] 
Minimum functionality
: App must provide lasting value beyond a simple website wrapper
[ ] 
Misleading metadata
: Keywords, description, and screenshots must accurately represent the app

Xcode Archive and Upload

[ ] Select "Any iOS Device (arm64)" as build destination
[ ] Product > Archive (builds the release archive)
[ ] Archive appears in Organizer window without errors
[ ] Validate the archive (Organizer > Validate App)
[ ] Resolve any validation warnings or errors
[ ] Distribute App > App Store Connect > Upload
[ ] Upload succeeds without errors
[ ] Build appears in App Store Connect under TestFlight within 15-30 minutes
[ ] Build processing completes (check for processing errors via email)
[ ] Select build in App Store Connect release
[ ] Submit for Review

Post-Submission

[ ] Monitor App Store Connect for review status changes
[ ] Respond to any App Review questions promptly (via Resolution Center)
[ ] Prepare release notes for the version
[ ] Decide release method: manual release, automatic after approval, or phased rollout
[ ] Phased release recommended for major updates (1% > 2% > 5% > 10% > 20% > 50% > 100% over 7 days)
[ ] Monitor crash reports after release via Xcode Organizer or third-party tool
[ ] Monitor App Store reviews and respond to user feedback

---

# Generate real asset catalogs
https://nagarjuna2997.github.io/ios-agent-skill/guides/design-asset-generation.html

Design · Reference guideGenerate real asset catalogsRepository guidance for Generate real asset catalogs. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Icons without paid tools
Optional Figma handoff
Evidence and limits

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Read design tokens
↓2
Generate named assets
↓3
Compile asset catalog
↓4
Inspect appearance variants

02 / ArchitectureResponsibility boundariesBoundary 1
Token documentBoundary 2
Asset generatorBoundary 3
Assets.xcassetsConnected responsibilities, not a required class hierarchy or an execution trace.
The CLI converts a strict JSON token file into a new 
Assets.xcassets
. It runs
locally without Figma, a paid service, an AI model or an API key. This is an
implementation of the semantic color guidance in 
design tokens
,
not a parser for arbitrary Markdown or Swift examples.

From a source checkout:

npm ci --prefix cli
npm run build --prefix cli
node cli/dist/index.js assets --tokens tokens.json --output App/Assets.xcassets

The output parent directory must exist. An existing catalog is never overwritten;
generate a sibling catalog, review its diff, then merge the intended changes.
The combined package includes this CLI starting with ios-agent-mcp 2.7.0.
Run 
npx -y ios-agent-mcp@latest assets --tokens tokens.json --output App/Assets.xcassets

to use it without a source checkout.

{
  "version": 1,
  "colors": {
    "AccentColor": {
      "light": "#2457DB",
      "dark": "#90B4FF",
      "highContrastLight": "#12358F",
      "highContrastDark": "#C7DAFF"
    }
  }
}

Names are ASCII identifiers, unique ignoring case; 
AccentColor
 is required.
All four appearances are required. Values are sRGB 
#RRGGBB
 or 
#RRGGBBAA
.
There is no guessed dark-mode conversion. High contrast variants should be chosen
and contrast-tested against their actual background. The generator does not
certify contrast, layout accessibility or App Review acceptance.

New scaffolds include 
App/design-tokens.json
, named 
AccentColor
, 
Background

and 
TextPrimary
 sets. XcodeGen starters set the global accent to 
AccentColor
.
Consume named assets with 
Color("Background")
 and 
Color("TextPrimary")
.

Icons without paid tools

new MyApp --xcodegen
 keeps the three editable SVG layers and also renders their
ordered composition as an opaque RGB 1024×1024 PNG in 
AppIcon.appiconset
. Xcode
uses its single-size iOS icon entry. These are placeholder shapes; customize them
before distributing an app. The generator does not add a person's name or branding.

After editing the SVG paths in any text editor or a free vector editor, regenerate:

node cli/dist/index.js assets --tokens App/design-tokens.json \
  --output App/ReviewedAssets.xcassets \
  --icon-layers App/MyApp/IconLayers --icon-background '#2457DB'

The existing layer manifest specifies back-to-front order. Inputs are bounded to
16 square SVGs, each at most 1 MiB. Use paths and shapes; text, linked images,
external references and entities are rejected. Convert text to paths in your
editor. The rasterizer is 
resvg-js
; the PNG encoder
is 
pngjs
. Their dependency licenses are retained.
No web upload or model call is involved.

A flattened PNG is not a native Liquid Glass icon. For that, import the separate
SVG layers into Apple's free Icon Composer, save the 
.icon
 document and validate
it in Xcode. This generator does 
not
 fabricate an undocumented 
.icon
 bundle.
See 
Apple's Icon Composer workflow
.

Optional Figma handoff

Use Figma's own integration to read variables if you already use it. Map semantic
color names and light/dark/high-contrast modes to the JSON fields above. Resolve
aliases to explicit sRGB hex values first. The same JSON can be authored by hand;
Figma and its MCP are optional. There is no maintained Figma parser here.

Evidence and limits

The CLI tests verify schema rejection, appearance slots, alpha conversion,
non-overwrite behavior, ordered raster pixels, RGB output and 1024×1024 dimensions.
On macOS, generated colors and the iOS app-icon set were compiled using 
xcrun
actool
 against the installed simulator SDK. This does not verify a native 
.icon
,
a full screenshot capture pipeline, symbol availability or visual accessibility.

Catalog format: 
Apple named colors

and 
appearance variants
.

---

# iOS Color System -- Complete Guide for Stunning SwiftUI UIs
https://nagarjuna2997.github.io/ios-agent-skill/guides/design-color-system.html

Design · Reference guideiOS Color System -- Complete Guide for Stunning SwiftUI UIsRepository guidance for iOS Color System -- Complete Guide for Stunning SwiftUI UIs. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Overview
1. Apple's Semantic Colors

System Grouped Colors
Flat Background Hierarchy
Fill Colors

2. Material Effects

Combining Materials with Borders

3. Dark Mode Support

Automatic Adaptation
Manual Color Scheme Override

4. Color Extension -- Hex Initializer
5. Five Stunning Pre-Built Color Palettes

Palette 1 -- Ocean Blue (Fintech / Productivity)
Palette 2 -- Sunset Warm (Social / Lifestyle)
Palette 3 -- Midnight Dark (Premium / Luxury)
Palette 4 -- Nature Green (Health / Wellness)
Palette 5 -- Violet Dream (Creative / Entertainment)
Using Palettes with Environment-Aware Adaptive Colors

6. Custom Colors via Asset Catalog
7. Gradient Recipes

Linear Gradient
Radial Gradient
Angular (Conic) Gradient
Mesh Gradient (iOS 18+)

8. Ten Stunning Gradient Combinations
9. Vibrancy and Blur Effects
10. Color Accessibility

Contrast Ratios
Color Blind Friendly Design Tips

Quick Reference

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Choose semantic roles
↓2
Assign appearance variants
↓3
Measure contrast
↓4
Inspect real screens

02 / ArchitectureResponsibility boundariesBoundary 1
Semantic rolesBoundary 2
Light and dark palettesBoundary 3
Accessible UIConnected responsibilities, not a required class hierarchy or an execution trace.
Overview

Color is the single most powerful tool for creating emotional impact in iOS applications. This guide covers every aspect of the SwiftUI color system, from Apple's semantic tokens to custom brand palettes, gradients, materials, and accessibility. Every code example compiles and produces production-quality results.

1. Apple's Semantic Colors

Semantic colors adapt automatically to light mode, dark mode, and increased contrast settings. Always prefer these over hardcoded values.

import SwiftUI

struct SemanticColorsShowcase: View {
    var body: some View {
        VStack(spacing: 16) {
            // Label colors -- automatically adapt to appearance
            Text("Primary Label")
                .foregroundStyle(.primary)
            Text("Secondary Label")
                .foregroundStyle(.secondary)
            Text("Tertiary Label")
                .foregroundStyle(.tertiary)
            Text("Quaternary Label")
                .foregroundStyle(.quaternary)

            Divider()

            // Tint / accent color
            Button("Accent Color Button") {}
                .tint(.accentColor)

            // Semantic intent colors
            HStack(spacing: 12) {
                Circle().fill(.red).frame(width: 32, height: 32)    // Destructive
                Circle().fill(.orange).frame(width: 32, height: 32) // Warning
                Circle().fill(.green).frame(width: 32, height: 32)  // Success
                Circle().fill(.blue).frame(width: 32, height: 32)   // Informational
                Circle().fill(.yellow).frame(width: 32, height: 32) // Caution
            }
        }
        .padding(24)
    }
}

System Grouped Colors

These create the layered card-on-background look native to iOS Settings and many Apple apps.

struct SystemBackgroundsDemo: View {
    var body: some View {
        ZStack {
            Color(.systemGroupedBackground)
                .ignoresSafeArea()

            VStack(spacing: 20) {
                // Primary surface card
                RoundedRectangle(cornerRadius: 16)
                    .fill(Color(.secondarySystemGroupedBackground))
                    .frame(height: 120)
                    .overlay(
                        Text("Secondary Grouped Background")
                            .foregroundStyle(.primary)
                    )

                // Nested surface
                RoundedRectangle(cornerRadius: 16)
                    .fill(Color(.tertiarySystemGroupedBackground))
                    .frame(height: 120)
                    .overlay(
                        Text("Tertiary Grouped Background")
                            .foregroundStyle(.secondary)
                    )
            }
            .padding(20)
        }
    }
}

Flat Background Hierarchy

For non-grouped layouts (full-bleed content rather than inset cards).

struct FlatBackgroundsDemo: View {
    var body: some View {
        ZStack {
            Color(.systemBackground)
                .ignoresSafeArea()

            VStack(spacing: 0) {
                Rectangle()
                    .fill(Color(.secondarySystemBackground))
                    .frame(height: 80)
                    .overlay(Text("Secondary").foregroundStyle(.primary))

                Rectangle()
                    .fill(Color(.tertiarySystemBackground))
                    .frame(height: 80)
                    .overlay(Text("Tertiary").foregroundStyle(.primary))
            }
        }
    }
}

Fill Colors

Use fills for shapes and backgrounds within cells.

struct FillColorsDemo: View {
    var body: some View {
        VStack(spacing: 12) {
            RoundedRectangle(cornerRadius: 10)
                .fill(Color(.systemFill))
                .frame(height: 50)
                .overlay(Text("System Fill"))

            RoundedRectangle(cornerRadius: 10)
                .fill(Color(.secondarySystemFill))
                .frame(height: 50)
                .overlay(Text("Secondary Fill"))

            RoundedRectangle(cornerRadius: 10)
                .fill(Color(.tertiarySystemFill))
                .frame(height: 50)
                .overlay(Text("Tertiary Fill"))

            RoundedRectangle(cornerRadius: 10)
                .fill(Color(.quaternarySystemFill))
                .frame(height: 50)
                .overlay(Text("Quaternary Fill"))
        }
        .padding()
    }
}

2. Material Effects

Materials create frosted-glass blur over underlying content. They are essential for modern iOS design.

struct MaterialShowcase: View {
    var body: some View {
        ZStack {
            // Rich background to show blur effect
            LinearGradient(
                colors: [.purple, .blue, .cyan, .mint],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
            .ignoresSafeArea()

            ScrollView {
                VStack(spacing: 16) {
                    materialCard("Ultra Thin Material", material: .ultraThinMaterial)
                    materialCard("Thin Material", material: .thinMaterial)
                    materialCard("Regular Material", material: .regularMaterial)
                    materialCard("Thick Material", material: .thickMaterial)
                    materialCard("Ultra Thick Material", material: .ultraThickMaterial)
                }
                .padding(20)
            }
        }
    }

    func materialCard(_ title: String, material: Material) -> some View {
        RoundedRectangle(cornerRadius: 20)
            .fill(material)
            .frame(height: 100)
            .overlay(
                Text(title)
                    .font(.headline)
                    .foregroundStyle(.primary)
            )
            .shadow(color: .black.opacity(0.1), radius: 10, y: 5)
    }
}

Combining Materials with Borders

struct GlassCard: View {
    var body: some View {
        ZStack {
            Image(systemName: "photo.artframe")
                .resizable()
                .scaledToFill()
                .frame(width: 400, height: 600)
                .clipped()

            VStack(alignment: .leading, spacing: 8) {
                Text("Glass Card")
                    .font(.title2.weight(.bold))
                Text("Beautiful frosted glass with a subtle border that catches light.")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }
            .padding(24)
            .frame(maxWidth: .infinity, alignment: .leading)
            .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 24))
            .overlay(
                RoundedRectangle(cornerRadius: 24)
                    .stroke(
                        LinearGradient(
                            colors: [.white.opacity(0.5), .white.opacity(0.1)],
                            startPoint: .topLeading,
                            endPoint: .bottomTrailing
                        ),
                        lineWidth: 1
                    )
            )
            .padding(20)
        }
    }
}

3. Dark Mode Support

Automatic Adaptation

SwiftUI semantic colors adapt automatically. For custom colors, use Asset Catalog entries with "Any" and "Dark" appearances.

Manual Color Scheme Override

struct DarkModeDemo: View {
    @Environment(\.colorScheme) var colorScheme

    var body: some View {
        VStack(spacing: 20) {
            Text("Current: \(colorScheme == .dark ? "Dark" : "Light")")
                .font(.headline)

            // Adaptive custom color
            RoundedRectangle(cornerRadius: 16)
                .fill(colorScheme == .dark
                    ? Color(hex: "1A1A2E")
                    : Color(hex: "F8F9FA"))
                .frame(height: 100)
                .overlay(
                    Text("Adaptive Card")
                        .foregroundStyle(colorScheme == .dark ? .white : .black)
                )
        }
        .padding()
    }
}

// Force a specific color scheme on any view subtree
struct ForcedSchemeExample: View {
    var body: some View {
        HStack(spacing: 20) {
            CardView(label: "Always Light")
                .environment(\.colorScheme, .light)

            CardView(label: "Always Dark")
                .environment(\.colorScheme, .dark)
        }
        .padding()
    }
}

struct CardView: View {
    let label: String
    var body: some View {
        Text(label)
            .padding()
            .background(Color(.secondarySystemBackground))
            .cornerRadius(12)
    }
}

4. Color Extension -- Hex Initializer

This extension is used throughout this guide and is essential for any custom palette work.

import SwiftUI

// The hex initialiser is NOT redefined here.
//
// It lives in `docs/design/design-tokens.md`, and this file used to declare a
// second `init(hex: String)` with a different body. Copy both into one target
// and the compiler stops you: `invalid redeclaration of 'init(hex:)'`. Worse,
// the old body fell back to **black** on a malformed string, which looks like
// a deliberate colour and ships unnoticed.
//
// Copy the canonical one from design-tokens.md. It accepts both
// `Color(hex: 0x6C63FF)` and `Color(hex: "6C63FF")`, and a malformed literal
// traps in DEBUG and renders magenta in release.

5. Five Stunning Pre-Built Color Palettes

Palette 1 -- Ocean Blue (Fintech / Productivity)

Role

Light Hex

Dark Hex

Text on it (light / dark)

Description

Primary

#0A6EBD

#3DA5F4

white 5.28:1 / black 7.89:1

Trust-inspiring blue

Secondary

#1E88E5

#64B5F6

black 5.71:1 / black 9.48:1

Lighter accent blue

Accent

#00BCD4

#4DD0E1

black 9.14:1 / black 11.43:1

Teal action highlight

Background

#F4F7FA

#0D1117

—

Clean paper / deep night

Surface

#FFFFFF

#161B22

—

Card surface

Text

#1A2233

#E6EDF3

—

High-contrast readable text

Error

#D32F2F

#EF5350

white 4.98:1 / black 6.02:1

Clear danger signal

struct OceanBluePalette {
    static let primary     = Color(hex: "0A6EBD")
    static let secondary   = Color(hex: "1E88E5")
    static let accent      = Color(hex: "00BCD4")
    static let background  = Color(hex: "F4F7FA")
    static let surface     = Color(hex: "FFFFFF")
    static let text        = Color(hex: "1A2233")
    static let error       = Color(hex: "D32F2F")

    static let primaryDark     = Color(hex: "3DA5F4")
    static let secondaryDark   = Color(hex: "64B5F6")
    static let accentDark      = Color(hex: "4DD0E1")
    static let backgroundDark  = Color(hex: "0D1117")
    static let surfaceDark     = Color(hex: "161B22")
    static let textDark        = Color(hex: "E6EDF3")
    static let errorDark       = Color(hex: "EF5350")
}

Body-text pairs
 — Text on Background 
14.79:1
 light, 
16.02:1
 dark · Text on Surface 
15.90:1
 light, 
14.64:1
 dark. All four clear the 4.5:1 body-text bar.

Palette 2 -- Sunset Warm (Social / Lifestyle)

Role

Light Hex

Dark Hex

Text on it (light / dark)

Description

Primary

#FF6B35

#FF8A5C

black 7.41:1 / black 9.04:1

Warm energetic orange

Secondary

#F7C948

#FFD966

black 13.4:1 / black 15.37:1

Sunny golden yellow

Accent

#E84393

#FD79A8

black 5.66:1 / black 8.48:1

Playful magenta

Background

#FFF8F0

#1A1215

—

Warm cream / warm dark

Surface

#FFFFFF

#2D1F23

—

Card surface

Text

#2D1810

#F5E6D8

—

Dark warm brown / cream

Error

#C0392B

#E74C3C

white 5.44:1 / black 5.5:1

Red alert

struct SunsetWarmPalette {
    static let primary     = Color(hex: "FF6B35")
    static let secondary   = Color(hex: "F7C948")
    static let accent      = Color(hex: "E84393")
    static let background  = Color(hex: "FFF8F0")
    static let surface     = Color(hex: "FFFFFF")
    static let text        = Color(hex: "2D1810")
    static let error       = Color(hex: "C0392B")

    static let primaryDark     = Color(hex: "FF8A5C")
    static let secondaryDark   = Color(hex: "FFD966")
    static let accentDark      = Color(hex: "FD79A8")
    static let backgroundDark  = Color(hex: "1A1215")
    static let surfaceDark     = Color(hex: "2D1F23")
    static let textDark        = Color(hex: "F5E6D8")
    static let errorDark       = Color(hex: "E74C3C")
}

Body-text pairs
 — Text on Background 
15.95:1
 light, 
15.06:1
 dark · Text on Surface 
16.80:1
 light, 
12.91:1
 dark. All four clear the 4.5:1 body-text bar.

Palette 3 -- Midnight Dark (Premium / Luxury)

Role

Light Hex

Dark Hex

Text on it (light / dark)

Description

Primary

#6C63FF

#8B83FF

black 4.87:1 / black 6.79:1

Electric indigo

Secondary

#A78BFA

#C4B5FD

black 7.72:1 / black 11.38:1

Soft lavender

Accent

#F472B6

#F9A8D4

black 7.93:1 / black 11.58:1

Rose gold accent

Background

#F5F3FF

#0B0B1A

—

Faint violet / pure dark

Surface

#FFFFFF

#13132B

—

Card surface

Text

#1E1B4B

#E2E0F0

—

Deep indigo / soft light

Error

#DC2626

#F87171

white 4.83:1 / black 7.59:1

Bright red

struct MidnightDarkPalette {
    static let primary     = Color(hex: "6C63FF")
    static let secondary   = Color(hex: "A78BFA")
    static let accent      = Color(hex: "F472B6")
    static let background  = Color(hex: "F5F3FF")
    static let surface     = Color(hex: "FFFFFF")
    static let text        = Color(hex: "1E1B4B")
    static let error       = Color(hex: "DC2626")

    static let primaryDark     = Color(hex: "8B83FF")
    static let secondaryDark   = Color(hex: "C4B5FD")
    static let accentDark      = Color(hex: "F9A8D4")
    static let backgroundDark  = Color(hex: "0B0B1A")
    static let surfaceDark     = Color(hex: "13132B")
    static let textDark        = Color(hex: "E2E0F0")
    static let errorDark       = Color(hex: "F87171")
}

Body-text pairs
 — Text on Background 
14.58:1
 light, 
15.00:1
 dark · Text on Surface 
15.99:1
 light, 
13.98:1
 dark. All four clear the 4.5:1 body-text bar.

Palette 4 -- Nature Green (Health / Wellness)

Role

Light Hex

Dark Hex

Text on it (light / dark)

Description

Primary

#2D9F6F

#4ADE80

black 6.3:1 / black 12.05:1

Fresh healing green

Secondary

#22D3EE

#67E8F9

black 11.62:1 / black 14.49:1

Cool sky cyan

Accent

#F59E0B

#FBBF24

black 9.78:1 / black 12.58:1

Warm honey gold

Background

#F0FDF4

#0A1A12

—

Faint mint / forest dark

Surface

#FFFFFF

#112118

—

Card surface

Text

#14352A

#D1FAE5

—

Deep forest / soft mint

Error

#DC2626

#FB7185

white 4.83:1 / black 7.8:1

Alert red

struct NatureGreenPalette {
    static let primary     = Color(hex: "2D9F6F")
    static let secondary   = Color(hex: "22D3EE")
    static let accent      = Color(hex: "F59E0B")
    static let background  = Color(hex: "F0FDF4")
    static let surface     = Color(hex: "FFFFFF")
    static let text        = Color(hex: "14352A")
    static let error       = Color(hex: "DC2626")

    static let primaryDark     = Color(hex: "4ADE80")
    static let secondaryDark   = Color(hex: "67E8F9")
    static let accentDark      = Color(hex: "FBBF24")
    static let backgroundDark  = Color(hex: "0A1A12")
    static let surfaceDark     = Color(hex: "112118")
    static let textDark        = Color(hex: "D1FAE5")
    static let errorDark       = Color(hex: "FB7185")
}

Body-text pairs
 — Text on Background 
12.76:1
 light, 
15.83:1
 dark · Text on Surface 
13.36:1
 light, 
14.76:1
 dark. All four clear the 4.5:1 body-text bar.

Palette 5 -- Violet Dream (Creative / Entertainment)

Role

Light Hex

Dark Hex

Text on it (light / dark)

Description

Primary

#8B5CF6

#A78BFA

black 4.96:1 / black 7.72:1

Vibrant violet

Secondary

#EC4899

#F472B6

black 5.95:1 / black 7.93:1

Hot pink

Accent

#06B6D4

#22D3EE

black 8.65:1 / black 11.62:1

Electric cyan

Background

#FAF5FF

#0F0720

—

Lavender mist / deep purple

Surface

#FFFFFF

#1A0F2E

—

Card surface

Text

#2E1065

#EDE9FE

—

Deep purple / pale lavender

Error

#E11D48

#FB7185

white 4.7:1 / black 7.8:1

Rose red

struct VioletDreamPalette {
    static let primary     = Color(hex: "8B5CF6")
    static let secondary   = Color(hex: "EC4899")
    static let accent      = Color(hex: "06B6D4")
    static let background  = Color(hex: "FAF5FF")
    static let surface     = Color(hex: "FFFFFF")
    static let text        = Color(hex: "2E1065")
    static let error       = Color(hex: "E11D48")

    static let primaryDark     = Color(hex: "A78BFA")
    static let secondaryDark   = Color(hex: "F472B6")
    static let accentDark      = Color(hex: "22D3EE")
    static let backgroundDark  = Color(hex: "0F0720")
    static let surfaceDark     = Color(hex: "1A0F2E")
    static let textDark        = Color(hex: "EDE9FE")
    static let errorDark       = Color(hex: "FB7185")
}

Using Palettes with Environment-Aware Adaptive Colors

struct AdaptiveColor {
    let light: Color
    let dark: Color

    func resolve(for scheme: ColorScheme) -> Color {
        scheme == .dark ? dark : light
    }
}

struct AdaptiveCardExample: View {
    @Environment(\.colorScheme) var scheme

    var primary: Color {
        AdaptiveColor(
            light: OceanBluePalette.primary,
            dark: OceanBluePalette.primaryDark
        ).resolve(for: scheme)
    }

    var body: some View {
        Text("Adaptive Palette Card")
            .font(.headline)
            .foregroundStyle(.white)
            .padding(24)
            .background(primary, in: RoundedRectangle(cornerRadius: 16))
    }
}

6. Custom Colors via Asset Catalog

Step-by-step for Xcode:

Open 
Assets.xcassets
.
Click the 
+
 button, choose "Color Set".
Name it (e.g., 
BrandPrimary
).
In the Attributes Inspector, set "Appearances" to "Any, Dark".
Set hex values for each appearance.
Use in SwiftUI:

// After defining "BrandPrimary" in the Asset Catalog:
Text("Brand Styled")
    .foregroundStyle(Color("BrandPrimary"))

// Type-safe alternative using an extension:
extension Color {
    static let brandPrimary = Color("BrandPrimary")
    static let brandSecondary = Color("BrandSecondary")
    static let brandAccent = Color("BrandAccent")
}

Text("Type Safe")
    .foregroundStyle(.brandPrimary)

7. Gradient Recipes

Linear Gradient

struct LinearGradientExamples: View {
    var body: some View {
        VStack(spacing: 16) {
            // Horizontal gradient
            RoundedRectangle(cornerRadius: 20)
                .fill(
                    LinearGradient(
                        colors: [Color(hex: "6C63FF"), Color(hex: "E84393")],
                        startPoint: .leading,
                        endPoint: .trailing
                    )
                )
                .frame(height: 100)

            // Diagonal gradient with multiple stops
            RoundedRectangle(cornerRadius: 20)
                .fill(
                    LinearGradient(
                        stops: [
                            .init(color: Color(hex: "667EEA"), location: 0),
                            .init(color: Color(hex: "764BA2"), location: 0.5),
                            .init(color: Color(hex: "F093FB"), location: 1),
                        ],
                        startPoint: .topLeading,
                        endPoint: .bottomTrailing
                    )
                )
                .frame(height: 100)
        }
        .padding()
    }
}

Radial Gradient

struct RadialGradientExample: View {
    var body: some View {
        Circle()
            .fill(
                RadialGradient(
                    colors: [
                        Color(hex: "FF6B35"),
                        Color(hex: "F7C948"),
                        Color(hex: "FF6B35").opacity(0.3),
                    ],
                    center: .center,
                    startRadius: 20,
                    endRadius: 150
                )
            )
            .frame(width: 300, height: 300)
            .shadow(color: Color(hex: "FF6B35").opacity(0.4), radius: 30, y: 10)
    }
}

Angular (Conic) Gradient

struct AngularGradientExample: View {
    var body: some View {
        Circle()
            .fill(
                AngularGradient(
                    colors: [
                        Color(hex: "8B5CF6"),
                        Color(hex: "EC4899"),
                        Color(hex: "06B6D4"),
                        Color(hex: "8B5CF6"),
                    ],
                    center: .center
                )
            )
            .frame(width: 200, height: 200)
    }
}

Mesh Gradient (iOS 18+)

@available(iOS 18.0, *)
struct MeshGradientExample: View {
    var body: some View {
        MeshGradient(
            width: 3,
            height: 3,
            points: [
                [0.0, 0.0], [0.5, 0.0], [1.0, 0.0],
                [0.0, 0.5], [0.5, 0.5], [1.0, 0.5],
                [0.0, 1.0], [0.5, 1.0], [1.0, 1.0],
            ],
            colors: [
                Color(hex: "6C63FF"), Color(hex: "8B5CF6"), Color(hex: "EC4899"),
                Color(hex: "3DA5F4"), Color(hex: "A78BFA"), Color(hex: "F472B6"),
                Color(hex: "06B6D4"), Color(hex: "22D3EE"), Color(hex: "F9A8D4"),
            ]
        )
        .frame(height: 400)
        .clipShape(RoundedRectangle(cornerRadius: 24))
        .ignoresSafeArea()
    }
}

8. Ten Stunning Gradient Combinations

Each gradient is named and ready to drop into any project.

enum StunningGradients {
    /// 1. Oceanic Depths -- deep sea to sky
    static let oceanicDepths = LinearGradient(
        colors: [Color(hex: "0A2463"), Color(hex: "1E88E5"), Color(hex: "00BCD4")],
        startPoint: .topLeading, endPoint: .bottomTrailing
    )

    /// 2. Sunset Boulevard -- golden hour warmth
    static let sunsetBoulevard = LinearGradient(
        colors: [Color(hex: "FF6B35"), Color(hex: "F7C948"), Color(hex: "FF8A5C")],
        startPoint: .leading, endPoint: .trailing
    )

    /// 3. Northern Lights -- aurora borealis
    static let northernLights = LinearGradient(
        colors: [Color(hex: "0F2027"), Color(hex: "203A43"), Color(hex: "2C5364"), Color(hex: "4ADE80")],
        startPoint: .top, endPoint: .bottom
    )

    /// 4. Rose Gold -- luxury feminine
    static let roseGold = LinearGradient(
        colors: [Color(hex: "F472B6"), Color(hex: "FBBF24"), Color(hex: "F9A8D4")],
        startPoint: .topLeading, endPoint: .bottomTrailing
    )

    /// 5. Electric Violet -- creative energy
    static let electricViolet = LinearGradient(
        colors: [Color(hex: "8B5CF6"), Color(hex: "6C63FF"), Color(hex: "EC4899")],
        startPoint: .topLeading, endPoint: .bottomTrailing
    )

    /// 6. Midnight City -- dark premium
    static let midnightCity = LinearGradient(
        colors: [Color(hex: "0B0B1A"), Color(hex: "1A1A2E"), Color(hex: "16213E")],
        startPoint: .top, endPoint: .bottom
    )

    /// 7. Fresh Mint -- health and clarity
    static let freshMint = LinearGradient(
        colors: [Color(hex: "2D9F6F"), Color(hex: "22D3EE"), Color(hex: "67E8F9")],
        startPoint: .leading, endPoint: .trailing
    )

    /// 8. Cyber Punk -- bold neon
    static let cyberPunk = LinearGradient(
        colors: [Color(hex: "F72585"), Color(hex: "7209B7"), Color(hex: "3A0CA3"), Color(hex: "4CC9F0")],
        startPoint: .topLeading, endPoint: .bottomTrailing
    )

    /// 9. Warm Ember -- cozy and inviting
    static let warmEmber = LinearGradient(
        colors: [Color(hex: "D32F2F"), Color(hex: "FF6B35"), Color(hex: "F7C948")],
        startPoint: .bottomLeading, endPoint: .topTrailing
    )

    /// 10. Iridescent Pearl -- subtle luxury shimmer
    static let iridescentPearl = LinearGradient(
        colors: [
            Color(hex: "E8D5F5"), Color(hex: "C4E0F9"),
            Color(hex: "D1FAE5"), Color(hex: "FEF3C7"), Color(hex: "E8D5F5"),
        ],
        startPoint: .topLeading, endPoint: .bottomTrailing
    )
}

// Usage in a view
struct GradientShowcase: View {
    var body: some View {
        ScrollView {
            VStack(spacing: 16) {
                gradientCard("Oceanic Depths", gradient: StunningGradients.oceanicDepths)
                gradientCard("Sunset Boulevard", gradient: StunningGradients.sunsetBoulevard)
                gradientCard("Northern Lights", gradient: StunningGradients.northernLights)
                gradientCard("Rose Gold", gradient: StunningGradients.roseGold)
                gradientCard("Electric Violet", gradient: StunningGradients.electricViolet)
                gradientCard("Midnight City", gradient: StunningGradients.midnightCity)
                gradientCard("Fresh Mint", gradient: StunningGradients.freshMint)
                gradientCard("Cyber Punk", gradient: StunningGradients.cyberPunk)
                gradientCard("Warm Ember", gradient: StunningGradients.warmEmber)
                gradientCard("Iridescent Pearl", gradient: StunningGradients.iridescentPearl)
            }
            .padding()
        }
    }

    func gradientCard(_ name: String, gradient: LinearGradient) -> some View {
        RoundedRectangle(cornerRadius: 20)
            .fill(gradient)
            .frame(height: 100)
            .overlay(
                Text(name)
                    .font(.title3.weight(.bold))
                    .foregroundStyle(.white)
                    .shadow(color: .black.opacity(0.3), radius: 4, y: 2)
            )
    }
}

9. Vibrancy and Blur Effects

struct VibrancyEffectDemo: View {
    var body: some View {
        ZStack {
            // Background image or gradient
            LinearGradient(
                colors: [Color(hex: "6C63FF"), Color(hex: "EC4899")],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
            .ignoresSafeArea()

            VStack(spacing: 20) {
                // Vibrant label on material
                Text("Vibrant Title")
                    .font(.largeTitle.weight(.bold))
                    .foregroundStyle(.primary)
                    .padding(20)
                    .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))

                // Hierarchical vibrancy
                VStack(alignment: .leading, spacing: 8) {
                    Label("Primary", systemImage: "star.fill")
                        .foregroundStyle(.primary)
                    Label("Secondary", systemImage: "star.leadinghalf.filled")
                        .foregroundStyle(.secondary)
                    Label("Tertiary", systemImage: "star")
                        .foregroundStyle(.tertiary)
                }
                .font(.headline)
                .padding(20)
                .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
            }
        }
    }
}

10. Color Accessibility

Contrast Ratios

WCAG 2.1 requires at least 4.5:1 contrast for normal text and 3:1 for large text. Use these helper utilities.

extension Color {
    /// Calculate relative luminance of a color.
    /// Returns a value between 0 (black) and 1 (white).
    func relativeLuminance() -> Double {
        // Approximate; for exact values resolve the UIColor components.
        // This is a conceptual guide -- use UIColor for runtime calculation:
        var r: CGFloat = 0; var g: CGFloat = 0; var b: CGFloat = 0; var a: CGFloat = 0
        UIColor(self).getRed(&r, green: &g, blue: &b, alpha: &a)

        func linearize(_ c: CGFloat) -> Double {
            let v = Double(c)
            return v <= 0.03928 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4)
        }

        return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
    }

    /// Calculate WCAG contrast ratio between two colors.
    func contrastRatio(with other: Color) -> Double {
        let l1 = self.relativeLuminance()
        let l2 = other.relativeLuminance()
        let lighter = max(l1, l2)
        let darker = min(l1, l2)
        return (lighter + 0.05) / (darker + 0.05)
    }
}

struct ContrastChecker: View {
    let foreground = Color(hex: "1A2233")
    let background = Color(hex: "F4F7FA")

    var body: some View {
        let ratio = foreground.contrastRatio(with: background)
        VStack(spacing: 12) {
            Text("Contrast Ratio: \(ratio, specifier: "%.1f"):1")
                .font(.headline)
            Text(ratio >= 4.5 ? "WCAG AA Pass" : "WCAG AA Fail")
                .font(.subheadline)
                .foregroundStyle(ratio >= 4.5 ? .green : .red)

            Text("Sample Text on Background")
                .foregroundStyle(foreground)
                .padding()
                .background(background, in: RoundedRectangle(cornerRadius: 12))
        }
        .padding()
    }
}

Color Blind Friendly Design Tips

Never rely on color alone to convey meaning -- combine with icons, labels, or patterns.
Use high-contrast pairings that remain distinguishable under protanopia, deuteranopia, and tritanopia.
Test with Xcode Accessibility Inspector or Simulator color filters.
Prefer blue/orange pairings (distinguishable under all common types) over red/green.

struct ColorBlindFriendlyStatus: View {
    var body: some View {
        HStack(spacing: 16) {
            Label("Success", systemImage: "checkmark.circle.fill")
                .foregroundStyle(Color(hex: "2D9F6F"))
            Label("Warning", systemImage: "exclamationmark.triangle.fill")
                .foregroundStyle(Color(hex: "F59E0B"))
            Label("Error", systemImage: "xmark.circle.fill")
                .foregroundStyle(Color(hex: "DC2626"))
        }
        .font(.headline)
    }
}

Quick Reference

Category

Key Types

Semantic Labels

.primary, .secondary, .tertiary, .quaternary

System Backgrounds

Color(.systemBackground), .secondarySystemBackground, .tertiarySystemBackground

Grouped Backgrounds

Color(.systemGroupedBackground), .secondarySystemGroupedBackground, .tertiarySystemGroupedBackground

Fills

Color(.systemFill) through Color(.quaternarySystemFill)

Materials

.ultraThinMaterial through .ultraThickMaterial

Gradients

LinearGradient, RadialGradient, AngularGradient, MeshGradient

Scheme Override

.environment(\.colorScheme, .dark)

Asset Catalog

Color("AssetName")

Hex Init

Color(hex: "FF5733")

Body-text pairs
 — Text on Background 
14.20:1
 light, 
16.51:1
 dark · Text on Surface 
15.24:1
 light, 
15.36:1
 dark. All four clear the 4.5:1 body-text bar.

---

# Design Tokens, Adaptive Color, and Liquid Glass
https://nagarjuna2997.github.io/ios-agent-skill/guides/design-design-tokens.html

Design · Reference guideDesign Tokens, Adaptive Color, and Liquid GlassRepository guidance for Design Tokens, Adaptive Color, and Liquid Glass. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

1. The Three-Tier Token Architecture

Implementation
Injecting the theme
Tier 3 — component tokens as ViewModifiers
Anti-patterns

2. Dark Mode Compliance

Prefer semantic system colors for surfaces and text
Custom brand colors need both variants
Elevation reads differently in each mode
Verify, don't assume

3. Dynamic Type Compliance

Never use a fixed point size
Layouts must reflow, not clip
Cap Dynamic Type only where it is genuinely unavoidable
Compliance checklist
Respect the other accessibility settings too

4. Materials and Liquid Glass

The one rule for any blur effect
Liquid Glass (iOS 26+, refined in iOS 27)
Availability fallback

5. Contrast Verification
Quick Reference

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Define token names
↓2
Map values to appearances
↓3
Generate shared resources
↓4
Reject literal styling

02 / ArchitectureResponsibility boundariesBoundary 1
Token sourceBoundary 2
Generated resourcesBoundary 3
View consumersConnected responsibilities, not a required class hierarchy or an execution trace.
For executable token JSON → asset catalogs and editable SVG → PNG icons, see 
asset generation
.

Load this when:
 building or reviewing an app's design system, adding a theme,
auditing dark-mode or Dynamic Type compliance, or applying glass/material
effects.

docs/design/color-system.md
 gives you 
palettes
 (hex values, gradients).
This document gives you the 
system
: how those values are structured so a single
change propagates everywhere, and how the result stays legible in dark mode, at
accessibility text sizes, and under increased-contrast settings.

1. The Three-Tier Token Architecture

Never let a raw color or number appear at a call site. Tokens flow in one
direction through three tiers:

Tier 1 — Primitive   Tier 2 — Semantic        Tier 3 — Component
(raw values)          (intent)                  (usage)
blue500  #0A84FF  →   accent                →   Button.background
gray900  #1C1C1E  →   textPrimary           →   Card.titleColor
space4   16pt     →   spacing.contentInset  →   Card.padding

Rules

Views reference 
Tier 3 or Tier 2 only
. A view that names 
blue500
 is a bug.
Tier 1 is 
private
 to the token module. It has no dark-mode variant — it is
  literally just a number.
Tier 2 is where light/dark, high-contrast, and theme switching resolve.
Adding a theme means adding one Tier 2 implementation, not editing views.

Implementation

// DesignSystem/Tokens/Primitives.swift
// Tier 1 — raw values. Never referenced from a View.
enum Primitive {
    static let blue500  = Color(hex: 0x0A84FF)
    static let blue600  = Color(hex: 0x0060DF)
    static let indigo500 = Color(hex: 0x5E5CE6)
    static let red500   = Color(hex: 0xFF3B30)
    static let green500 = Color(hex: 0x34C759)
    static let amber500 = Color(hex: 0xFF9F0A)

    // Spacing scale — a 4pt rhythm. Nothing else is permitted.
    static let space1: CGFloat = 4
    static let space2: CGFloat = 8
    static let space3: CGFloat = 12
    static let space4: CGFloat = 16
    static let space5: CGFloat = 24
    static let space6: CGFloat = 32
    static let space7: CGFloat = 48

    // Radii
    static let radiusS: CGFloat = 8
    static let radiusM: CGFloat = 16
    static let radiusL: CGFloat = 24
}

// THE canonical hex initialiser for this skill. Defined here, in the token
// layer, and nowhere else — `color-system.md` and
// `templates/common-patterns/design-system.swift` reference this file rather
// than redeclaring it. Two files declaring `init(hex: String)` with different
// bodies is not a style disagreement: copy both into one target and the
// compiler rejects it with `invalid redeclaration of 'init(hex:)'`.
extension Color {
    /// Hex as an integer literal: `Color(hex: 0x6C63FF)`.
    ///
    /// Preferred over the string form because a typo is a compile error rather
    /// than a runtime surprise — `0x6C63FZ` does not build, `"6C63FZ"` does.
    init(hex: UInt32, opacity: Double = 1) {
        self.init(
            .sRGB,
            red:   Double((hex >> 16) & 0xFF) / 255,
            green: Double((hex >>  8) & 0xFF) / 255,
            blue:  Double( hex        & 0xFF) / 255,
            opacity: opacity
        )
    }

    /// Hex as a string: `Color(hex: "6C63FF")`, with or without `#`,
    /// 6 digits (RGB) or 8 (RRGGBBAA).
    ///
    /// Exists because designers hand over strings and remote themes arrive as
    /// JSON. A malformed value traps in DEBUG and renders **magenta** in
    /// release — never black. The earlier versions of this initialiser fell
    /// back to black, which is indistinguishable from a deliberate colour and
    /// so shipped unnoticed; magenta appears nowhere in any of these palettes
    /// and is impossible to mistake for intent.
    init(hex string: String, opacity: Double = 1) {
        let cleaned = string
            .trimmingCharacters(in: .whitespacesAndNewlines)
            .replacingOccurrences(of: "#", with: "")

        var value: UInt64 = 0
        let scanned = Scanner(string: cleaned).scanHexInt64(&value)

        switch (scanned, cleaned.count) {
        case (true, 6):
            self.init(hex: UInt32(truncatingIfNeeded: value), opacity: opacity)
        case (true, 8):
            let alpha = Double(value & 0xFF) / 255
            self.init(hex: UInt32(truncatingIfNeeded: value >> 8), opacity: opacity * alpha)
        default:
            assertionFailure("Malformed hex colour literal: \(string)")
            self.init(hex: 0xFF00FF, opacity: opacity)
        }
    }
}

// DesignSystem/Tokens/Theme.swift
// Tier 2 — semantic intent. This is the swappable layer.
protocol Theme: Sendable {
    // Surfaces
    var background: Color { get }        // the page
    var surface: Color { get }           // cards, sheets
    var surfaceElevated: Color { get }   // popovers, menus

    // Content — must meet contrast against the surface it sits on
    var textPrimary: Color { get }
    var textSecondary: Color { get }
    var textOnAccent: Color { get }

    // Intent
    var accent: Color { get }
    var accentPressed: Color { get }
    var destructive: Color { get }
    var success: Color { get }
    var warning: Color { get }

    // Separators and elevation
    var separator: Color { get }
    var shadow: Color { get }
}

struct OceanTheme: Theme {
    // Apple's semantic colors already resolve light/dark AND increased contrast.
    // Prefer them for surfaces and text; reserve custom hex for brand accents.
    var background      = Color(.systemBackground)
    var surface         = Color(.secondarySystemBackground)
    var surfaceElevated = Color(.tertiarySystemBackground)

    var textPrimary   = Color(.label)
    var textSecondary = Color(.secondaryLabel)
    var textOnAccent  = Color.white

    var accent        = Primitive.blue500
    var accentPressed = Primitive.blue600
    var destructive   = Color(.systemRed)
    var success       = Color(.systemGreen)
    var warning       = Color(.systemOrange)

    var separator = Color(.separator)
    var shadow    = Color.black.opacity(0.08)
}

// DesignSystem/Tokens/Spacing.swift — Tier 2 for layout
enum Space {
    static let hairline    = Primitive.space1   // icon-to-label
    static let tight       = Primitive.space2   // within a control
    static let element     = Primitive.space3   // between related elements
    static let contentInset = Primitive.space4  // card padding, screen margins
    static let section     = Primitive.space5   // between sections
    static let major       = Primitive.space6   // above a page title
}

enum Radius {
    static let control = Primitive.radiusS      // buttons, chips
    static let card    = Primitive.radiusM      // cards, tiles
    static let sheet   = Primitive.radiusL      // modals
}

Injecting the theme

private struct ThemeKey: EnvironmentKey {
    static let defaultValue: any Theme = OceanTheme()
}

extension EnvironmentValues {
    var theme: any Theme {
        get { self[ThemeKey.self] }
        set { self[ThemeKey.self] = newValue }
    }
}

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            RootView().environment(\.theme, OceanTheme())
        }
    }
}

Tier 3 — component tokens as ViewModifiers

struct CardStyle: ViewModifier {
    @Environment(\.theme) private var theme

    func body(content: Content) -> some View {
        content
            .padding(Space.contentInset)
            .background(theme.surface, in: .rect(cornerRadius: Radius.card))
            .shadow(color: theme.shadow, radius: 8, y: 4)
    }
}

extension View {
    func cardStyle() -> some View { modifier(CardStyle()) }
}

// Usage — no raw values anywhere.
VStack(alignment: .leading, spacing: Space.element) {
    Text("Monthly total").font(.headline).foregroundStyle(theme.textPrimary)
    Text("$1,240").font(.largeTitle.bold()).foregroundStyle(theme.accent)
}
.cardStyle()

Anti-patterns

// WRONG — raw values at the call site. Changing the brand means grepping.
.padding(16)
.background(Color(red: 0.04, green: 0.52, blue: 1.0))
.cornerRadius(16)

// WRONG — a Tier 1 primitive leaking into a view.
.foregroundStyle(Primitive.blue500)

// WRONG — a semantic name that describes appearance, not intent.
var lightGray: Color { … }        // what happens in dark mode?
var textSecondary: Color { … }    // correct

// RIGHT
.padding(Space.contentInset)
.background(theme.accent, in: .rect(cornerRadius: Radius.card))

2. Dark Mode Compliance

Prefer semantic system colors for surfaces and text

They adapt to light/dark 
and
 to Increase Contrast and Reduce Transparency —
three accessibility settings for the price of one.

Role

Token

Light

Dark

Page

Color(.systemBackground)

white

black

Card

Color(.secondarySystemBackground)

light gray

dark gray

Elevated

Color(.tertiarySystemBackground)

white

lighter gray

Primary text

Color(.label)

near-black

near-white

Secondary text

Color(.secondaryLabel)

60%

60%

Divider

Color(.separator)

thin gray

thin gray

Custom brand colors need both variants

A brand accent tuned for white backgrounds usually fails on black. Define both
and resolve at render time:

extension Color {
    /// Resolves per trait collection — works in both modes without an asset catalog.
    static func adaptive(light: Color, dark: Color) -> Color {
        Color(UIColor { traits in
            traits.userInterfaceStyle == .dark ? UIColor(dark) : UIColor(light)
        })
    }
}

struct MidnightTheme: Theme {
    var accent = Color.adaptive(
        light: Primitive.blue500,   // vivid on white
        dark:  Color(hex: 0x64B5FF) // lightened so it stays visible on black
    )
    // …
}

The asset-catalog equivalent (
Color("Accent")
 with Any/Dark appearances) is
preferable when designers own the values; the code form is preferable when the
theme is swappable at runtime.

Elevation reads differently in each mode

Light mode:
 elevation = shadow. A card is white on light gray with
  
.shadow(color: theme.shadow, radius: 8, y: 4)
.
Dark mode:
 shadows are nearly invisible on black. Elevation = a 
lighter

  surface. 
secondarySystemBackground
 already does this; do not add a heavier
  shadow to compensate.

@Environment(\.colorScheme) private var scheme

.shadow(color: theme.shadow, radius: scheme == .dark ? 0 : 8, y: 4)
.overlay(                                  // a hairline stroke reads better in dark
    RoundedRectangle(cornerRadius: Radius.card)
        .strokeBorder(theme.separator, lineWidth: scheme == .dark ? 1 : 0)
)

Verify, don't assume

#Preview("Light") { ContentView().preferredColorScheme(.light) }
#Preview("Dark")  { ContentView().preferredColorScheme(.dark) }
#Preview("Increased Contrast") {
    ContentView().environment(\.colorSchemeContrast, .increased)
}

Every screen ships with all three previews. A pairing that only exists in one
mode is not done.

3. Dynamic Type Compliance

Never use a fixed point size

// WRONG — ignores the user's text size entirely.
.font(.system(size: 17))
.frame(height: 44)                       // clips at accessibility sizes

// RIGHT — semantic styles scale automatically.
.font(.headline)
.frame(minHeight: 44)                    // a floor, not a ceiling

// RIGHT — a custom font that still scales.
.font(.custom("Inter-SemiBold", size: 17, relativeTo: .headline))

Layouts must reflow, not clip

The accessibility sizes (
.accessibility1
 … 
.accessibility5
) can triple text
height. Horizontal rows must become vertical stacks.

struct StatRow: View {
    @Environment(\.dynamicTypeSize) private var typeSize
    let label: String
    let value: String

    var body: some View {
        // ViewThatFits picks the first layout that fits — no manual breakpoint.
        ViewThatFits(in: .horizontal) {
            HStack(spacing: Space.element) {
                Text(label)
                Spacer()
                Text(value).fontWeight(.semibold)
            }
            VStack(alignment: .leading, spacing: Space.hairline) {
                Text(label)
                Text(value).fontWeight(.semibold)
            }
        }
    }
}

// Or branch explicitly when the two layouts differ structurally.
if typeSize.isAccessibilitySize {
    VStack(alignment: .leading) { icon; label }
} else {
    HStack { icon; label }
}

Cap Dynamic Type only where it is genuinely unavoidable

// Acceptable: a fixed-height chart axis label or a tab bar item.
.dynamicTypeSize(...DynamicTypeSize.accessibility1)

// NOT acceptable: body copy, form fields, buttons, or list rows.
.dynamicTypeSize(.large)   // hard-pins every user to one size — never do this

Compliance checklist

[ ] No 
.font(.system(size:))
 without 
relativeTo:
.
[ ] No fixed 
.frame(height:)
 on a container holding text — use 
minHeight
.
[ ] Every 
HStack
 of label+value has a vertical fallback (
ViewThatFits
 or
      an 
isAccessibilitySize
 branch).
[ ] Icons paired with text use 
.imageScale(.medium)
 or a scaled symbol so
      they grow together.
[ ] Tap targets stay ≥ 44×44pt at every size.
[ ] Previewed at 
.xSmall
 
and
 
.accessibility5
:

#Preview("XS")  { ContentView().dynamicTypeSize(.xSmall) }
#Preview("A11y5") { ContentView().dynamicTypeSize(.accessibility5) }

Respect the other accessibility settings too

@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency

.animation(reduceMotion ? nil : .spring(duration: 0.3), value: isExpanded)
.background(reduceTransparency ? AnyShapeStyle(theme.surface)
                               : AnyShapeStyle(.ultraThinMaterial))

4. Materials and Liquid Glass

The one rule for any blur effect

A material is only correct when there is content behind it.
 Applied to a
solid background it renders as flat gray mud — the single most common way a
SwiftUI UI looks unfinished.

// WRONG — nothing behind it. This is just a muddy gray rectangle.
VStack { … }
    .background(.ultraThinMaterial)
    .background(theme.background)

// RIGHT — content scrolls beneath a floating bar.
ScrollView { content }
    .safeAreaInset(edge: .bottom) {
        HStack { … }
            .padding(Space.contentInset)
            .background(.ultraThinMaterial)
    }

Material

Blur

Use for

.ultraThinMaterial

lightest

Floating toolbars over content

.thinMaterial

light

Sheet backgrounds, overlays

.regularMaterial

medium

Sidebars, popovers

.thickMaterial

heavy

Modal scrims

.bar

system

Custom nav/tab bars

Liquid Glass (iOS 26+, refined in iOS 27)

Adopting it in an existing app is a different job.
 This section is about
applying the material to a view you own. For what an SDK rebuild changes on
its own — custom bar backgrounds that now fight the system, the scroll edge
effect, title-case section headers, layered app icons, and the

UIDesignRequiresCompatibility
 escape hatch — see

docs/design/liquid-glass-adoption.md
.

Liquid Glass is a dynamic material that refracts and reflects what is behind it
and responds to motion. It supersedes hand-rolled "glassmorphism" (a blur plus a
white stroke plus a gradient), which you should stop writing.

Availability: guard on iOS 26, not iOS 27.
 The Liquid Glass APIs
(
glassEffect
, 
GlassEffectContainer
, 
glassEffectID
, 
.buttonStyle(.glass)
)
were introduced in 
iOS 26
. iOS 27 continues and refines the design system,
but it did not reintroduce the API. Writing 
if #available(iOS 27, *)
 around

glassEffect
 would drop every iOS 26 device to the fallback path for no reason
— a silent regression for a large installed base. 
Guard on the version where
the symbol became available, never on the newest version you happen to be
building with.
 That rule holds for every API, not just this one.

if #available(iOS 26.0, *) {
    Text("Now Playing")
        .padding(Space.contentInset)
        .glassEffect()                                    // .regular, in a Capsule
}

// Shape, tint, and interactivity
.glassEffect(
    .regular
        .tint(theme.accent.opacity(0.7))
        .interactive(),                                   // reacts to touch
    in: .rect(cornerRadius: Radius.card)
)

// Buttons
Button("Play") { … }
    .buttonStyle(.glass)                                  // standard glass
Button("Subscribe") { … }
    .buttonStyle(.glassProminent)                         // accent-filled glass

Grouping and morphing.
 Sibling glass elements must live in a

GlassEffectContainer
 so they blend and merge instead of stacking blurs — each
independent 
.glassEffect()
 is a separate expensive render pass.

@available(iOS 26.0, *)
struct PlayerControls: View {
    @Namespace private var namespace
    @State private var isExpanded = false

    var body: some View {
        GlassEffectContainer(spacing: Space.tight) {
            HStack(spacing: Space.tight) {
                Button { … } label: { Image(systemName: "backward.fill") }
                    .glassEffect()
                    .glassEffectID("back", in: namespace)

                Button { isExpanded.toggle() } label: {
                    Image(systemName: isExpanded ? "pause.fill" : "play.fill")
                }
                .glassEffect()
                .glassEffectID("play", in: namespace)

                if isExpanded {
                    Button { … } label: { Image(systemName: "forward.fill") }
                        .glassEffect()
                        .glassEffectID("forward", in: namespace)
                }
            }
        }
        .animation(.spring(duration: 0.4), value: isExpanded)
    }
}

Guidelines

Glass goes on the 
navigation layer
 — floating controls, toolbars, tab bars,
  overlays. Not on content itself, and never on a whole scrolling list.
Never place glass on glass. One layer, over content.
Text on glass uses 
Color(.label)
, never a reduced opacity. The material
  already lowers effective contrast; do not lower it further.
Keep the count small. Every glass surface is a render pass; a grid of twenty
  glass cards will drop frames on older devices.

Availability fallback

Ship one modifier, branch once:

extension View {
    /// Liquid Glass on iOS 26+, an equivalent material treatment below it.
    func adaptiveGlass(cornerRadius: CGFloat = Radius.card) -> some View {
        modifier(AdaptiveGlass(cornerRadius: cornerRadius))
    }
}

private struct AdaptiveGlass: ViewModifier {
    @Environment(\.accessibilityReduceTransparency) private var reduceTransparency
    @Environment(\.theme) private var theme
    let cornerRadius: CGFloat

    func body(content: Content) -> some View {
        if reduceTransparency {
            // Accessibility wins over aesthetics — opaque surface, no blur.
            content.background(theme.surface, in: .rect(cornerRadius: cornerRadius))
        } else if #available(iOS 26.0, *) {
            content.glassEffect(.regular, in: .rect(cornerRadius: cornerRadius))
        } else {
            content
                .background(.ultraThinMaterial, in: .rect(cornerRadius: cornerRadius))
                .overlay(
                    RoundedRectangle(cornerRadius: cornerRadius)
                        .strokeBorder(.white.opacity(0.15), lineWidth: 1)
                )
        }
    }
}

5. Contrast Verification

The readability rules in 
SKILL.md
 are testable. Compute the ratio rather than
eyeballing it:

extension Color {
    /// WCAG relative luminance.
    private var relativeLuminance: Double {
        let components = UIColor(self).cgColor.components ?? [0, 0, 0]
        func channel(_ value: CGFloat) -> Double {
            let v = Double(value)
            return v <= 0.03928 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4)
        }
        return 0.2126 * channel(components[0])
             + 0.7152 * channel(components[safe: 1] ?? components[0])
             + 0.0722 * channel(components[safe: 2] ?? components[0])
    }

    /// WCAG contrast ratio, 1.0 (identical) to 21.0 (black on white).
    func contrastRatio(against other: Color) -> Double {
        let a = relativeLuminance, b = other.relativeLuminance
        let lighter = max(a, b), darker = min(a, b)
        return (lighter + 0.05) / (darker + 0.05)
    }
}

private extension Array {
    subscript(safe index: Int) -> Element? {
        indices.contains(index) ? self[index] : nil
    }
}

Then assert it in the test target so a palette change cannot regress
accessibility:

@Test("theme meets WCAG AA in both modes")
func themeContrast() {
    let theme = OceanTheme()
    #expect(theme.textPrimary.contrastRatio(against: theme.surface) >= 4.5)
    #expect(theme.textOnAccent.contrastRatio(against: theme.accent) >= 4.5)
    #expect(theme.textSecondary.contrastRatio(against: theme.surface) >= 4.5)
}

Content

Minimum ratio

Body text

4.5:1

Large text (18pt+, or 14pt bold)

3:1

UI controls, icons, focus rings

3:1

Decorative, disabled

no requirement

Quick Reference

Need

Use

Page background

theme.background → Color(.systemBackground)

Card background

theme.surface → Color(.secondarySystemBackground)

Any spacing value

Space.* — never a literal

Any corner radius

Radius.* — never a literal

Brand accent

Color.adaptive(light:dark:) or an asset-catalog color

Floating bar over content

.ultraThinMaterial, or .glassEffect() on iOS 26+

Multiple glass elements

one GlassEffectContainer

Text size

semantic styles, or .custom(_:size:relativeTo:)

Row that must reflow

ViewThatFits or typeSize.isAccessibilitySize

Verifying a pairing

contrastRatio(against:) in a test

---

# iOS Font Catalog — Ultimate Reference
https://nagarjuna2997.github.io/ios-agent-skill/guides/design-fonts-catalog.html

Design · Reference guideiOS Font Catalog — Ultimate ReferenceRepository guidance for iOS Font Catalog — Ultimate Reference. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Table of Contents
1. Apple System Fonts

SF Pro — The Default System Font
SF Pro Display vs SF Pro Text
SF Pro Rounded
SF Mono — Monospaced
SF Compact
New York — Serif Font
Width Variants (iOS 16+)
Complete System Font Design Matrix
All SwiftUI Text Styles

2. Built-in iOS Fonts

Sans-Serif Fonts

Helvetica Neue
Avenir
Avenir Next
Avenir Next Condensed
Gill Sans
Futura
Optima
Verdana
Trebuchet MS
Arial
Arial Rounded MT Bold
Academy Engraved LET
Al Nile
DIN Alternate / DIN Condensed
Helvetica

Serif Fonts

Georgia
Times New Roman
Palatino
Baskerville
Didot
Bodoni 72
Charter
Iowan Old Style
Cochin
Superclarendon

Monospaced Fonts

Courier New
Courier
Menlo
American Typewriter

Display and Decorative Fonts

Rockwell
Copperplate
Papyrus
Marker Felt
Chalkboard SE
Chalkduster
Noteworthy
Zapfino
Party LET
Savoye LET

Script and Handwriting Fonts

Snell Roundhand
Bradley Hand
Kefa
Kohinoor Telugu

Additional Built-in Fonts

Symbol / Dingbat Fonts
Additional Sans-Serif

International and Multi-script Fonts

Chinese
Japanese
Korean
Arabic
Devanagari (Hindi)
Bangla
Gujarati
Telugu
Gurmukhi (Punjabi)

3. Google Fonts -- Top 100 for iOS

Sans-Serif
Serif
Monospaced
Display
Handwriting and Script

4. How to Add Custom Fonts to iOS

Step 1: Obtain Font Files
Step 2: Add Files to Xcode Project
Step 3: Register Fonts in Info.plist
Step 4: Find the Exact Font Name
Step 5: Use in SwiftUI
Step 6: Use in UIKit
Dynamic Type with @ScaledMetric
Complete Custom Font Integration Example
Swift Package Manager Font Loading
Troubleshooting Custom Fonts

5. Font Pairing Recommendations

Pairing 1: SF Pro Display + SF Pro Text
Pairing 2: Playfair Display + Source Sans 3
Pairing 3: Montserrat + Lora
Pairing 4: Poppins + Inter
Pairing 5: Bebas Neue + Roboto
Pairing 6: DM Serif Display + DM Sans
Pairing 7: Space Grotesk + Inter
Pairing 8: Plus Jakarta Sans + Source Serif 4
Pairing 9: Outfit + Lato
Pairing 10: Oswald + Open Sans
Pairing 11: New York + SF Pro
Pairing 12: Archivo Black + Work Sans
Pairing 13: Cormorant Garamond + Montserrat
Pairing 14: Fredoka + Nunito
Pairing 15: Manrope + Merriweather

6. Font Management Utilities

FontManager: Register Custom Fonts Programmatically
App-Specific Type Scale Extension
Font Preview View
Dynamic Type Helper
List All Device Fonts (Utility Function)

7. Variable Fonts

What Are Variable Fonts?
Benefits
Common Axes
Using Variable Fonts in SwiftUI
Animating Variable Font Axes
Axis Tags Reference
Inspecting Variable Font Axes
Popular Variable Fonts for iOS
Variable Font with Dynamic Type

Quick Reference: Font Selection Decision Tree
Quick Reference: PostScript Names Cheat Sheet

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Choose a reading role
↓2
Check font license
↓3
Register bundled font
↓4
Test Dynamic Type

02 / ArchitectureResponsibility boundariesBoundary 1
Font filesBoundary 2
Typography rolesBoundary 3
Scaled textConnected responsibilities, not a required class hierarchy or an execution trace.

The definitive font reference for iOS and SwiftUI development.
Every font name listed here is the exact string to use in 
Font.custom()
 or 
UIFont(name:size:)
.

Table of Contents

Apple System Fonts
Built-in iOS Fonts
Google Fonts — Top 100 for iOS
How to Add Custom Fonts to iOS
Font Pairing Recommendations
Font Management Utilities
Variable Fonts

1. Apple System Fonts

Apple provides a family of proprietary system fonts that are optimized for every Apple platform.
These fonts are accessed through the SwiftUI 
.system()
 modifier and cannot be referenced by
PostScript name in 
Font.custom()
. They are available on-device without bundling.

SF Pro — The Default System Font

SF Pro is the default sans-serif used across iOS, iPadOS, macOS, and tvOS.
The system automatically selects 
SF Pro Text
 for sizes below 20pt and

SF Pro Display
 for sizes at 20pt and above. You never need to handle this manually.

All Weights:

Weight

UIFont.Weight

SwiftUI

Ultra Light

.ultraLight

.ultraLight

Thin

.thin

.thin

Light

.light

.light

Regular

.regular

.regular

Medium

.medium

.medium

Semibold

.semibold

.semibold

Bold

.bold

.bold

Heavy

.heavy

.heavy

Black

.black

.black

SwiftUI Examples:

// Default system font at various text styles
Text("Headline").font(.headline)
Text("Body text").font(.body)
Text("Caption").font(.caption)

// Explicit size and weight
Text("Custom").font(.system(size: 24, weight: .bold))
Text("Light text").font(.system(size: 16, weight: .light))
Text("Heavy text").font(.system(size: 32, weight: .heavy))

// Using text style with weight
Text("Title").font(.system(.title, weight: .semibold))
Text("Footnote").font(.system(.footnote, weight: .medium))

UIKit Examples:

let headline = UIFont.systemFont(ofSize: 24, weight: .bold)
let body = UIFont.systemFont(ofSize: 17, weight: .regular)
let caption = UIFont.systemFont(ofSize: 12, weight: .light)
let preferredBody = UIFont.preferredFont(forTextStyle: .body)

SF Pro Display vs SF Pro Text

The system handles optical size switching automatically:

SF Pro Text
: Optimized for sizes below 20pt. Slightly wider letter spacing, more open counters for legibility at small sizes.
SF Pro Display
: Optimized for sizes 20pt and above. Tighter spacing, refined details that shine at large sizes.

You do NOT need to select these manually. The system font API handles the switch.

// The system chooses Text or Display automatically based on size
Text("Small").font(.system(size: 14))   // Uses SF Pro Text
Text("Large").font(.system(size: 28))   // Uses SF Pro Display

SF Pro Rounded

A rounded variant of SF Pro with softer terminals. Great for friendly, approachable UIs,
settings screens, and apps aimed at younger audiences.

// SwiftUI — Rounded design
Text("Rounded").font(.system(size: 20, weight: .bold, design: .rounded))
Text("Rounded body").font(.system(.body, design: .rounded))
Text("Rounded title").font(.system(.title, design: .rounded, weight: .semibold))
Text("Rounded caption").font(.system(.caption, design: .rounded, weight: .medium))

// UIKit — Rounded design
let descriptor = UIFont.systemFont(ofSize: 20, weight: .bold).fontDescriptor
    .withDesign(.rounded)!
let roundedFont = UIFont(descriptor: descriptor, size: 20)

When to use:
 Health apps, children's apps, casual games, notification badges, friendly onboarding flows, Apple Fitness-style interfaces.

SF Mono — Monospaced

The system monospaced font. Every character occupies the same horizontal space.
Essential for code editors, terminal UIs, data tables with aligned numbers, and
countdown timers.

// SwiftUI — Monospaced design
Text("0123456789").font(.system(size: 16, weight: .regular, design: .monospaced))
Text("func hello()").font(.system(.body, design: .monospaced))
Text("Code block").font(.system(.callout, design: .monospaced, weight: .semibold))

// Monospaced digit (only digits are monospaced, letters are proportional)
Text("$1,234.56").monospacedDigit()

// UIKit
let descriptor = UIFont.systemFont(ofSize: 14, weight: .regular).fontDescriptor
    .withDesign(.monospaced)!
let monoFont = UIFont(descriptor: descriptor, size: 14)

When to use:
 Code editors, terminal emulators, data tables, timers, counters, version numbers, financial figures, developer tools.

SF Compact

Designed for Apple Watch and compact UI contexts. Narrower than SF Pro to fit more
content in constrained spaces.

// watchOS uses SF Compact automatically
// On iOS, you can access it through font descriptors if needed

When to use:
 watchOS apps (used automatically), widgets, compact UI elements.

New York — Serif Font

Apple's serif typeface. Available in four optical sizes that the system selects automatically.
Gives an editorial, literary, or premium magazine feel.

Optical Sizes:

- 
Small
: Optimized for caption and footnote sizes
- 
Medium
: Optimized for body text
- 
Large
: Optimized for titles and headlines
- 
Extra Large
: Optimized for large display text

// SwiftUI — Serif design
Text("Editorial").font(.system(size: 32, weight: .bold, design: .serif))
Text("Article body").font(.system(.body, design: .serif))
Text("Book title").font(.system(.largeTitle, design: .serif, weight: .black))
Text("Byline").font(.system(.subheadline, design: .serif, weight: .light))
Text("Pull quote").font(.system(.title2, design: .serif, weight: .semibold))

// UIKit
let descriptor = UIFont.systemFont(ofSize: 24, weight: .bold).fontDescriptor
    .withDesign(.serif)!
let serifFont = UIFont(descriptor: descriptor, size: 24)

When to use:
 News apps, book readers, editorial content, magazine layouts, Apple News-style interfaces, premium branding, literary apps.

Width Variants (iOS 16+)

Starting with iOS 16, you can access compressed, condensed, and expanded width variants
of the system font.

// SwiftUI — Width variants (iOS 16+)
Text("Compressed").font(.system(size: 20, weight: .bold).width(.compressed))
Text("Condensed").font(.system(size: 20, weight: .bold).width(.condensed))
Text("Standard").font(.system(size: 20, weight: .bold).width(.standard))
Text("Expanded").font(.system(size: 20, weight: .bold).width(.expanded))

// Combine with design
Text("Rounded Condensed")
    .font(.system(size: 20, weight: .semibold, design: .rounded).width(.condensed))

// UIKit — Width traits
var traits = UIFontDescriptor.SymbolicTraits()
let descriptor = UIFont.systemFont(ofSize: 20, weight: .bold).fontDescriptor
let condensedDescriptor = descriptor.withSymbolicTraits(.traitCondensed)!
let condensedFont = UIFont(descriptor: condensedDescriptor, size: 20)

Width options:

| Width        | Description                              |
|--------------|------------------------------------------|
| 
.compressed
| Narrowest, fits maximum content          |
| 
.condensed
 | Narrower than standard                   |
| 
.standard
  | Default width                            |
| 
.expanded
  | Wider, more spacious letterforms         |

Complete System Font Design Matrix

// All four system font designs
Text("Default").font(.system(.title, design: .default))      // SF Pro
Text("Rounded").font(.system(.title, design: .rounded))      // SF Pro Rounded
Text("Serif").font(.system(.title, design: .serif))           // New York
Text("Monospaced").font(.system(.title, design: .monospaced)) // SF Mono

All SwiftUI Text Styles

Text("Large Title")  .font(.largeTitle)    // 34pt bold
Text("Title")        .font(.title)         // 28pt regular
Text("Title 2")      .font(.title2)        // 22pt regular
Text("Title 3")      .font(.title3)        // 20pt regular
Text("Headline")     .font(.headline)      // 17pt semibold
Text("Subheadline")  .font(.subheadline)   // 15pt regular
Text("Body")         .font(.body)          // 17pt regular
Text("Callout")      .font(.callout)       // 16pt regular
Text("Footnote")     .font(.footnote)      // 13pt regular
Text("Caption")      .font(.caption)       // 12pt regular
Text("Caption 2")    .font(.caption2)      // 11pt regular

2. Built-in iOS Fonts

Every font listed below is preinstalled on iOS. The string shown is the exact PostScript name
to pass to 
Font.custom(_:size:)
 or 
UIFont(name:size:)
.

Sans-Serif Fonts

Helvetica Neue

The classic Swiss typeface. Was the iOS system font before SF Pro (iOS 8 and earlier).

Variant

Font Name

Ultra Light

HelveticaNeue-UltraLight

Ultra Light Italic

HelveticaNeue-UltraLightItalic

Thin

HelveticaNeue-Thin

Thin Italic

HelveticaNeue-ThinItalic

Light

HelveticaNeue-Light

Light Italic

HelveticaNeue-LightItalic

Regular

HelveticaNeue

Italic

HelveticaNeue-Italic

Medium

HelveticaNeue-Medium

Medium Italic

HelveticaNeue-MediumItalic

Bold

HelveticaNeue-Bold

Bold Italic

HelveticaNeue-BoldItalic

Condensed Bold

HelveticaNeue-CondensedBold

Condensed Black

HelveticaNeue-CondensedBlack

Text("Helvetica Neue").font(.custom("HelveticaNeue", size: 17))
Text("Helvetica Bold").font(.custom("HelveticaNeue-Bold", size: 17))
Text("Helvetica Light").font(.custom("HelveticaNeue-Light", size: 24))

Best for:
 Clean UI text, legacy app compatibility, neutral typography.

Avenir

A geometric sans-serif with a warm, humanist feel. Excellent readability.

Variant

Font Name

Book

Avenir-Book

Book Oblique

Avenir-BookOblique

Roman

Avenir-Roman

Oblique

Avenir-Oblique

Medium

Avenir-Medium

Medium Oblique

Avenir-MediumOblique

Heavy

Avenir-Heavy

Heavy Oblique

Avenir-HeavyOblique

Black

Avenir-Black

Black Oblique

Avenir-BlackOblique

Light

Avenir-Light

Light Oblique

Avenir-LightOblique

Text("Avenir Body").font(.custom("Avenir-Book", size: 17))
Text("Avenir Heading").font(.custom("Avenir-Heavy", size: 28))

Best for:
 Modern app UIs, lifestyle apps, friendly body text.

Avenir Next

The successor to Avenir with improved legibility and a wider weight range.

Variant

Font Name

Ultra Light

AvenirNext-UltraLight

Ultra Light Italic

AvenirNext-UltraLightItalic

Regular

AvenirNext-Regular

Italic

AvenirNext-Italic

Medium

AvenirNext-Medium

Medium Italic

AvenirNext-MediumItalic

Demi Bold

AvenirNext-DemiBold

Demi Bold Italic

AvenirNext-DemiBoldItalic

Bold

AvenirNext-Bold

Bold Italic

AvenirNext-BoldItalic

Heavy

AvenirNext-Heavy

Heavy Italic

AvenirNext-HeavyItalic

Text("Avenir Next").font(.custom("AvenirNext-Regular", size: 17))
Text("Avenir Next Bold").font(.custom("AvenirNext-Bold", size: 24))

Best for:
 Professional apps, enterprise UIs, presentations, marketing content.

Avenir Next Condensed

The condensed variant of Avenir Next. Useful when horizontal space is limited.

Variant

Font Name

Ultra Light

AvenirNextCondensed-UltraLight

Ultra Light Italic

AvenirNextCondensed-UltraLightItalic

Regular

AvenirNextCondensed-Regular

Italic

AvenirNextCondensed-Italic

Medium

AvenirNextCondensed-Medium

Medium Italic

AvenirNextCondensed-MediumItalic

Demi Bold

AvenirNextCondensed-DemiBold

Demi Bold Italic

AvenirNextCondensed-DemiBoldItalic

Bold

AvenirNextCondensed-Bold

Bold Italic

AvenirNextCondensed-BoldItalic

Heavy

AvenirNextCondensed-Heavy

Heavy Italic

AvenirNextCondensed-HeavyItalic

Text("Condensed").font(.custom("AvenirNextCondensed-Bold", size: 20))

Best for:
 Navigation bars, tab labels, space-constrained UI, tags, badges.

Gill Sans

A classic British humanist sans-serif with distinctive character.

Variant

Font Name

Regular

GillSans

Italic

GillSans-Italic

Light

GillSans-Light

Light Italic

GillSans-LightItalic

Semibold

GillSans-SemiBold

Semibold Italic

GillSans-SemiBoldItalic

Bold

GillSans-Bold

Bold Italic

GillSans-BoldItalic

Ultra Bold

GillSans-UltraBold

Text("Gill Sans").font(.custom("GillSans", size: 17))
Text("Gill Sans Bold").font(.custom("GillSans-Bold", size: 24))

Best for:
 British branding, classic design, book covers, elegant headings.

Futura

A geometric sans-serif icon. Clean circles and triangles form its letterforms.

Variant

Font Name

Medium

Futura-Medium

Medium Italic

Futura-MediumItalic

Bold

Futura-Bold

Condensed Medium

Futura-CondensedMedium

Condensed Extra Bold

Futura-CondensedExtraBold

Text("FUTURA").font(.custom("Futura-Bold", size: 32))
Text("Futura body").font(.custom("Futura-Medium", size: 17))

Best for:
 Fashion apps, bold statements, modern branding, geometric design systems.

Optima

A humanist sans-serif with subtle stroke contrast, straddling serif and sans-serif.

Variant

Font Name

Regular

Optima-Regular

Italic

Optima-Italic

Bold

Optima-Bold

Bold Italic

Optima-BoldItalic

Extra Black

Optima-ExtraBlack

Text("Optima").font(.custom("Optima-Regular", size: 17))

Best for:
 Wellness apps, spa and beauty branding, elegant body text, high-end retail.

Verdana

Designed by Matthew Carter for screen legibility. Wide letterforms, generous x-height.

Variant

Font Name

Regular

Verdana

Italic

Verdana-Italic

Bold

Verdana-Bold

Bold Italic

Verdana-BoldItalic

Text("Verdana").font(.custom("Verdana", size: 17))

Best for:
 Maximum screen readability, accessible UIs, form labels.

Trebuchet MS

A humanist sans-serif designed for web and screen use.

Variant

Font Name

Regular

TrebuchetMS

Italic

TrebuchetMS-Italic

Bold

TrebuchetMS-Bold

Bold Italic

Trebuchet-BoldItalic

Text("Trebuchet").font(.custom("TrebuchetMS", size: 17))

Best for:
 Web-style UIs, cross-platform consistency.

Arial

The ubiquitous sans-serif. Near-identical to Helvetica in metrics.

Variant

Font Name

Regular

ArialMT

Italic

Arial-ItalicMT

Bold

Arial-BoldMT

Bold Italic

Arial-BoldItalicMT

Text("Arial").font(.custom("ArialMT", size: 17))

Best for:
 Cross-platform compatibility, documents, web views.

Arial Rounded MT Bold

A rounded, friendly variant of Arial.

Variant

Font Name

Bold

ArialRoundedMTBold

Text("Rounded").font(.custom("ArialRoundedMTBold", size: 20))

Best for:
 Friendly UI elements, buttons, badges.

Academy Engraved LET

Variant

Font Name

Plain

AcademyEngravedLetPlain

Al Nile

Variant

Font Name

Regular

AlNile

Bold

AlNile-Bold

DIN Alternate / DIN Condensed

Variant

Font Name

DIN Alternate Bold

DINAlternate-Bold

DIN Condensed Bold

DINCondensed-Bold

Text("DIN").font(.custom("DINAlternate-Bold", size: 20))

Best for:
 Road signs style, technical UIs, data displays, dashboards.

Helvetica

The original Helvetica (not Neue).

Variant

Font Name

Regular

Helvetica

Italic (Oblique)

Helvetica-Oblique

Light

Helvetica-Light

Light Oblique

Helvetica-LightOblique

Bold

Helvetica-Bold

Bold Oblique

Helvetica-BoldOblique

Serif Fonts

Georgia

A screen-optimized serif with generous proportions.

Variant

Font Name

Regular

Georgia

Italic

Georgia-Italic

Bold

Georgia-Bold

Bold Italic

Georgia-BoldItalic

Text("Georgia").font(.custom("Georgia", size: 17))
Text("Georgia Bold").font(.custom("Georgia-Bold", size: 24))

Best for:
 Long-form reading, news articles, blog content, editorial apps.

Times New Roman

The classic newspaper serif.

Variant

Font Name

Regular

TimesNewRomanPSMT

Italic

TimesNewRomanPS-ItalicMT

Bold

TimesNewRomanPS-BoldMT

Bold Italic

TimesNewRomanPS-BoldItalicMT

Text("Times").font(.custom("TimesNewRomanPSMT", size: 17))

Best for:
 Document viewers, academic apps, traditional editorial.

Palatino

A Renaissance-inspired serif with wide proportions and excellent readability.

Variant

Font Name

Roman

Palatino-Roman

Italic

Palatino-Italic

Bold

Palatino-Bold

Bold Italic

Palatino-BoldItalic

Text("Palatino").font(.custom("Palatino-Roman", size: 17))

Best for:
 Book reading apps, literary content, premium editorial.

Baskerville

A transitional serif with sharp contrast and refined details.

Variant

Font Name

Regular

Baskerville

Italic

Baskerville-Italic

Semibold

Baskerville-SemiBold

Semibold Italic

Baskerville-SemiBoldItalic

Bold

Baskerville-Bold

Bold Italic

Baskerville-BoldItalic

Text("Baskerville").font(.custom("Baskerville", size: 17))
Text("Baskerville Bold").font(.custom("Baskerville-Bold", size: 28))

Best for:
 Premium branding, luxury apps, classic literature, legal documents.

Didot

A high-contrast modern serif with dramatic thick-thin strokes.

Variant

Font Name

Regular

Didot

Italic

Didot-Italic

Bold

Didot-Bold

Text("DIDOT").font(.custom("Didot-Bold", size: 36))

Best for:
 Fashion, luxury branding, magazine covers, high-end product displays.

Bodoni 72

Another high-contrast modern serif. Multiple optical variants available.

Variant

Font Name

Book

BodoniSvtyTwoITCTT-Book

Book Italic

BodoniSvtyTwoITCTT-BookIta

Bold

BodoniSvtyTwoITCTT-Bold

OS Book

BodoniSvtyTwoOSITCTT-Book

OS Book Italic

BodoniSvtyTwoOSITCTT-BookIt

OS Bold

BodoniSvtyTwoOSITCTT-Bold

SC Book

BodoniSvtyTwoSCITCTT-Book

Ornaments

BodoniOrnamentsITCTT

Text("BODONI").font(.custom("BodoniSvtyTwoITCTT-Bold", size: 36))
Text("Small Caps").font(.custom("BodoniSvtyTwoSCITCTT-Book", size: 20))

Best for:
 Fashion editorial, luxury branding, poster-style headings, high-contrast display.

Charter

A highly legible serif designed for laser printers and screens.

Variant

Font Name

Roman

Charter-Roman

Italic

Charter-Italic

Bold

Charter-Bold

Bold Italic

Charter-BoldItalic

Black

Charter-Black

Black Italic

Charter-BlackItalic

Text("Charter").font(.custom("Charter-Roman", size: 17))

Best for:
 Long-form reading, documentation, e-books, RSS readers.

Iowan Old Style

A refined old-style serif optimized for extended reading on screens.

Variant

Font Name

Roman

IowanOldStyle-Roman

Italic

IowanOldStyle-Italic

Bold

IowanOldStyle-Bold

Bold Italic

IowanOldStyle-BoldItalic

Text("Iowan Old Style").font(.custom("IowanOldStyle-Roman", size: 17))

Best for:
 Apple Books-style reading, literary apps, elegant body text.

Cochin

An elegant old-style serif.

Variant

Font Name

Regular

Cochin

Italic

Cochin-Italic

Bold

Cochin-Bold

Bold Italic

Cochin-BoldItalic

Superclarendon

A bold slab serif with strong visual impact.

Variant

Font Name

Regular

Superclarendon-Regular

Italic

Superclarendon-Italic

Light

Superclarendon-Light

Light Italic

Superclarendon-LightItalic

Bold

Superclarendon-Bold

Bold Italic

Superclarendon-BoldItalic

Black

Superclarendon-Black

Black Italic

Superclarendon-BlackItalic

Monospaced Fonts

Courier New

The classic typewriter monospaced font.

Variant

Font Name

Regular

CourierNewPSMT

Italic

CourierNewPS-ItalicMT

Bold

CourierNewPS-BoldMT

Bold Italic

CourierNewPS-BoldItalicMT

Text("Courier").font(.custom("CourierNewPSMT", size: 14))

Best for:
 Retro terminal UIs, typewriter aesthetic, screenplay formatters.

Courier

The original Courier (slightly different from Courier New).

Variant

Font Name

Regular

Courier

Oblique

Courier-Oblique

Bold

Courier-Bold

Bold Oblique

Courier-BoldOblique

Menlo

A monospaced font based on Bitstream Vera Sans Mono. The default Xcode font before SF Mono.

Variant

Font Name

Regular

Menlo-Regular

Italic

Menlo-Italic

Bold

Menlo-Bold

Bold Italic

Menlo-BoldItalic

Text("func main()").font(.custom("Menlo-Regular", size: 14))
Text("// Bold comment").font(.custom("Menlo-Bold", size: 14))

Best for:
 Code display, terminal emulators, developer tools, log viewers.

American Typewriter

A monospaced font with typewriter charm and serifs.

Variant

Font Name

Regular

AmericanTypewriter

Light

AmericanTypewriter-Light

Semibold

AmericanTypewriter-Semibold

Bold

AmericanTypewriter-Bold

Condensed

AmericanTypewriter-Condensed

Condensed Light

AmericanTypewriter-CondensedLight

Condensed Bold

AmericanTypewriter-CondensedBold

Text("Typewriter").font(.custom("AmericanTypewriter", size: 17))

Best for:
 Note-taking apps, journal/diary UIs, retro aesthetics, creative writing tools.

Display and Decorative Fonts

Rockwell

A geometric slab serif with strong presence.

Variant

Font Name

Regular

Rockwell-Regular

Italic

Rockwell-Italic

Bold

Rockwell-Bold

Bold Italic

Rockwell-BoldItalic

Text("ROCKWELL").font(.custom("Rockwell-Bold", size: 32))

Best for:
 Bold headings, poster-style layouts, strong brand statements.

Copperplate

An all-caps engraved style font with small-cap lowercase.

Variant

Font Name

Regular

Copperplate

Light

Copperplate-Light

Bold

Copperplate-Bold

Text("COPPERPLATE").font(.custom("Copperplate-Bold", size: 24))

Best for:
 Formal invitations, certificates, luxury branding, restaurant menus.

Papyrus

A distressed, hand-drawn style font.

Variant

Font Name

Regular

Papyrus

Condensed

Papyrus-Condensed

Text("Papyrus").font(.custom("Papyrus", size: 20))

Best for:
 Themed apps (ancient, natural), generally avoid for professional UIs.

Marker Felt

A felt-tip marker style font.

Variant

Font Name

Thin

MarkerFelt-Thin

Wide

MarkerFelt-Wide

Text("Marker Felt").font(.custom("MarkerFelt-Thin", size: 20))

Best for:
 Whiteboard UIs, sketching apps, children's content.

Chalkboard SE

A clean chalkboard-style handwriting font.

Variant

Font Name

Regular

ChalkboardSE-Regular

Light

ChalkboardSE-Light

Bold

ChalkboardSE-Bold

Text("Chalkboard").font(.custom("ChalkboardSE-Regular", size: 17))

Best for:
 Education apps, children's apps, informal notes.

Chalkduster

A rougher chalkboard-style font.

Variant

Font Name

Regular

Chalkduster

Text("Chalkduster").font(.custom("Chalkduster", size: 20))

Noteworthy

A casual handwriting font.

Variant

Font Name

Light

Noteworthy-Light

Bold

Noteworthy-Bold

Text("Noteworthy").font(.custom("Noteworthy-Light", size: 17))

Best for:
 Personal notes, diary entries, sticky note UIs.

Zapfino

An elaborate calligraphic script with extreme flourishes.

Variant

Font Name

Regular

Zapfino

Text("Zapfino").font(.custom("Zapfino", size: 24))

Best for:
 Decorative headers only, wedding apps, formal invitations (use sparingly).

Party LET

A festive, playful display font.

Variant

Font Name

Plain

PartyLetPlain

Savoye LET

An elegant script font.

Variant

Font Name

Plain

SavoyeLetPlain

Text("Savoye LET").font(.custom("SavoyeLetPlain", size: 28))

Best for:
 Elegant signatures, wedding invitations, formal flourishes.

Script and Handwriting Fonts

Snell Roundhand

A flowing copperplate script with graceful strokes.

Variant

Font Name

Regular

SnellRoundhand

Bold

SnellRoundhand-Bold

Black

SnellRoundhand-Black

Text("Elegant Script").font(.custom("SnellRoundhand", size: 24))

Best for:
 Formal invitations, signatures, elegant accents.

Bradley Hand

A casual handwriting style.

Variant

Font Name

Bold

BradleyHandITCTT-Bold

Text("Handwritten").font(.custom("BradleyHandITCTT-Bold", size: 17))

Best for:
 Personal touches, note-style UIs, casual annotations.

Kefa

Variant

Font Name

Regular

Kefa-Regular

Kohinoor Telugu

Variant

Font Name

Regular

KohinoorTelugu-Regular

Medium

KohinoorTelugu-Medium

Light

KohinoorTelugu-Light

Additional Built-in Fonts

Symbol / Dingbat Fonts

Font Family

Font Name

Description

Symbol

Symbol

Greek and math symbols

Zapf Dingbats

ZapfDingbatsITC

Decorative symbols

Additional Sans-Serif

Variant

Font Name

Euphemia UCAS

EuphemiaUCAS

Euphemia UCAS Bold

EuphemiaUCAS-Bold

Euphemia UCAS Italic

EuphemiaUCAS-Italic

Galvji

Galvji

Galvji Bold

Galvji-Bold

Galvji Bold Oblique

Galvji-BoldOblique

Galvji Oblique

Galvji-Oblique

Grantha Sangam MN

GranthaSangamMN-Regular

Grantha Sangam MN Bold

GranthaSangamMN-Bold

Hoefler Text

HoeflerText-Regular

Hoefler Text Italic

HoeflerText-Italic

Hoefler Text Bold

HoeflerText-Black

Hoefler Text Bold Italic

HoeflerText-BlackItalic

Kailasa

Kailasa

Kailasa Bold

Kailasa-Bold

Khmer Sangam MN

KhmerSangamMN

Lao Sangam MN

LaoSangamMN

Malayalam Sangam MN

MalayalamSangamMN

Malayalam Sangam MN Bold

MalayalamSangamMN-Bold

Myanmar Sangam MN

MyanmarSangamMN

Myanmar Sangam MN Bold

MyanmarSangamMN-Bold

Noto Nastaliq Urdu

NotoNastaliqUrdu

Noto Nastaliq Urdu Bold

NotoNastaliqUrdu-Bold

Noto Sans Kannada

NotoSansKannada-Regular

Noto Sans Kannada Bold

NotoSansKannada-Bold

Noto Sans Kannada Light

NotoSansKannada-Light

Noto Sans Myanmar

NotoSansMyanmar-Regular

Noto Sans Myanmar Bold

NotoSansMyanmar-Bold

Noto Sans Myanmar Light

NotoSansMyanmar-Light

Noto Sans Oriya

NotoSansOriya

Noto Sans Oriya Bold

NotoSansOriya-Bold

Sinhala Sangam MN

SinhalaSangamMN

Sinhala Sangam MN Bold

SinhalaSangamMN-Bold

Tamil Sangam MN

TamilSangamMN

Tamil Sangam MN Bold

TamilSangamMN-Bold

Thonburi

Thonburi

Thonburi Light

Thonburi-Light

Thonburi Bold

Thonburi-Bold

International and Multi-script Fonts

Chinese

Variant

Font Name

Script

PingFang SC Regular

PingFangSC-Regular

Simplified Chinese

PingFang SC Medium

PingFangSC-Medium

Simplified Chinese

PingFang SC Semibold

PingFangSC-Semibold

Simplified Chinese

PingFang SC Light

PingFangSC-Light

Simplified Chinese

PingFang SC Thin

PingFangSC-Thin

Simplified Chinese

PingFang SC Ultralight

PingFangSC-Ultralight

Simplified Chinese

PingFang TC Regular

PingFangTC-Regular

Traditional Chinese

PingFang TC Medium

PingFangTC-Medium

Traditional Chinese

PingFang TC Semibold

PingFangTC-Semibold

Traditional Chinese

PingFang TC Light

PingFangTC-Light

Traditional Chinese

PingFang TC Thin

PingFangTC-Thin

Traditional Chinese

PingFang TC Ultralight

PingFangTC-Ultralight

Traditional Chinese

PingFang HK Regular

PingFangHK-Regular

Hong Kong Chinese

PingFang HK Medium

PingFangHK-Medium

Hong Kong Chinese

PingFang HK Semibold

PingFangHK-Semibold

Hong Kong Chinese

PingFang HK Light

PingFangHK-Light

Hong Kong Chinese

PingFang HK Thin

PingFangHK-Thin

Hong Kong Chinese

PingFang HK Ultralight

PingFangHK-Ultralight

Hong Kong Chinese

Japanese

Variant

Font Name

Hiragino Sans W3

HiraginoSans-W3

Hiragino Sans W6

HiraginoSans-W6

Hiragino Sans W7

HiraginoSans-W7

Hiragino Mincho ProN W3

HiraMinProN-W3

Hiragino Mincho ProN W6

HiraMinProN-W6

Text("Japanese text").font(.custom("HiraginoSans-W3", size: 17))

Korean

Variant

Font Name

Apple SD Gothic Neo Regular

AppleSDGothicNeo-Regular

Apple SD Gothic Neo Thin

AppleSDGothicNeo-Thin

Apple SD Gothic Neo UltraLight

AppleSDGothicNeo-UltraLight

Apple SD Gothic Neo Light

AppleSDGothicNeo-Light

Apple SD Gothic Neo Medium

AppleSDGothicNeo-Medium

Apple SD Gothic Neo Semibold

AppleSDGothicNeo-SemiBold

Apple SD Gothic Neo Bold

AppleSDGothicNeo-Bold

Text("Korean text").font(.custom("AppleSDGothicNeo-Regular", size: 17))

Arabic

Variant

Font Name

Geeza Pro Regular

GeezaPro

Geeza Pro Bold

GeezaPro-Bold

Mishafi Regular

DiwanMishafi

Baghdad Regular

Baghdad

Farah

Farah

Damascus

Damascus

Damascus Light

DamascusLight

Damascus Medium

DamascusMedium

Damascus Semibold

DamascusSemiBold

Damascus Bold

DamascusBold

Devanagari (Hindi)

Variant

Font Name

Kohinoor Devanagari Regular

KohinoorDevanagari-Regular

Kohinoor Devanagari Light

KohinoorDevanagari-Light

Kohinoor Devanagari Semibold

KohinoorDevanagari-Semibold

Devanagari Sangam MN

DevanagariSangamMN

Devanagari Sangam MN Bold

DevanagariSangamMN-Bold

Bangla

Variant

Font Name

Kohinoor Bangla Regular

KohinoorBangla-Regular

Kohinoor Bangla Light

KohinoorBangla-Light

Kohinoor Bangla Semibold

KohinoorBangla-Semibold

Gujarati

Variant

Font Name

Kohinoor Gujarati Regular

KohinoorGujarati-Regular

Kohinoor Gujarati Light

KohinoorGujarati-Light

Kohinoor Gujarati Bold

KohinoorGujarati-Bold

Gujarati Sangam MN

GujaratiSangamMN

Gujarati Sangam MN Bold

GujaratiSangamMN-Bold

Telugu

Variant

Font Name

Kohinoor Telugu Regular

KohinoorTelugu-Regular

Kohinoor Telugu Medium

KohinoorTelugu-Medium

Kohinoor Telugu Light

KohinoorTelugu-Light

Gurmukhi (Punjabi)

Variant

Font Name

Mukta Mahee Regular

MuktaMahee-Regular

Mukta Mahee Light

MuktaMahee-Light

Mukta Mahee Bold

MuktaMahee-Bold

Gurmukhi MN

GurmukhiMN

Gurmukhi MN Bold

GurmukhiMN-Bold

3. Google Fonts -- Top 100 for iOS

These are the most popular Google Fonts used in iOS apps. To use any of these, you must
download the font files and add them to your Xcode project (see Section 4).

Sans-Serif

#

Font Name

Weights Available

Best Use Case

1

Inter

100-900

UI text, dashboards, SaaS

2

Roboto

100, 300, 400, 500, 700, 900

Material Design, Android parity

3

Open Sans

300-800

Universal body text

4

Lato

100, 300, 400, 700, 900

Friendly body text, corporate

5

Montserrat

100-900

Modern headings, marketing

6

Poppins

100-900

SaaS, startup, geometric UI

7

Nunito

200-900

Rounded, friendly, children's apps

8

Raleway

100-900

Elegant headings, fashion

9

Source Sans 3

200-900

Technical docs, code-adjacent text

10

Work Sans

100-900

Clean UI, editorial

11

DM Sans

100-900

Minimal UI, dashboard

12

Manrope

200-800

Tech products, developer tools

13

Plus Jakarta Sans

200-800

Fintech, professional

14

Space Grotesk

300-700

Tech, developer, coding apps

15

Outfit

100-900

Modern branding, startup

16

Sora

100-800

Futuristic, crypto, web3

17

Urbanist

100-900

Modern geometric, luxury tech

18

Lexend

100-900

Accessibility, dyslexia-friendly

19

Albert Sans

100-900

Geometric, versatile UI

20

Figtree

300-900

Friendly, approachable UI

21

Geist

100-900

Vercel-style, developer UI

22

Satoshi

300-900

Minimal, modern branding

23

Nunito Sans

200-900

UI text, clean dashboards

24

Karla

200-800

Grotesque, editorial

25

Rubik

300-900

Rounded, playful

26

Barlow

100-900

Industrial, technical

27

Mulish

200-900

Clean, minimal

28

Quicksand

300-700

Rounded, friendly

29

Cabin

400-700

Humanist, warm

30

Josefin Sans

100-700

Elegant, geometric

31

PT Sans

400, 700

Universal text, multilingual

32

Noto Sans

100-900

Global multilingual support

33

Overpass

100-900

Highway signage inspired, clean

34

IBM Plex Sans

100-700

Enterprise, corporate, IBM design

35

Red Hat Display

300-900

Open source branding

36

Exo 2

100-900

Futuristic, geometric

37

Archivo

100-900

Bold headings, editorial

38

Hind

300-700

Devanagari + Latin body text

39

Public Sans

100-900

Government, accessible

40

General Sans

200-700

Modern grotesque, branding

Serif

#

Font Name

Weights Available

Best Use Case

41

Playfair Display

400-900

Editorial headings, magazine

42

Merriweather

300, 400, 700, 900

Long-form reading, blogs

43

Lora

400-700

Book text, literary

44

Source Serif 4

200-900

Technical docs, paired with Source Sans

45

Crimson Text

400, 600, 700

Book text, classic reading

46

Libre Baskerville

400, 700

Elegant body text, traditional

47

EB Garamond

400-800

Book typography, literary

48

Cormorant Garamond

300-700

High fashion, luxury headings

49

DM Serif Display

400

Bold editorial headings

50

Bitter

100-900

Screen reading, warm serif

51

Noto Serif

100-900

Global multilingual serif

52

PT Serif

400, 700

Multilingual reading

53

Spectral

200-800

Long-form, screen-optimized

54

Vollkorn

400-900

Warm, organic reading

55

Fraunces

100-900

Playful serif, retro

56

Instrument Serif

400

Minimal editorial

57

Newsreader

200-800

News, journalism

Monospaced

#

Font Name

Weights Available

Best Use Case

58

Fira Code

300-700

Code editor with ligatures

59

JetBrains Mono

100-800

IDE, code editor, terminal

60

Source Code Pro

200-900

Code display, terminal

61

IBM Plex Mono

100-700

Enterprise code, terminal

62

Space Mono

400, 700

Futuristic, retro-tech

63

Roboto Mono

100-700

Data tables, code blocks

64

Inconsolata

200-900

Code display, clean mono

65

Ubuntu Mono

400, 700

Linux-style terminal

66

Cascadia Code

200-700

Windows Terminal-style

67

Geist Mono

100-900

Vercel developer tools

68

Anonymous Pro

400, 700

Coding, terminal

69

Overpass Mono

300-700

Data, tables

70

Red Hat Mono

300-700

Enterprise code

Display

#

Font Name

Weights Available

Best Use Case

71

Bebas Neue

400

Bold headings, posters

72

Oswald

200-700

Condensed headings, news

73

Anton

400

Impact headings, bold statements

74

Archivo Black

400

Ultra bold display

75

Righteous

400

Retro, rounded display

76

Fredoka

300-700

Playful, children's content

77

Lilita One

400

Fun, casual headings

78

Passion One

400, 700, 900

Sports, energetic

79

Bungee

400

Signage, athletic

80

Abril Fatface

400

High-contrast display

81

Alfa Slab One

400

Slab serif display

82

Lobster

400

Retro script display

83

Permanent Marker

400

Hand-drawn, casual

84

Bangers

400

Comic book, energetic

85

Staatliches

400

Display, condensed sans

86

Comfortaa

300-700

Rounded, futuristic

87

Russo One

400

Tech, gaming

88

Press Start 2P

400

Pixel art, retro gaming

89

Orbitron

400-900

Sci-fi, space, futuristic

90

Teko

300-700

Sports, condensed display

Handwriting and Script

#

Font Name

Weights Available

Best Use Case

91

Dancing Script

400-700

Casual elegance, invitations

92

Pacifico

400

Retro surf, casual branding

93

Caveat

400-700

Handwritten notes, annotations

94

Sacramento

400

Elegant script, signatures

95

Great Vibes

400

Formal calligraphy

96

Satisfy

400

Retro script

97

Kalam

300, 400, 700

Informal handwriting, notes

98

Patrick Hand

400

Casual handwriting

99

Indie Flower

400

Playful handwriting, fun

100

Amatic SC

400, 700

Tall, narrow handwriting

4. How to Add Custom Fonts to iOS

Step 1: Obtain Font Files

Download 
.ttf
 (TrueType) or 
.otf
 (OpenType) font files. For Google Fonts,
download from https://fonts.google.com or use a package manager.

Step 2: Add Files to Xcode Project

Drag the font files into your Xcode project navigator.
In the dialog that appears, check 
"Copy items if needed"
.
Ensure 
"Add to targets"
 has your app target checked.
Verify the files appear under 
Build Phases > Copy Bundle Resources
.

Step 3: Register Fonts in Info.plist

Add the font file names to your 
Info.plist
:

<key>UIAppFonts</key>
<array>
    <string>Inter-Regular.ttf</string>
    <string>Inter-Medium.ttf</string>
    <string>Inter-Bold.ttf</string>
    <string>PlayfairDisplay-Bold.ttf</string>
    <string>PlayfairDisplay-Regular.ttf</string>
</array>

Or in the Xcode Info tab, add a row:
- Key: 
Fonts provided by application

- Type: Array
- Items: each font filename (including extension)

Step 4: Find the Exact Font Name

The filename is NOT always the font name. Use this code to discover exact PostScript names:

// Run this once at app launch to print all available fonts
for family in UIFont.familyNames.sorted() {
    print("Family: \(family)")
    for name in UIFont.fontNames(forFamilyName: family) {
        print("  -- \(name)")
    }
}

Or find a specific family:

// Check a specific font family
let names = UIFont.fontNames(forFamilyName: "Inter")
print(names) // ["Inter-Regular", "Inter-Medium", "Inter-Bold", ...]

Step 5: Use in SwiftUI

// Basic usage
Text("Custom Font").font(.custom("Inter-Regular", size: 17))
Text("Bold Custom").font(.custom("Inter-Bold", size: 24))

// With Dynamic Type support (RECOMMENDED)
Text("Dynamic Type").font(.custom("Inter-Regular", size: 17, relativeTo: .body))
Text("Dynamic Title").font(.custom("Inter-Bold", size: 28, relativeTo: .title))
Text("Dynamic Caption").font(.custom("Inter-Regular", size: 12, relativeTo: .caption))

// Fixed size (does not scale with Dynamic Type)
Text("Fixed Size").font(.custom("Inter-Regular", fixedSize: 14))

Step 6: Use in UIKit

// Basic usage
let font = UIFont(name: "Inter-Regular", size: 17)

// With Dynamic Type metrics
let customFont = UIFont(name: "Inter-Regular", size: 17)!
let scaledFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: customFont)
label.font = scaledFont
label.adjustsFontForContentSizeCategory = true

Dynamic Type with @ScaledMetric

Use 
@ScaledMetric
 for custom font sizes that scale with Dynamic Type settings:

struct ContentView: View {
    @ScaledMetric(relativeTo: .body) var bodySize: CGFloat = 17
    @ScaledMetric(relativeTo: .title) var titleSize: CGFloat = 28
    @ScaledMetric(relativeTo: .caption) var captionSize: CGFloat = 12
    @ScaledMetric var iconSize: CGFloat = 24

    var body: some View {
        VStack {
            Text("Title")
                .font(.custom("PlayfairDisplay-Bold", size: titleSize))
            Text("Body text here")
                .font(.custom("Inter-Regular", size: bodySize))
            Text("Caption")
                .font(.custom("Inter-Regular", size: captionSize))
            Image(systemName: "star.fill")
                .font(.system(size: iconSize))
        }
    }
}

Complete Custom Font Integration Example

// App entry point - register fonts
@main
struct MyApp: App {
    init() {
        // Fonts are auto-registered via Info.plist
        // But you can verify they loaded:
        #if DEBUG
        if UIFont(name: "Inter-Regular", size: 17) == nil {
            print("WARNING: Inter-Regular font not found. Check Info.plist and bundle.")
        }
        #endif
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Swift Package Manager Font Loading

If your fonts come from a Swift Package:

import SwiftUI

extension Font {
    static func registerFontsFromBundle(bundle: Bundle) {
        let fontURLs = bundle.urls(forResourcesWithExtension: "ttf", subdirectory: nil) ?? []
        let otfURLs = bundle.urls(forResourcesWithExtension: "otf", subdirectory: nil) ?? []

        for url in fontURLs + otfURLs {
            CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil)
        }
    }
}

Troubleshooting Custom Fonts

Problem

Solution

Font not appearing

Check Info.plist entry matches exact filename

Wrong weight showing

Use PostScript name, not display name

Font not in bundle

Verify target membership in File Inspector

Dynamic Type not working

Use relativeTo: parameter in Font.custom()

Font looks different on device

Simulator and device may render differently; always test both

5. Font Pairing Recommendations

Fifteen proven font pairings for iOS apps. Each pairing includes a heading font,
a body font, the design context, and ready-to-use SwiftUI code.

Pairing 1: SF Pro Display + SF Pro Text

Style:
 System default, clean, Apple-native

Use case:
 Any iOS app that wants to feel native and polished

VStack(alignment: .leading, spacing: 8) {
    Text("Welcome Back")
        .font(.system(size: 34, weight: .bold))
    Text("Here is what happened while you were away. Your projects have new updates and your team left comments.")
        .font(.system(size: 17, weight: .regular))
        .foregroundStyle(.secondary)
}

Pairing 2: Playfair Display + Source Sans 3

Style:
 Editorial, magazine

Use case:
 News apps, editorial content, blog readers

VStack(alignment: .leading, spacing: 8) {
    Text("The Art of Typography")
        .font(.custom("PlayfairDisplay-Bold", size: 32, relativeTo: .largeTitle))
    Text("Typography is the art and technique of arranging type to make written language legible, readable, and appealing when displayed.")
        .font(.custom("SourceSans3-Regular", size: 17, relativeTo: .body))
        .foregroundStyle(.secondary)
}

Pairing 3: Montserrat + Lora

Style:
 Modern heading with classic body

Use case:
 Portfolio apps, creative agencies, lifestyle brands

VStack(alignment: .leading, spacing: 8) {
    Text("CREATIVE STUDIO")
        .font(.custom("Montserrat-Bold", size: 28, relativeTo: .title))
        .tracking(2)
    Text("We craft digital experiences that blend innovation with timeless design principles.")
        .font(.custom("Lora-Regular", size: 17, relativeTo: .body))
}

Pairing 4: Poppins + Inter

Style:
 SaaS, modern dashboard

Use case:
 Productivity apps, dashboards, admin panels, B2B tools

VStack(alignment: .leading, spacing: 8) {
    Text("Dashboard Overview")
        .font(.custom("Poppins-SemiBold", size: 24, relativeTo: .title2))
    Text("Your key metrics are performing above average this quarter with a 23% increase in engagement.")
        .font(.custom("Inter-Regular", size: 15, relativeTo: .body))
        .foregroundStyle(.secondary)
}

Pairing 5: Bebas Neue + Roboto

Style:
 Bold impact with clean body

Use case:
 Sports apps, fitness trackers, event apps, bold branding

VStack(alignment: .leading, spacing: 4) {
    Text("GAME DAY")
        .font(.custom("BebasNeue-Regular", size: 48, relativeTo: .largeTitle))
    Text("Get ready for tonight's matchup. Here are the stats, lineups, and predictions you need.")
        .font(.custom("Roboto-Regular", size: 16, relativeTo: .body))
        .foregroundStyle(.secondary)
}

Pairing 6: DM Serif Display + DM Sans

Style:
 Luxury, minimal elegance

Use case:
 Premium products, luxury e-commerce, high-end hospitality

VStack(alignment: .leading, spacing: 8) {
    Text("Curated Collection")
        .font(.custom("DMSerifDisplay-Regular", size: 32, relativeTo: .largeTitle))
    Text("Each piece in our collection has been carefully selected for its craftsmanship and timeless appeal.")
        .font(.custom("DMSans-Regular", size: 16, relativeTo: .body))
        .foregroundStyle(.secondary)
}

Pairing 7: Space Grotesk + Inter

Style:
 Tech, developer-focused

Use case:
 Developer tools, coding apps, API documentation

VStack(alignment: .leading, spacing: 8) {
    Text("API Reference")
        .font(.custom("SpaceGrotesk-Bold", size: 28, relativeTo: .title))
    Text("Explore our comprehensive API documentation with interactive examples and detailed guides.")
        .font(.custom("Inter-Regular", size: 15, relativeTo: .body))
        .foregroundStyle(.secondary)
}

Pairing 8: Plus Jakarta Sans + Source Serif 4

Style:
 Professional, trustworthy

Use case:
 Fintech, banking, insurance, professional services

VStack(alignment: .leading, spacing: 8) {
    Text("Financial Summary")
        .font(.custom("PlusJakartaSans-Bold", size: 24, relativeTo: .title2))
    Text("Your portfolio has grown 12.4% this quarter, outperforming the market benchmark by 3.2 percentage points.")
        .font(.custom("SourceSerif4-Regular", size: 16, relativeTo: .body))
        .foregroundStyle(.secondary)
}

Pairing 9: Outfit + Lato

Style:
 Startup, approachable

Use case:
 Startup landing pages, onboarding flows, consumer apps

VStack(alignment: .leading, spacing: 8) {
    Text("Get Started")
        .font(.custom("Outfit-SemiBold", size: 28, relativeTo: .title))
    Text("Set up your profile in just a few steps and start connecting with people who share your interests.")
        .font(.custom("Lato-Regular", size: 16, relativeTo: .body))
        .foregroundStyle(.secondary)
}

Pairing 10: Oswald + Open Sans

Style:
 News, content-heavy

Use case:
 News readers, content aggregators, media apps

VStack(alignment: .leading, spacing: 8) {
    Text("BREAKING NEWS")
        .font(.custom("Oswald-Bold", size: 28, relativeTo: .title))
    Text("Markets rally as economic indicators show stronger-than-expected growth in the third quarter.")
        .font(.custom("OpenSans-Regular", size: 16, relativeTo: .body))
        .foregroundStyle(.secondary)
}

Pairing 11: New York + SF Pro

Style:
 Apple editorial

Use case:
 Apple News-style apps, premium editorial, literary content

VStack(alignment: .leading, spacing: 8) {
    Text("The Future of Design")
        .font(.system(size: 32, weight: .bold, design: .serif))
    Text("As technology evolves, so does the language of design. New tools and paradigms are reshaping how we create.")
        .font(.system(size: 17, weight: .regular))
        .foregroundStyle(.secondary)
}

Pairing 12: Archivo Black + Work Sans

Style:
 Bold, sporty

Use case:
 Sports brands, fitness apps, bold product pages

VStack(alignment: .leading, spacing: 4) {
    Text("PUSH LIMITS")
        .font(.custom("ArchivoBlack-Regular", size: 36, relativeTo: .largeTitle))
    Text("Track your workouts, set new records, and compete with friends in weekly challenges.")
        .font(.custom("WorkSans-Regular", size: 16, relativeTo: .body))
        .foregroundStyle(.secondary)
}

Pairing 13: Cormorant Garamond + Montserrat

Style:
 High fashion, luxury

Use case:
 Fashion apps, luxury brands, art galleries, premium retail

VStack(alignment: .leading, spacing: 12) {
    Text("Autumn Collection")
        .font(.custom("CormorantGaramond-SemiBold", size: 36, relativeTo: .largeTitle))
    Text("EXPLORE THE LATEST ARRIVALS")
        .font(.custom("Montserrat-Medium", size: 12, relativeTo: .caption))
        .tracking(3)
        .foregroundStyle(.secondary)
}

Pairing 14: Fredoka + Nunito

Style:
 Kids, playful

Use case:
 Children's apps, educational games, family content

VStack(alignment: .leading, spacing: 8) {
    Text("Let's Learn!")
        .font(.custom("Fredoka-SemiBold", size: 32, relativeTo: .largeTitle))
    Text("Choose a fun activity below and start your learning adventure today.")
        .font(.custom("Nunito-Regular", size: 17, relativeTo: .body))
        .foregroundStyle(.secondary)
}

Pairing 15: Manrope + Merriweather

Style:
 Blog, long-form reading

Use case:
 Blog readers, RSS apps, knowledge bases, documentation

VStack(alignment: .leading, spacing: 8) {
    Text("Understanding Swift Concurrency")
        .font(.custom("Manrope-Bold", size: 24, relativeTo: .title2))
    Text("Swift concurrency introduces structured approaches to writing asynchronous code, making it safer and more readable than traditional callback patterns.")
        .font(.custom("Merriweather-Regular", size: 16, relativeTo: .body))
        .foregroundStyle(.secondary)
}

6. Font Management Utilities

FontManager: Register Custom Fonts Programmatically

import SwiftUI
import CoreText

final class FontManager {
    static let shared = FontManager()

    private var registeredFonts: Set<String> = []

    private init() {}

    /// Register all custom fonts from the main bundle.
    /// Call this in your App init or AppDelegate.
    func registerAllFonts() {
        registerFonts(from: .main, extensions: ["ttf", "otf"])
    }

    /// Register fonts from a specific bundle (useful for SPM packages).
    func registerFonts(from bundle: Bundle, extensions: [String] = ["ttf", "otf"]) {
        for ext in extensions {
            guard let urls = bundle.urls(forResourcesWithExtension: ext, subdirectory: nil) else {
                continue
            }
            for url in urls {
                registerFont(at: url)
            }
        }
    }

    /// Register a single font file by URL.
    func registerFont(at url: URL) {
        let fontName = url.lastPathComponent
        guard !registeredFonts.contains(fontName) else { return }

        var error: Unmanaged<CFError>?
        let success = CTFontManagerRegisterFontsForURL(url as CFURL, .process, &error)

        if success {
            registeredFonts.insert(fontName)
        } else if let error = error?.takeRetainedValue() {
            print("Failed to register font \(fontName): \(error)")
        }
    }

    /// Print all available font families and their font names (debug utility).
    func printAllFonts() {
        for family in UIFont.familyNames.sorted() {
            print("Family: \(family)")
            for name in UIFont.fontNames(forFamilyName: family).sorted() {
                print("  \(name)")
            }
        }
    }

    /// Check if a specific font is available.
    func isFontAvailable(_ fontName: String) -> Bool {
        return UIFont(name: fontName, size: 12) != nil
    }
}

App-Specific Type Scale Extension

Define a consistent type scale for your entire app:

import SwiftUI

extension Font {
    // MARK: - Display
    static let appDisplayLarge = Font.custom("Inter-Bold", size: 40, relativeTo: .largeTitle)
    static let appDisplayMedium = Font.custom("Inter-Bold", size: 34, relativeTo: .largeTitle)
    static let appDisplaySmall = Font.custom("Inter-SemiBold", size: 28, relativeTo: .title)

    // MARK: - Headings
    static let appHeading1 = Font.custom("Inter-Bold", size: 24, relativeTo: .title2)
    static let appHeading2 = Font.custom("Inter-SemiBold", size: 20, relativeTo: .title3)
    static let appHeading3 = Font.custom("Inter-SemiBold", size: 17, relativeTo: .headline)

    // MARK: - Body
    static let appBodyLarge = Font.custom("Inter-Regular", size: 17, relativeTo: .body)
    static let appBody = Font.custom("Inter-Regular", size: 15, relativeTo: .subheadline)
    static let appBodySmall = Font.custom("Inter-Regular", size: 13, relativeTo: .footnote)

    // MARK: - Labels
    static let appLabel = Font.custom("Inter-Medium", size: 14, relativeTo: .subheadline)
    static let appLabelSmall = Font.custom("Inter-Medium", size: 12, relativeTo: .caption)

    // MARK: - Caption
    static let appCaption = Font.custom("Inter-Regular", size: 12, relativeTo: .caption)
    static let appCaptionSmall = Font.custom("Inter-Regular", size: 11, relativeTo: .caption2)

    // MARK: - Monospaced
    static let appCode = Font.custom("JetBrainsMono-Regular", size: 14, relativeTo: .body)
    static let appCodeSmall = Font.custom("JetBrainsMono-Regular", size: 12, relativeTo: .caption)

    // MARK: - Special
    static let appButton = Font.custom("Inter-SemiBold", size: 16, relativeTo: .body)
    static let appTabBar = Font.custom("Inter-Medium", size: 10, relativeTo: .caption2)
    static let appBadge = Font.custom("Inter-Bold", size: 11, relativeTo: .caption2)
}

Usage:

VStack(alignment: .leading, spacing: 8) {
    Text("Page Title").font(.appHeading1)
    Text("Body content goes here.").font(.appBody)
    Text("12:34 PM").font(.appCaption)
    Text("let x = 42").font(.appCode)
}

Font Preview View

A debug view that displays all registered fonts:

import SwiftUI

struct FontPreviewView: View {
    @State private var families: [String] = []
    @State private var searchText = ""
    @State private var previewText = "The quick brown fox jumps over the lazy dog"
    @State private var previewSize: CGFloat = 17

    var filteredFamilies: [String] {
        if searchText.isEmpty {
            return families
        }
        return families.filter { $0.localizedCaseInsensitiveContains(searchText) }
    }

    var body: some View {
        NavigationStack {
            List {
                Section {
                    TextField("Preview text", text: $previewText)
                    Stepper("Size: \(Int(previewSize))pt", value: $previewSize, in: 8...72)
                }

                ForEach(filteredFamilies, id: \.self) { family in
                    Section(header: Text(family)) {
                        ForEach(UIFont.fontNames(forFamilyName: family).sorted(), id: \.self) { name in
                            VStack(alignment: .leading, spacing: 4) {
                                Text(previewText)
                                    .font(.custom(name, size: previewSize))
                                Text(name)
                                    .font(.caption)
                                    .foregroundStyle(.secondary)
                            }
                            .padding(.vertical, 2)
                        }
                    }
                }
            }
            .navigationTitle("Font Preview")
            .searchable(text: $searchText, prompt: "Search fonts...")
            .onAppear {
                families = UIFont.familyNames.sorted()
            }
        }
    }
}

#Preview {
    FontPreviewView()
}

Dynamic Type Helper

Ensure custom fonts respect the user's Dynamic Type preferences:

import SwiftUI

struct DynamicTypeFont: ViewModifier {
    let fontName: String
    let baseSize: CGFloat
    let textStyle: Font.TextStyle

    @ScaledMetric private var scaledSize: CGFloat

    init(fontName: String, baseSize: CGFloat, textStyle: Font.TextStyle = .body) {
        self.fontName = fontName
        self.baseSize = baseSize
        self.textStyle = textStyle
        self._scaledSize = ScaledMetric(wrappedValue: baseSize, relativeTo: textStyle)
    }

    func body(content: Content) -> some View {
        content.font(.custom(fontName, size: scaledSize))
    }
}

extension View {
    func dynamicFont(_ name: String, size: CGFloat, relativeTo style: Font.TextStyle = .body) -> some View {
        modifier(DynamicTypeFont(fontName: name, baseSize: size, textStyle: style))
    }
}

// Usage:
// Text("Hello").dynamicFont("Inter-Regular", size: 17, relativeTo: .body)

List All Device Fonts (Utility Function)

import UIKit

func getAllFonts() -> [(family: String, fonts: [String])] {
    UIFont.familyNames.sorted().map { family in
        (family: family, fonts: UIFont.fontNames(forFamilyName: family).sorted())
    }
}

func findFont(containing query: String) -> [String] {
    var results: [String] = []
    for family in UIFont.familyNames {
        for name in UIFont.fontNames(forFamilyName: family) {
            if name.localizedCaseInsensitiveContains(query) {
                results.append(name)
            }
        }
    }
    return results.sorted()
}

// Usage:
// let allFonts = getAllFonts()
// let interFonts = findFont(containing: "Inter")

7. Variable Fonts

What Are Variable Fonts?

A variable font is a single font file that contains an entire family of variations along
one or more design axes (weight, width, slant, optical size). Instead of shipping separate
files for Regular, Medium, Bold, and so on, a single variable font file can interpolate
smoothly between any values on its defined axes.

Benefits

Single file
: One 
.ttf
 replaces 10-20 individual weight files
Smooth interpolation
: Animate between weights or widths fluidly
Smaller bundle size
: Typically smaller than the equivalent collection of static fonts
Fine-grained control
: Access any weight (e.g., 450, 550) not just named stops
Animation
: Smoothly transition font weight, width, or slant

Common Axes

Axis Code

Name

Typical Range

Description

wght

Weight

100-900

Thin to Black

wdth

Width

75-125

Condensed to Expanded

slnt

Slant

-12 to 0

Upright to slanted

ital

Italic

0 or 1

Roman or Italic

opsz

Optical Size

8-144

Small text to display

Using Variable Fonts in SwiftUI

SwiftUI does not natively support setting arbitrary axis values. You need to drop down
to 
UIFont
 with font descriptors and bridge back to SwiftUI.

import SwiftUI
import UIKit

extension Font {
    /// Create a font from a variable font with a specific weight value.
    /// - Parameters:
    ///   - name: The PostScript name of the variable font
    ///   - size: Point size
    ///   - weight: Weight value (100-900, where 400 = regular, 700 = bold)
    static func variable(_ name: String, size: CGFloat, weight: CGFloat = 400) -> Font {
        let descriptor = UIFontDescriptor(fontAttributes: [
            .name: name,
            kCTFontVariationAttribute as UIFontDescriptor.AttributeName: [
                // Weight axis tag: 2003265652 = 'wght'
                2003265652: weight
            ]
        ])
        let uiFont = UIFont(descriptor: descriptor, size: size)
        return Font(uiFont)
    }

    /// Create a font from a variable font with weight and width axes.
    static func variable(
        _ name: String,
        size: CGFloat,
        weight: CGFloat = 400,
        width: CGFloat = 100
    ) -> Font {
        let descriptor = UIFontDescriptor(fontAttributes: [
            .name: name,
            kCTFontVariationAttribute as UIFontDescriptor.AttributeName: [
                2003265652: weight,  // wght
                2003072104: width    // wdth
            ]
        ])
        let uiFont = UIFont(descriptor: descriptor, size: size)
        return Font(uiFont)
    }
}

Usage:

VStack(spacing: 8) {
    Text("Weight 300").font(.variable("Inter", size: 17, weight: 300))
    Text("Weight 400").font(.variable("Inter", size: 17, weight: 400))
    Text("Weight 500").font(.variable("Inter", size: 17, weight: 500))
    Text("Weight 600").font(.variable("Inter", size: 17, weight: 600))
    Text("Weight 700").font(.variable("Inter", size: 17, weight: 700))
}

Animating Variable Font Axes

You can animate weight or width changes for smooth transitions:

import SwiftUI

struct AnimatedWeightText: View {
    @State private var weight: CGFloat = 400
    let fontName: String
    let text: String

    var body: some View {
        VStack(spacing: 20) {
            Text(text)
                .font(.variable(fontName, size: 32, weight: weight))
                .animation(.easeInOut(duration: 0.3), value: weight)

            Slider(value: $weight, in: 100...900, step: 1)
                .padding(.horizontal)

            Text("Weight: \(Int(weight))")
                .font(.caption)
                .foregroundStyle(.secondary)
        }
    }
}

Axis Tags Reference

When working with variable fonts programmatically, you need numeric axis tags.
These are computed from the 4-character axis tag string:

func axisTag(from string: String) -> Int {
    let chars = Array(string.utf8)
    guard chars.count == 4 else { return 0 }
    return Int(chars[0]) << 24 | Int(chars[1]) << 16 | Int(chars[2]) << 8 | Int(chars[3])
}

// Common axis tags:
// axisTag(from: "wght") = 2003265652
// axisTag(from: "wdth") = 2003072104
// axisTag(from: "slnt") = 1936486004
// axisTag(from: "ital") = 1769234796
// axisTag(from: "opsz") = 1869640570

Inspecting Variable Font Axes

Discover which axes and ranges a variable font supports:

import CoreText

func inspectVariableFont(named fontName: String, size: CGFloat = 17) {
    guard let uiFont = UIFont(name: fontName, size: size) else {
        print("Font not found: \(fontName)")
        return
    }

    let ctFont = uiFont as CTFont
    guard let axes = CTFontCopyVariationAxes(ctFont) as? [[String: Any]] else {
        print("\(fontName) is not a variable font (no variation axes).")
        return
    }

    print("Variable font: \(fontName)")
    print("Axes:")
    for axis in axes {
        let name = axis[kCTFontVariationAxisNameKey as String] ?? "Unknown"
        let tag = axis[kCTFontVariationAxisIdentifierKey as String] ?? 0
        let min = axis[kCTFontVariationAxisMinimumValueKey as String] ?? 0
        let max = axis[kCTFontVariationAxisMaximumValueKey as String] ?? 0
        let def = axis[kCTFontVariationAxisDefaultValueKey as String] ?? 0
        print("  \(name): tag=\(tag), range=\(min)...\(max), default=\(def)")
    }
}

Popular Variable Fonts for iOS

Font Name

Axes Available

Weight Range

Width Range

Notes

Inter

wght

100-900

--

Best all-around UI variable font

Roboto Flex

wght, wdth, slnt, opsz

100-1000

75-151

Most versatile variable font

Source Sans 3

wght, ital

200-900

--

Excellent for technical content

Outfit

wght

100-900

--

Modern, geometric

Work Sans

wght, ital

100-900

--

Clean, editorial

DM Sans

wght, ital, opsz

100-1000

--

Minimal, versatile

Manrope

wght

200-800

--

Tech-focused

Plus Jakarta Sans

wght, ital

200-800

--

Professional, fintech

Space Grotesk

wght

300-700

--

Developer tools

Sora

wght

100-800

--

Futuristic, web3

Montserrat

wght, ital

100-900

--

Popular geometric sans

Nunito

wght, ital

200-900

--

Rounded, friendly

Raleway

wght, ital

100-900

--

Elegant sans-serif

Playfair Display

wght, ital

400-900

--

Editorial serif

Lora

wght, ital

400-700

--

Literary serif

Fraunces

wght, opsz, SOFT, WONK

100-900

--

Playful serif with custom axes

Variable Font with Dynamic Type

Combine variable font control with Dynamic Type support:

import SwiftUI

struct VariableDynamicTypeFont: ViewModifier {
    let fontName: String
    let baseSize: CGFloat
    let weight: CGFloat
    let textStyle: Font.TextStyle

    @ScaledMetric private var scaledSize: CGFloat

    init(fontName: String, baseSize: CGFloat, weight: CGFloat, textStyle: Font.TextStyle) {
        self.fontName = fontName
        self.baseSize = baseSize
        self.weight = weight
        self.textStyle = textStyle
        self._scaledSize = ScaledMetric(wrappedValue: baseSize, relativeTo: textStyle)
    }

    func body(content: Content) -> some View {
        content.font(.variable(fontName, size: scaledSize, weight: weight))
    }
}

extension View {
    func variableFont(
        _ name: String,
        size: CGFloat,
        weight: CGFloat = 400,
        relativeTo style: Font.TextStyle = .body
    ) -> some View {
        modifier(VariableDynamicTypeFont(
            fontName: name,
            baseSize: size,
            weight: weight,
            textStyle: style
        ))
    }
}

// Usage:
// Text("Dynamic Variable").variableFont("Inter", size: 17, weight: 500, relativeTo: .body)

Quick Reference: Font Selection Decision Tree

What kind of content?
|
+-- System / Native feel
|   +-- Default        -> .system()
|   +-- Friendly       -> .system(design: .rounded)
|   +-- Editorial      -> .system(design: .serif)       (New York)
|   +-- Code/Data      -> .system(design: .monospaced)  (SF Mono)
|
+-- Custom branding
|   +-- Need variable weight control?
|   |   +-- Yes -> Inter, Roboto Flex, Outfit (variable fonts)
|   |   +-- No  -> Static font files
|   |
|   +-- What style?
|       +-- Clean modern        -> Inter, DM Sans, Plus Jakarta Sans
|       +-- Geometric           -> Poppins, Montserrat, Urbanist
|       +-- Humanist            -> Lato, Open Sans, Source Sans 3
|       +-- Tech / Developer    -> Space Grotesk, Manrope, Geist
|       +-- Rounded / Friendly  -> Nunito, Quicksand, Fredoka
|       +-- Editorial serif     -> Playfair Display, DM Serif Display
|       +-- Reading serif       -> Merriweather, Lora, EB Garamond
|       +-- Monospaced code     -> JetBrains Mono, Fira Code
|       +-- Bold display        -> Bebas Neue, Oswald, Anton
|
+-- Built-in (no bundling needed)
    +-- Sans-serif body     -> Avenir Next, Helvetica Neue, Gill Sans
    +-- Serif body          -> Georgia, Palatino, Charter, Iowan Old Style
    +-- Luxury display      -> Didot, Bodoni
    +-- Monospaced          -> Menlo, Courier New
    +-- Decorative          -> Copperplate, Rockwell, Zapfino

Quick Reference: PostScript Names Cheat Sheet

The most commonly needed PostScript names for 
Font.custom()
:

// Sans-Serif (built-in)
"HelveticaNeue"                   "HelveticaNeue-Bold"
"AvenirNext-Regular"              "AvenirNext-Bold"
"AvenirNext-DemiBold"             "AvenirNext-Medium"
"GillSans"                        "GillSans-Bold"
"Futura-Medium"                   "Futura-Bold"
"Optima-Regular"                  "Optima-Bold"

// Serif (built-in)
"Georgia"                         "Georgia-Bold"
"TimesNewRomanPSMT"               "TimesNewRomanPS-BoldMT"
"Palatino-Roman"                  "Palatino-Bold"
"Baskerville"                     "Baskerville-Bold"
"Didot"                           "Didot-Bold"
"BodoniSvtyTwoITCTT-Bold"        "BodoniSvtyTwoSCITCTT-Book"
"Charter-Roman"                   "Charter-Bold"
"IowanOldStyle-Roman"             "IowanOldStyle-Bold"

// Monospaced (built-in)
"Menlo-Regular"                   "Menlo-Bold"
"CourierNewPSMT"                  "CourierNewPS-BoldMT"
"AmericanTypewriter"              "AmericanTypewriter-Bold"

// Display (built-in)
"Copperplate"                     "Copperplate-Bold"
"Rockwell-Regular"                "Rockwell-Bold"
"DINAlternate-Bold"               "DINCondensed-Bold"

// Script (built-in)
"SnellRoundhand"                  "SnellRoundhand-Bold"
"BradleyHandITCTT-Bold"           "Zapfino"
"SavoyeLetPlain"                  "ChalkboardSE-Regular"

// International (built-in)
"PingFangSC-Regular"              "PingFangSC-Semibold"
"HiraginoSans-W3"                 "HiraginoSans-W6"
"AppleSDGothicNeo-Regular"        "AppleSDGothicNeo-Bold"
"KohinoorDevanagari-Regular"      "KohinoorBangla-Regular"

This catalog covers the Apple system fonts, all major built-in iOS fonts with exact PostScript names, the top 100 Google Fonts for mobile development, custom font integration guides, proven font pairings, management utilities, and variable font techniques. Every font name string is ready to use directly in Font.custom() or UIFont(name:size:).

---

# Layer-by-Layer Icons with Icon Composer
https://nagarjuna2997.github.io/ios-agent-skill/guides/design-icon-composer.html

Design · Reference guideLayer-by-Layer Icons with Icon ComposerRepository guidance for Layer-by-Layer Icons with Icon Composer. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Context
Pattern

Make the layer plan first
Compose and annotate
Integrate with the app
What an agent must report

Anti-Patterns

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Separate foreground layers
↓2
Export editable artwork
↓3
Assemble in Icon Composer
↓4
Inspect rendered variants

02 / ArchitectureResponsibility boundariesBoundary 1
Layer sourcesBoundary 2
Icon compositionBoundary 3
App icon assetConnected responsibilities, not a required class hierarchy or an execution trace.
Context

Use this for app identity, layered app icons, Liquid Glass icon treatments, and Xcode icon integration. The output should include separately editable artwork, a layer manifest, appearance decisions, and a verified Icon Composer document when the tool is available.

Official sources checked 2026-09-10:

Icon Composer
Create an app icon in Icon Composer
App icon design guidance
Create icons with Icon Composer

Pattern

Make the layer plan first

Define the app’s recognizable symbol and a single visual idea. Separate the background, supporting shape, primary symbol, and optional accent. Use semantic names and stable ordering. For example, a reading app can use a background fill, a book silhouette, a page mark, and a small bookmark accent; each foreground shape remains editable independently.

Prefer clean vector foregrounds. Use SVG or transparent PNG assets for import, exported on the same canvas so positions remain aligned. Keep text outlined. For raster art, retain transparent backgrounds around foreground artwork. Keep imported background art opaque and full bleed. Do not rasterize all pieces into one image.

A useful project handoff contains:

IconLayers/
  01-base.svg
  02-symbol.svg
  03-accent.svg
  manifest.json
  README.md

These filenames are a recommended project convention, not an Apple file-format requirement. The CLI’s 
--xcodegen
 starter creates editable layers as a starting point; replace their shapes and colors for the actual brand.

Compose and annotate

Launch Icon Composer from Xcode’s developer tools menu or the standalone app. Start a new document, choose the supported platforms, and set its background fill. Import foreground artwork, organize it into no more than four groups, and arrange depth from back to front. Use its material controls for highlights, refraction, translucency, and shadow rather than painting those effects into every asset.

Tune Default, Dark, and Mono appearances. Inspect at small sizes and against different surrounding backgrounds. Keep the silhouette recognizable when decorative detail disappears. Save the native 
.icon
 document, reopen it, and check that its layers and appearance settings remain editable.

Integrate with the app

Add the saved icon document to the Xcode project and associate it with the app target’s icon setting. Build with the actual SDK and inspect the installed icon. Use Apple’s asset-catalog image-stack workflow for platforms whose icon format differs, including tvOS and visionOS; do not assume the same Composer workflow applies to every platform.

Export flattened images only for marketing, previews, or compatibility workflows that specifically require them. Retain the editable source and native document alongside those exports.

What an agent must report

List the artwork files, layer ordering, appearance variants checked, native document path if created, and Xcode verification performed. If Icon Composer is unavailable, deliver the SVG/PNG layer pack plus import instructions and explicitly leave native 
.icon
 verification pending. Do not invent an undocumented 
.icon
 schema or rename a JSON file to make it appear native.

Anti-Patterns

One flattened image presented as a layered icon project.
System shadows, corner masks, or highlights baked into foreground layers and then applied again by the compositor.
A tiny detailed logo that loses its identity at home-screen size.
A generic starter icon described as finished brand artwork.
Claiming a native 
.icon
 file was verified without opening it in Icon Composer.

---

# Interaction Standards
https://nagarjuna2997.github.io/ios-agent-skill/guides/design-interaction-standards.html

Design · Reference guideInteraction StandardsRepository guidance for Interaction Standards. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

1. Animation Standards

Default Curves and Durations
When to Use Which Animation
Transition Standards

2. Haptic Feedback Rules

Haptic Selection Guide
SensoryFeedback (iOS 17+)
UIImpactFeedbackGenerator (iOS 16 and Below)
Best Practices

3. SF Symbols Guidelines

Size Guidelines
Rendering Modes
Variable Value Symbols
Symbol Effects
Preferred Symbol Weight

4. Button Style Standards

Complete Button Style System

5. Loading, Empty, and Error State Patterns

ViewState Enum
Loading View
Empty State View
Error State View
AsyncContentView Wrapper

6. Localization Approach

String Catalogs Setup
Date and Number Formatting
RTL and Layout Considerations

7. Privacy Manifest

PrivacyInfo.xcprivacy Structure
Required Reason API Categories
Third-Party SDK Manifests

8. Device Support and Adaptive Layout

Size Class Detection
NavigationSplitView for iPad Sidebar
ViewThatFits
iPad-Specific Features

9. Preview Provider Standards

Modern Previews (iOS 17+)
Interactive Previews
Preview with Mock Data
SwiftData Preview Container
Legacy PreviewProvider (iOS 16 and Below)

Quick Reference Summary

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Identify an interaction
↓2
Choose native behavior
↓3
Add accessible feedback
↓4
Test alternate inputs

02 / ArchitectureResponsibility boundariesBoundary 1
User inputBoundary 2
Interaction stateBoundary 3
Feedback and accessibilityConnected responsibilities, not a required class hierarchy or an execution trace.

Comprehensive reference for animations, haptics, symbols, button styles, state patterns, localization, privacy, adaptive layout, and preview standards.

1. Animation Standards

Default Curves and Durations

Category

Duration

Curve

Usage

Micro interaction

0.2s

.easeOut

Toggles, button presses, icon changes

Navigation transition

0.35s

.spring(response: 0.35, dampingFraction: 0.85)

Push, pop, tab switches

Content loading

0.3s

.easeInOut

Skeleton to content, fade-in

Dismissal

0.25s

.easeIn

Sheet dismiss, alert close, toast exit

Bouncy spring

--

.bouncy

Playful UI: reactions, badges, celebrations

Snappy spring

--

.snappy

Responsive controls: sliders, toggles

Smooth spring

--

.smooth

Elegant reveals: cards, overlays

When to Use Which Animation

Use Case

Animation

Rationale

Button tap feedback

.easeOut, 0.2s

Quick acknowledgment, no lingering

Toggle switch

.snappy

Responsive mechanical feel

Card expand/collapse

.spring(response: 0.35, dampingFraction: 0.85)

Natural, physical motion

Pull-to-refresh

.bouncy

Playful rubber-band feel

Modal presentation

.smooth

Elegant, unhurried entrance

Error shake

.default.repeatCount(3)

Attention-grabbing without being jarring

Skeleton shimmer

.easeInOut, 1.2s, repeat

Smooth continuous loop

Item deletion

.easeIn, 0.25s

Quick exit, attention moves forward

List reorder

.snappy

Keeps up with the finger

Hero transition

matchedGeometryEffect

Spatial continuity between screens

Transition Standards

// MARK: - Sheet Presentation (use system default)
.sheet(isPresented: $showSettings) {
    SettingsView()
}

// MARK: - Full Screen Cover with Custom Transition
.fullScreenCover(isPresented: $showOnboarding) {
    OnboardingView()
        .transition(.opacity.combined(with: .move(edge: .bottom)))
}

// MARK: - Navigation Push (system default)
NavigationStack {
    List(items) { item in
        NavigationLink(value: item) {
            ItemRow(item: item)
        }
    }
    .navigationDestination(for: Item.self) { item in
        ItemDetailView(item: item)
    }
}

// MARK: - Hero Transition with matchedGeometryEffect
struct HeroTransitionExample: View {
    @Namespace private var heroNamespace
    @State private var isExpanded = false

    var body: some View {
        if isExpanded {
            DetailCard(namespace: heroNamespace)
                .onTapGesture {
                    withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
                        isExpanded = false
                    }
                }
        } else {
            ThumbnailCard(namespace: heroNamespace)
                .onTapGesture {
                    withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
                        isExpanded = true
                    }
                }
        }
    }
}

struct ThumbnailCard: View {
    var namespace: Namespace.ID

    var body: some View {
        RoundedRectangle(cornerRadius: 12)
            .fill(.blue.gradient)
            .matchedGeometryEffect(id: "card", in: namespace)
            .frame(width: 120, height: 120)
            .overlay {
                Text("Tap")
                    .matchedGeometryEffect(id: "title", in: namespace)
            }
    }
}

struct DetailCard: View {
    var namespace: Namespace.ID

    var body: some View {
        RoundedRectangle(cornerRadius: 24)
            .fill(.blue.gradient)
            .matchedGeometryEffect(id: "card", in: namespace)
            .frame(maxWidth: .infinity, maxHeight: 400)
            .overlay {
                Text("Detail View")
                    .matchedGeometryEffect(id: "title", in: namespace)
            }
            .padding()
    }
}

// MARK: - Custom Asymmetric Transition
extension AnyTransition {
    static var slideAndFade: AnyTransition {
        .asymmetric(
            insertion: .move(edge: .trailing).combined(with: .opacity),
            removal: .move(edge: .leading).combined(with: .opacity)
        )
    }
}

// Usage:
struct CustomTransitionExample: View {
    @State private var showContent = false

    var body: some View {
        VStack {
            if showContent {
                ContentView()
                    .transition(.slideAndFade)
            }
            Button("Toggle") {
                withAnimation(.easeInOut(duration: 0.3)) {
                    showContent.toggle()
                }
            }
        }
    }
}

// MARK: - Phased Animation for Multi-Step Effects
struct PhasedAnimationExample: View {
    @State private var trigger = false

    var body: some View {
        Image(systemName: "bell.fill")
            .font(.system(size: 32))
            .phaseAnimator([false, true], trigger: trigger) { content, phase in
                content
                    .scaleEffect(phase ? 1.2 : 1.0)
                    .rotationEffect(.degrees(phase ? 15 : 0))
            } animation: { phase in
                phase ? .bouncy : .snappy
            }
            .onTapGesture { trigger.toggle() }
    }
}

2. Haptic Feedback Rules

Haptic Selection Guide

Haptic Type

When to Use

Examples

.success

Completed action

Save confirmed, message sent, purchase complete

.warning

Destructive action confirmation

Delete dialog appears, irreversible action prompt

.error

Failed action

Validation error, network failure, permission denied

.light

Toggle or selection change

Switch toggled, radio selected, checkbox tapped

.medium

Snap to position

Pull-to-refresh threshold reached, snap point hit

.heavy

Long press activation

Context menu triggered, drag-and-drop pickup

.selection

Scrolling through values

Picker scroll, date wheel spin, segment change

SensoryFeedback (iOS 17+)

// MARK: - SensoryFeedback Modifier (Preferred for iOS 17+)
struct HapticExamples: View {
    @State private var isFavorited = false
    @State private var taskCompleted = false
    @State private var showError = false
    @State private var sliderValue = 0.5

    var body: some View {
        VStack(spacing: 24) {
            // Success haptic on task completion
            Button("Complete Task") {
                taskCompleted = true
            }
            .sensoryFeedback(.success, trigger: taskCompleted)

            // Light haptic on toggle
            Toggle("Favorite", isOn: $isFavorited)
                .sensoryFeedback(.selection, trigger: isFavorited)

            // Error haptic on failure
            Button("Submit") {
                showError = true
            }
            .sensoryFeedback(.error, trigger: showError)

            // Impact haptic with weight
            Button("Heavy Action") { }
                .sensoryFeedback(.impact(weight: .heavy), trigger: taskCompleted)
        }
    }
}

UIImpactFeedbackGenerator (iOS 16 and Below)

// MARK: - Haptic Manager for Pre-iOS 17
final class HapticManager {
    static let shared = HapticManager()
    private init() {}

    func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle) {
        let generator = UIImpactFeedbackGenerator(style: style)
        generator.prepare()
        generator.impactOccurred()
    }

    func notification(_ type: UINotificationFeedbackGenerator.FeedbackType) {
        let generator = UINotificationFeedbackGenerator()
        generator.prepare()
        generator.notificationOccurred(type)
    }

    func selection() {
        let generator = UISelectionFeedbackGenerator()
        generator.prepare()
        generator.selectionChanged()
    }
}

// Usage:
Button("Save") {
    performSave()
    HapticManager.shared.notification(.success)
}

Button("Delete") {
    HapticManager.shared.notification(.warning)
    showDeleteConfirmation = true
}

Best Practices

Never fire haptics on passive events (scrolling, appearing, background refresh).
Respect the system setting: haptics are automatically suppressed when the user disables them.
Prepare generators early (
.prepare()
) before the moment of feedback for zero-latency response.
Do not chain multiple haptics in rapid succession; one feedback per gesture.
Test on a real device -- the Simulator does not produce haptic output.

3. SF Symbols Guidelines

Size Guidelines

Context

Point Size

Example

Inline with text

17pt

Label icons, list item accessories

Tab bar

24pt

Bottom navigation icons

Buttons

28-32pt

Toolbar actions, floating action buttons

Feature icons

44-64pt

Empty states, onboarding, settings headers

Rendering Modes

Mode

When to Use

Description

.monochrome

Default, single-tone UI elements

One color applied uniformly

.hierarchical

Icons needing depth

Primary color with opacity layers

.palette

Brand-specific multi-color

You control each layer color

.multicolor

System-defined rich icons

Weather, file types, flags

// MARK: - Rendering Modes
struct SymbolRenderingExamples: View {
    var body: some View {
        VStack(spacing: 20) {
            // Monochrome (default)
            Image(systemName: "heart.fill")
                .symbolRenderingMode(.monochrome)
                .foregroundStyle(.red)

            // Hierarchical — automatic depth
            Image(systemName: "square.stack.3d.up.fill")
                .symbolRenderingMode(.hierarchical)
                .foregroundStyle(.blue)
                .font(.system(size: 44))

            // Palette — explicit layer colors
            Image(systemName: "person.crop.circle.badge.checkmark")
                .symbolRenderingMode(.palette)
                .foregroundStyle(.blue, .green)
                .font(.system(size: 44))

            // Multicolor — system-defined
            Image(systemName: "cloud.sun.rain.fill")
                .symbolRenderingMode(.multicolor)
                .font(.system(size: 44))
        }
    }
}

Variable Value Symbols

// MARK: - Variable Value (0.0 to 1.0) for Progress
struct VariableSymbolExample: View {
    @State private var progress: Double = 0.0

    var body: some View {
        VStack(spacing: 16) {
            Image(systemName: "speaker.wave.3.fill", variableValue: progress)
                .font(.system(size: 48))
                .foregroundStyle(.blue)
                .contentTransition(.symbolEffect(.replace))

            Image(systemName: "wifi", variableValue: progress)
                .font(.system(size: 48))
                .foregroundStyle(.green)

            Slider(value: $progress, in: 0...1)
                .padding(.horizontal)
        }
    }
}

Symbol Effects

// MARK: - Symbol Effects (iOS 17+)
struct SymbolEffectsExample: View {
    @State private var bellTapped = false
    @State private var isActive = false
    @State private var downloadComplete = false

    var body: some View {
        VStack(spacing: 24) {
            // Bounce on tap
            Image(systemName: "bell.fill")
                .font(.system(size: 32))
                .symbolEffect(.bounce, value: bellTapped)
                .onTapGesture { bellTapped.toggle() }

            // Continuous pulse while active
            Image(systemName: "antenna.radiowaves.left.and.right")
                .font(.system(size: 32))
                .symbolEffect(.pulse, isActive: isActive)

            // Variable color animation (iterating layers)
            Image(systemName: "wifi")
                .font(.system(size: 32))
                .symbolEffect(.variableColor.iterative, isActive: isActive)

            // Appear / disappear
            Image(systemName: "checkmark.circle.fill")
                .font(.system(size: 32))
                .symbolEffect(.appear, isActive: downloadComplete)

            // Replace transition between symbols
            Image(systemName: isActive ? "pause.fill" : "play.fill")
                .contentTransition(.symbolEffect(.replace))
                .font(.system(size: 32))
                .onTapGesture {
                    withAnimation { isActive.toggle() }
                }

            // Breathe effect
            Image(systemName: "heart.fill")
                .font(.system(size: 32))
                .foregroundStyle(.red)
                .symbolEffect(.breathe, isActive: isActive)

            // Wiggle effect
            Image(systemName: "bell.badge.fill")
                .font(.system(size: 32))
                .symbolEffect(.wiggle, value: bellTapped)

            // Rotate effect
            Image(systemName: "gear")
                .font(.system(size: 32))
                .symbolEffect(.rotate, isActive: isActive)
        }
    }
}

Preferred Symbol Weight

Use 
.medium
 weight by default to match the system HIG:

Image(systemName: "gear")
    .fontWeight(.medium)

4. Button Style Standards

Complete Button Style System

// MARK: - Primary Button Style
struct PrimaryButtonStyle: ButtonStyle {
    @Environment(\.isEnabled) private var isEnabled
    var isLoading: Bool = false

    func makeBody(configuration: Configuration) -> some View {
        HStack(spacing: 8) {
            if isLoading {
                ProgressView()
                    .tint(.white)
            }
            configuration.label
        }
        .font(.body.weight(.semibold))
        .foregroundStyle(.white)
        .frame(maxWidth: .infinity, minHeight: 50)
        .background(
            RoundedRectangle(cornerRadius: 12)
                .fill(
                    isEnabled
                        ? AnyShapeStyle(LinearGradient(
                            colors: [.blue, .blue.opacity(0.8)],
                            startPoint: .topLeading,
                            endPoint: .bottomTrailing))
                        : AnyShapeStyle(.gray.opacity(0.4))
                )
        )
        .scaleEffect(configuration.isPressed ? 0.97 : 1.0)
        .opacity(isLoading ? 0.9 : 1.0)
        .animation(.easeOut(duration: 0.2), value: configuration.isPressed)
        .allowsHitTesting(!isLoading)
    }
}

// MARK: - Secondary Button Style
struct SecondaryButtonStyle: ButtonStyle {
    @Environment(\.isEnabled) private var isEnabled
    var isLoading: Bool = false

    func makeBody(configuration: Configuration) -> some View {
        HStack(spacing: 8) {
            if isLoading {
                ProgressView()
                    .tint(.accentColor)
            }
            configuration.label
        }
        .font(.body.weight(.semibold))
        .foregroundStyle(isEnabled ? .accentColor : .gray)
        .frame(maxWidth: .infinity, minHeight: 50)
        .background(
            RoundedRectangle(cornerRadius: 12)
                .stroke(isEnabled ? Color.accentColor : .gray, lineWidth: 1.5)
        )
        .scaleEffect(configuration.isPressed ? 0.97 : 1.0)
        .animation(.easeOut(duration: 0.2), value: configuration.isPressed)
    }
}

// MARK: - Destructive Button Style
struct DestructiveButtonStyle: ButtonStyle {
    @Environment(\.isEnabled) private var isEnabled
    var isLoading: Bool = false

    func makeBody(configuration: Configuration) -> some View {
        HStack(spacing: 8) {
            if isLoading {
                ProgressView()
                    .tint(.white)
            }
            configuration.label
        }
        .font(.body.weight(.semibold))
        .foregroundStyle(.white)
        .frame(maxWidth: .infinity, minHeight: 50)
        .background(
            RoundedRectangle(cornerRadius: 12)
                .fill(isEnabled ? Color.red : .gray.opacity(0.4))
        )
        .scaleEffect(configuration.isPressed ? 0.97 : 1.0)
        .animation(.easeOut(duration: 0.2), value: configuration.isPressed)
    }
}

// MARK: - Ghost Button Style
struct GhostButtonStyle: ButtonStyle {
    @Environment(\.isEnabled) private var isEnabled

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(.body.weight(.medium))
            .foregroundStyle(isEnabled ? .accentColor : .gray)
            .padding(.horizontal, 16)
            .padding(.vertical, 10)
            .scaleEffect(configuration.isPressed ? 0.95 : 1.0)
            .opacity(configuration.isPressed ? 0.7 : 1.0)
            .animation(.easeOut(duration: 0.2), value: configuration.isPressed)
    }
}

// MARK: - Icon Button Style
struct IconButtonStyle: ButtonStyle {
    var size: CGFloat = 44
    @Environment(\.isEnabled) private var isEnabled

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(.system(size: size * 0.45, weight: .medium))
            .foregroundStyle(isEnabled ? .accentColor : .gray)
            .frame(width: size, height: size)
            .background(Circle().fill(.ultraThinMaterial))
            .scaleEffect(configuration.isPressed ? 0.9 : 1.0)
            .animation(.easeOut(duration: 0.2), value: configuration.isPressed)
    }
}

// MARK: - Pill Button Style
struct PillButtonStyle: ButtonStyle {
    @Environment(\.isEnabled) private var isEnabled

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(.subheadline.weight(.semibold))
            .foregroundStyle(isEnabled ? .white : .gray)
            .padding(.horizontal, 20)
            .padding(.vertical, 10)
            .background(
                Capsule()
                    .fill(isEnabled ? Color.accentColor : .gray.opacity(0.3))
            )
            .scaleEffect(configuration.isPressed ? 0.95 : 1.0)
            .animation(.easeOut(duration: 0.2), value: configuration.isPressed)
    }
}

// MARK: - Usage Examples
struct ButtonShowcase: View {
    @State private var isLoading = false

    var body: some View {
        VStack(spacing: 16) {
            Button { } label: {
                Label("Continue", systemImage: "arrow.right")
            }
            .buttonStyle(PrimaryButtonStyle())

            Button { } label: {
                Label("Edit Profile", systemImage: "pencil")
            }
            .buttonStyle(SecondaryButtonStyle())

            Button(role: .destructive) { } label: {
                Label("Delete Account", systemImage: "trash")
            }
            .buttonStyle(DestructiveButtonStyle())

            Button("Learn More") { }
                .buttonStyle(GhostButtonStyle())

            Button { } label: {
                Image(systemName: "plus")
            }
            .buttonStyle(IconButtonStyle())

            Button { } label: {
                Label("Subscribe", systemImage: "star.fill")
            }
            .buttonStyle(PillButtonStyle())

            // Disabled state
            Button("Disabled") { }
                .buttonStyle(PrimaryButtonStyle())
                .disabled(true)

            // Loading state
            Button("Saving...") { }
                .buttonStyle(PrimaryButtonStyle(isLoading: true))
        }
        .padding()
    }
}

5. Loading, Empty, and Error State Patterns

ViewState Enum

// MARK: - Generic View State
enum ViewState<T> {
    case loading
    case loaded(T)
    case empty
    case error(Error)
}

Loading View

// MARK: - Shimmer Modifier
struct ShimmerModifier: ViewModifier {
    @State private var phase: CGFloat = 0

    func body(content: Content) -> some View {
        content
            .overlay(
                LinearGradient(
                    colors: [.clear, .white.opacity(0.4), .clear],
                    startPoint: .leading,
                    endPoint: .trailing
                )
                .offset(x: phase)
                .mask(content)
            )
            .onAppear {
                withAnimation(.easeInOut(duration: 1.2).repeatForever(autoreverses: false)) {
                    phase = UIScreen.main.bounds.width
                }
            }
    }
}

extension View {
    func shimmer() -> some View {
        modifier(ShimmerModifier())
    }
}

// MARK: - Skeleton Loading View
struct SkeletonRow: View {
    var body: some View {
        HStack(spacing: 12) {
            RoundedRectangle(cornerRadius: 8)
                .fill(.gray.opacity(0.2))
                .frame(width: 48, height: 48)

            VStack(alignment: .leading, spacing: 8) {
                RoundedRectangle(cornerRadius: 4)
                    .fill(.gray.opacity(0.2))
                    .frame(height: 14)
                    .frame(maxWidth: 160)

                RoundedRectangle(cornerRadius: 4)
                    .fill(.gray.opacity(0.2))
                    .frame(height: 12)
                    .frame(maxWidth: 100)
            }
        }
        .shimmer()
    }
}

// MARK: - Spinner Loading
struct LoadingSpinnerView: View {
    var message: String = "Loading..."

    var body: some View {
        VStack(spacing: 16) {
            ProgressView()
                .controlSize(.large)
            Text(message)
                .font(.subheadline)
                .foregroundStyle(.secondary)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

Empty State View

// MARK: - Empty State Presets
enum EmptyStatePreset {
    case noData
    case noSearchResults
    case noConnection
    case firstTimeUse

    var icon: String {
        switch self {
        case .noData: "tray"
        case .noSearchResults: "magnifyingglass"
        case .noConnection: "wifi.slash"
        case .firstTimeUse: "sparkles"
        }
    }

    var title: String {
        switch self {
        case .noData: "Nothing Here Yet"
        case .noSearchResults: "No Results Found"
        case .noConnection: "No Connection"
        case .firstTimeUse: "Get Started"
        }
    }

    var message: String {
        switch self {
        case .noData: "Items you add will appear here."
        case .noSearchResults: "Try a different search term."
        case .noConnection: "Check your internet connection and try again."
        case .firstTimeUse: "Tap the button below to create your first item."
        }
    }
}

struct EmptyStateView: View {
    var preset: EmptyStatePreset
    var actionTitle: String?
    var action: (() -> Void)?

    var body: some View {
        ContentUnavailableView {
            Label(preset.title, systemImage: preset.icon)
        } description: {
            Text(preset.message)
        } actions: {
            if let actionTitle, let action {
                Button(actionTitle, action: action)
                    .buttonStyle(.borderedProminent)
            }
        }
    }
}

Error State View

// MARK: - Error State View
struct ErrorStateView: View {
    let error: Error
    var retryAction: (() -> Void)?
    var reportAction: (() -> Void)?

    var body: some View {
        ContentUnavailableView {
            Label("Something Went Wrong", systemImage: "exclamationmark.triangle")
        } description: {
            Text(error.localizedDescription)
        } actions: {
            VStack(spacing: 12) {
                if let retryAction {
                    Button("Try Again", action: retryAction)
                        .buttonStyle(.borderedProminent)
                }
                if let reportAction {
                    Button("Report Issue", action: reportAction)
                        .font(.footnote)
                }
            }
        }
    }
}

AsyncContentView Wrapper

// MARK: - Async Content View
struct AsyncContentView<T, LoadedContent: View>: View {
    @Binding var state: ViewState<T>
    var loadingMessage: String = "Loading..."
    var emptyPreset: EmptyStatePreset = .noData
    var emptyAction: (() -> Void)?
    var retryAction: (() -> Void)?
    @ViewBuilder var content: (T) -> LoadedContent

    var body: some View {
        switch state {
        case .loading:
            LoadingSpinnerView(message: loadingMessage)
                .transition(.opacity)
        case .loaded(let data):
            content(data)
                .transition(.opacity)
        case .empty:
            EmptyStateView(
                preset: emptyPreset,
                actionTitle: emptyAction != nil ? "Add Item" : nil,
                action: emptyAction
            )
            .transition(.opacity)
        case .error(let error):
            ErrorStateView(error: error, retryAction: retryAction)
                .transition(.opacity)
        }
    }
}

// MARK: - Usage with Pull-to-Refresh
struct ItemListView: View {
    @State private var state: ViewState<[Item]> = .loading

    var body: some View {
        AsyncContentView(
            state: $state,
            emptyPreset: .noData,
            retryAction: { Task { await loadItems() } }
        ) { items in
            List(items) { item in
                Text(item.name)
            }
            .refreshable {
                await loadItems()
            }
        }
        .animation(.easeInOut(duration: 0.3), value: stateKey)
        .task { await loadItems() }
    }

    private var stateKey: String {
        switch state {
        case .loading: "loading"
        case .loaded: "loaded"
        case .empty: "empty"
        case .error: "error"
        }
    }

    private func loadItems() async {
        state = .loading
        do {
            let items = try await ItemService.fetchAll()
            state = items.isEmpty ? .empty : .loaded(items)
        } catch {
            state = .error(error)
        }
    }
}

6. Localization Approach

String Catalogs Setup

All user-facing strings must use 
String(localized:)
. Never hardcode display text.

Xcode creates a 
Localizable.xcstrings
 file (String Catalog) that automatically extracts strings.

// MARK: - Correct: Localized Strings
let title = String(localized: "welcome.title")
let message = String(localized: "welcome.message")

// With default value
let greeting = String(localized: "greeting", defaultValue: "Hello there!")

// MARK: - String Interpolation
let itemCount = 5
let label = String(localized: "\(itemCount) items remaining")

// MARK: - Pluralization (handled in .xcstrings catalog)
// In the String Catalog, define plural variants:
//   "item_count" -> one: "%lld item", other: "%lld items"
let countLabel = String(localized: "\(itemCount) items")

// MARK: - Table-based organization
let settingsTitle = String(localized: "title", table: "Settings")

Date and Number Formatting

// MARK: - Locale-Aware Formatting
struct FormattingExamples: View {
    let price: Decimal = 49.99
    let eventDate = Date()
    let progress = 0.756

    var body: some View {
        VStack(alignment: .leading) {
            // Currency — adapts to user locale
            Text(price, format: .currency(code: "USD"))

            // Date — adapts to locale conventions
            Text(eventDate, format: .dateTime.month(.wide).day().year())

            // Relative date
            Text(eventDate, format: .relative(presentation: .named))

            // Percentage
            Text(progress, format: .percent.precision(.fractionLength(1)))

            // Measurement
            Text(Measurement(value: 72, unit: UnitTemperature.fahrenheit),
                 format: .measurement(width: .abbreviated))
        }
    }
}

RTL and Layout Considerations

// MARK: - RTL-Safe Layout
struct RTLSafeView: View {
    @Environment(\.layoutDirection) var layoutDirection

    var body: some View {
        HStack {
            // Use .leading/.trailing, never .left/.right
            Image(systemName: "arrow.forward")
                .flipsForRightToLeftLayoutDirection(true)
            Text(String(localized: "next"))
        }
        .frame(maxWidth: .infinity, alignment: .leading) // Flips automatically in RTL
    }
}

7. Privacy Manifest

PrivacyInfo.xcprivacy Structure

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <!-- Tracking declaration -->
    <key>NSPrivacyTracking</key>
    <false/>
    <key>NSPrivacyTrackingDomains</key>
    <array/>

    <!-- Required Reason APIs -->
    <key>NSPrivacyAccessedAPITypes</key>
    <array>
        <!-- File timestamp APIs -->
        <dict>
            <key>NSPrivacyAccessedAPIType</key>
            <string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
            <key>NSPrivacyAccessedAPITypeReasons</key>
            <array>
                <string>C617.1</string> <!-- Access within app container -->
            </array>
        </dict>
        <!-- System boot time APIs -->
        <dict>
            <key>NSPrivacyAccessedAPIType</key>
            <string>NSPrivacyAccessedAPICategorySystemBootTime</string>
            <key>NSPrivacyAccessedAPITypeReasons</key>
            <array>
                <string>35F9.1</string> <!-- Measure elapsed time -->
            </array>
        </dict>
        <!-- Disk space APIs -->
        <dict>
            <key>NSPrivacyAccessedAPIType</key>
            <string>NSPrivacyAccessedAPICategoryDiskSpace</string>
            <key>NSPrivacyAccessedAPITypeReasons</key>
            <array>
                <string>E174.1</string> <!-- Check available disk space -->
            </array>
        </dict>
        <!-- User defaults APIs -->
        <dict>
            <key>NSPrivacyAccessedAPIType</key>
            <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
            <key>NSPrivacyAccessedAPITypeReasons</key>
            <array>
                <string>CA92.1</string> <!-- Access within app -->
            </array>
        </dict>
    </array>

    <!-- Collected data types -->
    <key>NSPrivacyCollectedDataTypes</key>
    <array>
        <dict>
            <key>NSPrivacyCollectedDataType</key>
            <string>NSPrivacyCollectedDataTypeCrashData</string>
            <key>NSPrivacyCollectedDataTypeLinked</key>
            <false/>
            <key>NSPrivacyCollectedDataTypeTracking</key>
            <false/>
            <key>NSPrivacyCollectedDataTypePurposes</key>
            <array>
                <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
            </array>
        </dict>
    </array>
</dict>
</plist>

Required Reason API Categories

Category

Common APIs

Typical Reason Code

File Timestamp

NSFileCreationDate, NSFileModificationDate, NSURLContentModificationDateKey

C617.1 (app container), DDA9.1 (user-presented)

System Boot Time

systemUptime, ProcessInfo.processInfo.systemUptime

35F9.1 (measure elapsed time)

Disk Space

volumeAvailableCapacityKey, volumeAvailableCapacityForImportantUsageKey

E174.1 (check space before write)

User Defaults

UserDefaults

CA92.1 (access within app)

Active Keyboard

activeInputModes

3EC4.1 (customize UI)

Third-Party SDK Manifests

Each third-party SDK must include its own 
PrivacyInfo.xcprivacy
. Verify during dependency audits:

// In Package.swift or Podfile, verify SDK authors ship privacy manifests.
// Xcode aggregates all manifests during App Store submission.
// Run: Product > Generate Privacy Report to audit before submission.

8. Device Support and Adaptive Layout

Size Class Detection

// MARK: - Adaptive Layout with Size Classes
struct AdaptiveView: View {
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
    @Environment(\.verticalSizeClass) private var verticalSizeClass

    var body: some View {
        if horizontalSizeClass == .regular {
            // iPad or iPhone landscape wide
            HStack(spacing: 0) {
                SidebarView()
                    .frame(width: 320)
                DetailView()
            }
        } else {
            // iPhone portrait
            NavigationStack {
                ListView()
            }
        }
    }
}

NavigationSplitView for iPad Sidebar

// MARK: - Navigation Split View
struct SplitLayoutView: View {
    @State private var selectedItem: Item?
    @State private var columnVisibility = NavigationSplitViewVisibility.automatic

    var body: some View {
        NavigationSplitView(columnVisibility: $columnVisibility) {
            List(items, selection: $selectedItem) { item in
                NavigationLink(value: item) {
                    ItemRow(item: item)
                }
            }
            .navigationTitle("Items")
        } detail: {
            if let selectedItem {
                ItemDetailView(item: selectedItem)
            } else {
                ContentUnavailableView("Select an Item",
                    systemImage: "sidebar.left",
                    description: Text("Choose an item from the sidebar."))
            }
        }
    }
}

ViewThatFits

// MARK: - ViewThatFits for Adaptive Components
struct AdaptiveActionBar: View {
    var body: some View {
        ViewThatFits(in: .horizontal) {
            // First choice: full horizontal layout
            HStack(spacing: 16) {
                Button("Save Draft") { }
                    .buttonStyle(SecondaryButtonStyle())
                Button("Preview") { }
                    .buttonStyle(SecondaryButtonStyle())
                Button("Publish") { }
                    .buttonStyle(PrimaryButtonStyle())
            }

            // Fallback: stacked layout
            VStack(spacing: 8) {
                Button("Publish") { }
                    .buttonStyle(PrimaryButtonStyle())
                HStack(spacing: 8) {
                    Button("Save Draft") { }
                        .buttonStyle(SecondaryButtonStyle())
                    Button("Preview") { }
                        .buttonStyle(SecondaryButtonStyle())
                }
            }
        }
        .padding()
    }
}

iPad-Specific Features

// MARK: - Pointer Hover Effect (iPad)
struct HoverableCard: View {
    @State private var isHovered = false

    var body: some View {
        RoundedRectangle(cornerRadius: 12)
            .fill(.background)
            .shadow(radius: isHovered ? 8 : 2)
            .scaleEffect(isHovered ? 1.02 : 1.0)
            .onHover { hovering in
                withAnimation(.easeOut(duration: 0.2)) {
                    isHovered = hovering
                }
            }
            .hoverEffect(.lift) // System pointer lift effect
    }
}

// MARK: - Keyboard Shortcuts (iPad with hardware keyboard)
struct ShortcutView: View {
    var body: some View {
        VStack {
            Text("Press Cmd+N to create")
        }
        .keyboardShortcut("n", modifiers: .command)
    }
}

9. Preview Provider Standards

Modern Previews (iOS 17+)

// MARK: - Basic Preview
#Preview {
    ContentView()
}

// MARK: - Named Preview
#Preview("Dark Mode") {
    ContentView()
        .preferredColorScheme(.dark)
}

// MARK: - Light and Dark Side by Side
#Preview("Color Schemes") {
    VStack {
        ContentView()
            .preferredColorScheme(.light)
        ContentView()
            .preferredColorScheme(.dark)
    }
}

// MARK: - Dynamic Type Sizes
#Preview("Large Text") {
    ContentView()
        .dynamicTypeSize(.xxxLarge)
}

#Preview("Accessibility Sizes") {
    ContentView()
        .dynamicTypeSize(.accessibility3)
}

// MARK: - Device Variations
#Preview("iPhone SE", traits: .fixedLayout(width: 375, height: 667)) {
    ContentView()
}

#Preview("iPad", traits: .fixedLayout(width: 1024, height: 768)) {
    ContentView()
}

// MARK: - Size That Fits
#Preview("Component", traits: .sizeThatFitsLayout) {
    PillButtonExample()
        .padding()
}

Interactive Previews

// MARK: - Interactive Preview with @Previewable
#Preview("Toggle Demo") {
    @Previewable @State var isOn = false

    Toggle("Notifications", isOn: $isOn)
        .padding()
}

#Preview("Counter") {
    @Previewable @State var count = 0

    VStack {
        Text("Count: \(count)")
            .font(.largeTitle)
        Button("Increment") { count += 1 }
            .buttonStyle(PrimaryButtonStyle())
    }
    .padding()
}

Preview with Mock Data

// MARK: - Preview with Mock Data
struct Item: Identifiable {
    let id: UUID
    let name: String
    let subtitle: String

    static let samples: [Item] = [
        Item(id: UUID(), name: "Morning Run", subtitle: "5.2 km"),
        Item(id: UUID(), name: "Yoga Session", subtitle: "45 min"),
        Item(id: UUID(), name: "Cycling", subtitle: "12.8 km"),
    ]
}

#Preview {
    List(Item.samples) { item in
        VStack(alignment: .leading) {
            Text(item.name).font(.headline)
            Text(item.subtitle).font(.caption).foregroundStyle(.secondary)
        }
    }
}

SwiftData Preview Container

// MARK: - SwiftData Preview Container
struct PreviewContainer {
    static var shared: ModelContainer {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try! ModelContainer(
            for: Task.self,
            configurations: config
        )
        // Insert sample data
        let context = container.mainContext
        for task in Task.sampleTasks {
            context.insert(task)
        }
        return container
    }
}

#Preview {
    TaskListView()
        .modelContainer(PreviewContainer.shared)
}

Legacy PreviewProvider (iOS 16 and Below)

// MARK: - Legacy PreviewProvider
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            ContentView()
                .previewDisplayName("Light")

            ContentView()
                .preferredColorScheme(.dark)
                .previewDisplayName("Dark")

            ContentView()
                .dynamicTypeSize(.xxxLarge)
                .previewDisplayName("Large Text")
        }
    }
}

Quick Reference Summary

Area

Key Rule

Micro interaction

0.2s .easeOut

Navigation

0.35s .spring(response: 0.35, dampingFraction: 0.85)

Dismissal

0.25s .easeIn

Haptic on success

.success via SensoryFeedback

Haptic on toggle

.selection

SF Symbol weight

.medium

SF Symbol inline size

17pt

Button press scale

0.97

Strings

Always String(localized:)

Layout direction

.leading/.trailing, never .left/.right

Privacy

Ship PrivacyInfo.xcprivacy with every target

Previews

Use #Preview macro, test light/dark/large text

---

# Adopting Liquid Glass
https://nagarjuna2997.github.io/ios-agent-skill/guides/design-liquid-glass-adoption.html

Design and Motion · Reference guideAdopting Liquid GlassRepository guidance for Liquid Glass. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

1. The decision you make before writing any code
2. What changes with no code, and what you must audit
3. Scroll edge effect
4. Navigation
5. Toolbars and menus
6. Controls and shape
7. Lists, forms, and a silent text change
8. App icons
9. Test matrix
Anti-Patterns
Checklist

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Locate navigation surfaces
↓2
Check SDK availability
↓3
Apply supported materials
↓4
Review legibility

02 / ArchitectureResponsibility boundariesBoundary 1
Content layerBoundary 2
Navigation chromeBoundary 3
Accessibility settingsConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
 rebuilding an existing app against the iOS 26 SDK or later,
auditing an interface after the rebuild, or deciding whether to take the new
look at all.

This is the migration half.
 For applying the material to a custom view —

glassEffect
, 
GlassEffectContainer
, the availability guard, the fallback —
see 
docs/design/design-tokens.md
 §4. That document is about opting 
in
 on a
view you own. This one is about what happens to the app you already shipped.

1. The decision you make before writing any code

Rebuilding against the iOS 26 SDK 
adopts the new design automatically
.
Standard components from SwiftUI, UIKit, and AppKit pick up Liquid Glass with no
code change. That is the whole point, and it is also the risk: an app you have
not re-audited ships a changed interface.

There is one escape hatch:

<!-- Info.plist -->
<key>UIDesignRequiresCompatibility</key>
<true/>

This keeps the app looking as it did when built against the previous SDK, while
still letting you build with the current one.

Treat it as a stopgap, not a decision.
 It buys a release cycle to do the
audit properly; it does not remove the work. An app frozen on the compatibility
key looks progressively more dated as the rest of the system moves, and Apple
has historically retired these keys. Set a date to remove it.

Situation

Do this

You can audit the interface this cycle

Rebuild, adopt, work through §2–§7

You must ship urgently and cannot audit

UIDesignRequiresCompatibility, with a tracked removal date

You are on the previous SDK entirely

Nothing yet — but the audit list still tells you what to expect

2. What changes with no code, and what you must audit

The rule that decides most of the work: 
the system now owns the background of
controls and navigation.
 Custom backgrounds you added to those elements do not
merely look dated — they sit on top of Liquid Glass and the scroll edge effect
and interfere with both.

Audit and, in most cases, delete custom backgrounds on:

NavigationStack
, 
NavigationSplitView
, and split-view columns
toolbars (
toolbar(content:)
) and title bars
tab bars
sheets and popovers — including any 
visualEffect
 view you put behind popover
  content, which is now duplicated work

// WRONG — a hand-rolled bar background, from before the system provided one.
// It overlays Liquid Glass and defeats the scroll edge effect, so content
// scrolling underneath loses the contrast the system would have given it.
.toolbarBackground(Color.appSurface, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)

// RIGHT — let the system decide. It adapts to overlap, focus, and scroll
// position in ways a static color cannot.
// (no modifier)

Do not hard-code control metrics.
 Controls adopt rounder, larger forms, and
an extra-large size option. Standard controls resize themselves; a control with
a pinned 
.frame(width:height:)
 will not, and will sit wrong next to the ones
that did.

3. Scroll edge effect

Scroll views obscure content passing beneath bars so controls stay legible.
System bars get this for free. 
A custom bar does not
 — it gets content
sliding under it at full contrast.

// A custom bottom bar. Register it so the system applies the scroll edge
// effect, rather than leaving your controls to fight the content behind them.
ScrollView {
    ArticleList(articles: model.articles)
}
.safeAreaBar(edge: .bottom) {
    PlaybackControls(model: model)
}

Use 
scrollEdgeEffectStyle(_:for:)
 when you need to choose the style rather
than accept the default.

4. Navigation

Navigation is the layer Liquid Glass lives in, so the separation between
navigation and content has to be real. If your content and your chrome are
interleaved, the new material has nothing coherent to float above.

// Tab bar that becomes a sidebar where there is room for one.
TabView {
    Tab("Library", systemImage: "books.vertical") { LibraryScreen() }
    Tab("Browse", systemImage: "square.grid.2x2") { BrowseScreen() }

    // The SEARCH ROLE, not a tab that happens to contain search. The system
    // pulls it to the trailing end and styles it as search — matching where
    // users have learned to look for it in every other app.
    Tab(role: .search) { SearchScreen() }
}
.tabViewStyle(.sidebarAdaptable)

// Let the tab bar recede while reading, and come back on the reverse scroll.
.tabBarMinimizeBehavior(.onScrollDown)

Background extension
 makes a hero image read as continuing beneath a
sidebar or inspector, without actually placing content under it — the system
mirrors and blurs the adjacent content so the sidebar stays legible:

NavigationSplitView {
    LibrarySidebar(selection: $selection)
} detail: {
    ScrollView {
        HeroImage(article: article)
            .backgroundExtensionEffect()
        ArticleBody(article: article)
    }
}

After adding either, 
check the safe areas of the content beside the sidebar
and inspector
 — that is where "peeking through" either works or clips.

5. Toolbars and menus

Toolbar items now group, and the grouping is meaningful: items sharing a
background read as related.

.toolbar {
    ToolbarItemGroup(placement: .primaryAction) {
        Button("Bookmark", systemImage: "bookmark") { model.bookmark() }
        Button("Share", systemImage: "square.and.arrow.up") { model.share() }
    }

    // Separates groups that must not read as one control.
    ToolbarSpacer(.fixed, placement: .primaryAction)

    ToolbarItem(placement: .primaryAction) {
        Button("Delete", systemImage: "trash", role: .destructive) { model.delete() }
    }
}

Three rules that are easy to get wrong:

Do not mix text and icons inside one group.
 Across items sharing a
  background it reads as an inconsistency, not a distinction.
Every icon-only item needs an accessibility label
, regardless of what is
  on screen. Someone using VoiceOver or Voice Control gets nothing from the
  glyph. 
review_swiftui
 and 
audit_app_store_readiness
 both flag this.
Hide the item, not its content.
 A toolbar item whose 
view
 is hidden
  leaves an empty slot the system still lays out.

// WRONG — an empty toolbar item the system reserves space for.
ToolbarItem { if canEdit { EditButton() } }

// RIGHT — the item itself goes away.
ToolbarItem { EditButton() }
    .hidden(!canEdit)

6. Controls and shape

Reach for the built-in glass button styles before building anything custom:

Button("Continue") { model.advance() }
    .buttonStyle(.glass)

Button("Buy Now") { model.purchase() }
    .buttonStyle(.glassProminent)

Nested shapes should be 
concentric
 with their container — the hardware's
corner radius informs the whole chain of rounded elements:

// Concentric with whatever contains it, rather than a guessed radius that
// looks subtly wrong at one screen size and badly wrong at another.
CardContent(article: article)
    .clipShape(ConcentricRectangle())

Be sparing with colour on controls and navigation.
 Colour on a glass surface
costs legibility. When you do use it, use a system colour or a custom one with
light, dark, and increased-contrast variants — the same rule as every other
token in 
docs/design/design-tokens.md
.

7. Lists, forms, and a silent text change

Rows and sections gained height, padding, and corner radius. The change that
bites is quieter:

Section headers are now title-case, not upper-case.
 The system no longer
force-capitalises them. A header you wrote as 
"recently played"
 — relying on
the old behaviour to render 
RECENTLY PLAYED
 — now renders exactly as written.

// WRONG — depended on the system shouting it for you.
Section("recently played") { … }

// RIGHT — write the capitalisation you want to see.
Section("Recently Played") { … }

Grep for lowercase 
Section(
 string literals. Nothing warns about this; it just
ships.

8. App icons

Icons are now layered, and the system applies reflection, refraction, shadow,
blur, and highlights across light, dark, clear, and tinted variants.

Separate your artwork into foreground / middle / background layers.
Do not bake in effects.
 Shadows and blurs you paint yourself get composited
  on top of the system's, and the result is muddy.
Prefer solid, filled, overlapping semi-transparent shapes over fine detail.
Compose in 
Icon Composer
 (ships with Xcode; also on Apple Design
  Resources), which previews the system effects and appearance variants.
Keep elements centred — the system masks to a rounded rectangle on
  iOS/iPadOS/macOS and a circle on watchOS. An irregular icon gets a
  system-provided background.

9. Test matrix

Liquid Glass adapts to user settings, and those settings remove or change the
effects you designed around. Standard components adapt on their own; 
anything
custom is yours to verify.

Setting

What to check

Reduce Transparency

Custom glass surfaces stay legible when the blur is gone

Reduce Motion

Morphing and fluid transitions degrade to something sensible

Increase Contrast

Custom colours still meet contrast against a glass background

Dark mode

Every custom surface and colour pairing

Accessibility text sizes

Controls and bars reflow rather than clip

The user's Liquid Glass appearance preference

Custom elements follow it

Per platform:

watchOS
 — changes are minimal and appear even without rebuilding. Adopt
  the watchOS 10 toolbar APIs and standard button styles to pick them up.
tvOS
 — controls take on glass 
when focused
. Adopt the standard focus
  APIs (
focusable(_:)
, 
isFocused
) so custom controls match. Only Apple TV 4K
  (2nd generation) and newer render the effects; older devices keep the current
  appearance, which is a fallback you do not have to write.
iPadOS
 — windows resize continuously to a minimum size rather than
  snapping between presets. See 
docs/tooling/device-hub.md
; rebuilding against
  the iOS 27 SDK also opts you into resizability.

Anti-Patterns

// WRONG — glass on every custom control in the app.
// The material exists to draw attention to content. Applied everywhere it
// competes with the content and flattens the hierarchy it was meant to create.
ForEach(filters) { filter in
    FilterChip(filter).glassEffect()
}

// RIGHT — reserve it for the few genuinely functional elements.

// WRONG — separate glass effects stacked next to each other.
// Each is its own render pass, and they will not morph into one another.
HStack {
    BackButton().glassEffect()
    PlayButton().glassEffect()
}

// RIGHT — one container, so they blend and merge.
GlassEffectContainer(spacing: Space.tight) {
    HStack {
        BackButton().glassEffect().glassEffectID("back", in: namespace)
        PlayButton().glassEffect().glassEffectID("play", in: namespace)
    }
}

// WRONG — an action sheet with no source.
// It now originates from the control that triggered it. With no anchor it
// appears detached from the thing it acts on.
.confirmationDialog("Delete?", isPresented: $isConfirming) { … }

// RIGHT — anchor it to its source so the relationship is visible.
.confirmationDialog("Delete?", isPresented: $isConfirming, presenting: item) { … }

// WRONG — shipping UIDesignRequiresCompatibility with no removal plan.
// It is a deferral. Left in place the app drifts further from the system every
// release, and the audit it postponed only grows.

// RIGHT — set it, file the work, remove it next cycle.

// WRONG — assuming every adoption API shares one availability floor.
// Liquid Glass arrived in iOS 26, but these APIs did not all land together.
// A blanket #available(iOS 26, *) around a symbol introduced later fails to
// compile; one at iOS 27 around an iOS 26 symbol silently drops every iOS 26
// device to the fallback — the mistake this skill flags most often.
if #available(iOS 26, *) { /* every new API */ }

// RIGHT — check each symbol's own floor in Xcode's documentation and guard on
// THAT version. `check_availability_guards` catches the over-restrictive case.

Checklist

[ ] Rebuilt against the current SDK and reviewed every screen
[ ] 
UIDesignRequiresCompatibility
 either absent, or present with a removal date
[ ] Custom backgrounds removed from bars, split views, sheets, popovers
[ ] No hard-coded control metrics
[ ] Custom bars registered for the scroll edge effect (
safeAreaBar
)
[ ] Navigation clearly separated from content
[ ] Search uses 
Tab(role: .search)
, not an ordinary tab
[ ] Toolbar items grouped meaningfully; 
ToolbarSpacer
 between unrelated groups
[ ] No group mixing text and icon items
[ ] Every icon-only control has an accessibility label
[ ] Hidden toolbar items hide the item, not the view
[ ] Section headers written in the capitalisation you want to see
[ ] Nested shapes concentric with their containers
[ ] Glass reserved for the few most important elements
[ ] Multiple glass elements share one 
GlassEffectContainer
[ ] App icon rebuilt as layers in Icon Composer, with no baked-in effects
[ ] Verified under Reduce Transparency, Reduce Motion, Increase Contrast, dark
      mode, and accessibility text sizes
[ ] Each new API guarded on 
its own
 introduction version

---

# Professional UI/UX System
https://nagarjuna2997.github.io/ios-agent-skill/guides/design-README.html

Design · Reference guideProfessional UI/UX SystemRepository guidance for Professional UI/UX System. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Context
Design Stack
Review Order
Pattern
Anti-Patterns
Production Checklist

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Define the product task
↓2
Choose semantic tokens
↓3
Compose adaptive screens
↓4
Review accessibility

02 / ArchitectureResponsibility boundariesBoundary 1
Product intentBoundary 2
Design systemBoundary 3
SwiftUI surfacesConnected responsibilities, not a required class hierarchy or an execution trace.
Context

Use this hub when a user asks for professional polish, better hierarchy, premium design, improved onboarding, stronger empty/loading/error states, better iPad adaptation, or a screen that feels native instead of merely functional.

Design Stack

Layer

Use

design-tokens.md

Source of truth for color, spacing, radius, typography, shadows

typography-system.md

Semantic text hierarchy and Dynamic Type

color-system.md

Contrast, semantic color roles, dark mode

interaction-standards.md

Touch targets, gestures, haptics, animation purpose

liquid-glass-adoption.md

Modern materials without contrast loss

stunning-ui-patterns.md

Full-screen composition patterns

Review Order

Primary task: can the user see what to do in two seconds?
Hierarchy: do size, weight, color, and position match importance?
Layout: are alignment, rhythm, gutters, and touch targets consistent?
Type: semantic styles first; fixed sizes only with a clear reason.
State: loading, empty, error, success, offline, disabled.
Adaptation: compact/regular widths, iPad resizability, keyboard, pointer.
Accessibility: VoiceOver labels, focus order, Dynamic Type, contrast, Reduce Motion.

Pattern

Prefer a compact state model over scattered booleans:

enum ScreenState<Content: Equatable>: Equatable {
    case loading
    case empty
    case failed(message: String)
    case loaded(Content)
}

Each state gets a designed view that keeps the same layout frame when possible. Loading should not resize the final surface; empty states should name the next action; errors should explain recovery.

Anti-Patterns

// WRONG: "premium" means adding gradients and cards everywhere.
Why: polish comes from hierarchy, restraint, rhythm, and state quality.

// RIGHT: define the primary task, reduce competing emphasis, then add motion/material only where it explains state.

// WRONG: a phone layout stretched full-width on iPad.
Why: density and reading length break.

// RIGHT: use navigation split, max readable width, sidebars, inspector panes, or two-column layouts.

Production Checklist

[ ] Primary action is visually dominant and reachable.
[ ] Every repeated spacing/color/type value comes from a token.
[ ] Text wraps at accessibility sizes.
[ ] Tap targets are at least 44x44pt.
[ ] Dark mode and contrast are checked.
[ ] Loading, empty, error, success, and offline states exist where relevant.
[ ] iPad layout is not a stretched phone screen.
[ ] Motion and haptics clarify state, not decoration.

---

# Stunning UI Patterns -- Complete SwiftUI Pattern Library
https://nagarjuna2997.github.io/ios-agent-skill/guides/design-stunning-ui-patterns.html

Design and Motion · Reference guideStunning UI Patterns -- Complete SwiftUI Pattern LibraryRepository guidance for Human Interface Guidelines. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Overview
1. Glass Morphism Card
2. Neumorphic Card
3. Gradient Card with Floating Shadow
4. Animated Onboarding Screen
5. Hero Image Header with Parallax Scroll
6. Bottom Sheet with Snap Points
7. Animated Tab Bar
8. Profile Card
9. Dashboard Cards with Charts
10. Floating Action Button
11. Custom Toggle with Animation
12. Swipeable Card Stack
13. Pull-to-Refresh with Custom Animation
14. Skeleton Loading / Shimmer Effect
15. Toast / Snackbar Notification
16. Expandable Card with matchedGeometryEffect
17. Animated Gradient Background
18. Blurred Header that Changes on Scroll
19. Chip / Tag Flow Layout
20. Rating Stars Component
Bonus: Combining Patterns -- Premium App Screen
Quick Reference

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Choose information hierarchy
↓2
Compose layout and spacing
↓3
Add meaningful motion
↓4
Review all screen states

02 / ArchitectureResponsibility boundariesBoundary 1
Content hierarchyBoundary 2
Reusable componentsBoundary 3
Screen statesConnected responsibilities, not a required class hierarchy or an execution trace.
Overview

This file is a production-ready pattern library of 20 stunning UI components. Every example compiles, uses beautiful colors from the color palettes defined in 
color-system.md
, and produces results worthy of a premium App Store feature. Copy, adapt, and compose these into world-class iOS applications.

All examples assume the 
Color(hex:)
 extension from 
color-system.md
 is available.

1. Glass Morphism Card

Frosted glass with a luminous border. Use over images or gradients for maximum effect.

import SwiftUI

struct GlassMorphismCard: View {
    var body: some View {
        ZStack {
            // Rich background
            LinearGradient(
                colors: [Color(hex: "6C63FF"), Color(hex: "EC4899"), Color(hex: "06B6D4")],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
            .ignoresSafeArea()

            VStack(alignment: .leading, spacing: 12) {
                HStack {
                    Image(systemName: "sparkles")
                        .font(.title2)
                        .foregroundStyle(.white)
                    Spacer()
                    Text("PRO")
                        .font(.caption.weight(.bold))
                        .kerning(1.5)
                        .foregroundStyle(.white)
                        .padding(.horizontal, 10)
                        .padding(.vertical, 4)
                        .background(.white.opacity(0.2), in: Capsule())
                }

                Text("Premium Plan")
                    .font(.title2.weight(.bold))
                    .foregroundStyle(.white)

                Text("Unlock all features and get early access to new content every week.")
                    .font(.subheadline)
                    .foregroundStyle(.white.opacity(0.8))
                    .lineSpacing(4)

                HStack {
                    Text("$9.99/mo")
                        .font(.title3.weight(.bold))
                        .foregroundStyle(.white)
                    Spacer()
                    Text("Subscribe")
                        .font(.subheadline.weight(.semibold))
                        .foregroundStyle(Color(hex: "6C63FF"))
                        .padding(.horizontal, 20)
                        .padding(.vertical, 10)
                        .background(.white, in: Capsule())
                }
                .padding(.top, 8)
            }
            .padding(24)
            .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 24))
            .overlay(
                RoundedRectangle(cornerRadius: 24)
                    .stroke(
                        LinearGradient(
                            colors: [.white.opacity(0.5), .white.opacity(0.05)],
                            startPoint: .topLeading,
                            endPoint: .bottomTrailing
                        ),
                        lineWidth: 1
                    )
            )
            .shadow(color: .black.opacity(0.15), radius: 20, y: 10)
            .padding(24)
        }
    }
}

2. Neumorphic Card

Soft shadows creating the illusion of raised or inset elements on a flat surface.

struct NeumorphicCard: View {
    @State private var isPressed = false

    var body: some View {
        let bgColor = Color(hex: "E8EDF2")

        ZStack {
            bgColor.ignoresSafeArea()

            VStack(spacing: 32) {
                // Raised card
                VStack(alignment: .leading, spacing: 8) {
                    Image(systemName: "wifi")
                        .font(.title)
                        .foregroundStyle(Color(hex: "6C63FF"))
                    Text("Network Status")
                        .font(.headline)
                    Text("Connected -- 120 Mbps")
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                }
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(24)
                .background(bgColor)
                .cornerRadius(20)
                .shadow(color: .white.opacity(0.7), radius: 10, x: -5, y: -5)
                .shadow(color: .black.opacity(0.15), radius: 10, x: 5, y: 5)

                // Inset / pressed style
                VStack(alignment: .leading, spacing: 8) {
                    Image(systemName: "bolt.fill")
                        .font(.title)
                        .foregroundStyle(Color(hex: "F59E0B"))
                    Text("Quick Actions")
                        .font(.headline)
                    Text("Tap to toggle")
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                }
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(24)
                .background(
                    RoundedRectangle(cornerRadius: 20)
                        .fill(bgColor)
                        .overlay(
                            RoundedRectangle(cornerRadius: 20)
                                .stroke(Color.black.opacity(0.05), lineWidth: 1)
                        )
                        .innerShadow(bgColor)
                )

                // Neumorphic button
                Button {
                    withAnimation(.spring(response: 0.3)) {
                        isPressed.toggle()
                    }
                } label: {
                    Image(systemName: "power")
                        .font(.title)
                        .foregroundStyle(isPressed ? Color(hex: "2D9F6F") : .gray)
                        .frame(width: 80, height: 80)
                        .background(bgColor)
                        .cornerRadius(40)
                        .shadow(
                            color: isPressed ? .clear : .white.opacity(0.7),
                            radius: isPressed ? 0 : 8, x: -4, y: -4
                        )
                        .shadow(
                            color: isPressed ? .clear : .black.opacity(0.15),
                            radius: isPressed ? 0 : 8, x: 4, y: 4
                        )
                        .overlay(
                            RoundedRectangle(cornerRadius: 40)
                                .stroke(Color.black.opacity(isPressed ? 0.08 : 0), lineWidth: 1)
                        )
                }
            }
            .padding(24)
        }
    }
}

// Helper modifier for inner shadow
extension View {
    func innerShadow(_ bgColor: Color) -> some View {
        self.overlay(
            RoundedRectangle(cornerRadius: 20)
                .stroke(Color.black.opacity(0.08), lineWidth: 1)
                .shadow(color: .black.opacity(0.1), radius: 3, x: 2, y: 2)
                .clipShape(RoundedRectangle(cornerRadius: 20))
        )
        .overlay(
            RoundedRectangle(cornerRadius: 20)
                .stroke(Color.white.opacity(0.5), lineWidth: 1)
                .shadow(color: .white.opacity(0.5), radius: 3, x: -2, y: -2)
                .clipShape(RoundedRectangle(cornerRadius: 20))
        )
    }
}

3. Gradient Card with Floating Shadow

The shadow color matches the card gradient, creating a luminous glow beneath.

struct GradientShadowCard: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                Image(systemName: "chart.line.uptrend.xyaxis")
                    .font(.title2)
                Text("Revenue")
                    .font(.headline)
                Spacer()
                Text("+24.5%")
                    .font(.subheadline.weight(.bold))
            }
            .foregroundStyle(.white)

            Text("$48,290")
                .font(.system(size: 36, weight: .bold, design: .rounded))
                .foregroundStyle(.white)

            Text("Compared to $38,800 last month")
                .font(.caption)
                .foregroundStyle(.white.opacity(0.7))
        }
        .padding(24)
        .background(
            LinearGradient(
                colors: [Color(hex: "6C63FF"), Color(hex: "8B5CF6")],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            ),
            in: RoundedRectangle(cornerRadius: 20)
        )
        // Floating colored shadow
        .shadow(color: Color(hex: "6C63FF").opacity(0.4), radius: 20, y: 12)
        .padding(24)
    }
}

4. Animated Onboarding Screen

TabView with custom animated page indicators and fluid transitions.

struct OnboardingScreen: View {
    @State private var currentPage = 0

    let pages: [(icon: String, title: String, subtitle: String, colors: [Color])] = [
        ("sparkles", "Welcome", "Discover a new way to organize your life.", [Color(hex: "6C63FF"), Color(hex: "A78BFA")]),
        ("bolt.fill", "Lightning Fast", "Everything you need, instantly at your fingertips.", [Color(hex: "EC4899"), Color(hex: "F472B6")]),
        ("heart.fill", "Made with Love", "Crafted by a team that cares about every detail.", [Color(hex: "2D9F6F"), Color(hex: "22D3EE")]),
    ]

    var body: some View {
        ZStack {
            // Animated background gradient
            LinearGradient(
                colors: pages[currentPage].colors,
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
            .ignoresSafeArea()
            .animation(.easeInOut(duration: 0.6), value: currentPage)

            VStack(spacing: 0) {
                TabView(selection: $currentPage) {
                    ForEach(0..<pages.count, id: \.self) { index in
                        VStack(spacing: 24) {
                            Image(systemName: pages[index].icon)
                                .font(.system(size: 80))
                                .foregroundStyle(.white)
                                .shadow(color: .white.opacity(0.3), radius: 20)

                            Text(pages[index].title)
                                .font(.largeTitle.weight(.bold))
                                .foregroundStyle(.white)

                            Text(pages[index].subtitle)
                                .font(.body)
                                .foregroundStyle(.white.opacity(0.8))
                                .multilineTextAlignment(.center)
                                .padding(.horizontal, 40)
                        }
                        .tag(index)
                    }
                }
                .tabViewStyle(.page(indexDisplayMode: .never))

                // Custom page indicator
                HStack(spacing: 10) {
                    ForEach(0..<pages.count, id: \.self) { index in
                        Capsule()
                            .fill(.white.opacity(currentPage == index ? 1 : 0.4))
                            .frame(
                                width: currentPage == index ? 28 : 8,
                                height: 8
                            )
                            .animation(.spring(response: 0.4, dampingFraction: 0.7), value: currentPage)
                    }
                }
                .padding(.bottom, 32)

                // CTA button
                Button {
                    if currentPage < pages.count - 1 {
                        withAnimation { currentPage += 1 }
                    }
                } label: {
                    Text(currentPage == pages.count - 1 ? "Get Started" : "Continue")
                        .font(.headline)
                        .foregroundStyle(pages[currentPage].colors[0])
                        .frame(maxWidth: .infinity)
                        .padding(.vertical, 16)
                        .background(.white, in: RoundedRectangle(cornerRadius: 16))
                }
                .padding(.horizontal, 24)
                .padding(.bottom, 40)
            }
        }
    }
}

5. Hero Image Header with Parallax Scroll

struct ParallaxHeroHeader: View {
    @State private var scrollOffset: CGFloat = 0

    var body: some View {
        ScrollView {
            VStack(spacing: 0) {
                GeometryReader { geo in
                    let minY = geo.frame(in: .global).minY
                    ZStack(alignment: .bottomLeading) {
                        // Parallax image
                        Rectangle()
                            .fill(
                                LinearGradient(
                                    colors: [Color(hex: "0A2463"), Color(hex: "1E88E5")],
                                    startPoint: .topLeading,
                                    endPoint: .bottomTrailing
                                )
                            )
                            .overlay(
                                Image(systemName: "mountain.2.fill")
                                    .resizable()
                                    .scaledToFit()
                                    .foregroundStyle(.white.opacity(0.15))
                                    .padding(40)
                            )
                            .offset(y: minY > 0 ? -minY * 0.5 : 0)

                        // Gradient overlay for text legibility
                        LinearGradient(
                            colors: [.clear, .black.opacity(0.6)],
                            startPoint: .top,
                            endPoint: .bottom
                        )

                        // Title content
                        VStack(alignment: .leading, spacing: 8) {
                            Text("EXPLORE")
                                .font(.caption.weight(.bold))
                                .kerning(2)
                                .foregroundStyle(.white.opacity(0.7))

                            Text("Mountains of\nSwiftUI")
                                .font(.largeTitle.weight(.bold))
                                .foregroundStyle(.white)
                        }
                        .padding(24)
                    }
                    .frame(height: max(350 + (minY > 0 ? minY : 0), 350))
                    .clipped()
                }
                .frame(height: 350)

                // Content below hero
                VStack(spacing: 16) {
                    ForEach(0..<10, id: \.self) { i in
                        HStack(spacing: 16) {
                            RoundedRectangle(cornerRadius: 12)
                                .fill(Color(hex: "1E88E5").opacity(0.1))
                                .frame(width: 60, height: 60)
                                .overlay(
                                    Image(systemName: "photo")
                                        .foregroundStyle(Color(hex: "1E88E5"))
                                )
                            VStack(alignment: .leading, spacing: 4) {
                                Text("Article \(i + 1)")
                                    .font(.headline)
                                Text("A beautiful description of this content piece.")
                                    .font(.subheadline)
                                    .foregroundStyle(.secondary)
                            }
                            Spacer()
                        }
                        .padding(16)
                        .background(Color(.secondarySystemGroupedBackground))
                        .cornerRadius(16)
                    }
                }
                .padding(16)
            }
        }
        .ignoresSafeArea(edges: .top)
    }
}

6. Bottom Sheet with Snap Points

struct BottomSheetDemo: View {
    @State private var sheetOffset: CGFloat = 500
    @GestureState private var dragOffset: CGFloat = 0

    private let snapPoints: [CGFloat] = [100, 350, 600]

    var body: some View {
        ZStack {
            Color(hex: "0B0B1A").ignoresSafeArea()

            VStack {
                Text("Drag the sheet up")
                    .foregroundStyle(.white)
                    .font(.headline)
            }

            // Sheet
            VStack(spacing: 0) {
                // Handle
                Capsule()
                    .fill(Color(.systemGray3))
                    .frame(width: 40, height: 5)
                    .padding(.top, 10)
                    .padding(.bottom, 16)

                // Sheet content
                VStack(alignment: .leading, spacing: 16) {
                    Text("Discover Nearby")
                        .font(.title2.weight(.bold))

                    ForEach(0..<5, id: \.self) { i in
                        HStack(spacing: 12) {
                            Circle()
                                .fill(
                                    LinearGradient(
                                        colors: [Color(hex: "8B5CF6"), Color(hex: "EC4899")],
                                        startPoint: .topLeading,
                                        endPoint: .bottomTrailing
                                    )
                                )
                                .frame(width: 44, height: 44)
                                .overlay(
                                    Image(systemName: "mappin")
                                        .foregroundStyle(.white)
                                )
                            VStack(alignment: .leading) {
                                Text("Location \(i + 1)")
                                    .font(.subheadline.weight(.semibold))
                                Text("\(Double.random(in: 0.1...5.0), specifier: "%.1f") km away")
                                    .font(.caption)
                                    .foregroundStyle(.secondary)
                            }
                            Spacer()
                            Image(systemName: "chevron.right")
                                .foregroundStyle(.tertiary)
                        }
                    }
                }
                .padding(.horizontal, 24)

                Spacer()
            }
            .frame(maxWidth: .infinity)
            .background(Color(.systemBackground))
            .cornerRadius(24)
            .shadow(color: .black.opacity(0.2), radius: 20, y: -5)
            .offset(y: sheetOffset + dragOffset)
            .gesture(
                DragGesture()
                    .updating($dragOffset) { value, state, _ in
                        state = value.translation.height
                    }
                    .onEnded { value in
                        let projected = sheetOffset + value.translation.height
                        let nearest = snapPoints.min(by: {
                            abs($0 - projected) < abs($1 - projected)
                        }) ?? snapPoints[1]
                        withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
                            sheetOffset = nearest
                        }
                    }
            )
        }
    }
}

7. Animated Tab Bar

struct AnimatedTabBar: View {
    @State private var selectedTab = 0
    @Namespace private var tabAnimation

    let tabs: [(icon: String, label: String)] = [
        ("house.fill", "Home"),
        ("magnifyingglass", "Search"),
        ("plus.circle.fill", "Add"),
        ("heart.fill", "Saved"),
        ("person.fill", "Profile"),
    ]

    var body: some View {
        VStack {
            Spacer()
            Text("Tab \(selectedTab)")
                .font(.largeTitle.weight(.bold))
                .foregroundStyle(.primary)
            Spacer()

            // Tab bar
            HStack {
                ForEach(0..<tabs.count, id: \.self) { index in
                    Button {
                        withAnimation(.spring(response: 0.35, dampingFraction: 0.7)) {
                            selectedTab = index
                        }
                    } label: {
                        VStack(spacing: 4) {
                            ZStack {
                                if selectedTab == index {
                                    Capsule()
                                        .fill(Color(hex: "6C63FF").opacity(0.15))
                                        .frame(width: 56, height: 32)
                                        .matchedGeometryEffect(id: "tabBG", in: tabAnimation)
                                }

                                Image(systemName: tabs[index].icon)
                                    .font(.system(size: index == 2 ? 28 : 20))
                                    .foregroundStyle(
                                        selectedTab == index
                                            ? Color(hex: "6C63FF")
                                            : .gray
                                    )
                                    .scaleEffect(selectedTab == index ? 1.15 : 1.0)
                            }
                            .frame(height: 32)

                            Text(tabs[index].label)
                                .font(.system(size: 10, weight: .medium))
                                .foregroundStyle(
                                    selectedTab == index
                                        ? Color(hex: "6C63FF")
                                        : .gray
                                )
                        }
                        .frame(maxWidth: .infinity)
                    }
                }
            }
            .padding(.top, 8)
            .padding(.bottom, 4)
            .background(
                Rectangle()
                    .fill(.ultraThinMaterial)
                    .ignoresSafeArea(edges: .bottom)
            )
        }
    }
}

8. Profile Card

struct ProfileCard: View {
    var body: some View {
        VStack(spacing: 0) {
            // Gradient header
            ZStack(alignment: .bottom) {
                LinearGradient(
                    colors: [Color(hex: "8B5CF6"), Color(hex: "EC4899")],
                    startPoint: .topLeading,
                    endPoint: .bottomTrailing
                )
                .frame(height: 140)

                // Avatar
                Circle()
                    .fill(Color(.systemBackground))
                    .frame(width: 88, height: 88)
                    .overlay(
                        Circle()
                            .fill(
                                LinearGradient(
                                    colors: [Color(hex: "6C63FF"), Color(hex: "A78BFA")],
                                    startPoint: .topLeading,
                                    endPoint: .bottomTrailing
                                )
                            )
                            .frame(width: 80, height: 80)
                            .overlay(
                                Text("JA")
                                    .font(.title.weight(.bold))
                                    .foregroundStyle(.white)
                            )
                    )
                    .offset(y: 44)
            }

            VStack(spacing: 12) {
                Text("Jane Appleseed")
                    .font(.title3.weight(.bold))
                    .padding(.top, 48)

                Text("Senior iOS Engineer")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)

                HStack(spacing: 32) {
                    statItem(value: "234", label: "Posts")
                    statItem(value: "12.4K", label: "Followers")
                    statItem(value: "891", label: "Following")
                }
                .padding(.top, 8)

                HStack(spacing: 12) {
                    Button {} label: {
                        Text("Follow")
                            .font(.subheadline.weight(.semibold))
                            .foregroundStyle(.white)
                            .frame(maxWidth: .infinity)
                            .padding(.vertical, 10)
                            .background(Color(hex: "6C63FF"), in: RoundedRectangle(cornerRadius: 12))
                    }

                    Button {} label: {
                        Text("Message")
                            .font(.subheadline.weight(.semibold))
                            .foregroundStyle(Color(hex: "6C63FF"))
                            .frame(maxWidth: .infinity)
                            .padding(.vertical, 10)
                            .background(Color(hex: "6C63FF").opacity(0.1), in: RoundedRectangle(cornerRadius: 12))
                    }
                }
                .padding(.top, 8)
            }
            .padding(24)
        }
        .background(Color(.systemBackground))
        .cornerRadius(24)
        .shadow(color: .black.opacity(0.1), radius: 16, y: 8)
        .padding(20)
    }

    func statItem(value: String, label: String) -> some View {
        VStack(spacing: 2) {
            Text(value)
                .font(.headline)
            Text(label)
                .font(.caption)
                .foregroundStyle(.secondary)
        }
    }
}

9. Dashboard Cards with Charts

struct CircularProgressCard: View {
    let progress: Double
    let title: String
    let subtitle: String
    let color: Color

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                VStack(alignment: .leading, spacing: 4) {
                    Text(title)
                        .font(.subheadline.weight(.semibold))
                    Text(subtitle)
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
                Spacer()

                ZStack {
                    Circle()
                        .stroke(color.opacity(0.15), lineWidth: 6)
                    Circle()
                        .trim(from: 0, to: progress)
                        .stroke(color, style: StrokeStyle(lineWidth: 6, lineCap: .round))
                        .rotationEffect(.degrees(-90))
                    Text("\(Int(progress * 100))%")
                        .font(.caption2.weight(.bold))
                        .foregroundStyle(color)
                }
                .frame(width: 48, height: 48)
            }

            // Mini bar chart
            HStack(alignment: .bottom, spacing: 4) {
                ForEach(0..<7, id: \.self) { _ in
                    let height = CGFloat.random(in: 12...40)
                    RoundedRectangle(cornerRadius: 3)
                        .fill(color.opacity(Double.random(in: 0.3...1.0)))
                        .frame(height: height)
                }
            }
            .frame(height: 40)
        }
        .padding(20)
        .background(Color(.secondarySystemGroupedBackground))
        .cornerRadius(20)
    }
}

struct DashboardView: View {
    var body: some View {
        ScrollView {
            LazyVGrid(columns: [
                GridItem(.flexible(), spacing: 12),
                GridItem(.flexible(), spacing: 12),
            ], spacing: 12) {
                CircularProgressCard(
                    progress: 0.78,
                    title: "Steps",
                    subtitle: "7,800 / 10,000",
                    color: Color(hex: "2D9F6F")
                )
                CircularProgressCard(
                    progress: 0.45,
                    title: "Calories",
                    subtitle: "1,350 / 3,000",
                    color: Color(hex: "FF6B35")
                )
                CircularProgressCard(
                    progress: 0.92,
                    title: "Sleep",
                    subtitle: "7.4 / 8.0 hrs",
                    color: Color(hex: "8B5CF6")
                )
                CircularProgressCard(
                    progress: 0.60,
                    title: "Water",
                    subtitle: "1.8 / 3.0 L",
                    color: Color(hex: "0A6EBD")
                )
            }
            .padding(16)
        }
    }
}

10. Floating Action Button

struct FloatingActionButton: View {
    @State private var isExpanded = false

    let actions: [(icon: String, color: Color, label: String)] = [
        ("camera.fill", Color(hex: "EC4899"), "Photo"),
        ("doc.fill", Color(hex: "F59E0B"), "Document"),
        ("link", Color(hex: "0A6EBD"), "Link"),
    ]

    var body: some View {
        ZStack(alignment: .bottomTrailing) {
            Color.clear // Takes full space

            VStack(spacing: 12) {
                if isExpanded {
                    ForEach(Array(actions.enumerated()), id: \.offset) { index, action in
                        HStack(spacing: 12) {
                            Text(action.label)
                                .font(.subheadline.weight(.medium))
                                .foregroundStyle(.primary)
                                .padding(.horizontal, 12)
                                .padding(.vertical, 6)
                                .background(.ultraThinMaterial, in: Capsule())

                            Button {} label: {
                                Image(systemName: action.icon)
                                    .font(.system(size: 18))
                                    .foregroundStyle(.white)
                                    .frame(width: 48, height: 48)
                                    .background(action.color, in: Circle())
                                    .shadow(color: action.color.opacity(0.3), radius: 8, y: 4)
                            }
                        }
                        .transition(.asymmetric(
                            insertion: .scale.combined(with: .opacity).combined(with: .offset(y: 20)),
                            removal: .scale.combined(with: .opacity)
                        ))
                    }
                }

                Button {
                    withAnimation(.spring(response: 0.35, dampingFraction: 0.7)) {
                        isExpanded.toggle()
                    }
                } label: {
                    Image(systemName: "plus")
                        .font(.title2.weight(.semibold))
                        .foregroundStyle(.white)
                        .frame(width: 60, height: 60)
                        .background(
                            LinearGradient(
                                colors: [Color(hex: "6C63FF"), Color(hex: "8B5CF6")],
                                startPoint: .topLeading,
                                endPoint: .bottomTrailing
                            ),
                            in: Circle()
                        )
                        .shadow(color: Color(hex: "6C63FF").opacity(0.4), radius: 12, y: 6)
                        .rotationEffect(.degrees(isExpanded ? 45 : 0))
                }
            }
            .padding(24)
        }
    }
}

11. Custom Toggle with Animation

struct PremiumToggle: View {
    @Binding var isOn: Bool

    var body: some View {
        Button {
            withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
                isOn.toggle()
            }
        } label: {
            ZStack(alignment: isOn ? .trailing : .leading) {
                Capsule()
                    .fill(
                        isOn
                            ? LinearGradient(
                                colors: [Color(hex: "6C63FF"), Color(hex: "8B5CF6")],
                                startPoint: .leading, endPoint: .trailing
                              )
                            : LinearGradient(
                                colors: [Color(hex: "E2E8F0"), Color(hex: "CBD5E1")],
                                startPoint: .leading, endPoint: .trailing
                              )
                    )
                    .frame(width: 56, height: 32)

                Circle()
                    .fill(.white)
                    .frame(width: 26, height: 26)
                    .shadow(color: .black.opacity(0.15), radius: 4, y: 2)
                    .padding(3)
            }
        }
        .buttonStyle(.plain)
    }
}

struct ToggleShowcase: View {
    @State private var darkMode = false
    @State private var notifications = true

    var body: some View {
        VStack(spacing: 16) {
            HStack {
                Label("Dark Mode", systemImage: "moon.fill")
                Spacer()
                PremiumToggle(isOn: $darkMode)
            }
            Divider()
            HStack {
                Label("Notifications", systemImage: "bell.fill")
                Spacer()
                PremiumToggle(isOn: $notifications)
            }
        }
        .padding(20)
        .background(Color(.secondarySystemGroupedBackground))
        .cornerRadius(16)
        .padding(20)
    }
}

12. Swipeable Card Stack

struct SwipeableCardStack: View {
    @State private var cards: [CardData] = [
        CardData(title: "Explore Tokyo", color: Color(hex: "6C63FF"), icon: "airplane"),
        CardData(title: "Visit Paris", color: Color(hex: "EC4899"), icon: "building.columns.fill"),
        CardData(title: "Surf Bali", color: Color(hex: "06B6D4"), icon: "water.waves"),
        CardData(title: "Hike Patagonia", color: Color(hex: "2D9F6F"), icon: "mountain.2.fill"),
        CardData(title: "Safari Kenya", color: Color(hex: "F59E0B"), icon: "leaf.fill"),
    ]

    struct CardData: Identifiable {
        let id = UUID()
        let title: String
        let color: Color
        let icon: String
    }

    var body: some View {
        ZStack {
            ForEach(Array(cards.enumerated().reversed()), id: \.element.id) { index, card in
                SwipeCard(card: card) {
                    withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
                        cards.removeAll { $0.id == card.id }
                    }
                }
                .scaleEffect(1.0 - CGFloat(index) * 0.04)
                .offset(y: CGFloat(index) * 8)
                .allowsHitTesting(index == 0)
            }
        }
        .padding(32)
    }
}

struct SwipeCard: View {
    let card: SwipeableCardStack.CardData
    let onRemove: () -> Void

    @State private var offset: CGSize = .zero
    @State private var rotation: Double = 0

    var body: some View {
        VStack(spacing: 20) {
            Image(systemName: card.icon)
                .font(.system(size: 60))
                .foregroundStyle(.white)

            Text(card.title)
                .font(.title.weight(.bold))
                .foregroundStyle(.white)

            HStack(spacing: 40) {
                Image(systemName: "xmark")
                    .font(.title2.weight(.bold))
                    .foregroundStyle(.white.opacity(offset.width < -20 ? 1 : 0.3))
                Image(systemName: "heart.fill")
                    .font(.title2.weight(.bold))
                    .foregroundStyle(.white.opacity(offset.width > 20 ? 1 : 0.3))
            }
        }
        .frame(maxWidth: .infinity)
        .frame(height: 400)
        .background(
            LinearGradient(
                colors: [card.color, card.color.opacity(0.7)],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            ),
            in: RoundedRectangle(cornerRadius: 24)
        )
        .shadow(color: card.color.opacity(0.3), radius: 16, y: 8)
        .offset(offset)
        .rotationEffect(.degrees(rotation))
        .gesture(
            DragGesture()
                .onChanged { value in
                    offset = value.translation
                    rotation = Double(value.translation.width / 20)
                }
                .onEnded { value in
                    if abs(value.translation.width) > 120 {
                        withAnimation(.easeOut(duration: 0.3)) {
                            offset = CGSize(
                                width: value.translation.width > 0 ? 500 : -500,
                                height: 0
                            )
                        }
                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                            onRemove()
                        }
                    } else {
                        withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
                            offset = .zero
                            rotation = 0
                        }
                    }
                }
        )
    }
}

13. Pull-to-Refresh with Custom Animation

struct CustomRefreshView: View {
    @State private var items = (1...20).map { "Item \($0)" }
    @State private var isRefreshing = false

    var body: some View {
        NavigationStack {
            List {
                ForEach(items, id: \.self) { item in
                    HStack(spacing: 12) {
                        Circle()
                            .fill(
                                LinearGradient(
                                    colors: [Color(hex: "6C63FF"), Color(hex: "A78BFA")],
                                    startPoint: .topLeading,
                                    endPoint: .bottomTrailing
                                )
                            )
                            .frame(width: 40, height: 40)
                            .overlay(
                                Image(systemName: "star.fill")
                                    .foregroundStyle(.white)
                                    .font(.caption)
                            )
                        Text(item)
                            .font(.body)
                    }
                    .padding(.vertical, 4)
                }
            }
            .refreshable {
                isRefreshing = true
                try? await Task.sleep(for: .seconds(2))
                items.shuffle()
                isRefreshing = false
            }
            .navigationTitle("Feed")
        }
    }
}

14. Skeleton Loading / Shimmer Effect

struct ShimmerEffect: ViewModifier {
    @State private var phase: CGFloat = 0

    func body(content: Content) -> some View {
        content
            .overlay(
                LinearGradient(
                    stops: [
                        .init(color: .clear, location: phase - 0.2),
                        .init(color: .white.opacity(0.5), location: phase),
                        .init(color: .clear, location: phase + 0.2),
                    ],
                    startPoint: .topLeading,
                    endPoint: .bottomTrailing
                )
                .mask(content)
            )
            .onAppear {
                withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
                    phase = 1.2
                }
            }
    }
}

extension View {
    func shimmer() -> some View {
        modifier(ShimmerEffect())
    }
}

struct SkeletonLoadingCard: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            // Avatar + name skeleton
            HStack(spacing: 12) {
                Circle()
                    .fill(Color(.systemGray5))
                    .frame(width: 44, height: 44)

                VStack(alignment: .leading, spacing: 6) {
                    RoundedRectangle(cornerRadius: 4)
                        .fill(Color(.systemGray5))
                        .frame(width: 120, height: 14)

                    RoundedRectangle(cornerRadius: 4)
                        .fill(Color(.systemGray5))
                        .frame(width: 80, height: 10)
                }
            }

            // Image placeholder
            RoundedRectangle(cornerRadius: 12)
                .fill(Color(.systemGray5))
                .frame(height: 180)

            // Text lines
            RoundedRectangle(cornerRadius: 4)
                .fill(Color(.systemGray5))
                .frame(height: 14)

            RoundedRectangle(cornerRadius: 4)
                .fill(Color(.systemGray5))
                .frame(width: 250, height: 14)

            RoundedRectangle(cornerRadius: 4)
                .fill(Color(.systemGray5))
                .frame(width: 180, height: 14)
        }
        .padding(16)
        .shimmer()
    }
}

struct SkeletonDemo: View {
    @State private var isLoading = true

    var body: some View {
        VStack {
            if isLoading {
                SkeletonLoadingCard()
                SkeletonLoadingCard()
            } else {
                Text("Content loaded!")
                    .font(.title)
            }
        }
        .onAppear {
            DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
                withAnimation { isLoading = false }
            }
        }
    }
}

15. Toast / Snackbar Notification

struct ToastModifier: ViewModifier {
    @Binding var isShowing: Bool
    let message: String
    let icon: String
    let color: Color

    func body(content: Content) -> some View {
        ZStack(alignment: .top) {
            content

            if isShowing {
                HStack(spacing: 12) {
                    Image(systemName: icon)
                        .font(.body.weight(.semibold))
                        .foregroundStyle(.white)

                    Text(message)
                        .font(.subheadline.weight(.medium))
                        .foregroundStyle(.white)

                    Spacer()

                    Button {
                        withAnimation(.spring(response: 0.3)) { isShowing = false }
                    } label: {
                        Image(systemName: "xmark")
                            .font(.caption.weight(.bold))
                            .foregroundStyle(.white.opacity(0.7))
                    }
                }
                .padding(16)
                .background(color, in: RoundedRectangle(cornerRadius: 14))
                .shadow(color: color.opacity(0.3), radius: 12, y: 6)
                .padding(.horizontal, 16)
                .padding(.top, 8)
                .transition(.move(edge: .top).combined(with: .opacity))
                .onAppear {
                    DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
                        withAnimation(.spring(response: 0.3)) { isShowing = false }
                    }
                }
            }
        }
        .animation(.spring(response: 0.4, dampingFraction: 0.8), value: isShowing)
    }
}

extension View {
    func toast(isShowing: Binding<Bool>, message: String, icon: String = "checkmark.circle.fill", color: Color = Color(hex: "2D9F6F")) -> some View {
        modifier(ToastModifier(isShowing: isShowing, message: message, icon: icon, color: color))
    }
}

struct ToastDemo: View {
    @State private var showSuccess = false
    @State private var showError = false

    var body: some View {
        VStack(spacing: 16) {
            Button("Show Success") {
                showSuccess = true
            }
            .buttonStyle(.borderedProminent)

            Button("Show Error") {
                showError = true
            }
            .buttonStyle(.bordered)
        }
        .toast(isShowing: $showSuccess, message: "Saved successfully!")
        .toast(isShowing: $showError, message: "Something went wrong.", icon: "exclamationmark.circle.fill", color: Color(hex: "DC2626"))
    }
}

16. Expandable Card with matchedGeometryEffect

struct ExpandableCardDemo: View {
    @Namespace private var animation
    @State private var selectedCard: Int? = nil

    let cards = [
        (title: "Design", icon: "paintbrush.fill", color: Color(hex: "8B5CF6")),
        (title: "Develop", icon: "chevron.left.forwardslash.chevron.right", color: Color(hex: "0A6EBD")),
        (title: "Deploy", icon: "rocket.fill", color: Color(hex: "2D9F6F")),
    ]

    var body: some View {
        ZStack {
            // Grid of collapsed cards
            if selectedCard == nil {
                VStack(spacing: 12) {
                    ForEach(0..<cards.count, id: \.self) { index in
                        let card = cards[index]
                        HStack(spacing: 16) {
                            Image(systemName: card.icon)
                                .font(.title2)
                                .foregroundStyle(.white)
                                .frame(width: 48, height: 48)
                                .background(card.color, in: RoundedRectangle(cornerRadius: 12))
                                .matchedGeometryEffect(id: "icon\(index)", in: animation)

                            Text(card.title)
                                .font(.headline)
                                .matchedGeometryEffect(id: "title\(index)", in: animation)

                            Spacer()

                            Image(systemName: "chevron.right")
                                .foregroundStyle(.tertiary)
                        }
                        .padding(16)
                        .background(
                            RoundedRectangle(cornerRadius: 16)
                                .fill(Color(.secondarySystemGroupedBackground))
                                .matchedGeometryEffect(id: "bg\(index)", in: animation)
                        )
                        .onTapGesture {
                            withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
                                selectedCard = index
                            }
                        }
                    }
                }
                .padding(16)
            }

            // Expanded card
            if let selected = selectedCard {
                let card = cards[selected]
                VStack(spacing: 20) {
                    HStack {
                        Button {
                            withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
                                selectedCard = nil
                            }
                        } label: {
                            Image(systemName: "xmark")
                                .font(.headline)
                                .foregroundStyle(.secondary)
                                .frame(width: 36, height: 36)
                                .background(.ultraThinMaterial, in: Circle())
                        }
                        Spacer()
                    }

                    Image(systemName: card.icon)
                        .font(.system(size: 48))
                        .foregroundStyle(.white)
                        .frame(width: 96, height: 96)
                        .background(card.color, in: RoundedRectangle(cornerRadius: 24))
                        .matchedGeometryEffect(id: "icon\(selected)", in: animation)

                    Text(card.title)
                        .font(.largeTitle.weight(.bold))
                        .matchedGeometryEffect(id: "title\(selected)", in: animation)

                    Text("This is the expanded view for the \(card.title) card. Here you can place detailed content, forms, or any additional UI elements.")
                        .font(.body)
                        .foregroundStyle(.secondary)
                        .multilineTextAlignment(.center)
                        .padding(.horizontal, 20)

                    Spacer()
                }
                .padding(24)
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .background(
                    RoundedRectangle(cornerRadius: 24)
                        .fill(Color(.secondarySystemGroupedBackground))
                        .matchedGeometryEffect(id: "bg\(selected)", in: animation)
                        .ignoresSafeArea()
                )
            }
        }
    }
}

17. Animated Gradient Background

struct AnimatedGradientBackground: View {
    @State private var animateGradient = false

    var body: some View {
        LinearGradient(
            colors: [
                Color(hex: "6C63FF"),
                Color(hex: "EC4899"),
                Color(hex: "06B6D4"),
                Color(hex: "8B5CF6"),
            ],
            startPoint: animateGradient ? .topLeading : .bottomLeading,
            endPoint: animateGradient ? .bottomTrailing : .topTrailing
        )
        .ignoresSafeArea()
        .onAppear {
            withAnimation(.easeInOut(duration: 5).repeatForever(autoreverses: true)) {
                animateGradient.toggle()
            }
        }
        .overlay(
            VStack(spacing: 16) {
                Text("Welcome Back")
                    .font(.largeTitle.weight(.bold))
                    .foregroundStyle(.white)
                Text("Your animated gradient background")
                    .font(.subheadline)
                    .foregroundStyle(.white.opacity(0.8))
            }
        )
    }
}

18. Blurred Header that Changes on Scroll

struct BlurredScrollHeader: View {
    @State private var scrollOffset: CGFloat = 0
    private let headerTitle = "Discover"

    var body: some View {
        ZStack(alignment: .top) {
            ScrollView {
                VStack(spacing: 0) {
                    // Spacer for the header
                    Color.clear.frame(height: 100)

                    // Content
                    LazyVStack(spacing: 12) {
                        ForEach(0..<25, id: \.self) { i in
                            HStack(spacing: 12) {
                                RoundedRectangle(cornerRadius: 10)
                                    .fill(
                                        LinearGradient(
                                            colors: [
                                                Color(hex: "6C63FF").opacity(Double(i % 5 + 1) / 5.0),
                                                Color(hex: "EC4899").opacity(Double(i % 5 + 1) / 5.0),
                                            ],
                                            startPoint: .topLeading,
                                            endPoint: .bottomTrailing
                                        )
                                    )
                                    .frame(width: 56, height: 56)
                                    .overlay(
                                        Image(systemName: "music.note")
                                            .foregroundStyle(.white)
                                    )
                                VStack(alignment: .leading, spacing: 4) {
                                    Text("Track \(i + 1)")
                                        .font(.headline)
                                    Text("Artist Name")
                                        .font(.subheadline)
                                        .foregroundStyle(.secondary)
                                }
                                Spacer()
                                Text("3:4\(i % 10)")
                                    .font(.caption)
                                    .foregroundStyle(.tertiary)
                            }
                            .padding(.horizontal, 16)
                            .padding(.vertical, 8)
                        }
                    }
                }
                .background(
                    GeometryReader { geo in
                        Color.clear.preference(
                            key: ScrollOffsetKey.self,
                            value: geo.frame(in: .named("scroll")).minY
                        )
                    }
                )
            }
            .coordinateSpace(name: "scroll")
            .onPreferenceChange(ScrollOffsetKey.self) { value in
                scrollOffset = value
            }

            // Floating header
            VStack(spacing: 0) {
                HStack {
                    Text(headerTitle)
                        .font(scrollOffset < -20 ? .headline : .largeTitle.weight(.bold))
                        .animation(.easeInOut(duration: 0.2), value: scrollOffset < -20)
                    Spacer()
                    Image(systemName: "magnifyingglass")
                        .font(.title3)
                        .foregroundStyle(.primary)
                }
                .padding(.horizontal, 16)
                .padding(.top, 56)
                .padding(.bottom, 12)
                .background(
                    Rectangle()
                        .fill(.ultraThinMaterial)
                        .opacity(scrollOffset < -10 ? 1 : 0)
                        .ignoresSafeArea(edges: .top)
                )
            }
        }
    }
}

struct ScrollOffsetKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

19. Chip / Tag Flow Layout

struct FlowLayout: Layout {
    var spacing: CGFloat = 8

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let result = layout(proposal: proposal, subviews: subviews)
        return result.size
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let result = layout(proposal: proposal, subviews: subviews)
        for (index, position) in result.positions.enumerated() {
            subviews[index].place(
                at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y),
                proposal: ProposedViewSize(result.sizes[index])
            )
        }
    }

    private func layout(proposal: ProposedViewSize, subviews: Subviews) -> LayoutResult {
        let maxWidth = proposal.width ?? .infinity
        var positions: [CGPoint] = []
        var sizes: [CGSize] = []
        var x: CGFloat = 0
        var y: CGFloat = 0
        var rowHeight: CGFloat = 0

        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            sizes.append(size)
            if x + size.width > maxWidth && x > 0 {
                x = 0
                y += rowHeight + spacing
                rowHeight = 0
            }
            positions.append(CGPoint(x: x, y: y))
            rowHeight = max(rowHeight, size.height)
            x += size.width + spacing
        }

        return LayoutResult(
            size: CGSize(width: maxWidth, height: y + rowHeight),
            positions: positions,
            sizes: sizes
        )
    }

    struct LayoutResult {
        var size: CGSize
        var positions: [CGPoint]
        var sizes: [CGSize]
    }
}

struct ChipView: View {
    let label: String
    let color: Color
    @State private var isSelected = false

    var body: some View {
        Button {
            withAnimation(.spring(response: 0.3)) {
                isSelected.toggle()
            }
        } label: {
            Text(label)
                .font(.subheadline.weight(.medium))
                .foregroundStyle(isSelected ? .white : color)
                .padding(.horizontal, 14)
                .padding(.vertical, 8)
                .background(
                    isSelected ? AnyShapeStyle(color) : AnyShapeStyle(color.opacity(0.1)),
                    in: Capsule()
                )
                .overlay(
                    Capsule()
                        .stroke(color.opacity(isSelected ? 0 : 0.3), lineWidth: 1)
                )
        }
        .buttonStyle(.plain)
    }
}

struct ChipFlowDemo: View {
    let tags = [
        ("SwiftUI", Color(hex: "6C63FF")),
        ("iOS 18", Color(hex: "EC4899")),
        ("Design", Color(hex: "2D9F6F")),
        ("Animation", Color(hex: "FF6B35")),
        ("Accessibility", Color(hex: "0A6EBD")),
        ("Performance", Color(hex: "8B5CF6")),
        ("Dark Mode", Color(hex: "1E1B4B")),
        ("Typography", Color(hex: "F59E0B")),
        ("Color System", Color(hex: "06B6D4")),
        ("Layout", Color(hex: "DC2626")),
    ]

    var body: some View {
        FlowLayout(spacing: 8) {
            ForEach(tags, id: \.0) { tag in
                ChipView(label: tag.0, color: tag.1)
            }
        }
        .padding(20)
    }
}

20. Rating Stars Component

struct RatingStars: View {
    @Binding var rating: Int
    let maxRating: Int
    let starSize: CGFloat
    let activeColor: Color
    let inactiveColor: Color

    init(
        rating: Binding<Int>,
        maxRating: Int = 5,
        starSize: CGFloat = 28,
        activeColor: Color = Color(hex: "F59E0B"),
        inactiveColor: Color = Color(hex: "E2E8F0")
    ) {
        self._rating = rating
        self.maxRating = maxRating
        self.starSize = starSize
        self.activeColor = activeColor
        self.inactiveColor = inactiveColor
    }

    var body: some View {
        HStack(spacing: 6) {
            ForEach(1...maxRating, id: \.self) { index in
                Image(systemName: index <= rating ? "star.fill" : "star")
                    .font(.system(size: starSize))
                    .foregroundStyle(index <= rating ? activeColor : inactiveColor)
                    .symbolEffect(.bounce, value: rating)
                    .onTapGesture {
                        withAnimation(.spring(response: 0.3, dampingFraction: 0.5)) {
                            rating = index
                        }
                    }
            }
        }
    }
}

struct RatingDemo: View {
    @State private var rating1 = 3
    @State private var rating2 = 4

    var body: some View {
        VStack(spacing: 32) {
            VStack(spacing: 8) {
                Text("Rate your experience")
                    .font(.headline)
                RatingStars(rating: $rating1)
                Text("\(rating1) out of 5")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }

            VStack(spacing: 8) {
                Text("Custom style")
                    .font(.headline)
                RatingStars(
                    rating: $rating2,
                    starSize: 36,
                    activeColor: Color(hex: "EC4899"),
                    inactiveColor: Color(hex: "FCE7F3")
                )
            }
        }
        .padding(24)
    }
}

Bonus: Combining Patterns -- Premium App Screen

A full composition showing how multiple patterns work together.

struct PremiumHomeScreen: View {
    @State private var selectedTab = 0
    @State private var showToast = false

    var body: some View {
        ZStack {
            Color(.systemGroupedBackground)
                .ignoresSafeArea()

            ScrollView {
                VStack(spacing: 20) {
                    // Hero gradient header
                    ZStack(alignment: .bottomLeading) {
                        LinearGradient(
                            colors: [Color(hex: "6C63FF"), Color(hex: "8B5CF6"), Color(hex: "EC4899")],
                            startPoint: .topLeading,
                            endPoint: .bottomTrailing
                        )
                        .frame(height: 220)
                        .cornerRadius(24)

                        VStack(alignment: .leading, spacing: 8) {
                            Text("Good Morning")
                                .font(.subheadline)
                                .foregroundStyle(.white.opacity(0.8))
                            Text("Jane")
                                .font(.largeTitle.weight(.bold))
                                .foregroundStyle(.white)
                        }
                        .padding(24)
                    }
                    .padding(.horizontal, 16)

                    // Chip tags
                    ScrollView(.horizontal, showsIndicators: false) {
                        HStack(spacing: 8) {
                            ForEach(["All", "Design", "Code", "Health", "Finance"], id: \.self) { tag in
                                Text(tag)
                                    .font(.subheadline.weight(.medium))
                                    .foregroundStyle(tag == "All" ? .white : .primary)
                                    .padding(.horizontal, 16)
                                    .padding(.vertical, 8)
                                    .background(
                                        tag == "All"
                                            ? AnyShapeStyle(Color(hex: "6C63FF"))
                                            : AnyShapeStyle(Color(.secondarySystemGroupedBackground)),
                                        in: Capsule()
                                    )
                            }
                        }
                        .padding(.horizontal, 16)
                    }

                    // Dashboard grid
                    LazyVGrid(columns: [
                        GridItem(.flexible(), spacing: 12),
                        GridItem(.flexible(), spacing: 12),
                    ], spacing: 12) {
                        CircularProgressCard(
                            progress: 0.72,
                            title: "Tasks",
                            subtitle: "18 / 25",
                            color: Color(hex: "6C63FF")
                        )
                        CircularProgressCard(
                            progress: 0.45,
                            title: "Goals",
                            subtitle: "3 / 7",
                            color: Color(hex: "2D9F6F")
                        )
                    }
                    .padding(.horizontal, 16)

                    // Glass card
                    VStack(alignment: .leading, spacing: 12) {
                        HStack {
                            Image(systemName: "sparkles")
                                .foregroundStyle(Color(hex: "F59E0B"))
                            Text("Featured")
                                .font(.headline)
                            Spacer()
                            Text("NEW")
                                .font(.caption2.weight(.bold))
                                .foregroundStyle(.white)
                                .padding(.horizontal, 8)
                                .padding(.vertical, 3)
                                .background(Color(hex: "EC4899"), in: Capsule())
                        }
                        Text("Unlock premium features and take your productivity to the next level.")
                            .font(.subheadline)
                            .foregroundStyle(.secondary)

                        Button {
                            showToast = true
                        } label: {
                            Text("Upgrade Now")
                                .font(.subheadline.weight(.semibold))
                                .foregroundStyle(.white)
                                .frame(maxWidth: .infinity)
                                .padding(.vertical, 12)
                                .background(
                                    LinearGradient(
                                        colors: [Color(hex: "6C63FF"), Color(hex: "8B5CF6")],
                                        startPoint: .leading,
                                        endPoint: .trailing
                                    ),
                                    in: RoundedRectangle(cornerRadius: 12)
                                )
                        }
                    }
                    .padding(20)
                    .background(Color(.secondarySystemGroupedBackground))
                    .cornerRadius(20)
                    .padding(.horizontal, 16)

                    // Bottom spacing for tab bar
                    Color.clear.frame(height: 80)
                }
            }
        }
        .toast(isShowing: $showToast, message: "Welcome to Premium!")
    }
}

Quick Reference

Pattern

Key Techniques

Glass Morphism

.ultraThinMaterial, gradient border stroke

Neumorphism

Dual shadows (light + dark), same-as-background fill

Gradient Shadow

Shadow color matching card gradient

Onboarding

TabView(.page), custom indicators, matchedGeometryEffect

Parallax Hero

GeometryReader, offset based on minY

Bottom Sheet

DragGesture, snap points, spring animation

Animated Tab Bar

matchedGeometryEffect, @Namespace

Profile Card

Gradient header, overlapping avatar, stat row

Dashboard Cards

Circle().trim(), mini bar charts

FAB

Expand/collapse with spring, rotation

Custom Toggle

ZStack alignment toggle, spring animation

Swipe Cards

DragGesture, rotation, threshold-based removal

Pull-to-Refresh

.refreshable async modifier

Skeleton/Shimmer

ViewModifier, animated LinearGradient overlay

Toast

ViewModifier, auto-dismiss, slide transition

Expandable Card

matchedGeometryEffect, @Namespace

Animated Gradient

repeatForever animation on gradient points

Blurred Scroll Header

PreferenceKey, .ultraThinMaterial opacity

Chip Flow Layout

Custom Layout protocol, Capsule backgrounds

Rating Stars

symbolEffect(.bounce), tap gesture

---

# Third-Party Animation Integration
https://nagarjuna2997.github.io/ios-agent-skill/guides/design-third-party-animations.html

Design · Reference guideThird-Party Animation IntegrationRepository guidance for Third-Party Animation Integration. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Lottie Integration

Adding Lottie via SPM
UIKit: LottieAnimationView Basics
SwiftUI: UIViewRepresentable Wrapper
Complete SwiftUI LottieView Wrapper with Binding Controls
Playing Specific Frame Ranges
Color Value Providers for Dynamic Theming

Rive Integration

Adding Rive via SPM
RiveViewModel Basics
SwiftUI Integration
State Machines: Inputs, Triggers, Booleans, Numbers
Artboard and Animation Selection
Complete Interactive Rive Toggle Example

When to Use What

Decision Table
Summary Guidelines
File Size Comparison
Performance Characteristics

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Decide whether native is enough
↓2
Check dependency and license
↓3
Integrate an isolated effect
↓4
Test reduced motion

02 / ArchitectureResponsibility boundariesBoundary 1
Animation assetsBoundary 2
Integration boundaryBoundary 3
Native screenConnected responsibilities, not a required class hierarchy or an execution trace.
Guide for integrating Lottie and Rive animation libraries into iOS projects, with SwiftUI wrappers and usage patterns.

Lottie Integration

Lottie
 renders Adobe After Effects animations exported as JSON via the Bodymovin plugin. It is the industry standard for complex vector animations on iOS.

Adding Lottie via SPM

In Xcode: File > Add Package Dependencies, then enter:

https://github.com/airbnb/lottie-ios.git

Select the 
Lottie
 library and add it to your target. Use the latest stable version (4.x+).

UIKit: LottieAnimationView Basics

import Lottie

let animationView = LottieAnimationView(name: "loading") // Loads loading.json from bundle
animationView.contentMode = .scaleAspectFit
animationView.loopMode = .loop
animationView.animationSpeed = 1.0
animationView.frame = CGRect(x: 0, y: 0, width: 200, height: 200)
view.addSubview(animationView)
animationView.play()

SwiftUI: UIViewRepresentable Wrapper

Lottie 4.x ships with a built-in 
LottieView
 for SwiftUI. If you need more control, build a custom wrapper:

import SwiftUI
import Lottie

struct LottieAnimationUIView: UIViewRepresentable {
    let animationName: String
    var loopMode: LottieLoopMode = .loop
    var animationSpeed: CGFloat = 1.0
    @Binding var isPlaying: Bool

    func makeUIView(context: Context) -> LottieAnimationView {
        let view = LottieAnimationView(name: animationName)
        view.contentMode = .scaleAspectFit
        view.loopMode = loopMode
        view.animationSpeed = animationSpeed
        return view
    }

    func updateUIView(_ uiView: LottieAnimationView, context: Context) {
        uiView.loopMode = loopMode
        uiView.animationSpeed = animationSpeed

        if isPlaying {
            if !uiView.isAnimationPlaying {
                uiView.play()
            }
        } else {
            uiView.pause()
        }
    }
}

Complete SwiftUI LottieView Wrapper with Binding Controls

import SwiftUI
import Lottie

struct LottiePlayerView: UIViewRepresentable {
    let animationName: String
    var loopMode: LottieLoopMode = .loop
    var animationSpeed: CGFloat = 1.0
    @Binding var playbackState: PlaybackState

    enum PlaybackState {
        case playing
        case paused
        case stopped
    }

    func makeCoordinator() -> Coordinator {
        Coordinator()
    }

    func makeUIView(context: Context) -> LottieAnimationView {
        let animationView = LottieAnimationView(name: animationName)
        animationView.contentMode = .scaleAspectFit
        animationView.loopMode = loopMode
        animationView.animationSpeed = animationSpeed
        context.coordinator.animationView = animationView
        return animationView
    }

    func updateUIView(_ uiView: LottieAnimationView, context: Context) {
        uiView.loopMode = loopMode
        uiView.animationSpeed = animationSpeed

        switch playbackState {
        case .playing:
            if !uiView.isAnimationPlaying {
                uiView.play()
            }
        case .paused:
            uiView.pause()
        case .stopped:
            uiView.stop()
        }
    }

    class Coordinator {
        weak var animationView: LottieAnimationView?
    }
}

// Usage
struct LottieDemo: View {
    @State private var playbackState: LottiePlayerView.PlaybackState = .playing
    @State private var speed: CGFloat = 1.0

    var body: some View {
        VStack(spacing: 24) {
            LottiePlayerView(
                animationName: "confetti",
                loopMode: .loop,
                animationSpeed: speed,
                playbackState: $playbackState
            )
            .frame(width: 300, height: 300)

            HStack(spacing: 16) {
                Button("Play") { playbackState = .playing }
                    .buttonStyle(.borderedProminent)

                Button("Pause") { playbackState = .paused }
                    .buttonStyle(.bordered)

                Button("Stop") { playbackState = .stopped }
                    .buttonStyle(.bordered)
            }

            VStack {
                Text("Speed: \(speed, specifier: "%.1f")x")
                    .font(.subheadline)
                Slider(value: $speed, in: 0.1...3.0, step: 0.1)
            }
            .padding(.horizontal)
        }
        .padding()
    }
}

Playing Specific Frame Ranges

import Lottie

// Play frames 0 through 60
let animationView = LottieAnimationView(name: "multiSection")
animationView.play(fromFrame: 0, toFrame: 60, loopMode: .playOnce)

// Play a named marker range (markers set in After Effects)
animationView.play(fromMarker: "start", toMarker: "end", loopMode: .loop)

// Jump to a specific progress (0.0 to 1.0)
animationView.currentProgress = 0.5

Color Value Providers for Dynamic Theming

import Lottie

let animationView = LottieAnimationView(name: "icon")

// Override a specific color in the animation
let colorProvider = ColorValueProvider(UIColor.systemBlue.lottieColorValue)
animationView.setValueProvider(
    colorProvider,
    keypath: AnimationKeypath(keypath: "**.Fill 1.Color")
)

// Use a dynamic color block
let dynamicProvider = ColorValueProvider { _ in
    return UIColor.tintColor.lottieColorValue
}
animationView.setValueProvider(
    dynamicProvider,
    keypath: AnimationKeypath(keypath: "**.Stroke 1.Color")
)

Rive Integration

Rive
 is a real-time animation platform that supports state machines, making animations interactive and responsive to user input. Rive files are typically smaller than Lottie JSON.

Adding Rive via SPM

In Xcode: File > Add Package Dependencies, then enter:

https://github.com/rive-app/rive-ios.git

Select the 
RiveRuntime
 library and add it to your target.

RiveViewModel Basics

import RiveRuntime

// Load a .riv file from the bundle
let riveViewModel = RiveViewModel(fileName: "animated_icon")

// In UIKit
let riveView = riveViewModel.createRiveView()
view.addSubview(riveView)

SwiftUI Integration

Rive provides a built-in SwiftUI view through 
RiveViewModel
:

import SwiftUI
import RiveRuntime

struct RiveAnimationView: View {
    var viewModel = RiveViewModel(fileName: "loading_spinner")

    var body: some View {
        viewModel.view()
            .frame(width: 200, height: 200)
    }
}

State Machines: Inputs, Triggers, Booleans, Numbers

Rive state machines let you control animation states through inputs defined in the Rive editor.

import SwiftUI
import RiveRuntime

struct RiveStateMachineDemo: View {
    var viewModel = RiveViewModel(fileName: "interactive_button", stateMachineName: "State Machine 1")

    var body: some View {
        VStack(spacing: 20) {
            viewModel.view()
                .frame(width: 300, height: 200)

            // Trigger a one-shot input
            Button("Fire Trigger") {
                viewModel.triggerInput("pressed")
            }

            // Toggle a boolean input
            Button("Toggle Hover") {
                viewModel.setInput("isHovered", value: true)
            }

            // Set a numeric input
            Button("Set Progress") {
                viewModel.setInput("progress", value: 0.75)
            }
        }
    }
}

Artboard and Animation Selection

import RiveRuntime

// Select a specific artboard and animation
let viewModel = RiveViewModel(
    fileName: "multi_artboard",
    artboardName: "IconArtboard",
    animationName: "idle"
)

// Switch animation at runtime
viewModel.play(animationName: "active")
viewModel.pause()
viewModel.stop()

Complete Interactive Rive Toggle Example

import SwiftUI
import RiveRuntime

struct RiveToggle: View {
    @State private var isOn = false
    var viewModel = RiveViewModel(fileName: "toggle_switch", stateMachineName: "Toggle Machine")

    var body: some View {
        VStack(spacing: 24) {
            viewModel.view()
                .frame(width: 120, height: 60)
                .onTapGesture {
                    isOn.toggle()
                    viewModel.setInput("isOn", value: isOn)
                }

            Text(isOn ? "Enabled" : "Disabled")
                .font(.headline)
                .foregroundStyle(isOn ? .green : .secondary)
        }
    }
}

// A more complete settings screen with Rive toggles
struct RiveSettingsView: View {
    @State private var notificationsOn = true
    @State private var darkModeOn = false

    var notificationsVM = RiveViewModel(fileName: "toggle_switch", stateMachineName: "Toggle Machine")
    var darkModeVM = RiveViewModel(fileName: "toggle_switch", stateMachineName: "Toggle Machine")

    var body: some View {
        NavigationStack {
            List {
                HStack {
                    Label("Notifications", systemImage: "bell.fill")
                    Spacer()
                    notificationsVM.view()
                        .frame(width: 60, height: 30)
                        .onTapGesture {
                            notificationsOn.toggle()
                            notificationsVM.setInput("isOn", value: notificationsOn)
                        }
                }

                HStack {
                    Label("Dark Mode", systemImage: "moon.fill")
                    Spacer()
                    darkModeVM.view()
                        .frame(width: 60, height: 30)
                        .onTapGesture {
                            darkModeOn.toggle()
                            darkModeVM.setInput("isOn", value: darkModeOn)
                        }
                }
            }
            .navigationTitle("Settings")
        }
    }
}

When to Use What

Decision Table

Use Case

Recommended

Why

Complex vector animations from After Effects

Lottie

Direct Bodymovin export, massive community library

One-shot success/error/loading animations

Lottie

Easy to drop in, many free animations on LottieFiles

Splash screen or onboarding animations

Lottie

Smooth, high-fidelity playback

Interactive toggles, buttons, switches

Rive

State machines handle input-driven transitions

Animations that respond to data (progress, score)

Rive

Number inputs drive animations smoothly

Character animations with multiple states

Rive

State machine graph handles complex state logic

Simple fade, scale, slide transitions

Native SwiftUI

No dependency needed, GPU-accelerated

Layout-driven animations (list reorder, insert/remove)

Native SwiftUI

Built-in transition and matchedGeometryEffect

Spring physics and gesture-driven animations

Native SwiftUI

UIViewPropertyAnimator or SwiftUI springs

Animated app icons or dynamic backgrounds

Rive

Tiny file size, runtime compositing

Accessibility-sensitive animations

Native SwiftUI

Respects Reduce Motion automatically

Summary Guidelines

Choose Lottie when:

- You have a designer using After Effects who exports via Bodymovin
- You need to drop in pre-made animations from LottieFiles.com
- The animation is purely visual (no user interaction controls it)
- You need frame-accurate playback of complex vector art
- File size is not a primary concern (Lottie JSON can be large)

Choose Rive when:

- Animations need to react to user input (taps, drags, state changes)
- You want a single file with multiple animation states and transitions
- File size matters (Rive binary format is typically 5-10x smaller than Lottie JSON)
- Your designer uses the Rive editor (not After Effects)
- You need runtime color/property changes without value providers

Choose native SwiftUI/UIKit when:

- Animations are tied to state changes (show/hide, expand/collapse)
- You need gesture-driven interactive animations
- The animation is simple (fade, scale, slide, spring)
- You want zero third-party dependencies
- You need full accessibility support (Reduce Motion, VoiceOver)
- Performance is critical (native animations use Core Animation directly)

File Size Comparison

Format

Typical Size

Notes

Lottie JSON

10-500 KB

Can be compressed with dotLottie (.lottie)

Lottie dotLottie

2-100 KB

Compressed format, supported in lottie-ios 4.x

Rive (.riv)

2-50 KB

Binary format, very compact

Native code

0 KB

No additional assets needed

Performance Characteristics

Library

CPU Usage

GPU Usage

Memory

Best For

Lottie (Main Thread)

Medium-High

Low

Medium

Simple animations

Lottie (Core Animation)

Low

Medium

Low

Complex looping animations

Rive

Low

Medium

Low

Interactive animations

Native SwiftUI

Very Low

Low

Very Low

UI transitions

Core Animation

Very Low

Medium

Low

Custom layer animations

Lottie supports two rendering engines: the default Main Thread renderer and the Core Animation renderer. For looping animations, use Core Animation rendering (
LottieAnimationView.configuration = .init(renderingEngine: .coreAnimation)
) for better performance.

---

# iOS Typography System -- Complete Guide for Stunning…
https://nagarjuna2997.github.io/ios-agent-skill/guides/design-typography-system.html

Design · Reference guideiOS Typography System -- Complete Guide for Stunning SwiftUI TextRepository guidance for iOS Typography System -- Complete Guide for Stunning SwiftUI Text. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Overview
1. Apple's Built-In Text Styles

Combining Style with Weight

2. Font Designs
3. Custom Fonts

Registering Custom Fonts
Using Custom Fonts in SwiftUI
Font Extension for Clean Usage

4. SF Symbols Integration

Basic Usage
Symbol Rendering Modes
Variable Value Symbols
Symbol Effects (iOS 17+)

5. Dynamic Type Support

@ScaledMetric
minimumScaleFactor

6. Typography Hierarchy Best Practices
7. Text Effects

Gradient Text
Shadow Text
Outlined Text (Stroke)
Animated Text

8. Markdown Support in Text
9. AttributedString

AttributedString with Links and Dates

10. Stunning Text Treatment Compositions

Hero Header with Gradient and Blur
Pill Tag with Custom Typography
Statistic Display with Mixed Typography

Quick Reference

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Define text roles
↓2
Apply scalable styles
↓3
Test long localized text
↓4
Verify largest text sizes

02 / ArchitectureResponsibility boundariesBoundary 1
Semantic stylesBoundary 2
Font metricsBoundary 3
Adaptive layoutConnected responsibilities, not a required class hierarchy or an execution trace.
Overview

Typography accounts for roughly 80% of a UI's visual surface. Getting it right is the single most impactful design decision. This guide covers Apple's built-in type system, custom fonts, SF Symbols, Dynamic Type, and advanced text effects -- all with compilable SwiftUI code.

1. Apple's Built-In Text Styles

These styles scale automatically with Dynamic Type and ensure consistency across the system.

import SwiftUI

struct TextStyleCatalog: View {
    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 12) {
                Text("Large Title").font(.largeTitle)    // 34pt, used for top-level headers
                Text("Title").font(.title)               // 28pt, screen titles
                Text("Title 2").font(.title2)            // 22pt, section headers
                Text("Title 3").font(.title3)            // 20pt, sub-section headers
                Text("Headline").font(.headline)         // 17pt semibold, row labels
                Text("Subheadline").font(.subheadline)   // 15pt, secondary row labels
                Text("Body").font(.body)                 // 17pt, primary content
                Text("Callout").font(.callout)           // 16pt, annotation text
                Text("Footnote").font(.footnote)         // 13pt, timestamps, captions
                Text("Caption").font(.caption)           // 12pt, legal text
                Text("Caption 2").font(.caption2)        // 11pt, smallest readable
            }
            .padding(24)
        }
    }
}

Combining Style with Weight

struct WeightedTextExamples: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text("Ultralight Title").font(.largeTitle.weight(.ultraLight))
            Text("Thin Title").font(.largeTitle.weight(.thin))
            Text("Light Title").font(.largeTitle.weight(.light))
            Text("Regular Title").font(.largeTitle.weight(.regular))
            Text("Medium Title").font(.largeTitle.weight(.medium))
            Text("Semibold Title").font(.largeTitle.weight(.semibold))
            Text("Bold Title").font(.largeTitle.weight(.bold))
            Text("Heavy Title").font(.largeTitle.weight(.heavy))
            Text("Black Title").font(.largeTitle.weight(.black))
        }
        .padding(24)
    }
}

2. Font Designs

Apple provides four design variants of San Francisco.

struct FontDesignShowcase: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 20) {
            VStack(alignment: .leading, spacing: 4) {
                Text("Default (SF Pro)")
                    .font(.system(.title2, design: .default, weight: .bold))
                Text("Clean and neutral -- ideal for most apps")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }

            VStack(alignment: .leading, spacing: 4) {
                Text("Rounded (SF Rounded)")
                    .font(.system(.title2, design: .rounded, weight: .bold))
                Text("Friendly and approachable -- great for wellness, kids, casual")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }

            VStack(alignment: .leading, spacing: 4) {
                Text("Serif (New York)")
                    .font(.system(.title2, design: .serif, weight: .bold))
                Text("Editorial and elegant -- perfect for news, reading, luxury")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }

            VStack(alignment: .leading, spacing: 4) {
                Text("Monospaced (SF Mono)")
                    .font(.system(.title2, design: .monospaced, weight: .bold))
                Text("Technical and precise -- code editors, data, developer tools")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }
        }
        .padding(24)
    }
}

3. Custom Fonts

Registering Custom Fonts

Add 
.ttf
 or 
.otf
 files to your Xcode project.
Ensure they are added to the target's "Copy Bundle Resources" build phase.
Add each font filename to 
Info.plist
 under the key 
UIAppFonts
 (also called "Fonts provided by application").

<!-- Info.plist entry -->
<key>UIAppFonts</key>
<array>
    <string>Satoshi-Regular.otf</string>
    <string>Satoshi-Bold.otf</string>
    <string>Satoshi-Medium.otf</string>
</array>

Using Custom Fonts in SwiftUI

struct CustomFontDemo: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Custom Regular")
                .font(.custom("Satoshi-Regular", size: 17))

            Text("Custom Bold")
                .font(.custom("Satoshi-Bold", size: 28))

            // Relative to a text style (scales with Dynamic Type)
            Text("Custom with Dynamic Type")
                .font(.custom("Satoshi-Medium", size: 17, relativeTo: .body))
        }
        .padding(24)
    }
}

Font Extension for Clean Usage

extension Font {
    static func satoshi(_ weight: SatoshiWeight, size: CGFloat) -> Font {
        .custom(weight.rawValue, size: size)
    }

    static func satoshiRelative(_ weight: SatoshiWeight, size: CGFloat, relativeTo style: TextStyle) -> Font {
        .custom(weight.rawValue, size: size, relativeTo: style)
    }

    enum SatoshiWeight: String {
        case regular = "Satoshi-Regular"
        case medium = "Satoshi-Medium"
        case bold = "Satoshi-Bold"
    }
}

// Usage:
// Text("Hello").font(.satoshi(.bold, size: 24))

4. SF Symbols Integration

SF Symbols 5+ includes over 5,000 symbols that integrate seamlessly with text.

Basic Usage

struct SFSymbolsBasic: View {
    var body: some View {
        VStack(spacing: 16) {
            // Inline with text (symbols match font metrics automatically)
            Label("Favorites", systemImage: "heart.fill")
                .font(.title2)

            // Standalone image with font size
            Image(systemName: "arrow.up.right.circle.fill")
                .font(.system(size: 48))
                .foregroundStyle(.blue)

            // Symbol alongside text baseline
            HStack(alignment: .firstTextBaseline) {
                Image(systemName: "clock.fill")
                Text("5 minutes ago")
            }
            .font(.subheadline)
            .foregroundStyle(.secondary)
        }
    }
}

Symbol Rendering Modes

struct SymbolRenderingModes: View {
    var body: some View {
        VStack(spacing: 24) {
            // Monochrome -- single color
            Image(systemName: "cloud.sun.rain.fill")
                .symbolRenderingMode(.monochrome)
                .font(.system(size: 48))
                .foregroundStyle(.blue)

            // Hierarchical -- primary color with automatic opacity layers
            Image(systemName: "cloud.sun.rain.fill")
                .symbolRenderingMode(.hierarchical)
                .font(.system(size: 48))
                .foregroundStyle(.blue)

            // Palette -- explicit colors for each layer
            Image(systemName: "cloud.sun.rain.fill")
                .symbolRenderingMode(.palette)
                .font(.system(size: 48))
                .foregroundStyle(.gray, .yellow, .blue)

            // Multicolor -- system-defined colors
            Image(systemName: "cloud.sun.rain.fill")
                .symbolRenderingMode(.multicolor)
                .font(.system(size: 48))
        }
    }
}

Variable Value Symbols

struct VariableSymbolDemo: View {
    @State private var progress: Double = 0.7

    var body: some View {
        VStack(spacing: 20) {
            Image(systemName: "speaker.wave.3.fill", variableValue: progress)
                .font(.system(size: 48))
                .foregroundStyle(.blue)
                .contentTransition(.symbolEffect(.automatic))

            Slider(value: $progress, in: 0...1)
                .padding(.horizontal, 40)

            Image(systemName: "wifi", variableValue: progress)
                .font(.system(size: 48))
                .foregroundStyle(.green)
        }
        .padding()
    }
}

Symbol Effects (iOS 17+)

struct SymbolEffectsDemo: View {
    @State private var isFavorite = false
    @State private var bounceCount = 0

    var body: some View {
        VStack(spacing: 32) {
            // Bounce effect
            Image(systemName: "bell.fill")
                .font(.system(size: 44))
                .foregroundStyle(.orange)
                .symbolEffect(.bounce, value: bounceCount)
                .onTapGesture { bounceCount += 1 }

            // Pulse effect (continuous)
            Image(systemName: "heart.fill")
                .font(.system(size: 44))
                .foregroundStyle(.red)
                .symbolEffect(.pulse)

            // Replace transition
            Button {
                isFavorite.toggle()
            } label: {
                Image(systemName: isFavorite ? "heart.fill" : "heart")
                    .font(.system(size: 44))
                    .foregroundStyle(isFavorite ? .red : .gray)
                    .contentTransition(.symbolEffect(.replace))
            }

            // Breathe effect (continuous)
            Image(systemName: "lungs.fill")
                .font(.system(size: 44))
                .foregroundStyle(.teal)
                .symbolEffect(.breathe)

            // Scale effect
            Image(systemName: "star.fill")
                .font(.system(size: 44))
                .foregroundStyle(.yellow)
                .symbolEffect(.scale.up, isActive: isFavorite)
        }
        .padding()
    }
}

5. Dynamic Type Support

@ScaledMetric

Scale arbitrary numeric values proportionally to the user's Dynamic Type setting.

struct ScaledMetricDemo: View {
    @ScaledMetric(relativeTo: .title) var iconSize: CGFloat = 28
    @ScaledMetric(relativeTo: .body) var spacing: CGFloat = 12
    @ScaledMetric(relativeTo: .body) var cardPadding: CGFloat = 16

    var body: some View {
        HStack(spacing: spacing) {
            Image(systemName: "person.circle.fill")
                .font(.system(size: iconSize))
                .foregroundStyle(.blue)

            VStack(alignment: .leading, spacing: 4) {
                Text("John Appleseed")
                    .font(.headline)
                Text("iOS Developer")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }
        }
        .padding(cardPadding)
        .background(Color(.secondarySystemBackground))
        .cornerRadius(16)
    }
}

minimumScaleFactor

Prevent text from being clipped while still supporting Dynamic Type.

struct ScaleFactorDemo: View {
    var body: some View {
        Text("This very long title will shrink instead of truncating")
            .font(.title)
            .minimumScaleFactor(0.5)
            .lineLimit(1)
            .padding()
    }
}

6. Typography Hierarchy Best Practices

A clear hierarchy uses no more than 3-4 sizes with weight variation.

struct TypographyHierarchyCard: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            // Overline -- smallest, uppercase, colored
            Text("FEATURED")
                .font(.caption.weight(.bold))
                .foregroundStyle(Color(hex: "6C63FF"))
                .kerning(1.5)
                .padding(.bottom, 8)

            // Title -- largest element on card
            Text("The Art of Typography")
                .font(.title2.weight(.bold))
                .foregroundStyle(.primary)
                .padding(.bottom, 4)

            // Subtitle -- contextual info
            Text("Design Systems -- March 2026")
                .font(.subheadline)
                .foregroundStyle(.secondary)
                .padding(.bottom, 16)

            // Body -- main content
            Text("Great typography establishes visual hierarchy, guides the reader, and creates emotional resonance. In iOS, the San Francisco font family provides all the tools needed for world-class type.")
                .font(.body)
                .foregroundStyle(.primary)
                .lineSpacing(4)
                .padding(.bottom, 16)

            // Action -- button text
            Text("Read More")
                .font(.subheadline.weight(.semibold))
                .foregroundStyle(Color(hex: "6C63FF"))
        }
        .padding(24)
        .background(Color(.secondarySystemGroupedBackground))
        .cornerRadius(20)
        .padding(.horizontal, 16)
    }
}

7. Text Effects

Gradient Text

struct GradientTextView: View {
    var body: some View {
        Text("Gradient Text")
            .font(.system(size: 48, weight: .black, design: .rounded))
            .foregroundStyle(
                LinearGradient(
                    colors: [Color(hex: "8B5CF6"), Color(hex: "EC4899"), Color(hex: "06B6D4")],
                    startPoint: .leading,
                    endPoint: .trailing
                )
            )
    }
}

Shadow Text

struct ShadowTextView: View {
    var body: some View {
        VStack(spacing: 32) {
            // Subtle shadow
            Text("Soft Shadow")
                .font(.largeTitle.weight(.bold))
                .foregroundStyle(.white)
                .shadow(color: .black.opacity(0.3), radius: 8, y: 4)

            // Glow effect
            Text("Neon Glow")
                .font(.largeTitle.weight(.black))
                .foregroundStyle(Color(hex: "06B6D4"))
                .shadow(color: Color(hex: "06B6D4").opacity(0.6), radius: 12)
                .shadow(color: Color(hex: "06B6D4").opacity(0.3), radius: 24)
        }
        .padding(40)
        .background(.black)
    }
}

Outlined Text (Stroke)

struct OutlinedTextView: View {
    var body: some View {
        ZStack {
            // Stroke layer
            Text("BOLD")
                .font(.system(size: 72, weight: .black))
                .foregroundStyle(.clear)
                .overlay(
                    Text("BOLD")
                        .font(.system(size: 72, weight: .black))
                        .foregroundStyle(
                            LinearGradient(
                                colors: [Color(hex: "6C63FF"), Color(hex: "EC4899")],
                                startPoint: .topLeading,
                                endPoint: .bottomTrailing
                            )
                        )
                        .mask(
                            Text("BOLD")
                                .font(.system(size: 72, weight: .black))
                        )
                )
        }
    }
}

// Alternative approach using strokeBorder on custom shape
struct StrokedText: View {
    var body: some View {
        Text("OUTLINE")
            .font(.system(size: 64, weight: .black))
            .foregroundStyle(.clear)
            .overlay(
                Text("OUTLINE")
                    .font(.system(size: 64, weight: .black))
                    .foregroundStyle(
                        .linearGradient(
                            colors: [Color(hex: "FF6B35"), Color(hex: "F7C948")],
                            startPoint: .top,
                            endPoint: .bottom
                        )
                    )
            )
    }
}

Animated Text

struct AnimatedCounterText: View {
    @State private var value: Double = 0

    var body: some View {
        VStack(spacing: 16) {
            Text("\(value, specifier: "%.0f")")
                .font(.system(size: 72, weight: .black, design: .rounded))
                .foregroundStyle(
                    LinearGradient(
                        colors: [Color(hex: "2D9F6F"), Color(hex: "22D3EE")],
                        startPoint: .top,
                        endPoint: .bottom
                    )
                )
                .contentTransition(.numericText())

            Button("Animate") {
                withAnimation(.spring(duration: 0.8, bounce: 0.2)) {
                    value = Double.random(in: 0...9999)
                }
            }
            .buttonStyle(.borderedProminent)
        }
    }
}

struct TypewriterText: View {
    let fullText: String
    @State private var displayedText = ""
    @State private var charIndex = 0

    var body: some View {
        Text(displayedText)
            .font(.title2.weight(.medium))
            .onAppear {
                Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { timer in
                    if charIndex < fullText.count {
                        let index = fullText.index(fullText.startIndex, offsetBy: charIndex)
                        displayedText += String(fullText[index])
                        charIndex += 1
                    } else {
                        timer.invalidate()
                    }
                }
            }
    }
}

8. Markdown Support in Text

SwiftUI Text views parse Markdown automatically starting in iOS 15.

struct MarkdownTextDemo: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("This is **bold** and this is *italic*.")

            Text("Visit [Apple](https://apple.com) for more.")

            Text("Use `code` in your text.")

            Text("~~Strikethrough~~ is supported too.")

            // Combine Markdown with font styling
            Text("**Premium Plan** -- $9.99/month")
                .font(.headline)

            // Multiline Markdown
            Text("""
            # Features
            - **Fast** performance
            - *Beautiful* design
            - `Clean` code
            """)
        }
        .padding(24)
    }
}

9. AttributedString

For rich text that goes beyond Markdown, use 
AttributedString
.

struct AttributedStringDemo: View {
    var attributedGreeting: AttributedString {
        var hello = AttributedString("Hello ")
        hello.font = .title.weight(.light)
        hello.foregroundColor = .secondary

        var name = AttributedString("World")
        name.font = .title.weight(.bold)
        name.foregroundColor = .primary

        var emoji = AttributedString(" !")
        emoji.font = .title

        return hello + name + emoji
    }

    var highlightedText: AttributedString {
        var full = AttributedString("SwiftUI makes building beautiful apps incredibly fast and enjoyable.")
        full.font = .body

        if let range = full.range(of: "beautiful") {
            full[range].foregroundColor = Color(hex: "8B5CF6")
            full[range].font = .body.weight(.bold)
        }

        if let range = full.range(of: "fast") {
            full[range].foregroundColor = Color(hex: "2D9F6F")
            full[range].font = .body.weight(.bold)
        }

        return full
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 20) {
            Text(attributedGreeting)
            Text(highlightedText)
                .lineSpacing(4)
        }
        .padding(24)
    }
}

AttributedString with Links and Dates

struct RichAttributedText: View {
    var formattedText: AttributedString {
        var text = AttributedString("Updated ")
        text.font = .footnote
        text.foregroundColor = .secondary

        var date = AttributedString(Date.now, format: .dateTime.month().day().year())
        date.font = .footnote.weight(.semibold)
        date.foregroundColor = .primary

        var separator = AttributedString(" -- ")
        separator.font = .footnote

        var link = AttributedString("View Source")
        link.font = .footnote.weight(.medium)
        link.foregroundColor = Color(hex: "0A6EBD")
        link.link = URL(string: "https://developer.apple.com")

        return text + date + separator + link
    }

    var body: some View {
        Text(formattedText)
            .padding()
    }
}

10. Stunning Text Treatment Compositions

Hero Header with Gradient and Blur

struct HeroTextHeader: View {
    var body: some View {
        ZStack {
            LinearGradient(
                colors: [Color(hex: "0B0B1A"), Color(hex: "1A1A2E")],
                startPoint: .top,
                endPoint: .bottom
            )
            .ignoresSafeArea()

            VStack(spacing: 12) {
                Text("Introducing")
                    .font(.title3.weight(.medium))
                    .foregroundStyle(.white.opacity(0.6))
                    .kerning(3)
                    .textCase(.uppercase)

                Text("Premium")
                    .font(.system(size: 64, weight: .black, design: .serif))
                    .foregroundStyle(
                        LinearGradient(
                            colors: [Color(hex: "F472B6"), Color(hex: "A78BFA"), Color(hex: "6C63FF")],
                            startPoint: .leading,
                            endPoint: .trailing
                        )
                    )

                Text("Crafted with care for those who\nappreciate the finer details.")
                    .font(.body)
                    .foregroundStyle(.white.opacity(0.5))
                    .multilineTextAlignment(.center)
                    .lineSpacing(4)
            }
        }
        .frame(height: 350)
    }
}

Pill Tag with Custom Typography

struct StyledTag: View {
    let text: String
    let color: Color

    var body: some View {
        Text(text.uppercased())
            .font(.caption2.weight(.bold))
            .kerning(1.2)
            .foregroundStyle(.white)
            .padding(.horizontal, 12)
            .padding(.vertical, 6)
            .background(color, in: Capsule())
    }
}

struct TagRow: View {
    var body: some View {
        HStack(spacing: 8) {
            StyledTag(text: "SwiftUI", color: Color(hex: "6C63FF"))
            StyledTag(text: "iOS 18", color: Color(hex: "EC4899"))
            StyledTag(text: "New", color: Color(hex: "2D9F6F"))
        }
    }
}

Statistic Display with Mixed Typography

struct StatDisplay: View {
    let value: String
    let unit: String
    let label: String

    var body: some View {
        VStack(spacing: 4) {
            HStack(alignment: .firstTextBaseline, spacing: 2) {
                Text(value)
                    .font(.system(size: 40, weight: .bold, design: .rounded))
                    .foregroundStyle(.primary)
                Text(unit)
                    .font(.title3.weight(.medium))
                    .foregroundStyle(.secondary)
            }
            Text(label)
                .font(.caption.weight(.medium))
                .foregroundStyle(.tertiary)
                .textCase(.uppercase)
                .kerning(1)
        }
    }
}

struct StatsRow: View {
    var body: some View {
        HStack(spacing: 32) {
            StatDisplay(value: "2.4", unit: "M", label: "Downloads")
            StatDisplay(value: "4.9", unit: "", label: "Rating")
            StatDisplay(value: "128", unit: "K", label: "Reviews")
        }
        .padding(24)
        .background(Color(.secondarySystemGroupedBackground))
        .cornerRadius(20)
    }
}

Quick Reference

Category

Key APIs

Text Styles

.largeTitle, .title, .title2, .title3, .headline, .subheadline, .body, .callout, .footnote, .caption, .caption2

Font Designs

.default, .rounded, .serif, .monospaced

Weights

.ultraLight through .black (9 levels)

Custom Fonts

.custom("Name", size:), .custom("Name", size:, relativeTo:)

SF Symbols

.symbolRenderingMode(), .symbolEffect(), variableValue:

Dynamic Type

@ScaledMetric, .minimumScaleFactor(), relativeTo:

Text Effects

.foregroundStyle() with gradients, .shadow(), .contentTransition()

Rich Text

Markdown in Text(), AttributedString

Kerning/Tracking

.kerning(), .tracking()

Line Spacing

.lineSpacing(), .lineLimit()

---

# ActivityKit & Live Activities
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-activitykit.html

Core UI and Apps · Reference guideActivityKit & Live ActivitiesRepository guidance for ActivityKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

ActivityAttributes and ActivityContent
Starting a Live Activity
Updating a Live Activity
Ending a Live Activity
Dynamic Island Presentations
Lock Screen and Supporting Views
Push-to-Update with Push Tokens
ActivityKit Push Notification Payload
Timer and Progress Live Activities
StaleDate and Dismissal Policy
Complete Delivery Tracking Example
Widget Bundle Registration
Key Considerations

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Check activity availability
↓2
Request an activity
↓3
Update activity content
↓4
End the activity

02 / ArchitectureResponsibility boundariesBoundary 1
App stateBoundary 2
Activity contentBoundary 3
Live Activity presentationConnected responsibilities, not a required class hierarchy or an execution trace.
ActivityKit enables Live Activities that display real-time, glanceable content on the Lock Screen and Dynamic Island. Live Activities are ideal for tracking ongoing events like deliveries, sports scores, workouts, and ride-sharing trips.

ActivityAttributes and ActivityContent

ActivityAttributes define the static and dynamic data for a Live Activity. The nested 
ContentState
 contains data that changes over time.

import ActivityKit
import Foundation

// Define the attributes for a delivery tracking Live Activity
struct DeliveryAttributes: ActivityAttributes {
    // Static data — set when the activity starts, never changes
    let orderNumber: String
    let restaurantName: String
    let estimatedDeliveryTime: Date

    // Dynamic data — updated throughout the activity lifecycle
    struct ContentState: Codable, Hashable {
        let status: DeliveryStatus
        let driverName: String
        let currentStep: Int
        let totalSteps: Int
        let estimatedMinutesRemaining: Int
    }
}

enum DeliveryStatus: String, Codable, Hashable {
    case preparing
    case pickedUp
    case onTheWay
    case nearbyDropoff
    case delivered

    var displayText: String {
        switch self {
        case .preparing: return "Preparing"
        case .pickedUp: return "Picked Up"
        case .onTheWay: return "On the Way"
        case .nearbyDropoff: return "Almost There"
        case .delivered: return "Delivered"
        }
    }

    var systemImage: String {
        switch self {
        case .preparing: return "fork.knife"
        case .pickedUp: return "bag.fill"
        case .onTheWay: return "car.fill"
        case .nearbyDropoff: return "mappin.and.ellipse"
        case .delivered: return "checkmark.circle.fill"
        }
    }
}

Starting a Live Activity

Request a Live Activity by providing initial content and a stale date. The system enforces a limit of one active Live Activity per app on iPhone.

import ActivityKit

class DeliveryTracker {
    var currentActivity: Activity<DeliveryAttributes>?

    func startTracking(orderNumber: String, restaurant: String, eta: Date) throws {
        // Check if Live Activities are enabled in Settings
        guard ActivityAuthorizationInfo().areActivitiesEnabled else {
            throw DeliveryError.activitiesDisabled
        }

        let attributes = DeliveryAttributes(
            orderNumber: orderNumber,
            restaurantName: restaurant,
            estimatedDeliveryTime: eta
        )

        let initialState = DeliveryAttributes.ContentState(
            status: .preparing,
            driverName: "",
            currentStep: 1,
            totalSteps: 4,
            estimatedMinutesRemaining: 45
        )

        let content = ActivityContent(
            state: initialState,
            staleDate: Calendar.current.date(byAdding: .minute, value: 15, to: .now),
            relevanceScore: 75
        )

        do {
            currentActivity = try Activity.request(
                attributes: attributes,
                content: content,
                pushType: .token  // Enable push-to-update; use nil for local-only
            )
            print("Live Activity started: \(currentActivity?.id ?? "nil")")
        } catch {
            throw DeliveryError.failedToStart(error)
        }
    }
}

Updating a Live Activity

Update the dynamic content state at any time while the activity is active.

extension DeliveryTracker {
    func updateStatus(to status: DeliveryStatus, driver: String, step: Int, minutes: Int) async {
        guard let activity = currentActivity else { return }

        let updatedState = DeliveryAttributes.ContentState(
            status: status,
            driverName: driver,
            currentStep: step,
            totalSteps: 4,
            estimatedMinutesRemaining: minutes
        )

        // Set a new stale date with each update
        let staleDate = Calendar.current.date(byAdding: .minute, value: 10, to: .now)

        let updatedContent = ActivityContent(
            state: updatedState,
            staleDate: staleDate,
            relevanceScore: status == .nearbyDropoff ? 100 : 75
        )

        await activity.update(updatedContent)
    }
}

Ending a Live Activity

End activities with a final content state. The 
dismissalPolicy
 controls how long the ended activity remains visible on the Lock Screen.

extension DeliveryTracker {
    func markDelivered() async {
        guard let activity = currentActivity else { return }

        let finalState = DeliveryAttributes.ContentState(
            status: .delivered,
            driverName: "Marcus",
            currentStep: 4,
            totalSteps: 4,
            estimatedMinutesRemaining: 0
        )

        let finalContent = ActivityContent(
            state: finalState,
            staleDate: nil
        )

        // .default: system decides when to remove (up to 4 hours)
        // .immediate: remove right away
        // .after(Date): remove after specified date
        await activity.end(finalContent, dismissalPolicy: .default)

        currentActivity = nil
    }

    func cancelActivity() async {
        guard let activity = currentActivity else { return }
        await activity.end(nil, dismissalPolicy: .immediate)
        currentActivity = nil
    }
}

Dynamic Island Presentations

Live Activities appear in three Dynamic Island presentations. All three must be implemented in the widget bundle.

import WidgetKit
import SwiftUI

struct DeliveryLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: DeliveryAttributes.self) { context in
            // LOCK SCREEN / STANDBY presentation
            LockScreenView(context: context)

        } dynamicIsland: { context in
            DynamicIsland {
                // EXPANDED — shown when user long-presses the Dynamic Island
                DynamicIslandExpandedRegion(.leading) {
                    Image(systemName: context.state.status.systemImage)
                        .font(.title2)
                        .foregroundStyle(.blue)
                }

                DynamicIslandExpandedRegion(.trailing) {
                    Text("\(context.state.estimatedMinutesRemaining) min")
                        .font(.headline)
                        .foregroundStyle(.secondary)
                }

                DynamicIslandExpandedRegion(.center) {
                    VStack(spacing: 4) {
                        Text(context.state.status.displayText)
                            .font(.headline)
                        Text(context.attributes.restaurantName)
                            .font(.caption)
                            .foregroundStyle(.secondary)
                    }
                }

                DynamicIslandExpandedRegion(.bottom) {
                    DeliveryProgressBar(
                        currentStep: context.state.currentStep,
                        totalSteps: context.state.totalSteps
                    )
                    .padding(.top, 4)
                }

            } compactLeading: {
                // COMPACT LEADING — left side of the pill
                Image(systemName: context.state.status.systemImage)
                    .foregroundStyle(.blue)

            } compactTrailing: {
                // COMPACT TRAILING — right side of the pill
                Text("\(context.state.estimatedMinutesRemaining)m")
                    .font(.caption2)
                    .foregroundStyle(.secondary)

            } minimal: {
                // MINIMAL — shown when multiple Live Activities are active
                Image(systemName: context.state.status.systemImage)
                    .foregroundStyle(.blue)
            }
            .keylineTint(.blue)
        }
    }
}

Lock Screen and Supporting Views

struct LockScreenView: View {
    let context: ActivityViewContext<DeliveryAttributes>

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                VStack(alignment: .leading) {
                    Text(context.attributes.restaurantName)
                        .font(.headline)
                    Text("Order #\(context.attributes.orderNumber)")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
                Spacer()
                Text(context.state.status.displayText)
                    .font(.subheadline.bold())
                    .padding(.horizontal, 10)
                    .padding(.vertical, 4)
                    .background(.blue.opacity(0.2))
                    .clipShape(Capsule())
            }

            DeliveryProgressBar(
                currentStep: context.state.currentStep,
                totalSteps: context.state.totalSteps
            )

            HStack {
                if !context.state.driverName.isEmpty {
                    Label(context.state.driverName, systemImage: "person.fill")
                        .font(.caption)
                }
                Spacer()
                if context.state.estimatedMinutesRemaining > 0 {
                    Text("~\(context.state.estimatedMinutesRemaining) min remaining")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
            }

            // Show a message when content is stale
            if context.isStale {
                Label("Updating...", systemImage: "arrow.clockwise")
                    .font(.caption2)
                    .foregroundStyle(.orange)
            }
        }
        .padding()
        .activityBackgroundTint(.black.opacity(0.7))
        .activitySystemActionForegroundColor(.white)
    }
}

struct DeliveryProgressBar: View {
    let currentStep: Int
    let totalSteps: Int

    var body: some View {
        HStack(spacing: 4) {
            ForEach(1...totalSteps, id: \.self) { step in
                Capsule()
                    .fill(step <= currentStep ? Color.blue : Color.gray.opacity(0.3))
                    .frame(height: 4)
            }
        }
    }
}

Push-to-Update with Push Tokens

Register for push tokens to update Live Activities from your server. The token can change, so observe it continuously.

extension DeliveryTracker {
    func observePushToken() {
        guard let activity = currentActivity else { return }

        Task {
            for await pushToken in activity.pushTokenUpdates {
                let tokenString = pushToken.map { String(format: "%02x", $0) }.joined()
                print("Push token: \(tokenString)")

                // Send token to your server
                await sendTokenToServer(token: tokenString, activityID: activity.id)
            }
        }
    }

    private func sendTokenToServer(token: String, activityID: String) async {
        guard let url = URL(string: "https://api.example.com/live-activity/register") else { return }
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        let body: [String: String] = [
            "activityId": activityID,
            "pushToken": token
        ]
        request.httpBody = try? JSONEncoder().encode(body)

        _ = try? await URLSession.shared.data(for: request)
    }
}

ActivityKit Push Notification Payload

Send this JSON payload from your server via APNs to update or end a Live Activity.

// APNs headers:
// apns-topic: <BundleID>.push-type.liveactivity
// apns-push-type: liveactivity

// Update payload
{
    "aps": {
        "timestamp": 1699900000,
        "event": "update",
        "content-state": {
            "status": "onTheWay",
            "driverName": "Marcus",
            "currentStep": 3,
            "totalSteps": 4,
            "estimatedMinutesRemaining": 12
        },
        "stale-date": 1699900600,
        "dismissal-date": 1699904200,
        "alert": {
            "title": "Delivery Update",
            "body": "Your order is on the way!"
        },
        "sound": "default",
        "relevance-score": 100
    }
}

// End payload
{
    "aps": {
        "timestamp": 1699901000,
        "event": "end",
        "dismissal-date": 1699904600,
        "content-state": {
            "status": "delivered",
            "driverName": "Marcus",
            "currentStep": 4,
            "totalSteps": 4,
            "estimatedMinutesRemaining": 0
        },
        "alert": {
            "title": "Order Delivered",
            "body": "Your food has arrived. Enjoy!"
        }
    }
}

Timer and Progress Live Activities

Use 
Text
 with date-relative formatting for automatic countdown timers that the system updates without push notifications.

struct TimerLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: TimerAttributes.self) { context in
            HStack {
                VStack(alignment: .leading) {
                    Text(context.attributes.timerName)
                        .font(.headline)
                    // Automatic countdown timer — system updates this every second
                    Text(context.state.endTime, style: .timer)
                        .font(.system(.title, design: .monospaced))
                        .foregroundStyle(.blue)
                }
                Spacer()
                // Relative time display: "in 5 min"
                Text(context.state.endTime, style: .relative)
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
            .padding()
            .activityBackgroundTint(.black)
        } dynamicIsland: { context in
            DynamicIsland {
                DynamicIslandExpandedRegion(.center) {
                    Text(context.state.endTime, style: .timer)
                        .font(.system(.title, design: .monospaced))
                }
            } compactLeading: {
                Image(systemName: "timer")
            } compactTrailing: {
                Text(context.state.endTime, style: .timer)
                    .frame(width: 50)
                    .font(.caption2.monospacedDigit())
            } minimal: {
                // Progress ring for minimal view
                ProgressView(
                    timerInterval: context.state.startTime...context.state.endTime,
                    countsDown: true
                ) {
                    EmptyView()
                }
                .progressViewStyle(.circular)
                .tint(.blue)
            }
        }
    }
}

struct TimerAttributes: ActivityAttributes {
    let timerName: String

    struct ContentState: Codable, Hashable {
        let startTime: Date
        let endTime: Date
    }
}

StaleDate and Dismissal Policy

Control how stale data is displayed and when ended activities are removed.

// Observe activity state changes across the app lifecycle
func observeAllActivities() {
    Task {
        // Monitor activities for this attribute type
        for await activity in Activity<DeliveryAttributes>.activityUpdates {
            print("New activity: \(activity.id)")

            Task {
                for await state in activity.activityStateUpdates {
                    switch state {
                    case .active:
                        print("Activity is active")
                    case .stale:
                        // Content has passed its staleDate — refresh it
                        print("Activity is stale — requesting update")
                        await refreshActivityFromServer(activity)
                    case .dismissed:
                        print("Activity was dismissed by user or system")
                    case .ended:
                        print("Activity has ended")
                    @unknown default:
                        break
                    }
                }
            }
        }
    }
}

func refreshActivityFromServer(_ activity: Activity<DeliveryAttributes>) async {
    guard let freshState = await fetchLatestState(for: activity.attributes.orderNumber) else {
        return
    }
    let content = ActivityContent(
        state: freshState,
        staleDate: Calendar.current.date(byAdding: .minute, value: 10, to: .now)
    )
    await activity.update(content)
}

func fetchLatestState(for orderNumber: String) async -> DeliveryAttributes.ContentState? {
    // Fetch from your API
    return nil
}

Complete Delivery Tracking Example

A full Livewire-style manager that coordinates the entire Live Activity lifecycle.

import ActivityKit
import Foundation

@MainActor
@Observable
class LiveDeliveryManager {
    var isTracking = false
    var currentStatus: DeliveryStatus = .preparing
    private var activity: Activity<DeliveryAttributes>?
    private var tokenObservationTask: Task<Void, Never>?

    var areActivitiesEnabled: Bool {
        ActivityAuthorizationInfo().areActivitiesEnabled
    }

    func startDelivery(order: String, restaurant: String, eta: Date) async throws {
        guard areActivitiesEnabled else {
            throw DeliveryError.activitiesDisabled
        }

        // End any existing activity first
        if activity != nil {
            await endDelivery()
        }

        let attributes = DeliveryAttributes(
            orderNumber: order,
            restaurantName: restaurant,
            estimatedDeliveryTime: eta
        )

        let initialState = DeliveryAttributes.ContentState(
            status: .preparing,
            driverName: "",
            currentStep: 1,
            totalSteps: 4,
            estimatedMinutesRemaining: 45
        )

        let content = ActivityContent(
            state: initialState,
            staleDate: Calendar.current.date(byAdding: .minute, value: 15, to: .now),
            relevanceScore: 50
        )

        activity = try Activity.request(
            attributes: attributes,
            content: content,
            pushType: .token
        )

        isTracking = true
        currentStatus = .preparing
        startObservingPushToken()
        startObservingState()
    }

    func update(status: DeliveryStatus, driver: String, step: Int, minutes: Int) async {
        guard let activity else { return }

        let state = DeliveryAttributes.ContentState(
            status: status,
            driverName: driver,
            currentStep: step,
            totalSteps: 4,
            estimatedMinutesRemaining: minutes
        )

        let content = ActivityContent(
            state: state,
            staleDate: Calendar.current.date(byAdding: .minute, value: 10, to: .now),
            relevanceScore: status == .nearbyDropoff ? 100 : 75
        )

        await activity.update(content)
        currentStatus = status
    }

    func endDelivery() async {
        guard let activity else { return }

        let finalState = DeliveryAttributes.ContentState(
            status: .delivered,
            driverName: "Driver",
            currentStep: 4,
            totalSteps: 4,
            estimatedMinutesRemaining: 0
        )

        let content = ActivityContent(state: finalState, staleDate: nil)
        await activity.end(content, dismissalPolicy: .after(
            Calendar.current.date(byAdding: .hour, value: 1, to: .now)!
        ))

        tokenObservationTask?.cancel()
        self.activity = nil
        isTracking = false
        currentStatus = .delivered
    }

    private func startObservingPushToken() {
        guard let activity else { return }
        tokenObservationTask?.cancel()

        tokenObservationTask = Task {
            for await token in activity.pushTokenUpdates {
                let tokenString = token.map { String(format: "%02x", $0) }.joined()
                await registerToken(tokenString, activityID: activity.id)
            }
        }
    }

    private func startObservingState() {
        guard let activity else { return }

        Task {
            for await state in activity.activityStateUpdates {
                switch state {
                case .dismissed, .ended:
                    self.isTracking = false
                    self.activity = nil
                default:
                    break
                }
            }
        }
    }

    private func registerToken(_ token: String, activityID: String) async {
        // Send to your backend
        print("Registering token \(token) for activity \(activityID)")
    }
}

enum DeliveryError: LocalizedError {
    case activitiesDisabled
    case failedToStart(Error)

    var errorDescription: String? {
        switch self {
        case .activitiesDisabled:
            return "Live Activities are disabled in Settings."
        case .failedToStart(let error):
            return "Failed to start activity: \(error.localizedDescription)"
        }
    }
}

Widget Bundle Registration

Register the Live Activity widget alongside your other widgets.

import WidgetKit
import SwiftUI

@main
struct AppWidgets: WidgetBundle {
    var body: some Widget {
        DeliveryLiveActivity()
        TimerLiveActivity()
        // Other widgets...
    }
}

Key Considerations

Size limit
: Live Activity UI is rendered at a fixed size; keep content concise.
Update frequency
: The system may throttle updates. Budget approximately one update per hour for push updates; local updates have a higher budget.
Stale date
: Always set a stale date so your UI can show a refresh indicator when data is old.
Push payload size
: The APNs payload for Live Activities must be under 4 KB.
Background
: Live Activities use 
activityBackgroundTint
 and 
activitySystemActionForegroundColor
 — standard SwiftUI background modifiers do not work.
Availability
: ActivityKit requires iOS 16.1+. Dynamic Island requires iPhone 14 Pro and later.
Info.plist
: Add 
NSSupportsLiveActivities
 set to 
YES
 in your app target's Info.plist.

---

# App Clips
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-app-clips.html

Core UI and Apps · Reference guideApp ClipsRepository guidance for App Clips. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

App Clip Target Setup in Xcode
Invocation URLs and Advanced Matching
NFC Tag and QR Code Triggers
App Clip Card Configuration
Size Limitations (15 MB)
App Group Data Handoff to Full App
Location Confirmation with CLAppClipCodeLocation
SKOverlay for Full App Promotion
Complete App Clip Example
Key Considerations

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Resolve invocation URL
↓2
Load the focused experience
↓3
Complete the short task
↓4
Offer full-app continuity

02 / ArchitectureResponsibility boundariesBoundary 1
Invocation linkBoundary 2
App Clip targetBoundary 3
Shared domain logicConnected responsibilities, not a required class hierarchy or an execution trace.
App Clips are lightweight versions of your app that users can discover and launch instantly from NFC tags, QR codes, Safari Smart Banners, Maps, and Messages. They provide focused functionality without requiring a full app download, with a strict 15 MB size limit.

App Clip Target Setup in Xcode

An App Clip is a separate target in your Xcode project that shares code with the main app through shared frameworks or file membership.

// 1. In Xcode: File > New > Target > App Clip
// 2. The App Clip target gets its own bundle identifier:
//    Main app: com.example.myapp
//    App Clip: com.example.myapp.Clip

// 3. App Clip entry point — same as a regular SwiftUI app
import SwiftUI

@main
struct MyAppClip: App {
    var body: some Scene {
        WindowGroup {
            AppClipRootView()
                .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
                    // Handle the invocation URL
                    handleInvocation(activity)
                }
        }
    }

    private func handleInvocation(_ activity: NSUserActivity) {
        guard let url = activity.webpageURL else { return }
        // Parse the URL to determine what to show
        // e.g., https://example.com/store/123 → show store 123
        AppClipRouter.shared.route(to: url)
    }
}

// 4. Share code between the main app and App Clip
// Use a shared framework or add files to both targets
// In Build Settings, set:
//   _APP_CLIP = 1  (for the App Clip target)

// Conditional compilation for target-specific code
#if APPCLIP
let isAppClip = true
#else
let isAppClip = false
#endif

Invocation URLs and Advanced Matching

Configure invocation URLs in App Store Connect. Each URL maps to a specific App Clip experience.

import SwiftUI

// URL routing for the App Clip
@Observable
class AppClipRouter {
    static let shared = AppClipRouter()

    var currentExperience: AppClipExperience = .default

    enum AppClipExperience {
        case `default`
        case store(storeID: String)
        case product(productID: String)
        case orderPickup(orderID: String)
    }

    func route(to url: URL) {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
            currentExperience = .default
            return
        }

        let pathComponents = components.path.split(separator: "/").map(String.init)

        // Match URL patterns:
        // https://example.com/store/123        → Store experience
        // https://example.com/product/abc      → Product experience
        // https://example.com/order/pickup/789 → Order pickup
        switch (pathComponents.first, pathComponents.dropFirst().first) {
        case ("store", let storeID?):
            currentExperience = .store(storeID: storeID)
        case ("product", let productID?):
            currentExperience = .product(productID: productID)
        case ("order", _):
            if let orderID = pathComponents.last, pathComponents.contains("pickup") {
                currentExperience = .orderPickup(orderID: orderID)
            }
        default:
            currentExperience = .default
        }
    }
}

// Root view that renders based on the invocation URL
struct AppClipRootView: View {
    var router = AppClipRouter.shared

    var body: some View {
        Group {
            switch router.currentExperience {
            case .default:
                DefaultExperienceView()
            case .store(let storeID):
                StoreExperienceView(storeID: storeID)
            case .product(let productID):
                ProductExperienceView(productID: productID)
            case .orderPickup(let orderID):
                OrderPickupView(orderID: orderID)
            }
        }
    }
}

NFC Tag and QR Code Triggers

App Clips can be triggered by NFC tags and QR codes that encode your registered invocation URL.

// NFC Tag Configuration:
// 1. Write an NDEF record to the NFC tag containing your invocation URL
// 2. The URL must match a registered App Clip experience in App Store Connect
// 3. Use Apple App Clip Codes (designed NFC + visual codes) for best UX

// QR Code Generation (for server or marketing team):
// The QR code simply encodes the invocation URL
// Example: https://appclip.example.com/store/downtown

// Reading NFC tag data within the App Clip (if needed)
import CoreNFC

class NFCReader: NSObject, NFCNDEFReaderSessionDelegate {
    var session: NFCNDEFReaderSession?

    func startScanning() {
        guard NFCNDEFReaderSession.readingAvailable else {
            print("NFC not available on this device")
            return
        }

        session = NFCNDEFReaderSession(
            delegate: self,
            queue: nil,
            invalidateAfterFirstRead: true
        )
        session?.alertMessage = "Hold your iPhone near the tag."
        session?.begin()
    }

    func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
        for message in messages {
            for record in message.records {
                if let url = record.wellKnownTypeURIPayload() {
                    print("NFC URL: \(url)")
                    Task { @MainActor in
                        AppClipRouter.shared.route(to: url)
                    }
                }
            }
        }
    }

    func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
        print("NFC session invalidated: \(error.localizedDescription)")
    }
}

App Clip Card Configuration

The App Clip Card is the system UI that appears before the App Clip launches. Configure it in App Store Connect.

// App Clip Card metadata is set in App Store Connect, not in code:
// - Header image: 3000 x 2000 px recommended
// - Title: Your app name or experience title
// - Subtitle: Brief description (up to 56 characters)
// - Call-to-action button: "Open" (default) or custom text

// In your app, provide metadata for the card via the associated website
// Add this to your webpage's <head>:
//
// <meta name="apple-itunes-app"
//       content="app-id=123456789,
//                app-clip-bundle-id=com.example.myapp.Clip,
//                app-clip-display=card">

// Smart App Banner for Safari (also triggers App Clip Card)
// <meta name="apple-itunes-app" content="app-id=123456789">

// Programmatically check if running as App Clip
import StoreKit

struct AppClipBanner: View {
    @State private var showingFullAppOverlay = false

    var body: some View {
        VStack {
            Text("You're using the App Clip")
                .font(.headline)
            Text("Download the full app for all features.")
                .font(.subheadline)
                .foregroundStyle(.secondary)
        }
    }
}

Size Limitations (15 MB)

App Clips must be under 15 MB (uncompressed, thinned for a specific device). Strategies to stay within the limit.

// Strategies to minimize App Clip size:

// 1. Share only necessary code — don't include unused frameworks
// In Build Phases, only include files the App Clip needs

// 2. Use on-demand resources for images and assets
// In the asset catalog, assign assets to "App Clip" tag
// Load them at runtime:
import Foundation

func loadOnDemandImage(tag: String) async throws -> Data {
    let request = NSBundleResourceRequest(tags: [tag])
    try await request.beginAccessingResources()
    // Access the resource
    guard let url = Bundle.main.url(forResource: "hero", withExtension: "jpg") else {
        throw AppClipError.resourceNotFound
    }
    let data = try Data(contentsOf: url)
    request.endAccessingResources()
    return data
}

enum AppClipError: Error {
    case resourceNotFound
}

// 3. Use SF Symbols instead of custom images where possible
// 4. Use system fonts instead of bundled custom fonts
// 5. Remove unused localizations
// 6. Use Asset Catalog slicing for images
// 7. Check size: Product > Archive > Distribute App > App Thinning report

// 8. Verify the thinned size:
// $ xcrun app-clip-size --app-clip-path path/to/MyAppClip.app

App Group Data Handoff to Full App

Share data from the App Clip to the full app using App Groups so users don't lose progress.

import Foundation

// Both the App Clip and main app must have the same App Group entitlement:
// group.com.example.myapp.shared

struct SharedDataManager {
    static let suiteName = "group.com.example.myapp.shared"

    // Save data from App Clip for the full app to read
    static func saveFromAppClip(userPreferences: UserPreferences) {
        guard let defaults = UserDefaults(suiteName: suiteName) else { return }

        if let data = try? JSONEncoder().encode(userPreferences) {
            defaults.set(data, forKey: "userPreferences")
        }
        defaults.set(true, forKey: "hasAppClipData")
        defaults.set(Date(), forKey: "appClipLastUsed")
    }

    // Read App Clip data from the full app
    static func loadAppClipData() -> UserPreferences? {
        guard let defaults = UserDefaults(suiteName: suiteName) else { return nil }
        guard defaults.bool(forKey: "hasAppClipData") else { return nil }

        guard let data = defaults.data(forKey: "userPreferences") else { return nil }
        return try? JSONDecoder().decode(UserPreferences.self, from: data)
    }

    // Share files via the shared container
    static var sharedContainerURL: URL? {
        FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: suiteName)
    }

    static func saveOrderData(_ order: Order) throws {
        guard let containerURL = sharedContainerURL else {
            throw AppClipError.resourceNotFound
        }
        let fileURL = containerURL.appendingPathComponent("pending_order.json")
        let data = try JSONEncoder().encode(order)
        try data.write(to: fileURL)
    }

    static func loadPendingOrder() throws -> Order? {
        guard let containerURL = sharedContainerURL else { return nil }
        let fileURL = containerURL.appendingPathComponent("pending_order.json")
        guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil }
        let data = try Data(contentsOf: fileURL)
        return try JSONDecoder().decode(Order.self, from: data)
    }
}

struct UserPreferences: Codable {
    var favoriteStoreID: String?
    var preferredPaymentMethod: String?
    var hasCompletedOnboarding: Bool
}

struct Order: Codable {
    let id: String
    let items: [OrderItem]
    let total: Decimal
    let storeID: String
}

struct OrderItem: Codable {
    let name: String
    let quantity: Int
    let price: Decimal
}

Location Confirmation with CLAppClipCodeLocation

Verify that the user is physically present at the expected location to prevent relay attacks.

import AppClip
import CoreLocation

class LocationVerifier {
    func verifyLocation(for activity: NSUserActivity) async -> Bool {
        // Check if the invocation included location data
        guard let payload = activity.appClipActivationPayload else {
            print("No App Clip activation payload")
            return false
        }

        // Define the expected region (set in App Store Connect)
        let expectedCenter = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
        let expectedRegion = CLCircularRegion(
            center: expectedCenter,
            radius: 100,  // meters
            identifier: "store-downtown"
        )

        do {
            try await payload.confirmAcquired(in: expectedRegion)
            print("Location confirmed — user is at the expected location")
            return true
        } catch let error as APActivationPayloadError {
            switch error.code {
            case .disallowed:
                print("User denied location access")
            case .doesNotMatch:
                print("User is not at the expected location")
            @unknown default:
                print("Unknown location error: \(error)")
            }
            return false
        } catch {
            print("Location verification failed: \(error)")
            return false
        }
    }
}

// Use in the App Clip's onContinueUserActivity handler
struct LocationAwareAppClip: View {
    @State private var isVerified = false
    @State private var isVerifying = true
    let verifier = LocationVerifier()

    var body: some View {
        Group {
            if isVerifying {
                ProgressView("Verifying location...")
            } else if isVerified {
                StoreExperienceView(storeID: "downtown")
            } else {
                ContentUnavailableView(
                    "Location Required",
                    systemImage: "location.slash",
                    description: Text("Please visit the store to use this App Clip.")
                )
            }
        }
        .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
            Task {
                isVerified = await verifier.verifyLocation(for: activity)
                isVerifying = false
            }
        }
    }
}

SKOverlay for Full App Promotion

Show an App Store overlay that encourages users to download the full app.

import StoreKit
import SwiftUI

// SwiftUI approach using appStoreOverlay
struct AppClipWithOverlay: View {
    @State private var showOverlay = false

    var body: some View {
        VStack(spacing: 20) {
            Text("Thanks for your order!")
                .font(.title.bold())

            Text("Download the full app to earn rewards, track orders, and more.")
                .font(.body)
                .foregroundStyle(.secondary)
                .multilineTextAlignment(.center)
                .padding(.horizontal)

            Button("Get the Full App") {
                showOverlay = true
            }
            .buttonStyle(.borderedProminent)
        }
        .appStoreOverlay(isPresented: $showOverlay) {
            SKOverlay.AppClipConfiguration(position: .bottom)
        }
        .onAppear {
            // Show overlay automatically after a delay
            Task {
                try? await Task.sleep(for: .seconds(3))
                showOverlay = true
            }
        }
    }
}

// UIKit approach for more control
import UIKit

class AppClipOverlayViewController: UIViewController, SKOverlayDelegate {
    private var overlay: SKOverlay?

    func presentFullAppOverlay() {
        let config = SKOverlay.AppClipConfiguration(position: .bottom)
        overlay = SKOverlay(configuration: config)
        overlay?.delegate = self

        guard let windowScene = view.window?.windowScene else { return }
        overlay?.present(in: windowScene)
    }

    func dismissOverlay() {
        guard let windowScene = view.window?.windowScene else { return }
        SKOverlay.dismiss(in: windowScene)
    }

    // SKOverlayDelegate
    func storeOverlayDidFinishDismissal(_ overlay: SKOverlay, transitionContext: SKOverlay.TransitionContext) {
        print("Overlay dismissed")
    }

    func storeOverlayDidFinishPresentation(_ overlay: SKOverlay, transitionContext: SKOverlay.TransitionContext) {
        print("Overlay presented")
    }

    func storeOverlay(_ overlay: SKOverlay, didFailToLoadWithError error: Error) {
        print("Overlay failed to load: \(error)")
    }
}

Complete App Clip Example

A full coffee shop ordering App Clip that demonstrates invocation handling, location verification, ordering flow, data handoff, and full app promotion.

import SwiftUI
import AppClip
import StoreKit

// MARK: - App Entry Point

@main
struct CoffeeShopClip: App {
    @State private var router = ClipRouter()

    var body: some Scene {
        WindowGroup {
            ClipContentView()
                .environment(router)
                .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
                    router.handleInvocation(activity)
                }
        }
    }
}

// MARK: - Router

@Observable
class ClipRouter {
    var shopID: String?
    var isLocationVerified = false
    var isLoading = true

    func handleInvocation(_ activity: NSUserActivity) {
        guard let url = activity.webpageURL,
              let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
            isLoading = false
            return
        }

        // Parse shop ID from URL: https://coffeeapp.example.com/shop/downtown
        let parts = components.path.split(separator: "/").map(String.init)
        if let shopIndex = parts.firstIndex(of: "shop"),
           shopIndex + 1 < parts.count {
            shopID = parts[shopIndex + 1]
        }

        // Verify location
        Task {
            await verifyLocation(activity)
            isLoading = false
        }
    }

    private func verifyLocation(_ activity: NSUserActivity) async {
        guard let payload = activity.appClipActivationPayload else {
            isLocationVerified = true  // Allow without location for testing
            return
        }

        let region = CLCircularRegion(
            center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
            radius: 200,
            identifier: "shop"
        )

        do {
            try await payload.confirmAcquired(in: region)
            isLocationVerified = true
        } catch {
            isLocationVerified = false
        }
    }
}

// MARK: - Content View

struct ClipContentView: View {
    @Environment(ClipRouter.self) private var router

    var body: some View {
        Group {
            if router.isLoading {
                ProgressView("Loading...")
            } else if let shopID = router.shopID {
                CoffeeOrderView(shopID: shopID)
            } else {
                ContentUnavailableView(
                    "Scan to Order",
                    systemImage: "qrcode.viewfinder",
                    description: Text("Scan a QR code at a participating coffee shop to start ordering.")
                )
            }
        }
    }
}

// MARK: - Order View

struct CoffeeOrderView: View {
    let shopID: String

    @State private var menuItems: [MenuItem] = MenuItem.sampleMenu
    @State private var cart: [CartItem] = []
    @State private var showCheckout = false
    @State private var showFullAppOverlay = false

    var cartTotal: Decimal {
        cart.reduce(0) { $0 + $1.menuItem.price * Decimal($1.quantity) }
    }

    var body: some View {
        NavigationStack {
            List {
                Section("Menu") {
                    ForEach(menuItems) { item in
                        MenuItemRow(item: item) {
                            addToCart(item)
                        }
                    }
                }

                if !cart.isEmpty {
                    Section("Your Order") {
                        ForEach(cart) { cartItem in
                            HStack {
                                Text(cartItem.menuItem.name)
                                Spacer()
                                Text("\(cartItem.quantity)x")
                                    .foregroundStyle(.secondary)
                                Text("$\(cartItem.menuItem.price * Decimal(cartItem.quantity))")
                                    .fontWeight(.medium)
                            }
                        }
                    }
                }
            }
            .navigationTitle("Order Coffee")
            .toolbar {
                ToolbarItem(placement: .bottomBar) {
                    if !cart.isEmpty {
                        Button {
                            showCheckout = true
                        } label: {
                            HStack {
                                Text("Checkout")
                                Spacer()
                                Text("$\(cartTotal)")
                            }
                            .frame(maxWidth: .infinity)
                        }
                        .buttonStyle(.borderedProminent)
                        .controlSize(.large)
                    }
                }
            }
            .sheet(isPresented: $showCheckout) {
                CheckoutView(
                    cart: cart,
                    total: cartTotal,
                    shopID: shopID,
                    onComplete: {
                        showFullAppOverlay = true
                        saveOrderForFullApp()
                    }
                )
            }
            .appStoreOverlay(isPresented: $showFullAppOverlay) {
                SKOverlay.AppClipConfiguration(position: .bottom)
            }
        }
    }

    private func addToCart(_ item: MenuItem) {
        if let index = cart.firstIndex(where: { $0.menuItem.id == item.id }) {
            cart[index].quantity += 1
        } else {
            cart.append(CartItem(menuItem: item, quantity: 1))
        }
    }

    private func saveOrderForFullApp() {
        let order = Order(
            id: UUID().uuidString,
            items: cart.map { OrderItem(name: $0.menuItem.name, quantity: $0.quantity, price: $0.menuItem.price) },
            total: cartTotal,
            storeID: shopID
        )
        try? SharedDataManager.saveOrderData(order)
    }
}

// MARK: - Checkout View

struct CheckoutView: View {
    let cart: [CartItem]
    let total: Decimal
    let shopID: String
    let onComplete: () -> Void

    @Environment(\.dismiss) private var dismiss
    @State private var isProcessing = false
    @State private var orderComplete = false

    var body: some View {
        NavigationStack {
            VStack(spacing: 24) {
                if orderComplete {
                    VStack(spacing: 16) {
                        Image(systemName: "checkmark.circle.fill")
                            .font(.system(size: 60))
                            .foregroundStyle(.green)

                        Text("Order Placed!")
                            .font(.title.bold())

                        Text("Your order will be ready in 5-10 minutes.")
                            .foregroundStyle(.secondary)
                    }
                    .padding(.top, 40)
                } else {
                    List {
                        Section("Order Summary") {
                            ForEach(cart) { item in
                                HStack {
                                    Text("\(item.quantity)x \(item.menuItem.name)")
                                    Spacer()
                                    Text("$\(item.menuItem.price * Decimal(item.quantity))")
                                }
                            }
                        }

                        Section {
                            HStack {
                                Text("Total")
                                    .fontWeight(.bold)
                                Spacer()
                                Text("$\(total)")
                                    .fontWeight(.bold)
                            }
                        }
                    }

                    Button {
                        placeOrder()
                    } label: {
                        if isProcessing {
                            ProgressView()
                                .frame(maxWidth: .infinity)
                        } else {
                            Text("Pay $\(total)")
                                .frame(maxWidth: .infinity)
                        }
                    }
                    .buttonStyle(.borderedProminent)
                    .controlSize(.large)
                    .disabled(isProcessing)
                    .padding()
                }

                Spacer()
            }
            .navigationTitle(orderComplete ? "Confirmed" : "Checkout")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Close") {
                        dismiss()
                    }
                }
            }
        }
    }

    private func placeOrder() {
        isProcessing = true
        Task {
            // Simulate payment processing
            try? await Task.sleep(for: .seconds(2))
            isProcessing = false
            orderComplete = true
            onComplete()
        }
    }
}

// MARK: - Supporting Views and Models

struct MenuItemRow: View {
    let item: MenuItem
    let onAdd: () -> Void

    var body: some View {
        HStack {
            VStack(alignment: .leading, spacing: 4) {
                Text(item.name)
                    .font(.headline)
                Text(item.itemDescription)
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
            Spacer()
            Text("$\(item.price)")
                .font(.subheadline.bold())
            Button {
                onAdd()
            } label: {
                Image(systemName: "plus.circle.fill")
                    .font(.title3)
            }
            .buttonStyle(.plain)
            .foregroundStyle(.blue)
        }
    }
}

struct MenuItem: Identifiable {
    let id = UUID()
    let name: String
    let itemDescription: String
    let price: Decimal

    static var sampleMenu: [MenuItem] {
        [
            MenuItem(name: "Espresso", itemDescription: "Rich single shot", price: 3.50),
            MenuItem(name: "Latte", itemDescription: "Espresso with steamed milk", price: 5.00),
            MenuItem(name: "Cold Brew", itemDescription: "Smooth, cold-steeped coffee", price: 4.50),
            MenuItem(name: "Matcha Latte", itemDescription: "Ceremonial grade matcha", price: 5.50),
            MenuItem(name: "Croissant", itemDescription: "Buttery, flaky pastry", price: 3.75)
        ]
    }
}

struct CartItem: Identifiable {
    let id = UUID()
    let menuItem: MenuItem
    var quantity: Int
}

Key Considerations

Size limit
: App Clips must be under 15 MB (thinned, uncompressed). Check with 
xcrun app-clip-size
.
Capabilities
: App Clips support Sign in with Apple, Apple Pay, notifications (for 8 hours after launch), and App Groups. They cannot access HealthKit, CallKit, or perform background networking.
Data persistence
: App Clip data may be deleted by the system after a period of inactivity. Use App Groups to hand off important data to the full app.
Invocation URLs
: Register all URLs in App Store Connect. URLs must use HTTPS. Each URL maps to one App Clip experience.
Apple App Clip Codes
: These are Apple-designed visual codes that combine NFC and visual scanning. Generate them in App Store Connect.
Notifications
: App Clips can request notification permission, but it expires 8 hours after last launch. Encourage users to download the full app for persistent notifications.
Sign in with Apple
: Credentials are shared between the App Clip and full app if both use the same team ID.
Testing
: Use the 
_XCAppClipURL
 environment variable in the Xcode scheme to simulate invocation URLs during development.

---

# App Intents
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-app-intents.html

Core UI and Apps · Reference guideApp IntentsRepository guidance for App Intents. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

AppIntent Protocol and perform()
@Parameter Property Wrapper
IntentDialog and IntentResult
AppShortcutsProvider for Siri and Shortcuts
EntityQuery for Spotlight Integration
AppIntents for Widgets (WidgetConfigurationIntent)
AppIntents for Focus Filters
Apple Intelligence Integration (SiriKit to App Intents Migration)
Interactive Widget Buttons with App Intents
Key Considerations

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Define a user action
↓2
Resolve typed parameters
↓3
Execute domain operation
↓4
Return a useful result

02 / ArchitectureResponsibility boundariesBoundary 1
Intent adapterBoundary 2
Domain serviceBoundary 3
Entities and resultsConnected responsibilities, not a required class hierarchy or an execution trace.
App Intents is Apple's modern framework for exposing app functionality to Siri, Shortcuts, Spotlight, Widgets, Focus Filters, and Apple Intelligence. It replaces SiriKit Intents with a Swift-native, protocol-driven approach that requires no intent definition files.

AppIntent Protocol and perform()

Every App Intent conforms to the 
AppIntent
 protocol and implements a 
perform()
 method that returns an 
IntentResult
.

import AppIntents

struct OpenArticleIntent: AppIntent {
    // Title shown in Shortcuts and Siri
    static var title: LocalizedStringResource = "Open Article"
    static var description: IntentDescription = "Opens a specific article in the app."

    // The system can open your app when this intent runs
    static var openAppWhenRun: Bool = true

    @Parameter(title: "Article Name")
    var articleName: String

    @MainActor
    func perform() async throws -> some IntentResult & ProvidesDialog {
        // Look up the article and navigate to it
        guard let article = ArticleStore.shared.find(byName: articleName) else {
            throw ArticleError.notFound(articleName)
        }

        NavigationManager.shared.navigate(to: article)

        return .result(dialog: "Opening \"\(article.title)\"")
    }
}

enum ArticleError: Error, CustomLocalizedStringResourceConvertible {
    case notFound(String)

    var localizedStringResource: LocalizedStringResource {
        switch self {
        case .notFound(let name):
            return "Could not find article \"\(name)\""
        }
    }
}

@Parameter Property Wrapper

Parameters define the inputs for your intent. They support default values, validation, and dynamic options.

import AppIntents

struct CreateReminderIntent: AppIntent {
    static var title: LocalizedStringResource = "Create Reminder"

    @Parameter(title: "Title", description: "The reminder title")
    var title: String

    @Parameter(title: "Due Date", description: "When the reminder is due")
    var dueDate: Date?

    @Parameter(
        title: "Priority",
        description: "Reminder priority level",
        default: .medium
    )
    var priority: ReminderPriority

    @Parameter(
        title: "Tags",
        description: "Tags to apply",
        optionsProvider: TagOptionsProvider()
    )
    var tags: [String]

    func perform() async throws -> some IntentResult & ProvidesDialog {
        let reminder = Reminder(
            title: title,
            dueDate: dueDate,
            priority: priority,
            tags: tags
        )
        try await ReminderStore.shared.save(reminder)
        return .result(dialog: "Created reminder: \(title)")
    }
}

// Enum parameter with automatic case display
enum ReminderPriority: String, AppEnum {
    case low, medium, high, urgent

    static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Priority")

    static var caseDisplayRepresentations: [ReminderPriority: DisplayRepresentation] = [
        .low: "Low",
        .medium: "Medium",
        .high: "High",
        .urgent: "Urgent"
    ]
}

// Dynamic options provider for parameter suggestions
struct TagOptionsProvider: DynamicOptionsProvider {
    func results() async throws -> [String] {
        await TagStore.shared.allTags().map(\.name)
    }
}

IntentDialog and IntentResult

Intents return results that can include dialogs, snippets (SwiftUI views), and values.

import AppIntents
import SwiftUI

struct CheckWeatherIntent: AppIntent {
    static var title: LocalizedStringResource = "Check Weather"

    @Parameter(title: "City")
    var city: String

    // Return multiple result types with protocols
    func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView & ReturnsValue<String> {
        let weather = try await WeatherService.shared.fetch(for: city)

        let summary = "\(weather.condition) — \(weather.temperature)°F"

        return .result(
            value: summary,
            dialog: IntentDialog(stringLiteral: "It's \(summary) in \(city)."),
            view: WeatherSnippetView(weather: weather)
        )
    }
}

struct WeatherSnippetView: View {
    let weather: WeatherData

    var body: some View {
        HStack(spacing: 16) {
            Image(systemName: weather.symbolName)
                .font(.largeTitle)
                .foregroundStyle(.blue)
            VStack(alignment: .leading) {
                Text("\(weather.temperature)°F")
                    .font(.title.bold())
                Text(weather.condition)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }
        }
        .padding()
    }
}

struct WeatherData {
    let temperature: Int
    let condition: String
    let symbolName: String
}

AppShortcutsProvider for Siri and Shortcuts

Expose your intents as system shortcuts that appear in Siri, the Shortcuts app, and Spotlight without user configuration.

import AppIntents

struct AppShortcuts: AppShortcutsProvider {
    // The App Shortcuts the system surfaces automatically
    static var appShortcuts: [AppShortcut] {
        AppShortcut(
            intent: OpenArticleIntent(),
            phrases: [
                "Open \(\.$articleName) in \(.applicationName)",
                "Show article \(\.$articleName) in \(.applicationName)",
                "Read \(\.$articleName) with \(.applicationName)"
            ],
            shortTitle: "Open Article",
            systemImageName: "doc.richtext"
        )

        AppShortcut(
            intent: CreateReminderIntent(),
            phrases: [
                "Create a reminder in \(.applicationName)",
                "Add a reminder with \(.applicationName)",
                "Remind me in \(.applicationName)"
            ],
            shortTitle: "Create Reminder",
            systemImageName: "checklist"
        )

        AppShortcut(
            intent: CheckWeatherIntent(),
            phrases: [
                "Check weather in \(\.$city) with \(.applicationName)",
                "What's the weather in \(\.$city)"
            ],
            shortTitle: "Check Weather",
            systemImageName: "cloud.sun"
        )
    }
}

EntityQuery for Spotlight Integration

Entities represent searchable objects in your app. EntityQuery lets Spotlight and the system find and display them.

import AppIntents
import CoreSpotlight

// Define an entity that Spotlight can index and display
struct ArticleEntity: AppEntity {
    static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Article")

    static var defaultQuery = ArticleQuery()

    var id: String
    var title: String
    var summary: String
    var category: String

    var displayRepresentation: DisplayRepresentation {
        DisplayRepresentation(
            title: LocalizedStringResource(stringLiteral: title),
            subtitle: LocalizedStringResource(stringLiteral: category),
            image: .init(systemName: "doc.richtext")
        )
    }
}

// Query that the system uses to find entities
struct ArticleQuery: EntityQuery {
    // Find entities by their identifiers
    func entities(for identifiers: [String]) async throws -> [ArticleEntity] {
        let articles = await ArticleStore.shared.fetchAll()
        return articles
            .filter { identifiers.contains($0.id) }
            .map { ArticleEntity(id: $0.id, title: $0.title, summary: $0.summary, category: $0.category) }
    }

    // Provide suggestions when the user is picking an entity
    func suggestedEntities() async throws -> [ArticleEntity] {
        let recent = await ArticleStore.shared.fetchRecent(limit: 10)
        return recent.map {
            ArticleEntity(id: $0.id, title: $0.title, summary: $0.summary, category: $0.category)
        }
    }
}

// Enable string-based search for entities
extension ArticleQuery: EntityStringQuery {
    func entities(matching query: String) async throws -> [ArticleEntity] {
        let results = await ArticleStore.shared.search(query)
        return results.map {
            ArticleEntity(id: $0.id, title: $0.title, summary: $0.summary, category: $0.category)
        }
    }
}

// Use entities in intents
struct ReadArticleIntent: AppIntent {
    static var title: LocalizedStringResource = "Read Article"

    @Parameter(title: "Article")
    var article: ArticleEntity

    static var openAppWhenRun: Bool = true

    @MainActor
    func perform() async throws -> some IntentResult & ProvidesDialog {
        NavigationManager.shared.navigateToArticle(id: article.id)
        return .result(dialog: "Opening \"\(article.title)\"")
    }
}

AppIntents for Widgets (WidgetConfigurationIntent)

Use 
WidgetConfigurationIntent
 to let users configure widgets through the App Intents system.

import AppIntents
import WidgetKit
import SwiftUI

// Widget configuration intent — users pick options in the widget editor
struct SelectCategoryIntent: WidgetConfigurationIntent {
    static var title: LocalizedStringResource = "Select Category"
    static var description: IntentDescription = "Choose which category to display."

    @Parameter(title: "Category", default: .all)
    var category: WidgetCategory

    @Parameter(title: "Show Count", default: true)
    var showCount: Bool

    @Parameter(title: "Max Items", default: 5)
    var maxItems: Int
}

enum WidgetCategory: String, AppEnum {
    case all, favorites, recent, trending

    static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Category")

    static var caseDisplayRepresentations: [WidgetCategory: DisplayRepresentation] = [
        .all: DisplayRepresentation(title: "All", image: .init(systemName: "square.grid.2x2")),
        .favorites: DisplayRepresentation(title: "Favorites", image: .init(systemName: "star.fill")),
        .recent: DisplayRepresentation(title: "Recent", image: .init(systemName: "clock")),
        .trending: DisplayRepresentation(title: "Trending", image: .init(systemName: "flame"))
    ]
}

// Widget using the configuration intent
struct CategoryWidget: Widget {
    let kind = "CategoryWidget"

    var body: some WidgetConfiguration {
        AppIntentConfiguration(
            kind: kind,
            intent: SelectCategoryIntent.self,
            provider: CategoryProvider()
        ) { entry in
            CategoryWidgetView(entry: entry)
                .containerBackground(.fill.tertiary, for: .widget)
        }
        .configurationDisplayName("Category")
        .description("Shows items from a selected category.")
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

struct CategoryEntry: TimelineEntry {
    let date: Date
    let category: WidgetCategory
    let items: [String]
    let showCount: Bool
}

struct CategoryProvider: AppIntentTimelineProvider {
    func placeholder(in context: Context) -> CategoryEntry {
        CategoryEntry(date: .now, category: .all, items: ["Loading..."], showCount: true)
    }

    func snapshot(for configuration: SelectCategoryIntent, in context: Context) async -> CategoryEntry {
        CategoryEntry(date: .now, category: configuration.category, items: ["Sample Item"], showCount: configuration.showCount)
    }

    func timeline(for configuration: SelectCategoryIntent, in context: Context) async -> Timeline<CategoryEntry> {
        let items = await fetchItems(for: configuration.category, limit: configuration.maxItems)
        let entry = CategoryEntry(
            date: .now,
            category: configuration.category,
            items: items,
            showCount: configuration.showCount
        )
        let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: .now)!
        return Timeline(entries: [entry], policy: .after(nextUpdate))
    }

    private func fetchItems(for category: WidgetCategory, limit: Int) async -> [String] {
        return ["Item 1", "Item 2", "Item 3"]
    }
}

struct CategoryWidgetView: View {
    let entry: CategoryEntry

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            HStack {
                Text(entry.category.rawValue.capitalized)
                    .font(.headline)
                if entry.showCount {
                    Spacer()
                    Text("\(entry.items.count)")
                        .font(.caption.bold())
                        .padding(.horizontal, 6)
                        .padding(.vertical, 2)
                        .background(.blue.opacity(0.2))
                        .clipShape(Capsule())
                }
            }
            ForEach(entry.items, id: \.self) { item in
                Text(item)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }
        }
    }
}

AppIntents for Focus Filters

Let users customize your app's behavior when a specific Focus mode is active.

import AppIntents

struct AppFocusFilter: SetFocusFilterIntent {
    static var title: LocalizedStringResource = "Set App Focus Filter"
    static var description: IntentDescription = "Customize which content is shown during Focus."

    @Parameter(title: "Show Notifications", default: true)
    var showNotifications: Bool

    @Parameter(title: "Category Filter")
    var category: WidgetCategory?

    @Parameter(title: "Mute Sounds", default: false)
    var muteSounds: Bool

    // The display representation shown in Focus settings
    var displayRepresentation: DisplayRepresentation {
        DisplayRepresentation(
            title: "App Focus",
            subtitle: category.map { "Showing \($0.rawValue)" } ?? "All content",
            image: .init(systemName: "app.badge")
        )
    }

    func perform() async throws -> some IntentResult {
        // Apply the focus configuration to your app
        await FocusManager.shared.apply(
            showNotifications: showNotifications,
            category: category,
            muteSounds: muteSounds
        )
        return .result()
    }
}

Apple Intelligence Integration (SiriKit to App Intents Migration)

App Intents is the foundation for Apple Intelligence features. Migrate from SiriKit to App Intents to enable AI-powered interactions.

import AppIntents

// Assistive intent that Apple Intelligence can invoke contextually
struct SendMessageIntent: AppIntent {
    static var title: LocalizedStringResource = "Send Message"
    static var description: IntentDescription = IntentDescription(
        "Send a message to a contact.",
        categoryName: "Messaging"
    )

    // Apple Intelligence can extract these from natural language
    @Parameter(title: "Recipient")
    var recipient: ContactEntity

    @Parameter(title: "Message")
    var message: String

    // Confirmation dialog before performing a sensitive action
    static var isDiscoverable: Bool = true

    func perform() async throws -> some IntentResult & ProvidesDialog {
        try await MessageService.shared.send(message, to: recipient.id)
        return .result(dialog: "Message sent to \(recipient.name).")
    }
}

// Contact entity for Apple Intelligence to reference
struct ContactEntity: AppEntity {
    static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Contact")
    static var defaultQuery = ContactQuery()

    var id: String
    var name: String
    var email: String

    var displayRepresentation: DisplayRepresentation {
        DisplayRepresentation(
            title: LocalizedStringResource(stringLiteral: name),
            subtitle: LocalizedStringResource(stringLiteral: email)
        )
    }
}

struct ContactQuery: EntityQuery {
    func entities(for identifiers: [String]) async throws -> [ContactEntity] {
        await ContactStore.shared.fetch(ids: identifiers).map {
            ContactEntity(id: $0.id, name: $0.name, email: $0.email)
        }
    }

    func suggestedEntities() async throws -> [ContactEntity] {
        await ContactStore.shared.frequentContacts(limit: 10).map {
            ContactEntity(id: $0.id, name: $0.name, email: $0.email)
        }
    }
}

extension ContactQuery: EntityStringQuery {
    func entities(matching query: String) async throws -> [ContactEntity] {
        await ContactStore.shared.search(query).map {
            ContactEntity(id: $0.id, name: $0.name, email: $0.email)
        }
    }
}

Interactive Widget Buttons with App Intents

Use App Intents to make widgets interactive with tappable buttons and toggles.

import AppIntents
import SwiftUI
import WidgetKit

// Intent performed when user taps a widget button
struct ToggleFavoriteIntent: AppIntent {
    static var title: LocalizedStringResource = "Toggle Favorite"

    @Parameter(title: "Item ID")
    var itemID: String

    init() {}

    init(itemID: String) {
        self.itemID = itemID
    }

    func perform() async throws -> some IntentResult {
        await ItemStore.shared.toggleFavorite(id: itemID)
        return .result()
    }
}

// Widget view with an interactive button
struct InteractiveWidgetView: View {
    let item: WidgetItem

    var body: some View {
        VStack {
            Text(item.name)
                .font(.headline)

            // This button runs the intent directly from the widget
            Button(intent: ToggleFavoriteIntent(itemID: item.id)) {
                Image(systemName: item.isFavorite ? "star.fill" : "star")
                    .foregroundStyle(item.isFavorite ? .yellow : .gray)
            }
            .buttonStyle(.plain)
        }
    }
}

struct WidgetItem {
    let id: String
    let name: String
    let isFavorite: Bool
}

Key Considerations

Availability
: App Intents requires iOS 16+. WidgetConfigurationIntent and Focus Filters require iOS 16+. AppShortcutsProvider requires iOS 16.4+.
Thread safety
: The 
perform()
 method can run on any thread. Use 
@MainActor
 when accessing UI state.
Error display
: Throw errors conforming to 
CustomLocalizedStringResourceConvertible
 so Siri and Shortcuts display meaningful messages.
Phrases
: Include 
\(.applicationName)
 in at least one phrase per AppShortcut so Siri associates the phrase with your app.
Testing
: Use the Shortcuts app and Siri to test intents. Use the 
xcrun simctl
 command to trigger intents in the simulator.
Migration
: SiriKit INIntent definitions can coexist with App Intents. Migrate incrementally by implementing the same functionality with App Intents and marking the SiriKit version as deprecated.

---

# Apple Intelligence
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-apple-intelligence.html

AI and Machine Learning · Reference guideApple IntelligenceRepository guidance for Apple Intelligence. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

1. Which framework does what
2. The privacy model

What you may claim
Declare it

3. App Intents — the front door to Siri
4. Image Playground
5. Visual Intelligence
6. Designing an AI feature that degrades
Anti-Patterns
Checklist

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Identify an intelligence feature
↓2
Check device and model readiness
↓3
Run a bounded request
↓4
Offer a non-AI fallback

02 / ArchitectureResponsibility boundariesBoundary 1
Feature interfaceBoundary 2
Capability boundaryBoundary 3
Model or fallbackConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
 deciding how an app should surface AI features, integrating
with Siri and system intelligence, using Private Cloud Compute, generating
images, or reasoning about the privacy guarantees you can state to users.

Apple Intelligence is the system layer, not a single API. Apps reach it through
several frameworks; this document routes you to the right one and covers the
privacy model that governs all of them.

1. Which framework does what

You want to…

Use

Doc

Run a prompt, generate structured data, call tools

Foundation Models

foundation-models.md

Let Siri and Spotlight invoke your app's actions

App Intents

app-intents.md

Generate images

Image Playground API

this doc, §4

Identify things in images and act on them

Visual Intelligence / VisionKit

this doc, §5

Run your own ML model

Core ML

ml/coreml.md

OCR, detection, segmentation

Vision

ml/vision.md

The most common mistake is reaching for a language model when App Intents is
the answer.
 If the user's goal is "do a thing in my app," that is an intent —
it is faster, deterministic, testable, and works without a model being available.
Use Foundation Models for open-ended generation, not for dispatching actions.

2. The privacy model

This is the part you must get right, because it determines what you may tell
users.

On-device (SystemLanguageModel)

- The prompt never leaves the device.
- Works offline. No account, no API key, no cost.
- Bounded by device capability: smaller context, lighter reasoning.

Private Cloud Compute (PrivateCloudComputeLanguageModel, iOS 27+)

- Runs on Apple silicon servers built for this purpose.
- 
Prompts are not retained.
 Data is used to serve the request and nothing else.
- No account setup, authentication, or API key required.
- Requires a network. Adds latency.
- Free for developers under the small-business download threshold.

Third-party models
 (via a custom 
LanguageModel
 conformance)
- 
Apple's privacy guarantees do not extend to these.
 A prompt sent to a
  third-party endpoint is governed by that vendor's terms.
- If your app can route to one, say so in your privacy policy and — where the
  content is sensitive — in the UI at the point of use.

What you may claim

On-device only        "Processed on your device."                    ✅
Private Cloud Compute "Processed by Apple. Your data isn't stored."  ✅
Third-party model     "Processed on your device."                    ❌ false
Any path              "We never see your data" (if you log prompts)  ❌ false

Do not log prompt or response content to your own analytics and simultaneously
describe the feature as private. If you log, say so.

Declare it

User-facing AI features that process personal data need the corresponding entries
in 
PrivacyInfo.xcprivacy
, and any third-party model SDK carries its own privacy
manifest requirements. See 
docs/design/interaction-standards.md
 §7.

3. App Intents — the front door to Siri

App Intents is how Apple Intelligence discovers what your app can do. It is not
optional plumbing; it is the integration surface.

struct AddRecipeIntent: AppIntent {
    static let title: LocalizedStringResource = "Add Recipe"
    static let description = IntentDescription("Saves a recipe to the user's collection.")

    @Parameter(title: "Name")
    var name: String

    @MainActor
    func perform() async throws -> some IntentResult & ProvidesDialog {
        try await RecipeStore.shared.add(named: name)
        return .result(dialog: "Saved \(name).")
    }
}

Points that matter:

Adopt system-defined schemas
 where one fits your domain. A schema-conforming
  intent gets natural-language handling for free, and does not depend on you
  guessing phrasings.
Do not hardcode invocation phrases.
 The system maps language to intents and
  keeps improving; hardcoded phrases freeze you at today's behavior.
Index your content
 with Core Spotlight so semantic search and on-device
  retrieval can find it.
New this year:
 a View Annotations API for mapping on-screen entities, and an
  App Intents testing framework that validates intents without UI automation —
  use it instead of XCUITest for intent coverage.

Full reference: 
docs/frameworks/app-intents.md
.

4. Image Playground

Generates images on Private Cloud Compute — photorealistic or stylized, from text
or a source photo.

import ImagePlayground

struct ComposeView: View {
    @State private var showGenerator = false
    @State private var generatedURL: URL?

    var body: some View {
        Button("Generate image") { showGenerator = true }
            .imagePlaygroundSheet(isPresented: $showGenerator) { url in
                generatedURL = url
            }
    }
}

Treat generation as 
optional and fallible
: it needs a network, can be
unavailable by region, and the user can cancel. The compose flow must work
without it.

5. Visual Intelligence

Lets the system surface your app's content when the user points at something in
the real world or on screen. You supply entities and the intents that act on them
(open, play, buy), and the system routes matches to your app.

Worth adopting when your app has a catalogue of real-world things — products,
plants, landmarks, media. Not worth it for a to-do list.

6. Designing an AI feature that degrades

Apple Intelligence is not available on every device, in every region, or in every
language. Availability is a runtime condition, not a compile-time one.

@MainActor
@Observable
final class SmartComposeModel {
    enum Mode { case intelligent, manual }

    private(set) var mode: Mode = .manual

    func configure() {
        guard #available(iOS 26.0, *) else { return }
        if case .available = SystemLanguageModel.default.availability {
            mode = .intelligent
        }
    }
}

Design rules:

The non-AI path is the product.
 The AI path is an enhancement layered on it.
  If removing the model breaks the feature, the feature is mis-scoped.
Never show a disabled AI button with no explanation.
 Either hide the entry
  point or state why it is unavailable.
Label generated content.
 Users should be able to tell what a model produced,
  especially before they send or publish it.
Always allow editing before commit.
 Do not auto-send, auto-post, or
  auto-purchase from generated output.
Latency is a design problem.
 Stream partial results; do not block the UI on
  a round trip. See 
foundation-models.md
 §2.

Anti-Patterns

// 1. A language model where an App Intent belongs.
"Parse the user's sentence and figure out which screen to open."
// Use App Intents. Deterministic, testable, works with no model.

// 2. Claiming privacy you do not provide.
Text("Processed entirely on your device.")   // while routing to a third-party API

// 3. Assuming availability.
let session = LanguageModelSession()          // fails on ineligible devices/regions
// Check SystemLanguageModel.default.availability.

// 4. An AI-only feature with no fallback.
// Unusable for every user without Apple Intelligence.

// 5. Auto-committing generated content.
send(generatedReply)                          // user never saw it
// Always let the user read and edit first.

// 6. Private Cloud Compute as the default.
// Costs latency and a network dependency. Start on-device.

// 7. Hardcoded Siri phrases.
// Freezes you at today's phrasing. Use schemas.

// 8. Logging prompts to analytics while marketing the feature as private.

Checklist

[ ] The right framework is used — App Intents for actions, Foundation Models for
      generation.
[ ] Availability is checked at runtime, not just with 
@available
.
[ ] The feature degrades to a working non-AI path.
[ ] Privacy claims match the actual execution path, including third-party models.
[ ] 
PrivacyInfo.xcprivacy
 reflects any personal data processed.
[ ] Generated content is labelled and editable before it is committed.
[ ] Long generations stream rather than blocking.
[ ] App content is indexed in Spotlight for retrieval and semantic search.
[ ] Intents are covered by the App Intents testing framework.

---

# ARKit -- Complete Guide for Augmented Reality on iOS and…
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-arkit.html

Graphics, 3D, and Games · Reference guideARKit -- Complete Guide for Augmented Reality on iOS and iPadOSRepository guidance for ARKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Overview
1. Permissions and Info.plist
2. Choosing a Configuration
3. Running an AR Session with RealityKit (Recommended)

SwiftUI lifecycle

4. Plane and Mesh Detection

Plane anchors
LiDAR scene reconstruction

5. Image and Object Tracking
6. Face Tracking and Blendshapes
7. Body Tracking (Motion Capture)
8. People Occlusion and Person Segmentation
9. Geo Anchors (City-Locked AR)
10. Saving and Loading World Maps
11. Lifecycle, Interruptions, and Cleanup
12. Common Pitfalls
13. When to Pick Each Renderer
14. Migration Notes

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Check device support
↓2
Configure tracking session
↓3
Consume anchors and updates
↓4
Handle tracking loss

02 / ArchitectureResponsibility boundariesBoundary 1
AR sessionBoundary 2
Anchors and trackingBoundary 3
Scene presentationConnected responsibilities, not a required class hierarchy or an execution trace.
Overview

ARKit is Apple's framework for building augmented reality experiences on iPhone and iPad with LiDAR-class devices. It fuses motion sensors and camera input to produce world tracking, plane detection, scene reconstruction, image/object recognition, body tracking, face tracking, and people occlusion. ARKit produces the 
data
 (anchors, meshes, transforms); rendering is done by 
RealityKit
 (preferred), SceneKit, Metal, or SpriteKit.

Use ARKit on 
iOS 11+
 / 
iPadOS 11+
. Many modern features (mesh reconstruction, motion capture, geo-anchors, scene depth) require LiDAR or A12+ chips. ARKit is 
not available on visionOS
 -- use ARKit-for-visionOS APIs (
import ARKit
 from 
visionOS
) which expose a different surface.

1. Permissions and Info.plist

<key>NSCameraUsageDescription</key>
<string>This app uses the camera to deliver augmented reality features.</string>

<!-- For face/world tracking with audio -->
<key>NSMicrophoneUsageDescription</key>
<string>Audio is captured to enrich AR experiences.</string>

<!-- For ARGeoTrackingConfiguration -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>Location is used to anchor AR content to real-world coordinates.</string>

Always check device support 
before
 instantiating a session:

import ARKit

guard ARWorldTrackingConfiguration.isSupported else {
    // Fall back to a non-AR experience
    return
}

2. Choosing a Configuration

Configuration

Use Case

Min Device

ARWorldTrackingConfiguration

6-DOF camera + plane/mesh detection

A9+

ARFaceTrackingConfiguration

Face anchors, blendshapes, Animoji-style

TrueDepth camera

ARImageTrackingConfiguration

Track moving 2D images (no world tracking)

A9+

ARObjectScanningConfiguration

Author .arobject reference files

A11+

ARBodyTrackingConfiguration

Skeleton tracking

A12+

ARGeoTrackingConfiguration

World-locked content via VPS

A12+, supported cities

ARPositionalTrackingConfiguration

6-DOF only (lowest power)

A9+

let configuration = ARWorldTrackingConfiguration()
configuration.planeDetection = [.horizontal, .vertical]
configuration.environmentTexturing = .automatic
configuration.frameSemantics.insert(.sceneDepth)         // LiDAR
configuration.sceneReconstruction = .meshWithClassification // LiDAR
configuration.userFaceTrackingEnabled = true             // Front+back camera fusion

Always confirm optional capabilities before opting in:

if ARWorldTrackingConfiguration.supportsSceneReconstruction(.meshWithClassification) {
    configuration.sceneReconstruction = .meshWithClassification
}

if ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth) {
    configuration.frameSemantics.insert(.sceneDepth)
}

3. Running an AR Session with RealityKit (Recommended)

import SwiftUI
import RealityKit
import ARKit

struct ARContainerView: UIViewRepresentable {
    func makeUIView(context: Context) -> ARView {
        let arView = ARView(frame: .zero, cameraMode: .ar, automaticallyConfigureSession: false)

        let configuration = ARWorldTrackingConfiguration()
        configuration.planeDetection = [.horizontal]
        configuration.environmentTexturing = .automatic

        arView.session.delegate = context.coordinator
        arView.session.run(configuration, options: [.resetTracking, .removeExistingAnchors])

        // Place a model when the user taps on a detected plane
        let tap = UITapGestureRecognizer(target: context.coordinator,
                                         action: #selector(Coordinator.handleTap(_:)))
        arView.addGestureRecognizer(tap)
        context.coordinator.arView = arView
        return arView
    }

    func updateUIView(_ uiView: ARView, context: Context) {}

    func makeCoordinator() -> Coordinator { Coordinator() }

    final class Coordinator: NSObject, ARSessionDelegate {
        weak var arView: ARView?

        @objc func handleTap(_ gesture: UITapGestureRecognizer) {
            guard let arView else { return }
            let location = gesture.location(in: arView)

            // Ray-cast against estimated planes
            let results = arView.raycast(from: location,
                                         allowing: .estimatedPlane,
                                         alignment: .horizontal)
            guard let first = results.first else { return }

            let anchor = AnchorEntity(world: first.worldTransform)
            let model = ModelEntity(mesh: .generateBox(size: 0.1),
                                    materials: [SimpleMaterial(color: .systemBlue, isMetallic: false)])
            model.generateCollisionShapes(recursive: true)
            anchor.addChild(model)
            arView.scene.addAnchor(anchor)
        }
    }
}

SwiftUI lifecycle

struct ContentView: View {
    var body: some View {
        ARContainerView()
            .ignoresSafeArea()
    }
}

4. Plane and Mesh Detection

Plane anchors

func session(_ session: ARSession, didAdd anchors: [ARAnchor]) {
    for case let plane as ARPlaneAnchor in anchors {
        // plane.alignment, plane.classification, plane.geometry
    }
}

LiDAR scene reconstruction

configuration.sceneReconstruction = .meshWithClassification

func session(_ session: ARSession, didUpdate anchors: [ARAnchor]) {
    for case let mesh as ARMeshAnchor in anchors {
        let geometry = mesh.geometry
        // geometry.vertices, geometry.faces, geometry.classification
    }
}

Mesh classifications include 
.wall
, 
.floor
, 
.ceiling
, 
.table
, 
.seat
, 
.window
, 
.door
, 
.none
.

5. Image and Object Tracking

guard let referenceImages = ARReferenceImage.referenceImages(
    inGroupNamed: "ARImages", bundle: .main
) else { fatalError("Missing AR Resources group") }

let configuration = ARWorldTrackingConfiguration()
configuration.detectionImages = referenceImages
configuration.maximumNumberOfTrackedImages = 4

For physical objects, scan a 
.arobject
 with the official Apple sample, then:

guard let referenceObjects = ARReferenceObject.referenceObjects(
    inGroupNamed: "ARObjects", bundle: .main
) else { return }
configuration.detectionObjects = referenceObjects

6. Face Tracking and Blendshapes

guard ARFaceTrackingConfiguration.isSupported else { return }

let config = ARFaceTrackingConfiguration()
config.maximumNumberOfTrackedFaces = 1
arView.session.run(config)

func session(_ session: ARSession, didUpdate anchors: [ARAnchor]) {
    for case let face as ARFaceAnchor in anchors {
        let smile = face.blendShapes[.mouthSmileLeft]?.floatValue ?? 0
        let blink = face.blendShapes[.eyeBlinkLeft]?.floatValue ?? 0
        // Drive an avatar with these values
    }
}

7. Body Tracking (Motion Capture)

guard ARBodyTrackingConfiguration.isSupported else { return }

let config = ARBodyTrackingConfiguration()
arView.session.run(config)

let characterAnchor = AnchorEntity()
arView.scene.addAnchor(characterAnchor)

func session(_ session: ARSession, didUpdate anchors: [ARAnchor]) {
    for case let body as ARBodyAnchor in anchors {
        characterAnchor.transform = Transform(matrix: body.transform)
        // body.skeleton.jointModelTransforms drives a rigged BodyTrackedEntity
    }
}

8. People Occlusion and Person Segmentation

if ARWorldTrackingConfiguration.supportsFrameSemantics(.personSegmentationWithDepth) {
    configuration.frameSemantics.insert(.personSegmentationWithDepth)
}

RealityKit's 
ARView
 automatically composites people in front of virtual content when this is enabled.

9. Geo Anchors (City-Locked AR)

guard ARGeoTrackingConfiguration.isSupported else { return }

ARGeoTrackingConfiguration.checkAvailability { available, error in
    guard available else { return }

    let config = ARGeoTrackingConfiguration()
    arView.session.run(config)

    let coordinate = CLLocationCoordinate2D(latitude: 37.3349, longitude: -122.0090)
    let anchor = ARGeoAnchor(coordinate: coordinate, altitude: 25.0)
    arView.session.add(anchor: anchor)
}

Only supported in select metropolitan areas -- always call 
checkAvailability
 first.

10. Saving and Loading World Maps

arView.session.getCurrentWorldMap { map, error in
    guard let map else { return }
    let data = try? NSKeyedArchiver.archivedData(withRootObject: map, requiringSecureCoding: true)
    try? data?.write(to: worldMapURL)
}

let data = try Data(contentsOf: worldMapURL)
let map = try NSKeyedUnarchiver.unarchivedObject(ofClass: ARWorldMap.self, from: data)

let config = ARWorldTrackingConfiguration()
config.initialWorldMap = map
arView.session.run(config)

For multi-user shared experiences, replace world maps with 
ARKit collaborative sessions
 (
isCollaborationEnabled = true
) and a 
MultipeerConnectivity
 transport.

11. Lifecycle, Interruptions, and Cleanup

final class SessionDelegate: NSObject, ARSessionDelegate {
    func session(_ session: ARSession, didFailWithError error: Error) {
        // Surface an alert; ARError.Code tells you what failed
    }

    func sessionWasInterrupted(_ session: ARSession) {
        // Camera was occluded or app backgrounded
    }

    func sessionInterruptionEnded(_ session: ARSession) {
        // Reset tracking to recover quickly
        guard let configuration = session.configuration else { return }
        session.run(configuration, options: [.resetTracking, .removeExistingAnchors])
    }

    func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) {
        switch camera.trackingState {
        case .notAvailable: break
        case .limited(let reason): break // .initializing, .relocalizing, .insufficientFeatures, .excessiveMotion
        case .normal: break
        }
    }
}

Always pause the session when the view disappears:

.onDisappear { arView.session.pause() }

12. Common Pitfalls

Forgetting NSCameraUsageDescription
 -- the app crashes silently on first session run.
Running ARWorldTrackingConfiguration on the simulator
 -- it isn't supported. Guard with 
#if !targetEnvironment(simulator)
.
Not gating by capability
 (
isSupported
, 
supportsFrameSemantics(_:)
) -- causes runtime crashes on older devices.
Holding strong references to ARFrame
 -- frames are pooled. Copy what you need (transforms, pixel buffer) and release the frame promptly.
Mixing front and back camera tracking carelessly
 -- enabling 
userFaceTrackingEnabled
 requires both cameras to be available; check first.
Recreating the ARView on every SwiftUI update
 -- 
UIViewRepresentable.makeUIView
 runs once; do session setup there, not in 
updateUIView
.
Skipping arView.session.pause()
 on disappear -- drains battery, keeps camera light on.

13. When to Pick Each Renderer

Renderer

Use When

RealityKit (default)

Modern iOS 13+ apps, USDZ, photorealistic PBR materials, ECS gameplay

SceneKit

Existing SceneKit codebases, custom shader modifiers, advanced animations

Metal

Custom render pipelines, post-processing, shipping a custom renderer

SpriteKit

2D AR overlays, simple gameplay

14. Migration Notes

iOS 17+
: 
ARView
 gained explicit object capture APIs via 
ObjectCaptureSession
 (in RealityKit).
iOS 18+
: Room Plan, improved hand tracking on visionOS pair, expanded geo coverage.
visionOS
: ARKit on visionOS uses 
data providers
 (
HandTrackingProvider
, 
WorldTrackingProvider
) instead of 
ARSession
. Code is not source-compatible.

See also: 
docs/frameworks/realitykit.md
, 
docs/platforms/visionos.md
.

---

# AuthenticationServices
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-authentication-services.html

Authentication, Security, and Privacy · Reference guideAuthenticationServicesRepository guidance for AuthenticationServices. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

1. The three facts that cause most Sign in with Apple bugs
2. Sign in with Apple

The seam
Bridging the delegate to async/await
The nonce
View model
The button

3. Revocation — the check people forget
4. Passkeys (iOS 16+)
5. OAuth via ASWebAuthenticationSession
6. Storing what comes back
Anti-Patterns
App Store notes
Checklist

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Start user-initiated sign-in
↓2
Request identity credential
↓3
Validate with trusted service
↓4
Establish app session

02 / ArchitectureResponsibility boundariesBoundary 1
Authorization UIBoundary 2
Identity verificationBoundary 3
Session stateConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
 adding Sign in with Apple, passkeys, or an OAuth/OIDC web
flow; storing or refreshing auth tokens; or handling an account that the user
can revoke from outside your app.

Covers 
AuthenticationServices
 — Sign in with Apple, passkeys
(
ASAuthorizationPlatformPublicKeyCredentialProvider
), and

ASWebAuthenticationSession
.

Availability:
 framework iOS 12+; Sign in with Apple iOS 13+;

SignInWithAppleButton
 (SwiftUI) iOS 14+; 
passkeys iOS 16+
.

1. The three facts that cause most Sign in with Apple bugs

Read these before the code. Each one produces a bug that only appears in
production, days after sign-in.

1. Name and email are returned exactly once — on the very first
authorization.
 Every subsequent sign-in returns 
nil
 for both, forever, even
after a reinstall. If you do not persist them at first authorization, they are
gone. There is no API to ask again; the user must revoke the app in Settings and
re-authorize.

2. The user can revoke access outside your app
 (Settings → Apple Account →
Sign-In & Security → Sign in with Apple). Your stored credential keeps looking
valid. You must check 
getCredentialState
 on launch and sign the user out when
it is not 
.authorized
.

3. The identity token is the only thing your server may trust.
 The user
identifier alone is not proof of anything — it arrives on-device and can be
forged by a modified client. Send the JWT and verify it server-side against
Apple's public keys.

2. Sign in with Apple

The seam

ASAuthorizationController
 is delegate-based, which does not compose with

async
/
await
 or with this skill's testability rules. Wrap it once behind a
protocol.

import AuthenticationServices

/// What the app actually needs. The view model depends on this, never on
/// ASAuthorizationController — see patterns/clean-architecture.md.
protocol AppleSignInService: Sendable {
    func signIn(nonce: String) async throws -> AppleCredential
    func credentialState(for userID: String) async -> ASAuthorizationAppleIDProvider.CredentialState
}

struct AppleCredential: Sendable {
    let userID: String
    /// JWT for your server. The ONLY value your backend should trust.
    let identityToken: Data
    let authorizationCode: Data
    /// Present on FIRST authorization only. Never returned again.
    let email: String?
    let fullName: PersonNameComponents?
}

Bridging the delegate to async/await

@MainActor
final class LiveAppleSignInService: NSObject, AppleSignInService {
    // Held for the lifetime of one request. The system does NOT retain the
    // controller — dropping it silently cancels the flow with no callback.
    private var controller: ASAuthorizationController?
    private var continuation: CheckedContinuation<AppleCredential, any Error>?

    func signIn(nonce: String) async throws -> AppleCredential {
        try await withCheckedThrowingContinuation { continuation in
            self.continuation = continuation

            let request = ASAuthorizationAppleIDProvider().createRequest()
            request.requestedScopes = [.fullName, .email]
            // Hash of a random nonce. Your server compares this against the
            // claim in the identity token to reject replayed tokens.
            request.nonce = sha256(nonce)

            let controller = ASAuthorizationController(authorizationRequests: [request])
            controller.delegate = self
            controller.presentationContextProvider = self
            self.controller = controller
            controller.performRequests()
        }
    }

    func credentialState(
        for userID: String
    ) async -> ASAuthorizationAppleIDProvider.CredentialState {
        await withCheckedContinuation { continuation in
            ASAuthorizationAppleIDProvider().getCredentialState(forUserID: userID) { state, _ in
                continuation.resume(returning: state)
            }
        }
    }

    /// Resume exactly once, then clear. Resuming a continuation twice is a
    /// runtime crash; never resuming leaks the task forever.
    private func finish(_ result: Result<AppleCredential, any Error>) {
        let pending = continuation
        continuation = nil
        controller = nil
        pending?.resume(with: result)
    }
}

extension LiveAppleSignInService: ASAuthorizationControllerDelegate {
    func authorizationController(
        controller: ASAuthorizationController,
        didCompleteWithAuthorization authorization: ASAuthorization
    ) {
        guard
            let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
            let identityToken = credential.identityToken,
            let authorizationCode = credential.authorizationCode
        else {
            finish(.failure(AuthError.malformedCredential))
            return
        }

        finish(.success(AppleCredential(
            userID: credential.user,
            identityToken: identityToken,
            authorizationCode: authorizationCode,
            email: credential.email,                 // first authorization only
            fullName: credential.fullName            // first authorization only
        )))
    }

    func authorizationController(
        controller: ASAuthorizationController,
        didCompleteWithError error: any Error
    ) {
        // The user tapping Cancel is not a failure to report.
        if let authError = error as? ASAuthorizationError, authError.code == .canceled {
            finish(.failure(CancellationError()))
        } else {
            finish(.failure(error))
        }
    }
}

extension LiveAppleSignInService: ASAuthorizationControllerPresentationContextProviding {
    func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
        // Do not use UIApplication.shared.windows.first — it is deprecated and
        // wrong under multiple scenes.
        UIApplication.shared.connectedScenes
            .compactMap { $0 as? UIWindowScene }
            .flatMap(\.windows)
            .first { $0.isKeyWindow } ?? ASPresentationAnchor()
    }
}

The nonce

import CryptoKit

func makeNonce(length: Int = 32) -> String {
    var bytes = [UInt8](repeating: 0, count: length)
    let status = SecRandomCopyBytes(kSecRandomDefault, length, &bytes)
    precondition(status == errSecSuccess, "SecRandomCopyBytes failed")
    return Data(bytes).base64EncodedString()
}

func sha256(_ input: String) -> String {
    SHA256.hash(data: Data(input.utf8))
        .map { String(format: "%02x", $0) }
        .joined()
}

Send the 
raw
 nonce to your server and the 
hashed
 one to Apple. The
server checks that the token's 
nonce
 claim equals 
sha256(rawNonce)
. Skipping
this means a stolen identity token can be replayed.

View model

@MainActor
@Observable
final class SignInModel {
    private(set) var isSigningIn = false
    var errorMessage: String?

    private let appleSignIn: any AppleSignInService
    private let session: any SessionStore

    init(appleSignIn: any AppleSignInService, session: any SessionStore) {
        self.appleSignIn = appleSignIn
        self.session = session
    }

    func signInWithApple() async {
        isSigningIn = true
        defer { isSigningIn = false }

        let nonce = makeNonce()
        do {
            let credential = try await appleSignIn.signIn(nonce: nonce)
            // Persist name/email NOW — they will never be returned again.
            try await session.establish(credential: credential, rawNonce: nonce)
            errorMessage = nil
        } catch is CancellationError {
            return                                   // user tapped Cancel
        } catch {
            errorMessage = String(localized: "Couldn't sign in. Please try again.")
        }
    }
}

The button

import AuthenticationServices
import SwiftUI

SignInWithAppleButton(.signIn) { request in
    request.requestedScopes = [.fullName, .email]
    request.nonce = sha256(model.currentNonce)
} onCompletion: { _ in
    // Handled by the service above; this closure exists only to satisfy the API.
}
.signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black)
.frame(height: 50)          // Apple's HIG: 44pt minimum

Use 
SignInWithAppleButton
, not a custom button. App Review rejects
reimplementations that do not match Apple's specified appearance, and the system
button handles localization and Dynamic Type for you.

3. Revocation — the check people forget

@MainActor
@Observable
final class SessionModel {
    private(set) var isSignedIn = false
    private let appleSignIn: any AppleSignInService
    private let session: any SessionStore

    /// Call from `.task` on the root view, every launch and every foreground.
    func validateSession() async {
        guard let userID = await session.storedAppleUserID() else {
            isSignedIn = false
            return
        }

        switch await appleSignIn.credentialState(for: userID) {
        case .authorized:
            isSignedIn = true
        case .revoked, .notFound:
            // The user revoked access in Settings, or the account is gone.
            await session.clear()
            isSignedIn = false
        case .transferred:
            // App was transferred between developer teams — migrate server-side.
            await session.beginTeamTransferMigration()
        @unknown default:
            isSignedIn = false
        }
    }
}

Also observe the revocation notification while running:

.task {
    for await _ in NotificationCenter.default.notifications(
        named: ASAuthorizationAppleIDProvider.credentialRevokedNotification
    ) {
        await model.validateSession()
    }
}

4. Passkeys (iOS 16+)

Passkeys replace passwords with a WebAuthn credential bound to your domain.
They require the 
Associated Domains
 capability with

webcredentials:example.com
, and a matching 
apple-app-site-association
 file.

@available(iOS 16.0, *)
@MainActor
final class PasskeyService: NSObject {
    private let domain = "example.com"
    private var continuation: CheckedContinuation<ASAuthorization, any Error>?
    private var controller: ASAuthorizationController?

    /// Registration: the challenge and userID come from YOUR server.
    func register(userName: String, userID: Data, challenge: Data) async throws -> ASAuthorization {
        let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(
            relyingPartyIdentifier: domain
        )
        let request = provider.createCredentialRegistrationRequest(
            challenge: challenge,
            name: userName,
            userID: userID
        )
        return try await perform(request)
    }

    /// Assertion: signing in with an existing passkey.
    func signIn(challenge: Data) async throws -> ASAuthorization {
        let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(
            relyingPartyIdentifier: domain
        )
        return try await perform(provider.createCredentialAssertionRequest(challenge: challenge))
    }

    private func perform(_ request: ASAuthorizationRequest) async throws -> ASAuthorization {
        try await withCheckedThrowingContinuation { continuation in
            self.continuation = continuation
            let controller = ASAuthorizationController(authorizationRequests: [request])
            controller.delegate = self
            controller.presentationContextProvider = self
            self.controller = controller
            controller.performRequests()
        }
    }
}

The challenge must come from your server and be single-use.
 A
client-generated challenge defeats the entire protocol.

For a sign-in field that offers both passkeys and a saved password, use

performAutoFillAssistedRequests()
 together with

.textContentType(.username)
 on the field.

5. OAuth via ASWebAuthenticationSession

For a third-party identity provider that has no native SDK.

@MainActor
final class WebAuthService: NSObject {
    private var session: ASWebAuthenticationSession?

    func authenticate(url: URL, callbackScheme: String) async throws -> URL {
        try await withCheckedThrowingContinuation { continuation in
            let session = ASWebAuthenticationSession(
                url: url,
                callbackURLScheme: callbackScheme
            ) { callbackURL, error in
                if let error {
                    let cancelled = (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin
                    continuation.resume(throwing: cancelled ? CancellationError() : error)
                } else if let callbackURL {
                    continuation.resume(returning: callbackURL)
                } else {
                    continuation.resume(throwing: AuthError.malformedCredential)
                }
            }

            session.presentationContextProvider = self
            // true = no shared cookies, so the user must log in each time.
            // false = single sign-on with Safari. Choose deliberately.
            session.prefersEphemeralWebBrowserSession = false
            self.session = session
            session.start()
        }
    }
}

Use 
PKCE
 for any OAuth flow from a mobile client. The implicit flow and
embedded 
WKWebView
 login are both rejected by most providers and by App Review.

6. Storing what comes back

Tokens go in the 
Keychain
, never 
UserDefaults
 — see

checklists/security.md
 and 
docs/frameworks/cryptokit.md
.

actor KeychainSessionStore: SessionStore {
    func establish(credential: AppleCredential, rawNonce: String) async throws {
        // Exchange with your server FIRST — it verifies the identity token and
        // the nonce, then returns your own session token.
        let session = try await api.exchange(
            identityToken: credential.identityToken,
            authorizationCode: credential.authorizationCode,
            rawNonce: rawNonce,
            // Send these on first authorization only — they are never resent.
            email: credential.email,
            fullName: credential.fullName.map(PersonNameComponentsFormatter().string(from:))
        )
        try store(session.token, account: "session")
        try store(credential.userID, account: "appleUserID")
    }
}

Use 
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
 for session tokens:
available to background tasks after first unlock, and never restored onto a
different device from a backup.

Anti-Patterns

// 1. Not persisting name/email at first authorization.
if let email = credential.email { showWelcome(email) }   // and then discarded
// They are returned ONCE, ever. Persist immediately or lose them permanently.

// 2. Trusting the user identifier as authentication.
api.login(userID: credential.user)
// Arrives on-device, forgeable. Send the identity token; verify server-side.

// 3. No nonce.
let request = provider.createRequest()
request.requestedScopes = [.fullName, .email]     // no request.nonce
// A stolen identity token can be replayed indefinitely.

// 4. Never checking credential state.
// The user revokes in Settings; your app stays "signed in" forever.
// Check getCredentialState on launch and on foreground.

// 5. Dropping the controller.
func signIn() {
    let controller = ASAuthorizationController(...)   // local, deallocated
    controller.performRequests()                      // silently never calls back
}

// 6. Resuming a continuation twice — or not at all.
continuation.resume(returning: credential)
continuation.resume(returning: credential)   // CRASH
// Every exit path resumes exactly once. Clear the stored continuation first.

// 7. Treating cancellation as an error.
catch { errorMessage = "Sign in failed" }    // fires when the user taps Cancel
catch is CancellationError { return }        // correct

// 8. Tokens in UserDefaults.
UserDefaults.standard.set(token, forKey: "authToken")   // plaintext, backed up
// Keychain.

// 9. A custom Sign in with Apple button.
Button("Sign in with Apple") { … }
// App Review rejects appearances that do not match the specification.

// 10. Embedded WKWebView for OAuth.
// Rejected by providers and by App Review. Use ASWebAuthenticationSession.

// 11. UIApplication.shared.windows.first as the presentation anchor.
// Deprecated, and wrong with multiple scenes. Use connectedScenes.

// 12. A client-generated passkey challenge.
// Defeats the protocol. The challenge is server-issued and single-use.

// 13. The view model naming ASAuthorizationController directly.
// Untestable and un-previewable. Depend on a protocol.

App Store notes

If your app offers third-party or social login, App Review requires an
  equivalent privacy-preserving option. Sign in with Apple satisfies this —
  check the current text of Guideline 4.8 before submitting, as the wording has
  changed across revisions.
Offer account 
deletion
 in-app if you offer account creation (Guideline
  5.1.1(v)), and revoke the Apple token server-side when the user deletes.
Passkeys need Associated Domains and a served
  
apple-app-site-association
. Verify it responds over HTTPS with no redirect
  before submitting — this is the usual cause of "passkeys work in debug, not in
  TestFlight".

Checklist

[ ] 
email
 and 
fullName
 persisted on first authorization.
[ ] A random nonce per request; hashed to Apple, raw to your server.
[ ] Server verifies the identity token against Apple's public keys.
[ ] 
getCredentialState
 checked on launch and on foreground.
[ ] 
credentialRevokedNotification
 observed while running.
[ ] 
ASAuthorizationController
 retained for the request's lifetime.
[ ] Every continuation resumes exactly once on every path.
[ ] 
CancellationError
 handled as a deliberate no-op.
[ ] Tokens in the Keychain with an appropriate accessibility class.
[ ] 
SignInWithAppleButton
, not a custom control.
[ ] Passkeys: Associated Domains configured, challenge server-issued.
[ ] OAuth: 
ASWebAuthenticationSession
 with PKCE, never a 
WKWebView
.
[ ] The view model depends on a protocol; sign-in previews with a stub.
[ ] Account deletion offered and the Apple token revoked server-side.

---

# AVFoundation
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-avfoundation.html

Camera, Media, and Audio · Reference guideAVFoundationRepository guidance for AVFoundation. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

AVPlayer and AVPlayerViewController
AVAudioPlayer and AVAudioRecorder
AVCaptureSession — Camera and Video Capture
AVAsset and AVAssetExportSession
Audio Session Configuration
Now Playing Info and Remote Commands

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Request needed permissions
↓2
Configure media session
↓3
Capture or play media
↓4
Handle interruptions

02 / ArchitectureResponsibility boundariesBoundary 1
Media sourceBoundary 2
AVFoundation pipelineBoundary 3
Playback or capture UIConnected responsibilities, not a required class hierarchy or an execution trace.
AVPlayer and AVPlayerViewController

import AVKit
import AVFoundation

// Simple video player in SwiftUI
struct VideoPlayerView: View {
    let url: URL
    @State private var player: AVPlayer?

    var body: some View {
        VideoPlayer(player: player)
            .onAppear {
                player = AVPlayer(url: url)
                player?.play()
            }
            .onDisappear {
                player?.pause()
                player = nil
            }
    }
}

// Full-featured player with AVPlayerViewController (UIKit wrapper)
struct FullScreenVideoPlayer: UIViewControllerRepresentable {
    let url: URL

    func makeUIViewController(context: Context) -> AVPlayerViewController {
        let controller = AVPlayerViewController()
        let player = AVPlayer(url: url)
        controller.player = player
        controller.allowsPictureInPicturePlayback = true
        controller.canStartPictureInPictureAutomaticallyFromInline = true
        return controller
    }

    func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {}
}

// Observing player state
@Observable
class VideoPlayerModel {
    var player: AVPlayer?
    var isPlaying = false
    var currentTime: TimeInterval = 0
    var duration: TimeInterval = 0

    private var timeObserver: Any?

    func loadVideo(url: URL) {
        let item = AVPlayerItem(url: url)
        player = AVPlayer(playerItem: item)

        // Observe playback time
        timeObserver = player?.addPeriodicTimeObserver(
            forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
            queue: .main
        ) { [weak self] time in
            self?.currentTime = time.seconds
        }

        // Observe duration when ready
        Task {
            if let duration = try? await item.asset.load(.duration) {
                self.duration = duration.seconds
            }
        }
    }

    func togglePlayback() {
        if isPlaying {
            player?.pause()
        } else {
            player?.play()
        }
        isPlaying.toggle()
    }

    func seek(to time: TimeInterval) {
        player?.seek(to: CMTime(seconds: time, preferredTimescale: 600))
    }

    deinit {
        if let observer = timeObserver {
            player?.removeTimeObserver(observer)
        }
    }
}

AVAudioPlayer and AVAudioRecorder

import AVFoundation

@Observable
class AudioManager {
    var isPlaying = false
    var isRecording = false
    var recordingURL: URL?

    private var audioPlayer: AVAudioPlayer?
    private var audioRecorder: AVAudioRecorder?

    // Play audio file
    func play(url: URL) throws {
        let session = AVAudioSession.sharedInstance()
        try session.setCategory(.playback, mode: .default)
        try session.setActive(true)

        audioPlayer = try AVAudioPlayer(contentsOf: url)
        audioPlayer?.prepareToPlay()
        audioPlayer?.play()
        isPlaying = true
    }

    func stopPlayback() {
        audioPlayer?.stop()
        isPlaying = false
    }

    // Record audio
    func startRecording() throws {
        let session = AVAudioSession.sharedInstance()
        try session.setCategory(.record, mode: .default)
        try session.setActive(true)

        let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
        let audioFilename = documentsPath.appendingPathComponent("\(UUID().uuidString).m4a")
        recordingURL = audioFilename

        let settings: [String: Any] = [
            AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
            AVSampleRateKey: 44100,
            AVNumberOfChannelsKey: 1,
            AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,
        ]

        audioRecorder = try AVAudioRecorder(url: audioFilename, settings: settings)
        audioRecorder?.isMeteringEnabled = true
        audioRecorder?.record()
        isRecording = true
    }

    func stopRecording() -> URL? {
        audioRecorder?.stop()
        isRecording = false
        return recordingURL
    }

    // Get audio levels for visualization
    func currentLevel() -> Float {
        audioRecorder?.updateMeters()
        return audioRecorder?.averagePower(forChannel: 0) ?? -160
    }
}

AVCaptureSession — Camera and Video Capture

import AVFoundation
import UIKit

class CameraManager: NSObject {
    let captureSession = AVCaptureSession()
    private var photoOutput = AVCapturePhotoOutput()
    private var videoOutput = AVCaptureMovieFileOutput()
    private var currentCamera: AVCaptureDevice.Position = .back
    var photoCaptureCompletion: ((UIImage?) -> Void)?

    func setupSession() {
        captureSession.beginConfiguration()
        captureSession.sessionPreset = .photo

        // Add camera input
        guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
              let input = try? AVCaptureDeviceInput(device: camera),
              captureSession.canAddInput(input)
        else { return }
        captureSession.addInput(input)

        // Add photo output
        guard captureSession.canAddOutput(photoOutput) else { return }
        captureSession.addOutput(photoOutput)
        photoOutput.isHighResolutionCaptureEnabled = true

        captureSession.commitConfiguration()
    }

    func startSession() {
        guard !captureSession.isRunning else { return }
        DispatchQueue.global(qos: .userInitiated).async { [weak self] in
            self?.captureSession.startRunning()
        }
    }

    func stopSession() {
        guard captureSession.isRunning else { return }
        captureSession.stopRunning()
    }

    func capturePhoto() {
        let settings = AVCapturePhotoSettings()
        settings.flashMode = .auto
        photoOutput.capturePhoto(with: settings, delegate: self)
    }

    func switchCamera() {
        captureSession.beginConfiguration()

        // Remove current input
        if let currentInput = captureSession.inputs.first as? AVCaptureDeviceInput {
            captureSession.removeInput(currentInput)
        }

        // Add new camera
        currentCamera = currentCamera == .back ? .front : .back
        guard let newCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: currentCamera),
              let newInput = try? AVCaptureDeviceInput(device: newCamera),
              captureSession.canAddInput(newInput)
        else { return }
        captureSession.addInput(newInput)
        captureSession.commitConfiguration()
    }

    // Check camera permission
    static func requestPermission() async -> Bool {
        let status = AVCaptureDevice.authorizationStatus(for: .video)
        switch status {
        case .authorized:
            return true
        case .notDetermined:
            return await AVCaptureDevice.requestAccess(for: .video)
        default:
            return false
        }
    }
}

extension CameraManager: AVCapturePhotoCaptureDelegate {
    func photoOutput(_ output: AVCapturePhotoOutput,
                     didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
        guard let data = photo.fileDataRepresentation(),
              let image = UIImage(data: data)
        else {
            photoCaptureCompletion?(nil)
            return
        }
        photoCaptureCompletion?(image)
    }
}

// Camera preview layer for UIKit
class CameraPreviewView: UIView {
    override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }

    var previewLayer: AVCaptureVideoPreviewLayer {
        layer as! AVCaptureVideoPreviewLayer
    }

    func configure(session: AVCaptureSession) {
        previewLayer.session = session
        previewLayer.videoGravity = .resizeAspectFill
    }
}

AVAsset and AVAssetExportSession

// Get video metadata
func getVideoInfo(url: URL) async throws -> (duration: TimeInterval, size: CGSize) {
    let asset = AVURLAsset(url: url)
    let duration = try await asset.load(.duration)

    let tracks = try await asset.loadTracks(withMediaType: .video)
    let size = try await tracks.first?.load(.naturalSize) ?? .zero

    return (duration.seconds, size)
}

// Export/compress video
func compressVideo(inputURL: URL, outputURL: URL) async throws {
    let asset = AVURLAsset(url: inputURL)

    guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else {
        throw ExportError.sessionCreationFailed
    }

    exportSession.outputURL = outputURL
    exportSession.outputFileType = .mp4
    exportSession.shouldOptimizeForNetworkUse = true

    await exportSession.export()

    if let error = exportSession.error {
        throw error
    }
}

// Trim video
func trimVideo(inputURL: URL, outputURL: URL, startTime: TimeInterval, endTime: TimeInterval) async throws {
    let asset = AVURLAsset(url: inputURL)

    guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
        throw ExportError.sessionCreationFailed
    }

    let start = CMTime(seconds: startTime, preferredTimescale: 600)
    let end = CMTime(seconds: endTime, preferredTimescale: 600)
    exportSession.timeRange = CMTimeRange(start: start, end: end)
    exportSession.outputURL = outputURL
    exportSession.outputFileType = .mp4

    await exportSession.export()
}

// Generate thumbnail
func generateThumbnail(url: URL, at time: TimeInterval) async throws -> UIImage {
    let asset = AVURLAsset(url: url)
    let generator = AVAssetImageGenerator(asset: asset)
    generator.appliesPreferredTrackTransform = true
    generator.maximumSize = CGSize(width: 320, height: 320)

    let cmTime = CMTime(seconds: time, preferredTimescale: 600)
    let (image, _) = try await generator.image(at: cmTime)
    return UIImage(cgImage: image)
}

enum ExportError: Error {
    case sessionCreationFailed
}

Audio Session Configuration

import AVFoundation

class AudioSessionManager {

    // Playback only (music, podcasts)
    static func configureForPlayback() throws {
        let session = AVAudioSession.sharedInstance()
        try session.setCategory(.playback, mode: .default, options: [])
        try session.setActive(true)
    }

    // Recording
    static func configureForRecording() throws {
        let session = AVAudioSession.sharedInstance()
        try session.setCategory(.record, mode: .default, options: [])
        try session.setActive(true)
    }

    // Play and record simultaneously (voice chat)
    static func configureForVoiceChat() throws {
        let session = AVAudioSession.sharedInstance()
        try session.setCategory(.playAndRecord, mode: .voiceChat, options: [
            .defaultToSpeaker,
            .allowBluetooth,
        ])
        try session.setActive(true)
    }

    // Handle interruptions (phone calls)
    static func observeInterruptions(handler: @escaping (Bool) -> Void) {
        NotificationCenter.default.addObserver(
            forName: AVAudioSession.interruptionNotification,
            object: AVAudioSession.sharedInstance(),
            queue: .main
        ) { notification in
            guard let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
                  let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
            handler(type == .began)
        }
    }
}

Now Playing Info and Remote Commands

import MediaPlayer

class NowPlayingManager {
    static let shared = NowPlayingManager()

    func setupRemoteCommands(
        onPlay: @escaping () -> Void,
        onPause: @escaping () -> Void,
        onNext: @escaping () -> Void,
        onPrevious: @escaping () -> Void
    ) {
        let commandCenter = MPRemoteCommandCenter.shared()

        commandCenter.playCommand.addTarget { _ in
            onPlay()
            return .success
        }
        commandCenter.pauseCommand.addTarget { _ in
            onPause()
            return .success
        }
        commandCenter.nextTrackCommand.addTarget { _ in
            onNext()
            return .success
        }
        commandCenter.previousTrackCommand.addTarget { _ in
            onPrevious()
            return .success
        }
    }

    func updateNowPlaying(title: String, artist: String, duration: TimeInterval, currentTime: TimeInterval, artwork: UIImage?) {
        var info = [String: Any]()
        info[MPMediaItemPropertyTitle] = title
        info[MPMediaItemPropertyArtist] = artist
        info[MPMediaItemPropertyPlaybackDuration] = duration
        info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
        info[MPNowPlayingInfoPropertyPlaybackRate] = 1.0

        if let image = artwork {
            info[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
        }

        MPNowPlayingInfoCenter.default().nowPlayingInfo = info
    }
}

---

# BackgroundTasks
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-background-tasks.html

System Integration · Reference guideBackgroundTasksRepository guidance for BackgroundTasks. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

BGTaskScheduler Registration
BGAppRefreshTask for Periodic Updates
BGProcessingTask for Long Operations
URLSession Background Transfers
AppDelegate Integration for Background Sessions
Testing Background Tasks in Xcode
Complete Background Sync Example

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Register task handlers
↓2
Schedule eligible work
↓3
Handle launch and expiration
↓4
Report completion

02 / ArchitectureResponsibility boundariesBoundary 1
System schedulerBoundary 2
Bounded task workerBoundary 3
Persistent app stateConnected responsibilities, not a required class hierarchy or an execution trace.
BGTaskScheduler Registration

Register task identifiers in 
Info.plist
 under 
BGTaskSchedulerPermittedIdentifiers
:

<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
    <string>com.yourapp.refresh</string>
    <string>com.yourapp.db-cleanup</string>
    <string>com.yourapp.sync</string>
</array>

Register handlers at app launch (before the end of the first 
applicationDidFinishLaunching
 call or in the 
@main
 App 
init
):

import BackgroundTasks

@main
struct MyApp: App {
    init() {
        registerBackgroundTasks()
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }

    private func registerBackgroundTasks() {
        BGTaskScheduler.shared.register(
            forTaskWithIdentifier: "com.yourapp.refresh",
            using: nil  // nil = main queue
        ) { task in
            guard let task = task as? BGAppRefreshTask else { return }
            handleAppRefresh(task: task)
        }

        BGTaskScheduler.shared.register(
            forTaskWithIdentifier: "com.yourapp.db-cleanup",
            using: nil
        ) { task in
            guard let task = task as? BGProcessingTask else { return }
            handleDatabaseCleanup(task: task)
        }

        BGTaskScheduler.shared.register(
            forTaskWithIdentifier: "com.yourapp.sync",
            using: nil
        ) { task in
            guard let task = task as? BGProcessingTask else { return }
            handleSync(task: task)
        }
    }
}

BGAppRefreshTask for Periodic Updates

App refresh tasks are short-lived (up to 30 seconds). Use for lightweight data fetches.

func handleAppRefresh(task: BGAppRefreshTask) {
    // Schedule the next refresh before doing work
    scheduleAppRefresh()

    let refreshTask = Task {
        do {
            let newData = try await DataService.shared.fetchLatestData()
            await MainActor.run {
                DataStore.shared.update(with: newData)
            }
            task.setTaskCompleted(success: true)
        } catch {
            task.setTaskCompleted(success: false)
        }
    }

    // Handle system cancelling the task
    task.expirationHandler = {
        refreshTask.cancel()
    }
}

func scheduleAppRefresh() {
    let request = BGAppRefreshTaskRequest(identifier: "com.yourapp.refresh")
    request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 minutes minimum

    do {
        try BGTaskScheduler.shared.submit(request)
    } catch {
        print("Could not schedule app refresh: \(error)")
    }
}

BGProcessingTask for Long Operations

Processing tasks can run for several minutes. Require device to be charging and/or on Wi-Fi.

func handleDatabaseCleanup(task: BGProcessingTask) {
    // Schedule the next cleanup
    scheduleDatabaseCleanup()

    let cleanupTask = Task {
        do {
            try await DatabaseManager.shared.performCleanup()
            try await DatabaseManager.shared.vacuumDatabase()
            try await CacheManager.shared.pruneExpiredEntries()
            task.setTaskCompleted(success: true)
        } catch {
            task.setTaskCompleted(success: false)
        }
    }

    task.expirationHandler = {
        cleanupTask.cancel()
    }
}

func scheduleDatabaseCleanup() {
    let request = BGProcessingTaskRequest(identifier: "com.yourapp.db-cleanup")
    request.earliestBeginDate = Date(timeIntervalSinceNow: 24 * 60 * 60) // Daily
    request.requiresNetworkConnectivity = false
    request.requiresExternalPower = true  // Only when charging

    do {
        try BGTaskScheduler.shared.submit(request)
    } catch {
        print("Could not schedule cleanup: \(error)")
    }
}

func handleSync(task: BGProcessingTask) {
    scheduleSync()

    let syncTask = Task {
        do {
            try await SyncEngine.shared.performFullSync()
            task.setTaskCompleted(success: true)
        } catch {
            task.setTaskCompleted(success: false)
        }
    }

    task.expirationHandler = {
        syncTask.cancel()
    }
}

func scheduleSync() {
    let request = BGProcessingTaskRequest(identifier: "com.yourapp.sync")
    request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60) // 1 hour
    request.requiresNetworkConnectivity = true
    request.requiresExternalPower = false

    do {
        try BGTaskScheduler.shared.submit(request)
    } catch {
        print("Could not schedule sync: \(error)")
    }
}

URLSession Background Transfers

Background transfers continue even when your app is suspended or terminated.

final class BackgroundDownloadManager: NSObject, URLSessionDownloadDelegate {
    static let shared = BackgroundDownloadManager()

    private lazy var backgroundSession: URLSession = {
        let config = URLSessionConfiguration.background(
            withIdentifier: "com.yourapp.background-download"
        )
        config.isDiscretionary = true           // System chooses optimal time
        config.sessionSendsLaunchEvents = true  // Wake app on completion
        config.allowsCellularAccess = false     // Wi-Fi only
        config.timeoutIntervalForResource = 60 * 60 * 24 // 24 hours
        return URLSession(configuration: config, delegate: self, delegateQueue: nil)
    }()

    /// Completion handler stored from AppDelegate for background session events
    var backgroundCompletionHandler: (() -> Void)?

    /// Start a background download
    func download(url: URL) -> URLSessionDownloadTask {
        let task = backgroundSession.downloadTask(with: url)
        task.earliestBeginDate = Date(timeIntervalSinceNow: 60) // Delay start
        task.countOfBytesClientExpectsToSend = 200    // Request size estimate
        task.countOfBytesClientExpectsToReceive = 5_000_000 // Response size estimate
        task.resume()
        return task
    }

    /// Start a background upload
    func upload(data: Data, to url: URL) -> URLSessionUploadTask {
        var request = URLRequest(url: url)
        request.httpMethod = "POST"

        // Write data to temp file (required for background uploads)
        let tempURL = FileManager.default.temporaryDirectory
            .appendingPathComponent(UUID().uuidString)
        try? data.write(to: tempURL)

        let task = backgroundSession.uploadTask(with: request, fromFile: tempURL)
        task.resume()
        return task
    }

    // MARK: - URLSessionDownloadDelegate

    func urlSession(
        _ session: URLSession,
        downloadTask: URLSessionDownloadTask,
        didFinishDownloadingTo location: URL
    ) {
        // Move file from temp location before this method returns
        let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
        let destinationURL = documentsURL.appendingPathComponent(
            downloadTask.originalRequest?.url?.lastPathComponent ?? "download"
        )

        try? FileManager.default.removeItem(at: destinationURL)
        try? FileManager.default.moveItem(at: location, to: destinationURL)
    }

    func urlSession(
        _ session: URLSession,
        downloadTask: URLSessionDownloadTask,
        didWriteData bytesWritten: Int64,
        totalBytesWritten: Int64,
        totalBytesExpectedToWrite: Int64
    ) {
        let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
        Task { @MainActor in
            NotificationCenter.default.post(
                name: .downloadProgress,
                object: nil,
                userInfo: ["progress": progress, "taskID": downloadTask.taskIdentifier]
            )
        }
    }

    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        if let error {
            print("Background transfer failed: \(error.localizedDescription)")
        }
    }

    // Called when all background events have been delivered
    func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
        Task { @MainActor in
            backgroundCompletionHandler?()
            backgroundCompletionHandler = nil
        }
    }
}

extension Notification.Name {
    static let downloadProgress = Notification.Name("downloadProgress")
}

AppDelegate Integration for Background Sessions

import UIKit

class AppDelegate: NSObject, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        handleEventsForBackgroundURLSession identifier: String,
        completionHandler: @escaping () -> Void
    ) {
        BackgroundDownloadManager.shared.backgroundCompletionHandler = completionHandler
    }
}

// In your @main App:
@main
struct MyApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        WindowGroup { ContentView() }
    }
}

Testing Background Tasks in Xcode

Use the Xcode debugger console to simulate background task execution:

# Simulate app refresh task launch
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.yourapp.refresh"]

# Simulate processing task launch
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.yourapp.db-cleanup"]

# Simulate expiration of a running task
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.yourapp.refresh"]

Complete Background Sync Example

import BackgroundTasks
import OSLog

@Observable
final class SyncEngine {
    static let shared = SyncEngine()

    private(set) var lastSyncDate: Date?
    private(set) var isSyncing = false

    private let logger = Logger(subsystem: "com.yourapp", category: "Sync")

    func performFullSync() async throws {
        guard !isSyncing else { return }
        isSyncing = true
        defer { isSyncing = false }

        logger.info("Starting full sync")

        // 1. Push local changes
        let pendingChanges = try await LocalStore.shared.pendingChanges()
        if !pendingChanges.isEmpty {
            logger.info("Pushing \(pendingChanges.count) local changes")
            try await APIClient.shared.pushChanges(pendingChanges)
            try await LocalStore.shared.markSynced(pendingChanges)
        }

        // 2. Pull remote changes
        let remoteChanges = try await APIClient.shared.pullChanges(since: lastSyncDate)
        logger.info("Pulled \(remoteChanges.count) remote changes")
        try await LocalStore.shared.applyRemoteChanges(remoteChanges)

        // 3. Update sync timestamp
        lastSyncDate = Date()
        UserDefaults.standard.set(lastSyncDate, forKey: "lastSyncDate")

        logger.info("Sync completed successfully")
    }
}

// Schedule sync when app backgrounds
struct ContentView: View {
    @Environment(\.scenePhase) private var scenePhase
    @State private var syncEngine = SyncEngine.shared

    var body: some View {
        NavigationStack {
            VStack {
                if syncEngine.isSyncing {
                    ProgressView("Syncing...")
                }
                if let lastSync = syncEngine.lastSyncDate {
                    Text("Last sync: \(lastSync, format: .relative(presentation: .named))")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
            }
        }
        .onChange(of: scenePhase) { _, newPhase in
            if newPhase == .background {
                scheduleSync()
                scheduleAppRefresh()
            }
        }
        .task {
            // Sync on app launch if stale
            if let last = syncEngine.lastSyncDate,
               Date().timeIntervalSince(last) > 300 { // 5 minutes
                try? await syncEngine.performFullSync()
            }
        }
    }
}

// Placeholder protocols for completeness
enum LocalStore {
    static let shared = LocalStore.self
    static func pendingChanges() async throws -> [Any] { [] }
    static func markSynced(_ changes: [Any]) async throws {}
    static func applyRemoteChanges(_ changes: [Any]) async throws {}
}

enum APIClient {
    static let shared = APIClient.self
    static func pushChanges(_ changes: [Any]) async throws {}
    static func pullChanges(since date: Date?) async throws -> [Any] { [] }
}

---

# CloudKit
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-cloudkit.html

Data Management · Reference guideCloudKitRepository guidance for CloudKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

CKContainer and CKDatabase (Public, Private, Shared)
CKRecord CRUD Operations

Create
Read
Update
Delete

CKQuery with NSPredicate
CKSubscription for Real-Time Push
CKShare and Sharing Records
CloudKit Dashboard
Integration with SwiftData/CoreData

NSPersistentCloudKitContainer (CoreData + CloudKit)
SwiftData with CloudKit (iOS 17+)

Custom Record Zones
Modern CloudKit (iOS 17+)

CKSyncEngine
CKSystemSharingUIObserver

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Define records and zones
↓2
Check account availability
↓3
Fetch or save changes
↓4
Resolve errors and conflicts

02 / ArchitectureResponsibility boundariesBoundary 1
Local app stateBoundary 2
Sync coordinatorBoundary 3
CloudKit recordsConnected responsibilities, not a required class hierarchy or an execution trace.
CloudKit is Apple's cloud backend framework providing database storage, authentication, and asset management. It syncs data across devices using iCloud with public, private, and shared databases.

CKContainer and CKDatabase (Public, Private, Shared)

import CloudKit

class CloudKitManager: ObservableObject {
    // Default container matches your app's bundle ID
    let container = CKContainer.default()

    // Custom container identifier
    let customContainer = CKContainer(identifier: "iCloud.com.yourapp.name")

    // Access different databases
    lazy var publicDB = container.publicCloudDatabase     // Accessible to all users
    lazy var privateDB = container.privateCloudDatabase    // User's private data (counts toward user's iCloud quota)
    lazy var sharedDB = container.sharedCloudDatabase      // Data shared with this user by others

    // Check iCloud account status
    func checkAccountStatus() async throws -> CKAccountStatus {
        let status = try await container.accountStatus()
        switch status {
        case .available:
            print("iCloud available")
        case .noAccount:
            print("No iCloud account signed in")
        case .restricted:
            print("iCloud restricted by parental controls or MDM")
        case .couldNotDetermine:
            print("Could not determine iCloud status")
        case .temporarilyUnavailable:
            print("iCloud temporarily unavailable")
        @unknown default:
            break
        }
        return status
    }

    // Get current user record ID
    func fetchUserRecordID() async throws -> CKRecord.ID {
        return try await container.userRecordID()
    }

    // Request user discoverability permission
    func requestPermission() async throws -> CKContainer.ApplicationPermissionStatus {
        return try await container.requestApplicationPermission(.userDiscoverability)
    }
}

CKRecord CRUD Operations

Create

extension CloudKitManager {
    func createNote(title: String, body: String, image: UIImage?) async throws -> CKRecord {
        let record = CKRecord(recordType: "Note")
        record["title"] = title as CKRecordValue
        record["body"] = body as CKRecordValue
        record["createdAt"] = Date() as CKRecordValue
        record["tags"] = ["swift", "cloudkit"] as CKRecordValue  // Arrays are supported

        // Save an image asset
        if let image = image, let data = image.jpegData(compressionQuality: 0.8) {
            let tempURL = FileManager.default.temporaryDirectory
                .appendingPathComponent(UUID().uuidString + ".jpg")
            try data.write(to: tempURL)
            record["image"] = CKAsset(fileURL: tempURL)
        }

        // Reference another record
        let folderRecordID = CKRecord.ID(recordName: "folder-abc")
        record["folder"] = CKRecord.Reference(recordID: folderRecordID, action: .deleteSelf)
        // .deleteSelf: child is deleted when parent is deleted
        // .none: no cascade behavior

        let savedRecord = try await privateDB.save(record)
        return savedRecord
    }
}

Read

extension CloudKitManager {
    // Fetch a single record by ID
    func fetchNote(recordID: CKRecord.ID) async throws -> CKRecord {
        return try await privateDB.record(for: recordID)
    }

    // Fetch multiple records efficiently
    func fetchNotes(recordIDs: [CKRecord.ID]) async throws -> [CKRecord] {
        let results = try await privateDB.records(for: recordIDs)
        return results.compactMap { _, result in
            try? result.get()
        }
    }

    // Fetch only specific fields (desiredKeys) to reduce bandwidth
    func fetchNoteTitles(recordIDs: [CKRecord.ID]) async throws -> [CKRecord] {
        let results = try await privateDB.records(for: recordIDs, desiredKeys: ["title", "createdAt"])
        return results.compactMap { _, result in try? result.get() }
    }
}

Update

extension CloudKitManager {
    func updateNote(recordID: CKRecord.ID, newTitle: String) async throws -> CKRecord {
        // Fetch the existing record first to get its change tag
        let record = try await privateDB.record(for: recordID)
        record["title"] = newTitle as CKRecordValue
        record["modifiedAt"] = Date() as CKRecordValue

        // Save returns the updated record
        return try await privateDB.save(record)
    }
}

Delete

extension CloudKitManager {
    // Delete a single record
    func deleteNote(recordID: CKRecord.ID) async throws {
        try await privateDB.deleteRecord(withID: recordID)
    }

    // Batch operations using modifyRecords
    func batchSaveAndDelete(
        toSave: [CKRecord],
        toDelete: [CKRecord.ID]
    ) async throws {
        let (saveResults, deleteResults) = try await privateDB.modifyRecords(
            saving: toSave,
            deleting: toDelete,
            savePolicy: .changedKeys,       // Only upload changed fields
            atomicityType: .nonAtomic       // .full for all-or-nothing
        )

        for (id, result) in saveResults {
            switch result {
            case .success(let record):
                print("Saved: \(record.recordID.recordName)")
            case .failure(let error):
                print("Save failed for \(id): \(error)")
            }
        }
        print("Deleted \(deleteResults.count) records")
    }
}

CKQuery with NSPredicate

extension CloudKitManager {
    // Fetch all records of a type
    func fetchAllNotes() async throws -> [CKRecord] {
        let predicate = NSPredicate(value: true)
        let query = CKQuery(recordType: "Note", predicate: predicate)
        query.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]

        let (results, _) = try await privateDB.records(matching: query)
        return results.compactMap { _, result in try? result.get() }
    }

    // Filtered query with string matching
    func searchNotes(containing text: String) async throws -> [CKRecord] {
        // Tokenized field queries use CONTAINS; CloudKit also supports
        // BEGINSWITH, ==, IN, and tokenized full-text search via allTokens
        let predicate = NSPredicate(format: "title CONTAINS %@", text)
        let query = CKQuery(recordType: "Note", predicate: predicate)

        let (results, _) = try await privateDB.records(matching: query)
        return results.compactMap { _, result in try? result.get() }
    }

    // Compound predicates
    func fetchRecentNotes(after date: Date, tag: String) async throws -> [CKRecord] {
        let datePredicate = NSPredicate(format: "createdAt > %@", date as NSDate)
        let tagPredicate = NSPredicate(format: "tags CONTAINS %@", tag)
        let compound = NSCompoundPredicate(
            andPredicateWithSubpredicates: [datePredicate, tagPredicate]
        )

        let query = CKQuery(recordType: "Note", predicate: compound)
        let (results, _) = try await privateDB.records(matching: query)
        return results.compactMap { _, result in try? result.get() }
    }

    // Paginated query using cursor
    func fetchNotesPaginated(
        cursor: CKQueryOperation.Cursor? = nil,
        limit: Int = 20
    ) async throws -> ([CKRecord], CKQueryOperation.Cursor?) {
        if let cursor = cursor {
            let (results, newCursor) = try await privateDB.records(
                continuingMatchFrom: cursor,
                resultsLimit: limit
            )
            let records = results.compactMap { _, result in try? result.get() }
            return (records, newCursor)
        } else {
            let query = CKQuery(recordType: "Note", predicate: NSPredicate(value: true))
            query.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]

            let (results, newCursor) = try await privateDB.records(
                matching: query,
                resultsLimit: limit
            )
            let records = results.compactMap { _, result in try? result.get() }
            return (records, newCursor)
        }
    }
}

CKSubscription for Real-Time Push

extension CloudKitManager {
    // Query subscription: fires when records matching a predicate change
    func subscribeToNoteChanges() async throws {
        let subscription = CKQuerySubscription(
            recordType: "Note",
            predicate: NSPredicate(value: true),
            subscriptionID: "note-changes",
            options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
        )

        let notification = CKSubscription.NotificationInfo()
        notification.title = "Note Updated"
        notification.alertBody = "A note was modified"
        notification.shouldBadge = true
        notification.soundName = "default"
        notification.shouldSendContentAvailable = true  // Silent push for background refresh

        subscription.notificationInfo = notification

        try await privateDB.save(subscription)
    }

    // Database subscription: fires on any change in the database
    func subscribeToDatabaseChanges() async throws {
        let subscription = CKDatabaseSubscription(subscriptionID: "private-db-changes")

        let notification = CKSubscription.NotificationInfo()
        notification.shouldSendContentAvailable = true
        subscription.notificationInfo = notification

        try await privateDB.save(subscription)
    }

    // Fetch changes since last sync using server change tokens
    func fetchChanges(in zoneID: CKRecordZone.ID = .default) async throws {
        // Load saved token
        let tokenData = UserDefaults.standard.data(forKey: "zoneChangeToken-\(zoneID.zoneName)")
        let token = tokenData.flatMap {
            try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: $0)
        }

        let changes = try await privateDB.recordZoneChanges(inZoneWith: zoneID, since: token)

        for modification in changes.modificationResultsByID {
            let (recordID, result) = modification
            switch result {
            case .success(let modification):
                print("Modified: \(recordID.recordName), record: \(modification.record.recordType)")
            case .failure(let error):
                print("Error for \(recordID): \(error)")
            }
        }

        for deletion in changes.deletions {
            print("Deleted: \(deletion.recordID.recordName), type: \(deletion.recordType)")
        }

        // Persist the new change token for next sync
        if let newToken = changes.changeToken,
           let data = try? NSKeyedArchiver.archivedData(
               withRootObject: newToken, requiringSecureCoding: true
           ) {
            UserDefaults.standard.set(data, forKey: "zoneChangeToken-\(zoneID.zoneName)")
        }
    }
}

CKShare and Sharing Records

extension CloudKitManager {
    // Create a share for a record
    func shareRecord(_ record: CKRecord) async throws -> CKShare {
        let share = CKShare(rootRecord: record)
        share[CKShare.SystemFieldKey.title] = "Shared Note" as CKRecordValue
        share[CKShare.SystemFieldKey.shareType] = "com.app.note" as CKRecordValue
        share.publicPermission = .readOnly  // .none, .readOnly, .readWrite

        // Save both the record and the share atomically
        let (savedResults, _) = try await privateDB.modifyRecords(
            saving: [record, share],
            deleting: []
        )

        guard let savedShare = try? savedResults[share.recordID]?.get() as? CKShare else {
            throw CloudKitError.shareFailed
        }

        // share.url contains the URL to send to participants
        print("Share URL: \(savedShare.url?.absoluteString ?? "none")")

        return savedShare
    }

    // Accept a share from a URL
    func acceptShare(metadata: CKShare.Metadata) async throws {
        try await container.accept(metadata)
    }

    // Fetch records shared with this user
    func fetchSharedRecords() async throws -> [CKRecord] {
        let zones = try await sharedDB.allRecordZones()
        var allRecords: [CKRecord] = []

        for zone in zones {
            let query = CKQuery(recordType: "Note", predicate: NSPredicate(value: true))
            let (results, _) = try await sharedDB.records(
                matching: query,
                inZoneWith: zone.zoneID
            )
            allRecords += results.compactMap { _, result in try? result.get() }
        }
        return allRecords
    }
}

// SwiftUI wrapper for the system sharing controller
struct CloudSharingView: UIViewControllerRepresentable {
    let share: CKShare
    let container: CKContainer

    func makeUIViewController(context: Context) -> UICloudSharingController {
        let controller = UICloudSharingController(share: share, container: container)
        controller.availablePermissions = [.allowReadOnly, .allowReadWrite, .allowPrivate]
        return controller
    }

    func updateUIViewController(_ uiViewController: UICloudSharingController, context: Context) {}
}

enum CloudKitError: Error {
    case shareFailed
    case accountUnavailable
    case recordNotFound
}

CloudKit Dashboard

The CloudKit Dashboard at https://icloud.developer.apple.com provides:

Schema
: View and modify record types, fields, and indexes. Add queryable and sortable indexes for any field used in CKQuery predicates or sort descriptors.
Data Browser
: Query, create, edit, and delete records in public, private (via Team Development), and shared databases.
Logs
: View server-side request logs, error breakdowns, and push notification delivery status.
Telemetry
: Monitor request counts, error rates, latency, and data transfer metrics.
Subscriptions
: View and manage active CKSubscription objects.
Security Roles
: Control public database access with custom roles (e.g., allowing authenticated users to create records).
Deployment
: Promote your development schema to production. This is a one-way operation and cannot be reversed.
Reset Development
: Reset the development environment schema to match production when needed.

Integration with SwiftData/CoreData

NSPersistentCloudKitContainer (CoreData + CloudKit)

import CoreData

class PersistenceController {
    static let shared = PersistenceController()

    let container: NSPersistentCloudKitContainer

    init() {
        container = NSPersistentCloudKitContainer(name: "Model")

        guard let description = container.persistentStoreDescriptions.first else {
            fatalError("No persistent store description found")
        }

        // Enable CloudKit sync
        description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
            containerIdentifier: "iCloud.com.yourapp.name"
        )

        // Required: enable remote change notifications
        description.setOption(
            true as NSNumber,
            forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey
        )

        // Required: enable history tracking for CloudKit sync
        description.setOption(
            true as NSNumber,
            forKey: NSPersistentHistoryTrackingKey
        )

        container.loadPersistentStores { description, error in
            if let error = error {
                fatalError("Core Data store failed to load: \(error)")
            }
        }

        // Automatically merge remote changes into the view context
        container.viewContext.automaticallyMergesChangesFromParent = true
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy

        // Observe remote changes
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleRemoteChange),
            name: .NSPersistentStoreRemoteChange,
            object: container.persistentStoreCoordinator
        )
    }

    @objc func handleRemoteChange(_ notification: Notification) {
        print("Remote change received from CloudKit")
        // Process persistent history if needed
    }
}

SwiftData with CloudKit (iOS 17+)

import SwiftData

@Model
class Note {
    var title: String
    var body: String
    var createdAt: Date
    var tags: [String]

    // Relationships must be optional for CloudKit compatibility
    var folder: Folder?

    init(title: String, body: String) {
        self.title = title
        self.body = body
        self.createdAt = Date()
        self.tags = []
    }
}

// SwiftData syncs with CloudKit automatically when the iCloud capability is enabled
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Note.self])
    }
}

// Requirements for SwiftData + CloudKit:
// 1. Enable iCloud and CloudKit in Signing & Capabilities
// 2. All stored properties must have default values or be optional
// 3. No unique constraints (CloudKit does not support them)
// 4. Relationships must be optional with no required cascade rules
// 5. @Attribute(.externalStorage) maps to CKAsset for large data

Custom Record Zones

extension CloudKitManager {
    // Custom zones allow atomic commits and change tracking
    func createCustomZone() async throws -> CKRecordZone {
        let zone = CKRecordZone(zoneName: "NotesZone")
        let savedZone = try await privateDB.save(zone)
        return savedZone
    }

    func saveToCustomZone(title: String) async throws -> CKRecord {
        let zoneID = CKRecordZone.ID(zoneName: "NotesZone")
        let recordID = CKRecord.ID(recordName: UUID().uuidString, zoneID: zoneID)
        let record = CKRecord(recordType: "Note", recordID: recordID)
        record["title"] = title as CKRecordValue
        return try await privateDB.save(record)
    }
}

Modern CloudKit (iOS 17+)

CKSyncEngine

CKSyncEngine
 is Apple's recommended sync engine that replaces manual change token management, zone fetching, and subscription handling. It encapsulates the full sync lifecycle -- scheduling, batching, retry logic, conflict resolution, and account change handling -- into a single, delegate-driven API.

Why CKSyncEngine replaces manual change token fetching
: Previously, developers had to manually manage 
CKServerChangeToken
 objects, handle 
CKFetchRecordZoneChangesOperation
, process 
CKModifyRecordZonesOperation
, manage retry and error logic, and track database subscriptions. 
CKSyncEngine
 handles all of this automatically, dramatically reducing boilerplate and the surface area for sync bugs.

import CloudKit

// MARK: - CKSyncEngine Setup

class SyncManager: CKSyncEngineDelegate {
    let syncEngine: CKSyncEngine

    init() {
        // Load any previously persisted sync engine state
        let lastKnownState = Self.loadSyncEngineState()

        // Configure the sync engine
        let configuration = CKSyncEngine.Configuration(
            database: CKContainer.default().privateCloudDatabase,
            stateSerialization: lastKnownState,
            delegate: self
        )

        syncEngine = CKSyncEngine(configuration)
    }

    // MARK: - CKSyncEngineDelegate Methods

    // Called when the sync engine has events to process
    func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async {
        switch event {
        case .stateUpdate(let stateUpdate):
            // Persist the sync engine state so it can resume after app relaunch
            Self.saveSyncEngineState(stateUpdate.stateSerialization)

        case .accountChange(let event):
            handleAccountChange(event)

        case .fetchedDatabaseChanges(let event):
            // New zones discovered or zones deleted on the server
            for modification in event.modifications {
                print("Zone modified: \(modification.zoneID)")
            }
            for deletion in event.deletions {
                print("Zone deleted: \(deletion.zoneID)")
                // Remove local data for this zone
            }

        case .fetchedRecordZoneChanges(let event):
            handleFetchedChanges(event)

        case .sentDatabaseChanges(let event):
            // Confirmation that zone creates/deletes were sent
            for saved in event.savedZones {
                print("Zone saved to server: \(saved.zoneID)")
            }

        case .sentRecordZoneChanges(let event):
            handleSentChanges(event)

        case .willFetchChanges:
            // Prepare for incoming changes (e.g., show sync indicator)
            break

        case .didFetchChanges:
            // All fetched changes have been processed
            break

        case .willSendChanges:
            break

        case .didSendChanges:
            break

        @unknown default:
            break
        }
    }

    // Called when the engine needs the next batch of records to send
    func nextRecordZoneChangeBatch(
        _ context: CKSyncEngine.SendChangesContext,
        syncEngine: CKSyncEngine
    ) async -> CKSyncEngine.RecordZoneChangeBatch? {
        // Get pending changes from the engine's state
        let pendingChanges = syncEngine.state.pendingRecordZoneChanges

        // Build a batch from your local data
        let batch = await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: pendingChanges) { recordID in
            // Return the CKRecord for this ID from your local store
            return self.buildRecord(for: recordID)
        }

        return batch
    }

    // MARK: - Handling Fetched Changes

    private func handleFetchedChanges(_ event: CKSyncEngine.Event.FetchedRecordZoneChanges) {
        // Process modifications (inserts and updates from the server)
        for modification in event.modifications {
            let record = modification.record
            // Upsert into your local store
            saveLocally(record)
            print("Fetched: \(record.recordType) - \(record.recordID.recordName)")
        }

        // Process deletions
        for deletion in event.deletions {
            deleteLocally(recordID: deletion.recordID)
            print("Deleted remotely: \(deletion.recordID.recordName)")
        }
    }

    // MARK: - Handling Sent Changes and Conflicts

    private func handleSentChanges(_ event: CKSyncEngine.Event.SentRecordZoneChanges) {
        // Records successfully saved to the server
        for saved in event.savedRecords {
            // Update local store with server-assigned system fields
            updateLocalSystemFields(saved)
            print("Sent to server: \(saved.recordID.recordName)")
        }

        // Records that failed to save
        for failed in event.failedRecordSaves {
            let recordID = failed.record.recordID

            switch failed.error.code {
            case .serverRecordChanged:
                // CONFLICT: The server has a newer version
                // Resolve by merging or choosing server/client wins
                if let serverRecord = failed.error.serverRecord {
                    resolveConflict(
                        clientRecord: failed.record,
                        serverRecord: serverRecord
                    )
                }

            case .zoneNotFound:
                // Zone doesn't exist yet; the engine will auto-create it on next sync
                break

            case .unknownItem:
                // Record was already deleted on the server
                deleteLocally(recordID: recordID)

            default:
                print("Failed to save \(recordID): \(failed.error)")
            }
        }

        // Deletions confirmed
        for deletedID in event.deletedRecordIDs {
            print("Server confirmed deletion: \(deletedID.recordName)")
        }
    }

    // MARK: - Conflict Resolution

    private func resolveConflict(clientRecord: CKRecord, serverRecord: CKRecord) {
        // Strategy: merge non-conflicting fields, server wins for conflicts
        let mergedRecord = serverRecord

        // Example: merge by taking the latest modification date per field
        // Or implement custom logic per record type
        if let clientModified = clientRecord.modificationDate,
           let serverModified = serverRecord.modificationDate,
           clientModified > serverModified {
            // Client is newer for this field — apply client changes
            for key in clientRecord.allKeys() {
                mergedRecord[key] = clientRecord[key]
            }
        }

        // Tell the engine to retry with the merged record
        syncEngine.state.add(pendingRecordZoneChanges: [
            .saveRecord(mergedRecord.recordID)
        ])
    }

    // MARK: - Account Changes

    private func handleAccountChange(_ event: CKSyncEngine.Event.AccountChange) {
        switch event.changeType {
        case .signIn:
            // User signed into iCloud — start syncing
            print("iCloud account signed in")

        case .switchAccounts:
            // Different iCloud account — clear local cache and re-sync
            clearLocalData()
            print("iCloud account switched")

        case .signOut:
            // User signed out — optionally keep local data or clear it
            print("iCloud account signed out")

        @unknown default:
            break
        }
    }

    // MARK: - Scheduling Sync

    func addPendingChange(recordID: CKRecord.ID) {
        // Tell the engine there's a local change to sync
        syncEngine.state.add(pendingRecordZoneChanges: [
            .saveRecord(recordID)
        ])
    }

    func addPendingDeletion(recordID: CKRecord.ID) {
        syncEngine.state.add(pendingRecordZoneChanges: [
            .deleteRecord(recordID)
        ])
    }

    // MARK: - State Persistence

    private static func saveSyncEngineState(_ state: CKSyncEngine.State.Serialization) {
        if let data = try? JSONEncoder().encode(state) {
            UserDefaults.standard.set(data, forKey: "ckSyncEngineState")
        }
    }

    private static func loadSyncEngineState() -> CKSyncEngine.State.Serialization? {
        guard let data = UserDefaults.standard.data(forKey: "ckSyncEngineState") else { return nil }
        return try? JSONDecoder().decode(CKSyncEngine.State.Serialization.self, from: data)
    }

    // MARK: - Local Store Helpers (implement with your persistence layer)

    private func buildRecord(for recordID: CKRecord.ID) -> CKRecord? {
        // Fetch your local model and convert to CKRecord
        return nil
    }

    private func saveLocally(_ record: CKRecord) {
        // Insert or update in your local database (SwiftData, CoreData, etc.)
    }

    private func deleteLocally(recordID: CKRecord.ID) {
        // Delete from your local database
    }

    private func updateLocalSystemFields(_ record: CKRecord) {
        // Store the server's changeTag and other system fields locally
    }

    private func clearLocalData() {
        // Clear all local synced data on account switch
    }
}

CKSystemSharingUIObserver

CKSystemSharingUIObserver
 provides modern observation of CloudKit sharing UI events, allowing your app to react when the system sharing controller saves a share or encounters an error.

import CloudKit

class SharingManager {
    private var sharingObserver: CKSystemSharingUIObserver?

    func observeSharingUI(for container: CKContainer) {
        sharingObserver = CKSystemSharingUIObserver(container)

        // Observe when a share is saved from the system UI
        sharingObserver?.systemSharingUIDidSaveShareBlock = { recordID, result in
            switch result {
            case .success(let share):
                print("Share saved: \(share.url?.absoluteString ?? "no URL")")
                // Update your UI to reflect the share
            case .failure(let error):
                print("Share save failed: \(error)")
            }
        }

        // Observe when a share is stopped from the system UI
        sharingObserver?.systemSharingUIDidStopSharingBlock = { recordID, result in
            switch result {
            case .success:
                print("Sharing stopped for record: \(recordID)")
                // Update your UI to remove sharing indicators
            case .failure(let error):
                print("Stop sharing failed: \(error)")
            }
        }
    }
}

---

# Core AI -- Custom On-Device AI Models on Apple Silicon
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-core-ai.html

AI and Machine Learning · Reference guideCore AI -- Custom On-Device AI Models on Apple SiliconRepository guidance for Core AI. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Overview
1. When to Choose Core AI
2. Xcode Project Setup
3. Load a Model and Function
4. Run NDArray Inference
5. Specialization, Caching, and Storage
6. Ahead-of-Time Compilation
7. Debugging and Profiling
8. Security and Product Rules
9. Review Checklist
Released SDK boundary

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Choose a custom model workload
↓2
Verify installed SDK support
↓3
Prepare model assets
↓4
Measure inference on device

02 / ArchitectureResponsibility boundariesBoundary 1
Model assetsBoundary 2
Inference boundaryBoundary 3
App featureConnected responsibilities, not a required class hierarchy or an execution trace.
Overview

Core AI is Apple's framework for bringing custom AI models into an app and running them efficiently on Apple silicon. Use it when you own or ship a model asset and need on-device inference across CPU, GPU, and Neural Engine with Swift APIs, model specialization, caching, ahead-of-time compilation, and Core AI debugging tools.

Use 
Foundation Models
 when you need language generation, structured output, tools, or Apple Intelligence model access. Use 
Core ML
 when your model is not a neural network workload, or when an existing Core ML pipeline already covers the feature.

Release status checked 2026-09-16: iOS 27 and Xcode 27 are released. This guide describes the released API direction; Core AI examples have not been compiled on this repository’s Xcode 26.6 verification host. See 
release verification
.

1. When to Choose Core AI

Need

Prefer

Ship a custom neural model in .aimodel / .aimodelc format

Core AI

Prompt Apple Foundation models, PCC, or a server LLM through one API

Foundation Models

Use trees, classical ML, tabular feature engineering, or existing .mlmodel assets

Core ML

Write custom GPU kernels or render/compute pipelines directly

Metal

Run MLX models or local agent experiments on macOS

MLX Swift

Core AI starts from an 
.aimodel
 source asset. Convert and optimize model assets before app integration using Apple's Core AI PyTorch Extensions and Core AI Optimization tooling.

2. Xcode Project Setup

Add the 
.aimodel
 file to the app or package target.
Confirm the file appears in the target build phases.
Install the Metal Toolchain. Builds that include 
.aimodel
 files fail without the required compiler.
Inspect the model in Xcode's model viewer before writing runtime code.

The model viewer should answer:

What are the model's function names?
What input and output names does each function expect?
Are inputs 
NDArray
 values or images?
What compute/storage precision does the asset use?
Is the author/license metadata present?

3. Load a Model and Function

import CoreAI
import Foundation

@available(iOS 27.0, macOS 27.0, visionOS 27.0, *)
enum ClassifierError: Error {
    case missingModel
    case missingFunction(String)
    case missingOutput(String)
    case unexpectedOutputType
}

@available(iOS 27.0, macOS 27.0, visionOS 27.0, *)
actor ImageClassifier {
    private let function: InferenceFunction

    init(modelURL: URL) async throws {
        let model = try await AIModel(contentsOf: modelURL, options: .default)

        guard let descriptor = model.functionDescriptor(for: "main") else {
            throw ClassifierError.missingFunction("main")
        }

        // Use the descriptor for logging, validation, or assertions in debug builds.
        debugPrint(descriptor)

        guard let function = try model.loadFunction(named: "main") else {
            throw ClassifierError.missingFunction("main")
        }
        self.function = function
    }
}

AIModel
 represents the specialized model asset for the current device. Loading an 
InferenceFunction
 prepares runnable resources for a specific compute graph and can be expensive, so do it before the first user-visible inference path.

4. Run 
NDArray
 Inference

The converted model defines the exact input and output names. Match those names in code.

@available(iOS 27.0, macOS 27.0, visionOS 27.0, *)
extension ImageClassifier {
    func predict(features: [Float]) async throws -> [Float] {
        var input = NDArray(shape: [1, features.count], scalarType: .float32)
        var mutable = input.mutableView(as: Float.self)

        guard let elements = mutable.contiguousElements else {
            throw ClassifierError.unexpectedOutputType
        }

        for index in features.indices {
            elements[index] = features[index]
        }

        var outputs = try await function.run(inputs: ["input": input])
        guard let predictionValue = outputs.remove("prediction") else {
            throw ClassifierError.missingOutput("prediction")
        }
        guard let prediction = predictionValue.ndArray else {
            throw ClassifierError.unexpectedOutputType
        }

        let view = prediction.view(as: Float.self)
        guard let values = view.contiguousElements else {
            throw ClassifierError.unexpectedOutputType
        }
        return Array(values)
    }
}

Keep the model's conversion metadata and the Swift constants together. A mismatch in input names, shapes, or scalar type is an integration defect, not a prompt problem.

5. Specialization, Caching, and Storage

Loading a 
.aimodel
 can require device-specific specialization. The default behavior specializes and caches the result so later loads are faster.

Use explicit specialization when you want to control timing or cache policy:

let model = try await AIModel.specialize(
    contentsOf: modelURL,
    options: .default,
    cachePolicy: .persistent
)

let bookmark = model.bookmarkData
UserDefaults.standard.set(bookmark, forKey: "classifier.model.bookmark")

Use persistent cache policy only when the product really needs it. Specialized assets are tied to OS versions, source model content, storage pressure, and compute options. If bookmark resolution fails later, redownload or rebundle the source model and specialize again.

For app groups, use an 
AIModelCache(appGroup:)
 so related apps/extensions do not duplicate specialized assets.

6. Ahead-of-Time Compilation

Large models can take too long to specialize on first launch. Use 
coreai-build
 to move the expensive compile step to the build machine:

xcodebuild -downloadComponent MetalToolchain
xcrun coreai-build compile MyModel.aimodel --platform iOS --min-deployment-version 27.0 --output compiled/

At runtime, choose the compiled asset for the current device architecture:

let arch = AIModel.deviceArchitectureName
let assetName = "MyModel.\(arch).aimodelc"

Host architecture-specific 
.aimodelc
 assets remotely when they are large. Background Assets can manage downloads and updates.

7. Debugging and Profiling

Core AI ships with three main debugging surfaces:

Xcode model viewer
 for metadata, operation distribution, function signatures, precision, and model inspection.
Core AI debug gauge
 during a debug session for model load, specialization, and inference activity.
Core AI instrument
 for timing across CPU, GPU, and Neural Engine.
Core AI Debugger app
 for inspecting 
.aimodel
 structure and tracing tensor values back to Python source.

Use this workflow:

Validate the 
.aimodel
 in the Core AI Debugger before app integration.
Confirm function names and signatures in Xcode.
Run with the Xcode gauge while exercising real UI flows.
Profile with the Core AI instrument before changing compute options.
Compare outputs against reference data whenever conversion or optimization changes.

8. Security and Product Rules

Keep model licenses and attribution in the asset metadata and app documentation.
Treat downloaded models as executable product inputs: sign, version, checksum, and stage rollout.
Do not run first-time specialization on a critical interaction without progress UI.
Provide thermal, battery, and storage fallbacks for large models.
Prefer 
.default
 compute options until profiling proves a need to override.
Keep user data on device unless the feature explicitly explains network use.

9. Review Checklist

[ ] 
.aimodel
 target membership and Metal Toolchain setup are documented
[ ] Function names, input names, output names, shapes, and scalar types are asserted
[ ] First-run specialization has loading UI and cancellation behavior
[ ] Cache policy is intentional and storage impact is tested
[ ] AOT compilation considered for large models
[ ] Debugger/gauge/Instruments workflow captured in the PR
[ ] Reference-output regression tests exist for conversion/optimization changes
[ ] Fallback path exists for unsupported devices, storage pressure, and OS updates

See also: 
docs/frameworks/foundation-models.md
, 
docs/frameworks/ml/coreml.md
, 
docs/frameworks/ml/on-device-ai.md
, 
docs/frameworks/metal.md
.

Released SDK boundary

Apple’s Core AI documentation
 now marks the 27.0 platform APIs as non-beta. Model format, supported operators, memory requirements and device eligibility still need validation per model. Use 
Apple’s Core AI model adapter
 when connecting a supported local model to Foundation Models; it is a separate dependency, not vendored framework code. This repository has not run a converted Core AI model on-device.

---

# Core Data
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-core-data.html

Data Management · Reference guideCore DataRepository guidance for Core Data. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

NSManagedObjectModel and .xcdatamodeld
Persistent Container Setup
NSManagedObjectContext — View and Background
NSFetchRequest and NSPredicate
NSFetchedResultsController
Relationships
Lightweight Migration
Core Data with CloudKit

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Define persistent model
↓2
Create managed contexts
↓3
Fetch and save changes
↓4
Test migration and failure

02 / ArchitectureResponsibility boundariesBoundary 1
Persistent storeBoundary 2
Context ownershipBoundary 3
View modelsConnected responsibilities, not a required class hierarchy or an execution trace.
NSManagedObjectModel and .xcdatamodeld

Core Data uses an 
.xcdatamodeld
 file to define your schema visually in Xcode. Each entity maps to an 
NSManagedObject
 subclass.

// Auto-generated NSManagedObject subclass (Codegen: Class Definition)
// Or manually define with Codegen: Manual/None

import CoreData

@objc(Task)
public class Task: NSManagedObject {
    @NSManaged public var id: UUID
    @NSManaged public var title: String
    @NSManaged public var isCompleted: Bool
    @NSManaged public var createdAt: Date
    @NSManaged public var priority: Int16
    @NSManaged public var category: Category? // Relationship
}

extension Task {
    @nonobjc public class func fetchRequest() -> NSFetchRequest<Task> {
        return NSFetchRequest<Task>(entityName: "Task")
    }

    var priorityLevel: Priority {
        get { Priority(rawValue: priority) ?? .medium }
        set { priority = newValue.rawValue }
    }
}

enum Priority: Int16 {
    case low = 0, medium = 1, high = 2
}

Persistent Container Setup

class PersistenceController {
    static let shared = PersistenceController()

    let container: NSPersistentContainer

    init(inMemory: Bool = false) {
        container = NSPersistentContainer(name: "MyApp")

        if inMemory {
            container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
        }

        container.loadPersistentStores { description, error in
            if let error = error as NSError? {
                fatalError("Core Data store failed: \(error), \(error.userInfo)")
            }
        }

        container.viewContext.automaticallyMergesChangesFromParent = true
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    }

    // Preview helper
    static var preview: PersistenceController = {
        let controller = PersistenceController(inMemory: true)
        let ctx = controller.container.viewContext
        for i in 0..<10 {
            let task = Task(context: ctx)
            task.id = UUID()
            task.title = "Sample Task \(i)"
            task.isCompleted = i.isMultiple(of: 3)
            task.createdAt = Date()
            task.priority = Int16(i % 3)
        }
        try? ctx.save()
        return controller
    }()
}

// Inject into SwiftUI
@main
struct MyApp: App {
    let persistence = PersistenceController.shared

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.managedObjectContext, persistence.container.viewContext)
        }
    }
}

NSManagedObjectContext — View and Background

// View context (main thread) — for reads and light writes
let viewContext = PersistenceController.shared.container.viewContext

// Background context — for heavy operations
func importData(_ items: [ItemDTO]) async throws {
    let context = PersistenceController.shared.container.newBackgroundContext()
    context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy

    try await context.perform {
        for dto in items {
            let task = Task(context: context)
            task.id = dto.id
            task.title = dto.title
            task.isCompleted = dto.completed
            task.createdAt = dto.createdAt
        }
        try context.save()
    }
}

// performBackgroundTask convenience
PersistenceController.shared.container.performBackgroundTask { context in
    // Already on a background queue
    let request = Task.fetchRequest()
    request.predicate = NSPredicate(format: "isCompleted == YES")
    if let completed = try? context.fetch(request) {
        completed.forEach { context.delete($0) }
        try? context.save()
    }
}

NSFetchRequest and NSPredicate

// Basic fetch
let request = Task.fetchRequest()
request.sortDescriptors = [
    NSSortDescriptor(keyPath: \Task.priority, ascending: false),
    NSSortDescriptor(keyPath: \Task.createdAt, ascending: true),
]
let allTasks = try viewContext.fetch(request)

// Filtered fetch
request.predicate = NSPredicate(format: "isCompleted == %@ AND priority >= %d", NSNumber(value: false), 1)

// Compound predicates
let notCompleted = NSPredicate(format: "isCompleted == NO")
let highPriority = NSPredicate(format: "priority == %d", Priority.high.rawValue)
let searchPredicate = NSPredicate(format: "title CONTAINS[cd] %@", searchText)
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [notCompleted, highPriority, searchPredicate])

// Fetch limit and batch size
request.fetchLimit = 20
request.fetchBatchSize = 50

// Count instead of fetching objects
let count = try viewContext.count(for: request)

// Fetch specific properties
request.propertiesToFetch = ["title", "priority"]
request.resultType = .dictionaryResultType

// SwiftUI @FetchRequest
struct TaskListView: View {
    @Environment(\.managedObjectContext) private var viewContext

    @FetchRequest(
        sortDescriptors: [SortDescriptor(\.createdAt, order: .reverse)],
        predicate: NSPredicate(format: "isCompleted == NO"),
        animation: .default
    )
    private var tasks: FetchedResults<Task>

    var body: some View {
        List {
            ForEach(tasks) { task in
                TaskRow(task: task)
            }
            .onDelete(perform: deleteTasks)
        }
    }

    private func deleteTasks(offsets: IndexSet) {
        offsets.map { tasks[$0] }.forEach(viewContext.delete)
        try? viewContext.save()
    }
}

NSFetchedResultsController

// Used primarily in UIKit for efficient table/collection view updates
class TaskListViewController: UITableViewController, NSFetchedResultsControllerDelegate {

    private var fetchedResultsController: NSFetchedResultsController<Task>!

    override func viewDidLoad() {
        super.viewDidLoad()

        let request = Task.fetchRequest()
        request.sortDescriptors = [
            NSSortDescriptor(keyPath: \Task.priority, ascending: false),
            NSSortDescriptor(keyPath: \Task.title, ascending: true),
        ]
        request.predicate = NSPredicate(format: "isCompleted == NO")

        fetchedResultsController = NSFetchedResultsController(
            fetchRequest: request,
            managedObjectContext: PersistenceController.shared.container.viewContext,
            sectionNameKeyPath: "priority",
            cacheName: "taskCache"
        )
        fetchedResultsController.delegate = self
        try? fetchedResultsController.performFetch()
    }

    // Diffable data source integration
    func controller(
        _ controller: NSFetchedResultsController<NSFetchRequestResult>,
        didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference
    ) {
        let snapshot = snapshot as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>
        dataSource.apply(snapshot, animatingDifferences: true)
    }
}

Relationships

// One-to-Many: Category has many Tasks
@objc(Category)
public class Category: NSManagedObject {
    @NSManaged public var id: UUID
    @NSManaged public var name: String
    @NSManaged public var tasks: NSSet? // One-to-many (inverse of Task.category)
}

extension Category {
    var tasksArray: [Task] {
        let set = tasks as? Set<Task> ?? []
        return set.sorted { $0.createdAt < $1.createdAt }
    }

    @objc(addTasksObject:)
    @NSManaged public func addToTasks(_ value: Task)

    @objc(removeTasksObject:)
    @NSManaged public func removeFromTasks(_ value: Task)
}

// Creating with relationships
let category = Category(context: viewContext)
category.id = UUID()
category.name = "Work"

let task = Task(context: viewContext)
task.id = UUID()
task.title = "Prepare presentation"
task.category = category // Sets both sides with inverse relationship

try viewContext.save()

// Many-to-Many: Tasks can have many Tags, Tags can have many Tasks
// Define in .xcdatamodeld with inverse relationships on both sides
// Both sides generate NSSet properties

Lightweight Migration

// Automatic lightweight migration (handles simple changes)
let description = NSPersistentStoreDescription()
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true
container.persistentStoreDescriptions = [description]

// Supported lightweight migrations:
// - Adding a new attribute (with default value)
// - Removing an attribute
// - Renaming an attribute (set renaming identifier in model editor)
// - Adding a new entity
// - Adding/removing a relationship
// - Changing optional <-> non-optional (if default value set)

Core Data with CloudKit

class CloudPersistenceController {
    static let shared = CloudPersistenceController()

    let container: NSPersistentCloudKitContainer

    init() {
        container = NSPersistentCloudKitContainer(name: "MyApp")

        // Configure for CloudKit sync
        guard let description = container.persistentStoreDescriptions.first else {
            fatalError("No store description")
        }
        description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
        description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
        description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
            containerIdentifier: "iCloud.com.myapp.data"
        )

        container.loadPersistentStores { _, error in
            if let error { fatalError("CloudKit store failed: \(error)") }
        }

        container.viewContext.automaticallyMergesChangesFromParent = true
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy

        // Observe remote changes
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(storeRemoteChange),
            name: .NSPersistentStoreRemoteChange,
            object: container.persistentStoreCoordinator
        )
    }

    @objc private func storeRemoteChange(_ notification: Notification) {
        // Handle remote changes if needed
    }
}

---

# CoreLocation
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-core-location.html

System Integration · Reference guideCoreLocationRepository guidance for Core Location. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

CLLocationManager Setup and Permissions
Continuous and One-Shot Location
Geocoding and Reverse Geocoding
Geofencing (CLCircularRegion)
iBeacon Monitoring
Background Location Updates

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Explain location purpose
↓2
Request appropriate access
↓3
Consume location updates
↓4
Stop unnecessary tracking

02 / ArchitectureResponsibility boundariesBoundary 1
Authorization stateBoundary 2
Location serviceBoundary 3
Feature consumerConnected responsibilities, not a required class hierarchy or an execution trace.
CLLocationManager Setup and Permissions

Add to 
Info.plist
:
- 
NSLocationWhenInUseUsageDescription
 — required for foreground location
- 
NSLocationAlwaysAndWhenInUseUsageDescription
 — required for background location

import CoreLocation

@Observable
class LocationManager: NSObject, CLLocationManagerDelegate {
    static let shared = LocationManager()

    var currentLocation: CLLocation?
    var authorizationStatus: CLAuthorizationStatus = .notDetermined
    var locationError: Error?

    private let manager = CLLocationManager()

    override init() {
        super.init()
        manager.delegate = self
        manager.desiredAccuracy = kCLLocationAccuracyBest
        manager.distanceFilter = 10 // meters
    }

    func requestPermission() {
        manager.requestWhenInUseAuthorization()
    }

    func requestAlwaysPermission() {
        manager.requestAlwaysAuthorization()
    }

    // Delegate: authorization changed
    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        authorizationStatus = manager.authorizationStatus

        switch manager.authorizationStatus {
        case .authorizedWhenInUse, .authorizedAlways:
            manager.startUpdatingLocation()
        case .denied, .restricted:
            locationError = LocationError.permissionDenied
        case .notDetermined:
            break
        @unknown default:
            break
        }
    }

    // Delegate: location updated
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        currentLocation = locations.last
    }

    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        locationError = error
    }
}

enum LocationError: LocalizedError {
    case permissionDenied
    case locationUnavailable

    var errorDescription: String? {
        switch self {
        case .permissionDenied: return "Location permission denied"
        case .locationUnavailable: return "Location unavailable"
        }
    }
}

Continuous and One-Shot Location

extension LocationManager {

    // Start continuous updates
    func startTracking() {
        manager.startUpdatingLocation()
    }

    func stopTracking() {
        manager.stopUpdatingLocation()
    }

    // One-shot location request
    func requestCurrentLocation() {
        manager.requestLocation()
    }

    // Significant location changes (battery efficient, ~500m threshold)
    func startSignificantLocationMonitoring() {
        guard CLLocationManager.significantLocationChangeMonitoringAvailable() else { return }
        manager.startMonitoringSignificantLocationChanges()
    }
}

// SwiftUI usage
struct NearbyView: View {
    let locationManager = LocationManager.shared

    var body: some View {
        VStack {
            if let location = locationManager.currentLocation {
                Text("Lat: \(location.coordinate.latitude, specifier: "%.4f")")
                Text("Lon: \(location.coordinate.longitude, specifier: "%.4f")")
                Text("Accuracy: \(location.horizontalAccuracy, specifier: "%.0f")m")
            } else {
                Text("Locating...")
            }
        }
        .onAppear {
            locationManager.requestPermission()
        }
    }
}

Geocoding and Reverse Geocoding

import CoreLocation

class GeocodingService {
    private let geocoder = CLGeocoder()

    // Address string to coordinates
    func geocode(address: String) async throws -> CLLocation {
        let placemarks = try await geocoder.geocodeAddressString(address)
        guard let location = placemarks.first?.location else {
            throw GeocodingError.noResults
        }
        return location
    }

    // Coordinates to address
    func reverseGeocode(location: CLLocation) async throws -> String {
        let placemarks = try await geocoder.reverseGeocodeLocation(location)
        guard let placemark = placemarks.first else {
            throw GeocodingError.noResults
        }
        return [
            placemark.name,
            placemark.locality,
            placemark.administrativeArea,
            placemark.country,
        ]
        .compactMap { $0 }
        .joined(separator: ", ")
    }

    // Structured address from placemark
    func structuredAddress(from location: CLLocation) async throws -> Address {
        let placemarks = try await geocoder.reverseGeocodeLocation(location)
        guard let pm = placemarks.first else { throw GeocodingError.noResults }
        return Address(
            street: [pm.subThoroughfare, pm.thoroughfare].compactMap { $0 }.joined(separator: " "),
            city: pm.locality ?? "",
            state: pm.administrativeArea ?? "",
            postalCode: pm.postalCode ?? "",
            country: pm.country ?? "",
            isoCountryCode: pm.isoCountryCode ?? ""
        )
    }
}

struct Address {
    let street, city, state, postalCode, country, isoCountryCode: String
}

enum GeocodingError: Error {
    case noResults
}

Geofencing (CLCircularRegion)

extension LocationManager {

    func startMonitoringRegion(
        center: CLLocationCoordinate2D,
        radius: CLLocationDistance,
        identifier: String
    ) {
        guard CLLocationManager.isMonitoringAvailable(for: CLCircularRegion.self) else { return }

        let region = CLCircularRegion(
            center: center,
            radius: min(radius, manager.maximumRegionMonitoringDistance),
            identifier: identifier
        )
        region.notifyOnEntry = true
        region.notifyOnExit = true

        manager.startMonitoring(for: region)
    }

    func stopMonitoringRegion(identifier: String) {
        for region in manager.monitoredRegions {
            if region.identifier == identifier {
                manager.stopMonitoring(for: region)
            }
        }
    }

    // Delegate callbacks
    func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
        NotificationCenter.default.post(
            name: .didEnterGeofence,
            object: nil,
            userInfo: ["regionId": region.identifier]
        )
    }

    func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) {
        NotificationCenter.default.post(
            name: .didExitGeofence,
            object: nil,
            userInfo: ["regionId": region.identifier]
        )
    }
}

extension Notification.Name {
    static let didEnterGeofence = Notification.Name("didEnterGeofence")
    static let didExitGeofence = Notification.Name("didExitGeofence")
}

// Example: monitor arrival at office
let officeCoordinate = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
LocationManager.shared.startMonitoringRegion(
    center: officeCoordinate,
    radius: 100,
    identifier: "office"
)

iBeacon Monitoring

extension LocationManager {

    func startBeaconMonitoring(uuid: UUID, major: UInt16? = nil, minor: UInt16? = nil) {
        let constraint: CLBeaconIdentityConstraint
        if let major, let minor {
            constraint = CLBeaconIdentityConstraint(uuid: uuid, major: major, minor: minor)
        } else if let major {
            constraint = CLBeaconIdentityConstraint(uuid: uuid, major: major)
        } else {
            constraint = CLBeaconIdentityConstraint(uuid: uuid)
        }

        let region = CLBeaconRegion(beaconIdentityConstraint: constraint, identifier: uuid.uuidString)
        region.notifyEntryStateOnDisplay = true

        manager.startMonitoring(for: region)
        manager.startRangingBeacons(satisfying: constraint)
    }

    func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon],
                         satisfying constraint: CLBeaconIdentityConstraint) {
        for beacon in beacons {
            let proximity: String
            switch beacon.proximity {
            case .immediate: proximity = "immediate"
            case .near: proximity = "near"
            case .far: proximity = "far"
            case .unknown: proximity = "unknown"
            @unknown default: proximity = "unknown"
            }
            print("Beacon \(beacon.minor): \(proximity), accuracy: \(beacon.accuracy)m")
        }
    }
}

Background Location Updates

// Enable in Xcode: Signing & Capabilities > Background Modes > Location updates

extension LocationManager {

    func enableBackgroundUpdates() {
        manager.allowsBackgroundLocationUpdates = true
        manager.pausesLocationUpdatesAutomatically = false
        manager.showsBackgroundLocationIndicator = true // Blue status bar indicator
    }

    // For navigation-style continuous tracking
    func startNavigationTracking() {
        manager.desiredAccuracy = kCLLocationAccuracyBestForNavigation
        manager.activityType = .automotiveNavigation
        enableBackgroundUpdates()
        manager.startUpdatingLocation()
    }

    // For fitness tracking
    func startFitnessTracking() {
        manager.desiredAccuracy = kCLLocationAccuracyBest
        manager.activityType = .fitness
        manager.distanceFilter = 5
        enableBackgroundUpdates()
        manager.startUpdatingLocation()
    }
}

// Visit monitoring (battery efficient, detects arrivals/departures)
extension LocationManager {
    func startVisitMonitoring() {
        manager.startMonitoringVisits()
    }

    func locationManager(_ manager: CLLocationManager, didVisit visit: CLVisit) {
        let coordinate = visit.coordinate
        let arrival = visit.arrivalDate
        let departure = visit.departureDate
        print("Visit at \(coordinate): arrived \(arrival), departed \(departure)")
    }
}

---

# Core Spotlight RAG -- Private App-Local Retrieval for…
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-core-spotlight-rag.html

AI and Machine Learning · Reference guideCore Spotlight RAG -- Private App-Local Retrieval for Foundation ModelsRepository guidance for Core Spotlight RAG, Core Spotlight. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Overview
1. Architecture
2. Index App Content
3. Use SpotlightSearchTool
4. Contact and Identity Queries
5. Grounding Rules
6. Testing
7. Review Checklist

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Choose indexable content
↓2
Index scoped items
↓3
Retrieve relevant records
↓4
Ground the response

02 / ArchitectureResponsibility boundariesBoundary 1
Source documentsBoundary 2
Search indexBoundary 3
Retrieval consumerConnected responsibilities, not a required class hierarchy or an execution trace.
Overview

Core Spotlight indexes app content on device. In the iOS 27 generation, Apple adds 
SpotlightSearchTool
, a Foundation Models 
Tool
 that lets a 
LanguageModelSession
 use app-indexed content as private retrieval context.

Use this for app-local RAG: notes, documents, tasks, messages, projects, photos, or any user-owned content the app already manages.

Core Spotlight indexes remain private to the device owner. Do not mirror this content to a server unless the product explicitly requires sync and explains it.

1. Architecture

App content store
  -> CSSearchableItem + CSSearchableItemAttributeSet
  -> CSSearchableIndex
  -> SpotlightSearchTool
  -> LanguageModelSession
  -> grounded answer with app-local context

Core Spotlight is not just a search UI feature. It becomes the retrieval substrate the model can query through a tool.

2. Index App Content

import CoreSpotlight
import UniformTypeIdentifiers

struct Note: Identifiable {
    let id: String
    let title: String
    let body: String
    let modifiedAt: Date
}

func searchableItem(for note: Note) -> CSSearchableItem {
    let attributes = CSSearchableItemAttributeSet(contentType: .text)
    attributes.title = note.title
    attributes.contentDescription = note.body
    attributes.subject = note.title
    attributes.contentCreationDate = note.modifiedAt

    return CSSearchableItem(
        uniqueIdentifier: note.id,
        domainIdentifier: "notes",
        attributeSet: attributes
    )
}

func index(notes: [Note]) async throws {
    let items = notes.map(searchableItem(for:))
    try await CSSearchableIndex.default().indexSearchableItems(items)
}

Index content as it changes. A model cannot retrieve what the app never indexed, and stale indexes create stale answers.

3. Use 
SpotlightSearchTool

import CoreSpotlight
import FoundationModels

@available(iOS 27.0, macOS 27.0, *)
func makeNotesSession() -> LanguageModelSession {
    var source = CoreSpotlightSource(
        fetchAttributes: [.subject, .contentDescription, .contentCreationDate]
    )
    source.maximumResultCount = 12

    let configuration = SpotlightSearchTool.Configuration(
        sources: [.coreSpotlight(source)]
    )
    let searchTool = SpotlightSearchTool(configuration: configuration)

    return LanguageModelSession(
        tools: [searchTool],
        instructions: """
        Answer using the user's indexed notes when relevant.
        Cite note titles when you use note content.
        Say when the notes do not contain enough information.
        """
    )
}

Configure the source narrowly. Fetch only attributes the answer needs, set a bounded result count, and provide instructions for attribution and uncertainty.

4. Contact and Identity Queries

Searches involving "me", "my manager", or people in messages can require identity resolution. If your app supports person-centric retrieval, implement Apple's contact resolution hooks with your app's account/profile source.

Rules:

Do not infer identity from Contacts without permission and purpose.
Prefer the signed-in app profile when it is authoritative.
Keep identity resolution deterministic in tests.
Redact or avoid sensitive attributes unless the answer needs them.

5. Grounding Rules

The answer should distinguish retrieved facts from model reasoning:

Include source titles or stable item names in UI.
Use a "not found" answer when retrieval returns nothing relevant.
Do not let the model invent records that are absent from the index.
Reindex immediately after edits, deletes, imports, and sync conflict resolution.
Delete index entries when app content is deleted.

6. Testing

Create a deterministic test index:

Seed a few known 
CSSearchableItem
 values.
Ask queries with expected hits.
Ask queries with expected misses.
Verify the model calls the Spotlight tool before answering.
Verify answers cite retrieved item titles.

For model behavior, pair this with 
docs/testing/evaluations.md
 and a 
ToolCallEvaluator
.

7. Review Checklist

[ ] Indexed fields are minimal, useful, and privacy-reviewed
[ ] Index updates run on create/update/delete/import/sync
[ ] 
SpotlightSearchTool
 result count and attributes are bounded
[ ] Instructions require source attribution and uncertainty
[ ] Deleted app content is removed from the Spotlight index
[ ] Tests cover hit, miss, stale-index, and tool-failure paths
[ ] No server RAG is used for private app-local content without explicit product need

See also: 
docs/frameworks/foundation-models.md
, 
docs/frameworks/app-intents-intelligence.md
, 
docs/frameworks/usernotifications.md
, 
docs/testing/evaluations.md
.

---

# CryptoKit
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-cryptokit.html

Authentication, Security, and Privacy · Reference guideCryptoKitRepository guidance for CryptoKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Hashing (SHA-256, SHA-384, SHA-512)
HMAC Authentication
AES-GCM Symmetric Encryption / Decryption
P256 / P384 / P521 Key Agreement and Signing
Curve25519 Key Exchange
Secure Enclave Integration
ChaChaPoly for Performance
Complete Secure Messaging Example

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Choose a cryptographic operation
↓2
Load protected key material
↓3
Authenticate or encrypt data
↓4
Handle verification failure

02 / ArchitectureResponsibility boundariesBoundary 1
Key storageBoundary 2
Cryptographic operationBoundary 3
Protected payloadConnected responsibilities, not a required class hierarchy or an execution trace.
Hashing (SHA-256, SHA-384, SHA-512)

import CryptoKit
import Foundation

// SHA-256
func sha256Hash(data: Data) -> String {
    let digest = SHA256.hash(data: data)
    return digest.compactMap { String(format: "%02x", $0) }.joined()
}

// SHA-384
func sha384Hash(data: Data) -> String {
    let digest = SHA384.hash(data: data)
    return digest.compactMap { String(format: "%02x", $0) }.joined()
}

// SHA-512
func sha512Hash(data: Data) -> String {
    let digest = SHA512.hash(data: data)
    return digest.compactMap { String(format: "%02x", $0) }.joined()
}

// Hash a string
let message = "Hello, CryptoKit!"
let messageData = Data(message.utf8)
let hash = sha256Hash(data: messageData)
// "a1b2c3..." (64 hex characters)

// Hash a file
func hashFile(at url: URL) throws -> SHA256Digest {
    let data = try Data(contentsOf: url)
    return SHA256.hash(data: data)
}

// Compare digests securely (constant-time comparison)
let digest1 = SHA256.hash(data: Data("abc".utf8))
let digest2 = SHA256.hash(data: Data("abc".utf8))
let isEqual = digest1 == digest2 // true — uses constant-time comparison

HMAC Authentication

func createHMAC(message: Data, key: SymmetricKey) -> Data {
    let mac = HMAC<SHA256>.authenticationCode(for: message, using: key)
    return Data(mac)
}

func verifyHMAC(message: Data, mac: Data, key: SymmetricKey) -> Bool {
    HMAC<SHA256>.isValidAuthenticationCode(mac, authenticating: message, using: key)
}

// Usage
let key = SymmetricKey(size: .bits256)
let message = Data("Authenticate this message".utf8)
let mac = createHMAC(message: message, key: key)
let isValid = verifyHMAC(message: message, mac: mac, key: key) // true

// HMAC for API request signing
func signRequest(_ request: inout URLRequest, body: Data, secretKey: SymmetricKey) {
    let timestamp = String(Int(Date().timeIntervalSince1970))
    let payload = timestamp.data(using: .utf8)! + body
    let signature = HMAC<SHA256>.authenticationCode(for: payload, using: secretKey)
    request.setValue(Data(signature).base64EncodedString(), forHTTPHeaderField: "X-Signature")
    request.setValue(timestamp, forHTTPHeaderField: "X-Timestamp")
}

AES-GCM Symmetric Encryption / Decryption

struct AESEncryptor {
    /// Encrypt data with AES-GCM
    static func encrypt(data: Data, key: SymmetricKey) throws -> Data {
        let sealedBox = try AES.GCM.seal(data, using: key)
        // combined = nonce + ciphertext + tag
        guard let combined = sealedBox.combined else {
            throw CryptoError.encryptionFailed
        }
        return combined
    }

    /// Decrypt AES-GCM sealed data
    static func decrypt(data: Data, key: SymmetricKey) throws -> Data {
        let sealedBox = try AES.GCM.SealedBox(combined: data)
        return try AES.GCM.open(sealedBox, using: key)
    }

    /// Encrypt a string
    static func encryptString(_ string: String, key: SymmetricKey) throws -> Data {
        try encrypt(data: Data(string.utf8), key: key)
    }

    /// Decrypt to string
    static func decryptString(data: Data, key: SymmetricKey) throws -> String {
        let decrypted = try decrypt(data: data, key: key)
        guard let string = String(data: decrypted, encoding: .utf8) else {
            throw CryptoError.decodingFailed
        }
        return string
    }

    /// Generate a key from a password using HKDF
    static func deriveKey(from password: String, salt: Data) -> SymmetricKey {
        let inputKey = SymmetricKey(data: Data(password.utf8))
        let derived = HKDF<SHA256>.deriveKey(
            inputKeyMaterial: inputKey,
            salt: salt,
            info: Data("AES-GCM-Encryption".utf8),
            outputByteCount: 32
        )
        return derived
    }
}

enum CryptoError: LocalizedError {
    case encryptionFailed, decodingFailed

    var errorDescription: String? {
        switch self {
        case .encryptionFailed: "Encryption failed."
        case .decodingFailed: "Failed to decode decrypted data."
        }
    }
}

P256 / P384 / P521 Key Agreement and Signing

// Digital signatures with P256
struct ECDSASigner {
    let privateKey: P256.Signing.PrivateKey

    init() {
        privateKey = P256.Signing.PrivateKey()
    }

    var publicKey: P256.Signing.PublicKey {
        privateKey.publicKey
    }

    func sign(data: Data) throws -> P256.Signing.ECDSASignature {
        try privateKey.signature(for: data)
    }

    static func verify(
        signature: P256.Signing.ECDSASignature,
        data: Data,
        publicKey: P256.Signing.PublicKey
    ) -> Bool {
        publicKey.isValidSignature(signature, for: data)
    }
}

// Key agreement (Diffie-Hellman) with P256
struct KeyAgreement {
    static func sharedSecret(
        privateKey: P256.KeyAgreement.PrivateKey,
        publicKey: P256.KeyAgreement.PublicKey
    ) throws -> SymmetricKey {
        let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: publicKey)
        // Derive a symmetric key using HKDF
        return sharedSecret.hkdfDerivedSymmetricKey(
            using: SHA256.self,
            salt: Data(),
            sharedInfo: Data("P256-Key-Agreement".utf8),
            outputByteCount: 32
        )
    }
}

// Usage: Two parties derive the same shared key
let alicePrivate = P256.KeyAgreement.PrivateKey()
let bobPrivate = P256.KeyAgreement.PrivateKey()

let aliceSharedKey = try KeyAgreement.sharedSecret(
    privateKey: alicePrivate, publicKey: bobPrivate.publicKey
)
let bobSharedKey = try KeyAgreement.sharedSecret(
    privateKey: bobPrivate, publicKey: alicePrivate.publicKey
)
// aliceSharedKey == bobSharedKey

Curve25519 Key Exchange

struct Curve25519Exchange {
    static func deriveSharedKey(
        privateKey: Curve25519.KeyAgreement.PrivateKey,
        peerPublicKey: Curve25519.KeyAgreement.PublicKey
    ) throws -> SymmetricKey {
        let shared = try privateKey.sharedSecretFromKeyAgreement(with: peerPublicKey)
        return shared.hkdfDerivedSymmetricKey(
            using: SHA256.self,
            salt: Data(),
            sharedInfo: Data("Curve25519-Exchange".utf8),
            outputByteCount: 32
        )
    }
}

// Curve25519 signing
let signingKey = Curve25519.Signing.PrivateKey()
let message = Data("Sign this message".utf8)
let signature = try signingKey.signature(for: message)
let isValid = signingKey.publicKey.isValidSignature(signature, for: message)

// Export / import keys
let publicKeyData = signingKey.publicKey.rawRepresentation // 32 bytes
let restoredPublicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKeyData)

Secure Enclave Integration

struct SecureEnclaveManager {
    /// Create a private key stored in the Secure Enclave
    static func createKey() throws -> SecureEnclave.P256.Signing.PrivateKey {
        guard SecureEnclave.isAvailable else {
            throw SecureEnclaveError.notAvailable
        }

        // Key with access control
        let accessControl = SecAccessControlCreateWithFlags(
            nil,
            kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
            [.privateKeyUsage, .biometryCurrentSet],
            nil
        )!

        return try SecureEnclave.P256.Signing.PrivateKey(
            accessControl: accessControl
        )
    }

    /// Sign data with Secure Enclave key (requires biometric auth)
    static func sign(data: Data, key: SecureEnclave.P256.Signing.PrivateKey) throws -> Data {
        let signature = try key.signature(for: data)
        return signature.derRepresentation
    }

    /// Persist and restore Secure Enclave keys
    static func persistKey(_ key: SecureEnclave.P256.Signing.PrivateKey) throws -> Data {
        key.dataRepresentation
    }

    static func restoreKey(from data: Data) throws -> SecureEnclave.P256.Signing.PrivateKey {
        try SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: data)
    }
}

enum SecureEnclaveError: LocalizedError {
    case notAvailable
    var errorDescription: String? { "Secure Enclave is not available on this device." }
}

ChaChaPoly for Performance

ChaChaPoly (ChaCha20-Poly1305) is faster than AES-GCM on devices without AES hardware acceleration.

struct ChaChaEncryptor {
    static func encrypt(data: Data, key: SymmetricKey) throws -> Data {
        let sealedBox = try ChaChaPoly.seal(data, using: key)
        return sealedBox.combined
    }

    static func decrypt(data: Data, key: SymmetricKey) throws -> Data {
        let sealedBox = try ChaChaPoly.SealedBox(combined: data)
        return try ChaChaPoly.open(sealedBox, using: key)
    }
}

Complete Secure Messaging Example

import CryptoKit
import Foundation

/// End-to-end encrypted messaging using Curve25519 key exchange + AES-GCM
final class SecureMessenger {
    let identityKey: Curve25519.KeyAgreement.PrivateKey
    var peerPublicKey: Curve25519.KeyAgreement.PublicKey?

    var publicKeyData: Data {
        identityKey.publicKey.rawRepresentation
    }

    init() {
        identityKey = Curve25519.KeyAgreement.PrivateKey()
    }

    /// Set the peer's public key (received over network)
    func setPeerPublicKey(_ data: Data) throws {
        peerPublicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: data)
    }

    /// Derive the shared encryption key
    private func sharedKey() throws -> SymmetricKey {
        guard let peerPublicKey else { throw MessengerError.noPeerKey }
        let shared = try identityKey.sharedSecretFromKeyAgreement(with: peerPublicKey)
        return shared.hkdfDerivedSymmetricKey(
            using: SHA256.self,
            salt: Data("SecureMessenger-v1".utf8),
            sharedInfo: Data(),
            outputByteCount: 32
        )
    }

    /// Encrypt a message
    func encrypt(_ plaintext: String) throws -> Data {
        let key = try sharedKey()
        let data = Data(plaintext.utf8)
        let sealed = try AES.GCM.seal(data, using: key)
        guard let combined = sealed.combined else {
            throw MessengerError.encryptionFailed
        }
        return combined
    }

    /// Decrypt a message
    func decrypt(_ ciphertext: Data) throws -> String {
        let key = try sharedKey()
        let box = try AES.GCM.SealedBox(combined: ciphertext)
        let decrypted = try AES.GCM.open(box, using: key)
        guard let message = String(data: decrypted, encoding: .utf8) else {
            throw MessengerError.decodingFailed
        }
        return message
    }

    /// Sign a message for authenticity
    func sign(_ data: Data) throws -> Data {
        let signingKey = Curve25519.Signing.PrivateKey()
        let signature = try signingKey.signature(for: data)
        return signature
    }
}

enum MessengerError: LocalizedError {
    case noPeerKey, encryptionFailed, decodingFailed

    var errorDescription: String? {
        switch self {
        case .noPeerKey: "Peer public key not set."
        case .encryptionFailed: "Message encryption failed."
        case .decodingFailed: "Failed to decode decrypted message."
        }
    }
}

// Usage
let alice = SecureMessenger()
let bob = SecureMessenger()

try alice.setPeerPublicKey(bob.publicKeyData)
try bob.setPeerPublicKey(alice.publicKeyData)

let encrypted = try alice.encrypt("Hello, Bob!")
let decrypted = try bob.decrypt(encrypted) // "Hello, Bob!"

---

# SwiftData and Core Data Concurrency
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-data-concurrency.html

Data Management · Reference guideSwiftData and Core Data ConcurrencyRepository guidance for Data Concurrency. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

The One Rule
SwiftData

The main-actor context
@ModelActor for background work
Re-fetching by identifier
Getting changes back to the UI
Autosave
SwiftData anti-patterns

Core Data

Context topology
Background work
Crossing the boundary with NSManagedObjectID
Batch operations skip the object graph entirely
Core Data anti-patterns

Choosing a Strategy
Testing
Checklist

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Identify data ownership
↓2
Isolate mutable state
↓3
Cross boundaries with safe values
↓4
Test concurrent operations

02 / ArchitectureResponsibility boundariesBoundary 1
UI actorBoundary 2
Data service boundaryBoundary 3
Persistent storageConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
 importing or syncing data in the background, seeing
"context is not thread safe" or 
Sendable
 errors around a model type, writing
more than a few dozen objects at once, or a list stutters during a save.

docs/frameworks/swiftdata.md
 and 
docs/frameworks/core-data.md
 cover the APIs.
This document covers the single rule that both frameworks enforce and the
patterns that follow from it.

The One Rule

A managed object belongs to the context that fetched it, and that context
belongs to one actor. Never pass the object across a boundary — pass its ID.

Framework

Object

Context

Cross-boundary token

SwiftData

@Model class

ModelContext

PersistentIdentifier

Core Data

NSManagedObject

NSManagedObjectContext

NSManagedObjectID

@Model
 classes are 
not
 
Sendable
, and neither is 
ModelContext
. Any code
that appears to move one between actors is either failing to compile under Swift 6
or silently corrupting the store under Swift 5.

// WRONG — hands a model object to another actor.
let trip = try context.fetch(descriptor).first!
await backgroundWorker.process(trip)          // Sendable error / crash

// RIGHT — hand over the identifier and re-fetch on the far side.
let id = trip.persistentModelID
await backgroundWorker.process(id)

SwiftData

The main-actor context

@Environment(\.modelContext)
 is main-actor-bound and backed by the container's

mainContext
. Use it for everything the user directly touches: a tap that
toggles a flag, a form that inserts one record, a 
@Query
-driven list.

struct TripList: View {
    @Query(sort: \Trip.startDate) private var trips: [Trip]
    @Environment(\.modelContext) private var context

    var body: some View {
        List(trips) { trip in
            TripRow(trip: trip)
                .swipeActions {
                    Button(role: .destructive) {
                        context.delete(trip)     // main actor, immediate, correct
                    } label: { Label("Delete", systemImage: "trash") }
                }
        }
    }
}

Do not move this work off the main actor. A single insert is microseconds; the
actor hop costs more than the save.

@ModelActor
 for background work

Import a thousand records, run a migration, reconcile a sync — that belongs on
its own actor with its own context.

@ModelActor
actor DataImporter {
    /// The macro synthesizes `init(modelContainer:)` plus an isolated
    /// `modelContext` created for THIS actor. Never construct a context yourself.
    func importTrips(from payloads: [TripPayload]) throws {
        for (index, payload) in payloads.enumerated() {
            modelContext.insert(Trip(payload: payload))

            // Batch saves. Saving per-record is orders of magnitude slower;
            // never saving at all balloons memory until the loop ends.
            if index % 500 == 499 {
                try modelContext.save()
            }
        }
        try modelContext.save()
    }

    /// Returns IDs, not objects. The caller re-fetches on its own context.
    func staleTripIDs(before date: Date) throws -> [PersistentIdentifier] {
        let descriptor = FetchDescriptor<Trip>(
            predicate: #Predicate { $0.updatedAt < date }
        )
        return try modelContext.fetch(descriptor).map(\.persistentModelID)
    }
}

// Calling it — the container is Sendable, so it crosses freely.
@MainActor
@Observable
final class TripListModel {
    private let container: ModelContainer
    private(set) var isImporting = false

    init(container: ModelContainer) { self.container = container }

    func runImport(_ payloads: [TripPayload]) async {
        isImporting = true
        defer { isImporting = false }

        let importer = DataImporter(modelContainer: container)
        do {
            try await importer.importTrips(from: payloads)
            // @Query on the main context picks up the change automatically.
        } catch {
            // surface it
        }
    }
}

Re-fetching by identifier

@ModelActor
actor TripEditor {
    func markComplete(_ id: PersistentIdentifier) throws {
        // `model(for:)` resolves an identifier against THIS actor's context.
        guard let trip = modelContext.model(for: id) as? Trip else { return }
        trip.isComplete = true
        try modelContext.save()
    }
}

PersistentIdentifier
 is 
Sendable
 and stable, which is exactly why it — and
not the object — is what crosses.

Getting changes back to the UI

Two mechanisms, in order of preference:

@Query.
 A background save on a context from the same container
   propagates automatically. Nothing to write.
Explicit re-fetch.
 When you are not using 
@Query
, re-fetch on the main
   context after the background actor finishes.

func refresh() {
    trips = (try? context.fetch(FetchDescriptor<Trip>(sort: [.init(\.startDate)]))) ?? []
}

Do 
not
 try to observe a background context's objects from the UI. They belong
to the other actor.

Autosave

ModelContainer
 autosaves by default on the main context. For a background
importer, turn it off so your batching is the only thing writing:

let container = try ModelContainer(
    for: Trip.self,
    configurations: ModelConfiguration(isStoredInMemoryOnly: false)
)
container.mainContext.autosaveEnabled = true    // default; fine for UI edits

SwiftData anti-patterns

// 1. Constructing a context by hand for background work.
let context = ModelContext(container)           // not actor-isolated — unsafe
Task.detached { context.insert(…) }
// Use @ModelActor.

// 2. Returning model objects from an actor.
func fetchTrips() -> [Trip] { … }               // Sendable violation
func fetchTripIDs() -> [PersistentIdentifier] { … }   // correct

// 3. Saving inside a tight loop.
for payload in payloads {
    context.insert(Trip(payload: payload))
    try context.save()                          // one transaction per record
}

// 4. Never saving.
for payload in tenThousandPayloads { context.insert(…) }
try context.save()                              // peak memory holds all 10k

// 5. Doing a large import on the main actor.
@MainActor func importAll() { … }               // freezes the UI

// 6. Storing a @Model object on an @Observable view model as source of truth.
@Observable final class VM { var trip: Trip }    // lifetime tied to a context
// Store the ID, or a Sendable value-type snapshot, and re-fetch.

Core Data

Context topology

NSPersistentContainer
├── viewContext          (main queue)   — UI reads, @FetchRequest
└── newBackgroundContext (private queue) — imports, batch work

final class PersistenceController {
    static let shared = PersistenceController()
    let container: NSPersistentContainer

    init(inMemory: Bool = false) {
        container = NSPersistentContainer(name: "Model")
        if inMemory {
            container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
        }
        container.loadPersistentStores { _, error in
            if let error { fatalError("Store failed to load: \(error)") }
        }

        // Background saves merge into the UI context automatically.
        container.viewContext.automaticallyMergesChangesFromParent = true

        // Last write wins on a property-by-property basis. Choose deliberately:
        // this is correct for server-authoritative sync, wrong for local edits
        // the user expects to keep.
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
        container.viewContext.undoManager = nil          // perf: UI contexts rarely need it
    }
}

Background work

extension PersistenceController {
    /// `performBackgroundTask` gives you a context on its own private queue.
    /// The closure body is the ONLY place that context may be touched.
    func importTrips(_ payloads: [TripPayload]) async throws {
        try await container.performBackgroundTask { context in
            context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy

            for (index, payload) in payloads.enumerated() {
                let trip = Trip(context: context)
                trip.id = payload.id
                trip.name = payload.name

                if index % 500 == 499 {
                    try context.save()
                    context.reset()          // release the object graph
                }
            }
            if context.hasChanges { try context.save() }
        }
    }
}

context.reset()
 after each batch is what keeps memory flat on a large import.
Without it the context retains every object it has ever materialised.

Crossing the boundary with 
NSManagedObjectID

// Background -> main
let objectIDs: [NSManagedObjectID] = try await container.performBackgroundTask { context in
    try context.fetch(request).map(\.objectID)
}

await MainActor.run {
    let viewContext = container.viewContext
    let trips = objectIDs.compactMap { viewContext.object(with: $0) as? Trip }
    // safe: these belong to viewContext
}

Note 
object(with:)
 returns a fault and will throw if the row is gone; use

existingObject(with:)
 when the object may have been deleted.

Batch operations skip the object graph entirely

For deletes and updates of many rows, 
NSBatchDeleteRequest
 /

NSBatchUpdateRequest
 execute in SQL and never materialise objects — orders of
magnitude faster. The cost is that in-memory contexts do not know, so you must
merge the changes yourself.

func deleteTrips(olderThan date: Date) async throws {
    try await container.performBackgroundTask { context in
        let fetch = NSFetchRequest<NSFetchRequestResult>(entityName: "Trip")
        fetch.predicate = NSPredicate(format: "updatedAt < %@", date as NSDate)

        let request = NSBatchDeleteRequest(fetchRequest: fetch)
        request.resultType = .resultTypeObjectIDs

        let result = try context.execute(request) as? NSBatchDeleteResult
        guard let ids = result?.result as? [NSManagedObjectID] else { return }

        // Without this the UI keeps showing deleted rows until relaunch.
        NSManagedObjectContext.mergeChanges(
            fromRemoteContextSave: [NSDeletedObjectsKey: ids],
            into: [self.container.viewContext]
        )
    }
}

Core Data anti-patterns

// 1. Touching a context outside its queue.
let context = container.newBackgroundContext()
context.insert(obj)                              // wrong queue
context.perform { context.insert(obj) }           // correct

// 2. Passing NSManagedObject between queues.
DispatchQueue.main.async { label.text = trip.name }   // trip is not thread-safe
// Read the value inside perform, pass the String.

// 3. Saving viewContext for a large import — blocks the UI.
// 4. Deleting thousands of rows one at a time instead of NSBatchDeleteRequest.
// 5. Forgetting to merge batch-operation results into viewContext.
// 6. automaticallyMergesChangesFromParent left false, then wondering why the
//    UI does not update after a background save.

Choosing a Strategy

Situation

SwiftData

Core Data

One-off user edit

@Environment(\.modelContext)

viewContext

List display

@Query

@FetchRequest / NSFetchedResultsController

Import of 100+ records

@ModelActor

performBackgroundTask

Delete many rows

fetch IDs, delete on a @ModelActor

NSBatchDeleteRequest + merge

Cross-boundary reference

PersistentIdentifier

NSManagedObjectID

Conflict resolution

ModelConfiguration

mergePolicy

Testing

Use an in-memory store so tests are isolated and fast.

// SwiftData
@MainActor
@Suite("TripStore")
struct TripStoreTests {
    private func makeContainer() throws -> ModelContainer {
        try ModelContainer(
            for: Trip.self,
            configurations: ModelConfiguration(isStoredInMemoryOnly: true)
        )
    }

    @Test("import writes every record")
    func importAll() async throws {
        let container = try makeContainer()
        let importer = DataImporter(modelContainer: container)

        try await importer.importTrips(from: TripPayload.samples)

        let count = try container.mainContext.fetchCount(FetchDescriptor<Trip>())
        #expect(count == TripPayload.samples.count)
    }
}

// Core Data
let controller = PersistenceController(inMemory: true)

Previews get the same treatment — see the SwiftData preview container in

docs/design/interaction-standards.md
.

Checklist

[ ] No 
@Model
 object or 
NSManagedObject
 crosses an actor or queue boundary.
[ ] Background work uses 
@ModelActor
 (SwiftData) or 
performBackgroundTask

      (Core Data) — never a hand-rolled context on a detached task.
[ ] Bulk writes save in batches (~500) rather than per-record or once at the end.
[ ] Core Data: 
automaticallyMergesChangesFromParent = true
 on 
viewContext
.
[ ] Core Data: a deliberate 
mergePolicy
, not the default error-on-conflict.
[ ] Batch delete/update results are merged into 
viewContext
.
[ ] Actors return identifiers, never model objects.
[ ] Tests and previews use an in-memory store.

---

# Device Integrity (DeviceCheck & App Attest)
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-device-integrity.html

Authentication, Security, and Privacy · Reference guideDevice Integrity (DeviceCheck & App Attest)Repository guidance for Device Integrity. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

DeviceCheck — Per-Device Bits
DCAppAttestService — Full Attestation Flow

Step 1: Generate a Key
Step 2: Attest the Key with Apple
Step 3: Generate Assertions for API Calls

Server-Side Verification (Reference)
Fraud Prevention Patterns

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Request server challenge
↓2
Generate device attestation
↓3
Validate proof on server
↓4
Apply a server-side policy

02 / ArchitectureResponsibility boundariesBoundary 1
Client attestationBoundary 2
Challenge verificationBoundary 3
Server policyConnected responsibilities, not a required class hierarchy or an execution trace.
DeviceCheck — Per-Device Bits

DeviceCheck lets you store 
two bits
 per device on Apple's servers, persisting across app reinstalls.

import DeviceCheck

final class DeviceCheckManager {
    private let device = DCDevice.current

    /// Check if DeviceCheck is supported
    var isSupported: Bool {
        device.isSupported
    }

    /// Generate a device token to send to your server
    func generateToken() async throws -> Data {
        guard isSupported else {
            throw IntegrityError.deviceCheckNotSupported
        }
        return try await device.generateToken()
    }

    /// Server-side: Query Apple for the two bits
    /// POST https://api.development.devicecheck.apple.com/v1/query_two_bits
    ///
    /// Request body:
    /// {
    ///     "device_token": "<base64 token>",
    ///     "transaction_id": "<uuid>",
    ///     "timestamp": <milliseconds since epoch>
    /// }
    ///
    /// Response:
    /// { "bit0": true, "bit1": false, "last_update_time": "2025-01" }

    /// Server-side: Update the two bits
    /// POST https://api.development.devicecheck.apple.com/v1/update_two_bits
    ///
    /// Request body:
    /// {
    ///     "device_token": "<base64 token>",
    ///     "transaction_id": "<uuid>",
    ///     "timestamp": <milliseconds since epoch>,
    ///     "bit0": true,
    ///     "bit1": false
    /// }

    /// Example: Mark device as having redeemed a promo
    func markPromoRedeemed() async throws {
        let token = try await generateToken()
        let payload: [String: Any] = [
            "device_token": token.base64EncodedString(),
            "transaction_id": UUID().uuidString,
            "timestamp": Int(Date().timeIntervalSince1970 * 1000),
            "bit0": true,    // bit0 = promo redeemed
            "bit1": false
        ]

        var request = URLRequest(url: URL(string: "https://api.yourserver.com/devicecheck/update")!)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try JSONSerialization.data(withJSONObject: payload)

        let (_, response) = try await URLSession.shared.data(for: request)
        guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
            throw IntegrityError.serverError
        }
    }
}

DCAppAttestService — Full Attestation Flow

App Attest cryptographically proves that API requests come from a genuine, unmodified copy of your app.

Step 1: Generate a Key

import DeviceCheck

final class AppAttestManager {
    private let attest = DCAppAttestService.shared

    /// Check if App Attest is supported
    var isSupported: Bool {
        attest.isSupported
    }

    /// Step 1: Generate an attestation key pair (stored in Secure Enclave)
    func generateKey() async throws -> String {
        guard isSupported else {
            throw IntegrityError.appAttestNotSupported
        }
        let keyId = try await attest.generateKey()
        // Store keyId in Keychain for later use
        try KeychainHelper.save(keyId, forKey: "appAttestKeyId")
        return keyId
    }
}

Step 2: Attest the Key with Apple

extension AppAttestManager {

    /// Step 2: Attest the key — do this ONCE per key
    /// Send the attestation object to your server for verification
    func attestKey(_ keyId: String) async throws -> Data {
        // 1. Get a one-time challenge from your server
        let challenge = try await fetchChallenge()

        // 2. Create a hash of the challenge
        let challengeHash = Data(SHA256.hash(data: challenge))

        // 3. Request attestation from Apple
        let attestation = try await attest.attestKey(keyId, clientDataHash: challengeHash)

        // 4. Send attestation + challenge to your server for verification
        try await verifyAttestationOnServer(
            keyId: keyId,
            attestation: attestation,
            challenge: challenge
        )

        return attestation
    }

    private func fetchChallenge() async throws -> Data {
        let url = URL(string: "https://api.yourserver.com/attest/challenge")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return data
    }

    private func verifyAttestationOnServer(
        keyId: String,
        attestation: Data,
        challenge: Data
    ) async throws {
        var request = URLRequest(url: URL(string: "https://api.yourserver.com/attest/verify")!)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        let body: [String: String] = [
            "keyId": keyId,
            "attestation": attestation.base64EncodedString(),
            "challenge": challenge.base64EncodedString()
        ]
        request.httpBody = try JSONEncoder().encode(body)

        let (_, response) = try await URLSession.shared.data(for: request)
        guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
            throw IntegrityError.attestationFailed
        }
    }
}

Step 3: Generate Assertions for API Calls

import CryptoKit

extension AppAttestManager {

    /// Step 3: Generate an assertion for each sensitive API request
    func generateAssertion(for requestData: Data) async throws -> Data {
        guard let keyId = try KeychainHelper.load(forKey: "appAttestKeyId") else {
            throw IntegrityError.noKeyFound
        }

        // Hash the request data (the payload you want to protect)
        let clientDataHash = Data(SHA256.hash(data: requestData))

        // Generate assertion
        let assertion = try await attest.generateAssertion(keyId, clientDataHash: clientDataHash)
        return assertion
    }

    /// Make an attested API request
    func makeAttestedRequest(
        url: URL,
        method: String = "POST",
        body: Data
    ) async throws -> (Data, HTTPURLResponse) {
        let assertion = try await generateAssertion(for: body)

        var request = URLRequest(url: url)
        request.httpMethod = method
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue(assertion.base64EncodedString(), forHTTPHeaderField: "X-App-Assertion")
        request.httpBody = body

        let (data, response) = try await URLSession.shared.data(for: request)
        guard let http = response as? HTTPURLResponse else {
            throw IntegrityError.serverError
        }
        return (data, http)
    }
}

Server-Side Verification (Reference)

Your server must verify attestations and assertions with Apple.

/// Server-side pseudocode (Node.js / Python / Swift on server):
///
/// Attestation verification:
/// 1. Decode the CBOR attestation object
/// 2. Verify the x5c certificate chain leads to Apple's App Attest root CA
/// 3. Extract the public key from the leaf certificate
/// 4. Verify nonce = SHA256(SHA256(challenge) + attestation.authData)
/// 5. Verify the App ID (team ID + bundle ID) in the credential certificate
/// 6. Store the public key and counter for the keyId
///
/// Assertion verification:
/// 1. Decode the CBOR assertion
/// 2. Compute authenticatorData + SHA256(clientData)
/// 3. Verify the signature using the stored public key for this keyId
/// 4. Verify the counter is greater than the stored counter (replay protection)
/// 5. Update the stored counter
///
/// Apple App Attest root certificate:
/// https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem

Fraud Prevention Patterns

/// Comprehensive integrity guard combining DeviceCheck + App Attest
final class IntegrityGuard {
    private let appAttest = AppAttestManager()
    private let deviceCheck = DeviceCheckManager()
    private var isAttested = false

    /// Call during app launch or first sensitive action
    func initialize() async {
        // 1. Generate and attest key (one-time setup)
        guard appAttest.isSupported else {
            // Fallback: use DeviceCheck token for basic verification
            return
        }

        do {
            let existingKeyId = try? KeychainHelper.load(forKey: "appAttestKeyId")

            if existingKeyId == nil {
                let keyId = try await appAttest.generateKey()
                try await appAttest.attestKey(keyId)
            }

            isAttested = true
        } catch {
            // Handle attestation failure — device may be compromised
            // Log and potentially restrict features
        }
    }

    /// Protect a sensitive API call
    func protectedRequest(
        url: URL,
        body: Encodable
    ) async throws -> Data {
        let bodyData = try JSONEncoder().encode(body)

        if isAttested {
            let (data, response) = try await appAttest.makeAttestedRequest(
                url: url,
                body: bodyData
            )
            guard response.statusCode == 200 else {
                throw IntegrityError.serverError
            }
            return data
        } else {
            // Fallback: use DeviceCheck token
            let token = try await deviceCheck.generateToken()

            var request = URLRequest(url: url)
            request.httpMethod = "POST"
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            request.setValue(token.base64EncodedString(), forHTTPHeaderField: "X-Device-Token")
            request.httpBody = bodyData

            let (data, _) = try await URLSession.shared.data(for: request)
            return data
        }
    }
}

/// Keychain helper for storing the attest key ID
enum KeychainHelper {
    static func save(_ value: String, forKey key: String) throws {
        let data = Data(value.utf8)
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecValueData as String: data,
            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
        ]
        SecItemDelete(query as CFDictionary)
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw IntegrityError.keychainError
        }
    }

    static func load(forKey key: String) throws -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)

        guard status == errSecSuccess, let data = result as? Data else {
            return nil
        }
        return String(data: data, encoding: .utf8)
    }
}

enum IntegrityError: LocalizedError {
    case deviceCheckNotSupported
    case appAttestNotSupported
    case attestationFailed
    case noKeyFound
    case serverError
    case keychainError

    var errorDescription: String? {
        switch self {
        case .deviceCheckNotSupported: "DeviceCheck is not supported on this device."
        case .appAttestNotSupported: "App Attest is not supported on this device."
        case .attestationFailed: "Key attestation failed."
        case .noKeyFound: "No attestation key found. Re-enrollment required."
        case .serverError: "Server verification failed."
        case .keychainError: "Keychain operation failed."
        }
    }
}

---

# Extended Apple Frameworks
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-extended-apple-frameworks.html

Core UI and Apps · Reference guideExtended Apple FrameworksRepository guidance for QuickLook, LinkPresentation, UniformTypeIdentifiers, PDFKit, PencilKit, Core Animation, Core Haptics, MLX, File Provider, SQLite, Multipeer Connectivity, Nearby Interaction, Network Extension, SpriteKit, GameplayKit, Game Controller, RoomPlan, Object Capture, AVKit, Core Media, ReplayKit, MusicKit, ShazamKit, App Store Server API, Wallet Orders, Security Framework, AccessorySetupKit, ExternalAccessory, SensorKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Context
Core UI and document workflows

QuickLook
LinkPresentation
UniformTypeIdentifiers
PDFKit
PencilKit

Design, motion, and feedback

Core Animation
Core Haptics

AI and signal processing

MLX
Sound Analysis

Data and file providers

File Provider
SQLite

Networking and connectivity

Multipeer Connectivity
Nearby Interaction
Network Extension

Games, spatial capture, and controllers

SpriteKit
GameplayKit
Game Controller
RoomPlan
Object Capture

Camera, media, and audio

AVKit
Core Media
ReplayKit
MusicKit
ShazamKit

Commerce and wallet server flows

App Store Server API
Wallet Orders

Security and hardware access

Security Framework
AccessorySetupKit
ExternalAccessory
SensorKit

Reporting standard

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Identify the platform task
↓2
Choose a focused framework
↓3
Check permissions and support
↓4
Verify representative inputs

02 / ArchitectureResponsibility boundariesBoundary 1
App requirementBoundary 2
Framework adapterBoundary 3
Platform capabilityConnected responsibilities, not a required class hierarchy or an execution trace.
Context

Load this guide when a requested Apple technology is tracked in 
frameworks.json
 but does not have a dedicated deep-dive file. It covers the remaining framework routes needed for 100% coverage of this repository's Apple technology catalog.

This is not a replacement for Apple documentation. Treat the Apple Developer link in 
frameworks.json
 as canonical, then use this file for agent routing, first implementation choices, verification, and common failure modes.

Core UI and document workflows

QuickLook

Use Quick Look when the app needs system previews for documents, images, PDFs, audio, video, Live Photos, Office/iWork files, text files, or USDZ objects. Prefer 
QLPreviewController
 for user-facing previews. For custom file types, provide a Quick Look preview extension and declare supported UTTypes in 
QLSupportedContentTypes
.

Avoid Quick Look when the app needs advanced playback controls, heavily customized document rendering, or overlays inside the preview. Use AVFoundation, PDFKit, or a custom renderer instead.

Verification: open representative file types on device and simulator, verify security-scoped resource access for document-picker URLs, and test missing/unsupported file behavior.

LinkPresentation

Use LinkPresentation to render rich URL previews with title, icon, image, and metadata. Cache 
LPLinkMetadata
 results so lists do not repeatedly fetch metadata while scrolling.

Rules: never block initial UI on metadata fetching, handle URLs with no preview data, and avoid treating metadata as trusted content.

Verification: test slow networks, private URLs, malformed URLs, and reused cells in scrolling views.

UniformTypeIdentifiers

Use Uniform Type Identifiers for file import/export, document pickers, share sheets, drag and drop, pasteboard, and Quick Look extension declarations. Prefer 
UTType
 over stringly typed MIME or extension checks.

Rules: check conformance with 
conforms(to:)
, define exported/imported types in app metadata when the app owns a custom file format, and keep accepted types narrow.

Verification: test files with uppercase extensions, missing extensions, ambiguous MIME types, iCloud Drive, Files app, AirDrop, and share-sheet imports.

PDFKit

Use PDFKit for native PDF display, navigation, search, annotations, selections, and document metadata. Use SwiftUI wrappers when a SwiftUI app needs 
PDFView
.

Rules: do not load very large PDFs on the main actor if parsing or search can be deferred, avoid assuming every PDF has selectable text, and test password-protected or malformed PDFs.

Verification: test zoom, rotation, memory pressure, search, annotation persistence, dark-mode container contrast, and VoiceOver navigation.

PencilKit

Use PencilKit when the feature needs Apple Pencil drawing, markup, sketches, handwriting surfaces, or low-latency ink. Store 
PKDrawing
 data, not screenshots, when strokes need to remain editable.

Rules: configure tool picker ownership per scene, preserve scale when compositing drawings over images or PDFs, and design finger-vs-pencil behavior intentionally.

Verification: test Apple Pencil, finger drawing, undo/redo, iPad multitasking, orientation changes, and drawing persistence.

Design, motion, and feedback

Core Animation

Use Core Animation for layer-backed animation, custom transitions, masks, shape layers, gradients, replicators, and performance-sensitive visual effects below SwiftUI or UIKit.

Rules: prefer transform and opacity animation over layout animation, keep layer tree changes on the main thread, avoid offscreen-rendering surprises, and set final model-layer values to match animations.

Verification: profile with Core Animation and Instruments, inspect dropped frames, and test Reduce Motion alternatives.

Core Haptics

Use Core Haptics for custom tactile feedback patterns, synchronized audio-haptic experiences, and game-like interactions. Use UIKit feedback generators for simple selection, impact, and notification feedback.

Rules: check hardware capability, handle audio-session interruptions, start/stop engines around app lifecycle events, and provide a non-haptic fallback.

Verification: test on physical devices because simulators cannot prove haptic quality.

AI and signal processing

MLX

Use MLX for Apple-silicon-focused machine-learning experimentation and local model workflows when the project explicitly targets MLX. For app-integrated Apple-platform ML, prefer Foundation Models, Core ML, Vision, Natural Language, Speech, or Accelerate unless MLX is a stated requirement.

Rules: isolate MLX experiments from App Store app code until deployment constraints are clear, document hardware assumptions, and keep model artifacts out of source control unless intentionally small.

Verification: record device/Mac chip, memory footprint, model size, latency, and fallback path.

Sound Analysis

Use Sound Analysis for classifying audio, detecting sound events, or running Core ML models over audio streams. Route speech transcription to Speech, music recognition to ShazamKit, and low-level capture/playback to AVFoundation.

Rules: request microphone permission only when live audio is required, process streaming audio off the main actor, and design privacy copy that explains what audio is analyzed.

Verification: test microphone permission states, background/foreground transitions, noisy rooms, silence, and model confidence thresholds.

Data and file providers

File Provider

Use File Provider when the app exposes remote or app-managed documents through the system Files experience. It is an extension architecture, not a generic file picker.

Rules: model stable item identifiers, sync metadata separately from file contents, handle eviction/materialization, and treat extension memory/time limits as hard constraints.

Verification: test Files app browsing, open-in-place, offline behavior, conflicts, deletion, rename, and large-file download cancellation.

SQLite

Use SQLite when the app needs relational storage, portable database files, full control over queries, or compatibility with an existing SQLite schema. Prefer SwiftData/Core Data for object graph persistence when their model fits.

Rules: put database access behind an actor or serial queue, use prepared statements, run migrations transactionally, and avoid doing database I/O on the main actor.

Verification: test migrations from real older schemas, corruption recovery, concurrent access, large imports, and backup/restore behavior.

Networking and connectivity

Multipeer Connectivity

Use Multipeer Connectivity for nearby peer-to-peer discovery and data exchange over infrastructure Wi-Fi, peer-to-peer Wi-Fi, and Bluetooth-backed discovery. It fits local collaboration, nearby sharing, and offline peer sessions.

Rules: design explicit trust and invitation UX, handle peers appearing/disappearing, bound payload size, and keep session state observable from the main actor.

Verification: test multiple physical devices, locked screens, app backgrounding, network changes, and duplicate peer names.

Nearby Interaction

Use Nearby Interaction for spatially aware nearby-device experiences with supported hardware and compatible peer discovery. Pair it with Multipeer Connectivity or another channel for token exchange.

Rules: guard hardware support, request permission with clear value, and provide a non-UWB fallback.

Verification: test supported and unsupported devices, permission denial, interrupted sessions, distance/orientation accuracy, and multi-user environments.

Network Extension

Use Network Extension for VPN, content filtering, DNS proxying, packet tunnels, app proxying, and related managed-network capabilities. This area often requires entitlements and App Review justification.

Rules: verify entitlement availability before designing the feature, isolate extension code, keep privacy claims exact, and avoid using Network Extension for ordinary HTTP requests.

Verification: test entitlement provisioning, on-device behavior, managed configuration, reconnects, sleep/wake, and App Store policy fit.

Games, spatial capture, and controllers

SpriteKit

Use SpriteKit for 2D games, particle effects, tile maps, sprite animation, physics-driven 2D scenes, and lightweight interactive visual experiences.

Rules: keep game state deterministic, separate rendering from game rules, load atlases intentionally, and avoid mixing SwiftUI layout assumptions into the SpriteKit scene.

Verification: test frame pacing, texture memory, touch/controller input, pause/resume, scene transitions, and different refresh rates.

GameplayKit

Use GameplayKit for game architecture helpers: state machines, entity-component design, pathfinding, randomization, agents, goals, and rule systems.

Rules: keep the simulation model independent from SpriteKit/SceneKit/RealityKit rendering, seed randomness for reproducible tests, and keep state transitions explicit.

Verification: unit-test state machines, pathfinding edge cases, deterministic random sequences, and save/restore behavior.

Game Controller

Use Game Controller for physical controllers, keyboard/mouse game input, controller discovery, button/axis state, haptics, and player indexing.

Rules: support remapping where appropriate, provide touch fallback on iPhone/iPad, handle connect/disconnect live, and avoid assuming a specific controller layout.

Verification: test Xbox/PlayStation/MFi controllers, keyboard input, tvOS focus, disconnects, and multiple players.

RoomPlan

Use RoomPlan for LiDAR-backed room scanning and structured room-capture output. Route general AR placement to ARKit/RealityKit.

Rules: guard device support, explain scanning privacy, handle incomplete scans, and give users editing/review affordances before export.

Verification: test small/large rooms, poor lighting, reflective surfaces, interrupted scans, export, and memory use.

Object Capture

Use Object Capture for photogrammetry workflows that create 3D assets from image sets. Treat it as a capture pipeline with strict input quality needs, not a one-click magic feature.

Rules: guide users through coverage, lighting, focus, and overlap; validate image sets before processing; and plan for long-running work and large outputs.

Verification: test small and reflective objects, incomplete image sets, processing failure, output scale/orientation, and USDZ import into RealityKit.

Camera, media, and audio

AVKit

Use AVKit for system playback UI, Picture in Picture, player controls, and platform-native media presentation. Use AVFoundation when custom capture, editing, composition, or low-level playback control is required.

Rules: manage 
AVPlayer
 lifetime, observe playback state safely, support interruptions, and avoid rebuilding players on every SwiftUI body update.

Verification: test streaming, local files, AirPlay, PiP, background audio policy, captions, interruption, and poor networks.

Core Media

Use Core Media for timestamps, sample buffers, format descriptions, time ranges, and low-level media pipelines. It usually appears with AVFoundation, VideoToolbox, Core Video, or Metal.

Rules: preserve timebase correctness, avoid copying sample buffers unnecessarily, and keep buffer lifetimes explicit.

Verification: test frame timing, audio/video sync, dropped frames, format changes, and memory pressure.

ReplayKit

Use ReplayKit for screen recording, broadcast upload extensions, and user-controlled capture of app or game sessions.

Rules: make recording consent obvious, handle unavailable recording states, protect sensitive screens, and separate broadcast extension constraints from main-app assumptions.

Verification: test start/stop, microphone on/off, interruptions, broadcast extension memory, and App Review privacy expectations.

MusicKit

Use MusicKit for Apple Music catalog access, playback integration, user library access, and music subscription-aware experiences.

Rules: request music authorization only when needed, handle subscription and region availability, and avoid assuming catalog identifiers are playable for every user.

Verification: test authorized/denied states, no subscription, different regions, offline playback expectations, and storefront changes.

ShazamKit

Use ShazamKit for audio matching against Shazam's catalog or a custom signature catalog. Route generic audio classification to Sound Analysis.

Rules: explain microphone use, handle noisy environments, and keep matching UI resilient to no-match outcomes.

Verification: test live microphone matching, file matching, low volume, background noise, and custom catalog updates.

Commerce and wallet server flows

App Store Server API

Use App Store Server API for server-side transaction lookup, subscription status, transaction history, refund/consumption workflows, and App Store Server Notifications integration. Keep StoreKit 2 in the app for on-device purchase flow and transaction listening.

Rules: never put App Store Server API private keys in the app, validate signed data on the server, and model idempotency for notifications.

Verification: test sandbox, production environment separation, notification retries, revoked/refunded transactions, subscription grace periods, and key rotation.

Wallet Orders

Use Wallet Orders for order tracking experiences in Apple Wallet when the product and merchant flow fit Apple's Wallet order model.

Rules: keep order state accurate, privacy-preserving, and synchronized with backend status; avoid using Wallet Orders as a generic notification system.

Verification: test order updates, cancellation/refund states, backend signing, user removal from Wallet, and localization.

Security and hardware access

Security Framework

Use the Security framework for Keychain, certificates, identities, trust evaluation, secure transport-adjacent trust objects, and lower-level security services. Prefer CryptoKit for modern cryptographic operations when it fits.

Rules: store secrets in Keychain, use access control intentionally, avoid custom crypto, and keep certificate-pinning rotation plans realistic.

Verification: test device lock state, biometric changes, keychain migration, iCloud Keychain expectations, certificate expiry, and failure paths.

AccessorySetupKit

Use AccessorySetupKit for guided setup and authorization of compatible accessories. Route ongoing Bluetooth communication to Core Bluetooth or ExternalAccessory as appropriate.

Rules: design setup around user consent, accessory discovery limits, and clear recovery from failed pairing.

Verification: test first setup, re-setup, permission denial, nearby multiple accessories, and accessory firmware differences.

ExternalAccessory

Use ExternalAccessory for Made for iPhone/iPad accessory communication using supported protocols. Do not use it for generic BLE; use Core Bluetooth for that.

Rules: verify protocol strings, entitlement needs, connection lifecycle, and background behavior before implementation.

Verification: test physical accessories, cable/Bluetooth transport, disconnects, background/foreground transitions, and unsupported firmware.

SensorKit

Use SensorKit only when the app has the required entitlement and a legitimate sensor-research or approved data-use case. It is not a general sensor API.

Rules: verify entitlement availability first, keep consent and privacy language precise, minimize retention, and design data export/deletion.

Verification: test authorization states, entitlement provisioning, data availability, privacy disclosures, and fallback behavior when unavailable.

Reporting standard

For any technology in this guide, report with:

Status: VERIFIED / INSPECTED / UNVERIFIED
Apple source: framework URL from frameworks.json
Local guide: docs/frameworks/extended-apple-frameworks.md
Implementation route: framework choice and fallback
Evidence: build, device/simulator run, entitlement check, Instruments, logs, or static review
Remaining risk: unavailable hardware, entitlement, account, device, or OS coverage

---

# Foundation Models
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-foundation-models.html

AI and Machine Learning · Reference guideFoundation ModelsRepository guidance for Foundation Models. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

1. Basic session

Check availability before you show UI

2. Structured output with @Generable

Streaming partial results

3. Tool calling

Built-in system tools (iOS 27+)
Controlling when tools are used

4. Multimodal prompts (iOS 27+)
5. Model selection

Bringing your own model (iOS 27+)

6. Dynamic Profiles (iOS 27+)

Two orchestration patterns

7. Context, tokens, and cost
8. Concurrency
9. Error handling
10. Availability
11. Testing and evaluation
Anti-Patterns
Checklist
Released routing APIs and a compiled starting point

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Check model readiness
↓2
Create a scoped session
↓3
Request structured output or tools
↓4
Validate and handle fallback

02 / ArchitectureResponsibility boundariesBoundary 1
App requestBoundary 2
Model sessionBoundary 3
Tools and typed outputConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
 adding on-device or Private Cloud Compute language-model
features, generating structured Swift data from a prompt, building tool-calling
or agentic behavior, or integrating a third-party LLM through Apple's model
abstraction.

Foundation Models gives you a Swift API over Apple's language models — on-device
via 
SystemLanguageModel
, server-side via 
PrivateCloudComputeLanguageModel
,
and any other model through the open 
LanguageModel
 protocol.

docs/frameworks/ml/on-device-ai.md
 covers the wider on-device AI landscape
(MLX Swift, Core ML). This document is the Foundation Models reference.

Availability:
 the framework and 
SystemLanguageModel
 are iOS 26+/macOS 26+.

PrivateCloudComputeLanguageModel
, Dynamic Profiles, image attachments, and the
open 
LanguageModel
 protocol are 
iOS 27+
. Guard accordingly — see

Availability
 below.

1. Basic session

import FoundationModels

@available(iOS 26.0, macOS 26.0, *)
@MainActor
@Observable
final class SummarizerModel {
    private(set) var summary: String = ""
    private(set) var isResponding = false

    private let session: LanguageModelSession

    init() {
        session = LanguageModelSession(
            instructions: "You summarize articles in two sentences. Be concrete."
        )
    }

    func summarize(_ article: String) async {
        isResponding = true
        defer { isResponding = false }

        do {
            let response = try await session.respond(to: article)
            summary = response.content
        } catch is CancellationError {
            return
        } catch {
            summary = ""
            // surface the error — see Error handling below
        }
    }
}

LanguageModelSession
 is 
stateful
: every prompt and response is appended to
its 
transcript
, and that transcript is what the model sees on the next turn.
One session per conversation; do not reuse one session for unrelated tasks.

Check availability before you show UI

The model is not present on every device or in every region. Branch on it rather
than letting the call fail:

switch SystemLanguageModel.default.availability {
case .available:
    ShowFeature()
case .unavailable(let reason):
    // .deviceNotEligible, .appleIntelligenceNotEnabled, .modelNotReady
    FeatureUnavailableView(reason: reason)
}

Never ship an entry point that only fails at tap time. This is the

@available
 check's runtime counterpart, and both are required.

2. Structured output with 
@Generable

Do not ask for JSON and parse it yourself. 
@Generable
 constrains decoding so
the model returns a real Swift value.

@available(iOS 26.0, macOS 26.0, *)
@Generable
struct Recipe {
    @Guide(description: "Dish name, title case, no punctuation")
    let name: String

    @Guide(description: "Ingredients with quantities", .count(3...12))
    let ingredients: [String]

    @Guide(description: "Total minutes, cooking plus prep", .range(5...240))
    let minutes: Int

    @Guide(description: "Difficulty for a home cook")
    let difficulty: Difficulty

    @Generable
    enum Difficulty: String {
        case easy, medium, hard
    }
}

let response = try await session.respond(
    to: "A weeknight pasta using pantry staples.",
    generating: Recipe.self
)
let recipe: Recipe = response.content   // typed, no JSON parsing

@Guide
 is what makes the output usable. A bare 
let minutes: Int
 invites any
integer; 
.range(5...240)
 makes an out-of-range answer unrepresentable.

Streaming partial results

@Generable
 synthesizes a 
PartiallyGenerated
 type whose properties fill in as
the model produces them — the right way to avoid a spinner on a long generation.

@MainActor
@Observable
final class RecipeModel {
    private(set) var partial: Recipe.PartiallyGenerated?

    func generate(_ prompt: String) async throws {
        let stream = session.streamResponse(to: prompt, generating: Recipe.self)
        for try await partial in stream {
            self.partial = partial       // main-actor write, view updates per chunk
        }
    }
}

// In the view — render what exists, leave the rest as placeholders.
if let name = model.partial?.name {
    Text(name).font(.headline)
} else {
    Text("Generating…").redacted(reason: .placeholder)
}

3. Tool calling

A 
Tool
 lets the model call your code. Use it for anything the model cannot
know: live data, the user's own content, or an action with a side effect.

@available(iOS 26.0, macOS 26.0, *)
struct FindRecipesTool: Tool {
    let name = "findRecipes"
    let description = "Search the user's saved recipes by ingredient."

    // Non-Sendable dependencies must be captured safely — this store is an actor.
    let store: RecipeStore

    @Generable
    struct Arguments {
        @Guide(description: "Ingredient to search for, singular, lowercase")
        let ingredient: String
    }

    func call(arguments: Arguments) async throws -> String {
        let matches = try await store.search(ingredient: arguments.ingredient)
        guard !matches.isEmpty else { return "No saved recipes with that ingredient." }
        return matches.map(\.name).joined(separator: ", ")
    }
}

let session = LanguageModelSession(
    tools: [FindRecipesTool(store: store)],
    instructions: "Help the user cook using recipes they have saved."
)

Tool rules that matter in practice:

description is the routing signal.
 The model decides whether to call your
  tool by reading it. Vague description, tool never fires.
Tools must be Sendable
 and are called from a concurrent context. Hold
  dependencies as actors or immutable values — see 
Concurrency
 below.
Return a short, factual string.
 Not JSON, not prose. The model reads it.
Throwing ends the tool call.
 Handle expected failures by returning a
  sentence the model can use ("no results"), and reserve 
throw
 for real errors.

Built-in system tools (iOS 27+)

Vision-backed tools ship with the framework — do not reimplement them:

Tool

Does

OCRTool

Text extraction from an image

BarcodeReaderTool

Barcode and QR reading

Spotlight search tool

Local retrieval (RAG) over the user's indexed content

Controlling when tools are used

let response = try await session.respond(
    to: "Write out the instructions for folding a paper crane.",
    options: GenerationOptions(toolCallingMode: .required)   // .allowed | .disallowed | .required
)

.required
 forces a tool call — and can loop forever if nothing ends it. Always
give it an exit: flip to 
.disallowed
 once the tool has run, or have the tool
throw 
CancellationError
 to break the loop.

4. Multimodal prompts (iOS 27+)

Images attach directly to a prompt. Accepted: 
UIImage
, 
NSImage
, 
CGImage
,
Core Image images, 
CVPixelBuffer
, and file URLs.

@available(iOS 27.0, *)
func identify(_ image: UIImage) async throws -> String {
    let response = try await session.respond {
        "What animal is this? Answer with the species only."
        Attachment(image)
    }
    return response.content
}

Combine with 
@Generable
 when you need structure rather than a sentence.

5. Model selection

Model

Where it runs

Use for

SystemLanguageModel

On device

Default. Private, offline, free, low latency

PrivateCloudComputeLanguageModel (iOS 27+)

Private Cloud Compute

Larger context (32K), harder reasoning

CoreAILanguageModel, MLXLanguageModel

On device

Open-source conformances for custom weights

Third-party packages

Varies

Anthropic and Google publish conforming Swift packages

@available(iOS 27.0, *)
let session = LanguageModelSession(
    model: PrivateCloudComputeLanguageModel(),
    instructions: "You are a careful technical reviewer."
)

let response = try await session.respond(
    to: prompt,
    contextOptions: ContextOptions(reasoningLevel: .deep)   // .light | .deep
)

Default to on-device.
 Reach for Private Cloud Compute only when the task
genuinely needs the bigger context or deeper reasoning — it costs latency and
requires a network. PCC retains no prompt data; see

docs/frameworks/apple-intelligence.md
 for the privacy model.

Bringing your own model (iOS 27+)

The abstraction is open. A custom provider conforms to two protocols:

public protocol LanguageModel: Sendable {
    var capabilities: LanguageModelCapabilities { get }
    var executorConfiguration: Executor.Configuration { get }
}

public protocol LanguageModelExecutor: Sendable {
    init(configuration: Configuration) throws
    func prewarm(model: Model, transcript: Transcript)
    func respond(
        to request: LanguageModelExecutorGenerationRequest,
        model: Model,
        streamingInto channel: LanguageModelExecutorGenerationChannel
    ) async throws
}

@available(iOS 27.0, *)
struct MyLanguageModel: LanguageModel {
    typealias Executor = MyLanguageModelExecutor

    var capabilities: LanguageModelCapabilities {
        LanguageModelCapabilities(capabilities: [.toolCalling, .guidedGeneration, .reasoning])
    }

    var executorConfiguration: Executor.Configuration {
        Executor.Configuration(endpoint: endpoint, apiKeyIdentifier: keyID)
    }
}

Two things to get right:

Configuration is the cache key.
 The framework caches executors by its
  hash, which is what preserves the KV cache across calls. Do not put per-request
  values in it.
Always implement streaming.
 The one-shot API collects deltas internally, so
  a streaming 
respond
 gives you both for free.

Declare only capabilities you actually support — claiming 
.guidedGeneration

you cannot honor produces malformed output rather than a clean error.

6. Dynamic Profiles (iOS 27+)

A 
DynamicProfile
 swaps instructions, tools, and even the model 
within one
session
, based on your app's state. This is the primitive for agentic features.

@available(iOS 27.0, *)
struct CookingProfile: LanguageModelSession.DynamicProfile {
    let state: CookingState

    var body: some DynamicProfile {
        switch state.mode {
        case .browsing:
            Profile {
                Instructions("Help the user pick a recipe. Be brief.")
                FindRecipesTool(store: state.store)
            }
            .model(state.systemModel)

        case .cooking:
            Profile {
                Instructions("Guide the user step by step. One step at a time.")
                TimerTool()
            }
            .model(state.pccModel)
            .reasoningLevel(.deep)
        }
    }
}

let session = LanguageModelSession(profile: CookingProfile(state: state))

The transcript is preserved across mode switches, so the model keeps context
while its instructions and tools change underneath it.

Two orchestration patterns

Baton pass
 — profiles share the full transcript; a tool flips the mode:

Profile {
    BrainstormInstructions()
    HandoffTool()
}
.onToolCall { state.mode = .planning }

Phone a friend
 — a tool spawns a short-lived child session with its own
isolated transcript, so a subtask cannot pollute the main conversation:

struct SummarizeTool: Tool {
    let name = "summarize"
    let description = "Summarize the discussion so far into one paragraph."

    func call(arguments: Arguments) async throws -> String {
        let child = LanguageModelSession(profile: SummaryProfile())
        return try await child.respond(to: arguments.text).content
    }
}

This mirrors the subagent model in 
docs/orchestration/subagents.md
: isolated
context for the subtask, one result handed back to the caller.

7. Context, tokens, and cost

Context is finite. Long conversations will hit the window.

let model = SystemLanguageModel()
print(model.contextSize)                                  // e.g. 8192
let count = try await model.tokenCount(for: prompt)       // iOS 26.4+

let response = try await session.respond(to: prompt)
print(response.usage.input.totalTokenCount)
print(response.usage.input.cachedTokenCount)
print(response.usage.output.totalTokenCount)

When a transcript grows past the window, transform it rather than letting the
call fail — a rolling window, or dropping completed tool calls:

Profile { CoachInstructions() }
    .historyTransform { history in
        // Keep the most recent exchanges; drop resolved tool traffic.
        history.suffix(40)
    }

Appending preserves the KV cache; rewriting history invalidates it
 and adds
latency. Prefer appending. Measure before you optimize — Xcode's Foundation
Models instrument shows cache behavior directly.

8. Concurrency

Foundation Models is 
async
 throughout and interacts with the isolation rules in

docs/swift/swift-concurrency.md
.

// RIGHT — @MainActor model, session owned by it, tool dependencies are actors.
@available(iOS 26.0, *)
@MainActor
@Observable
final class ChatModel {
    private(set) var messages: [Message] = []
    private let session: LanguageModelSession
    private var task: Task<Void, Never>?

    func send(_ text: String) async {
        task?.cancel()                       // supersede the in-flight response
        let task = Task { await stream(text) }
        self.task = task
        await task.value
    }

    private func stream(_ text: String) async {
        do {
            for try await partial in session.streamResponse(to: text) {
                try Task.checkCancellation()
                messages[messages.count - 1].body = partial
            }
        } catch is CancellationError {
            return
        } catch {
            // surface it
        }
    }
}

Rules:

LanguageModelSession
 is 
not
 re-entrant in a useful way. Check
  
session.isResponding
, or hold a single in-flight 
Task
, before sending
  another prompt. Overlapping calls interleave into one transcript.
Tools run off the main actor.
 Their dependencies must be 
Sendable
 —
  use an 
actor
 store rather than 
@unchecked Sendable
.
Streaming loops must honour cancellation, since 
.task
 cancels on disappear.
Never hold a 
LanguageModelSession
 in a nonisolated 
@Observable
 — same
  data-race rule as any other UI-facing model.

9. Error handling

do {
    let response = try await session.respond(to: prompt)
    handle(response.content)
} catch is CancellationError {
    return                                        // user moved on — not a failure
} catch let error as LanguageModelSession.GenerationError {
    switch error {
    case .exceededContextWindowSize:
        await compactTranscript()                 // then retry
    case .guardrailViolation:
        message = String(localized: "Let's try a different question.")
    case .unsupportedLanguageOrLocale:
        message = String(localized: "Not available in this language yet.")
    default:
        message = String(localized: "Something went wrong. Try again.")
    }
} catch {
    message = String(localized: "Something went wrong. Try again.")
}

Guardrail violations are 
expected
, not exceptional — the model declining is
normal operation. Handle it as a product state with a real message, never as a
crash or a silent empty result.

10. Availability

Two versions are in play. Do not collapse them.

// Baseline framework + on-device model.
@available(iOS 26.0, macOS 26.0, *)

// PCC model, Dynamic Profiles, image attachments, custom LanguageModel providers.
@available(iOS 27.0, *)

func makeSession() -> LanguageModelSession? {
    if #available(iOS 27.0, *) {
        return LanguageModelSession(profile: CookingProfile(state: state))
    } else if #available(iOS 26.0, *) {
        return LanguageModelSession(instructions: fallbackInstructions)
    } else {
        return nil          // feature hidden entirely below iOS 26
    }
}

An app supporting iOS 17+ (this skill's baseline) must treat every Foundation
Models feature as additive. The non-AI path is the product; the AI path is an
enhancement.

11. Testing and evaluation

Model output is non-deterministic, so assert on 
shape and constraints
, not
exact strings.

@Test("recipe generation respects guides")
func recipeConstraints() async throws {
    let response = try await session.respond(to: "A quick pasta.", generating: Recipe.self)
    let recipe = response.content

    #expect((5...240).contains(recipe.minutes))
    #expect((3...12).contains(recipe.ingredients.count))
    #expect(!recipe.name.isEmpty)
}

For UI and unit tests, put the model behind a protocol like every other
dependency (
docs/testing/mocking-strategy.md
), so tests do not invoke a real
model:

protocol RecipeGenerating: Sendable {
    func generate(from prompt: String) async throws -> Recipe
}

struct StubRecipeGenerator: RecipeGenerating {
    var result: Recipe = .sample
    func generate(from prompt: String) async throws -> Recipe { result }
}

For measuring output 
quality
 across prompt changes, use the 
Evaluations
framework
 rather than eyeballing — it quantifies whether a prompt tweak
actually helped.

Anti-Patterns

// 1. Asking for JSON and parsing it by hand.
let json = try await session.respond(to: "Return JSON with name and minutes")
let recipe = try JSONDecoder().decode(Recipe.self, from: Data(json.content.utf8))
// Use @Generable. The model is not a reliable JSON emitter.

// 2. A @Generable type with no @Guide.
@Generable struct Recipe { let minutes: Int }        // accepts 0, accepts 99999
@Generable struct Recipe {
    @Guide(description: "Total minutes", .range(5...240)) let minutes: Int
}

// 3. Shipping the feature without an availability check.
LanguageModelSession(...)                            // fails on ineligible devices
// Check SystemLanguageModel.default.availability first, and gate the entry point.

// 4. One session reused for unrelated tasks.
// The transcript is shared — earlier turns leak into later answers.
// One session per conversation.

// 5. Overlapping prompts on one session.
Button("Send") { Task { await model.send(text) } }   // tap twice, transcripts interleave
// Guard with session.isResponding or a single in-flight Task.

// 6. Treating a guardrail violation as a crash.
let response = try! await session.respond(to: userText)
// Declining is normal. Handle it as a product state.

// 7. A tool whose description does not say when to use it.
let description = "Recipe tool"                      // never gets called
let description = "Search the user's saved recipes by ingredient."

// 8. Non-Sendable state captured in a Tool.
struct MyTool: Tool { let cache: NSMutableDictionary }  // data race
struct MyTool: Tool { let cache: CacheActor }

// 9. Private Cloud Compute by default.
LanguageModelSession(model: PrivateCloudComputeLanguageModel())
// Costs latency and needs a network. Default on-device; escalate deliberately.

// 10. .required tool calling with no exit condition.
// Loops until the context window fills. Flip to .disallowed after the call.

// 11. Blocking the UI on a long generation.
// Stream with PartiallyGenerated and render as it arrives.

// 12. Asserting exact model output in a test.
#expect(recipe.name == "Garlic Pasta")               // flaky by construction
#expect(!recipe.name.isEmpty)                        // assert shape and constraints

Checklist

[ ] 
@available
 guard matches the feature: iOS 26 for the baseline, iOS 27 for
      PCC, Dynamic Profiles, attachments, and custom providers.
[ ] 
SystemLanguageModel.default.availability
 checked before the entry point
      is shown, with a real unavailable state.
[ ] Structured output uses 
@Generable
 with 
@Guide
 constraints — never
      hand-parsed JSON.
[ ] Long generations stream via 
PartiallyGenerated
.
[ ] Every 
Tool
 has a description saying 
when
 to use it, and is 
Sendable
.
[ ] 
.required
 tool calling has an exit condition.
[ ] One session per conversation; overlapping prompts are guarded.
[ ] Guardrail violations and context-window overflow are handled as product
      states with real messages.
[ ] Cancellation is honoured in streaming loops.
[ ] On-device is the default; PCC is a deliberate escalation.
[ ] Tests assert shape and constraints, and unit tests use a protocol double.
[ ] The feature is additive — the app still works with no model available.

Released routing APIs and a compiled starting point

Reviewed 2026-09-16 against 
Apple’s Foundation Models updates
. iOS 27 adds the 
LanguageModel
 protocol for provider adapters, alongside Apple’s on-device and Private Cloud Compute models. A shared protocol does not provide provider credentials, entitlements, pricing, or identical privacy guarantees. Confirm the actual vendor adapter and model capability before offering Claude or Gemini; this repository does not bundle or claim a verified adapter for either.

Apple links 
CoreAILanguageModel
 and 
MLXLanguageModel
 for local model integrations. Device memory, model licensing, conversion and workload size still constrain what can run locally; “full-scale models” is not an unlimited capability guarantee.

ReadingAssistant.swift
 is a complete Foundation Models tool-calling composition with an injected reading catalog, per-request session, runtime availability check and local-search fallback. It uses the iOS 26 baseline so the actual implementation compiles on our installed toolchain. Tests call the real 
Tool
 and exercise fallback/error/cancellation through an injected backend; they do not assert a model chose the tool or generated a correct answer. Xcode 27-only routing remains a separate validation task.

---

# Foundation Framework
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-foundation.html

Data Management · Reference guideFoundation FrameworkRepository guidance for Foundation. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

URLSession — Async Networking
FileManager
UserDefaults and @AppStorage
JSONEncoder/JSONDecoder and Codable
Formatters
NotificationCenter
Timer and RunLoop
ProcessInfo and Bundle

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Define a data contract
↓2
Transform or decode values
↓3
Handle dates files and URLs
↓4
Test malformed input

02 / ArchitectureResponsibility boundariesBoundary 1
Input valuesBoundary 2
Foundation operationsBoundary 3
Domain valuesConnected responsibilities, not a required class hierarchy or an execution trace.
URLSession — Async Networking

// Basic async data request
func fetchUser(id: Int) async throws -> User {
    let url = URL(string: "https://api.example.com/users/\(id)")!
    let (data, response) = try await URLSession.shared.data(from: url)

    guard let httpResponse = response as? HTTPURLResponse,
          (200...299).contains(httpResponse.statusCode) else {
        throw URLError(.badServerResponse)
    }
    return try JSONDecoder().decode(User.self, from: data)
}

// Download with progress
func downloadFile(from url: URL) async throws -> URL {
    let (localURL, response) = try await URLSession.shared.download(from: url)

    guard let httpResponse = response as? HTTPURLResponse,
          httpResponse.statusCode == 200 else {
        throw URLError(.badServerResponse)
    }
    // Move from temp to permanent location
    let destination = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
        .appendingPathComponent(url.lastPathComponent)
    try FileManager.default.moveItem(at: localURL, to: destination)
    return destination
}

// Upload data
func uploadImage(_ imageData: Data) async throws -> UploadResponse {
    var request = URLRequest(url: URL(string: "https://api.example.com/upload")!)
    request.httpMethod = "POST"
    request.setValue("image/jpeg", forHTTPHeaderField: "Content-Type")

    let (data, _) = try await URLSession.shared.upload(for: request, from: imageData)
    return try JSONDecoder().decode(UploadResponse.self, from: data)
}

// Custom URLSession configuration
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
config.waitsForConnectivity = true
config.requestCachePolicy = .returnCacheDataElseLoad
config.httpAdditionalHeaders = ["Authorization": "Bearer \(token)"]
let session = URLSession(configuration: config)

FileManager

let fm = FileManager.default

// App directories
let documentsURL = fm.urls(for: .documentDirectory, in: .userDomainMask).first!
let cachesURL = fm.urls(for: .cachesDirectory, in: .userDomainMask).first!
let appSupportURL = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!

// Create directory
let logsDir = documentsURL.appendingPathComponent("Logs", isDirectory: true)
try fm.createDirectory(at: logsDir, withIntermediateDirectories: true)

// Write and read string
let filePath = documentsURL.appendingPathComponent("notes.txt")
try "Hello, world!".write(to: filePath, atomically: true, encoding: .utf8)
let contents = try String(contentsOf: filePath, encoding: .utf8)

// Write and read JSON
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let jsonData = try encoder.encode(myModel)
try jsonData.write(to: documentsURL.appendingPathComponent("data.json"))

// File attributes
let attrs = try fm.attributesOfItem(atPath: filePath.path)
let fileSize = attrs[.size] as? UInt64
let modified = attrs[.modificationDate] as? Date

// List directory contents
let items = try fm.contentsOfDirectory(
    at: documentsURL,
    includingPropertiesForKeys: [.fileSizeKey, .creationDateKey],
    options: .skipsHiddenFiles
)

// Check existence and delete
if fm.fileExists(atPath: filePath.path) {
    try fm.removeItem(at: filePath)
}

// Copy and move
try fm.copyItem(at: source, to: destination)
try fm.moveItem(at: source, to: destination)

UserDefaults and @AppStorage

// Basic UserDefaults
let defaults = UserDefaults.standard
defaults.set("dark", forKey: "theme")
defaults.set(true, forKey: "notificationsEnabled")
defaults.set(42, forKey: "launchCount")
let theme = defaults.string(forKey: "theme") ?? "system"

// Register defaults (call in AppDelegate or App init)
UserDefaults.standard.register(defaults: [
    "theme": "system",
    "notificationsEnabled": true,
    "launchCount": 0,
])

// App Groups for sharing between app and extensions
let sharedDefaults = UserDefaults(suiteName: "group.com.myapp.shared")
sharedDefaults?.set(true, forKey: "isPremium")

// @AppStorage in SwiftUI — auto-persists to UserDefaults
struct SettingsView: View {
    @AppStorage("theme") private var theme = "system"
    @AppStorage("fontSize") private var fontSize = 16.0
    @AppStorage("isPremium", store: UserDefaults(suiteName: "group.com.myapp.shared"))
    private var isPremium = false

    var body: some View {
        Form {
            Picker("Theme", selection: $theme) {
                Text("System").tag("system")
                Text("Light").tag("light")
                Text("Dark").tag("dark")
            }
            Slider(value: $fontSize, in: 12...24, step: 1) {
                Text("Font Size: \(Int(fontSize))")
            }
        }
    }
}

JSONEncoder/JSONDecoder and Codable

// Basic Codable model
struct Article: Codable, Identifiable {
    let id: Int
    let title: String
    let body: String
    let publishedAt: Date
    let author: Author
    let tags: [String]

    struct Author: Codable {
        let name: String
        let avatarURL: URL

        enum CodingKeys: String, CodingKey {
            case name
            case avatarURL = "avatar_url"
        }
    }
}

// Configured decoder
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
let articles = try decoder.decode([Article].self, from: jsonData)

// Configured encoder
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(articles)

// Custom Codable for complex JSON
struct SearchResult: Codable {
    let query: String
    let results: [Item]

    struct Item: Codable {
        let id: String
        let score: Double
    }

    enum CodingKeys: String, CodingKey {
        case query = "q"
        case results = "hits"
    }

    // Custom decoding
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        query = try container.decode(String.self, forKey: .query)
        results = try container.decodeIfPresent([Item].self, forKey: .results) ?? []
    }
}

Formatters

// DateFormatter
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
dateFormatter.timeStyle = .short
dateFormatter.locale = Locale(identifier: "en_US")
let dateString = dateFormatter.string(from: Date()) // "Mar 30, 2026 at 3:45 PM"

// ISO 8601
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let isoString = isoFormatter.string(from: Date())

// RelativeDateTimeFormatter
let relativeFormatter = RelativeDateTimeFormatter()
relativeFormatter.unitsStyle = .full
relativeFormatter.string(from: Date().addingTimeInterval(-3600)) // "1 hour ago"

// NumberFormatter
let currencyFormatter = NumberFormatter()
currencyFormatter.numberStyle = .currency
currencyFormatter.locale = Locale(identifier: "en_US")
currencyFormatter.string(from: 49.99) // "$49.99"

let percentFormatter = NumberFormatter()
percentFormatter.numberStyle = .percent
percentFormatter.maximumFractionDigits = 1
percentFormatter.string(from: 0.856) // "85.6%"

// Modern formatted API (iOS 15+)
let formatted = Date.now.formatted(.dateTime.month(.wide).day().year())
let price = 49.99.formatted(.currency(code: "USD"))
let percent = 0.856.formatted(.percent.precision(.fractionLength(1)))

NotificationCenter

// Define custom notification
extension Notification.Name {
    static let userDidLogin = Notification.Name("userDidLogin")
    static let cartUpdated = Notification.Name("cartUpdated")
}

// Post notification with userInfo
NotificationCenter.default.post(
    name: .userDidLogin,
    object: nil,
    userInfo: ["userId": "abc123", "timestamp": Date()]
)

// Observe with closure (returns token — store it)
let token = NotificationCenter.default.addObserver(
    forName: .cartUpdated,
    object: nil,
    queue: .main
) { notification in
    if let count = notification.userInfo?["itemCount"] as? Int {
        print("Cart has \(count) items")
    }
}

// Observe with async sequence (iOS 17+)
func observeLoginEvents() async {
    for await notification in NotificationCenter.default.notifications(named: .userDidLogin) {
        if let userId = notification.userInfo?["userId"] as? String {
            print("User logged in: \(userId)")
        }
    }
}

// Remove observer
NotificationCenter.default.removeObserver(token)

Timer and RunLoop

// Scheduled repeating timer
let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
    print("Tick")
}
timer.invalidate() // Stop the timer

// Timer with tolerance for battery efficiency
let efficientTimer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { _ in
    refreshData()
}
efficientTimer.tolerance = 5.0 // System can delay up to 5 seconds

// Async timer pattern
func startCountdown(from seconds: Int) async {
    for remaining in stride(from: seconds, through: 0, by: -1) {
        print("\(remaining)...")
        try? await Task.sleep(for: .seconds(1))
    }
}

// Timer publisher (Combine)
import Combine
let timerPublisher = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()

ProcessInfo and Bundle

// ProcessInfo
let processInfo = ProcessInfo.processInfo
let systemUptime = processInfo.systemUptime
let isLowPowerMode = processInfo.isLowPowerModeEnabled
let thermalState = processInfo.thermalState // .nominal, .fair, .serious, .critical
let osVersion = processInfo.operatingSystemVersion // (major: 18, minor: 0, patch: 0)

// Check for Xcode preview or simulator
#if DEBUG
let isPreview = processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
#endif

// Bundle
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String
let bundleId = Bundle.main.bundleIdentifier

// Load bundled resource
if let url = Bundle.main.url(forResource: "config", withExtension: "json"),
   let data = try? Data(contentsOf: url) {
    let config = try JSONDecoder().decode(AppConfig.self, from: data)
}

// Localized strings
let greeting = Bundle.main.localizedString(forKey: "greeting", value: nil, table: nil)
// Or use the macro: String(localized: "greeting")

---

# CoreBluetooth
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-hardware-core-bluetooth.html

Networking and Connectivity · Reference guideCoreBluetoothRepository guidance for Core Bluetooth. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

CBCentralManager Setup and State Handling
Scanning for Peripherals
Connecting and Discovering Services/Characteristics
Reading, Writing, and Subscribing to Characteristics
CBPeripheralManager — Acting as a Peripheral
Background BLE Execution Modes
Error Handling and Timeout Patterns
Complete BLE Heart Rate Monitor Example

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Check Bluetooth state
↓2
Discover eligible peripherals
↓3
Connect and exchange values
↓4
Recover from disconnection

02 / ArchitectureResponsibility boundariesBoundary 1
Central managerBoundary 2
Peripheral servicesBoundary 3
App connection stateConnected responsibilities, not a required class hierarchy or an execution trace.
CBCentralManager Setup and State Handling

Add to 
Info.plist
:
- 
NSBluetoothAlwaysUsageDescription
 — required for Bluetooth access
- 
UIBackgroundModes
 — include 
bluetooth-central
 and/or 
bluetooth-peripheral
 for background BLE

import CoreBluetooth

@Observable
class BLEManager: NSObject, CBCentralManagerDelegate {
    static let shared = BLEManager()

    var centralManager: CBCentralManager!
    var isBluetoothReady = false
    var bluetoothState: CBManagerState = .unknown
    var discoveredPeripherals: [CBPeripheral] = []
    var connectedPeripheral: CBPeripheral?

    override init() {
        super.init()
        centralManager = CBCentralManager(delegate: self, queue: nil)
    }

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        bluetoothState = central.state

        switch central.state {
        case .poweredOn:
            isBluetoothReady = true
        case .poweredOff:
            isBluetoothReady = false
        case .unauthorized:
            isBluetoothReady = false
        case .unsupported:
            isBluetoothReady = false
        case .resetting:
            isBluetoothReady = false
        case .unknown:
            break
        @unknown default:
            break
        }
    }
}

Scanning for Peripherals

// Define service UUIDs to scan for
let heartRateServiceUUID = CBUUID(string: "180D")
let batteryServiceUUID = CBUUID(string: "180F")

extension BLEManager {
    func startScanning() {
        guard isBluetoothReady else { return }

        // Scan for specific services (recommended) or pass nil for all
        centralManager.scanForPeripherals(
            withServices: [heartRateServiceUUID],
            options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
        )
    }

    func stopScanning() {
        centralManager.stopScan()
    }

    func centralManager(
        _ central: CBCentralManager,
        didDiscover peripheral: CBPeripheral,
        advertisementData: [String: Any],
        rssi RSSI: NSNumber
    ) {
        // Filter by signal strength
        guard RSSI.intValue > -80 else { return }

        // Parse advertisement data
        let localName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
        let isConnectable = advertisementData[CBAdvertisementDataIsConnectable] as? Bool ?? false

        if !discoveredPeripherals.contains(where: { $0.identifier == peripheral.identifier }) {
            discoveredPeripherals.append(peripheral)
        }
    }
}

Connecting and Discovering Services/Characteristics

extension BLEManager: CBPeripheralDelegate {
    func connect(to peripheral: CBPeripheral) {
        centralManager.stopScan()
        peripheral.delegate = self
        centralManager.connect(peripheral, options: nil)
    }

    func disconnect() {
        guard let peripheral = connectedPeripheral else { return }
        centralManager.cancelPeripheralConnection(peripheral)
    }

    // Connection callbacks
    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        connectedPeripheral = peripheral
        // Discover services after connecting
        peripheral.discoverServices([heartRateServiceUUID, batteryServiceUUID])
    }

    func centralManager(
        _ central: CBCentralManager,
        didFailToConnect peripheral: CBPeripheral,
        error: Error?
    ) {
        connectedPeripheral = nil
    }

    func centralManager(
        _ central: CBCentralManager,
        didDisconnectPeripheral peripheral: CBPeripheral,
        error: Error?
    ) {
        connectedPeripheral = nil
        // Auto-reconnect if unexpected disconnect
        if error != nil {
            centralManager.connect(peripheral, options: nil)
        }
    }

    // Service discovery
    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
        guard let services = peripheral.services else { return }

        for service in services {
            peripheral.discoverCharacteristics(nil, for: service)
        }
    }

    // Characteristic discovery
    func peripheral(
        _ peripheral: CBPeripheral,
        didDiscoverCharacteristicsFor service: CBService,
        error: Error?
    ) {
        guard let characteristics = service.characteristics else { return }

        for characteristic in characteristics {
            if characteristic.properties.contains(.notify) {
                peripheral.setNotifyValue(true, for: characteristic)
            }
            if characteristic.properties.contains(.read) {
                peripheral.readValue(for: characteristic)
            }
        }
    }
}

Reading, Writing, and Subscribing to Characteristics

let heartRateMeasurementUUID = CBUUID(string: "2A37")
let bodySensorLocationUUID = CBUUID(string: "2A38")

extension BLEManager {
    // Read a characteristic value
    func readCharacteristic(_ characteristic: CBCharacteristic) {
        guard let peripheral = connectedPeripheral else { return }
        peripheral.readValue(for: characteristic)
    }

    // Write a value to a characteristic
    func writeCharacteristic(
        _ characteristic: CBCharacteristic,
        data: Data,
        withResponse: Bool = true
    ) {
        guard let peripheral = connectedPeripheral else { return }
        let type: CBCharacteristicWriteType = withResponse ? .withResponse : .withoutResponse
        peripheral.writeValue(data, for: characteristic, type: type)
    }

    // Subscribe to notifications
    func subscribeToCharacteristic(_ characteristic: CBCharacteristic) {
        guard let peripheral = connectedPeripheral else { return }
        peripheral.setNotifyValue(true, for: characteristic)
    }

    // Handle updated values
    func peripheral(
        _ peripheral: CBPeripheral,
        didUpdateValueFor characteristic: CBCharacteristic,
        error: Error?
    ) {
        guard let data = characteristic.value, error == nil else { return }

        switch characteristic.uuid {
        case heartRateMeasurementUUID:
            let heartRate = parseHeartRate(from: data)
            // Update UI with heart rate
        case bodySensorLocationUUID:
            let location = parseSensorLocation(from: data)
            // Update UI with sensor location
        default:
            break
        }
    }

    // Write confirmation
    func peripheral(
        _ peripheral: CBPeripheral,
        didWriteValueFor characteristic: CBCharacteristic,
        error: Error?
    ) {
        if let error {
            // Handle write error
        }
    }

    private func parseHeartRate(from data: Data) -> Int {
        let bytes = [UInt8](https://github.com/Nagarjuna2997/ios-agent-skill/blob/main/docs/frameworks/hardware/data)
        // Bit 0 of first byte indicates format: 0 = UInt8, 1 = UInt16
        let isUInt16 = (bytes[0] & 0x01) == 1
        if isUInt16 {
            return Int(UInt16(bytes[1]) | (UInt16(bytes[2]) << 8))
        } else {
            return Int(bytes[1])
        }
    }

    private func parseSensorLocation(from data: Data) -> String {
        let bytes = [UInt8](https://github.com/Nagarjuna2997/ios-agent-skill/blob/main/docs/frameworks/hardware/data)
        switch bytes[0] {
        case 0: return "Other"
        case 1: return "Chest"
        case 2: return "Wrist"
        case 3: return "Finger"
        case 4: return "Hand"
        case 5: return "Ear Lobe"
        case 6: return "Foot"
        default: return "Unknown"
        }
    }
}

CBPeripheralManager — Acting as a Peripheral

@Observable
class BLEPeripheralManager: NSObject, CBPeripheralManagerDelegate {
    var peripheralManager: CBPeripheralManager!
    var isAdvertising = false

    private var heartRateCharacteristic: CBMutableCharacteristic?
    private var subscribedCentrals: [CBCentral] = []

    override init() {
        super.init()
        peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
    }

    func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
        guard peripheral.state == .poweredOn else { return }
        setupServices()
    }

    private func setupServices() {
        // Create characteristic
        let heartRateChar = CBMutableCharacteristic(
            type: CBUUID(string: "2A37"),
            properties: [.notify, .read],
            value: nil,
            permissions: [.readable]
        )
        heartRateCharacteristic = heartRateChar

        // Create service
        let heartRateService = CBMutableService(
            type: CBUUID(string: "180D"),
            primary: true
        )
        heartRateService.characteristics = [heartRateChar]

        peripheralManager.add(heartRateService)
    }

    func peripheralManager(
        _ peripheral: CBPeripheralManager,
        didAdd service: CBService,
        error: Error?
    ) {
        guard error == nil else { return }
        startAdvertising()
    }

    func startAdvertising() {
        peripheralManager.startAdvertising([
            CBAdvertisementDataServiceUUIDsKey: [CBUUID(string: "180D")],
            CBAdvertisementDataLocalNameKey: "MyHeartMonitor"
        ])
        isAdvertising = true
    }

    func stopAdvertising() {
        peripheralManager.stopAdvertising()
        isAdvertising = false
    }

    // Handle subscription from central
    func peripheralManager(
        _ peripheral: CBPeripheralManager,
        central: CBCentral,
        didSubscribeTo characteristic: CBCharacteristic
    ) {
        subscribedCentrals.append(central)
    }

    // Send updated heart rate to subscribers
    func updateHeartRate(_ bpm: Int) {
        guard let characteristic = heartRateCharacteristic else { return }

        var data = Data()
        data.append(UInt8(0x00)) // Flags: UInt8 format
        data.append(UInt8(bpm))

        let didSend = peripheralManager.updateValue(
            data,
            for: characteristic,
            onSubscribedCentrals: nil
        )

        if !didSend {
            // Queue is full; will retry when peripheralManagerIsReady is called
        }
    }

    func peripheralManagerIsReadyToUpdateSubscribers(_ peripheral: CBPeripheralManager) {
        // Retry sending queued updates
    }
}

Background BLE Execution Modes

Add to 
Info.plist
:

<key>UIBackgroundModes</key>
<array>
    <string>bluetooth-central</string>
    <string>bluetooth-peripheral</string>
</array>

// Initialize with state restoration for background operation
class BackgroundBLEManager: NSObject, CBCentralManagerDelegate {
    static let restorationIdentifier = "com.app.ble.central"

    var centralManager: CBCentralManager!

    override init() {
        super.init()
        centralManager = CBCentralManager(
            delegate: self,
            queue: nil,
            options: [
                CBCentralManagerOptionRestoreIdentifierKey: Self.restorationIdentifier
            ]
        )
    }

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        if central.state == .poweredOn {
            // Scanning in background requires specific service UUIDs
            central.scanForPeripherals(
                withServices: [CBUUID(string: "180D")],
                options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
            )
        }
    }

    // State restoration — called when app is relaunched in background
    func centralManager(
        _ central: CBCentralManager,
        willRestoreState dict: [String: Any]
    ) {
        // Restore previously connected peripherals
        if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] {
            for peripheral in peripherals {
                peripheral.delegate = self // reassign delegate
                // Re-discover services if needed
                if peripheral.state == .connected {
                    peripheral.discoverServices([CBUUID(string: "180D")])
                }
            }
        }

        // Restore scan services
        if let scanServices = dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID] {
            // App was scanning for these services
        }
    }
}

extension BackgroundBLEManager: CBPeripheralDelegate {
    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {}
    func peripheral(
        _ peripheral: CBPeripheral,
        didDiscoverCharacteristicsFor service: CBService,
        error: Error?
    ) {}
    func peripheral(
        _ peripheral: CBPeripheral,
        didUpdateValueFor characteristic: CBCharacteristic,
        error: Error?
    ) {}
}

Error Handling and Timeout Patterns

enum BLEError: LocalizedError {
    case bluetoothUnavailable
    case connectionTimeout
    case connectionFailed(Error?)
    case serviceNotFound
    case characteristicNotFound
    case writeFailed(Error?)
    case readFailed(Error?)

    var errorDescription: String? {
        switch self {
        case .bluetoothUnavailable:
            return "Bluetooth is not available on this device."
        case .connectionTimeout:
            return "Connection to the device timed out."
        case .connectionFailed(let error):
            return "Failed to connect: \(error?.localizedDescription ?? "Unknown error")"
        case .serviceNotFound:
            return "Required service not found on device."
        case .characteristicNotFound:
            return "Required characteristic not found."
        case .writeFailed(let error):
            return "Write failed: \(error?.localizedDescription ?? "Unknown error")"
        case .readFailed(let error):
            return "Read failed: \(error?.localizedDescription ?? "Unknown error")"
        }
    }
}

actor BLEConnectionManager {
    private var connectionContinuation: CheckedContinuation<CBPeripheral, Error>?
    private var centralManager: CBCentralManager
    private var delegate: BLEConnectionDelegate

    init(centralManager: CBCentralManager) {
        self.centralManager = centralManager
        self.delegate = BLEConnectionDelegate()
    }

    func connect(to peripheral: CBPeripheral, timeout: Duration = .seconds(10)) async throws -> CBPeripheral {
        try await withThrowingTaskGroup(of: CBPeripheral.self) { group in
            group.addTask {
                try await withCheckedThrowingContinuation { continuation in
                    Task { await self.storeContinuation(continuation) }
                    self.centralManager.connect(peripheral, options: nil)
                }
            }

            group.addTask {
                try await Task.sleep(for: timeout)
                throw BLEError.connectionTimeout
            }

            let result = try await group.next()!
            group.cancelAll()
            return result
        }
    }

    private func storeContinuation(_ continuation: CheckedContinuation<CBPeripheral, Error>) {
        connectionContinuation = continuation
    }

    func didConnect(_ peripheral: CBPeripheral) {
        connectionContinuation?.resume(returning: peripheral)
        connectionContinuation = nil
    }

    func didFailToConnect(_ peripheral: CBPeripheral, error: Error?) {
        connectionContinuation?.resume(throwing: BLEError.connectionFailed(error))
        connectionContinuation = nil
    }
}

private class BLEConnectionDelegate: NSObject {}

Complete BLE Heart Rate Monitor Example

import SwiftUI
import CoreBluetooth

@Observable
class HeartRateMonitor: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
    var heartRate: Int = 0
    var sensorLocation: String = "Unknown"
    var isConnected = false
    var isScanning = false
    var discoveredDevices: [CBPeripheral] = []
    var statusMessage = "Tap Scan to find devices"

    private var centralManager: CBCentralManager!
    private var heartRatePeripheral: CBPeripheral?

    private let heartRateServiceUUID = CBUUID(string: "180D")
    private let heartRateMeasurementUUID = CBUUID(string: "2A37")
    private let bodySensorLocationUUID = CBUUID(string: "2A38")

    override init() {
        super.init()
        centralManager = CBCentralManager(delegate: self, queue: nil)
    }

    func startScan() {
        guard centralManager.state == .poweredOn else {
            statusMessage = "Bluetooth is not ready"
            return
        }
        discoveredDevices.removeAll()
        centralManager.scanForPeripherals(
            withServices: [heartRateServiceUUID],
            options: nil
        )
        isScanning = true
        statusMessage = "Scanning..."
    }

    func stopScan() {
        centralManager.stopScan()
        isScanning = false
    }

    func connect(to peripheral: CBPeripheral) {
        stopScan()
        heartRatePeripheral = peripheral
        peripheral.delegate = self
        centralManager.connect(peripheral, options: nil)
        statusMessage = "Connecting to \(peripheral.name ?? "device")..."
    }

    func disconnectDevice() {
        guard let peripheral = heartRatePeripheral else { return }
        centralManager.cancelPeripheralConnection(peripheral)
    }

    // MARK: - CBCentralManagerDelegate

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        switch central.state {
        case .poweredOn:
            statusMessage = "Ready to scan"
        case .poweredOff:
            statusMessage = "Bluetooth is off"
        case .unauthorized:
            statusMessage = "Bluetooth permission denied"
        default:
            statusMessage = "Bluetooth unavailable"
        }
    }

    func centralManager(
        _ central: CBCentralManager,
        didDiscover peripheral: CBPeripheral,
        advertisementData: [String: Any],
        rssi RSSI: NSNumber
    ) {
        if !discoveredDevices.contains(where: { $0.identifier == peripheral.identifier }) {
            discoveredDevices.append(peripheral)
        }
    }

    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        isConnected = true
        statusMessage = "Connected to \(peripheral.name ?? "device")"
        peripheral.discoverServices([heartRateServiceUUID])
    }

    func centralManager(
        _ central: CBCentralManager,
        didDisconnectPeripheral peripheral: CBPeripheral,
        error: Error?
    ) {
        isConnected = false
        heartRate = 0
        statusMessage = "Disconnected"
    }

    // MARK: - CBPeripheralDelegate

    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
        guard let services = peripheral.services else { return }
        for service in services where service.uuid == heartRateServiceUUID {
            peripheral.discoverCharacteristics(
                [heartRateMeasurementUUID, bodySensorLocationUUID],
                for: service
            )
        }
    }

    func peripheral(
        _ peripheral: CBPeripheral,
        didDiscoverCharacteristicsFor service: CBService,
        error: Error?
    ) {
        guard let characteristics = service.characteristics else { return }
        for characteristic in characteristics {
            switch characteristic.uuid {
            case heartRateMeasurementUUID:
                peripheral.setNotifyValue(true, for: characteristic)
            case bodySensorLocationUUID:
                peripheral.readValue(for: characteristic)
            default:
                break
            }
        }
    }

    func peripheral(
        _ peripheral: CBPeripheral,
        didUpdateValueFor characteristic: CBCharacteristic,
        error: Error?
    ) {
        guard let data = characteristic.value else { return }
        let bytes = [UInt8](https://github.com/Nagarjuna2997/ios-agent-skill/blob/main/docs/frameworks/hardware/data)

        switch characteristic.uuid {
        case heartRateMeasurementUUID:
            let isUInt16 = (bytes[0] & 0x01) == 1
            heartRate = isUInt16
                ? Int(UInt16(bytes[1]) | (UInt16(bytes[2]) << 8))
                : Int(bytes[1])

        case bodySensorLocationUUID:
            let locations = ["Other", "Chest", "Wrist", "Finger", "Hand", "Ear Lobe", "Foot"]
            let index = Int(bytes[0])
            sensorLocation = index < locations.count ? locations[index] : "Unknown"

        default:
            break
        }
    }
}

struct HeartRateView: View {
    @State private var monitor = HeartRateMonitor()

    var body: some View {
        NavigationStack {
            VStack(spacing: 24) {
                // Heart rate display
                VStack(spacing: 8) {
                    Image(systemName: "heart.fill")
                        .font(.system(size: 60))
                        .foregroundStyle(.red)
                        .symbolEffect(.pulse, isActive: monitor.isConnected)

                    Text("\(monitor.heartRate)")
                        .font(.system(size: 72, weight: .bold, design: .rounded))
                        .contentTransition(.numericText())
                        .animation(.spring, value: monitor.heartRate)

                    Text("BPM")
                        .font(.title3)
                        .foregroundStyle(.secondary)

                    Text("Sensor: \(monitor.sensorLocation)")
                        .font(.caption)
                        .foregroundStyle(.tertiary)
                }
                .padding()

                Text(monitor.statusMessage)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)

                // Device list
                if !monitor.discoveredDevices.isEmpty {
                    List(monitor.discoveredDevices, id: \.identifier) { device in
                        Button {
                            monitor.connect(to: device)
                        } label: {
                            HStack {
                                Image(systemName: "heart.circle")
                                Text(device.name ?? "Unknown Device")
                                Spacer()
                                Image(systemName: "chevron.right")
                                    .foregroundStyle(.tertiary)
                            }
                        }
                    }
                    .listStyle(.insetGrouped)
                }

                Spacer()
            }
            .navigationTitle("Heart Rate")
            .toolbar {
                if monitor.isConnected {
                    Button("Disconnect") {
                        monitor.disconnectDevice()
                    }
                } else {
                    Button(monitor.isScanning ? "Stop" : "Scan") {
                        if monitor.isScanning {
                            monitor.stopScan()
                        } else {
                            monitor.startScan()
                        }
                    }
                }
            }
        }
    }
}

---

# CoreMotion
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-hardware-core-motion.html

Health, Hardware, and Sensors · Reference guideCoreMotionRepository guidance for Core Motion. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

CMMotionManager Setup
Accelerometer Data (CMAccelerometerData)
Gyroscope Data (CMGyroData)
Device Motion (CMDeviceMotion)
CMPedometer — Steps, Distance, Floors, Cadence
CMMotionActivityManager — Activity Recognition
CMAltimeter — Relative Altitude
Motion Data Frequency and Battery Considerations
Complete Pedometer and Motion Tracking Examples

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Check sensor availability
↓2
Start required updates
↓3
Process motion samples
↓4
Stop updates on exit

02 / ArchitectureResponsibility boundariesBoundary 1
Device sensorsBoundary 2
Motion processingBoundary 3
Feature stateConnected responsibilities, not a required class hierarchy or an execution trace.
CMMotionManager Setup

Only one 
CMMotionManager
 should exist per app. Create it as a shared instance.

import CoreMotion

@Observable
class MotionManager {
    static let shared = MotionManager()

    let motionManager = CMMotionManager()

    var accelerometerData: CMAccelerometerData?
    var gyroData: CMGyroData?
    var deviceMotion: CMDeviceMotion?

    var isAccelerometerAvailable: Bool { motionManager.isAccelerometerAvailable }
    var isGyroAvailable: Bool { motionManager.isGyroAvailable }
    var isDeviceMotionAvailable: Bool { motionManager.isDeviceMotionAvailable }

    deinit {
        stopAllUpdates()
    }

    func stopAllUpdates() {
        motionManager.stopAccelerometerUpdates()
        motionManager.stopGyroUpdates()
        motionManager.stopDeviceMotionUpdates()
    }
}

Accelerometer Data (CMAccelerometerData)

Measures acceleration along x, y, z axes in G-force units. Includes gravity.

extension MotionManager {
    /// Start accelerometer updates at the given frequency
    func startAccelerometer(frequency: Double = 60.0) {
        guard isAccelerometerAvailable else { return }

        motionManager.accelerometerUpdateInterval = 1.0 / frequency

        motionManager.startAccelerometerUpdates(to: .main) { [weak self] data, error in
            guard let data, error == nil else { return }
            self?.accelerometerData = data

            // Access raw values
            let x = data.acceleration.x  // lateral
            let y = data.acceleration.y  // longitudinal
            let z = data.acceleration.z  // vertical
            let magnitude = sqrt(x * x + y * y + z * z)
        }
    }

    /// Poll-based accelerometer reading (no callback)
    func readAccelerometer() -> CMAccelerometerData? {
        motionManager.startAccelerometerUpdates()
        return motionManager.accelerometerData
    }

    func stopAccelerometer() {
        motionManager.stopAccelerometerUpdates()
    }
}

Gyroscope Data (CMGyroData)

Measures rotation rate in radians per second around x, y, z axes.

extension MotionManager {
    func startGyroscope(frequency: Double = 60.0) {
        guard isGyroAvailable else { return }

        motionManager.gyroUpdateInterval = 1.0 / frequency

        motionManager.startGyroUpdates(to: .main) { [weak self] data, error in
            guard let data, error == nil else { return }
            self?.gyroData = data

            let rotationX = data.rotationRate.x  // pitch rate
            let rotationY = data.rotationRate.y  // yaw rate
            let rotationZ = data.rotationRate.z  // roll rate
        }
    }

    func stopGyroscope() {
        motionManager.stopGyroUpdates()
    }
}

Device Motion (CMDeviceMotion)

Combines accelerometer, gyroscope, and magnetometer into processed motion data. Separates user acceleration from gravity and provides attitude (orientation).

extension MotionManager {
    func startDeviceMotion(
        frequency: Double = 60.0,
        referenceFrame: CMAttitudeReferenceFrame = .xArbitraryZVertical
    ) {
        guard isDeviceMotionAvailable else { return }

        motionManager.deviceMotionUpdateInterval = 1.0 / frequency

        motionManager.startDeviceMotionUpdates(
            using: referenceFrame,
            to: .main
        ) { [weak self] motion, error in
            guard let motion, error == nil else { return }
            self?.deviceMotion = motion

            // Attitude — device orientation
            let pitch = motion.attitude.pitch   // radians, nose up/down
            let roll = motion.attitude.roll     // radians, tilt left/right
            let yaw = motion.attitude.yaw       // radians, compass heading

            // Rotation rate (processed, bias removed)
            let rotRate = motion.rotationRate

            // Gravity vector (unit gravity direction in device frame)
            let gx = motion.gravity.x
            let gy = motion.gravity.y
            let gz = motion.gravity.z

            // User acceleration (gravity removed)
            let ux = motion.userAcceleration.x
            let uy = motion.userAcceleration.y
            let uz = motion.userAcceleration.z

            // Magnetic field (calibrated)
            let field = motion.magneticField.field
            let accuracy = motion.magneticField.accuracy
        }
    }

    func stopDeviceMotion() {
        motionManager.stopDeviceMotionUpdates()
    }

    /// Detect device orientation from gravity
    func currentOrientation() -> String {
        guard let gravity = deviceMotion?.gravity else { return "Unknown" }

        if gravity.z < -0.8 { return "Face Up" }
        if gravity.z > 0.8 { return "Face Down" }
        if gravity.x < -0.8 { return "Landscape Left" }
        if gravity.x > 0.8 { return "Landscape Right" }
        if gravity.y < -0.8 { return "Portrait" }
        if gravity.y > 0.8 { return "Portrait Upside Down" }
        return "Unknown"
    }
}

CMPedometer — Steps, Distance, Floors, Cadence

No special permissions needed; data is available if the hardware supports it. Use 
CMPedometer
 instead of 
CMMotionManager
 for step counting.

@Observable
class PedometerManager {
    let pedometer = CMPedometer()

    var steps: Int = 0
    var distance: Double = 0        // meters
    var floorsAscended: Int = 0
    var floorsDescended: Int = 0
    var currentCadence: Double = 0  // steps per second
    var currentPace: Double = 0     // seconds per meter
    var isAvailable: Bool { CMPedometer.isStepCountingAvailable() }

    /// Query historical pedometer data
    func fetchSteps(from start: Date, to end: Date = .now) async throws -> CMPedometerData {
        try await withCheckedThrowingContinuation { continuation in
            pedometer.queryPedometerData(from: start, to: end) { data, error in
                if let error {
                    continuation.resume(throwing: error)
                    return
                }
                guard let data else {
                    continuation.resume(throwing: PedometerError.noData)
                    return
                }
                continuation.resume(returning: data)
            }
        }
    }

    /// Start live pedometer updates from a given date
    func startLiveUpdates(from date: Date = Calendar.current.startOfDay(for: .now)) {
        guard isAvailable else { return }

        pedometer.startUpdates(from: date) { [weak self] data, error in
            guard let data, error == nil else { return }

            Task { @MainActor in
                self?.steps = data.numberOfSteps.intValue
                self?.distance = data.distance?.doubleValue ?? 0
                self?.floorsAscended = data.floorsAscended?.intValue ?? 0
                self?.floorsDescended = data.floorsDescended?.intValue ?? 0
                self?.currentCadence = data.currentCadence?.doubleValue ?? 0
                self?.currentPace = data.currentPace?.doubleValue ?? 0
            }
        }
    }

    func stopLiveUpdates() {
        pedometer.stopUpdates()
    }
}

enum PedometerError: LocalizedError {
    case noData
    case notAvailable

    var errorDescription: String? {
        switch self {
        case .noData: return "No pedometer data available."
        case .notAvailable: return "Step counting is not available on this device."
        }
    }
}

CMMotionActivityManager — Activity Recognition

Detects whether the user is walking, running, driving, cycling, or stationary. Requires 
NSMotionUsageDescription
 in 
Info.plist
.

import CoreMotion

@Observable
class ActivityManager {
    let activityManager = CMMotionActivityManager()

    var currentActivity: String = "Unknown"
    var confidence: CMMotionActivityConfidence = .low
    var isStationary = false
    var isWalking = false
    var isRunning = false
    var isAutomotive = false
    var isCycling = false

    var isAvailable: Bool { CMMotionActivityManager.isActivityAvailable() }

    func startActivityUpdates() {
        guard isAvailable else { return }

        activityManager.startActivityUpdates(to: .main) { [weak self] activity in
            guard let activity, let self else { return }

            self.confidence = activity.confidence
            self.isStationary = activity.stationary
            self.isWalking = activity.walking
            self.isRunning = activity.running
            self.isAutomotive = activity.automotive
            self.isCycling = activity.cycling

            self.currentActivity = self.activityDescription(activity)
        }
    }

    func stopActivityUpdates() {
        activityManager.stopActivityUpdates()
    }

    /// Query historical activities
    func fetchActivities(from start: Date, to end: Date = .now) async throws -> [CMMotionActivity] {
        try await withCheckedThrowingContinuation { continuation in
            activityManager.queryActivityStarting(
                from: start,
                to: end,
                to: .main
            ) { activities, error in
                if let error {
                    continuation.resume(throwing: error)
                    return
                }
                continuation.resume(returning: activities ?? [])
            }
        }
    }

    private func activityDescription(_ activity: CMMotionActivity) -> String {
        if activity.running { return "Running" }
        if activity.cycling { return "Cycling" }
        if activity.automotive { return "Driving" }
        if activity.walking { return "Walking" }
        if activity.stationary { return "Stationary" }
        return "Unknown"
    }
}

CMAltimeter — Relative Altitude

Measures relative altitude changes using the barometric pressure sensor.

@Observable
class AltimeterManager {
    let altimeter = CMAltimeter()

    var relativeAltitude: Double = 0  // meters, relative to start
    var pressure: Double = 0          // kilopascals

    var isAvailable: Bool { CMAltimeter.isRelativeAltitudeAvailable() }

    func startAltimeterUpdates() {
        guard isAvailable else { return }

        altimeter.startRelativeAltitudeUpdates(to: .main) { [weak self] data, error in
            guard let data, error == nil else { return }

            self?.relativeAltitude = data.relativeAltitude.doubleValue
            self?.pressure = data.pressure.doubleValue
        }
    }

    func stopAltimeterUpdates() {
        altimeter.stopRelativeAltitudeUpdates()
    }
}

Motion Data Frequency and Battery Considerations

/// Frequency guidelines:
/// - UI updates (tilt, orientation): 10-30 Hz
/// - Games and AR: 60 Hz
/// - Gesture detection: 50-100 Hz
/// - Avoid > 100 Hz unless necessary — major battery drain

extension MotionManager {
    /// Configure for low-power UI-driven usage
    func configureLowPower() {
        motionManager.accelerometerUpdateInterval = 1.0 / 10.0  // 10 Hz
        motionManager.gyroUpdateInterval = 1.0 / 10.0
        motionManager.deviceMotionUpdateInterval = 1.0 / 10.0
    }

    /// Configure for high-fidelity game/AR usage
    func configureHighFidelity() {
        motionManager.accelerometerUpdateInterval = 1.0 / 60.0  // 60 Hz
        motionManager.gyroUpdateInterval = 1.0 / 60.0
        motionManager.deviceMotionUpdateInterval = 1.0 / 60.0
    }
}

/// Best practices:
/// - Stop updates when the app enters the background
/// - Use the lowest frequency that meets your needs
/// - Prefer CMDeviceMotion over raw accelerometer/gyro (sensor fusion is better)
/// - Use CMPedometer for step counting instead of raw accelerometer
/// - Process data on a background queue, update UI on main queue

Complete Pedometer and Motion Tracking Examples

import SwiftUI
import CoreMotion

struct PedometerView: View {
    @State private var pedometerManager = PedometerManager()
    @State private var activityManager = ActivityManager()

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 24) {
                    // Step counter ring
                    ZStack {
                        Circle()
                            .stroke(.gray.opacity(0.2), lineWidth: 12)

                        Circle()
                            .trim(from: 0, to: min(Double(pedometerManager.steps) / 10000.0, 1.0))
                            .stroke(
                                .green.gradient,
                                style: StrokeStyle(lineWidth: 12, lineCap: .round)
                            )
                            .rotationEffect(.degrees(-90))
                            .animation(.spring(duration: 0.8), value: pedometerManager.steps)

                        VStack(spacing: 4) {
                            Image(systemName: "figure.walk")
                                .font(.largeTitle)
                                .foregroundStyle(.green)

                            Text("\(pedometerManager.steps)")
                                .font(.system(size: 48, weight: .bold, design: .rounded))
                                .contentTransition(.numericText())

                            Text("steps today")
                                .font(.subheadline)
                                .foregroundStyle(.secondary)
                        }
                    }
                    .frame(width: 200, height: 200)

                    // Current activity
                    HStack {
                        Image(systemName: activityIcon)
                            .font(.title2)
                            .foregroundStyle(.blue)
                            .frame(width: 44, height: 44)
                            .background(.blue.opacity(0.1), in: Circle())

                        VStack(alignment: .leading) {
                            Text("Current Activity")
                                .font(.caption)
                                .foregroundStyle(.secondary)
                            Text(activityManager.currentActivity)
                                .font(.headline)
                        }

                        Spacer()
                    }
                    .padding()
                    .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
                    .padding(.horizontal)

                    // Metrics grid
                    LazyVGrid(columns: [
                        GridItem(.flexible()),
                        GridItem(.flexible())
                    ], spacing: 16) {
                        MotionMetricCard(
                            title: "Distance",
                            value: String(
                                format: "%.1f",
                                pedometerManager.distance / 1000
                            ),
                            unit: "km",
                            icon: "location.fill",
                            color: .blue
                        )

                        MotionMetricCard(
                            title: "Floors Up",
                            value: "\(pedometerManager.floorsAscended)",
                            unit: "floors",
                            icon: "arrow.up",
                            color: .orange
                        )

                        MotionMetricCard(
                            title: "Cadence",
                            value: String(
                                format: "%.0f",
                                pedometerManager.currentCadence * 60
                            ),
                            unit: "steps/min",
                            icon: "metronome.fill",
                            color: .purple
                        )

                        MotionMetricCard(
                            title: "Floors Down",
                            value: "\(pedometerManager.floorsDescended)",
                            unit: "floors",
                            icon: "arrow.down",
                            color: .teal
                        )
                    }
                    .padding(.horizontal)
                }
                .padding(.top)
            }
            .navigationTitle("Pedometer")
            .onAppear {
                pedometerManager.startLiveUpdates()
                activityManager.startActivityUpdates()
            }
            .onDisappear {
                pedometerManager.stopLiveUpdates()
                activityManager.stopActivityUpdates()
            }
        }
    }

    var activityIcon: String {
        if activityManager.isRunning { return "figure.run" }
        if activityManager.isCycling { return "figure.outdoor.cycle" }
        if activityManager.isAutomotive { return "car.fill" }
        if activityManager.isWalking { return "figure.walk" }
        return "figure.stand"
    }
}

struct MotionMetricCard: View {
    let title: String
    let value: String
    let unit: String
    let icon: String
    let color: Color

    var body: some View {
        VStack(spacing: 8) {
            Image(systemName: icon)
                .foregroundStyle(color)
                .font(.title3)

            Text(value)
                .font(.system(.title2, design: .rounded, weight: .bold))
                .contentTransition(.numericText())

            Text(unit)
                .font(.caption)
                .foregroundStyle(.secondary)
        }
        .frame(maxWidth: .infinity)
        .padding()
        .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
    }
}

struct MotionTrackerView: View {
    @State private var motionManager = MotionManager()

    var body: some View {
        NavigationStack {
            List {
                if let motion = motionManager.deviceMotion {
                    Section("Attitude (degrees)") {
                        MotionRow(label: "Pitch", value: degrees(motion.attitude.pitch))
                        MotionRow(label: "Roll", value: degrees(motion.attitude.roll))
                        MotionRow(label: "Yaw", value: degrees(motion.attitude.yaw))
                    }

                    Section("User Acceleration (G)") {
                        MotionRow(label: "X", value: motion.userAcceleration.x)
                        MotionRow(label: "Y", value: motion.userAcceleration.y)
                        MotionRow(label: "Z", value: motion.userAcceleration.z)
                    }

                    Section("Gravity (G)") {
                        MotionRow(label: "X", value: motion.gravity.x)
                        MotionRow(label: "Y", value: motion.gravity.y)
                        MotionRow(label: "Z", value: motion.gravity.z)
                    }

                    Section("Rotation Rate (rad/s)") {
                        MotionRow(label: "X", value: motion.rotationRate.x)
                        MotionRow(label: "Y", value: motion.rotationRate.y)
                        MotionRow(label: "Z", value: motion.rotationRate.z)
                    }

                    Section("Orientation") {
                        Text(motionManager.currentOrientation())
                            .font(.headline)
                    }
                } else {
                    ContentUnavailableView(
                        "No Motion Data",
                        systemImage: "gyroscope",
                        description: Text("Waiting for device motion updates.")
                    )
                }
            }
            .navigationTitle("Motion Tracker")
            .onAppear {
                motionManager.startDeviceMotion(frequency: 30)
            }
            .onDisappear {
                motionManager.stopDeviceMotion()
            }
        }
    }

    func degrees(_ radians: Double) -> Double {
        radians * 180.0 / .pi
    }
}

struct MotionRow: View {
    let label: String
    let value: Double

    var body: some View {
        HStack {
            Text(label)
                .foregroundStyle(.secondary)
            Spacer()
            Text(String(format: "%.3f", value))
                .monospacedDigit()
                .contentTransition(.numericText())
        }
    }
}

---

# CoreNFC
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-hardware-core-nfc.html

Health, Hardware, and Sensors · Reference guideCoreNFCRepository guidance for Core NFC. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

NFCNDEFReaderSession Setup and Entitlements
Reading NDEF Tags
Parsing NDEF Payloads — URL, Text, Custom
Writing to NDEF Tags
NFCTagReaderSession — ISO 14443, ISO 15693, FeliCa
Background Tag Reading
Error Handling
Complete NFC Tag Reader/Writer Example

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Check reading capability
↓2
Start a foreground session
↓3
Read supported tag data
↓4
Invalidate the session

02 / ArchitectureResponsibility boundariesBoundary 1
Reader sessionBoundary 2
NFC tagBoundary 3
Validated domain dataConnected responsibilities, not a required class hierarchy or an execution trace.
NFCNDEFReaderSession Setup and Entitlements

Requirements:
- iPhone 7 or later
- Add 
Near Field Communication Tag Reading
 capability in Xcode
- Add 
NFCReaderUsageDescription
 to 
Info.plist

- Add NFC entitlement to your provisioning profile

Info.plist
 entries:

<key>NFCReaderUsageDescription</key>
<string>This app reads NFC tags to retrieve information.</string>

<!-- For background tag reading -->
<key>com.apple.developer.associated-application-identifier</key>
<string>$(TeamIdentifierPrefix)com.example.app</string>

Entitlements file (
.entitlements
):

<key>com.apple.developer.nfc.readersession.formats</key>
<array>
    <string>NDEF</string>
    <string>TAG</string>
</array>

import CoreNFC

@Observable
class NFCManager: NSObject {
    var scannedMessage: String = ""
    var scannedRecords: [NFCRecord] = []
    var isScanning = false
    var error: Error?

    private var ndefSession: NFCNDEFReaderSession?
    private var writeMessage: NFCNDEFMessage?

    var isNFCAvailable: Bool {
        NFCNDEFReaderSession.readingAvailable
    }
}

struct NFCRecord: Identifiable {
    let id = UUID()
    let type: String
    let payload: String
    let rawData: Data
}

Reading NDEF Tags

extension NFCManager: NFCNDEFReaderSessionDelegate {
    func startReading() {
        guard isNFCAvailable else {
            error = NFCError.notAvailable
            return
        }

        ndefSession = NFCNDEFReaderSession(
            delegate: self,
            queue: nil,
            invalidateAfterFirstRead: true
        )
        ndefSession?.alertMessage = "Hold your iPhone near an NFC tag."
        ndefSession?.begin()
        isScanning = true
    }

    // MARK: - NFCNDEFReaderSessionDelegate

    func readerSessionDidBecomeActive(_ session: NFCNDEFReaderSession) {
        // Session is active and scanning
    }

    func readerSession(
        _ session: NFCNDEFReaderSession,
        didDetectNDEFs messages: [NFCNDEFMessage]
    ) {
        var records: [NFCRecord] = []

        for message in messages {
            for record in message.records {
                let parsed = parseNDEFRecord(record)
                records.append(parsed)
            }
        }

        Task { @MainActor in
            self.scannedRecords = records
            self.scannedMessage = records.map(\.payload).joined(separator: "\n")
            self.isScanning = false
        }
    }

    func readerSession(
        _ session: NFCNDEFReaderSession,
        didInvalidateWithError error: Error
    ) {
        let readerError = error as? NFCReaderError

        Task { @MainActor in
            // User cancelled is not a real error
            if readerError?.code != .readerSessionInvalidationErrorUserCanceled {
                self.error = error
            }
            self.isScanning = false
        }
    }
}

Parsing NDEF Payloads — URL, Text, Custom

extension NFCManager {
    func parseNDEFRecord(_ record: NFCNDEFPayload) -> NFCRecord {
        let typeString = String(data: record.type, encoding: .utf8) ?? "Unknown"

        switch record.typeNameFormat {
        case .nfcWellKnown:
            return parseWellKnownRecord(record, typeString: typeString)
        case .media:
            let mimeType = typeString
            let payload = String(data: record.payload, encoding: .utf8) ?? ""
            return NFCRecord(type: mimeType, payload: payload, rawData: record.payload)
        case .absoluteURI:
            let uri = String(data: record.payload, encoding: .utf8) ?? ""
            return NFCRecord(type: "URI", payload: uri, rawData: record.payload)
        case .nfcExternal:
            let payload = String(data: record.payload, encoding: .utf8) ?? ""
            return NFCRecord(type: "External: \(typeString)", payload: payload, rawData: record.payload)
        default:
            return NFCRecord(
                type: "Unknown",
                payload: record.payload.map { String(format: "%02x", $0) }.joined(),
                rawData: record.payload
            )
        }
    }

    private func parseWellKnownRecord(
        _ record: NFCNDEFPayload,
        typeString: String
    ) -> NFCRecord {
        switch typeString {
        case "T":
            // Text record
            return parseTextRecord(record)
        case "U":
            // URI record
            return parseURIRecord(record)
        default:
            let payload = String(data: record.payload, encoding: .utf8) ?? ""
            return NFCRecord(type: typeString, payload: payload, rawData: record.payload)
        }
    }

    private func parseTextRecord(_ record: NFCNDEFPayload) -> NFCRecord {
        let payload = record.payload
        guard !payload.isEmpty else {
            return NFCRecord(type: "Text", payload: "", rawData: payload)
        }

        let statusByte = payload[0]
        let languageCodeLength = Int(statusByte & 0x3F)
        let isUTF16 = (statusByte & 0x80) != 0

        let textStartIndex = 1 + languageCodeLength
        guard textStartIndex < payload.count else {
            return NFCRecord(type: "Text", payload: "", rawData: payload)
        }

        let textData = payload.subdata(in: textStartIndex..<payload.count)
        let encoding: String.Encoding = isUTF16 ? .utf16 : .utf8
        let text = String(data: textData, encoding: encoding) ?? ""

        return NFCRecord(type: "Text", payload: text, rawData: payload)
    }

    private func parseURIRecord(_ record: NFCNDEFPayload) -> NFCRecord {
        // Use Apple's built-in helper
        if let url = record.wellKnownTypeURIPayload() {
            return NFCRecord(type: "URL", payload: url.absoluteString, rawData: record.payload)
        }

        // Manual parsing fallback
        let prefixes = [
            0x00: "",
            0x01: "http://www.",
            0x02: "https://www.",
            0x03: "http://",
            0x04: "https://",
            0x05: "tel:",
            0x06: "mailto:"
        ]

        let payload = record.payload
        guard !payload.isEmpty else {
            return NFCRecord(type: "URL", payload: "", rawData: payload)
        }

        let prefix = prefixes[Int(payload[0])] ?? ""
        let remainder = String(data: payload.subdata(in: 1..<payload.count), encoding: .utf8) ?? ""

        return NFCRecord(type: "URL", payload: prefix + remainder, rawData: payload)
    }
}

Writing to NDEF Tags

extension NFCManager {
    func startWriting(text: String) {
        guard isNFCAvailable else {
            error = NFCError.notAvailable
            return
        }

        // Create NDEF message to write
        guard let textPayload = NFCNDEFPayload.wellKnownTypeTextPayload(
            string: text,
            locale: .current
        ) else {
            error = NFCError.invalidPayload
            return
        }

        writeMessage = NFCNDEFMessage(records: [textPayload])

        ndefSession = NFCNDEFReaderSession(
            delegate: self,
            queue: nil,
            invalidateAfterFirstRead: false  // false to allow writing
        )
        ndefSession?.alertMessage = "Hold your iPhone near an NFC tag to write."
        ndefSession?.begin()
        isScanning = true
    }

    func startWritingURL(_ url: URL) {
        guard isNFCAvailable else {
            error = NFCError.notAvailable
            return
        }

        guard let urlPayload = NFCNDEFPayload.wellKnownTypeURIPayload(url: url) else {
            error = NFCError.invalidPayload
            return
        }

        writeMessage = NFCNDEFMessage(records: [urlPayload])

        ndefSession = NFCNDEFReaderSession(
            delegate: self,
            queue: nil,
            invalidateAfterFirstRead: false
        )
        ndefSession?.alertMessage = "Hold your iPhone near an NFC tag to write."
        ndefSession?.begin()
        isScanning = true
    }

    // Called when a writable tag is detected
    func readerSession(
        _ session: NFCNDEFReaderSession,
        didDetect tags: [any NFCNDEFTag]
    ) {
        guard let tag = tags.first else {
            session.invalidate(errorMessage: "No tag found.")
            return
        }

        session.connect(to: tag) { [weak self] connectionError in
            guard connectionError == nil, let self else {
                session.invalidate(errorMessage: "Connection failed.")
                return
            }

            tag.queryNDEFStatus { status, capacity, error in
                guard error == nil else {
                    session.invalidate(errorMessage: "Failed to query tag.")
                    return
                }

                switch status {
                case .notSupported:
                    session.invalidate(errorMessage: "Tag is not NDEF formatted.")
                case .readOnly:
                    session.invalidate(errorMessage: "Tag is read-only.")
                case .readWrite:
                    guard let message = self.writeMessage else {
                        session.invalidate(errorMessage: "No message to write.")
                        return
                    }

                    // Check capacity
                    let messageLength = message.length
                    guard messageLength <= capacity else {
                        session.invalidate(
                            errorMessage: "Message too large for tag (\(messageLength)/\(capacity) bytes)."
                        )
                        return
                    }

                    tag.writeNDEF(message) { writeError in
                        if let writeError {
                            session.invalidate(errorMessage: "Write failed: \(writeError.localizedDescription)")
                        } else {
                            session.alertMessage = "Successfully wrote to tag!"
                            session.invalidate()
                        }
                    }
                @unknown default:
                    session.invalidate(errorMessage: "Unknown tag status.")
                }
            }
        }
    }
}

NFCTagReaderSession — ISO 14443, ISO 15693, FeliCa

@Observable
class NFCTagReader: NSObject, NFCTagReaderSessionDelegate {
    var tagID: String = ""
    var tagType: String = ""

    private var tagSession: NFCTagReaderSession?

    func startTagReading(pollingOption: NFCTagReaderSession.PollingOption = .iso14443) {
        guard NFCTagReaderSession.readingAvailable else { return }

        tagSession = NFCTagReaderSession(
            pollingOption: pollingOption,
            delegate: self,
            queue: nil
        )
        tagSession?.alertMessage = "Hold your iPhone near the tag."
        tagSession?.begin()
    }

    func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) {}

    func tagReaderSession(
        _ session: NFCTagReaderSession,
        didInvalidateWithError error: Error
    ) {}

    func tagReaderSession(
        _ session: NFCTagReaderSession,
        didDetect tags: [NFCTag]
    ) {
        guard let tag = tags.first else { return }

        session.connect(to: tag) { error in
            guard error == nil else {
                session.invalidate(errorMessage: "Connection failed.")
                return
            }

            switch tag {
            case .iso7816(let iso7816Tag):
                // ISO 14443 Type A/B — used by payment cards, passports
                let identifier = iso7816Tag.identifier
                    .map { String(format: "%02x", $0) }
                    .joined(separator: ":")

                Task { @MainActor in
                    self.tagID = identifier
                    self.tagType = "ISO 7816 (ISO 14443)"
                }

                // Send APDU command
                let apdu = NFCISO7816APDU(
                    instructionClass: 0x00,
                    instructionCode: 0xB0,
                    p1Parameter: 0x00,
                    p2Parameter: 0x00,
                    data: Data(),
                    expectedResponseLength: 256
                )
                iso7816Tag.sendCommand(apdu: apdu) { responseData, sw1, sw2, error in
                    // Process response
                    session.alertMessage = "Tag read successfully."
                    session.invalidate()
                }

            case .iso15693(let iso15693Tag):
                // ISO 15693 — NFC-V, vicinity cards
                let identifier = iso15693Tag.identifier
                    .map { String(format: "%02x", $0) }
                    .joined(separator: ":")

                Task { @MainActor in
                    self.tagID = identifier
                    self.tagType = "ISO 15693"
                }
                session.alertMessage = "Tag read successfully."
                session.invalidate()

            case .feliCa(let feliCaTag):
                // FeliCa — used in Japan (Suica, etc.)
                let idm = feliCaTag.currentIDm
                    .map { String(format: "%02x", $0) }
                    .joined(separator: ":")

                Task { @MainActor in
                    self.tagID = idm
                    self.tagType = "FeliCa"
                }
                session.alertMessage = "Tag read successfully."
                session.invalidate()

            case .miFare(let miFareTag):
                // MiFare — NXP tags
                let identifier = miFareTag.identifier
                    .map { String(format: "%02x", $0) }
                    .joined(separator: ":")

                Task { @MainActor in
                    self.tagID = identifier
                    self.tagType = "MiFare (\(miFareTag.mifareFamily))"
                }
                session.alertMessage = "Tag read successfully."
                session.invalidate()

            @unknown default:
                session.invalidate(errorMessage: "Unsupported tag type.")
            }
        }
    }
}

Background Tag Reading

Add to 
Info.plist
 for Universal Links-style NFC launch:

<key>com.apple.developer.associated-domains</key>
<array>
    <string>applinks:example.com</string>
</array>

import SwiftUI

// Handle background tag reading in your App
@main
struct NFCApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onContinueUserActivity(
                    NSUserActivityTypeBrowsingWeb
                ) { userActivity in
                    // Handle NFC tag scanned in background
                    guard let url = userActivity.webpageURL else { return }
                    handleNFCURL(url)
                }
        }
    }

    func handleNFCURL(_ url: URL) {
        // Process the URL from the NFC tag
        // This is called when the user taps the NFC notification
    }
}

Error Handling

enum NFCError: LocalizedError {
    case notAvailable
    case invalidPayload
    case tagNotWritable
    case messageTooLarge(needed: Int, capacity: Int)
    case connectionFailed
    case readFailed(Error)
    case writeFailed(Error)

    var errorDescription: String? {
        switch self {
        case .notAvailable:
            return "NFC is not available on this device."
        case .invalidPayload:
            return "The NFC payload is invalid."
        case .tagNotWritable:
            return "This NFC tag is read-only."
        case .messageTooLarge(let needed, let capacity):
            return "Message (\(needed) bytes) exceeds tag capacity (\(capacity) bytes)."
        case .connectionFailed:
            return "Failed to connect to the NFC tag."
        case .readFailed(let error):
            return "Read failed: \(error.localizedDescription)"
        case .writeFailed(let error):
            return "Write failed: \(error.localizedDescription)"
        }
    }
}

Complete NFC Tag Reader/Writer Example

import SwiftUI
import CoreNFC

struct NFCReaderWriterView: View {
    @State private var nfcManager = NFCManager()
    @State private var writeText = ""
    @State private var writeURL = ""
    @State private var selectedMode: NFCMode = .read

    enum NFCMode: String, CaseIterable {
        case read = "Read"
        case writeText = "Write Text"
        case writeURL = "Write URL"
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 24) {
                // Mode picker
                Picker("Mode", selection: $selectedMode) {
                    ForEach(NFCMode.allCases, id: \.self) { mode in
                        Text(mode.rawValue).tag(mode)
                    }
                }
                .pickerStyle(.segmented)
                .padding(.horizontal)

                switch selectedMode {
                case .read:
                    readModeView
                case .writeText:
                    writeTextView
                case .writeURL:
                    writeURLView
                }

                Spacer()

                // NFC availability indicator
                if !nfcManager.isNFCAvailable {
                    Label("NFC not available on this device", systemImage: "xmark.circle")
                        .foregroundStyle(.red)
                        .font(.caption)
                }
            }
            .navigationTitle("NFC Tags")
        }
    }

    var readModeView: some View {
        VStack(spacing: 20) {
            Image(systemName: "wave.3.right")
                .font(.system(size: 60))
                .foregroundStyle(.blue)
                .symbolEffect(.variableColor.iterative, isActive: nfcManager.isScanning)

            Button {
                nfcManager.startReading()
            } label: {
                Label("Scan NFC Tag", systemImage: "sensor.tag.radiowaves.forward.fill")
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(.blue.gradient, in: RoundedRectangle(cornerRadius: 14))
                    .foregroundStyle(.white)
                    .font(.headline)
            }
            .disabled(!nfcManager.isNFCAvailable)
            .padding(.horizontal)

            // Results
            if !nfcManager.scannedRecords.isEmpty {
                List(nfcManager.scannedRecords) { record in
                    VStack(alignment: .leading, spacing: 4) {
                        Text(record.type)
                            .font(.caption)
                            .foregroundStyle(.secondary)
                            .padding(.horizontal, 8)
                            .padding(.vertical, 2)
                            .background(.blue.opacity(0.1), in: Capsule())

                        Text(record.payload)
                            .font(.body)
                            .textSelection(.enabled)
                    }
                    .padding(.vertical, 4)
                }
                .listStyle(.insetGrouped)
            }
        }
    }

    var writeTextView: some View {
        VStack(spacing: 20) {
            TextField("Enter text to write", text: $writeText, axis: .vertical)
                .textFieldStyle(.roundedBorder)
                .lineLimit(3...6)
                .padding(.horizontal)

            Button {
                nfcManager.startWriting(text: writeText)
            } label: {
                Label("Write to Tag", systemImage: "square.and.pencil")
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(.green.gradient, in: RoundedRectangle(cornerRadius: 14))
                    .foregroundStyle(.white)
                    .font(.headline)
            }
            .disabled(writeText.isEmpty || !nfcManager.isNFCAvailable)
            .padding(.horizontal)
        }
    }

    var writeURLView: some View {
        VStack(spacing: 20) {
            TextField("https://example.com", text: $writeURL)
                .textFieldStyle(.roundedBorder)
                .keyboardType(.URL)
                .autocapitalization(.none)
                .padding(.horizontal)

            Button {
                if let url = URL(string: writeURL) {
                    nfcManager.startWritingURL(url)
                }
            } label: {
                Label("Write URL to Tag", systemImage: "link")
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(.orange.gradient, in: RoundedRectangle(cornerRadius: 14))
                    .foregroundStyle(.white)
                    .font(.headline)
            }
            .disabled(URL(string: writeURL) == nil || !nfcManager.isNFCAvailable)
            .padding(.horizontal)
        }
    }
}

---

# HealthKit
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-hardware-healthkit.html

Health, Hardware, and Sensors · Reference guideHealthKitRepository guidance for HealthKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

HKHealthStore Setup and Authorization
HKSampleType, HKQuantityType, HKCategoryType
Reading Health Data — HKSampleQuery
HKStatisticsQuery for Aggregated Data
Writing Health Data
HKStatisticsCollectionQuery — Aggregated Data Over Time
HKObserverQuery — Background Delivery
HKWorkoutSession and HKLiveWorkoutBuilder
Workout Routes with CoreLocation
Complete Step Counter and Workout Tracker

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Explain requested health access
↓2
Request scoped authorization
↓3
Query or save permitted data
↓4
Handle unavailable data

02 / ArchitectureResponsibility boundariesBoundary 1
Health permissionsBoundary 2
Health storeBoundary 3
Health featureConnected responsibilities, not a required class hierarchy or an execution trace.
HKHealthStore Setup and Authorization

Add to 
Info.plist
:
- 
NSHealthShareUsageDescription
 — required for reading health data
- 
NSHealthUpdateUsageDescription
 — required for writing health data

Add the HealthKit capability in Xcode under Signing & Capabilities.

import HealthKit

@Observable
class HealthKitManager {
    static let shared = HealthKitManager()

    let healthStore = HKHealthStore()
    var isAuthorized = false
    var authorizationError: Error?

    // Check availability
    var isHealthDataAvailable: Bool {
        HKHealthStore.isHealthDataAvailable()
    }

    func requestAuthorization() async throws {
        guard isHealthDataAvailable else {
            throw HealthKitError.notAvailable
        }

        // Types to read
        let readTypes: Set<HKObjectType> = [
            HKQuantityType(.stepCount),
            HKQuantityType(.heartRate),
            HKQuantityType(.activeEnergyBurned),
            HKQuantityType(.distanceWalkingRunning),
            HKQuantityType(.bodyMass),
            HKCategoryType(.sleepAnalysis),
            HKObjectType.workoutType(),
            HKObjectType.activitySummaryType()
        ]

        // Types to write
        let writeTypes: Set<HKSampleType> = [
            HKQuantityType(.stepCount),
            HKQuantityType(.bodyMass),
            HKQuantityType(.activeEnergyBurned),
            HKObjectType.workoutType()
        ]

        try await healthStore.requestAuthorization(
            toShare: writeTypes,
            read: readTypes
        )
        isAuthorized = true
    }
}

enum HealthKitError: LocalizedError {
    case notAvailable
    case notAuthorized
    case noData
    case writeFailed(Error)

    var errorDescription: String? {
        switch self {
        case .notAvailable:
            return "HealthKit is not available on this device."
        case .notAuthorized:
            return "HealthKit authorization was not granted."
        case .noData:
            return "No health data found."
        case .writeFailed(let error):
            return "Failed to write data: \(error.localizedDescription)"
        }
    }
}

HKSampleType, HKQuantityType, HKCategoryType

// Quantity types — numeric values with units
let stepCount = HKQuantityType(.stepCount)
let heartRate = HKQuantityType(.heartRate)
let bodyMass = HKQuantityType(.bodyMass)
let height = HKQuantityType(.height)
let activeEnergy = HKQuantityType(.activeEnergyBurned)
let distance = HKQuantityType(.distanceWalkingRunning)

// Category types — enum-based values
let sleepAnalysis = HKCategoryType(.sleepAnalysis)
let mindfulSession = HKCategoryType(.mindfulSession)

// Workout type
let workoutType = HKObjectType.workoutType()

// Units
let bpm = HKUnit.count().unitDivided(by: .minute())
let kg = HKUnit.gramUnit(with: .kilo)
let miles = HKUnit.mile()
let kcal = HKUnit.kilocalorie()
let steps = HKUnit.count()

Reading Health Data — HKSampleQuery

extension HealthKitManager {
    /// Query recent heart rate samples
    func fetchHeartRateSamples(limit: Int = 10) async throws -> [HKQuantitySample] {
        let heartRateType = HKQuantityType(.heartRate)

        let sortDescriptor = NSSortDescriptor(
            key: HKSampleSortIdentifierStartDate,
            ascending: false
        )

        return try await withCheckedThrowingContinuation { continuation in
            let query = HKSampleQuery(
                sampleType: heartRateType,
                predicate: nil,
                limit: limit,
                sortDescriptors: [sortDescriptor]
            ) { _, samples, error in
                if let error {
                    continuation.resume(throwing: error)
                    return
                }
                let quantitySamples = samples as? [HKQuantitySample] ?? []
                continuation.resume(returning: quantitySamples)
            }
            healthStore.execute(query)
        }
    }

    /// Query samples within a date range
    func fetchSamples(
        type: HKQuantityType,
        start: Date,
        end: Date = .now,
        limit: Int = HKObjectQueryNoLimit
    ) async throws -> [HKQuantitySample] {
        let predicate = HKQuery.predicateForSamples(
            withStart: start,
            end: end,
            options: .strictStartDate
        )

        let sortDescriptor = NSSortDescriptor(
            key: HKSampleSortIdentifierStartDate,
            ascending: false
        )

        return try await withCheckedThrowingContinuation { continuation in
            let query = HKSampleQuery(
                sampleType: type,
                predicate: predicate,
                limit: limit,
                sortDescriptors: [sortDescriptor]
            ) { _, samples, error in
                if let error {
                    continuation.resume(throwing: error)
                    return
                }
                let quantitySamples = samples as? [HKQuantitySample] ?? []
                continuation.resume(returning: quantitySamples)
            }
            healthStore.execute(query)
        }
    }
}

HKStatisticsQuery for Aggregated Data

extension HealthKitManager {
    /// Get today's total step count
    func fetchTodayStepCount() async throws -> Double {
        let stepType = HKQuantityType(.stepCount)

        let startOfDay = Calendar.current.startOfDay(for: .now)
        let predicate = HKQuery.predicateForSamples(
            withStart: startOfDay,
            end: .now,
            options: .strictStartDate
        )

        return try await withCheckedThrowingContinuation { continuation in
            let query = HKStatisticsQuery(
                quantityType: stepType,
                quantitySamplePredicate: predicate,
                options: .cumulativeSum
            ) { _, statistics, error in
                if let error {
                    continuation.resume(throwing: error)
                    return
                }
                let count = statistics?.sumQuantity()?.doubleValue(for: .count()) ?? 0
                continuation.resume(returning: count)
            }
            healthStore.execute(query)
        }
    }

    /// Get average resting heart rate for a period
    func fetchAverageHeartRate(start: Date, end: Date = .now) async throws -> Double {
        let heartRateType = HKQuantityType(.heartRate)
        let predicate = HKQuery.predicateForSamples(
            withStart: start,
            end: end,
            options: .strictStartDate
        )
        let bpmUnit = HKUnit.count().unitDivided(by: .minute())

        return try await withCheckedThrowingContinuation { continuation in
            let query = HKStatisticsQuery(
                quantityType: heartRateType,
                quantitySamplePredicate: predicate,
                options: .discreteAverage
            ) { _, statistics, error in
                if let error {
                    continuation.resume(throwing: error)
                    return
                }
                let avg = statistics?.averageQuantity()?.doubleValue(for: bpmUnit) ?? 0
                continuation.resume(returning: avg)
            }
            healthStore.execute(query)
        }
    }
}

Writing Health Data

extension HealthKitManager {
    /// Save a body mass measurement
    func saveBodyMass(kg value: Double, date: Date = .now) async throws {
        let bodyMassType = HKQuantityType(.bodyMass)
        let quantity = HKQuantity(unit: .gramUnit(with: .kilo), doubleValue: value)

        let sample = HKQuantitySample(
            type: bodyMassType,
            quantity: quantity,
            start: date,
            end: date
        )

        try await healthStore.save(sample)
    }

    /// Save a step count entry
    func saveSteps(count: Double, start: Date, end: Date) async throws {
        let stepType = HKQuantityType(.stepCount)
        let quantity = HKQuantity(unit: .count(), doubleValue: count)

        let sample = HKQuantitySample(
            type: stepType,
            quantity: quantity,
            start: start,
            end: end
        )

        try await healthStore.save(sample)
    }

    /// Save a sleep analysis entry
    func saveSleep(start: Date, end: Date, value: HKCategoryValueSleepAnalysis) async throws {
        let sleepType = HKCategoryType(.sleepAnalysis)

        let sample = HKCategorySample(
            type: sleepType,
            value: value.rawValue,
            start: start,
            end: end
        )

        try await healthStore.save(sample)
    }
}

HKStatisticsCollectionQuery — Aggregated Data Over Time

extension HealthKitManager {
    /// Get daily step counts for the last 7 days
    func fetchDailySteps(days: Int = 7) async throws -> [(date: Date, steps: Double)] {
        let stepType = HKQuantityType(.stepCount)
        let calendar = Calendar.current

        let endDate = Date.now
        guard let startDate = calendar.date(byAdding: .day, value: -days, to: endDate) else {
            throw HealthKitError.noData
        }

        let anchorDate = calendar.startOfDay(for: endDate)
        let daily = DateComponents(day: 1)

        let predicate = HKQuery.predicateForSamples(
            withStart: startDate,
            end: endDate,
            options: .strictStartDate
        )

        return try await withCheckedThrowingContinuation { continuation in
            let query = HKStatisticsCollectionQuery(
                quantityType: stepType,
                quantitySamplePredicate: predicate,
                options: .cumulativeSum,
                anchorDate: anchorDate,
                intervalComponents: daily
            )

            query.initialResultsHandler = { _, collection, error in
                if let error {
                    continuation.resume(throwing: error)
                    return
                }

                var results: [(date: Date, steps: Double)] = []

                collection?.enumerateStatistics(from: startDate, to: endDate) { statistics, _ in
                    let count = statistics.sumQuantity()?.doubleValue(for: .count()) ?? 0
                    results.append((date: statistics.startDate, steps: count))
                }

                continuation.resume(returning: results)
            }

            healthStore.execute(query)
        }
    }
}

HKObserverQuery — Background Delivery

extension HealthKitManager {
    /// Enable background delivery for step count updates
    func enableBackgroundStepDelivery() async throws {
        let stepType = HKQuantityType(.stepCount)

        try await healthStore.enableBackgroundDelivery(
            for: stepType,
            frequency: .hourly
        )
    }

    /// Observe step count changes in real time
    func observeStepChanges(handler: @escaping (Double) -> Void) {
        let stepType = HKQuantityType(.stepCount)

        let observerQuery = HKObserverQuery(
            sampleType: stepType,
            predicate: nil
        ) { [weak self] _, completionHandler, error in
            guard error == nil, let self else {
                completionHandler()
                return
            }

            Task {
                let steps = try? await self.fetchTodayStepCount()
                await MainActor.run {
                    handler(steps ?? 0)
                }
                completionHandler()
            }
        }

        healthStore.execute(observerQuery)
    }
}

HKWorkoutSession and HKLiveWorkoutBuilder

import HealthKit

@Observable
class WorkoutManager: NSObject {
    let healthStore = HKHealthStore()
    var workoutSession: HKWorkoutSession?
    var workoutBuilder: HKLiveWorkoutBuilder?

    var isWorkoutActive = false
    var heartRate: Double = 0
    var activeCalories: Double = 0
    var distance: Double = 0
    var elapsedTime: TimeInterval = 0

    func startWorkout(type: HKWorkoutActivityType) async throws {
        let configuration = HKWorkoutConfiguration()
        configuration.activityType = type
        configuration.locationType = .outdoor

        workoutSession = try HKWorkoutSession(
            healthStore: healthStore,
            configuration: configuration
        )

        workoutBuilder = workoutSession?.associatedWorkoutBuilder()
        workoutBuilder?.dataSource = HKLiveWorkoutDataSource(
            healthStore: healthStore,
            workoutConfiguration: configuration
        )

        workoutSession?.delegate = self
        workoutBuilder?.delegate = self

        let startDate = Date.now
        workoutSession?.startActivity(with: startDate)
        try await workoutBuilder?.beginCollection(at: startDate)

        isWorkoutActive = true
    }

    func pauseWorkout() {
        workoutSession?.pause()
    }

    func resumeWorkout() {
        workoutSession?.resume()
    }

    func endWorkout() async throws {
        workoutSession?.end()
        guard let builder = workoutBuilder else { return }
        try await builder.endCollection(at: .now)
        try await builder.finishWorkout()

        isWorkoutActive = false
        workoutSession = nil
        workoutBuilder = nil
    }
}

extension WorkoutManager: HKWorkoutSessionDelegate {
    func workoutSession(
        _ workoutSession: HKWorkoutSession,
        didChangeTo toState: HKWorkoutSessionState,
        from fromState: HKWorkoutSessionState,
        date: Date
    ) {
        switch toState {
        case .running:
            isWorkoutActive = true
        case .paused:
            isWorkoutActive = false
        case .ended:
            isWorkoutActive = false
        default:
            break
        }
    }

    func workoutSession(
        _ workoutSession: HKWorkoutSession,
        didFailWithError error: Error
    ) {
        isWorkoutActive = false
    }
}

extension WorkoutManager: HKLiveWorkoutBuilderDelegate {
    func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}

    func workoutBuilder(
        _ workoutBuilder: HKLiveWorkoutBuilder,
        didCollectDataOf collectedTypes: Set<HKSampleType>
    ) {
        for type in collectedTypes {
            guard let quantityType = type as? HKQuantityType else { continue }
            let statistics = workoutBuilder.statistics(for: quantityType)

            switch quantityType {
            case HKQuantityType(.heartRate):
                let bpmUnit = HKUnit.count().unitDivided(by: .minute())
                heartRate = statistics?.mostRecentQuantity()?.doubleValue(for: bpmUnit) ?? 0

            case HKQuantityType(.activeEnergyBurned):
                activeCalories = statistics?.sumQuantity()?.doubleValue(for: .kilocalorie()) ?? 0

            case HKQuantityType(.distanceWalkingRunning):
                distance = statistics?.sumQuantity()?.doubleValue(for: .meter()) ?? 0

            default:
                break
            }
        }

        elapsedTime = workoutBuilder.elapsedTime
    }
}

Workout Routes with CoreLocation

import CoreLocation

extension WorkoutManager {
    /// Add a route to a completed workout
    func addRoute(locations: [CLLocation], to workout: HKWorkout) async throws {
        let routeBuilder = HKWorkoutRouteBuilder(
            healthStore: healthStore,
            device: nil
        )

        try await routeBuilder.insertRouteData(locations)
        try await routeBuilder.finishRoute(with: workout, metadata: nil)
    }
}

Complete Step Counter and Workout Tracker

import SwiftUI
import HealthKit

@Observable
class StepCounterViewModel {
    private let manager = HealthKitManager.shared

    var todaySteps: Double = 0
    var weeklySteps: [(date: Date, steps: Double)] = []
    var goalProgress: Double = 0
    var isLoading = false
    var error: Error?

    let dailyGoal: Double = 10_000

    func loadData() async {
        isLoading = true
        defer { isLoading = false }

        do {
            try await manager.requestAuthorization()
            todaySteps = try await manager.fetchTodayStepCount()
            goalProgress = min(todaySteps / dailyGoal, 1.0)
            weeklySteps = try await manager.fetchDailySteps(days: 7)
        } catch {
            self.error = error
        }
    }
}

struct StepCounterView: View {
    @State private var viewModel = StepCounterViewModel()

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 24) {
                    // Circular progress
                    ZStack {
                        Circle()
                            .stroke(.gray.opacity(0.2), lineWidth: 16)

                        Circle()
                            .trim(from: 0, to: viewModel.goalProgress)
                            .stroke(
                                .green.gradient,
                                style: StrokeStyle(lineWidth: 16, lineCap: .round)
                            )
                            .rotationEffect(.degrees(-90))
                            .animation(.spring(duration: 1.0), value: viewModel.goalProgress)

                        VStack(spacing: 4) {
                            Image(systemName: "figure.walk")
                                .font(.title)
                                .foregroundStyle(.green)

                            Text("\(Int(viewModel.todaySteps))")
                                .font(.system(size: 44, weight: .bold, design: .rounded))
                                .contentTransition(.numericText())

                            Text("of \(Int(viewModel.dailyGoal)) steps")
                                .font(.subheadline)
                                .foregroundStyle(.secondary)
                        }
                    }
                    .frame(width: 220, height: 220)
                    .padding(.top)

                    // Weekly chart
                    VStack(alignment: .leading, spacing: 12) {
                        Text("This Week")
                            .font(.headline)

                        HStack(alignment: .bottom, spacing: 8) {
                            ForEach(viewModel.weeklySteps, id: \.date) { entry in
                                VStack(spacing: 4) {
                                    RoundedRectangle(cornerRadius: 6)
                                        .fill(
                                            entry.steps >= viewModel.dailyGoal
                                                ? .green.gradient
                                                : .blue.gradient
                                        )
                                        .frame(
                                            height: max(
                                                4,
                                                CGFloat(entry.steps / viewModel.dailyGoal) * 120
                                            )
                                        )

                                    Text(entry.date.formatted(.dateTime.weekday(.narrow)))
                                        .font(.caption2)
                                        .foregroundStyle(.secondary)
                                }
                                .frame(maxWidth: .infinity)
                            }
                        }
                        .frame(height: 140)
                    }
                    .padding()
                    .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
                    .padding(.horizontal)
                }
            }
            .navigationTitle("Steps")
            .task {
                await viewModel.loadData()
            }
            .refreshable {
                await viewModel.loadData()
            }
        }
    }
}

struct WorkoutTrackerView: View {
    @State private var workoutManager = WorkoutManager()

    var body: some View {
        NavigationStack {
            VStack(spacing: 24) {
                if workoutManager.isWorkoutActive {
                    // Active workout display
                    VStack(spacing: 20) {
                        // Timer
                        Text(
                            Duration.seconds(workoutManager.elapsedTime)
                                .formatted(.time(pattern: .hourMinuteSecond))
                        )
                        .font(.system(size: 48, weight: .bold, design: .monospaced))

                        // Metrics grid
                        LazyVGrid(columns: [
                            GridItem(.flexible()),
                            GridItem(.flexible())
                        ], spacing: 16) {
                            MetricCard(
                                title: "Heart Rate",
                                value: "\(Int(workoutManager.heartRate))",
                                unit: "BPM",
                                icon: "heart.fill",
                                color: .red
                            )

                            MetricCard(
                                title: "Calories",
                                value: "\(Int(workoutManager.activeCalories))",
                                unit: "kcal",
                                icon: "flame.fill",
                                color: .orange
                            )

                            MetricCard(
                                title: "Distance",
                                value: String(format: "%.2f", workoutManager.distance / 1000),
                                unit: "km",
                                icon: "figure.run",
                                color: .green
                            )
                        }
                        .padding(.horizontal)

                        // Controls
                        HStack(spacing: 24) {
                            Button {
                                workoutManager.pauseWorkout()
                            } label: {
                                Image(systemName: "pause.fill")
                                    .font(.title2)
                                    .frame(width: 60, height: 60)
                                    .background(.yellow.gradient, in: Circle())
                                    .foregroundStyle(.black)
                            }

                            Button {
                                Task { try? await workoutManager.endWorkout() }
                            } label: {
                                Image(systemName: "stop.fill")
                                    .font(.title2)
                                    .frame(width: 60, height: 60)
                                    .background(.red.gradient, in: Circle())
                                    .foregroundStyle(.white)
                            }
                        }
                    }
                } else {
                    // Workout type selection
                    VStack(spacing: 16) {
                        Text("Start a Workout")
                            .font(.title2.bold())

                        ForEach(workoutTypes, id: \.type) { workout in
                            Button {
                                Task {
                                    try? await workoutManager.startWorkout(type: workout.type)
                                }
                            } label: {
                                HStack {
                                    Image(systemName: workout.icon)
                                        .font(.title2)
                                        .frame(width: 44)
                                    Text(workout.name)
                                        .font(.headline)
                                    Spacer()
                                    Image(systemName: "chevron.right")
                                        .foregroundStyle(.tertiary)
                                }
                                .padding()
                                .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
                            }
                            .buttonStyle(.plain)
                        }
                    }
                    .padding()
                }
            }
            .navigationTitle("Workout")
        }
    }

    var workoutTypes: [(name: String, type: HKWorkoutActivityType, icon: String)] {
        [
            ("Outdoor Run", .running, "figure.run"),
            ("Outdoor Walk", .walking, "figure.walk"),
            ("Cycling", .cycling, "figure.outdoor.cycle"),
            ("Swimming", .swimming, "figure.pool.swim"),
            ("Strength Training", .traditionalStrengthTraining, "dumbbell.fill")
        ]
    }
}

struct MetricCard: View {
    let title: String
    let value: String
    let unit: String
    let icon: String
    let color: Color

    var body: some View {
        VStack(spacing: 8) {
            Image(systemName: icon)
                .foregroundStyle(color)
                .font(.title3)

            Text(value)
                .font(.system(.title, design: .rounded, weight: .bold))
                .contentTransition(.numericText())

            Text(unit)
                .font(.caption)
                .foregroundStyle(.secondary)
        }
        .frame(maxWidth: .infinity)
        .padding()
        .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
    }
}

---

# HomeKit and Matter
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-hardware-homekit.html

System Integration · Reference guideHomeKit and MatterRepository guidance for HomeKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

HMHomeManager Setup
Homes, Rooms, and Zones
Accessories and Services
Characteristics — Reading, Writing, Subscribing
Automations and Triggers
Action Sets and Scenes
Matter Support
Complete Smart Home Controller Example

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Request home access
↓2
Discover accessories
↓3
Read or write characteristics
↓4
Handle unreachable devices

02 / ArchitectureResponsibility boundariesBoundary 1
Home managerBoundary 2
Accessories and servicesBoundary 3
Home controlsConnected responsibilities, not a required class hierarchy or an execution trace.
HMHomeManager Setup

Add the HomeKit capability in Xcode under Signing & Capabilities. Add 
NSHomeKitUsageDescription
 to 
Info.plist
.

import HomeKit

@Observable
class HomeManager: NSObject, HMHomeManagerDelegate {
    static let shared = HomeManager()

    let homeManager = HMHomeManager()

    var homes: [HMHome] = []
    var primaryHome: HMHome?
    var isReady = false
    var error: Error?

    override init() {
        super.init()
        homeManager.delegate = self
    }

    func homeManagerDidUpdateHomes(_ manager: HMHomeManager) {
        homes = manager.homes
        primaryHome = manager.primaryHome
        isReady = true
    }

    func homeManagerDidUpdatePrimaryHome(_ manager: HMHomeManager) {
        primaryHome = manager.primaryHome
    }
}

Homes, Rooms, and Zones

extension HomeManager {
    // MARK: - Homes

    func addHome(named name: String) async throws -> HMHome {
        try await withCheckedThrowingContinuation { continuation in
            homeManager.addHome(withName: name) { home, error in
                if let error {
                    continuation.resume(throwing: error)
                } else if let home {
                    continuation.resume(returning: home)
                }
            }
        }
    }

    func removeHome(_ home: HMHome) async throws {
        try await withCheckedThrowingContinuation { continuation in
            homeManager.removeHome(home) { error in
                if let error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume()
                }
            }
        }
    }

    // MARK: - Rooms

    func addRoom(named name: String, to home: HMHome) async throws -> HMRoom {
        try await withCheckedThrowingContinuation { continuation in
            home.addRoom(withName: name) { room, error in
                if let error {
                    continuation.resume(throwing: error)
                } else if let room {
                    continuation.resume(returning: room)
                }
            }
        }
    }

    // MARK: - Zones

    func addZone(named name: String, to home: HMHome) async throws -> HMZone {
        try await withCheckedThrowingContinuation { continuation in
            home.addZone(withName: name) { zone, error in
                if let error {
                    continuation.resume(throwing: error)
                } else if let zone {
                    continuation.resume(returning: zone)
                }
            }
        }
    }

    func addRoom(_ room: HMRoom, to zone: HMZone) async throws {
        try await withCheckedThrowingContinuation { continuation in
            zone.addRoom(room) { error in
                if let error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume()
                }
            }
        }
    }
}

Accessories and Services

@Observable
class AccessoryManager: NSObject, HMHomeDelegate {
    var accessories: [HMAccessory] = []
    var home: HMHome

    init(home: HMHome) {
        self.home = home
        super.init()
        home.delegate = self
        accessories = home.accessories
    }

    // Add accessory via setup code
    func addAccessory() async throws {
        // This presents the system UI for adding accessories
        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
            home.addAndSetupAccessories { error in
                if let error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume()
                }
            }
        }
        accessories = home.accessories
    }

    // Remove an accessory
    func removeAccessory(_ accessory: HMAccessory) async throws {
        try await withCheckedThrowingContinuation { continuation in
            home.removeAccessory(accessory) { error in
                if let error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume()
                }
            }
        }
        accessories = home.accessories
    }

    // Assign accessory to a room
    func assignAccessory(_ accessory: HMAccessory, to room: HMRoom) async throws {
        try await withCheckedThrowingContinuation { continuation in
            home.assignAccessory(accessory, to: room) { error in
                if let error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume()
                }
            }
        }
    }

    // Get services for an accessory
    func services(for accessory: HMAccessory) -> [HMService] {
        accessory.services.filter { service in
            // Filter out information services
            service.serviceType != HMServiceTypeAccessoryInformation
        }
    }

    // HMHomeDelegate — accessory updates
    func home(_ home: HMHome, didAdd accessory: HMAccessory) {
        accessories = home.accessories
    }

    func home(_ home: HMHome, didRemove accessory: HMAccessory) {
        accessories = home.accessories
    }

    func home(
        _ home: HMHome,
        didUpdate room: HMRoom,
        for accessory: HMAccessory
    ) {
        // Accessory moved to a different room
    }
}

Characteristics — Reading, Writing, Subscribing

extension AccessoryManager {
    // Read a characteristic value
    func readCharacteristic(_ characteristic: HMCharacteristic) async throws -> Any? {
        try await withCheckedThrowingContinuation { continuation in
            characteristic.readValue { error in
                if let error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume(returning: characteristic.value)
                }
            }
        }
    }

    // Write a characteristic value
    func writeCharacteristic(_ characteristic: HMCharacteristic, value: Any) async throws {
        try await withCheckedThrowingContinuation { continuation in
            characteristic.writeValue(value) { error in
                if let error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume()
                }
            }
        }
    }

    // Subscribe to characteristic changes
    func subscribeToCharacteristic(_ characteristic: HMCharacteristic) async throws {
        try await withCheckedThrowingContinuation { continuation in
            characteristic.enableNotification(true) { error in
                if let error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume()
                }
            }
        }
    }

    // Common operations
    func toggleLight(_ service: HMService) async throws {
        guard let powerState = service.characteristics.first(where: {
            $0.characteristicType == HMCharacteristicTypePowerState
        }) else { return }

        let currentValue = powerState.value as? Bool ?? false
        try await writeCharacteristic(powerState, value: !currentValue)
    }

    func setBrightness(_ service: HMService, to value: Int) async throws {
        guard let brightness = service.characteristics.first(where: {
            $0.characteristicType == HMCharacteristicTypeBrightness
        }) else { return }

        let clamped = max(0, min(100, value))
        try await writeCharacteristic(brightness, value: clamped)
    }

    func setThermostat(_ service: HMService, targetTemp: Double) async throws {
        guard let targetTemperature = service.characteristics.first(where: {
            $0.characteristicType == HMCharacteristicTypeTargetTemperature
        }) else { return }

        try await writeCharacteristic(targetTemperature, value: targetTemp)
    }

    func getLockState(_ service: HMService) async throws -> Bool {
        guard let lockState = service.characteristics.first(where: {
            $0.characteristicType == HMCharacteristicTypeCurrentLockMechanismState
        }) else { return false }

        let value = try await readCharacteristic(lockState)
        // 0 = unsecured, 1 = secured
        return (value as? Int) == 1
    }
}

Automations and Triggers

extension HomeManager {
    // MARK: - Timer Trigger (time-based automation)

    func createTimerTrigger(
        name: String,
        fireDate: DateComponents,
        recurrence: DateComponents? = nil,
        actionSet: HMActionSet,
        in home: HMHome
    ) async throws {
        let trigger = HMTimerTrigger(
            name: name,
            fireDate: fireDate,
            timeZone: .current,
            recurrence: recurrence,
            recurrenceCalendar: .current
        )

        try await withCheckedThrowingContinuation { continuation in
            home.addTrigger(trigger) { error in
                if let error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume()
                }
            }
        }

        // Add action set to trigger
        try await withCheckedThrowingContinuation { continuation in
            trigger.addActionSet(actionSet) { error in
                if let error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume()
                }
            }
        }

        // Enable the trigger
        try await withCheckedThrowingContinuation { continuation in
            trigger.enable(true) { error in
                if let error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume()
                }
            }
        }
    }

    // MARK: - Event Trigger (condition-based automation)

    func createSunsetTrigger(
        name: String,
        offset: TimeInterval = 0,
        actionSet: HMActionSet,
        in home: HMHome
    ) async throws {
        let sunsetEvent = HMSignificantTimeEvent(
            significantEvent: .sunset,
            offset: DateComponents(second: Int(offset))
        )

        let trigger = HMEventTrigger(
            name: name,
            events: [sunsetEvent],
            predicate: nil
        )

        try await withCheckedThrowingContinuation { continuation in
            home.addTrigger(trigger) { error in
                if let error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume()
                }
            }
        }

        try await withCheckedThrowingContinuation { continuation in
            trigger.addActionSet(actionSet) { error in
                if let error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume()
                }
            }
        }
    }

    // Create a characteristic event trigger (when a value changes)
    func createCharacteristicTrigger(
        name: String,
        characteristic: HMCharacteristic,
        targetValue: Any,
        actionSet: HMActionSet,
        in home: HMHome
    ) async throws {
        let event = HMCharacteristicEvent(
            characteristic: characteristic,
            triggerValue: targetValue as? NSCopying
        )

        let trigger = HMEventTrigger(
            name: name,
            events: [event],
            predicate: nil
        )

        try await withCheckedThrowingContinuation { continuation in
            home.addTrigger(trigger) { error in
                if let error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume()
                }
            }
        }

        try await withCheckedThrowingContinuation { continuation in
            trigger.addActionSet(actionSet) { error in
                if let error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume()
                }
            }
        }
    }
}

Action Sets and Scenes

extension HomeManager {
    // Create an action set (scene)
    func createScene(
        named name: String,
        actions: [(characteristic: HMCharacteristic, value: Any)],
        in home: HMHome
    ) async throws -> HMActionSet {
        let actionSet = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<HMActionSet, Error>) in
            home.addActionSet(withName: name) { actionSet, error in
                if let error {
                    continuation.resume(throwing: error)
                } else if let actionSet {
                    continuation.resume(returning: actionSet)
                }
            }
        }

        // Add actions to the set
        for action in actions {
            let writeAction = HMCharacteristicWriteAction(
                characteristic: action.characteristic,
                targetValue: action.value as! NSCopying
            )

            try await withCheckedThrowingContinuation { continuation in
                actionSet.addAction(writeAction) { error in
                    if let error {
                        continuation.resume(throwing: error)
                    } else {
                        continuation.resume()
                    }
                }
            }
        }

        return actionSet
    }

    // Execute a scene
    func executeScene(_ actionSet: HMActionSet, in home: HMHome) async throws {
        try await withCheckedThrowingContinuation { continuation in
            home.executeActionSet(actionSet) { error in
                if let error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume()
                }
            }
        }
    }

    // Get built-in action sets
    func builtInScenes(for home: HMHome) -> (
        homeArrive: HMActionSet?,
        homeLeave: HMActionSet?,
        sleep: HMActionSet?,
        wake: HMActionSet?
    ) {
        let sets = home.actionSets
        return (
            homeArrive: sets.first { $0.actionSetType == HMActionSetTypeHomeArrival },
            homeLeave: sets.first { $0.actionSetType == HMActionSetTypHomeDeparture },
            sleep: sets.first { $0.actionSetType == HMActionSetTypeSleep },
            wake: sets.first { $0.actionSetType == HMActionSetTypeWakeUp }
        )
    }
}

Matter Support

Requires iOS 16.1+. Add the Matter capability in Xcode.

import MatterSupport

@Observable
class MatterManager {
    /// Request to add a Matter device to the home
    func addMatterDevice() async throws {
        let topology = MatterAddDeviceRequest.Topology(
            ecosystemName: "My Home App",
            homes: [
                MatterAddDeviceRequest.Home(displayName: "My Home")
            ]
        )

        let request = MatterAddDeviceRequest(topology: topology)

        // This presents the system Matter pairing UI
        try await request.perform()
    }
}

// Handle Matter setup in your App struct
extension MatterManager {
    /// Commission a device with a setup code
    func addDeviceWithSetupCode() async throws {
        let topology = MatterAddDeviceRequest.Topology(
            ecosystemName: "My Home App",
            homes: [
                MatterAddDeviceRequest.Home(displayName: "My Home")
            ]
        )

        let request = MatterAddDeviceRequest(
            topology: topology,
            setupPayload: nil  // System will prompt user to scan QR or enter code
        )

        try await request.perform()
    }
}

Complete Smart Home Controller Example

import SwiftUI
import HomeKit

struct SmartHomeView: View {
    @State private var homeManager = HomeManager.shared
    @State private var selectedHome: HMHome?
    @State private var showAddAccessory = false

    var body: some View {
        NavigationStack {
            Group {
                if homeManager.isReady, let home = selectedHome ?? homeManager.primaryHome {
                    HomeDetailView(home: home)
                } else if !homeManager.isReady {
                    ProgressView("Loading homes...")
                } else {
                    ContentUnavailableView(
                        "No Home",
                        systemImage: "house",
                        description: Text("Add a home to get started.")
                    )
                }
            }
            .navigationTitle("My Home")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Menu {
                        ForEach(homeManager.homes, id: \.uniqueIdentifier) { home in
                            Button(home.name) {
                                selectedHome = home
                            }
                        }
                        Divider()
                        Button("Add Home", systemImage: "plus") {
                            Task {
                                _ = try? await homeManager.addHome(named: "New Home")
                            }
                        }
                    } label: {
                        Image(systemName: "house.fill")
                    }
                }
            }
        }
    }
}

struct HomeDetailView: View {
    let home: HMHome
    @State private var accessoryManager: AccessoryManager?

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 16) {
                // Rooms section
                ForEach(home.rooms, id: \.uniqueIdentifier) { room in
                    RoomCard(room: room, accessoryManager: accessoryManager)
                }

                // Scenes section
                if !home.actionSets.isEmpty {
                    VStack(alignment: .leading, spacing: 12) {
                        Text("Scenes")
                            .font(.headline)
                            .padding(.horizontal)

                        ScrollView(.horizontal, showsIndicators: false) {
                            HStack(spacing: 12) {
                                ForEach(home.actionSets, id: \.uniqueIdentifier) { scene in
                                    SceneButton(
                                        scene: scene,
                                        home: home
                                    )
                                }
                            }
                            .padding(.horizontal)
                        }
                    }
                }
            }
            .padding(.vertical)
        }
        .onAppear {
            accessoryManager = AccessoryManager(home: home)
        }
    }
}

struct RoomCard: View {
    let room: HMRoom
    let accessoryManager: AccessoryManager?

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text(room.name)
                .font(.headline)

            let roomAccessories = room.accessories
            if roomAccessories.isEmpty {
                Text("No accessories")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            } else {
                LazyVGrid(columns: [
                    GridItem(.flexible()),
                    GridItem(.flexible())
                ], spacing: 10) {
                    ForEach(roomAccessories, id: \.uniqueIdentifier) { accessory in
                        AccessoryTile(
                            accessory: accessory,
                            accessoryManager: accessoryManager
                        )
                    }
                }
            }
        }
        .padding()
        .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
        .padding(.horizontal)
    }
}

struct AccessoryTile: View {
    let accessory: HMAccessory
    let accessoryManager: AccessoryManager?
    @State private var isOn = false

    var body: some View {
        Button {
            toggleAccessory()
        } label: {
            VStack(spacing: 8) {
                Image(systemName: iconForAccessory)
                    .font(.title2)
                    .foregroundStyle(isOn ? .yellow : .secondary)

                Text(accessory.name)
                    .font(.caption)
                    .lineLimit(1)
                    .foregroundStyle(.primary)

                Text(isOn ? "On" : "Off")
                    .font(.caption2)
                    .foregroundStyle(.secondary)
            }
            .frame(maxWidth: .infinity)
            .padding()
            .background(
                isOn ? Color.yellow.opacity(0.15) : Color.gray.opacity(0.1),
                in: RoundedRectangle(cornerRadius: 12)
            )
        }
        .buttonStyle(.plain)
        .task { await loadState() }
    }

    var iconForAccessory: String {
        for service in accessory.services {
            switch service.serviceType {
            case HMServiceTypeLightbulb: return "lightbulb.fill"
            case HMServiceTypeFan: return "fan.fill"
            case HMServiceTypeThermostat: return "thermometer"
            case HMServiceTypeLockMechanism: return "lock.fill"
            case HMServiceTypeGarageDoorOpener: return "door.garage.closed"
            case HMServiceTypeSwitch: return "switch.2"
            default: continue
            }
        }
        return "house.fill"
    }

    func loadState() async {
        for service in accessory.services {
            if let powerChar = service.characteristics.first(where: {
                $0.characteristicType == HMCharacteristicTypePowerState
            }) {
                let value = try? await accessoryManager?.readCharacteristic(powerChar)
                isOn = value as? Bool ?? false
                return
            }
        }
    }

    func toggleAccessory() {
        for service in accessory.services {
            if let powerChar = service.characteristics.first(where: {
                $0.characteristicType == HMCharacteristicTypePowerState
            }) {
                Task {
                    try? await accessoryManager?.writeCharacteristic(powerChar, value: !isOn)
                    isOn.toggle()
                }
                return
            }
        }
    }
}

struct SceneButton: View {
    let scene: HMActionSet
    let home: HMHome

    var body: some View {
        Button {
            Task {
                try? await HomeManager.shared.executeScene(scene, in: home)
            }
        } label: {
            VStack(spacing: 8) {
                Image(systemName: iconForScene)
                    .font(.title2)
                    .foregroundStyle(.blue)

                Text(scene.name)
                    .font(.caption)
                    .foregroundStyle(.primary)
            }
            .frame(width: 80, height: 80)
            .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
        }
        .buttonStyle(.plain)
    }

    var iconForScene: String {
        switch scene.actionSetType {
        case HMActionSetTypeHomeArrival: return "house.fill"
        case HMActionSetTypHomeDeparture: return "figure.walk"
        case HMActionSetTypeSleep: return "moon.fill"
        case HMActionSetTypeWakeUp: return "sun.max.fill"
        default: return "sparkles"
        }
    }
}

---

# LocalAuthentication
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-local-authentication.html

Authentication, Security, and Privacy · Reference guideLocalAuthenticationRepository guidance for LocalAuthentication. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

1. The one thing to understand before writing any of this
2. The seam
3. The implementation
4. The view model
5. The real boundary: Keychain access control
6. Info.plist
Anti-Patterns
Testing
Checklist

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Check available policy
↓2
Explain authentication reason
↓3
Evaluate local authentication
↓4
Handle cancellation or fallback

02 / ArchitectureResponsibility boundariesBoundary 1
Protected actionBoundary 2
Authentication contextBoundary 3
Authorization resultConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
 gating a screen or an action behind Face ID, Touch ID, or
Optic ID; unlocking a value from the Keychain with biometrics; or deciding what
to do when biometry is unavailable, locked out, or refused.

Covers 
LAContext
, the policies, the error cases that actually occur, and the
line between LocalAuthentication and Keychain access control.

Availability:
 framework iOS 8+; 
.biometryAny
 / 
.biometryCurrentSet

Keychain flags iOS 11.3+; Optic ID visionOS 1+; 
LAContext
 is 
not

Sendable
.

1. The one thing to understand before writing any of this

evaluatePolicy returning true is not a security boundary. It is a UI
event.

It tells you the system displayed a prompt and the user satisfied it. It does
not protect anything. An attacker with your binary can patch the branch, and a
jailbroken device can lie about the result. If the only thing standing between
someone and the data is 
if success { showSecrets() }
, the data is not
protected — it is merely inconvenient to reach.

The real boundary is the 
Keychain
, with a 
SecAccessControl
 that names
biometry. There, the Secure Enclave refuses to release the bytes until biometry
succeeds; there is no branch to patch, because the decryption key is never
handed over. See §5.

Use 
evaluatePolicy
 when the goal is 
presence
 — "confirm it's you before
transferring money", "re-authenticate after backgrounding". Use Keychain access
control when the goal is 
secrecy
.

A second fact worth knowing up front:
 
LAContext
 caches a successful
evaluation for the lifetime of the context object. Reusing one context across
screens means the second screen silently unlocks with no prompt. That is
occasionally what you want (
touchIDAuthenticationAllowableReuseDuration
) and
usually a bug. 
One context per authentication.

2. The seam

Biometrics is a dependency like any other. Behind a protocol it is injectable,
so a 
#Preview
 and a unit test never trigger a real prompt — which they cannot
do headlessly anyway, so a design that skips the seam is a design with no tests.

import Foundation
import LocalAuthentication

/// What the app actually needs. Not "an LAContext".
public protocol BiometricAuthenticating: Sendable {
    /// What the device offers right now, including why it cannot be used.
    func availability() -> BiometricAvailability

    /// Prompts, and returns only on success. Throws `BiometricError` otherwise.
    func authenticate(reason: String) async throws
}

public enum Biometry: Equatable, Sendable {
    case none, touchID, faceID, opticID
}

public enum BiometricAvailability: Equatable, Sendable {
    case available(Biometry)
    /// Hardware exists, but the user has not enrolled a face or finger.
    case notEnrolled(Biometry)
    /// Too many failures. Only a device passcode clears this.
    case lockedOut(Biometry)
    /// No biometric hardware, or the user disabled it for this app.
    case unavailable
}

public enum BiometricError: LocalizedError, Equatable {
    case cancelledByUser
    case cancelledBySystem
    case fellBackToPasscode
    case notEnrolled
    case lockedOut
    case notAvailable
    case failed

    public var errorDescription: String? {
        switch self {
        case .cancelledByUser, .cancelledBySystem, .fellBackToPasscode:
            // Not failures. See the anti-patterns.
            nil
        case .notEnrolled:
            String(localized: "Set up Face ID in Settings to unlock this way.")
        case .lockedOut:
            String(localized: "Face ID is locked. Enter your device passcode to re-enable it.")
        case .notAvailable:
            String(localized: "This device can't use biometric unlock.")
        case .failed:
            String(localized: "Couldn't verify it's you. Try again.")
        }
    }
}

3. The implementation

import LocalAuthentication

/// `LAContext` is not `Sendable` and a context must not be reused across
/// authentications, so this type creates one per call and never stores it.
/// That is what makes the actor safe *and* correct at the same time.
public actor BiometricAuthenticator: BiometricAuthenticating {

    /// `.deviceOwnerAuthenticationWithBiometrics` — biometry only, no passcode
    /// fallback. Use when the whole point is "prove it's this person".
    ///
    /// `.deviceOwnerAuthentication` — biometry, then passcode. Use when the
    /// point is "prove it's the device owner", which is almost every case that
    /// is not a payment confirmation.
    private let policy: LAPolicy

    public init(policy: LAPolicy = .deviceOwnerAuthentication) {
        self.policy = policy
    }

    public nonisolated func availability() -> BiometricAvailability {
        let context = LAContext()
        var error: NSError?

        // canEvaluatePolicy must be called before biometryType is meaningful.
        // Read in the other order it reports .none on a perfectly good device.
        let canEvaluate = context.canEvaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            error: &error
        )

        let biometry: Biometry = switch context.biometryType {
        case .faceID: .faceID
        case .touchID: .touchID
        case .opticID: .opticID
        default: .none
        }

        if canEvaluate { return .available(biometry) }

        return switch LAError.Code(rawValue: error?.code ?? -1) {
        case .biometryNotEnrolled: .notEnrolled(biometry)
        case .biometryLockout: .lockedOut(biometry)
        default: .unavailable
        }
    }

    public func authenticate(reason: String) async throws {
        let context = LAContext()

        // Shown beneath the prompt on Touch ID, and in the passcode sheet.
        // It must say what happens, not what the app is: "Unlock your vault",
        // never "Authenticate with Face ID". The user can see it is Face ID.
        context.localizedFallbackTitle = String(localized: "Use Passcode")

        do {
            // The throwing async overload. The completion-handler form calls
            // back on an arbitrary queue, which is where "UI updated from a
            // background thread" crashes come from.
            try await context.evaluatePolicy(policy, localizedReason: reason)
        } catch let error as LAError {
            throw Self.map(error)
        }
    }

    private static func map(_ error: LAError) -> BiometricError {
        switch error.code {
        case .userCancel:           .cancelledByUser
        case .systemCancel, .appCancel: .cancelledBySystem
        case .userFallback:         .fellBackToPasscode
        case .biometryNotEnrolled:  .notEnrolled
        case .biometryLockout:      .lockedOut
        case .biometryNotAvailable, .passcodeNotSet: .notAvailable
        default:                    .failed
        }
    }
}

On localizedReason:
 it is user-facing, on screen, in the system prompt.
It must be localized, and it must complete the sentence 
" is trying to
…"
. "Unlock your saved cards" is right. "Authenticate" is not a sentence.

4. The view model

import Observation

@MainActor
@Observable
public final class VaultLockModel {
    public private(set) var isUnlocked = false
    public private(set) var isAuthenticating = false
    public var errorMessage: String?

    /// Drives the button label and the empty state — a screen that says
    /// "Unlock with Face ID" on a device with no Face ID is a dead end.
    public private(set) var availability: BiometricAvailability = .unavailable

    private let authenticator: any BiometricAuthenticating

    public init(authenticator: any BiometricAuthenticating) {
        self.authenticator = authenticator
    }

    public func refreshAvailability() {
        availability = authenticator.availability()
    }

    public func unlock() async {
        isAuthenticating = true
        defer { isAuthenticating = false }

        do {
            try await authenticator.authenticate(
                reason: String(localized: "Unlock your saved cards")
            )
            isUnlocked = true
            errorMessage = nil
        } catch let error as BiometricError {
            // A cancel is a decision, not a failure. `errorDescription` is nil
            // for those cases, so this assignment is the whole policy.
            errorMessage = error.errorDescription
        } catch {
            errorMessage = String(localized: "Couldn't verify it's you. Try again.")
        }
    }

    /// Call from `.onChange(of: scenePhase)`. Leaving a vault unlocked across
    /// backgrounding defeats the lock — the next person to pick up the phone is
    /// already inside.
    public func lock() {
        isUnlocked = false
    }
}

5. The real boundary: Keychain access control

When the requirement is that data stays unreadable without biometry, the check
belongs in the Keychain, not in an 
if
.

import Foundation
import LocalAuthentication
import Security

public enum BiometricKeychain {

    /// `.biometryCurrentSet` invalidates the item when a face or fingerprint is
    /// added or removed. `.biometryAny` survives enrolment changes — which means
    /// someone who can add their own face to an unlocked device inherits access
    /// to the secret. For anything worth protecting, use `.biometryCurrentSet`
    /// and accept that re-enrolment forces the user to sign in again.
    public static func store(_ secret: Data, account: String) throws {
        var error: Unmanaged<CFError>?
        guard let access = SecAccessControlCreateWithFlags(
            nil,
            kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
            .biometryCurrentSet,
            &error
        ) else {
            throw error!.takeRetainedValue() as Error
        }

        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: account,
            kSecValueData as String: secret,
            kSecAttrAccessControl as String: access
        ]

        SecItemDelete(query as CFDictionary)   // no duplicates on re-store
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else { throw KeychainError.status(status) }
    }

    /// The biometric prompt happens *inside* SecItemCopyMatching. There is no
    /// branch to patch: without a successful evaluation the Secure Enclave
    /// never releases the bytes.
    public static func load(account: String, reason: String) throws -> Data {
        let context = LAContext()
        context.localizedReason = reason

        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: account,
            kSecReturnData as String: true,
            kSecUseAuthenticationContext as String: context
        ]

        var item: CFTypeRef?
        let status = SecItemCopyMatching(query as CFDictionary, &item)
        guard status == errSecSuccess, let data = item as? Data else {
            throw KeychainError.status(status)
        }
        return data
    }
}

public enum KeychainError: LocalizedError {
    case status(OSStatus)

    public var errorDescription: String? {
        switch self {
        case .status(errSecUserCanceled): nil          // a decision, not a failure
        case .status: String(localized: "Couldn't unlock your saved data.")
        }
    }
}

SecItemCopyMatching
 with an access control 
blocks
 — it does not return
until the user has responded to the biometric prompt. Call it from an actor:

public actor SecureCardStore {
    public init() {}

    public func loadCards(reason: String) throws -> Data {
        try BiometricKeychain.load(account: "cards", reason: reason)
    }
}

Never call it inside a 
@MainActor
 type's body, and never reach for

Task.detached
 to escape — that drops isolation, priority, and task-locals to
solve a problem an actor already solves.

6. Info.plist

Face ID requires a purpose string. Without it the app 
crashes
 the first time
it prompts — not at launch, not in review necessarily, but on a user's device
the first time they tap Unlock.

<key>NSFaceIDUsageDescription</key>
<string>Unlock your saved cards without typing your password.</string>

Touch ID and device passcode need no key. Face ID does, and the crash it causes
is easy to miss because the simulator you tested on was set to Touch ID.

Anti-Patterns

// WRONG — a security theatre boundary.
// The branch is patchable and the data was never encrypted. This protects
// nothing; it only adds a prompt.
if try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: r) {
    self.decryptedCards = loadCardsFromDisk()
}

// RIGHT — the key never leaves the Secure Enclave without biometry, and the
// blocking Keychain call runs on an actor rather than a detached task (which
// would drop isolation, priority, and task-locals for no benefit).
let secret = try await secureStore.loadCards(reason: r)

// WRONG — one context reused across the app.
// It caches the successful evaluation, so the second screen unlocks with no
// prompt at all. The user believes they re-authenticated. They did not.
final class Auth { static let context = LAContext() }

// RIGHT — a fresh LAContext per authentication.
let context = LAContext()

// WRONG — biometryType read before canEvaluatePolicy.
// It reports .none on a device with working Face ID, so the UI offers a
// password field to someone who has Face ID enrolled.
let type = LAContext().biometryType

// RIGHT — evaluate first; the type is only populated afterwards.
let context = LAContext()
_ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
let type = context.biometryType

// WRONG — a cancel rendered as a failure.
// The user tapped Cancel. Telling them "Authentication failed" implies
// something broke and trains them to distrust the prompt.
catch { errorMessage = "Authentication failed" }

// RIGHT — cancels and passcode fallbacks are outcomes, not errors.
catch let error as LAError where error.code == .userCancel { return }

// WRONG — .deviceOwnerAuthenticationWithBiometrics as the only path.
// A user with no enrolled biometry, or one who is locked out, can never get in.
// There is no passcode fallback in this policy — that is what it means.
try await context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: r)

// RIGHT — allow the passcode unless the requirement is specifically biometry.
try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: r)

// WRONG — .biometryAny on a secret worth protecting.
// The item survives enrolment changes, so adding a new face to an unlocked
// device grants that face access to the existing secret.
SecAccessControlCreateWithFlags(nil, accessible, .biometryAny, &error)

// RIGHT — invalidate when the enrolled set changes.
SecAccessControlCreateWithFlags(nil, accessible, .biometryCurrentSet, &error)

// WRONG — kSecAttrAccessibleWhenUnlocked on a device-bound secret.
// It migrates to a new device through an encrypted backup, which is exactly
// what a device-bound credential must not do.
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked

// RIGHT
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly

// WRONG — LAContext stored on a @MainActor @Observable model.
// LAContext is not Sendable; this is a data race the moment evaluation moves
// off the main actor, and a Swift 6 error.
@MainActor @Observable final class LockModel { let context = LAContext() }

// RIGHT — the context lives inside the actor that uses it, created per call.

// WRONG — a completion handler updating UI directly.
// evaluatePolicy's completion runs on an arbitrary queue. This is the classic
// "UIView modified from a background thread" crash.
context.evaluatePolicy(policy, localizedReason: r) { success, _ in
    self.isUnlocked = success
}

// RIGHT — the async overload, called from an isolated context.
try await context.evaluatePolicy(policy, localizedReason: r)

// WRONG — a hardcoded, untranslated reason string.
// It is displayed to the user, by the system, in their language everywhere
// except this sentence.
localizedReason: "Authenticate"

// RIGHT — localized, and a complete thought.
localizedReason: String(localized: "Unlock your saved cards")

// WRONG — unlocked state that survives backgrounding.
// The next person to pick up the phone is already past the lock screen.

// RIGHT — relock on scenePhase change.
.onChange(of: scenePhase) { _, phase in if phase != .active { model.lock() } }

// WRONG — biometrics as the only way in.
// Face ID fails with sunglasses, gloves defeat Touch ID, and lockout needs a
// passcode to clear. An app with no alternative path is an app the user is
// locked out of.

// RIGHT — always keep a password or passcode route to the same data.

Testing

Biometrics cannot be evaluated headlessly, so the protocol seam is not
optional — it is the only way any of this is testable.

public struct StubBiometrics: BiometricAuthenticating {
    // Note the name: a stored `availability` property would collide with the
    // protocol's `availability()` method — a redeclaration error, not a
    // shadowing warning.
    public var reportedAvailability: BiometricAvailability
    public var error: BiometricError?

    public init(
        reporting availability: BiometricAvailability = .available(.faceID),
        error: BiometricError? = nil
    ) {
        self.reportedAvailability = availability
        self.error = error
    }

    public func availability() -> BiometricAvailability { reportedAvailability }

    public func authenticate(reason: String) async throws {
        if let error { throw error }
    }
}

Cover, at minimum: success; 
.cancelledByUser
 producing 
no
 error message;

.lockedOut
 producing one that mentions the passcode; and 
.notEnrolled

rendering a screen that does not offer Face ID.

On the simulator, 
Features → Face ID → Enrolled
, then 
Matching Face
 or

Non-matching Face
. Simulator biometry is not a substitute for a device
pass — enrolment changes and lockout behave differently on real hardware.

Checklist

[ ] 
NSFaceIDUsageDescription
 present — its absence is a crash, not a warning
[ ] Secrets protected by Keychain access control, not by an 
if
[ ] 
.biometryCurrentSet
, not 
.biometryAny
, for anything sensitive
[ ] 
kSecAttrAccessibleWhenUnlockedThisDeviceOnly
 for device-bound credentials
[ ] A fresh 
LAContext
 per authentication
[ ] 
canEvaluatePolicy
 called before reading 
biometryType
[ ] 
.deviceOwnerAuthentication
 unless biometry-only is a stated requirement
[ ] Cancel and passcode-fallback produce no error UI
[ ] Lockout and not-enrolled produce distinct, actionable messages
[ ] A non-biometric route to the same data exists
[ ] State relocks on backgrounding
[ ] 
LAContext
 never stored on a 
@MainActor @Observable
 type
[ ] 
localizedReason
 is localized and completes "
 is trying to …"
[ ] Behind a protocol, with a stub, so previews and tests never prompt

---

# MapKit
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-mapkit.html

System Integration · Reference guideMapKitRepository guidance for MapKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Map View in SwiftUI
Annotations
Overlays
MKLocalSearch for Place Search
MKDirections for Routing
MapCamera and MapCameraPosition
Look Around
iOS 18+ Additions

AnyMapContent
MapSelection
MapKit Improvements Summary

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Define map region
↓2
Load annotations or search
↓3
Select a destination
↓4
Present route or place detail

02 / ArchitectureResponsibility boundariesBoundary 1
Location dataBoundary 2
Map contentBoundary 3
Selection stateConnected responsibilities, not a required class hierarchy or an execution trace.
Map View in SwiftUI

import SwiftUI
import MapKit

// Basic map with position binding
struct BasicMapView: View {
    @State private var position = MapCameraPosition.region(
        MKCoordinateRegion(
            center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
            span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
        )
    )

    var body: some View {
        Map(position: $position) {
            // Map content goes here
        }
        .mapStyle(.standard(elevation: .realistic))
        .mapControls {
            MapUserLocationButton()
            MapCompass()
            MapScaleView()
            MapPitchToggle()
        }
    }
}

// Map styles
// .standard(elevation: .realistic, pointsOfInterest: .including([.restaurant, .cafe]))
// .imagery(elevation: .realistic)
// .hybrid(elevation: .realistic)

Annotations

struct Place: Identifiable {
    let id = UUID()
    let name: String
    let coordinate: CLLocationCoordinate2D
    let category: String
}

struct AnnotatedMapView: View {
    let places: [Place] = [
        Place(name: "Ferry Building", coordinate: .init(latitude: 37.7956, longitude: -122.3933), category: "landmark"),
        Place(name: "Golden Gate Park", coordinate: .init(latitude: 37.7694, longitude: -122.4862), category: "park"),
        Place(name: "Chinatown", coordinate: .init(latitude: 37.7941, longitude: -122.4078), category: "neighborhood"),
    ]

    @State private var selectedPlace: Place?
    @State private var position = MapCameraPosition.automatic

    var body: some View {
        Map(position: $position, selection: $selectedPlace) {
            ForEach(places) { place in
                // Marker — system-styled pin
                Marker(place.name, coordinate: place.coordinate)
                    .tint(colorForCategory(place.category))
                    .tag(place)
            }
        }
        .onChange(of: selectedPlace) { _, newValue in
            if let place = newValue {
                print("Selected: \(place.name)")
            }
        }
    }

    func colorForCategory(_ category: String) -> Color {
        switch category {
        case "landmark": return .orange
        case "park": return .green
        case "neighborhood": return .purple
        default: return .red
        }
    }
}

// Custom annotation with SwiftUI view
struct CustomAnnotationMap: View {
    let places: [Place]

    var body: some View {
        Map {
            ForEach(places) { place in
                Annotation(place.name, coordinate: place.coordinate) {
                    VStack(spacing: 0) {
                        Image(systemName: "mappin.circle.fill")
                            .font(.title)
                            .foregroundStyle(.red)
                        Text(place.name)
                            .font(.caption2)
                            .padding(4)
                            .background(.ultraThinMaterial)
                            .cornerRadius(4)
                    }
                }
            }
        }
    }
}

Overlays

struct OverlayMapView: View {
    let routeCoordinates: [CLLocationCoordinate2D] = [
        CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
        CLLocationCoordinate2D(latitude: 37.7849, longitude: -122.4094),
        CLLocationCoordinate2D(latitude: 37.7949, longitude: -122.3994),
    ]

    var body: some View {
        Map {
            // Circle overlay
            MapCircle(center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), radius: 500)
                .foregroundStyle(.blue.opacity(0.2))
                .stroke(.blue, lineWidth: 2)

            // Polyline overlay (route)
            MapPolyline(coordinates: routeCoordinates)
                .stroke(.blue, lineWidth: 4)

            // Polygon overlay (area)
            MapPolygon(coordinates: [
                CLLocationCoordinate2D(latitude: 37.77, longitude: -122.42),
                CLLocationCoordinate2D(latitude: 37.78, longitude: -122.42),
                CLLocationCoordinate2D(latitude: 37.78, longitude: -122.41),
                CLLocationCoordinate2D(latitude: 37.77, longitude: -122.41),
            ])
            .foregroundStyle(.green.opacity(0.15))
            .stroke(.green, lineWidth: 2)
        }
    }
}

MKLocalSearch for Place Search

@Observable
class PlaceSearchManager {
    var searchResults: [MKMapItem] = []
    var isSearching = false

    func search(query: String, region: MKCoordinateRegion) async {
        isSearching = true
        defer { isSearching = false }

        let request = MKLocalSearch.Request()
        request.naturalLanguageQuery = query
        request.region = region
        request.resultTypes = [.pointOfInterest, .address]

        do {
            let search = MKLocalSearch(request: request)
            let response = try await search.start()
            searchResults = response.mapItems
        } catch {
            searchResults = []
        }
    }

    // Category-based search
    func searchNearby(category: MKPointOfInterestCategory, region: MKCoordinateRegion) async {
        let request = MKLocalPointsOfInterestRequest(center: region.center, radius: 1000)
        request.pointOfInterestFilter = MKPointOfInterestFilter(including: [category])

        do {
            let search = MKLocalSearch(request: request)
            let response = try await search.start()
            searchResults = response.mapItems
        } catch {
            searchResults = []
        }
    }
}

// Usage in SwiftUI
struct SearchableMapView: View {
    @State private var searchManager = PlaceSearchManager()
    @State private var searchText = ""
    @State private var position = MapCameraPosition.automatic

    var body: some View {
        Map(position: $position) {
            ForEach(searchManager.searchResults, id: \.self) { item in
                if let coordinate = item.placemark.coordinate as CLLocationCoordinate2D? {
                    Marker(item.name ?? "Unknown", coordinate: coordinate)
                }
            }
        }
        .searchable(text: $searchText)
        .onSubmit(of: .search) {
            Task {
                await searchManager.search(
                    query: searchText,
                    region: MKCoordinateRegion(
                        center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
                        span: MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1)
                    )
                )
            }
        }
    }
}

MKDirections for Routing

@Observable
class DirectionsManager {
    var route: MKRoute?
    var travelTime: TimeInterval = 0
    var distance: CLLocationDistance = 0

    func getDirections(
        from source: CLLocationCoordinate2D,
        to destination: CLLocationCoordinate2D,
        transportType: MKDirectionsTransportType = .automobile
    ) async throws {
        let request = MKDirections.Request()
        request.source = MKMapItem(placemark: MKPlacemark(coordinate: source))
        request.destination = MKMapItem(placemark: MKPlacemark(coordinate: destination))
        request.transportType = transportType
        request.requestsAlternateRoutes = true

        let directions = MKDirections(request: request)
        let response = try await directions.calculate()

        if let primaryRoute = response.routes.first {
            route = primaryRoute
            travelTime = primaryRoute.expectedTravelTime
            distance = primaryRoute.distance
        }
    }

    func getETA(
        from source: CLLocationCoordinate2D,
        to destination: CLLocationCoordinate2D
    ) async throws -> TimeInterval {
        let request = MKDirections.Request()
        request.source = MKMapItem(placemark: MKPlacemark(coordinate: source))
        request.destination = MKMapItem(placemark: MKPlacemark(coordinate: destination))

        let directions = MKDirections(request: request)
        let response = try await directions.calculateETA()
        return response.expectedTravelTime
    }
}

// Display route on map
struct RouteMapView: View {
    @State private var directionsManager = DirectionsManager()

    var body: some View {
        Map {
            if let route = directionsManager.route {
                MapPolyline(route.polyline)
                    .stroke(.blue, lineWidth: 5)
            }
        }
        .overlay(alignment: .bottom) {
            if directionsManager.route != nil {
                VStack {
                    Text("Distance: \(directionsManager.distance / 1000, specifier: "%.1f") km")
                    Text("ETA: \(Int(directionsManager.travelTime / 60)) min")
                }
                .padding()
                .background(.ultraThinMaterial)
                .cornerRadius(12)
                .padding()
            }
        }
    }
}

MapCamera and MapCameraPosition

struct CameraControlMap: View {
    @State private var position: MapCameraPosition = .automatic

    var body: some View {
        VStack {
            Map(position: $position)

            HStack {
                Button("SF") {
                    withAnimation {
                        position = .region(MKCoordinateRegion(
                            center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
                            span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
                        ))
                    }
                }
                Button("3D View") {
                    withAnimation {
                        position = .camera(MapCamera(
                            centerCoordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
                            distance: 1000,
                            heading: 45,
                            pitch: 60
                        ))
                    }
                }
                Button("User Location") {
                    withAnimation {
                        position = .userLocation(fallback: .automatic)
                    }
                }
            }
            .buttonStyle(.bordered)
        }
    }
}

Look Around

struct LookAroundPreview: View {
    let coordinate: CLLocationCoordinate2D
    @State private var lookAroundScene: MKLookAroundScene?

    var body: some View {
        Group {
            if let scene = lookAroundScene {
                LookAroundPreview(initialScene: scene)
                    .frame(height: 200)
                    .cornerRadius(12)
            } else {
                ContentUnavailableView("No Look Around available", systemImage: "eye.slash")
            }
        }
        .task {
            await fetchScene()
        }
    }

    private func fetchScene() async {
        let request = MKLookAroundSceneRequest(coordinate: coordinate)
        lookAroundScene = try? await request.scene
    }
}

iOS 18+ Additions

AnyMapContent

AnyMapContent
 provides type-erased map content, enabling dynamic and conditional map building without complex generic constraints. This is useful when composing map content from heterogeneous sources or building map content conditionally at runtime.

import SwiftUI
import MapKit

struct DynamicMapView: View {
    let places: [Place]
    let routes: [MKRoute]
    @State private var showOverlays = true

    var body: some View {
        Map {
            // Use AnyMapContent for conditional content composition
            ForEach(places) { place in
                if place.category == "landmark" {
                    AnyMapContent(
                        Marker(place.name, coordinate: place.coordinate)
                            .tint(.orange)
                    )
                } else {
                    AnyMapContent(
                        Annotation(place.name, coordinate: place.coordinate) {
                            Image(systemName: "mappin.circle.fill")
                                .foregroundStyle(.blue)
                        }
                    )
                }
            }

            if showOverlays {
                ForEach(routes, id: \.self) { route in
                    AnyMapContent(
                        MapPolyline(route.polyline)
                            .stroke(.blue, lineWidth: 4)
                    )
                }
            }
        }
    }
}

// Building map content dynamically from a heterogeneous collection
struct MapContentBuilder {
    enum MapItem {
        case marker(name: String, coordinate: CLLocationCoordinate2D, tint: Color)
        case circle(center: CLLocationCoordinate2D, radius: Double)
        case polyline(coordinates: [CLLocationCoordinate2D])
    }

    @MapContentBuilder
    static func content(for items: [MapItem]) -> some MapContent {
        ForEach(Array(items.enumerated()), id: \.offset) { _, item in
            switch item {
            case .marker(let name, let coordinate, let tint):
                AnyMapContent(
                    Marker(name, coordinate: coordinate)
                        .tint(tint)
                )
            case .circle(let center, let radius):
                AnyMapContent(
                    MapCircle(center: center, radius: radius)
                        .foregroundStyle(.blue.opacity(0.2))
                        .stroke(.blue, lineWidth: 2)
                )
            case .polyline(let coordinates):
                AnyMapContent(
                    MapPolyline(coordinates: coordinates)
                        .stroke(.red, lineWidth: 3)
                )
            }
        }
    }
}

MapSelection

iOS 18 enhances map selection handling with 
MapSelection
, enabling richer interaction with map features including automatic selection of points of interest, physical features (mountains, lakes), and custom annotations.

import SwiftUI
import MapKit

struct SelectableMapView: View {
    @State private var selection: MapSelection<MKMapItem>?

    var body: some View {
        Map(selection: $selection) {
            Marker("Apple Park", coordinate: CLLocationCoordinate2D(latitude: 37.3349, longitude: -122.0090))
                .tag(MapSelection<MKMapItem>.tag(for: "apple-park"))
        }
        // Enable automatic selection of built-in map features
        .mapFeatureSelectionAccessory(.automatic)
        // React to selection changes
        .onChange(of: selection) { _, newSelection in
            if let selection = newSelection {
                handleSelection(selection)
            }
        }
        .sheet(item: $selection) { selectedItem in
            // Present details for the selected map feature
            MapItemDetailView(selection: selectedItem)
        }
    }

    private func handleSelection(_ selection: MapSelection<MKMapItem>) {
        // Handle different selection types
        print("Map item selected")
    }
}

// Detailed view for selected map features
struct MapItemDetailView: View {
    let selection: MapSelection<MKMapItem>

    var body: some View {
        VStack {
            Text("Selected Location")
                .font(.headline)
            // Display details about the selected feature
        }
        .padding()
        .presentationDetents([.medium])
    }
}

MapKit Improvements Summary

Additional iOS 18 MapKit enhancements:

Unified Maps URLs
: 
MKMapItem.openMaps(with:launchOptions:)
 now generates universal map links that work consistently across iOS, macOS, and the web, making it easier to share locations across platforms.
Improved .mapStyle() options
: Additional customization for map rendering styles including finer control over point-of-interest filtering and label density.
MapFeature selection
: Tap on built-in map features (parks, transit stops, businesses) to get details without custom annotation overlays via 
.mapFeatureSelectionAccessory()
.
Performance
: MapKit rendering and tile-loading performance improvements for large numbers of annotations and overlays.

---

# Metal -- GPU Rendering, Compute, and Performance-Critical…
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-metal.html

Graphics, 3D, and Games · Reference guideMetal -- GPU Rendering, Compute, and Performance-Critical GraphicsRepository guidance for Metal. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Overview
1. Choosing Metal
2. Minimal MTKView Renderer
3. Minimal Shader File
4. Vertex Buffers
5. Uniforms and In-Flight Frames
6. Textures with MetalKit
7. Compute Kernels
8. Depth, Stencil, and Render State
9. ARKit Camera Frames and Metal
10. Swift Concurrency Boundaries
11. Debugging and Profiling
12. Common Pitfalls
13. Review Checklist

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Create GPU resources
↓2
Encode rendering or compute work
↓3
Submit command buffers
↓4
Profile and synchronize

02 / ArchitectureResponsibility boundariesBoundary 1
CPU preparationBoundary 2
GPU command pipelineBoundary 3
Output buffersConnected responsibilities, not a required class hierarchy or an execution trace.
Overview

Metal gives Swift and Objective-C apps direct access to Apple GPUs for rendering, compute, image processing, data-parallel work, and custom frame pipelines. Use Metal when high-level frameworks such as RealityKit, SceneKit, SpriteKit, Core Image, or SwiftUI cannot provide the performance, visual result, or GPU scheduling control the product needs.

Reach for Metal deliberately. It is powerful, but it makes the app responsible for pipeline state, memory lifetime, synchronization, shader compilation, frame pacing, and GPU debugging.

1. Choosing Metal

Need

Prefer

Common AR / 3D entities, physics, USDZ

RealityKit

Existing scene graph and .scnassets

SceneKit

Custom post-processing, compute, renderer ownership

Metal

Image filters without custom kernels

Core Image

Charts and UI drawing

SwiftUI / Core Graphics

Use 
MetalKit
 for app display setup. Apple documents 
MTKView
 as the Metal-aware view that owns the drawable setup, render-pass descriptor creation, optional depth/stencil textures, and delegate callbacks. 
MTKView
 is 
@MainActor
, so create and configure it on the main actor.

2. Minimal 
MTKView
 Renderer

import Metal
import MetalKit

@MainActor
final class Renderer: NSObject, MTKViewDelegate {
    private let device: any MTLDevice
    private let commandQueue: any MTLCommandQueue
    private let pipelineState: any MTLRenderPipelineState

    init(view: MTKView) throws {
        guard let device = MTLCreateSystemDefaultDevice(),
              let commandQueue = device.makeCommandQueue() else {
            throw RendererError.metalUnavailable
        }

        self.device = device
        self.commandQueue = commandQueue

        view.device = device
        view.colorPixelFormat = .bgra8Unorm
        view.depthStencilPixelFormat = .depth32Float
        view.framebufferOnly = true
        view.clearColor = MTLClearColor(red: 0.02, green: 0.03, blue: 0.05, alpha: 1)

        let library = try device.makeDefaultLibrary(bundle: .main)
        guard let vertexFunction = library.makeFunction(name: "vertex_main"),
              let fragmentFunction = library.makeFunction(name: "fragment_main") else {
            throw RendererError.missingShaderFunction
        }

        let descriptor = MTLRenderPipelineDescriptor()
        descriptor.vertexFunction = vertexFunction
        descriptor.fragmentFunction = fragmentFunction
        descriptor.colorAttachments[0].pixelFormat = view.colorPixelFormat
        descriptor.depthAttachmentPixelFormat = view.depthStencilPixelFormat
        pipelineState = try device.makeRenderPipelineState(descriptor: descriptor)

        super.init()
        view.delegate = self
    }

    func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {}

    func draw(in view: MTKView) {
        guard let descriptor = view.currentRenderPassDescriptor,
              let drawable = view.currentDrawable,
              let commandBuffer = commandQueue.makeCommandBuffer(),
              let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else {
            return
        }

        encoder.setRenderPipelineState(pipelineState)
        encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
        encoder.endEncoding()

        commandBuffer.present(drawable)
        commandBuffer.commit()
    }
}

enum RendererError: Error {
    case metalUnavailable
    case missingShaderFunction
    case bufferAllocationFailed
}

SwiftUI wrapper:

import MetalKit
import SwiftUI

struct MetalView: UIViewRepresentable {
    func makeUIView(context: Context) -> MTKView {
        let view = MTKView()
        context.coordinator.attach(to: view)
        return view
    }

    func updateUIView(_ uiView: MTKView, context: Context) {}

    func makeCoordinator() -> Coordinator { Coordinator() }

    @MainActor
    final class Coordinator {
        private var renderer: Renderer?

        func attach(to view: MTKView) {
            renderer = try? Renderer(view: view)
        }
    }
}

3. Minimal Shader File

Add a 
.metal
 file to the app target. Xcode compiles it into 
default.metallib
.

#include <metal_stdlib>
using namespace metal;

struct VertexOut {
    float4 position [[position]];
    float3 color;
};

vertex VertexOut vertex_main(uint vertexID [[vertex_id]]) {
    float2 positions[3] = {
        float2( 0.0,  0.6),
        float2(-0.6, -0.6),
        float2( 0.6, -0.6)
    };

    float3 colors[3] = {
        float3(1.0, 0.2, 0.2),
        float3(0.2, 1.0, 0.4),
        float3(0.2, 0.4, 1.0)
    };

    VertexOut out;
    out.position = float4(positions[vertexID], 0.0, 1.0);
    out.color = colors[vertexID];
    return out;
}

fragment float4 fragment_main(VertexOut in [[stage_in]]) {
    return float4(in.color, 1.0);
}

Keep shader names stable and fail fast if a function is missing. A nil shader function usually means the 
.metal
 file is not in target membership or the Swift name no longer matches the shader function.

4. Vertex Buffers

struct Vertex {
    var position: SIMD3<Float>
    var color: SIMD3<Float>
}

let vertices: [Vertex] = [
    Vertex(position: [-0.5, -0.5, 0], color: [1, 0, 0]),
    Vertex(position: [ 0.5, -0.5, 0], color: [0, 1, 0]),
    Vertex(position: [ 0.0,  0.5, 0], color: [0, 0, 1]),
]

guard let vertexBuffer = device.makeBuffer(
    bytes: vertices,
    length: MemoryLayout<Vertex>.stride * vertices.count,
    options: [.storageModeShared]
) else {
    throw RendererError.bufferAllocationFailed
}

Prefer immutable buffers for static geometry. Use ring buffers or multiple in-flight buffers for per-frame uniforms so the CPU does not overwrite data the GPU is still reading.

5. Uniforms and In-Flight Frames

struct Uniforms {
    var modelViewProjectionMatrix: simd_float4x4
    var time: Float
}

private let maxFramesInFlight = 3
private let frameSemaphore = DispatchSemaphore(value: 3)
private var frameIndex = 0
private var uniformBuffers: [MTLBuffer] = []

func beginFrame() {
    frameSemaphore.wait()
    frameIndex = (frameIndex + 1) % maxFramesInFlight
}

func endFrame(commandBuffer: MTLCommandBuffer) {
    commandBuffer.addCompletedHandler { [frameSemaphore] _ in
        frameSemaphore.signal()
    }
}

Never mutate a shared uniform buffer immediately after encoding it unless you know the GPU has finished using it.

6. Textures with MetalKit

import MetalKit

let loader = MTKTextureLoader(device: device)
let texture = try loader.newTexture(
    name: "albedo",
    scaleFactor: UIScreen.main.scale,
    bundle: .main,
    options: [
        .textureUsage: MTLTextureUsage.shaderRead.rawValue,
        .textureStorageMode: MTLStorageMode.private.rawValue,
        .SRGB: true,
    ]
)

Use private storage for GPU-only textures. Use shared storage only when the CPU must read or write the resource.

7. Compute Kernels

#include <metal_stdlib>
using namespace metal;

kernel void brighten(texture2d<float, access::read> source [[texture(0)]],
                     texture2d<float, access::write> output [[texture(1)]],
                     uint2 id [[thread_position_in_grid]]) {
    if (id.x >= output.get_width() || id.y >= output.get_height()) {
        return;
    }

    float4 pixel = source.read(id);
    output.write(float4(min(pixel.rgb * 1.15, 1.0), pixel.a), id);
}

let width = pipeline.threadExecutionWidth
let height = max(1, pipeline.maxTotalThreadsPerThreadgroup / width)
let threadsPerGroup = MTLSize(width: width, height: height, depth: 1)
let threadsPerGrid = MTLSize(width: output.width, height: output.height, depth: 1)

encoder.setComputePipelineState(pipeline)
encoder.setTexture(source, index: 0)
encoder.setTexture(output, index: 1)
encoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerGroup)

Dispatch with bounds checks in the shader. Texture dimensions are rarely exact multiples of the threadgroup size.

8. Depth, Stencil, and Render State

let depthDescriptor = MTLDepthStencilDescriptor()
depthDescriptor.depthCompareFunction = .less
depthDescriptor.isDepthWriteEnabled = true
let depthState = device.makeDepthStencilState(descriptor: depthDescriptor)

encoder.setDepthStencilState(depthState)

Pipeline state is expensive to create. Apple recommends creating shared objects such as command queues, pipelines, buffers, and textures during initialization instead of time-critical paths. Build render and compute pipeline states before 
draw(in:)
.

9. ARKit Camera Frames and Metal

ARKit gives each 
ARFrame
 a camera image as a 
CVPixelBuffer
. Use 
CVMetalTextureCache
 to wrap camera planes as Metal textures without copying.

import ARKit
import CoreVideo
import Metal

var textureCache: CVMetalTextureCache?
CVMetalTextureCacheCreate(nil, nil, device, nil, &textureCache)

func makeTexture(from pixelBuffer: CVPixelBuffer,
                 plane: Int,
                 pixelFormat: MTLPixelFormat) -> MTLTexture? {
    guard let textureCache else { return nil }

    let width = CVPixelBufferGetWidthOfPlane(pixelBuffer, plane)
    let height = CVPixelBufferGetHeightOfPlane(pixelBuffer, plane)
    var cvTexture: CVMetalTexture?

    let status = CVMetalTextureCacheCreateTextureFromImage(
        nil,
        textureCache,
        pixelBuffer,
        nil,
        pixelFormat,
        width,
        height,
        plane,
        &cvTexture
    )

    guard status == kCVReturnSuccess else { return nil }
    return cvTexture.flatMap(CVMetalTextureGetTexture)
}

Use RealityKit unless the product needs custom camera compositing, segmentation, reconstruction rendering, or research-grade visualization.

10. Swift Concurrency Boundaries

Metal objects are reference types that often represent GPU resources. Treat the renderer as owning them on one execution context. Because 
MTKView
 is 
@MainActor
, keep view configuration and delegate attachment on the main actor.

@MainActor
final class RenderModel {
    private var renderer: Renderer?

    func attach(view: MTKView) {
        renderer = try? Renderer(view: view)
    }
}

For asset preparation, decode or generate CPU-side data off the main actor, then hand immutable bytes to the renderer for buffer creation. Keep command encoding serialized unless you have a clear multi-queue design.

11. Debugging and Profiling

Enable Metal API Validation in debug schemes.
Capture GPU frames in Xcode and inspect render passes, attachments, and pipeline state.
Use Instruments' Metal System Trace for frame pacing and GPU/CPU overlap.
Give command buffers and resources labels so captures are readable.
Watch for pixel format mismatches between 
MTKView
, pipeline descriptors, and render pass attachments.

commandBuffer.label = "Main scene command buffer"
pipelineDescriptor.label = "Opaque mesh pipeline"
vertexBuffer.label = "Static mesh vertices"

12. Common Pitfalls

Creating pipeline state during rendering
 -- compile it during setup.
Forgetting target membership for .metal files
 -- 
makeDefaultLibrary()
 will not contain the shader.
Writing to buffers still in use by the GPU
 -- use in-flight buffers or command-buffer completion handlers.
Mismatched pixel formats
 -- the pipeline descriptor must match the render pass.
Assuming one threadgroup fits all textures
 -- bounds-check compute kernels.
Skipping labels
 -- unlabeled GPU captures are slow to debug.

13. Review Checklist

[ ] 
MTLCreateSystemDefaultDevice()
 failure has a user-facing fallback
[ ] 
MTKView
 setup and delegate attachment happen on the main actor
[ ] Pipeline states are created outside the draw loop
[ ] 
.metal
 functions are checked and failures are actionable
[ ] Per-frame data uses in-flight buffers or synchronization
[ ] Textures use private storage unless CPU access is required
[ ] Render pass formats match pipeline descriptors
[ ] Command buffers/resources are labeled in debug builds
[ ] GPU work is profiled before lower-level rewrites

See also: 
docs/frameworks/realitykit.md
, 
docs/frameworks/scenekit.md
, 
docs/frameworks/arkit.md
, 
docs/frameworks/accelerate.md
.

---

# CoreML -- Complete Guide for On-Device Machine Learning
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-ml-coreml.html

AI and Machine Learning · Reference guideCoreML -- Complete Guide for On-Device Machine LearningRepository guidance for Core ML. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Overview
1. Loading an MLModel

From the App Bundle (Compiled .mlmodelc)
From a Compiled Model URL
From a Downloaded .mlmodel (Runtime Compilation)

2. Compute Unit Configuration
3. MLMultiArray for Input and Output

Using MLTensor (iOS 18+)

4. Making Predictions

Synchronous Prediction
Async Prediction (iOS 17+)
Batch Prediction

5. VNCoreMLRequest -- Vision + CoreML Pipeline
6. Complete Image Classification Example
7. Text Prediction Example
8. Model Caching and Performance Optimization
9. Converting Models with coremltools (Overview)
10. Performance Tips
Quick Reference

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Load compatible model
↓2
Prepare typed inputs
↓3
Run prediction
↓4
Validate output and latency

02 / ArchitectureResponsibility boundariesBoundary 1
Model assetBoundary 2
Prediction adapterBoundary 3
App decisionConnected responsibilities, not a required class hierarchy or an execution trace.
Overview

CoreML is Apple's framework for running machine learning models on-device with hardware-accelerated inference across CPU, GPU, and the Neural Engine. It supports vision, natural language, audio, and tabular models with a unified API surface. Every code example below compiles and follows production best practices.

1. Loading an MLModel

From the App Bundle (Compiled .mlmodelc)

When you drag a 
.mlmodel
 file into Xcode, it auto-generates a Swift class. You can also load manually.

import CoreML

// Auto-generated class usage (Xcode compiles the model at build time)
let imageClassifier = try MobileNetV2(configuration: MLModelConfiguration())

// Manual loading from the bundle
let bundleURL = Bundle.main.url(forResource: "MobileNetV2", withExtension: "mlmodelc")!
let model = try MLModel(contentsOf: bundleURL)

From a Compiled Model URL

import CoreML

func loadModel(from compiledURL: URL) async throws -> MLModel {
    let configuration = MLModelConfiguration()
    configuration.computeUnits = .all
    return try MLModel(contentsOf: compiledURL, configuration: configuration)
}

From a Downloaded .mlmodel (Runtime Compilation)

import CoreML

func compileAndLoad(downloadedModelURL: URL) async throws -> MLModel {
    // Compile the .mlmodel into .mlmodelc at runtime
    let compiledURL = try await MLModel.compileModel(at: downloadedModelURL)

    // Move to a permanent location (compiled models are placed in a temp directory)
    let permanentURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
        .appendingPathComponent("Models")
        .appendingPathComponent(compiledURL.lastPathComponent)

    try FileManager.default.createDirectory(
        at: permanentURL.deletingLastPathComponent(),
        withIntermediateDirectories: true
    )

    if FileManager.default.fileExists(atPath: permanentURL.path) {
        try FileManager.default.removeItem(at: permanentURL)
    }
    try FileManager.default.moveItem(at: compiledURL, to: permanentURL)

    let configuration = MLModelConfiguration()
    configuration.computeUnits = .all
    return try MLModel(contentsOf: permanentURL, configuration: configuration)
}

2. Compute Unit Configuration

Control where inference runs: CPU only, CPU and GPU, CPU and Neural Engine, or all available hardware.

import CoreML

func configuredModel() throws -> MLModel {
    let config = MLModelConfiguration()

    // Options:
    // .cpuOnly        -- safest, always available
    // .cpuAndGPU      -- good for large matrix operations
    // .cpuAndNeuralEngine -- best power efficiency for supported models
    // .all            -- let CoreML decide the optimal hardware (recommended)
    config.computeUnits = .all

    // Optionally allow low-precision accumulation for speed
    config.allowLowPrecisionAccumulationOnGPU = true

    return try MLModel(contentsOf: Bundle.main.url(forResource: "MyModel", withExtension: "mlmodelc")!,
                       configuration: config)
}

3. MLMultiArray for Input and Output

MLMultiArray is the primary numeric tensor type for CoreML model inputs and outputs.

import CoreML

// Create a multi-array with shape [1, 3, 224, 224] (batch, channels, height, width)
let inputArray = try MLMultiArray(shape: [1, 3, 224, 224], dataType: .float32)

// Fill with data
for i in 0..<inputArray.count {
    inputArray[i] = NSNumber(value: Float.random(in: 0...1))
}

// Access specific elements using a flat index
let value = inputArray[0].floatValue

// Access with multi-dimensional subscript
let multiIndex = [0, 1, 112, 112] as [NSNumber]
let pixel = inputArray[multiIndex].floatValue

Using MLTensor (iOS 18+)

MLTensor provides a more ergonomic, Accelerate-backed tensor API.

import CoreML

@available(iOS 18.0, *)
func tensorOperations() {
    // Create from shape and scalar
    let zeros = MLTensor(zeros: [1, 3, 224, 224], scalarType: Float.self)

    // Create from an array
    let data = MLTensor([1.0, 2.0, 3.0, 4.0] as [Float])

    // Reshape
    let reshaped = data.reshaped(to: [2, 2])

    // Arithmetic
    let scaled = data * 2.0
    let summed = data + MLTensor([0.5, 0.5, 0.5, 0.5] as [Float])

    // Convert to MLMultiArray for model input
    let multiArray = zeros.shapedArray(of: Float.self)
}

4. Making Predictions

Synchronous Prediction

import CoreML

func predict(with model: MLModel, input: MLMultiArray) throws -> MLFeatureProvider {
    let inputFeature = try MLDictionaryFeatureProvider(
        dictionary: ["input_tensor": MLFeatureValue(multiArray: input)]
    )

    let options = MLPredictionOptions()
    options.usesCPUOnly = false  // allow GPU and Neural Engine

    let output = try model.prediction(from: inputFeature, options: options)
    return output
}

Async Prediction (iOS 17+)

import CoreML

@available(iOS 17.0, *)
func asyncPredict(with model: MLModel, input: MLMultiArray) async throws -> MLFeatureProvider {
    let inputFeature = try MLDictionaryFeatureProvider(
        dictionary: ["input_tensor": MLFeatureValue(multiArray: input)]
    )

    let options = MLPredictionOptions()
    let output = try await model.prediction(from: inputFeature, options: options)
    return output
}

Batch Prediction

import CoreML

func batchPredict(with model: MLModel, inputs: [MLDictionaryFeatureProvider]) throws -> [MLFeatureProvider] {
    let batchProvider = MLArrayBatchProvider(array: inputs)
    let options = MLPredictionOptions()

    let batchResults = try model.predictions(from: batchProvider, options: options)

    var results: [MLFeatureProvider] = []
    for i in 0..<batchResults.count {
        results.append(batchResults.features(at: i))
    }
    return results
}

5. VNCoreMLRequest -- Vision + CoreML Pipeline

Combine Vision preprocessing (resize, crop, normalize) with a CoreML model in a single pipeline.

import CoreML
import Vision
import UIKit

func classifyImage(_ image: UIImage) async throws -> [(String, Float)] {
    guard let cgImage = image.cgImage else {
        throw NSError(domain: "ImageError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid image"])
    }

    let configuration = MLModelConfiguration()
    configuration.computeUnits = .all
    let mlModel = try MobileNetV2(configuration: configuration).model
    let vnModel = try VNCoreMLModel(for: mlModel)

    return try await withCheckedThrowingContinuation { continuation in
        let request = VNCoreMLRequest(model: vnModel) { request, error in
            if let error {
                continuation.resume(throwing: error)
                return
            }

            guard let results = request.results as? [VNClassificationObservation] else {
                continuation.resume(returning: [])
                return
            }

            let topResults = results.prefix(5).map { ($0.identifier, $0.confidence) }
            continuation.resume(returning: topResults)
        }

        // Configure image preprocessing
        request.imageCropAndScaleOption = .centerCrop

        let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
        do {
            try handler.perform([request])
        } catch {
            continuation.resume(throwing: error)
        }
    }
}

6. Complete Image Classification Example

A full SwiftUI view that loads an image and classifies it with CoreML.

import SwiftUI
import CoreML
import Vision
import PhotosUI

@Observable
final class ImageClassifierViewModel {
    var classifications: [(label: String, confidence: Float)] = []
    var selectedImage: UIImage?
    var isProcessing = false
    var errorMessage: String?

    @MainActor
    func classify() async {
        guard let image = selectedImage, let cgImage = image.cgImage else { return }

        isProcessing = true
        errorMessage = nil

        do {
            let config = MLModelConfiguration()
            config.computeUnits = .all
            let mlModel = try MobileNetV2(configuration: config).model
            let vnModel = try VNCoreMLModel(for: mlModel)

            let results: [(String, Float)] = try await withCheckedThrowingContinuation { continuation in
                let request = VNCoreMLRequest(model: vnModel) { request, error in
                    if let error {
                        continuation.resume(throwing: error)
                        return
                    }
                    let observations = (request.results as? [VNClassificationObservation]) ?? []
                    let top5 = observations.prefix(5).map { ($0.identifier, $0.confidence) }
                    continuation.resume(returning: top5)
                }
                request.imageCropAndScaleOption = .centerCrop

                let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
                do {
                    try handler.perform([request])
                } catch {
                    continuation.resume(throwing: error)
                }
            }

            classifications = results.map { (label: $0.0, confidence: $0.1) }
        } catch {
            errorMessage = error.localizedDescription
        }

        isProcessing = false
    }
}

struct ImageClassifierView: View {
    @State private var viewModel = ImageClassifierViewModel()
    @State private var photoItem: PhotosPickerItem?

    var body: some View {
        NavigationStack {
            VStack(spacing: 24) {
                if let image = viewModel.selectedImage {
                    Image(uiImage: image)
                        .resizable()
                        .scaledToFit()
                        .frame(maxHeight: 300)
                        .clipShape(RoundedRectangle(cornerRadius: 16))
                        .shadow(color: .black.opacity(0.2), radius: 12, y: 6)
                } else {
                    ContentUnavailableView("Select a Photo",
                                           systemImage: "photo.on.rectangle",
                                           description: Text("Choose an image to classify"))
                }

                PhotosPicker(selection: $photoItem, matching: .images) {
                    Label("Choose Photo", systemImage: "photo.badge.plus")
                        .font(.headline)
                        .padding()
                        .frame(maxWidth: .infinity)
                        .background(.tint, in: RoundedRectangle(cornerRadius: 12))
                        .foregroundStyle(.white)
                }

                if viewModel.isProcessing {
                    ProgressView("Classifying...")
                }

                if let error = viewModel.errorMessage {
                    Text(error)
                        .foregroundStyle(.red)
                        .font(.caption)
                }

                if !viewModel.classifications.isEmpty {
                    VStack(alignment: .leading, spacing: 8) {
                        Text("Results")
                            .font(.headline)

                        ForEach(viewModel.classifications, id: \.label) { item in
                            HStack {
                                Text(item.label)
                                    .font(.body)
                                Spacer()
                                Text("\(item.confidence * 100, specifier: "%.1f")%")
                                    .font(.body.monospacedDigit())
                                    .foregroundStyle(.secondary)
                            }
                            .padding(.vertical, 4)
                        }
                    }
                    .padding()
                    .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
                }

                Spacer()
            }
            .padding()
            .navigationTitle("Image Classifier")
            .onChange(of: photoItem) { _, newItem in
                Task {
                    if let data = try? await newItem?.loadTransferable(type: Data.self),
                       let uiImage = UIImage(data: data) {
                        viewModel.selectedImage = uiImage
                        await viewModel.classify()
                    }
                }
            }
        }
    }
}

7. Text Prediction Example

Using a CoreML model trained on tabular or text data to make predictions.

import CoreML

struct SentimentPredictor {
    private let model: MLModel

    init() throws {
        let config = MLModelConfiguration()
        config.computeUnits = .cpuOnly  // text models often run best on CPU
        self.model = try SentimentClassifier(configuration: config).model
    }

    func predict(text: String) throws -> (label: String, confidence: Double) {
        let input = try MLDictionaryFeatureProvider(
            dictionary: ["text": MLFeatureValue(string: text)]
        )

        let output = try model.prediction(from: input)

        let label = output.featureValue(for: "label")?.stringValue ?? "unknown"
        let probabilities = output.featureValue(for: "labelProbability")?.dictionaryValue ?? [:]

        let confidence = (probabilities[label as NSObject] as? NSNumber)?.doubleValue ?? 0.0
        return (label: label, confidence: confidence)
    }

    func predictBatch(texts: [String]) throws -> [(label: String, confidence: Double)] {
        let inputs: [MLDictionaryFeatureProvider] = try texts.map { text in
            try MLDictionaryFeatureProvider(dictionary: ["text": MLFeatureValue(string: text)])
        }

        let batchProvider = MLArrayBatchProvider(array: inputs)
        let batchResults = try model.predictions(from: batchProvider)

        var results: [(label: String, confidence: Double)] = []
        for i in 0..<batchResults.count {
            let output = batchResults.features(at: i)
            let label = output.featureValue(for: "label")?.stringValue ?? "unknown"
            let probs = output.featureValue(for: "labelProbability")?.dictionaryValue ?? [:]
            let confidence = (probs[label as NSObject] as? NSNumber)?.doubleValue ?? 0.0
            results.append((label: label, confidence: confidence))
        }
        return results
    }
}

8. Model Caching and Performance Optimization

import CoreML

actor ModelCache {
    static let shared = ModelCache()

    private var cache: [String: MLModel] = [:]

    func model(named name: String, computeUnits: MLComputeUnits = .all) throws -> MLModel {
        if let cached = cache[name] {
            return cached
        }

        guard let url = Bundle.main.url(forResource: name, withExtension: "mlmodelc") else {
            throw ModelCacheError.modelNotFound(name)
        }

        let config = MLModelConfiguration()
        config.computeUnits = computeUnits

        let model = try MLModel(contentsOf: url, configuration: config)
        cache[name] = model
        return model
    }

    func preload(modelNames: [String]) async throws {
        for name in modelNames {
            _ = try model(named: name)
        }
    }

    func evict(named name: String) {
        cache.removeValue(forKey: name)
    }

    func evictAll() {
        cache.removeAll()
    }
}

enum ModelCacheError: LocalizedError {
    case modelNotFound(String)

    var errorDescription: String? {
        switch self {
        case .modelNotFound(let name):
            return "Model '\(name)' not found in the app bundle."
        }
    }
}

9. Converting Models with coremltools (Overview)

Use Python's 
coremltools
 to convert models from PyTorch, TensorFlow, or ONNX into 
.mlmodel
 format.

# Install: pip install coremltools torch torchvision

import coremltools as ct
import torch
import torchvision

# Load a pretrained PyTorch model
torch_model = torchvision.models.mobilenet_v2(pretrained=True)
torch_model.eval()

# Trace the model with example input
example_input = torch.randn(1, 3, 224, 224)
traced_model = torch.jit.trace(torch_model, example_input)

# Convert to CoreML
coreml_model = ct.convert(
    traced_model,
    inputs=[ct.ImageType(name="image", shape=(1, 3, 224, 224), scale=1/255.0)],
    classifier_config=ct.ClassifierConfig("imagenet_classes.txt"),
    compute_units=ct.ComputeUnit.ALL,
    minimum_deployment_target=ct.target.iOS17,
)

# Save
coreml_model.save("MobileNetV2.mlpackage")

Key conversion options:
- 
compute_precision
 -- use 
ct.precision.FLOAT16
 for smaller models and faster Neural Engine inference.
- 
minimum_deployment_target
 -- set to the lowest iOS version you support for maximum compatibility.
- 
ct.ImageType
 -- declares the input as an image so Vision can preprocess it automatically.
- 
ct.ClassifierConfig
 -- adds classification metadata so results come as 
VNClassificationObservation
.

10. Performance Tips

Technique

Benefit

Use .all compute units

Let CoreML pick the fastest hardware

Float16 quantization

2x smaller model, faster on Neural Engine

Batch predictions

Amortize model setup overhead

Cache loaded models

Avoid repeated disk I/O and compilation

Background thread inference

Keep UI responsive

Model warmup

Run a dummy prediction at launch to prime the pipeline

Use MLPackage over MLModel

Modern format with better optimization support

Profile with Instruments

Use the CoreML Instrument to find bottlenecks

import CoreML

// Warmup: run a dummy prediction to prime the model pipeline
func warmup(model: MLModel, inputName: String, shape: [NSNumber]) throws {
    let dummyInput = try MLMultiArray(shape: shape, dataType: .float32)
    let provider = try MLDictionaryFeatureProvider(
        dictionary: [inputName: MLFeatureValue(multiArray: dummyInput)]
    )
    _ = try model.prediction(from: provider)
}

Quick Reference

Class / Protocol

Purpose

MLModel

Core class for loading and running models

MLModelConfiguration

Configure compute units, precision

MLMultiArray

N-dimensional numeric array for model I/O

MLTensor

Modern tensor API (iOS 18+)

MLFeatureProvider

Protocol for model input/output

MLDictionaryFeatureProvider

Dictionary-based feature provider

MLArrayBatchProvider

Batch multiple inputs for prediction

MLPredictionOptions

Options for prediction (CPU-only flag)

VNCoreMLModel

Bridge a CoreML model into the Vision pipeline

VNCoreMLRequest

Vision request that uses a CoreML model

---

# NaturalLanguage Framework -- Complete Guide for Text…
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-ml-natural-language.html

AI and Machine Learning · Reference guideNaturalLanguage Framework -- Complete Guide for Text Processing and NLPRepository guidance for Natural Language. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Overview
1. Tokenization -- NLTokenizer

Tokenizing with Language Hint

2. Part-of-Speech Tagging -- NLTagger
3. Named Entity Recognition
4. Lemmatization
5. Language Identification -- NLLanguageRecognizer
6. Sentiment Analysis

Sentence-Level Sentiment Analysis

7. Text Embeddings -- NLEmbedding

Word Embeddings
Sentence Embeddings

8. Custom NLModel with Create ML
9. Complete Text Analysis Pipeline
Quick Reference

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Choose language task
↓2
Configure tokenizer or tagger
↓3
Process text units
↓4
Interpret linguistic results

02 / ArchitectureResponsibility boundariesBoundary 1
Text inputBoundary 2
Language analysisBoundary 3
Structured annotationsConnected responsibilities, not a required class hierarchy or an execution trace.
Overview

The NaturalLanguage framework provides on-device natural language processing for tokenization, language identification, named entity recognition, part-of-speech tagging, sentiment analysis, lemmatization, and text embeddings. All processing runs locally with no network dependency. Every code example below compiles and follows production best practices.

1. Tokenization -- NLTokenizer

Split text into words, sentences, or paragraphs with locale-aware boundary detection.

import NaturalLanguage

func tokenize(_ text: String, unit: NLTokenUnit) -> [String] {
    let tokenizer = NLTokenizer(unit: unit)
    tokenizer.string = text

    var tokens: [String] = []
    tokenizer.enumerateTokens(in: text.startIndex..<text.endIndex) { range, _ in
        tokens.append(String(text[range]))
        return true  // continue enumeration
    }
    return tokens
}

// Usage
let sentence = "The quick brown fox jumps over the lazy dog. It was a sunny day."

let words = tokenize(sentence, unit: .word)
// ["The", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog", "It", "was", "a", "sunny", "day"]

let sentences = tokenize(sentence, unit: .sentence)
// ["The quick brown fox jumps over the lazy dog. ", "It was a sunny day."]

let paragraphs = tokenize("First paragraph.\n\nSecond paragraph.", unit: .paragraph)
// ["First paragraph.\n\n", "Second paragraph."]

Tokenizing with Language Hint

import NaturalLanguage

func tokenizeWithLanguage(_ text: String, language: NLLanguage) -> [String] {
    let tokenizer = NLTokenizer(unit: .word)
    tokenizer.string = text
    tokenizer.setLanguage(language)

    var tokens: [String] = []
    tokenizer.enumerateTokens(in: text.startIndex..<text.endIndex) { range, attributes in
        let token = String(text[range])
        tokens.append(token)
        return true
    }
    return tokens
}

// Japanese text tokenization
let japaneseTokens = tokenizeWithLanguage("東京は日本の首都です", language: .japanese)
// Properly segments into Japanese word boundaries

// Chinese text tokenization
let chineseTokens = tokenizeWithLanguage("北京是中国的首都", language: .simplifiedChinese)

2. Part-of-Speech Tagging -- NLTagger

Identify the grammatical role of each word: noun, verb, adjective, etc.

import NaturalLanguage

struct TaggedWord {
    let word: String
    let tag: NLTag?
    let tagName: String
}

func tagPartsOfSpeech(_ text: String) -> [TaggedWord] {
    let tagger = NLTagger(tagSchemes: [.lexicalClass])
    tagger.string = text

    var results: [TaggedWord] = []

    tagger.enumerateTags(
        in: text.startIndex..<text.endIndex,
        unit: .word,
        scheme: .lexicalClass,
        options: [.omitWhitespace, .omitPunctuation]
    ) { tag, range in
        let word = String(text[range])
        let tagName: String = switch tag {
        case .noun:              "Noun"
        case .verb:              "Verb"
        case .adjective:         "Adjective"
        case .adverb:            "Adverb"
        case .pronoun:           "Pronoun"
        case .determiner:        "Determiner"
        case .particle:          "Particle"
        case .preposition:       "Preposition"
        case .conjunction:       "Conjunction"
        case .interjection:      "Interjection"
        case .number:            "Number"
        default:                 tag?.rawValue ?? "Unknown"
        }
        results.append(TaggedWord(word: word, tag: tag, tagName: tagName))
        return true
    }

    return results
}

// Usage
let tagged = tagPartsOfSpeech("The quick brown fox jumps over the lazy dog")
for item in tagged {
    print("\(item.word): \(item.tagName)")
}
// The: Determiner
// quick: Adjective
// brown: Adjective
// fox: Noun
// jumps: Verb
// over: Preposition
// the: Determiner
// lazy: Adjective
// dog: Noun

3. Named Entity Recognition

Identify people, places, organizations, and other named entities.

import NaturalLanguage

struct NamedEntity {
    let text: String
    let type: String
}

func extractNamedEntities(_ text: String) -> [NamedEntity] {
    let tagger = NLTagger(tagSchemes: [.nameType])
    tagger.string = text

    var entities: [NamedEntity] = []

    tagger.enumerateTags(
        in: text.startIndex..<text.endIndex,
        unit: .word,
        scheme: .nameType,
        options: [.omitWhitespace, .omitPunctuation, .joinNames]
    ) { tag, range in
        guard let tag else { return true }

        let entityText = String(text[range])
        let entityType: String = switch tag {
        case .personalName:       "Person"
        case .placeName:          "Place"
        case .organizationName:   "Organization"
        default:                  tag.rawValue
        }

        entities.append(NamedEntity(text: entityText, type: entityType))
        return true
    }

    return entities
}

// Usage
let entities = extractNamedEntities("Tim Cook announced the new iPhone at Apple Park in Cupertino.")
// [("Tim Cook", "Person"), ("iPhone", "Organization"), ("Apple Park", "Organization"), ("Cupertino", "Place")]

4. Lemmatization

Reduce words to their base (dictionary) form.

import NaturalLanguage

func lemmatize(_ text: String) -> [(word: String, lemma: String)] {
    let tagger = NLTagger(tagSchemes: [.lemma])
    tagger.string = text

    var results: [(word: String, lemma: String)] = []

    tagger.enumerateTags(
        in: text.startIndex..<text.endIndex,
        unit: .word,
        scheme: .lemma,
        options: [.omitWhitespace, .omitPunctuation]
    ) { tag, range in
        let word = String(text[range])
        let lemma = tag?.rawValue ?? word
        results.append((word: word, lemma: lemma))
        return true
    }

    return results
}

// Usage
let lemmas = lemmatize("The dogs were running quickly through the forests")
// [("dogs", "dog"), ("were", "be"), ("running", "run"), ("quickly", "quickly"), ("forests", "forest")]

5. Language Identification -- NLLanguageRecognizer

Detect the language of a text string or rank probable languages.

import NaturalLanguage

func identifyLanguage(_ text: String) -> NLLanguage? {
    let recognizer = NLLanguageRecognizer()
    recognizer.processString(text)
    return recognizer.dominantLanguage
}

func rankLanguages(_ text: String, maxResults: Int = 5) -> [(NLLanguage, Double)] {
    let recognizer = NLLanguageRecognizer()
    recognizer.processString(text)

    let hypotheses = recognizer.languageHypotheses(withMaximum: maxResults)
    return hypotheses
        .sorted { $0.value > $1.value }
        .map { ($0.key, $0.value) }
}

// Constrain to expected languages for better accuracy
func identifyLanguageConstrained(_ text: String, candidates: [NLLanguage]) -> NLLanguage? {
    let recognizer = NLLanguageRecognizer()
    recognizer.languageConstraints = candidates
    recognizer.processString(text)
    return recognizer.dominantLanguage
}

// Usage
let language = identifyLanguage("Bonjour, comment allez-vous?")
// .french

let ranked = rankLanguages("Das ist ein Test")
// [(.german, 0.98), (.dutch, 0.01), ...]

let constrained = identifyLanguageConstrained(
    "Ciao, come stai?",
    candidates: [.italian, .spanish, .french]
)
// .italian

6. Sentiment Analysis

Determine the emotional tone of text using the built-in sentiment tagger.

import NaturalLanguage

/// Returns a sentiment score between -1.0 (negative) and 1.0 (positive)
func analyzeSentiment(_ text: String) -> Double {
    let tagger = NLTagger(tagSchemes: [.sentimentScore])
    tagger.string = text

    let (sentimentTag, _) = tagger.tag(
        at: text.startIndex,
        unit: .paragraph,
        scheme: .sentimentScore
    )

    return Double(sentimentTag?.rawValue ?? "0") ?? 0.0
}

enum Sentiment: String {
    case positive, negative, neutral
}

func classifySentiment(_ text: String) -> Sentiment {
    let score = analyzeSentiment(text)
    if score > 0.1 { return .positive }
    if score < -0.1 { return .negative }
    return .neutral
}

// Usage
let score1 = analyzeSentiment("I absolutely love this product! It's amazing!")
// ~0.8 (positive)

let score2 = analyzeSentiment("This is terrible. Worst experience ever.")
// ~-0.7 (negative)

let score3 = analyzeSentiment("The meeting is at 3pm in the conference room.")
// ~0.0 (neutral)

let sentiment = classifySentiment("Great job on the presentation!")
// .positive

Sentence-Level Sentiment Analysis

import NaturalLanguage

struct SentenceSentiment {
    let sentence: String
    let score: Double
    let label: Sentiment
}

func analyzeSentimentBySentence(_ text: String) -> [SentenceSentiment] {
    // First, split into sentences
    let sentenceTokenizer = NLTokenizer(unit: .sentence)
    sentenceTokenizer.string = text

    var results: [SentenceSentiment] = []

    sentenceTokenizer.enumerateTokens(in: text.startIndex..<text.endIndex) { range, _ in
        let sentence = String(text[range]).trimmingCharacters(in: .whitespacesAndNewlines)
        guard !sentence.isEmpty else { return true }

        let score = analyzeSentiment(sentence)
        let label: Sentiment
        if score > 0.1 { label = .positive }
        else if score < -0.1 { label = .negative }
        else { label = .neutral }

        results.append(SentenceSentiment(sentence: sentence, score: score, label: label))
        return true
    }

    return results
}

7. Text Embeddings -- NLEmbedding

Compute vector representations of words and sentences for semantic similarity.

Word Embeddings

import NaturalLanguage

func wordSimilarity(_ word1: String, _ word2: String, language: NLLanguage = .english) -> Double? {
    guard let embedding = NLEmbedding.wordEmbedding(for: language) else { return nil }

    // Distance is between 0 (identical) and 2 (opposite)
    let distance = embedding.distance(between: word1, and: word2)

    // Convert to similarity (1.0 = identical, 0.0 = unrelated)
    return 1.0 - (distance / 2.0)
}

func findNearestWords(to word: String, maxResults: Int = 10, language: NLLanguage = .english) -> [(String, Double)] {
    guard let embedding = NLEmbedding.wordEmbedding(for: language) else { return [] }

    var results: [(String, Double)] = []
    embedding.enumerateNeighbors(for: word, maximumCount: maxResults) { neighbor, distance in
        let similarity = 1.0 - (distance / 2.0)
        results.append((neighbor, similarity))
        return true
    }
    return results
}

func wordVector(_ word: String, language: NLLanguage = .english) -> [Double]? {
    guard let embedding = NLEmbedding.wordEmbedding(for: language) else { return nil }
    return embedding.vector(for: word)
}

// Usage
let similarity = wordSimilarity("king", "queen")
// ~0.85 (very similar)

let neighbors = findNearestWords(to: "swift", maxResults: 5)
// [("fast", 0.82), ("quick", 0.78), ("rapid", 0.75), ...]

Sentence Embeddings

import NaturalLanguage

@available(iOS 15.0, *)
func sentenceSimilarity(_ sentence1: String, _ sentence2: String, language: NLLanguage = .english) -> Double? {
    guard let embedding = NLEmbedding.sentenceEmbedding(for: language) else { return nil }
    let distance = embedding.distance(between: sentence1, and: sentence2)
    return 1.0 - (distance / 2.0)
}

@available(iOS 15.0, *)
func findSimilarSentences(to query: String, in candidates: [String], language: NLLanguage = .english) -> [(String, Double)] {
    guard let embedding = NLEmbedding.sentenceEmbedding(for: language) else { return [] }

    return candidates.compactMap { candidate in
        let distance = embedding.distance(between: query, and: candidate)
        let similarity = 1.0 - (distance / 2.0)
        return (candidate, similarity)
    }
    .sorted { $0.1 > $1.1 }
}

// Usage
let sim = sentenceSimilarity(
    "How is the weather today?",
    "What's the forecast for today?"
)
// ~0.85 (semantically similar)

let results = findSimilarSentences(
    to: "I need help with my account",
    in: [
        "How do I reset my password?",
        "Where is the nearest restaurant?",
        "I want to change my profile settings",
        "What time does the store close?"
    ]
)
// Ranked by semantic similarity to the query

8. Custom NLModel with Create ML

Train a custom text classifier and use it with NLTagger.

import NaturalLanguage
import CoreML

// Loading and using a custom NLModel (trained with Create ML)
func loadCustomModel() throws -> NLModel {
    let modelURL = Bundle.main.url(forResource: "CustomTextClassifier", withExtension: "mlmodelc")!
    return try NLModel(contentsOf: modelURL)
}

// Standalone prediction
func classifyText(_ text: String, model: NLModel) -> String? {
    return model.predictedLabel(for: text)
}

// Prediction with confidence scores
func classifyTextWithConfidence(_ text: String, model: NLModel) -> [(String, Double)] {
    let hypotheses = model.predictedLabelHypotheses(for: text, maximumCount: 5)
    return hypotheses
        .sorted { $0.value > $1.value }
        .map { ($0.key, $0.value) }
}

// Using a custom model with NLTagger for per-token classification
func tagWithCustomModel(_ text: String, model: NLModel) -> [(String, String?)] {
    let tagger = NLTagger(tagSchemes: [.nameType])
    tagger.string = text
    tagger.setModels([model], forTagScheme: .nameType)

    var results: [(String, String?)] = []

    tagger.enumerateTags(
        in: text.startIndex..<text.endIndex,
        unit: .word,
        scheme: .nameType,
        options: [.omitWhitespace, .omitPunctuation]
    ) { tag, range in
        let word = String(text[range])
        results.append((word, tag?.rawValue))
        return true
    }

    return results
}

9. Complete Text Analysis Pipeline

A full SwiftUI view combining multiple NaturalLanguage features.

import SwiftUI
import NaturalLanguage

@Observable
final class TextAnalysisViewModel {
    var inputText = ""
    var detectedLanguage = ""
    var sentimentScore = 0.0
    var sentimentLabel = ""
    var wordCount = 0
    var sentenceCount = 0
    var entities: [NamedEntity] = []
    var posTagged: [TaggedWord] = []

    func analyze() {
        guard !inputText.isEmpty else { return }

        // Language detection
        let recognizer = NLLanguageRecognizer()
        recognizer.processString(inputText)
        detectedLanguage = recognizer.dominantLanguage?.rawValue ?? "Unknown"

        // Sentiment
        let sentimentTagger = NLTagger(tagSchemes: [.sentimentScore])
        sentimentTagger.string = inputText
        let (tag, _) = sentimentTagger.tag(at: inputText.startIndex, unit: .paragraph, scheme: .sentimentScore)
        sentimentScore = Double(tag?.rawValue ?? "0") ?? 0.0
        if sentimentScore > 0.1 { sentimentLabel = "Positive" }
        else if sentimentScore < -0.1 { sentimentLabel = "Negative" }
        else { sentimentLabel = "Neutral" }

        // Tokenization counts
        let wordTokenizer = NLTokenizer(unit: .word)
        wordTokenizer.string = inputText
        wordCount = 0
        wordTokenizer.enumerateTokens(in: inputText.startIndex..<inputText.endIndex) { _, _ in
            wordCount += 1
            return true
        }

        let sentenceTokenizer = NLTokenizer(unit: .sentence)
        sentenceTokenizer.string = inputText
        sentenceCount = 0
        sentenceTokenizer.enumerateTokens(in: inputText.startIndex..<inputText.endIndex) { _, _ in
            sentenceCount += 1
            return true
        }

        // Named entities
        entities = extractNamedEntities(inputText)

        // POS tagging (first 20 words)
        posTagged = Array(tagPartsOfSpeech(inputText).prefix(20))
    }
}

struct TextAnalysisView: View {
    @State private var viewModel = TextAnalysisViewModel()

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(alignment: .leading, spacing: 20) {
                    TextEditor(text: $viewModel.inputText)
                        .frame(minHeight: 120)
                        .padding(8)
                        .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))

                    Button("Analyze") {
                        viewModel.analyze()
                    }
                    .buttonStyle(.borderedProminent)
                    .frame(maxWidth: .infinity)

                    if !viewModel.detectedLanguage.isEmpty {
                        GroupBox("Overview") {
                            LabeledContent("Language", value: viewModel.detectedLanguage)
                            LabeledContent("Words", value: "\(viewModel.wordCount)")
                            LabeledContent("Sentences", value: "\(viewModel.sentenceCount)")
                        }

                        GroupBox("Sentiment") {
                            LabeledContent("Score", value: String(format: "%.2f", viewModel.sentimentScore))
                            LabeledContent("Label", value: viewModel.sentimentLabel)
                        }

                        if !viewModel.entities.isEmpty {
                            GroupBox("Named Entities") {
                                ForEach(viewModel.entities, id: \.text) { entity in
                                    LabeledContent(entity.text, value: entity.type)
                                }
                            }
                        }

                        if !viewModel.posTagged.isEmpty {
                            GroupBox("Parts of Speech") {
                                LazyVGrid(columns: [
                                    GridItem(.flexible()),
                                    GridItem(.flexible()),
                                    GridItem(.flexible())
                                ], spacing: 8) {
                                    ForEach(viewModel.posTagged, id: \.word) { item in
                                        VStack(spacing: 4) {
                                            Text(item.word)
                                                .font(.body.bold())
                                            Text(item.tagName)
                                                .font(.caption)
                                                .foregroundStyle(.secondary)
                                        }
                                        .padding(8)
                                        .background(.quaternary, in: RoundedRectangle(cornerRadius: 8))
                                    }
                                }
                            }
                        }
                    }
                }
                .padding()
            }
            .navigationTitle("Text Analysis")
        }
    }
}

Quick Reference

Class

Purpose

NLTokenizer

Split text into words, sentences, or paragraphs

NLTagger

Tag tokens with POS, NER, lemma, sentiment

NLLanguageRecognizer

Detect the language of a string

NLEmbedding

Word and sentence vector embeddings

NLModel

Load and use custom Create ML text models

Tag Scheme

Tags Produced

.lexicalClass

Noun, Verb, Adjective, Adverb, Pronoun, Determiner, etc.

.nameType

PersonalName, PlaceName, OrganizationName

.lemma

Base/dictionary form of each word

.sentimentScore

Floating-point score from -1.0 to 1.0

.language

Per-token language identification

Embedding Type

Available From

Dimensions

Word embedding

iOS 13+

~128-300 dimensions

Sentence embedding

iOS 15+

~512 dimensions

---

# Sound Analysis
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-ml-sound-analysis.html

AI and Machine Learning · Reference guideSound AnalysisRepository guidance for Sound Analysis. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Context
Choosing the analyzer
Audio file analysis
Live audio stream analysis
Custom sound classifiers
Privacy and UX
Verification checklist
Source anchor

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Prepare audio stream
↓2
Configure classification request
↓3
Consume classification results
↓4
Handle stream termination

02 / ArchitectureResponsibility boundariesBoundary 1
Audio sourceBoundary 2
Sound analyzerBoundary 3
Classification consumerConnected responsibilities, not a required class hierarchy or an execution trace.
Context

Load this when an app needs sound classification, audio-event detection, built-in sound labels, custom Core ML sound classifiers, live microphone sound analysis, file-based audio tagging, or sound timestamps.

Use Sound Analysis for non-speech sounds. Use Speech for spoken-word transcription, ShazamKit for music recognition/matching, AVFoundation for capture/playback, and Core ML when the app owns a model that is not routed through 
SNClassifySoundRequest
.

Choosing the analyzer

Need

Use

Analyze an existing audio file

SNAudioFileAnalyzer

Analyze microphone or live stream buffers

SNAudioStreamAnalyzer

Use Apple's built-in sound classifier

SNClassifySoundRequest(classifierIdentifier:)

Use a custom sound classifier

SNClassifySoundRequest(mlModel:)

Receive results

SNResultsObserving

Audio file analysis

import Foundation
import SoundAnalysis

final class SoundFileClassifier {
    private var observer: SoundResultsObserver?

    func classifyFile(at url: URL) throws {
        let request = try SNClassifySoundRequest(classifierIdentifier: .version1)
        let analyzer = try SNAudioFileAnalyzer(url: url)

        let observer = SoundResultsObserver()
        self.observer = observer

        try analyzer.add(request, withObserver: observer)
        analyzer.analyze()
    }
}

final class SoundResultsObserver: NSObject, SNResultsObserving {
    func request(_ request: SNRequest, didProduce result: SNResult) {
        guard let result = result as? SNClassificationResult,
              let best = result.classifications.first else {
            return
        }

        let time = result.timeRange.start.seconds
        print("Sound at \(time): \(best.identifier), confidence \(best.confidence)")
    }

    func request(_ request: SNRequest, didFailWithError error: Error) {
        print("Sound analysis failed: \(error)")
    }

    func requestDidComplete(_ request: SNRequest) {
        print("Sound analysis complete")
    }
}

Important: keep a strong reference to the observer. Sound analyzers do not keep observers alive for you.

Live audio stream analysis

For microphone analysis:

Request microphone permission with clear purpose text.
Configure 
AVAudioEngine
.
Create 
SNAudioStreamAnalyzer
 with the input format.
Add 
SNClassifySoundRequest
.
Feed audio buffers from the audio tap.
Stop the engine and remove taps when the feature ends.

Rules:

Keep audio work off the main actor.
Avoid retaining raw audio unless the product explicitly needs it.
Debounce classifications before updating UI.
Treat low-confidence labels as suggestions, not facts.

Custom sound classifiers

Use a custom Core ML sound classifier when built-in labels are not enough.

Rules:

Train with representative audio from the real environment.
Keep train/test splits clean.
Validate noisy rooms, silence, overlapping sounds, and device microphones.
Version the model and labels together.
Keep confidence thresholds product-specific.

Privacy and UX

Explain microphone use before requesting permission.
Show when live listening is active.
Provide a clear stop control.
Avoid background listening unless the product, entitlement, and policy story are explicit.
Do not upload raw audio by default.
Tell users when results are approximate.

Verification checklist

[ ] Microphone permission states are tested.
[ ] File-based and stream-based paths are tested separately.
[ ] Observer lifetime is strong and documented.
[ ] Confidence threshold is defined.
[ ] Silence, noise, and overlapping sounds are tested.
[ ] Audio engine stop/removal is tested.
[ ] Memory and battery are measured for long sessions.
[ ] Privacy copy matches the audio data path.

Source anchor

Use only to verify API signatures and availability: 
https://developer.apple.com/documentation/soundanalysis

---

# Speech Framework -- Complete Guide for Speech Recognition…
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-ml-speech.html

AI and Machine Learning · Reference guideSpeech Framework -- Complete Guide for Speech Recognition and TranscriptionRepository guidance for Speech. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Overview
1. Setup and Authorization
2. Creating a Speech Recognizer
3. On-Device vs Server-Based Recognition
4. Transcribing Audio Files -- SFSpeechURLRecognitionRequest

Transcription with Confidence Scores and Alternatives

5. Live Audio Transcription -- SFSpeechAudioBufferRecognitionRequest
6. Complete Live Transcription SwiftUI View
7. Language Selection
8. Monitoring Recognizer Availability
9. iOS 17+ Improvements

Handling Custom Vocabulary

10. Performance Considerations

Restarting for Long Sessions

Quick Reference

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Request required permissions
↓2
Start speech recognition
↓3
Update transcript state
↓4
Handle cancellation and errors

02 / ArchitectureResponsibility boundariesBoundary 1
Audio captureBoundary 2
Recognition sessionBoundary 3
Transcript UIConnected responsibilities, not a required class hierarchy or an execution trace.
Overview

The Speech framework provides on-device and server-assisted speech recognition for converting audio to text. It supports live microphone transcription, audio file transcription, multiple languages, confidence scores, and alternative transcriptions. iOS 17+ introduced improvements for on-device recognition quality and reduced latency. Every code example below compiles and follows production best practices.

1. Setup and Authorization

Speech recognition requires explicit user permission. Add both keys to your Info.plist:

NSSpeechRecognitionUsageDescription
 -- why you need speech recognition
NSMicrophoneUsageDescription
 -- why you need microphone access (for live audio)

import Speech

func requestSpeechAuthorization() async -> SFSpeechRecognizerAuthorizationStatus {
    await withCheckedContinuation { continuation in
        SFSpeechRecognizer.requestAuthorization { status in
            continuation.resume(returning: status)
        }
    }
}

func checkAuthorizationStatus() -> Bool {
    switch SFSpeechRecognizer.authorizationStatus() {
    case .authorized:
        return true
    case .denied:
        print("User denied speech recognition access")
        return false
    case .restricted:
        print("Speech recognition restricted on this device")
        return false
    case .notDetermined:
        print("Speech recognition permission not yet requested")
        return false
    @unknown default:
        return false
    }
}

2. Creating a Speech Recognizer

import Speech

// Default locale (user's device locale)
let defaultRecognizer = SFSpeechRecognizer()

// Specific language
let englishRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))
let japaneseRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "ja-JP"))
let spanishRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "es-ES"))

// Check availability
func isRecognizerAvailable(for locale: Locale) -> Bool {
    guard let recognizer = SFSpeechRecognizer(locale: locale) else { return false }
    return recognizer.isAvailable
}

// List all supported locales
func supportedLocales() -> Set<Locale> {
    return SFSpeechRecognizer.supportedLocales()
}

3. On-Device vs Server-Based Recognition

import Speech

func configureRecognizer() -> SFSpeechRecognizer? {
    guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US")) else {
        return nil
    }

    // Check if on-device recognition is available
    if recognizer.supportsOnDeviceRecognition {
        print("On-device recognition supported -- works offline")
    } else {
        print("This locale requires server-based recognition")
    }

    return recognizer
}

// When creating a recognition request, you can require on-device processing
func createOnDeviceRequest() -> SFSpeechAudioBufferRecognitionRequest {
    let request = SFSpeechAudioBufferRecognitionRequest()

    // Force on-device recognition (fails if not available)
    request.requiresOnDeviceRecognition = true

    // Additional configuration
    request.shouldReportPartialResults = true
    request.addsPunctuation = true  // iOS 16+

    return request
}

4. Transcribing Audio Files -- SFSpeechURLRecognitionRequest

import Speech

func transcribeAudioFile(at url: URL, locale: Locale = Locale(identifier: "en-US")) async throws -> String {
    guard let recognizer = SFSpeechRecognizer(locale: locale),
          recognizer.isAvailable else {
        throw SpeechError.recognizerUnavailable
    }

    let request = SFSpeechURLRecognitionRequest(url: url)
    request.shouldReportPartialResults = false
    request.addsPunctuation = true

    if recognizer.supportsOnDeviceRecognition {
        request.requiresOnDeviceRecognition = true
    }

    return try await withCheckedThrowingContinuation { continuation in
        recognizer.recognitionTask(with: request) { result, error in
            if let error {
                continuation.resume(throwing: error)
                return
            }

            guard let result, result.isFinal else { return }
            continuation.resume(returning: result.bestTranscription.formattedString)
        }
    }
}

enum SpeechError: LocalizedError {
    case recognizerUnavailable
    case audioEngineError
    case notAuthorized

    var errorDescription: String? {
        switch self {
        case .recognizerUnavailable:
            return "Speech recognizer is not available for the selected language."
        case .audioEngineError:
            return "Audio engine failed to start."
        case .notAuthorized:
            return "Speech recognition is not authorized."
        }
    }
}

Transcription with Confidence Scores and Alternatives

import Speech

struct TranscriptionResult {
    let text: String
    let confidence: Float
    let segments: [SegmentDetail]
    let alternatives: [String]
}

struct SegmentDetail {
    let text: String
    let confidence: Float
    let timestamp: TimeInterval
    let duration: TimeInterval
}

func transcribeWithDetails(at url: URL) async throws -> TranscriptionResult {
    guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US")),
          recognizer.isAvailable else {
        throw SpeechError.recognizerUnavailable
    }

    let request = SFSpeechURLRecognitionRequest(url: url)
    request.shouldReportPartialResults = false
    request.addsPunctuation = true

    return try await withCheckedThrowingContinuation { continuation in
        recognizer.recognitionTask(with: request) { result, error in
            if let error {
                continuation.resume(throwing: error)
                return
            }

            guard let result, result.isFinal else { return }

            let bestTranscription = result.bestTranscription

            // Extract per-segment details
            let segments = bestTranscription.segments.map { segment in
                SegmentDetail(
                    text: segment.substring,
                    confidence: segment.confidence,
                    timestamp: segment.timestamp,
                    duration: segment.duration
                )
            }

            // Overall confidence (average of segment confidences)
            let totalConfidence = segments.isEmpty ? 0 :
                segments.reduce(Float(0)) { $0 + $1.confidence } / Float(segments.count)

            // Alternative transcriptions
            let alternatives = result.transcriptions.dropFirst().map { $0.formattedString }

            let transcriptionResult = TranscriptionResult(
                text: bestTranscription.formattedString,
                confidence: totalConfidence,
                segments: segments,
                alternatives: Array(alternatives)
            )

            continuation.resume(returning: transcriptionResult)
        }
    }
}

5. Live Audio Transcription -- SFSpeechAudioBufferRecognitionRequest

Real-time speech-to-text using the device microphone with AVAudioEngine.

import Speech
import AVFoundation

@Observable
@MainActor
final class LiveTranscriptionManager {
    var transcribedText = ""
    var isRecording = false
    var errorMessage: String?

    private var audioEngine: AVAudioEngine?
    private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
    private var recognitionTask: SFSpeechRecognitionTask?
    private let speechRecognizer: SFSpeechRecognizer?

    init(locale: Locale = Locale(identifier: "en-US")) {
        self.speechRecognizer = SFSpeechRecognizer(locale: locale)
    }

    func startRecording() async {
        // Check authorization
        let authStatus = await requestSpeechAuthorization()
        guard authStatus == .authorized else {
            errorMessage = "Speech recognition not authorized"
            return
        }

        guard let speechRecognizer, speechRecognizer.isAvailable else {
            errorMessage = "Speech recognizer not available"
            return
        }

        // Stop any existing session
        stopRecording()

        // Configure audio session
        let audioSession = AVAudioSession.sharedInstance()
        do {
            try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers)
            try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
        } catch {
            errorMessage = "Audio session setup failed: \(error.localizedDescription)"
            return
        }

        // Create recognition request
        let request = SFSpeechAudioBufferRecognitionRequest()
        request.shouldReportPartialResults = true
        request.addsPunctuation = true

        if speechRecognizer.supportsOnDeviceRecognition {
            request.requiresOnDeviceRecognition = true
        }

        self.recognitionRequest = request

        // Create audio engine
        let engine = AVAudioEngine()
        self.audioEngine = engine

        let inputNode = engine.inputNode
        let recordingFormat = inputNode.outputFormat(forBus: 0)

        // Install a tap on the audio input
        inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, _ in
            request.append(buffer)
        }

        // Start the audio engine
        do {
            engine.prepare()
            try engine.start()
        } catch {
            errorMessage = "Audio engine failed to start: \(error.localizedDescription)"
            return
        }

        isRecording = true
        transcribedText = ""

        // Start recognition task
        recognitionTask = speechRecognizer.recognitionTask(with: request) { [weak self] result, error in
            Task { @MainActor in
                guard let self else { return }

                if let result {
                    self.transcribedText = result.bestTranscription.formattedString
                }

                if let error {
                    self.errorMessage = error.localizedDescription
                    self.stopRecording()
                }

                if result?.isFinal == true {
                    self.stopRecording()
                }
            }
        }
    }

    func stopRecording() {
        audioEngine?.stop()
        audioEngine?.inputNode.removeTap(onBus: 0)
        audioEngine = nil

        recognitionRequest?.endAudio()
        recognitionRequest = nil

        recognitionTask?.cancel()
        recognitionTask = nil

        isRecording = false
    }

    private func requestSpeechAuthorization() async -> SFSpeechRecognizerAuthorizationStatus {
        await withCheckedContinuation { continuation in
            SFSpeechRecognizer.requestAuthorization { status in
                continuation.resume(returning: status)
            }
        }
    }
}

6. Complete Live Transcription SwiftUI View

import SwiftUI
import Speech

struct LiveTranscriptionView: View {
    @State private var manager = LiveTranscriptionManager()

    var body: some View {
        NavigationStack {
            VStack(spacing: 24) {
                // Transcribed text display
                ScrollView {
                    Text(manager.transcribedText.isEmpty ? "Tap the microphone to start speaking..." : manager.transcribedText)
                        .font(.body)
                        .foregroundStyle(manager.transcribedText.isEmpty ? .secondary : .primary)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .padding()
                }
                .frame(maxHeight: .infinity)
                .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))

                // Recording indicator
                if manager.isRecording {
                    HStack(spacing: 8) {
                        Circle()
                            .fill(.red)
                            .frame(width: 10, height: 10)
                            .opacity(manager.isRecording ? 1 : 0)
                            .animation(.easeInOut(duration: 0.5).repeatForever(), value: manager.isRecording)
                        Text("Listening...")
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                    }
                }

                // Error display
                if let error = manager.errorMessage {
                    Text(error)
                        .font(.caption)
                        .foregroundStyle(.red)
                        .padding(.horizontal)
                }

                // Record button
                Button {
                    Task {
                        if manager.isRecording {
                            manager.stopRecording()
                        } else {
                            await manager.startRecording()
                        }
                    }
                } label: {
                    Image(systemName: manager.isRecording ? "stop.circle.fill" : "mic.circle.fill")
                        .font(.system(size: 64))
                        .foregroundStyle(manager.isRecording ? .red : .accentColor)
                        .symbolEffect(.bounce, value: manager.isRecording)
                }
                .padding(.bottom, 32)

                // Copy button
                if !manager.transcribedText.isEmpty {
                    Button {
                        UIPasteboard.general.string = manager.transcribedText
                    } label: {
                        Label("Copy Transcription", systemImage: "doc.on.doc")
                    }
                    .buttonStyle(.bordered)
                }
            }
            .padding()
            .navigationTitle("Live Transcription")
        }
    }
}

7. Language Selection

import Speech
import SwiftUI

struct LanguagePickerView: View {
    @State private var selectedLocale: Locale = Locale(identifier: "en-US")
    @State private var supportedLocales: [Locale] = []

    var body: some View {
        List {
            Section("Select Language") {
                ForEach(supportedLocales, id: \.identifier) { locale in
                    Button {
                        selectedLocale = locale
                    } label: {
                        HStack {
                            VStack(alignment: .leading) {
                                Text(locale.localizedString(forIdentifier: locale.identifier) ?? locale.identifier)
                                    .font(.body)
                                Text(locale.identifier)
                                    .font(.caption)
                                    .foregroundStyle(.secondary)
                            }
                            Spacer()

                            if locale.identifier == selectedLocale.identifier {
                                Image(systemName: "checkmark")
                                    .foregroundStyle(.accentColor)
                            }

                            // Show on-device badge
                            if let recognizer = SFSpeechRecognizer(locale: locale),
                               recognizer.supportsOnDeviceRecognition {
                                Text("On-Device")
                                    .font(.caption2)
                                    .padding(.horizontal, 6)
                                    .padding(.vertical, 2)
                                    .background(.green.opacity(0.15), in: Capsule())
                                    .foregroundStyle(.green)
                            }
                        }
                    }
                    .tint(.primary)
                }
            }
        }
        .task {
            supportedLocales = SFSpeechRecognizer.supportedLocales()
                .sorted { ($0.identifier) < ($1.identifier) }
        }
    }
}

8. Monitoring Recognizer Availability

import Speech

class SpeechRecognizerMonitor: NSObject, SFSpeechRecognizerDelegate {
    private let recognizer: SFSpeechRecognizer
    var onAvailabilityChanged: ((Bool) -> Void)?

    init(locale: Locale = Locale(identifier: "en-US")) {
        self.recognizer = SFSpeechRecognizer(locale: locale) ?? SFSpeechRecognizer()!
        super.init()
        self.recognizer.delegate = self
    }

    var isAvailable: Bool {
        recognizer.isAvailable
    }

    func speechRecognizer(_ speechRecognizer: SFSpeechRecognizer, availabilityDidChange available: Bool) {
        onAvailabilityChanged?(available)
    }
}

9. iOS 17+ Improvements

iOS 17 introduced several improvements to speech recognition:

import Speech

@available(iOS 17.0, *)
func modernTranscription(at url: URL) async throws -> String {
    guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US")),
          recognizer.isAvailable else {
        throw SpeechError.recognizerUnavailable
    }

    let request = SFSpeechURLRecognitionRequest(url: url)

    // iOS 17+: Improved on-device models with better accuracy
    request.requiresOnDeviceRecognition = true

    // iOS 16+: Automatic punctuation
    request.addsPunctuation = true

    // Task-level customization
    request.shouldReportPartialResults = false

    // iOS 17+: Use the new async recognition API
    let result = try await recognizer.recognitionTask(with: request)
    return result.bestTranscription.formattedString
}

Handling Custom Vocabulary

import Speech

func transcribeWithCustomVocabulary(at url: URL, customPhrases: [String]) async throws -> String {
    guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US")),
          recognizer.isAvailable else {
        throw SpeechError.recognizerUnavailable
    }

    let request = SFSpeechURLRecognitionRequest(url: url)
    request.shouldReportPartialResults = false
    request.addsPunctuation = true

    // Add domain-specific vocabulary to improve recognition
    request.contextualStrings = customPhrases

    return try await withCheckedThrowingContinuation { continuation in
        recognizer.recognitionTask(with: request) { result, error in
            if let error {
                continuation.resume(throwing: error)
                return
            }
            guard let result, result.isFinal else { return }
            continuation.resume(returning: result.bestTranscription.formattedString)
        }
    }
}

// Usage
// let text = try await transcribeWithCustomVocabulary(
//     at: audioURL,
//     customPhrases: ["SwiftUI", "CoreML", "Xcode", "WWDC", "visionOS"]
// )

10. Performance Considerations

Consideration

Recommendation

On-device vs server

Prefer on-device for privacy and offline; server for broader language support

Buffer size

1024 samples is a good default for real-time; increase for batch

Audio format

16kHz mono is optimal for speech recognition

Session duration

Apple limits recognition to ~1 minute per task; restart for longer sessions

Battery impact

On-device recognition uses less battery than server-based

Memory

Audio buffers accumulate; call endAudio() promptly when done

Background

Speech recognition is not available in the background

Rate limiting

Apple throttles server-based requests per device per day

Restarting for Long Sessions

import Speech
import AVFoundation

@Observable
@MainActor
final class ContinuousTranscriptionManager {
    var fullTranscript = ""
    var isRecording = false

    private var audioEngine: AVAudioEngine?
    private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
    private var recognitionTask: SFSpeechRecognitionTask?
    private let speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))

    /// Restarts the recognition task to handle Apple's ~1 minute limit
    func restartRecognition() async {
        guard isRecording else { return }

        // End current request
        recognitionRequest?.endAudio()
        recognitionTask?.cancel()

        // Create a new request and task
        let request = SFSpeechAudioBufferRecognitionRequest()
        request.shouldReportPartialResults = true
        request.addsPunctuation = true

        if speechRecognizer?.supportsOnDeviceRecognition == true {
            request.requiresOnDeviceRecognition = true
        }

        self.recognitionRequest = request

        // Reinstall the audio tap
        let inputNode = audioEngine?.inputNode
        let format = inputNode?.outputFormat(forBus: 0)

        inputNode?.removeTap(onBus: 0)
        inputNode?.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in
            request.append(buffer)
        }

        // Start new recognition task
        recognitionTask = speechRecognizer?.recognitionTask(with: request) { [weak self] result, error in
            Task { @MainActor in
                guard let self else { return }
                if let result {
                    // Append final results to full transcript
                    if result.isFinal {
                        self.fullTranscript += result.bestTranscription.formattedString + " "
                        // Restart for continuous recognition
                        await self.restartRecognition()
                    }
                }
            }
        }
    }
}

Quick Reference

Class

Purpose

SFSpeechRecognizer

Main class for speech recognition; configure locale and check availability

SFSpeechAudioBufferRecognitionRequest

Recognition request fed with live audio buffers

SFSpeechURLRecognitionRequest

Recognition request for audio files on disk

SFSpeechRecognitionTask

A running recognition operation; cancel or monitor

SFSpeechRecognitionResult

Contains transcriptions, confidence, and finality

SFTranscription

A single transcription with formatted text and segments

SFTranscriptionSegment

Per-word detail: text, confidence, timestamp, duration

Property

Type

Purpose

shouldReportPartialResults

Bool

Emit intermediate results during recognition

requiresOnDeviceRecognition

Bool

Force on-device processing (no network)

addsPunctuation

Bool

Automatic punctuation insertion (iOS 16+)

contextualStrings

[String]

Domain-specific vocabulary hints

supportsOnDeviceRecognition

Bool

Whether the locale supports offline recognition

---

# Translation
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-ml-translation.html

AI and Machine Learning · Reference guideTranslationRepository guidance for Translation. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Context
Choosing the API
System translation UI
Custom translation flow
Language availability
Product and privacy rules
Verification checklist
Source anchor

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Select source and target languages
↓2
Check translation readiness
↓3
Translate scoped text
↓4
Handle unavailable language support

02 / ArchitectureResponsibility boundariesBoundary 1
Source textBoundary 2
Translation sessionBoundary 3
Translated presentationConnected responsibilities, not a required class hierarchy or an execution trace.
Context

Load this when an app needs in-app text translation, system translation UI, custom translation flows, language-pair availability checks, model download handling, or multilingual user-generated content workflows.

Use the Translation framework for text translation. Use Natural Language for text analysis, Speech for speech-to-text, and Foundation Models only when translation is part of a broader generative workflow that still needs review.

Choosing the API

Need

Use

Simple user-facing translation popover

translationPresentation(...)

Replace selected text with a translation

translationPresentation(..., replacementAction:)

Custom UI with one or more translated strings

translationTask(...) and TranslationSession

Batch translation

TranslationSession batch APIs

Check if languages are supported/installed

LanguageAvailability

System translation UI

Use the system presentation when the product needs a familiar, lightweight translation affordance and does not need custom layout.

import SwiftUI
import Translation

struct MessageRow: View {
    let text: String
    @State private var showingTranslation = false

    var body: some View {
        Text(text)
            .textSelection(.enabled)
            .contextMenu {
                Button("Translate") {
                    showingTranslation = true
                }
            }
            .translationPresentation(
                isPresented: $showingTranslation,
                text: text
            )
    }
}

Rules:

Attach the presentation to the view that owns the source text.
Do not present translation for empty or already-redacted private text.
Keep the original text available so users can compare.

Custom translation flow

Use a custom session when the app owns the layout, wants batch translation, or needs to store a translated draft.

import SwiftUI
import Translation

struct TranslationDraftView: View {
    let sourceText: String
    let source: Locale.Language?
    let target: Locale.Language?

    @State private var translatedText = ""
    @State private var errorMessage: String?

    var body: some View {
        VStack(alignment: .leading) {
            Text(sourceText)
            Divider()
            Text(translatedText.isEmpty ? "Translation unavailable" : translatedText)
        }
        .translationTask(source: source, target: target) { session in
            do {
                let response = try await session.translate(sourceText)
                translatedText = response.targetText
            } catch is CancellationError {
                return
            } catch {
                errorMessage = error.localizedDescription
            }
        }
    }
}

Rules:

Keep translation work tied to view/task lifetime.
Cancel or let SwiftUI cancel translation when inputs change.
Show source and target language when user trust matters.
Do not silently replace legal, medical, financial, or safety-critical text.

Language availability

Check availability before offering a custom translation control.

import Translation

func translationIsSupported(
    from source: Locale.Language,
    to target: Locale.Language
) async -> Bool {
    let availability = LanguageAvailability()
    let status = await availability.status(from: source, to: target)

    switch status {
    case .installed, .supported:
        return true
    case .unsupported:
        return false
    @unknown default:
        return false
    }
}

Rules:

supported
 may still require a model download before use.
Treat unsupported language pairs as a normal UI state.
Test source-language auto-detection separately from explicit source language.

Product and privacy rules

Explain when translated text is generated by the system.
Let users inspect the original text.
Do not use translation as a hidden moderation, compliance, or policy decision.
Avoid storing translations unless the product needs persistence.
If stored, mark the translated language and source text version.
Handle mixed-language text, names, code snippets, and domain terms carefully.

Verification checklist

[ ] Source and target languages are explicit or intentionally auto-detected.
[ ] 
LanguageAvailability
 is checked for custom flows.
[ ] Unsupported language pairs have a clear fallback.
[ ] Model download or permission prompts are not surprising.
[ ] Cancellation is tested when text/language changes.
[ ] Original text remains reachable.
[ ] Localized UI labels fit at accessibility text sizes.
[ ] Privacy copy matches the actual data path.

Source anchor

Use only to verify API signatures and availability: 
https://developer.apple.com/documentation/translation

---

# Vision Framework -- Complete Guide for Image Analysis and…
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-ml-vision.html

AI and Machine Learning · Reference guideVision Framework -- Complete Guide for Image Analysis and Computer VisionRepository guidance for Vision. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Overview
1. Performing Vision Requests
2. Text Recognition (OCR) -- VNRecognizeTextRequest

Accurate vs Fast Recognition
Getting Bounding Boxes for Recognized Text

3. Face Detection and Landmarks

Detect Face Rectangles
Detect Face Landmarks (Eyes, Nose, Mouth, etc.)

4. Barcode and QR Code Detection
5. Person Segmentation (Background Removal)
6. Object Tracking in Video
7. Image Classification
8. VisionKit -- DataScannerViewController (iOS 16+)
9. ImageAnalyzer and ImageAnalysisInteraction (iOS 16+)
10. Complete Multi-Request Pipeline
Quick Reference

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Prepare image orientation
↓2
Configure a vision request
↓3
Perform image analysis
↓4
Map observations to UI

02 / ArchitectureResponsibility boundariesBoundary 1
Image inputBoundary 2
Vision requestBoundary 3
ObservationsConnected responsibilities, not a required class hierarchy or an execution trace.
Overview

The Vision framework provides high-performance image analysis for text recognition (OCR), face detection, barcode scanning, object tracking, person segmentation, and image classification. All processing runs on-device using optimized CoreML models managed by the system. Every code example below compiles and follows production best practices.

1. Performing Vision Requests

All Vision requests follow the same pattern: create a request handler, configure requests, and perform them.

import Vision
import UIKit

// From CGImage
func performRequest(on image: UIImage, request: VNRequest) throws {
    guard let cgImage = image.cgImage else { return }
    let handler = VNImageRequestHandler(cgImage: cgImage, orientation: .up, options: [:])
    try handler.perform([request])
}

// From CIImage
func performRequest(on ciImage: CIImage, request: VNRequest) throws {
    let handler = VNImageRequestHandler(ciImage: ciImage, options: [:])
    try handler.perform([request])
}

// From CVPixelBuffer (camera frames)
func performRequest(on pixelBuffer: CVPixelBuffer, request: VNRequest) throws {
    let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: .right, options: [:])
    try handler.perform([request])
}

// From file URL
func performRequest(at url: URL, request: VNRequest) throws {
    let handler = VNImageRequestHandler(url: url, options: [:])
    try handler.perform([request])
}

2. Text Recognition (OCR) -- VNRecognizeTextRequest

Accurate vs Fast Recognition

import Vision
import UIKit

func recognizeText(in image: UIImage, accurate: Bool = true) async throws -> [String] {
    guard let cgImage = image.cgImage else { return [] }

    return try await withCheckedThrowingContinuation { continuation in
        let request = VNRecognizeTextRequest { request, error in
            if let error {
                continuation.resume(throwing: error)
                return
            }

            let results = (request.results as? [VNRecognizedTextObservation]) ?? []
            let strings = results.compactMap { observation in
                observation.topCandidates(1).first?.string
            }
            continuation.resume(returning: strings)
        }

        // .accurate -- slower but higher quality, supports language correction
        // .fast     -- faster but lower accuracy, no language correction
        request.recognitionLevel = accurate ? .accurate : .fast

        // Supported languages (call supportedRecognitionLanguages() to list all)
        request.recognitionLanguages = ["en-US", "fr-FR", "de-DE"]

        // Enable automatic language correction
        request.usesLanguageCorrection = true

        // Minimum text height relative to image height (0.0 to 1.0)
        request.minimumTextHeight = 0.01

        // Limit to specific character set (useful for numbers/codes)
        // request.customWords = ["specific", "domain", "terms"]

        let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
        do {
            try handler.perform([request])
        } catch {
            continuation.resume(throwing: error)
        }
    }
}

Getting Bounding Boxes for Recognized Text

import Vision
import UIKit

struct RecognizedTextBlock {
    let text: String
    let confidence: Float
    let boundingBox: CGRect  // normalized coordinates (0,0) at bottom-left
}

func recognizeTextWithLocations(in image: UIImage) async throws -> [RecognizedTextBlock] {
    guard let cgImage = image.cgImage else { return [] }

    return try await withCheckedThrowingContinuation { continuation in
        let request = VNRecognizeTextRequest { request, error in
            if let error {
                continuation.resume(throwing: error)
                return
            }

            let results = (request.results as? [VNRecognizedTextObservation]) ?? []
            let blocks = results.compactMap { observation -> RecognizedTextBlock? in
                guard let candidate = observation.topCandidates(1).first else { return nil }
                return RecognizedTextBlock(
                    text: candidate.string,
                    confidence: candidate.confidence,
                    boundingBox: observation.boundingBox
                )
            }
            continuation.resume(returning: blocks)
        }

        request.recognitionLevel = .accurate
        request.usesLanguageCorrection = true

        let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
        do {
            try handler.perform([request])
        } catch {
            continuation.resume(throwing: error)
        }
    }
}

3. Face Detection and Landmarks

Detect Face Rectangles

import Vision
import UIKit

func detectFaces(in image: UIImage) async throws -> [VNFaceObservation] {
    guard let cgImage = image.cgImage else { return [] }

    return try await withCheckedThrowingContinuation { continuation in
        let request = VNDetectFaceRectanglesRequest { request, error in
            if let error {
                continuation.resume(throwing: error)
                return
            }
            let faces = (request.results as? [VNFaceObservation]) ?? []
            continuation.resume(returning: faces)
        }

        let handler = VNImageRequestHandler(cgImage: cgImage, orientation: .up, options: [:])
        do {
            try handler.perform([request])
        } catch {
            continuation.resume(throwing: error)
        }
    }
}

Detect Face Landmarks (Eyes, Nose, Mouth, etc.)

import Vision
import UIKit

struct FaceDetail {
    let boundingBox: CGRect
    let roll: NSNumber?
    let yaw: NSNumber?
    let leftEye: [CGPoint]?
    let rightEye: [CGPoint]?
    let nose: [CGPoint]?
    let outerLips: [CGPoint]?
}

func detectFaceLandmarks(in image: UIImage) async throws -> [FaceDetail] {
    guard let cgImage = image.cgImage else { return [] }

    return try await withCheckedThrowingContinuation { continuation in
        let request = VNDetectFaceLandmarksRequest { request, error in
            if let error {
                continuation.resume(throwing: error)
                return
            }

            let faces = (request.results as? [VNFaceObservation]) ?? []
            let details = faces.map { face in
                let landmarks = face.landmarks
                return FaceDetail(
                    boundingBox: face.boundingBox,
                    roll: face.roll,
                    yaw: face.yaw,
                    leftEye: landmarks?.leftEye?.normalizedPoints.map { CGPoint(x: $0.x, y: $0.y) },
                    rightEye: landmarks?.rightEye?.normalizedPoints.map { CGPoint(x: $0.x, y: $0.y) },
                    nose: landmarks?.nose?.normalizedPoints.map { CGPoint(x: $0.x, y: $0.y) },
                    outerLips: landmarks?.outerLips?.normalizedPoints.map { CGPoint(x: $0.x, y: $0.y) }
                )
            }
            continuation.resume(returning: details)
        }

        let handler = VNImageRequestHandler(cgImage: cgImage, orientation: .up, options: [:])
        do {
            try handler.perform([request])
        } catch {
            continuation.resume(throwing: error)
        }
    }
}

4. Barcode and QR Code Detection

import Vision
import UIKit

struct DetectedBarcode {
    let payload: String
    let symbology: VNBarcodeSymbology
    let boundingBox: CGRect
}

func detectBarcodes(in image: UIImage) async throws -> [DetectedBarcode] {
    guard let cgImage = image.cgImage else { return [] }

    return try await withCheckedThrowingContinuation { continuation in
        let request = VNDetectBarcodesRequest { request, error in
            if let error {
                continuation.resume(throwing: error)
                return
            }

            let results = (request.results as? [VNBarcodeObservation]) ?? []
            let barcodes = results.compactMap { observation -> DetectedBarcode? in
                guard let payload = observation.payloadStringValue else { return nil }
                return DetectedBarcode(
                    payload: payload,
                    symbology: observation.symbology,
                    boundingBox: observation.boundingBox
                )
            }
            continuation.resume(returning: barcodes)
        }

        // Limit to specific symbologies for better performance
        request.symbologies = [
            .qr,
            .ean13,
            .ean8,
            .code128,
            .code39,
            .upce,
            .pdf417,
            .aztec,
            .dataMatrix
        ]

        let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
        do {
            try handler.perform([request])
        } catch {
            continuation.resume(throwing: error)
        }
    }
}

5. Person Segmentation (Background Removal)

import Vision
import UIKit
import CoreImage
import CoreImage.CIFilterBuiltins

@available(iOS 15.0, *)
func removeBackground(from image: UIImage) async throws -> UIImage? {
    guard let cgImage = image.cgImage else { return nil }

    return try await withCheckedThrowingContinuation { continuation in
        let request = VNGeneratePersonSegmentationRequest { request, error in
            if let error {
                continuation.resume(throwing: error)
                return
            }

            guard let result = (request.results as? [VNPixelBufferObservation])?.first else {
                continuation.resume(returning: nil)
                return
            }

            let maskImage = CIImage(cvPixelBuffer: result.pixelBuffer)
            let originalImage = CIImage(cgImage: cgImage)

            // Scale mask to match original image size
            let scaleX = originalImage.extent.width / maskImage.extent.width
            let scaleY = originalImage.extent.height / maskImage.extent.height
            let scaledMask = maskImage.transformed(by: CGAffineTransform(scaleX: scaleX, y: scaleY))

            // Apply mask using CIBlendWithMask
            let filter = CIFilter.blendWithMask()
            filter.inputImage = originalImage
            filter.backgroundImage = CIImage(color: .clear).cropped(to: originalImage.extent)
            filter.maskImage = scaledMask

            guard let outputCIImage = filter.outputImage else {
                continuation.resume(returning: nil)
                return
            }

            let context = CIContext()
            guard let outputCGImage = context.createCGImage(outputCIImage, from: outputCIImage.extent) else {
                continuation.resume(returning: nil)
                return
            }

            let result = UIImage(cgImage: outputCGImage)
            continuation.resume(returning: result)
        }

        // Quality levels: .balanced (default), .accurate, .fast
        request.qualityLevel = .accurate

        let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
        do {
            try handler.perform([request])
        } catch {
            continuation.resume(throwing: error)
        }
    }
}

6. Object Tracking in Video

import Vision
import AVFoundation

class ObjectTracker {
    private var trackingRequest: VNTrackObjectRequest?
    private let sequenceHandler = VNSequenceRequestHandler()

    /// Start tracking an object defined by an initial bounding box
    func startTracking(initialBoundingBox: CGRect) {
        let observation = VNDetectedObjectObservation(boundingBox: initialBoundingBox)

        trackingRequest = VNTrackObjectRequest(detectedObjectObservation: observation) { [weak self] request, error in
            guard let results = request.results as? [VNDetectedObjectObservation],
                  let trackedObject = results.first else { return }

            if trackedObject.confidence < 0.3 {
                // Object lost, stop tracking
                self?.trackingRequest = nil
                return
            }

            // Update tracking for next frame
            self?.trackingRequest = VNTrackObjectRequest(detectedObjectObservation: trackedObject)
        }

        trackingRequest?.trackingLevel = .accurate
    }

    /// Process a new video frame
    func processFrame(_ pixelBuffer: CVPixelBuffer) throws -> CGRect? {
        guard let request = trackingRequest else { return nil }

        try sequenceHandler.perform([request], on: pixelBuffer, orientation: .up)

        guard let results = request.results as? [VNDetectedObjectObservation],
              let tracked = results.first else { return nil }

        return tracked.boundingBox
    }
}

7. Image Classification

import Vision
import UIKit

func classifyImage(_ image: UIImage) async throws -> [(identifier: String, confidence: Float)] {
    guard let cgImage = image.cgImage else { return [] }

    return try await withCheckedThrowingContinuation { continuation in
        let request = VNClassifyImageRequest { request, error in
            if let error {
                continuation.resume(throwing: error)
                return
            }

            let results = (request.results as? [VNClassificationObservation]) ?? []
            let topResults = results
                .filter { $0.confidence > 0.1 }
                .prefix(10)
                .map { (identifier: $0.identifier, confidence: $0.confidence) }
            continuation.resume(returning: topResults)
        }

        let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
        do {
            try handler.perform([request])
        } catch {
            continuation.resume(throwing: error)
        }
    }
}

8. VisionKit -- DataScannerViewController (iOS 16+)

Live camera scanning for text and barcodes with a system-provided UI.

import SwiftUI
import VisionKit

@available(iOS 16.0, *)
struct DataScannerView: UIViewControllerRepresentable {
    @Binding var scannedText: String
    @Binding var scannedBarcode: String
    let scanType: DataScannerViewController.RecognizedDataType

    func makeUIViewController(context: Context) -> DataScannerViewController {
        let scanner = DataScannerViewController(
            recognizedDataTypes: [scanType],
            qualityLevel: .balanced,
            recognizesMultipleItems: false,
            isHighFrameRateTrackingEnabled: true,
            isHighlightingEnabled: true
        )
        scanner.delegate = context.coordinator
        return scanner
    }

    func updateUIViewController(_ uiViewController: DataScannerViewController, context: Context) {}

    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }

    class Coordinator: NSObject, DataScannerViewControllerDelegate {
        let parent: DataScannerView

        init(parent: DataScannerView) {
            self.parent = parent
        }

        func dataScanner(_ dataScanner: DataScannerViewController, didTapOn item: RecognizedItem) {
            switch item {
            case .text(let text):
                parent.scannedText = text.transcript
            case .barcode(let barcode):
                parent.scannedBarcode = barcode.payloadStringValue ?? ""
            @unknown default:
                break
            }
        }

        func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) {
            // Handle newly recognized items
            for item in addedItems {
                switch item {
                case .text(let text):
                    parent.scannedText = text.transcript
                case .barcode(let barcode):
                    parent.scannedBarcode = barcode.payloadStringValue ?? ""
                @unknown default:
                    break
                }
            }
        }
    }
}

@available(iOS 16.0, *)
struct ScannerContainerView: View {
    @State private var scannedText = ""
    @State private var scannedBarcode = ""
    @State private var isShowingScanner = false

    var body: some View {
        VStack(spacing: 20) {
            if DataScannerViewController.isSupported && DataScannerViewController.isAvailable {
                Button("Scan Text") {
                    isShowingScanner = true
                }
                .buttonStyle(.borderedProminent)

                if !scannedText.isEmpty {
                    Text("Scanned: \(scannedText)")
                        .padding()
                        .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
                }
            } else {
                ContentUnavailableView("Scanner Not Available",
                                       systemImage: "camera.fill",
                                       description: Text("This device does not support data scanning."))
            }
        }
        .sheet(isPresented: $isShowingScanner) {
            DataScannerView(
                scannedText: $scannedText,
                scannedBarcode: $scannedBarcode,
                scanType: .text()
            )
            .ignoresSafeArea()
        }
    }
}

9. ImageAnalyzer and ImageAnalysisInteraction (iOS 16+)

Enable Live Text on any image view -- users can select, copy, translate, and interact with text in images.

import SwiftUI
import VisionKit

@available(iOS 16.0, *)
struct LiveTextImageView: UIViewRepresentable {
    let image: UIImage

    func makeUIView(context: Context) -> UIImageView {
        let imageView = UIImageView(image: image)
        imageView.contentMode = .scaleAspectFit
        imageView.isUserInteractionEnabled = true

        let interaction = ImageAnalysisInteraction()
        interaction.preferredInteractionTypes = [.textSelection, .dataDetectors]
        imageView.addInteraction(interaction)

        Task {
            let analyzer = ImageAnalyzer()
            let configuration = ImageAnalyzer.Configuration([.text, .machineReadableCode])

            do {
                let analysis = try await analyzer.analyze(image, configuration: configuration)
                await MainActor.run {
                    interaction.analysis = analysis
                }
            } catch {
                print("Image analysis failed: \(error.localizedDescription)")
            }
        }

        return imageView
    }

    func updateUIView(_ uiView: UIImageView, context: Context) {
        uiView.image = image
    }
}

@available(iOS 16.0, *)
struct LiveTextDemoView: View {
    let sampleImage: UIImage

    var body: some View {
        NavigationStack {
            LiveTextImageView(image: sampleImage)
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .navigationTitle("Live Text")
                .navigationBarTitleDisplayMode(.inline)
        }
    }
}

10. Complete Multi-Request Pipeline

Run multiple Vision requests together for maximum efficiency.

import Vision
import UIKit

struct ImageAnalysisResult {
    var recognizedText: [String] = []
    var faceCount: Int = 0
    var barcodes: [String] = []
    var classifications: [(String, Float)] = []
}

func analyzeImage(_ image: UIImage) async throws -> ImageAnalysisResult {
    guard let cgImage = image.cgImage else {
        throw NSError(domain: "Vision", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid image"])
    }

    return try await withCheckedThrowingContinuation { continuation in
        var result = ImageAnalysisResult()
        let group = DispatchGroup()

        // Text recognition
        group.enter()
        let textRequest = VNRecognizeTextRequest { request, _ in
            defer { group.leave() }
            let observations = (request.results as? [VNRecognizedTextObservation]) ?? []
            result.recognizedText = observations.compactMap { $0.topCandidates(1).first?.string }
        }
        textRequest.recognitionLevel = .accurate

        // Face detection
        group.enter()
        let faceRequest = VNDetectFaceRectanglesRequest { request, _ in
            defer { group.leave() }
            result.faceCount = (request.results as? [VNFaceObservation])?.count ?? 0
        }

        // Barcode detection
        group.enter()
        let barcodeRequest = VNDetectBarcodesRequest { request, _ in
            defer { group.leave() }
            let observations = (request.results as? [VNBarcodeObservation]) ?? []
            result.barcodes = observations.compactMap { $0.payloadStringValue }
        }

        // Image classification
        group.enter()
        let classifyRequest = VNClassifyImageRequest { request, _ in
            defer { group.leave() }
            let observations = (request.results as? [VNClassificationObservation]) ?? []
            result.classifications = observations
                .filter { $0.confidence > 0.1 }
                .prefix(5)
                .map { ($0.identifier, $0.confidence) }
        }

        group.notify(queue: .main) {
            continuation.resume(returning: result)
        }

        // Perform all requests together -- Vision optimizes shared preprocessing
        let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
        do {
            try handler.perform([textRequest, faceRequest, barcodeRequest, classifyRequest])
        } catch {
            continuation.resume(throwing: error)
        }
    }
}

Quick Reference

Request

Observation Type

Purpose

VNRecognizeTextRequest

VNRecognizedTextObservation

OCR -- extract text from images

VNDetectFaceRectanglesRequest

VNFaceObservation

Locate faces

VNDetectFaceLandmarksRequest

VNFaceObservation

Eyes, nose, mouth positions

VNDetectBarcodesRequest

VNBarcodeObservation

QR codes, barcodes

VNGeneratePersonSegmentationRequest

VNPixelBufferObservation

Background removal mask

VNTrackObjectRequest

VNDetectedObjectObservation

Track objects across frames

VNClassifyImageRequest

VNClassificationObservation

Scene/object classification

VNCoreMLRequest

varies

Run custom CoreML models

VisionKit Class

Purpose

DataScannerViewController

Live camera text/barcode scanning UI (iOS 16+)

ImageAnalyzer

Analyze images for Live Text content (iOS 16+)

ImageAnalysisInteraction

Add text selection to image views (iOS 16+)

---

# Network.framework
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-network-framework.html

Networking and Connectivity · Reference guideNetwork.frameworkRepository guidance for Network Framework. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

1. The three facts that shape every design here
2. Path monitoring
3. A framed TCP connection
4. TLS
5. Listening (local peer-to-peer)
Anti-Patterns
Checklist

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Select transport parameters
↓2
Start connection
↓3
Send and receive framed data
↓4
Handle connection transitions

02 / ArchitectureResponsibility boundariesBoundary 1
Connection stateBoundary 2
Transport channelBoundary 3
Message handlingConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
 you need a socket rather than an HTTP request — a custom
protocol, a persistent TCP or UDP connection, a local peer-to-peer listener, TLS
you must configure yourself — or when you need to observe connectivity changes
with 
NWPathMonitor
.

Do not load this for HTTP.
 
URLSession
 already sits on Network.framework
and gives you caching, cookies, redirects, retries, background transfers, and
ATS compliance for free. Reimplementing HTTP over 
NWConnection
 is a large
amount of work to arrive somewhere worse. See 
docs/frameworks/networking.md
.

Availability:
 Network.framework iOS 12+; 
NWBrowser
 iOS 13+; the types are

not
 
Sendable
, and their callbacks arrive on a queue you supply.

1. The three facts that shape every design here

1. NWConnection is not Sendable and its handlers run on your queue.

Every state and receive callback lands on the 
DispatchQueue
 passed to

start(queue:)
. Touching 
@MainActor
 state from inside one is a data race and
a Swift 6 error. The fix is not to hop with 
DispatchQueue.main.async
 — it is to
own the connection inside an 
actor
 and expose an 
AsyncStream
 outward.

2. TCP is a byte stream, not a message stream.
 
receive
 gives you whatever
bytes have arrived. One 
send
 of 100 bytes may arrive as four callbacks; three
sends may arrive as one. Any code that treats one receive as one message works
perfectly on localhost and fails on a real network. You must frame — length
prefix, delimiter, or 
NWProtocolFramer
.

3. .ready is not "connected forever".
 Connections go 
.waiting
 when the
path is unsatisfied and can return to 
.ready
 on their own. Tearing down on

.waiting
 throws away the recovery the framework is doing for you; treating

.failed
 as recoverable leaks a dead connection. They are different states and
need different handling.

2. Path monitoring

The simplest correct thing in the framework, and the most commonly misused.

import Network

public enum Connectivity: Equatable, Sendable {
    case satisfied(isExpensive: Bool, isConstrained: Bool)
    case unsatisfied
}

/// An actor, because NWPathMonitor's handler fires on a background queue and
/// the value it produces is read from the main actor.
public actor NetworkMonitor {
    private let monitor = NWPathMonitor()
    private let queue = DispatchQueue(label: "NetworkMonitor")
    private var continuations: [UUID: AsyncStream<Connectivity>.Continuation] = [:]
    private var current: Connectivity = .unsatisfied
    private var isStarted = false

    public init() {}

    /// A fresh stream per caller. One shared stream means the second observer
    /// steals elements from the first.
    public func updates() -> AsyncStream<Connectivity> {
        let id = UUID()
        return AsyncStream { continuation in
            continuations[id] = continuation
            continuation.yield(current)          // current value, not just changes
            continuation.onTermination = { [weak self] _ in
                Task { await self?.remove(id) }
            }
            start()
        }
    }

    private func remove(_ id: UUID) {
        continuations[id] = nil
    }

    private func start() {
        guard !isStarted else { return }
        isStarted = true

        monitor.pathUpdateHandler = { [weak self] path in
            // Fires on `queue`. Hop back onto the actor rather than touching
            // state from here.
            let value: Connectivity = path.status == .satisfied
                ? .satisfied(isExpensive: path.isExpensive, isConstrained: path.isConstrained)
                : .unsatisfied
            Task { await self?.publish(value) }
        }
        monitor.start(queue: queue)
    }

    private func publish(_ value: Connectivity) {
        current = value
        for continuation in continuations.values {
            continuation.yield(value)
        }
    }

    deinit {
        monitor.cancel()
    }
}

isExpensive and isConstrained are not decoration.
 
isExpensive
 means
cellular or a personal hotspot; 
isConstrained
 means the user turned on Low
Data Mode and explicitly asked you to use less. Ignoring the second is ignoring a
direct instruction from the user, and App Review does notice.

Never use path status as a precondition.
 
.satisfied
 means a route exists,
not that your server is reachable. Code that refuses to try because the monitor
says unsatisfied fails on captive portals and VPN transitions where the request
would have worked. Make the request; handle the failure.

3. A framed TCP connection

import Foundation
import Network

public enum ConnectionError: LocalizedError {
    case failed(NWError)
    case closedByPeer
    case messageTooLarge(Int)
    case malformedFrame

    public var errorDescription: String? {
        switch self {
        case .failed: String(localized: "Lost the connection. Trying again.")
        case .closedByPeer: String(localized: "The other side disconnected.")
        case .messageTooLarge, .malformedFrame:
            String(localized: "Received unreadable data.")
        }
    }
}

/// Owns the connection. Nothing outside this actor touches NWConnection, which
/// is what makes the non-Sendable type safe to use.
public actor MessageConnection {
    private let connection: NWConnection
    private let queue = DispatchQueue(label: "MessageConnection")

    /// Bytes received but not yet forming a complete frame.
    private var buffer = Data()

    private var messages: AsyncStream<Data>.Continuation?
    private var readyContinuation: CheckedContinuation<Void, any Error>?
    private var hasResumedReady = false

    /// A ceiling on any single frame. Without it, a hostile or buggy peer
    /// sending a 4 GB length prefix makes the app allocate until it is killed.
    private let maximumFrameSize: Int

    public init(host: String, port: UInt16, useTLS: Bool = true, maximumFrameSize: Int = 4 << 20) {
        let parameters: NWParameters = useTLS ? .tls : .tcp

        // Disable Nagle for latency-sensitive small messages. Leave it on for
        // throughput-oriented traffic — turning it off there costs bandwidth.
        if let tcp = parameters.defaultProtocolStack.internetProtocol as? NWProtocolTCP.Options {
            tcp.noDelay = true
            tcp.connectionTimeout = 10
        }

        self.connection = NWConnection(
            host: NWEndpoint.Host(host),
            port: NWEndpoint.Port(rawValue: port)!,
            using: parameters
        )
        self.maximumFrameSize = maximumFrameSize
    }

    // MARK: Lifecycle

    public func connect() async throws {
        connection.stateUpdateHandler = { [weak self] state in
            Task { await self?.handle(state) }
        }
        connection.start(queue: queue)

        try await withCheckedThrowingContinuation { continuation in
            readyContinuation = continuation
        }

        receiveLoop()
    }

    private func handle(_ state: NWConnection.State) {
        switch state {
        case .ready:
            resumeReady(with: .success(()))

        case .waiting(let error):
            // NOT a failure. The path is temporarily unsatisfied and the
            // framework is already retrying. Tearing down here throws away the
            // recovery it is doing for you.
            log("waiting: \(error)")

        case .failed(let error):
            // Terminal. This one does need a new connection.
            resumeReady(with: .failure(ConnectionError.failed(error)))
            messages?.finish()

        case .cancelled:
            messages?.finish()

        case .setup, .preparing:
            break

        @unknown default:
            break
        }
    }

    /// A continuation must be resumed exactly once. `.waiting` -> `.ready` ->
    /// `.failed` is a normal sequence, so without this guard a flaky network
    /// crashes the app with "resumed more than once".
    private func resumeReady(with result: Result<Void, any Error>) {
        guard !hasResumedReady, let continuation = readyContinuation else { return }
        hasResumedReady = true
        readyContinuation = nil
        continuation.resume(with: result)
    }

    public func cancel() {
        connection.cancel()
        messages?.finish()
    }

    // MARK: Sending

    /// Length-prefixed framing: 4-byte big-endian count, then the payload.
    public func send(_ payload: Data) async throws {
        guard payload.count <= maximumFrameSize else {
            throw ConnectionError.messageTooLarge(payload.count)
        }

        var frame = Data()
        withUnsafeBytes(of: UInt32(payload.count).bigEndian) { frame.append(contentsOf: $0) }
        frame.append(payload)

        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
            connection.send(content: frame, completion: .contentProcessed { error in
                if let error {
                    continuation.resume(throwing: ConnectionError.failed(error))
                } else {
                    continuation.resume()
                }
            })
        }
    }

    // MARK: Receiving

    public func incomingMessages() -> AsyncStream<Data> {
        AsyncStream { continuation in
            messages = continuation
        }
    }

    private func receiveLoop() {
        connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) {
            [weak self] content, _, isComplete, error in
            Task { await self?.handleReceive(content, isComplete: isComplete, error: error) }
        }
    }

    private func handleReceive(_ content: Data?, isComplete: Bool, error: NWError?) {
        if let error {
            log("receive failed: \(error)")
            messages?.finish()
            return
        }

        if let content, !content.isEmpty {
            buffer.append(content)
            drainFrames()
        }

        if isComplete {
            messages?.finish()
            return
        }

        receiveLoop()   // re-arm; `receive` delivers once per call
    }

    /// TCP is a byte stream. One receive is not one message — this is where
    /// code that assumes otherwise breaks the moment it leaves localhost.
    private func drainFrames() {
        while buffer.count >= 4 {
            let length = buffer.prefix(4).withUnsafeBytes {
                Int($0.load(as: UInt32.self).bigEndian)
            }

            guard length <= maximumFrameSize else {
                log("frame of \(length) exceeds the ceiling; closing")
                connection.cancel()
                messages?.finish()
                return
            }

            guard buffer.count >= 4 + length else { return }   // partial frame; wait

            let payload = buffer.subdata(in: 4..<(4 + length))
            buffer.removeSubrange(0..<(4 + length))
            messages?.yield(payload)
        }
    }

    private func log(_ message: String) {
        // OSLog in production — see docs/frameworks/oslog.md.
    }
}

4. TLS

NWParameters.tls
 gives you the system defaults, which are correct. Only reach
for 
sec_protocol_options
 when you have a specific requirement — a pinned
certificate, or a private CA.

import CryptoKit
import Network

func pinnedParameters(expectedSPKISHA256: Data) -> NWParameters {
    let options = NWProtocolTLS.Options()

    sec_protocol_options_set_verify_block(
        options.securityProtocolOptions,
        { _, trustRef, complete in
            let trust = sec_trust_copy_ref(trustRef).takeRetainedValue()

            // Evaluate the chain normally FIRST. Pinning replaces nothing —
            // it is an additional constraint on top of a valid chain.
            var error: CFError?
            guard SecTrustEvaluateWithError(trust, &error) else {
                return complete(false)
            }

            guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
                  let leaf = chain.first,
                  let publicKey = SecCertificateCopyKey(leaf),
                  let spki = SecKeyCopyExternalRepresentation(publicKey, nil) as Data?
            else {
                return complete(false)
            }

            // Pin the public key, not the certificate: the key survives
            // certificate renewal, so routine rotation does not brick the app.
            let digest = Data(SHA256.hash(data: spki))
            complete(digest == expectedSPKISHA256)
        },
        DispatchQueue(label: "TLSVerify")
    )

    return NWParameters(tls: options)
}

Pin at least two keys — the current one and a backup you control.
 A single
pin turns a lost private key into an app that cannot reach its own server until
every user installs an update. Ship an expiry date for the pin set, after which
the app falls back to standard validation rather than becoming a brick.

5. Listening (local peer-to-peer)

public actor MessageListener {
    private let listener: NWListener
    private let queue = DispatchQueue(label: "MessageListener")
    private var connections: [ObjectIdentifier: NWConnection] = [:]

    public init(port: UInt16, service: String) throws {
        let parameters = NWParameters.tls
        parameters.includePeerToPeer = true          // AWDL, for local discovery

        listener = try NWListener(
            using: parameters,
            on: NWEndpoint.Port(rawValue: port) ?? .any
        )
        // Bonjour advertisement. The type must also appear in
        // NSBonjourServices in Info.plist or discovery silently fails.
        listener.service = NWListener.Service(type: service)
    }

    public func start() {
        listener.newConnectionHandler = { [weak self] connection in
            Task { await self?.accept(connection) }
        }
        listener.stateUpdateHandler = { state in
            // .failed here is usually a port already in use.
        }
        listener.start(queue: queue)
    }

    private func accept(_ connection: NWConnection) {
        // Retain it. A connection that goes out of scope is cancelled
        // immediately, and the symptom is peers that connect then vanish.
        connections[ObjectIdentifier(connection)] = connection

        connection.stateUpdateHandler = { [weak self] state in
            switch state {
            case .cancelled, .failed:
                Task { await self?.drop(connection) }
            default:
                break
            }
        }
        connection.start(queue: queue)
    }

    private func drop(_ connection: NWConnection) {
        connections[ObjectIdentifier(connection)] = nil
    }
}

Local networking needs 
both
 Info.plist keys since iOS 14, or the first
connection attempt fails with no useful diagnostic:

<key>NSLocalNetworkUsageDescription</key>
<string>Find and connect to nearby devices running this app.</string>
<key>NSBonjourServices</key>
<array>
    <string>_myapp._tcp</string>
</array>

Anti-Patterns

// WRONG — one receive treated as one message.
// Works on localhost, fails on a real network: TCP is a byte stream, so a
// 100-byte send may arrive as four callbacks and three sends as one.
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, _, _ in
    let message = try? JSONDecoder().decode(Message.self, from: data!)
}

// RIGHT — buffer, then extract complete frames.
buffer.append(data); drainFrames()

// WRONG — an unbounded length prefix.
// A peer sending 0xFFFFFFFF makes the app allocate 4 GB and get killed.
guard buffer.count >= 4 + length else { return }

// RIGHT — a ceiling, checked before trusting the length.
guard length <= maximumFrameSize else { connection.cancel(); return }

// WRONG — .waiting treated as a failure.
// The path is temporarily unsatisfied and the framework is already retrying.
// Tearing down discards that recovery and usually starts a reconnect storm.
case .waiting: reconnect()

// RIGHT — log it and let the framework recover; act on .failed.
case .waiting(let error): log(error)
case .failed(let error): reconnect()

// WRONG — a continuation resumed from a state handler with no guard.
// .waiting -> .ready -> .failed is a normal sequence on a flaky network, and
// resuming twice is a hard crash.
case .ready: continuation.resume()
case .failed(let e): continuation.resume(throwing: e)

// RIGHT — resume exactly once.
private func resumeReady(with result: Result<Void, any Error>) { guard !hasResumedReady … }

// WRONG — receive called once.
// `receive` delivers a single callback. Without re-arming, exactly one chunk
// ever arrives and the connection appears to hang.
connection.receive(…) { data, _, _, _ in self.handle(data) }

// RIGHT — call receive again at the end of every callback.

// WRONG — NWConnection state mutated from its own handler on a @MainActor type.
// The handler runs on the connection's queue. This is a data race and a Swift 6
// error, and DispatchQueue.main.async is not the fix.
@MainActor final class Client { var isConnected = false
    func start() { c.stateUpdateHandler = { s in self.isConnected = (s == .ready) } } }

// RIGHT — the connection lives in an actor; the outside world gets an AsyncStream.

// WRONG — an accepted connection not retained.
// It deallocates at the end of the handler and is cancelled immediately. The
// symptom is peers that connect and instantly vanish.
listener.newConnectionHandler = { connection in connection.start(queue: q) }

// RIGHT — hold it until it is cancelled or fails.
connections[ObjectIdentifier(connection)] = connection

// WRONG — path status as a precondition.
// .satisfied means a route exists, not that your server is reachable. This
// refuses to try on captive portals and VPN transitions where it would work.
guard monitor.currentPath.status == .satisfied else { throw .offline }

// RIGHT — attempt the request; handle the failure.

// WRONG — ignoring isConstrained.
// Low Data Mode is the user explicitly asking you to use less. Prefetching
// through it ignores a direct instruction.
await prefetchEverything()

// RIGHT
if case .satisfied(_, let isConstrained) = connectivity, !isConstrained { await prefetch() }

// WRONG — TLS verification disabled to "make it work in dev".
// This ships. It always ships.
sec_protocol_options_set_verify_block(opts, { _, _, complete in complete(true) }, q)

// RIGHT — a real certificate in dev, or a pin scoped to a DEBUG build with a
// release branch that ignores the flag entirely.

// WRONG — a single pinned certificate.
// Routine certificate renewal bricks every installed copy of the app.
let pinned = [productionCertificateHash]

// RIGHT — pin public keys (which survive renewal), at least two, with an expiry.
let pinned = [currentSPKIHash, backupSPKIHash]

// WRONG — Network.framework for HTTP.
// Reimplements caching, redirects, cookies, retries, and ATS, worse.
let connection = NWConnection(host: "api.example.com", port: 443, using: .tls)

// RIGHT
let (data, response) = try await URLSession.shared.data(for: request)

Checklist

[ ] 
URLSession
 ruled out for a stated reason before reaching for this
[ ] 
NWConnection
 owned by an actor; nothing outside it touches the connection
[ ] Messages framed; a receive is never assumed to be one message
[ ] A maximum frame size, checked before allocating
[ ] 
receive
 re-armed at the end of every callback
[ ] 
.waiting
 and 
.failed
 handled differently
[ ] Every continuation resumed exactly once, guarded
[ ] Accepted connections retained until cancelled
[ ] Path status used as a hint, never as a precondition
[ ] 
isConstrained
 respected — Low Data Mode is an instruction
[ ] TLS verification never disabled, in any build that can ship
[ ] Pins on public keys, at least two, with an expiry fallback
[ ] 
NSLocalNetworkUsageDescription
 and 
NSBonjourServices
 present for local discovery
[ ] Connections cancelled on 
scenePhase
 background — a live socket drains battery

---

# Networking
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-networking.html

Networking and Connectivity · Reference guideNetworkingRepository guidance for Networking. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

URLSession Async/Await Patterns
Generic API Client
Authentication — Bearer Token and OAuth
Error Handling and Retry Logic
Multipart Form Data Upload
WebSocket
Network Monitoring

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Build a typed request
↓2
Await URLSession response
↓3
Validate and decode payload
↓4
Map failures to UI state

02 / ArchitectureResponsibility boundariesBoundary 1
View modelBoundary 2
API clientBoundary 3
Remote endpointConnected responsibilities, not a required class hierarchy or an execution trace.
URLSession Async/Await Patterns

// GET request
func fetchUsers() async throws -> [User] {
    let url = URL(string: "https://api.example.com/users")!
    let (data, response) = try await URLSession.shared.data(from: url)

    guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
        throw APIError.badResponse
    }
    return try JSONDecoder().decode([User].self, from: data)
}

// POST request
func createUser(_ user: CreateUserRequest) async throws -> User {
    var request = URLRequest(url: URL(string: "https://api.example.com/users")!)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try JSONEncoder().encode(user)

    let (data, response) = try await URLSession.shared.data(for: request)

    guard let http = response as? HTTPURLResponse, http.statusCode == 201 else {
        throw APIError.badResponse
    }
    return try JSONDecoder().decode(User.self, from: data)
}

// Download with progress using AsyncBytes
func downloadWithProgress(from url: URL) async throws -> Data {
    let (bytes, response) = try await URLSession.shared.bytes(from: url)

    guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
        throw APIError.badResponse
    }

    let totalBytes = Int(http.expectedContentLength)
    var data = Data(capacity: totalBytes)

    for try await byte in bytes {
        data.append(byte)
        let progress = Double(data.count) / Double(totalBytes)
        await MainActor.run { self.downloadProgress = progress }
    }
    return data
}

Generic API Client

enum HTTPMethod: String {
    case get = "GET", post = "POST", put = "PUT", patch = "PATCH", delete = "DELETE"
}

enum APIError: LocalizedError {
    case badURL
    case badResponse
    case unauthorized
    case notFound
    case serverError(statusCode: Int)
    case decodingError(Error)
    case networkError(Error)

    var errorDescription: String? {
        switch self {
        case .badURL: return "Invalid URL"
        case .badResponse: return "Bad server response"
        case .unauthorized: return "Authentication required"
        case .notFound: return "Resource not found"
        case .serverError(let code): return "Server error (\(code))"
        case .decodingError(let err): return "Decoding failed: \(err.localizedDescription)"
        case .networkError(let err): return "Network error: \(err.localizedDescription)"
        }
    }
}

actor APIClient {
    static let shared = APIClient()

    private let baseURL: URL
    private let session: URLSession
    private let decoder: JSONDecoder
    private let encoder: JSONEncoder
    private var authToken: String?

    init(
        baseURL: URL = URL(string: "https://api.example.com")!,
        session: URLSession = .shared
    ) {
        self.baseURL = baseURL
        self.session = session
        self.decoder = JSONDecoder()
        self.decoder.keyDecodingStrategy = .convertFromSnakeCase
        self.decoder.dateDecodingStrategy = .iso8601
        self.encoder = JSONEncoder()
        self.encoder.keyEncodingStrategy = .convertToSnakeCase
        self.encoder.dateEncodingStrategy = .iso8601
    }

    func setToken(_ token: String?) {
        self.authToken = token
    }

    func request<T: Decodable>(
        _ method: HTTPMethod,
        path: String,
        body: (any Encodable)? = nil,
        queryItems: [URLQueryItem]? = nil
    ) async throws -> T {
        var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: true)!
        components.queryItems = queryItems

        guard let url = components.url else { throw APIError.badURL }

        var request = URLRequest(url: url)
        request.httpMethod = method.rawValue
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("application/json", forHTTPHeaderField: "Accept")

        if let token = authToken {
            request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }

        if let body {
            request.httpBody = try encoder.encode(body)
        }

        let (data, response): (Data, URLResponse)
        do {
            (data, response) = try await session.data(for: request)
        } catch {
            throw APIError.networkError(error)
        }

        guard let http = response as? HTTPURLResponse else {
            throw APIError.badResponse
        }

        switch http.statusCode {
        case 200...299:
            do {
                return try decoder.decode(T.self, from: data)
            } catch {
                throw APIError.decodingError(error)
            }
        case 401:
            throw APIError.unauthorized
        case 404:
            throw APIError.notFound
        default:
            throw APIError.serverError(statusCode: http.statusCode)
        }
    }
}

// Usage
let users: [User] = try await APIClient.shared.request(.get, path: "/users")
let newUser: User = try await APIClient.shared.request(.post, path: "/users", body: CreateUserRequest(name: "Alice"))

Authentication — Bearer Token and OAuth

actor AuthManager {
    static let shared = AuthManager()

    private var accessToken: String?
    private var refreshToken: String?
    private var tokenExpiry: Date?
    private var refreshTask: Task<String, Error>?

    func validToken() async throws -> String {
        // Return cached token if still valid
        if let token = accessToken, let expiry = tokenExpiry, expiry > Date() {
            return token
        }

        // Deduplicate concurrent refresh calls
        if let refreshTask {
            return try await refreshTask.value
        }

        let task = Task<String, Error> {
            defer { refreshTask = nil }
            guard let refresh = refreshToken else { throw APIError.unauthorized }
            let response = try await performTokenRefresh(refreshToken: refresh)
            self.accessToken = response.accessToken
            self.refreshToken = response.refreshToken
            self.tokenExpiry = Date().addingTimeInterval(TimeInterval(response.expiresIn))
            return response.accessToken
        }
        self.refreshTask = task
        return try await task.value
    }

    private func performTokenRefresh(refreshToken: String) async throws -> TokenResponse {
        var request = URLRequest(url: URL(string: "https://auth.example.com/token")!)
        request.httpMethod = "POST"
        request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")

        let body = "grant_type=refresh_token&refresh_token=\(refreshToken)&client_id=\(clientId)"
        request.httpBody = body.data(using: .utf8)

        let (data, _) = try await URLSession.shared.data(for: request)
        return try JSONDecoder().decode(TokenResponse.self, from: data)
    }
}

struct TokenResponse: Codable {
    let accessToken: String
    let refreshToken: String
    let expiresIn: Int
}

Error Handling and Retry Logic

func fetchWithRetry<T: Decodable>(
    _ type: T.Type,
    url: URL,
    maxRetries: Int = 3,
    delay: Duration = .seconds(1)
) async throws -> T {
    var lastError: Error?

    for attempt in 0..<maxRetries {
        do {
            let (data, response) = try await URLSession.shared.data(from: url)
            guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
                throw APIError.badResponse
            }
            return try JSONDecoder().decode(T.self, from: data)
        } catch {
            lastError = error

            // Don't retry client errors (4xx)
            if let apiError = error as? APIError, case .unauthorized = apiError { throw error }
            if let apiError = error as? APIError, case .notFound = apiError { throw error }

            if attempt < maxRetries - 1 {
                let backoff = Duration.seconds(pow(2.0, Double(attempt)))
                try await Task.sleep(for: backoff)
            }
        }
    }
    throw lastError ?? APIError.badResponse
}

Multipart Form Data Upload

func uploadMultipart(
    imageData: Data,
    filename: String,
    fields: [String: String] = [:]
) async throws -> UploadResponse {
    let boundary = UUID().uuidString
    var request = URLRequest(url: URL(string: "https://api.example.com/upload")!)
    request.httpMethod = "POST"
    request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")

    var body = Data()

    // Text fields
    for (key, value) in fields {
        body.append("--\(boundary)\r\n".data(using: .utf8)!)
        body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!)
        body.append("\(value)\r\n".data(using: .utf8)!)
    }

    // File field
    body.append("--\(boundary)\r\n".data(using: .utf8)!)
    body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
    body.append("Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)!)
    body.append(imageData)
    body.append("\r\n".data(using: .utf8)!)
    body.append("--\(boundary)--\r\n".data(using: .utf8)!)

    request.httpBody = body

    let (data, _) = try await URLSession.shared.data(for: request)
    return try JSONDecoder().decode(UploadResponse.self, from: data)
}

WebSocket

actor WebSocketClient {
    private var webSocketTask: URLSessionWebSocketTask?
    private let url: URL

    init(url: URL) {
        self.url = url
    }

    func connect() {
        webSocketTask = URLSession.shared.webSocketTask(with: url)
        webSocketTask?.resume()
        listenForMessages()
    }

    func send(_ message: String) async throws {
        try await webSocketTask?.send(.string(message))
    }

    func send(_ data: Data) async throws {
        try await webSocketTask?.send(.data(data))
    }

    private func listenForMessages() {
        webSocketTask?.receive { [weak self] result in
            switch result {
            case .success(let message):
                switch message {
                case .string(let text):
                    print("Received text: \(text)")
                case .data(let data):
                    print("Received data: \(data.count) bytes")
                @unknown default:
                    break
                }
                // Continue listening
                self?.listenForMessages()
            case .failure(let error):
                print("WebSocket error: \(error)")
            }
        }
    }

    func disconnect() {
        webSocketTask?.cancel(with: .normalClosure, reason: nil)
    }
}

Network Monitoring

import Network

@Observable
class NetworkMonitor {
    static let shared = NetworkMonitor()

    var isConnected = true
    var connectionType: ConnectionType = .unknown

    enum ConnectionType {
        case wifi, cellular, ethernet, unknown
    }

    private let monitor = NWPathMonitor()
    private let queue = DispatchQueue(label: "NetworkMonitor")

    init() {
        monitor.pathUpdateHandler = { [weak self] path in
            DispatchQueue.main.async {
                self?.isConnected = path.status == .satisfied
                self?.connectionType = self?.getConnectionType(path) ?? .unknown
            }
        }
        monitor.start(queue: queue)
    }

    private func getConnectionType(_ path: NWPath) -> ConnectionType {
        if path.usesInterfaceType(.wifi) { return .wifi }
        if path.usesInterfaceType(.cellular) { return .cellular }
        if path.usesInterfaceType(.wiredEthernet) { return .ethernet }
        return .unknown
    }

    deinit {
        monitor.cancel()
    }
}

// Usage in SwiftUI
struct ContentView: View {
    let networkMonitor = NetworkMonitor.shared

    var body: some View {
        Group {
            if networkMonitor.isConnected {
                MainContentView()
            } else {
                OfflineView()
            }
        }
    }
}

---

# OSLog & MetricKit
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-oslog.html

System Integration · Reference guideOSLog & MetricKitRepository guidance for OSLog. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Logger Struct (iOS 14+)
Log Levels
String Interpolation Privacy
os_signpost for Performance Profiling
Structured Logging Utility
MXMetricManager (MetricKit)
Xcode Instruments Integration
Complete Logging Setup Example

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Define subsystem and category
↓2
Emit privacy-aware events
↓3
Reproduce the behavior
↓4
Inspect filtered logs

02 / ArchitectureResponsibility boundariesBoundary 1
App eventsBoundary 2
Structured loggingBoundary 3
Diagnostic readerConnected responsibilities, not a required class hierarchy or an execution trace.
Logger Struct (iOS 14+)

import OSLog

extension Logger {
    /// Bundle identifier as default subsystem
    private static let subsystem = Bundle.main.bundleIdentifier ?? "com.yourapp"

    /// Loggers organized by category
    static let network = Logger(subsystem: subsystem, category: "Network")
    static let ui = Logger(subsystem: subsystem, category: "UI")
    static let data = Logger(subsystem: subsystem, category: "DataStore")
    static let auth = Logger(subsystem: subsystem, category: "Authentication")
    static let payment = Logger(subsystem: subsystem, category: "Payment")
    static let sync = Logger(subsystem: subsystem, category: "Sync")
}

Log Levels

func demonstrateLogLevels() {
    // .debug — Verbose developer info. Not persisted by default. Not collected in production.
    Logger.network.debug("Request headers: \(headers)")

    // .info — Helpful but not essential. Persisted only during log collect.
    Logger.network.info("Starting request to \(endpoint)")

    // .notice (default) — Essential for troubleshooting. Persisted up to storage limit.
    Logger.auth.notice("User signed in successfully")

    // .error — Error conditions. Always persisted.
    Logger.data.error("Failed to save context: \(error.localizedDescription)")

    // .fault — Bug in the program. Always persisted. Captures calling process info.
    Logger.data.fault("Core Data stack not initialized — this should never happen")
}

String Interpolation Privacy

func logWithPrivacy(userId: String, email: String, itemCount: Int) {
    // Default: dynamic strings are PRIVATE (redacted in production logs)
    Logger.auth.info("User logged in: \(userId)")
    // Output in production: "User logged in: <private>"

    // Explicitly mark as public when safe
    Logger.ui.info("Screen loaded: \(screenName, privacy: .public)")

    // Mark as private (default for dynamic values)
    Logger.auth.info("Email: \(email, privacy: .private)")

    // Hash private data for correlation without revealing values
    Logger.auth.info("User hash: \(email, privacy: .private(mask: .hash))")

    // Numeric values are PUBLIC by default
    Logger.data.info("Items count: \(itemCount)")

    // Format specifiers
    Logger.payment.info("Amount: \(amount, format: .fixed(precision: 2), privacy: .private)")

    // Boolean and numeric types
    Logger.network.debug("Cache hit: \(isCached, privacy: .public), size: \(responseSize) bytes")
}

os_signpost for Performance Profiling

import os

// Create a signpost log
let pointsOfInterest = OSLog(subsystem: "com.yourapp", category: .pointsOfInterest)
let networkLog = OSLog(subsystem: "com.yourapp.network", category: "Requests")

// Mark an interval (begin + end)
func fetchData() async throws -> Data {
    let signpostID = OSSignpostID(log: networkLog)

    os_signpost(.begin, log: networkLog, name: "FetchData", signpostID: signpostID,
                "URL: %{public}@", url.absoluteString)

    let (data, _) = try await URLSession.shared.data(from: url)

    os_signpost(.end, log: networkLog, name: "FetchData", signpostID: signpostID,
                "Received %d bytes", data.count)

    return data
}

// Mark a single event (point of interest)
func userTappedCheckout() {
    os_signpost(.event, log: pointsOfInterest, name: "Checkout", "User tapped checkout button")
}

// Modern signpost API (iOS 15+)
let signposter = OSSignposter(subsystem: "com.yourapp", category: "Performance")

func modernSignpostExample() async throws {
    let state = signposter.beginInterval("DataLoad")

    let data = try await loadData()
    signposter.emitEvent("DataReceived", "\(data.count) bytes")

    signposter.endInterval("DataLoad", state)
}

// Automatic interval with withIntervalSignpost
func automaticSignpost() async throws -> [Item] {
    try await signposter.withIntervalSignpost("FetchItems") {
        try await api.fetchItems()
    }
}

Structured Logging Utility

import OSLog

/// Centralized logging with context
enum AppLogger {
    private static let logger = Logger(
        subsystem: Bundle.main.bundleIdentifier ?? "com.yourapp",
        category: "App"
    )

    static func logNetworkRequest(
        method: String,
        url: URL,
        statusCode: Int?,
        duration: TimeInterval,
        error: Error? = nil
    ) {
        if let error {
            Logger.network.error("""
                Network error — \(method, privacy: .public) \(url.absoluteString, privacy: .public) \
                status=\(statusCode ?? 0) \
                duration=\(duration, format: .fixed(precision: 3))s \
                error=\(error.localizedDescription)
                """)
        } else {
            Logger.network.info("""
                Network — \(method, privacy: .public) \(url.absoluteString, privacy: .public) \
                status=\(statusCode ?? 0) \
                duration=\(duration, format: .fixed(precision: 3))s
                """)
        }
    }

    static func logUserAction(_ action: String, screen: String, metadata: [String: String] = [:]) {
        let metaString = metadata.map { "\($0.key)=\($0.value)" }.joined(separator: ", ")
        Logger.ui.notice("Action: \(action, privacy: .public) screen=\(screen, privacy: .public) \(metaString)")
    }

    static func logAppLifecycle(_ event: String) {
        logger.notice("Lifecycle: \(event, privacy: .public)")
    }
}

MXMetricManager (MetricKit)

import MetricKit

final class MetricsManager: NSObject, MXMetricManagerSubscriber {
    static let shared = MetricsManager()

    func startCollecting() {
        MXMetricManager.shared.add(self)
    }

    func stopCollecting() {
        MXMetricManager.shared.remove(self)
    }

    // Called once per day with aggregated metrics
    func didReceive(_ payloads: [MXMetricPayload]) {
        for payload in payloads {
            processMetricPayload(payload)
        }
    }

    // Called when diagnostic reports are available
    func didReceive(_ payloads: [MXDiagnosticPayload]) {
        for payload in payloads {
            processDiagnosticPayload(payload)
        }
    }

    private func processMetricPayload(_ payload: MXMetricPayload) {
        // Launch time
        if let launchMetrics = payload.applicationLaunchMetrics {
            let resumeTime = launchMetrics.histogrammedTimeToFirstDraw
            Logger.data.info("Launch histogram: \(resumeTime)")
        }

        // Responsiveness (hang rate)
        if let responsiveness = payload.applicationResponsivenessMetrics {
            let hangTime = responsiveness.histogrammedApplicationHangTime
            Logger.data.info("Hang time histogram: \(hangTime)")
        }

        // CPU
        if let cpu = payload.cpuMetrics {
            let cumulativeCPUTime = cpu.cumulativeCPUTime
            Logger.data.info("CPU time: \(cumulativeCPUTime)")
        }

        // Memory
        if let memory = payload.memoryMetrics {
            let peakMemory = memory.peakMemoryUsage
            Logger.data.info("Peak memory: \(peakMemory)")
        }

        // Disk I/O
        if let disk = payload.diskIOMetrics {
            let writes = disk.cumulativeLogicalWrites
            Logger.data.info("Disk writes: \(writes)")
        }

        // Network
        if let network = payload.networkTransferMetrics {
            let upload = network.cumulativeCellularUpload
            let download = network.cumulativeCellularDownload
            Logger.data.info("Cellular: up=\(upload), down=\(download)")
        }

        // Export as JSON for server-side analytics
        let jsonData = payload.jsonRepresentation()
        sendToAnalyticsServer(jsonData)
    }

    private func processDiagnosticPayload(_ payload: MXDiagnosticPayload) {
        // Crash diagnostics
        if let crashes = payload.crashDiagnostics {
            for crash in crashes {
                Logger.data.fault("Crash: \(crash.jsonRepresentation())")
                let callStack = crash.callStackTree
                // Analyze call stack for crash root cause
            }
        }

        // Hang diagnostics
        if let hangs = payload.hangDiagnostics {
            for hang in hangs {
                let duration = hang.hangDuration
                Logger.data.error("Hang detected: \(duration)s")
            }
        }

        // Disk write diagnostics
        if let diskWrites = payload.diskWriteExceptionDiagnostics {
            for diagnostic in diskWrites {
                let totalWrites = diagnostic.totalWritesCaused
                Logger.data.error("Excessive disk writes: \(totalWrites)")
            }
        }

        // CPU exceptions
        if let cpuExceptions = payload.cpuExceptionDiagnostics {
            for exception in cpuExceptions {
                let totalCPU = exception.totalCPUTime
                Logger.data.error("CPU exception: \(totalCPU)s")
            }
        }
    }

    private func sendToAnalyticsServer(_ data: Data) {
        // Upload payload JSON to your analytics backend
    }
}

Xcode Instruments Integration

/// Custom OSLog categories map to Instruments categories
/// View in Instruments > Logging > your subsystem

// For Instruments profiling, use os_signpost:
import os

final class InstrumentsHelper {
    private static let log = OSLog(subsystem: "com.yourapp", category: "Performance")

    /// Wrap any async operation with Instruments-visible signpost
    static func measure<T>(
        _ name: StaticString,
        _ operation: () async throws -> T
    ) async rethrows -> T {
        let id = OSSignpostID(log: log)
        os_signpost(.begin, log: log, name: name, signpostID: id)
        let result = try await operation()
        os_signpost(.end, log: log, name: name, signpostID: id)
        return result
    }

    /// Measure synchronous work
    static func measureSync<T>(
        _ name: StaticString,
        _ operation: () throws -> T
    ) rethrows -> T {
        let id = OSSignpostID(log: log)
        os_signpost(.begin, log: log, name: name, signpostID: id)
        let result = try operation()
        os_signpost(.end, log: log, name: name, signpostID: id)
        return result
    }
}

// Usage:
// let items = await InstrumentsHelper.measure("LoadItems") {
//     try await api.fetchItems()
// }

Complete Logging Setup Example

import SwiftUI
import OSLog
import MetricKit

@main
struct MyApp: App {
    @State private var metricsManager = MetricsManager.shared

    init() {
        MetricsManager.shared.startCollecting()
        AppLogger.logAppLifecycle("App launched")
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
                .onOpenURL { url in
                    Logger.ui.info("Opened URL: \(url.absoluteString, privacy: .public)")
                }
        }
    }
}

// Network layer with structured logging
final class LoggingHTTPClient {
    private let session: URLSession
    private let signposter = OSSignposter(subsystem: "com.yourapp", category: "HTTP")

    init(session: URLSession = .shared) {
        self.session = session
    }

    func request(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) {
        let method = urlRequest.httpMethod ?? "GET"
        let url = urlRequest.url!

        Logger.network.info("Starting \(method, privacy: .public) \(url.absoluteString, privacy: .public)")

        let state = signposter.beginInterval("HTTP", "\(method) \(url.path())")
        let start = CFAbsoluteTimeGetCurrent()

        do {
            let (data, response) = try await session.data(for: urlRequest)
            let httpResponse = response as! HTTPURLResponse
            let duration = CFAbsoluteTimeGetCurrent() - start

            signposter.endInterval("HTTP", state)

            AppLogger.logNetworkRequest(
                method: method,
                url: url,
                statusCode: httpResponse.statusCode,
                duration: duration
            )

            return (data, httpResponse)
        } catch {
            let duration = CFAbsoluteTimeGetCurrent() - start
            signposter.endInterval("HTTP", state)

            AppLogger.logNetworkRequest(
                method: method,
                url: url,
                statusCode: nil,
                duration: duration,
                error: error
            )

            throw error
        }
    }
}

---

# PhotosUI & AVKit
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-photosui.html

Core UI and Apps · Reference guidePhotosUI & AVKitRepository guidance for PhotosUI, Photos. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

PhotosPicker (Single, Multiple, Filtered by Type)
Transferable Protocol for Photo Loading
PHPickerViewController (UIKit)
AVCaptureSession for Custom Camera
AVCapturePhotoOutput and AVCaptureVideoDataOutput
VideoPlayer in SwiftUI with Custom Controls
Picture-in-Picture (AVPictureInPictureController)
PHPhotoLibrary for Saving Photos and Videos
Complete Photo Picker and Custom Camera Example
Key Considerations

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Present system photo picker
↓2
Receive selected items
↓3
Load transferable content
↓4
Handle cancellation or load failure

02 / ArchitectureResponsibility boundariesBoundary 1
Picker selectionBoundary 2
Content loadingBoundary 3
App media stateConnected responsibilities, not a required class hierarchy or an execution trace.
PhotosUI provides the system photo picker for selecting photos and videos. AVKit and AVFoundation provide video playback, custom camera capture, and Picture-in-Picture support. Together they cover the full media pipeline from capture to display.

PhotosPicker (Single, Multiple, Filtered by Type)

The SwiftUI 
PhotosPicker
 presents the system photo picker with privacy-preserving access (no permissions prompt required).

import SwiftUI
import PhotosUI

struct SinglePhotoPicker: View {
    @State private var selectedItem: PhotosPickerItem?
    @State private var selectedImage: Image?

    var body: some View {
        VStack(spacing: 20) {
            if let selectedImage {
                selectedImage
                    .resizable()
                    .scaledToFit()
                    .frame(maxHeight: 300)
                    .clipShape(RoundedRectangle(cornerRadius: 12))
            }

            // Single photo picker
            PhotosPicker(
                selection: $selectedItem,
                matching: .images,  // Only show images
                photoLibrary: .shared()
            ) {
                Label("Select Photo", systemImage: "photo.on.rectangle")
            }
            .buttonStyle(.borderedProminent)
        }
        .onChange(of: selectedItem) { oldValue, newValue in
            Task {
                if let data = try? await newValue?.loadTransferable(type: Data.self),
                   let uiImage = UIImage(data: data) {
                    selectedImage = Image(uiImage: uiImage)
                }
            }
        }
    }
}

// Multiple photo selection with type filtering
struct MultiplePhotoPicker: View {
    @State private var selectedItems: [PhotosPickerItem] = []
    @State private var selectedImages: [UIImage] = []

    var body: some View {
        VStack {
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(spacing: 12) {
                    ForEach(selectedImages, id: \.self) { image in
                        Image(uiImage: image)
                            .resizable()
                            .scaledToFill()
                            .frame(width: 100, height: 100)
                            .clipShape(RoundedRectangle(cornerRadius: 8))
                    }
                }
                .padding(.horizontal)
            }

            // Multiple selection with max count and filter
            PhotosPicker(
                selection: $selectedItems,
                maxSelectionCount: 10,
                selectionBehavior: .ordered,
                matching: .any(of: [.images, .screenshots, .not(.videos)]),
                preferredItemEncoding: .compatible,
                photoLibrary: .shared()
            ) {
                Label("Select Photos (max 10)", systemImage: "photo.stack")
            }
            .buttonStyle(.bordered)

            // Filter examples:
            // .images                       — all images
            // .videos                       — all videos
            // .livePhotos                   — Live Photos only
            // .screenshots                  — screenshots only
            // .any(of: [.images, .videos])  — images or videos
            // .all(of: [.images, .not(.screenshots)]) — images excluding screenshots
        }
        .onChange(of: selectedItems) { oldValue, newValue in
            Task {
                selectedImages = []
                for item in newValue {
                    if let data = try? await item.loadTransferable(type: Data.self),
                       let image = UIImage(data: data) {
                        selectedImages.append(image)
                    }
                }
            }
        }
    }
}

Transferable Protocol for Photo Loading

Use 
Transferable
 conformance for type-safe photo loading from 
PhotosPickerItem
.

import SwiftUI
import PhotosUI
import CoreTransferable

// Custom Transferable type for loading images
struct PickedImage: Transferable {
    let image: Image
    let uiImage: UIImage

    static var transferRepresentation: some TransferRepresentation {
        DataRepresentation(importedContentType: .image) { data in
            guard let uiImage = UIImage(data: data) else {
                throw PickerError.importFailed
            }
            return PickedImage(image: Image(uiImage: uiImage), uiImage: uiImage)
        }
    }
}

// Transferable for video loading
struct PickedVideo: Transferable {
    let url: URL

    static var transferRepresentation: some TransferRepresentation {
        FileRepresentation(contentType: .movie) { video in
            SentTransferredFile(video.url)
        } importing: { received in
            // Copy to a permanent location
            let destination = FileManager.default.temporaryDirectory
                .appendingPathComponent(UUID().uuidString)
                .appendingPathExtension("mov")
            try FileManager.default.copyItem(at: received.file, to: destination)
            return PickedVideo(url: destination)
        }
    }
}

enum PickerError: LocalizedError {
    case importFailed

    var errorDescription: String? {
        "Failed to import the selected media."
    }
}

// Usage with Transferable
struct TransferablePickerView: View {
    @State private var selectedItem: PhotosPickerItem?
    @State private var pickedImage: PickedImage?
    @State private var isLoading = false

    var body: some View {
        VStack(spacing: 20) {
            if isLoading {
                ProgressView("Loading...")
            } else if let pickedImage {
                pickedImage.image
                    .resizable()
                    .scaledToFit()
                    .frame(maxHeight: 300)
            }

            PhotosPicker(selection: $selectedItem, matching: .images) {
                Label("Choose Photo", systemImage: "photo")
            }
        }
        .onChange(of: selectedItem) { _, newValue in
            Task {
                isLoading = true
                pickedImage = try? await newValue?.loadTransferable(type: PickedImage.self)
                isLoading = false
            }
        }
    }
}

PHPickerViewController (UIKit)

For UIKit codebases, use 
PHPickerViewController
 which provides the same privacy-preserving picker.

import UIKit
import PhotosUI

class PhotoPickerViewController: UIViewController, PHPickerViewControllerDelegate {
    private var selectedImages: [UIImage] = []

    func presentPicker() {
        var config = PHPickerConfiguration(photoLibrary: .shared())
        config.selectionLimit = 5          // 0 = unlimited
        config.filter = .images            // .videos, .livePhotos, .any(of:)
        config.preferredAssetRepresentationMode = .current
        config.selection = .ordered        // Maintain selection order

        let picker = PHPickerViewController(configuration: config)
        picker.delegate = self
        present(picker, animated: true)
    }

    func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
        dismiss(animated: true)

        selectedImages = []

        for result in results {
            let provider = result.itemProvider

            if provider.canLoadObject(ofClass: UIImage.self) {
                provider.loadObject(ofClass: UIImage.self) { [weak self] image, error in
                    if let image = image as? UIImage {
                        DispatchQueue.main.async {
                            self?.selectedImages.append(image)
                            self?.updateUI()
                        }
                    }
                }
            }

            // Load video
            if provider.hasItemConformingToTypeIdentifier("public.movie") {
                provider.loadFileRepresentation(forTypeIdentifier: "public.movie") { url, error in
                    guard let url else { return }
                    // Copy video to permanent location before the temporary file is deleted
                    let destination = FileManager.default.temporaryDirectory
                        .appendingPathComponent(UUID().uuidString + ".mov")
                    try? FileManager.default.copyItem(at: url, to: destination)
                }
            }
        }
    }

    private func updateUI() {
        // Update your collection view or image views
    }
}

AVCaptureSession for Custom Camera

Build a custom camera interface using 
AVCaptureSession
, 
AVCaptureDeviceInput
, and preview layers.

import AVFoundation
import UIKit

class CameraManager: NSObject {
    let session = AVCaptureSession()
    private let photoOutput = AVCapturePhotoOutput()
    private let videoOutput = AVCaptureVideoDataOutput()
    private var currentDevice: AVCaptureDevice?

    enum CameraPosition {
        case front, back
    }

    func configure(position: CameraPosition = .back) throws {
        session.beginConfiguration()
        defer { session.commitConfiguration() }

        session.sessionPreset = .photo

        // Remove existing inputs
        session.inputs.forEach { session.removeInput($0) }

        // Select camera device
        let devicePosition: AVCaptureDevice.Position = position == .back ? .back : .front
        guard let device = AVCaptureDevice.default(
            .builtInWideAngleCamera,
            for: .video,
            position: devicePosition
        ) else {
            throw CameraError.deviceNotAvailable
        }

        currentDevice = device

        // Add input
        let input = try AVCaptureDeviceInput(device: device)
        guard session.canAddInput(input) else {
            throw CameraError.cannotAddInput
        }
        session.addInput(input)

        // Add photo output
        guard session.canAddOutput(photoOutput) else {
            throw CameraError.cannotAddOutput
        }
        session.addOutput(photoOutput)
        photoOutput.isHighResolutionCaptureEnabled = true
        photoOutput.maxPhotoQualityPrioritization = .quality
    }

    func start() {
        guard !session.isRunning else { return }
        DispatchQueue.global(qos: .userInitiated).async { [weak self] in
            self?.session.startRunning()
        }
    }

    func stop() {
        guard session.isRunning else { return }
        session.stopRunning()
    }

    func switchCamera() throws {
        let newPosition: CameraPosition = currentDevice?.position == .back ? .front : .back
        try configure(position: newPosition)
    }
}

enum CameraError: LocalizedError {
    case deviceNotAvailable
    case cannotAddInput
    case cannotAddOutput
    case permissionDenied

    var errorDescription: String? {
        switch self {
        case .deviceNotAvailable: return "Camera device not available."
        case .cannotAddInput: return "Cannot add camera input."
        case .cannotAddOutput: return "Cannot add camera output."
        case .permissionDenied: return "Camera access denied."
        }
    }
}

AVCapturePhotoOutput and AVCaptureVideoDataOutput

Capture photos and process live video frames from the camera session.

import AVFoundation
import UIKit

// Photo capture delegate
class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
    private let completion: (Result<UIImage, Error>) -> Void

    init(completion: @escaping (Result<UIImage, Error>) -> Void) {
        self.completion = completion
    }

    func photoOutput(
        _ output: AVCapturePhotoOutput,
        didFinishProcessingPhoto photo: AVCapturePhoto,
        error: Error?
    ) {
        if let error {
            completion(.failure(error))
            return
        }

        guard let data = photo.fileDataRepresentation(),
              let image = UIImage(data: data) else {
            completion(.failure(CameraError.cannotAddOutput))
            return
        }

        completion(.success(image))
    }
}

// Extend CameraManager with photo capture
extension CameraManager {
    private static var captureDelegate: PhotoCaptureDelegate?

    func capturePhoto(completion: @escaping (Result<UIImage, Error>) -> Void) {
        let settings = AVCapturePhotoSettings()
        settings.flashMode = .auto
        settings.isHighResolutionPhotoEnabled = true

        // Configure format
        if let previewPixelType = settings.availablePreviewPhotoPixelFormatTypes.first {
            settings.previewPhotoFormat = [
                kCVPixelBufferPixelFormatTypeKey as String: previewPixelType
            ]
        }

        let delegate = PhotoCaptureDelegate(completion: completion)
        Self.captureDelegate = delegate  // Retain the delegate
        photoOutput.capturePhoto(with: settings, delegate: delegate)
    }
}

// Video frame processing (for real-time analysis, filters, etc.)
class VideoFrameProcessor: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate {
    let processingQueue = DispatchQueue(label: "com.app.videoProcessing")
    var onFrameCaptured: ((CVPixelBuffer, CMTime) -> Void)?

    func captureOutput(
        _ output: AVCaptureOutput,
        didOutput sampleBuffer: CMSampleBuffer,
        from connection: AVCaptureConnection
    ) {
        guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
        let timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
        onFrameCaptured?(pixelBuffer, timestamp)
    }

    func captureOutput(
        _ output: AVCaptureOutput,
        didDrop sampleBuffer: CMSampleBuffer,
        from connection: AVCaptureConnection
    ) {
        // Frame was dropped due to processing backpressure
        print("Frame dropped")
    }
}

// Adding video output to camera manager
extension CameraManager {
    func addVideoOutput(processor: VideoFrameProcessor) {
        let videoOutput = AVCaptureVideoDataOutput()
        videoOutput.setSampleBufferDelegate(processor, queue: processor.processingQueue)
        videoOutput.alwaysDiscardsLateVideoFrames = true

        if session.canAddOutput(videoOutput) {
            session.addOutput(videoOutput)
        }
    }
}

VideoPlayer in SwiftUI with Custom Controls

Use 
AVKit.VideoPlayer
 for standard playback or build custom controls with 
AVPlayer
.

import SwiftUI
import AVKit

// Simple video player with built-in controls
struct SimpleVideoPlayerView: View {
    @State private var player = AVPlayer(url: URL(string: "https://example.com/video.mp4")!)

    var body: some View {
        VideoPlayer(player: player) {
            // Optional overlay content
            VStack {
                Spacer()
                HStack {
                    Spacer()
                    Text("Live")
                        .font(.caption.bold())
                        .padding(.horizontal, 8)
                        .padding(.vertical, 4)
                        .background(.red)
                        .foregroundStyle(.white)
                        .clipShape(Capsule())
                        .padding()
                }
            }
        }
        .frame(height: 300)
        .clipShape(RoundedRectangle(cornerRadius: 12))
        .onDisappear {
            player.pause()
        }
    }
}

// Custom video player with custom controls
struct CustomVideoPlayerView: View {
    let url: URL

    @State private var player: AVPlayer?
    @State private var isPlaying = false
    @State private var currentTime: Double = 0
    @State private var duration: Double = 0
    @State private var showControls = true

    var body: some View {
        ZStack {
            // Video layer
            VideoPlayerLayer(player: player)
                .onTapGesture {
                    withAnimation(.easeInOut(duration: 0.2)) {
                        showControls.toggle()
                    }
                }

            // Custom controls overlay
            if showControls {
                controlsOverlay
                    .transition(.opacity)
            }
        }
        .frame(height: 300)
        .clipShape(RoundedRectangle(cornerRadius: 12))
        .onAppear {
            setupPlayer()
        }
        .onDisappear {
            player?.pause()
        }
    }

    private var controlsOverlay: some View {
        ZStack {
            // Semi-transparent background
            Color.black.opacity(0.4)

            VStack {
                Spacer()

                // Play/Pause button
                Button {
                    togglePlayback()
                } label: {
                    Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill")
                        .font(.system(size: 50))
                        .foregroundStyle(.white)
                }

                Spacer()

                // Progress bar
                HStack(spacing: 12) {
                    Text(formatTime(currentTime))
                        .font(.caption.monospacedDigit())
                        .foregroundStyle(.white)

                    Slider(value: $currentTime, in: 0...max(duration, 1)) { editing in
                        if !editing {
                            player?.seek(to: CMTime(seconds: currentTime, preferredTimescale: 600))
                        }
                    }
                    .tint(.white)

                    Text(formatTime(duration))
                        .font(.caption.monospacedDigit())
                        .foregroundStyle(.white)
                }
                .padding(.horizontal)
                .padding(.bottom, 16)
            }
        }
    }

    private func setupPlayer() {
        player = AVPlayer(url: url)

        // Observe time
        player?.addPeriodicTimeObserver(
            forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
            queue: .main
        ) { time in
            currentTime = time.seconds
        }

        // Get duration
        Task {
            if let durationCM = try? await player?.currentItem?.asset.load(.duration) {
                duration = durationCM.seconds
            }
        }
    }

    private func togglePlayback() {
        if isPlaying {
            player?.pause()
        } else {
            player?.play()
        }
        isPlaying.toggle()
    }

    private func formatTime(_ seconds: Double) -> String {
        let mins = Int(seconds) / 60
        let secs = Int(seconds) % 60
        return String(format: "%d:%02d", mins, secs)
    }
}

// UIViewRepresentable for AVPlayerLayer
struct VideoPlayerLayer: UIViewRepresentable {
    let player: AVPlayer?

    func makeUIView(context: Context) -> PlayerUIView {
        PlayerUIView(player: player)
    }

    func updateUIView(_ uiView: PlayerUIView, context: Context) {
        uiView.playerLayer.player = player
    }
}

class PlayerUIView: UIView {
    var playerLayer: AVPlayerLayer

    init(player: AVPlayer?) {
        playerLayer = AVPlayerLayer(player: player)
        super.init(frame: .zero)
        playerLayer.videoGravity = .resizeAspectFill
        layer.addSublayer(playerLayer)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        playerLayer.frame = bounds
    }
}

Picture-in-Picture (AVPictureInPictureController)

Enable Picture-in-Picture for video playback so users can continue watching in a floating window.

import AVKit
import SwiftUI

class PiPManager: NSObject, AVPictureInPictureControllerDelegate {
    private var pipController: AVPictureInPictureController?
    var onPiPStatusChanged: ((Bool) -> Void)?

    func setup(with playerLayer: AVPlayerLayer) {
        guard AVPictureInPictureController.isPictureInPictureSupported() else {
            print("PiP is not supported on this device")
            return
        }

        pipController = AVPictureInPictureController(playerLayer: playerLayer)
        pipController?.delegate = self
        pipController?.canStartPictureInPictureAutomaticallyFromInline = true
    }

    func togglePiP() {
        guard let pipController else { return }

        if pipController.isPictureInPictureActive {
            pipController.stopPictureInPicture()
        } else {
            pipController.startPictureInPicture()
        }
    }

    var isPiPActive: Bool {
        pipController?.isPictureInPictureActive ?? false
    }

    var isPiPPossible: Bool {
        pipController?.isPictureInPicturePossible ?? false
    }

    // MARK: - AVPictureInPictureControllerDelegate

    func pictureInPictureControllerWillStartPictureInPicture(
        _ pictureInPictureController: AVPictureInPictureController
    ) {
        onPiPStatusChanged?(true)
    }

    func pictureInPictureControllerDidStopPictureInPicture(
        _ pictureInPictureController: AVPictureInPictureController
    ) {
        onPiPStatusChanged?(false)
    }

    func pictureInPictureController(
        _ pictureInPictureController: AVPictureInPictureController,
        failedToStartPictureInPictureWithError error: Error
    ) {
        print("PiP failed to start: \(error.localizedDescription)")
    }

    func pictureInPictureController(
        _ pictureInPictureController: AVPictureInPictureController,
        restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
    ) {
        // Restore your UI when PiP stops
        completionHandler(true)
    }
}

// Configure audio session for PiP background audio
import AVFAudio

func configureAudioSession() {
    do {
        let session = AVAudioSession.sharedInstance()
        try session.setCategory(.playback, mode: .moviePlayback)
        try session.setActive(true)
    } catch {
        print("Audio session configuration failed: \(error)")
    }
}

PHPhotoLibrary for Saving Photos and Videos

Save captured photos and videos to the user's photo library with proper permissions handling.

import Photos
import UIKit

class PhotoLibraryManager {
    // Check and request authorization
    static func requestAuthorization() async -> PHAuthorizationStatus {
        let status = PHPhotoLibrary.authorizationStatus(for: .addOnly)

        switch status {
        case .notDetermined:
            return await PHPhotoLibrary.requestAuthorization(for: .addOnly)
        default:
            return status
        }
    }

    // Save a UIImage to the photo library
    static func saveImage(_ image: UIImage) async throws {
        let status = await requestAuthorization()
        guard status == .authorized || status == .limited else {
            throw PhotoLibraryError.permissionDenied
        }

        try await PHPhotoLibrary.shared().performChanges {
            let request = PHAssetCreationRequest.forAsset()
            guard let imageData = image.jpegData(compressionQuality: 0.9) else {
                return
            }
            request.addResource(with: .photo, data: imageData, options: nil)
            request.creationDate = Date()
        }
    }

    // Save a video file to the photo library
    static func saveVideo(at url: URL) async throws {
        let status = await requestAuthorization()
        guard status == .authorized || status == .limited else {
            throw PhotoLibraryError.permissionDenied
        }

        try await PHPhotoLibrary.shared().performChanges {
            PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: url)
        }
    }

    // Save to a specific album (create if needed)
    static func saveImage(_ image: UIImage, toAlbum albumName: String) async throws {
        let status = await requestAuthorization()
        guard status == .authorized else {
            throw PhotoLibraryError.permissionDenied
        }

        // Find or create the album
        let album = try await findOrCreateAlbum(named: albumName)

        try await PHPhotoLibrary.shared().performChanges {
            let assetRequest = PHAssetCreationRequest.forAsset()
            guard let imageData = image.jpegData(compressionQuality: 0.9) else { return }
            assetRequest.addResource(with: .photo, data: imageData, options: nil)

            guard let placeholder = assetRequest.placeholderForCreatedAsset,
                  let albumChangeRequest = PHAssetCollectionChangeRequest(for: album) else {
                return
            }
            albumChangeRequest.addAssets([placeholder] as NSFastEnumeration)
        }
    }

    private static func findOrCreateAlbum(named name: String) async throws -> PHAssetCollection {
        // Search for existing album
        let fetchOptions = PHFetchOptions()
        fetchOptions.predicate = NSPredicate(format: "title = %@", name)
        let collections = PHAssetCollection.fetchAssetCollections(
            with: .album,
            subtype: .any,
            options: fetchOptions
        )

        if let existing = collections.firstObject {
            return existing
        }

        // Create new album
        var placeholder: PHObjectPlaceholder?
        try await PHPhotoLibrary.shared().performChanges {
            let request = PHAssetCollectionChangeRequest.creationRequestForAssetCollection(withTitle: name)
            placeholder = request.placeholderForCreatedAssetCollection
        }

        guard let placeholder,
              let album = PHAssetCollection.fetchAssetCollections(
                  withLocalIdentifiers: [placeholder.localIdentifier],
                  options: nil
              ).firstObject else {
            throw PhotoLibraryError.albumCreationFailed
        }

        return album
    }
}

enum PhotoLibraryError: LocalizedError {
    case permissionDenied
    case albumCreationFailed

    var errorDescription: String? {
        switch self {
        case .permissionDenied:
            return "Photo library access denied. Enable it in Settings."
        case .albumCreationFailed:
            return "Failed to create photo album."
        }
    }
}

Complete Photo Picker and Custom Camera Example

A full-featured media capture app with both a photo picker and custom camera interface.

import SwiftUI
import PhotosUI
import AVFoundation

// MARK: - Main View

struct MediaCaptureView: View {
    @State private var selectedTab = 0
    @State private var capturedImages: [UIImage] = []

    var body: some View {
        NavigationStack {
            VStack(spacing: 0) {
                // Photo grid
                ScrollView {
                    if capturedImages.isEmpty {
                        ContentUnavailableView(
                            "No Photos Yet",
                            systemImage: "photo.on.rectangle.angled",
                            description: Text("Take a photo or pick from your library.")
                        )
                    } else {
                        LazyVGrid(columns: [
                            GridItem(.flexible(), spacing: 2),
                            GridItem(.flexible(), spacing: 2),
                            GridItem(.flexible(), spacing: 2)
                        ], spacing: 2) {
                            ForEach(capturedImages.indices, id: \.self) { index in
                                Image(uiImage: capturedImages[index])
                                    .resizable()
                                    .scaledToFill()
                                    .frame(minHeight: 120)
                                    .clipped()
                            }
                        }
                    }
                }

                // Bottom toolbar
                MediaToolbar(
                    onPhotoPicked: { images in
                        capturedImages.append(contentsOf: images)
                    },
                    onPhotoTaken: { image in
                        capturedImages.append(image)
                    }
                )
            }
            .navigationTitle("Photos")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    if !capturedImages.isEmpty {
                        Button("Save All") {
                            saveAllPhotos()
                        }
                    }
                }
            }
        }
    }

    private func saveAllPhotos() {
        Task {
            for image in capturedImages {
                try? await PhotoLibraryManager.saveImage(image, toAlbum: "My App")
            }
        }
    }
}

// MARK: - Media Toolbar

struct MediaToolbar: View {
    let onPhotoPicked: ([UIImage]) -> Void
    let onPhotoTaken: (UIImage) -> Void

    @State private var selectedItems: [PhotosPickerItem] = []
    @State private var showCamera = false

    var body: some View {
        HStack(spacing: 40) {
            // Photo picker button
            PhotosPicker(
                selection: $selectedItems,
                maxSelectionCount: 20,
                matching: .images
            ) {
                VStack(spacing: 4) {
                    Image(systemName: "photo.on.rectangle")
                        .font(.title2)
                    Text("Library")
                        .font(.caption)
                }
            }

            // Camera button
            Button {
                showCamera = true
            } label: {
                VStack(spacing: 4) {
                    Image(systemName: "camera.fill")
                        .font(.title2)
                    Text("Camera")
                        .font(.caption)
                }
            }
        }
        .padding()
        .frame(maxWidth: .infinity)
        .background(.ultraThinMaterial)
        .onChange(of: selectedItems) { _, newValue in
            Task {
                var images: [UIImage] = []
                for item in newValue {
                    if let data = try? await item.loadTransferable(type: Data.self),
                       let image = UIImage(data: data) {
                        images.append(image)
                    }
                }
                onPhotoPicked(images)
                selectedItems = []
            }
        }
        .fullScreenCover(isPresented: $showCamera) {
            CameraView(onCapture: onPhotoTaken)
        }
    }
}

// MARK: - Camera View

struct CameraView: View {
    let onCapture: (UIImage) -> Void

    @Environment(\.dismiss) private var dismiss
    @State private var cameraManager = SwiftUICameraManager()
    @State private var lastCapturedImage: UIImage?
    @State private var flashEnabled = false
    @State private var isFrontCamera = false

    var body: some View {
        ZStack {
            // Camera preview
            CameraPreviewView(session: cameraManager.session)
                .ignoresSafeArea()

            VStack {
                // Top bar
                HStack {
                    Button("Cancel") {
                        dismiss()
                    }
                    .foregroundStyle(.white)

                    Spacer()

                    Button {
                        flashEnabled.toggle()
                    } label: {
                        Image(systemName: flashEnabled ? "bolt.fill" : "bolt.slash")
                            .foregroundStyle(.white)
                            .font(.title3)
                    }
                }
                .padding()

                Spacer()

                // Bottom controls
                HStack(spacing: 60) {
                    // Last captured thumbnail
                    if let lastImage = lastCapturedImage {
                        Image(uiImage: lastImage)
                            .resizable()
                            .scaledToFill()
                            .frame(width: 50, height: 50)
                            .clipShape(RoundedRectangle(cornerRadius: 8))
                    } else {
                        Color.clear.frame(width: 50, height: 50)
                    }

                    // Capture button
                    Button {
                        capturePhoto()
                    } label: {
                        Circle()
                            .fill(.white)
                            .frame(width: 70, height: 70)
                            .overlay(
                                Circle()
                                    .stroke(.white, lineWidth: 3)
                                    .frame(width: 80, height: 80)
                            )
                    }

                    // Switch camera
                    Button {
                        switchCamera()
                    } label: {
                        Image(systemName: "camera.rotate")
                            .font(.title2)
                            .foregroundStyle(.white)
                            .frame(width: 50, height: 50)
                    }
                }
                .padding(.bottom, 40)
            }
        }
        .onAppear {
            Task {
                await cameraManager.checkPermissionAndStart()
            }
        }
        .onDisappear {
            cameraManager.stop()
        }
    }

    private func capturePhoto() {
        cameraManager.capturePhoto { result in
            if case .success(let image) = result {
                lastCapturedImage = image
                onCapture(image)
            }
        }
    }

    private func switchCamera() {
        isFrontCamera.toggle()
        try? cameraManager.switchCamera()
    }
}

// MARK: - Camera Preview (UIViewRepresentable)

struct CameraPreviewView: UIViewRepresentable {
    let session: AVCaptureSession

    func makeUIView(context: Context) -> CameraPreviewUIView {
        CameraPreviewUIView(session: session)
    }

    func updateUIView(_ uiView: CameraPreviewUIView, context: Context) {}
}

class CameraPreviewUIView: UIView {
    private var previewLayer: AVCaptureVideoPreviewLayer

    init(session: AVCaptureSession) {
        previewLayer = AVCaptureVideoPreviewLayer(session: session)
        super.init(frame: .zero)
        previewLayer.videoGravity = .resizeAspectFill
        layer.addSublayer(previewLayer)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        previewLayer.frame = bounds
    }
}

// MARK: - SwiftUI Camera Manager

@Observable
class SwiftUICameraManager {
    let session = AVCaptureSession()
    private let photoOutput = AVCapturePhotoOutput()
    private var captureDelegate: PhotoCaptureHandler?

    func checkPermissionAndStart() async {
        switch AVCaptureDevice.authorizationStatus(for: .video) {
        case .authorized:
            setupAndStart()
        case .notDetermined:
            let granted = await AVCaptureDevice.requestAccess(for: .video)
            if granted { setupAndStart() }
        default:
            break
        }
    }

    private func setupAndStart() {
        session.beginConfiguration()
        session.sessionPreset = .photo

        guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
              let input = try? AVCaptureDeviceInput(device: device),
              session.canAddInput(input) else {
            session.commitConfiguration()
            return
        }

        session.addInput(input)

        if session.canAddOutput(photoOutput) {
            session.addOutput(photoOutput)
        }

        session.commitConfiguration()

        DispatchQueue.global(qos: .userInitiated).async { [weak self] in
            self?.session.startRunning()
        }
    }

    func stop() {
        session.stopRunning()
    }

    func switchCamera() throws {
        session.beginConfiguration()
        defer { session.commitConfiguration() }

        guard let currentInput = session.inputs.first as? AVCaptureDeviceInput else { return }
        session.removeInput(currentInput)

        let newPosition: AVCaptureDevice.Position = currentInput.device.position == .back ? .front : .back
        guard let newDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: newPosition),
              let newInput = try? AVCaptureDeviceInput(device: newDevice),
              session.canAddInput(newInput) else {
            // Re-add old input if switch fails
            if session.canAddInput(currentInput) { session.addInput(currentInput) }
            return
        }
        session.addInput(newInput)
    }

    func capturePhoto(completion: @escaping (Result<UIImage, Error>) -> Void) {
        let settings = AVCapturePhotoSettings()
        let handler = PhotoCaptureHandler(completion: completion)
        captureDelegate = handler
        photoOutput.capturePhoto(with: settings, delegate: handler)
    }
}

class PhotoCaptureHandler: NSObject, AVCapturePhotoCaptureDelegate {
    private let completion: (Result<UIImage, Error>) -> Void

    init(completion: @escaping (Result<UIImage, Error>) -> Void) {
        self.completion = completion
    }

    func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
        if let error {
            DispatchQueue.main.async { self.completion(.failure(error)) }
            return
        }
        guard let data = photo.fileDataRepresentation(),
              let image = UIImage(data: data) else {
            DispatchQueue.main.async { self.completion(.failure(CameraError.cannotAddOutput)) }
            return
        }
        DispatchQueue.main.async { self.completion(.success(image)) }
    }
}

Key Considerations

PhotosPicker
: No permissions prompt required. The system mediates access. Available iOS 16+.
Camera permissions
: Add 
NSCameraUsageDescription
 to Info.plist. Always check authorization before accessing 
AVCaptureSession
.
Photo library permissions
: Add 
NSPhotoLibraryAddUsageDescription
 for save-only access. Add 
NSPhotoLibraryUsageDescription
 for full read/write access.
Background audio
: Set the audio session category to 
.playback
 and enable the "Audio, AirPlay, and Picture in Picture" background mode for PiP.
Memory
: Large photos can consume significant memory. Use 
CGImageSource
 for progressive loading of very large images.
Thread safety
: 
AVCaptureSession
 configuration must happen on a single thread. Never call 
startRunning()
 on the main thread.
Transferable
: Use 
Transferable
 with 
PhotosPickerItem
 for type-safe, modern photo loading (iOS 16+).
Video
: For video playback, prefer 
AVPlayer
 with 
VideoPlayer
 in SwiftUI. For recording, use 
AVCaptureMovieFileOutput
.

---

# RealityKit -- Complete Guide for 3D Rendering, AR, and…
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-realitykit.html

Graphics, 3D, and Games · Reference guideRealityKit -- Complete Guide for 3D Rendering, AR, and Spatial AppsRepository guidance for RealityKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Overview
1. Core Concepts: Entity Component System
2. SwiftUI: RealityView (iOS 18 / visionOS 1+)

Loading a USDZ asset
Reality Composer Pro scenes (visionOS)

3. UIKit: ARView for iOS AR
4. Materials and Lighting

PBR (PhysicallyBasedMaterial)
Unlit / shaded surface materials
Custom shader graphs

5. Animation

Built-in skeletal animations
Transform animation (move/scale/rotate)

6. Physics
7. Input and Gestures (visionOS)
8. Audio
9. Lighting (visionOS / macOS)
10. Networking and Multipeer Sync
11. ECS: Custom Components and Systems

Component
System

12. Object Capture (iOS 17+)
13. Performance Tips
14. Common Pitfalls
15. Platform Differences
16. 3D Content in an Ordinary iOS Screen (non-AR)

The two lines that opt out
A light rig, because non-AR scenes have no environment
Framing on a phone: you have about 27 degrees, not 55
Orbit controls silently override your camera
Text in 3D: MeshResource.generateText does not exist on iOS
Scene is ambiguous when both frameworks are imported
Checklist for non-AR 3D on iOS

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Load or create entities
↓2
Attach components
↓3
Add entities to a scene
↓4
Update behavior and interactions

02 / ArchitectureResponsibility boundariesBoundary 1
Entity hierarchyBoundary 2
Components and systemsBoundary 3
Rendered sceneConnected responsibilities, not a required class hierarchy or an execution trace.
Overview

RealityKit is Apple's modern 3D engine and the default renderer for AR on iOS/iPadOS and for spatial content on visionOS. It uses a Swift-first 
Entity Component System (ECS)
, ships with PBR materials, physics, audio, networking sync, and tightly integrates with USDZ and Reality Composer Pro. Pair it with 
ARKit
 for sensor data on iOS, or with 
RealityView
 / 
ImmersiveSpace
 on visionOS.

Available on 
iOS 13+
, 
macOS 10.15+
, 
tvOS 16+
, and 
visionOS 1.0+
. SwiftUI integration via 
RealityView
 requires iOS 18+ / visionOS 1+ / macOS 15+ for the unified API; older code uses 
ARView
 (UIKit) wrapped in 
UIViewRepresentable
.

1. Core Concepts: Entity Component System

import RealityKit

// Entity: a node in the scene graph
let cube = ModelEntity(
    mesh: .generateBox(size: 0.2),
    materials: [SimpleMaterial(color: .systemBlue, isMetallic: false)]
)

// Components: data attached to entities
cube.components.set(InputTargetComponent())
cube.components.set(CollisionComponent(shapes: [.generateBox(size: [0.2, 0.2, 0.2])]))
cube.components.set(HoverEffectComponent())                // visionOS hover

// Anchor: where in the world the subtree lives
let anchor = AnchorEntity(world: .zero)
anchor.addChild(cube)

The full hierarchy: 
Scene
 -> 
AnchorEntity
 -> 
Entity
 (with components and children).

2. SwiftUI: 
RealityView
 (iOS 18 / visionOS 1+)

import SwiftUI
import RealityKit

struct GalaxyView: View {
    var body: some View {
        RealityView { content in
            // Initial setup -- runs once
            let sphere = ModelEntity(
                mesh: .generateSphere(radius: 0.1),
                materials: [SimpleMaterial(color: .orange, roughness: 0.3, isMetallic: true)]
            )
            sphere.position = [0, 1.5, -1]
            content.add(sphere)
        } update: { content in
            // Re-runs whenever bound state changes
        }
    }
}

Loading a USDZ asset

RealityView { content in
    if let model = try? await Entity(named: "Toy_robot", in: .main) {
        model.position = [0, 0, -1]
        content.add(model)
    }
}

Reality Composer Pro scenes (visionOS)

RealityView { content in
    if let scene = try? await Entity(named: "GalaxyScene", in: realityKitContentBundle) {
        content.add(scene)
    }
}

3. UIKit: 
ARView
 for iOS AR

import RealityKit
import ARKit

let arView = ARView(frame: .zero, cameraMode: .ar, automaticallyConfigureSession: true)

// Place an entity 1 meter in front of the camera
let anchor = AnchorEntity(plane: .horizontal, classification: .floor, minimumBounds: [0.5, 0.5])
let modelEntity = try ModelEntity.load(named: "Robot")
anchor.addChild(modelEntity)
arView.scene.addAnchor(anchor)

ARView
 automatically composites people occlusion, depth, and lighting estimation when the session opts in -- see 
docs/frameworks/arkit.md
.

4. Materials and Lighting

PBR (PhysicallyBasedMaterial)

var material = PhysicallyBasedMaterial()
material.baseColor = .init(tint: .systemTeal)
material.metallic = 0.9
material.roughness = 0.2
material.emissiveColor = .init(color: .blue)
material.emissiveIntensity = 0.5

// Texture maps
material.baseColor = .init(texture: .init(try .load(named: "albedo")))
material.normal = .init(texture: .init(try .load(named: "normal")))

let entity = ModelEntity(mesh: .generateSphere(radius: 0.1), materials: [material])

Unlit / shaded surface materials

let unlit = UnlitMaterial(color: .white)

Custom shader graphs

Author shaders visually in 
Reality Composer Pro
, then bind parameters at runtime:

guard var material = entity.model?.materials.first as? ShaderGraphMaterial else { return }
try material.setParameter(name: "Tint", value: .color(.systemPink))
entity.model?.materials = [material]

5. Animation

Built-in skeletal animations

let robot = try ModelEntity.load(named: "Robot")
if let animation = robot.availableAnimations.first {
    robot.playAnimation(animation.repeat(duration: .infinity), transitionDuration: 0.5)
}

Transform animation (move/scale/rotate)

let move = FromToByAnimation<Transform>(
    name: "slide",
    from: .init(translation: [0, 0, 0]),
    to: .init(translation: [0.5, 0, 0]),
    duration: 1.0,
    bindTarget: .transform
)

let resource = try AnimationResource.generate(with: move)
entity.playAnimation(resource)

6. Physics

// 1. Generate collision shapes from the mesh
entity.generateCollisionShapes(recursive: true)

// 2. Add a physics body
entity.components.set(
    PhysicsBodyComponent(
        massProperties: .init(mass: 1.0),
        material: .generate(friction: 0.4, restitution: 0.2),
        mode: .dynamic
    )
)

// 3. Apply forces / impulses
entity.applyLinearImpulse([0, 2, 0], relativeTo: nil)

PhysicsBodyComponent.mode
: 
.dynamic
, 
.kinematic
, or 
.static
.

7. Input and Gestures (visionOS)

RealityView { content in
    let cube = ModelEntity(mesh: .generateBox(size: 0.2),
                           materials: [SimpleMaterial(color: .green, isMetallic: false)])
    cube.components.set(InputTargetComponent())
    cube.components.set(CollisionComponent(shapes: [.generateBox(size: [0.2, 0.2, 0.2])]))
    content.add(cube)
}
.gesture(
    DragGesture()
        .targetedToAnyEntity()
        .onChanged { value in
            value.entity.position = value.convert(value.location3D, from: .local, to: value.entity.parent!)
        }
)

For iOS, use 
arView.installGestures([.translation, .rotation, .scale], for: entity)
.

8. Audio

let resource = try AudioFileResource.load(named: "spaceship.wav",
                                          configuration: .init(shouldLoop: true))
let controller = entity.prepareAudio(resource)
controller.gain = -6
controller.play()

Spatial audio is automatic when the entity has a position; head-tracked rendering happens for free on visionOS / AirPods Pro.

9. Lighting (visionOS / macOS)

let light = DirectionalLight()
light.light.intensity = 5000
light.light.color = .white
light.shadow = .init(maximumDistance: 10, depthBias: 0.001)
light.orientation = simd_quatf(angle: -.pi / 4, axis: [1, 0, 0])
content.add(light)

For image-based lighting:

let env = try await EnvironmentResource(named: "studio_small")
content.environment.lighting.resource = env

iOS AR uses real-world lighting estimation automatically -- do not add manual lights unless you want extra fill.

10. Networking and Multipeer Sync

import MultipeerConnectivity

let session = MCSession(peer: MCPeerID(displayName: UIDevice.current.name))
arView.scene.synchronizationService = try? MultipeerConnectivityService(session: session)

// Tag entities you want replicated
entity.synchronization?.ownershipTransferMode = .autoAccept

All entities with a 
SynchronizationComponent
 (added by default to AnchorEntity) are kept in sync across peers.

11. ECS: Custom Components and Systems

Component

struct SpinComponent: Component {
    var radiansPerSecond: Float = .pi
}

System

final class SpinSystem: System {
    static let query = EntityQuery(where: .has(SpinComponent.self))

    init(scene: Scene) {}

    func update(context: SceneUpdateContext) {
        for entity in context.scene.performQuery(Self.query) {
            guard let spin = entity.components[SpinComponent.self] else { continue }
            entity.transform.rotation *= simd_quatf(
                angle: spin.radiansPerSecond * Float(context.deltaTime),
                axis: [0, 1, 0]
            )
        }
    }
}

// Register once at app start
SpinComponent.registerComponent()
SpinSystem.registerSystem()

Now any entity with 
SpinComponent()
 rotates automatically -- no per-entity update logic.

12. Object Capture (iOS 17+)

import RealityKit

let session = ObjectCaptureSession()
session.start(imagesDirectory: capturesURL,
              configuration: .init(isOverCaptureEnabled: true))

// Later, generate the model
let photogrammetry = try PhotogrammetrySession(input: capturesURL)
try photogrammetry.process(requests: [
    .modelFile(url: outputURL, detail: .reduced)
])

Requires LiDAR for guided capture; works on Macs for cloud-quality processing.

13. Performance Tips

Reuse mesh and material resources
 -- 
MeshResource.generateBox(...)
 is cheap, but loading a USDZ multiple times is not. Load once, clone with 
entity.clone(recursive: true)
.
Disable shadows on high-poly entities
 when frame-rate dips -- 
model.components[ShadowComponent.self] = nil
.
Cap physics body count
 -- mark static geometry as 
.static
; only player/interactable items as 
.dynamic
.
Use LowLevelMesh (iOS 18+)
 for procedural geometry instead of regenerating 
MeshResource
 every frame.
Profile with the RealityKit Trace template in Instruments
 -- shows GPU time per pass and ECS update breakdown.
Bake lighting into Reality Composer Pro scenes
 rather than adding runtime lights when possible.

14. Common Pitfalls

Forgetting generateCollisionShapes(recursive:)
 -- raycasts and gestures silently miss the entity.
Adding entities outside an AnchorEntity
 -- nothing renders. Always parent under an anchor or 
content
.
Using SimpleMaterial for production
 -- it lacks PBR. Switch to 
PhysicallyBasedMaterial
.
Modifying entity.transform from a background thread
 -- RealityKit is 
main-thread only
. Schedule updates inside 
RealityView.update
 or a 
System
.
Skipping registerComponent() / registerSystem()
 -- custom ECS code silently does nothing if not registered.
Loading USDZ synchronously
 -- use 
try await Entity(named:)
 to avoid stalling the render loop.
Confusing iOS RealityKit with visionOS RealityKit
 -- 
ImmersiveSpace
, hand tracking, and eye tracking are visionOS-only. 
RealityView and its content closure are not
: they exist on iOS 18+ and macOS 15+, where the content type is 
RealityViewCameraContent
. See the platform table immediately below, and section 16 for building non-AR 3D on iOS.

15. Platform Differences

Capability

iOS

macOS

visionOS

RealityView (SwiftUI)

iOS 18+

macOS 15+

visionOS 1+

ARView (UIKit/AppKit)

All

All

--

AR session

ARKit

--

ARKit-for-visionOS

Hand/eye input

--

--

Yes

Reality Composer Pro scenes

Yes

Yes

Yes

Object capture

iOS 17+

macOS 12+

--

16. 3D Content in an Ordinary iOS Screen (non-AR)

Load this when
 you want 3D in a normal app screen — a product viewer, a
card carousel, a data sculpture — with no camera and no world tracking.

This is the most common 3D request on iOS and the easiest one to get wrong,
because 
RealityView on iOS defaults to world tracking
. Follow the AR
sections above for a non-AR screen and you get a camera-permission prompt and a
live camera feed behind your content — on a screen that was never meant to see
through the phone.

The two lines that opt out

import RealityKit
import SwiftUI

struct GalleryView: View {
    var body: some View {
        RealityView { content in
            // 1. Opt out of world tracking. Without this the view starts an AR
            //    session, prompts for camera access, and composites your scene
            //    over the camera feed.
            content.camera = .virtual

            // 2. Having opted out, you own the camera. There is no default one,
            //    so without this the scene renders from the origin.
            let camera = PerspectiveCamera()
            camera.camera.fieldOfViewInDegrees = 55
            camera.look(at: .zero, from: [0, 0.35, 1.6], relativeTo: nil)
            content.add(camera)

            content.add(makeLightRig())
            content.add(makeScene())
        }
    }
}

content.camera
 and 
PerspectiveCamera
 require 
iOS 18+
. On iOS 17 the
equivalent is 
ARView(frame:cameraMode:automaticallyConfigureSession:)
 with

cameraMode: .nonAR
, wrapped in a 
UIViewRepresentable
:

struct LegacyRealityView: UIViewRepresentable {
    func makeUIView(context: Context) -> ARView {
        // .nonAR is the iOS 17 equivalent of `content.camera = .virtual`.
        let view = ARView(frame: .zero, cameraMode: .nonAR, automaticallyConfigureSession: false)
        view.environment.background = .color(.clear)
        return view
    }

    func updateUIView(_ view: ARView, context: Context) {}
}

A light rig, because non-AR scenes have no environment

An AR scene inherits real-world lighting estimation. A virtual one inherits
nothing, so 
PhysicallyBasedMaterial
 renders black until you light it. The
minimum that reads well:

private func makeLightRig() -> Entity {
    let rig = Entity()

    let key = DirectionalLight()
    key.light.intensity = 2_500
    key.light.color = .white
    key.look(at: .zero, from: [1.2, 1.8, 1.4], relativeTo: nil)
    rig.addChild(key)

    let fill = DirectionalLight()
    fill.light.intensity = 800
    fill.look(at: .zero, from: [-1.5, 0.4, 1.0], relativeTo: nil)
    rig.addChild(fill)

    return rig
}

Framing on a phone: you have about 27 degrees, not 55

PerspectiveCamera.camera.fieldOfViewInDegrees
 is the 
vertical
 field.
Horizontal is derived from the aspect ratio, and a portrait iPhone is roughly
0.46 wide-to-tall:

horizontal = 2 · atan( tan(vertical / 2) · aspect )
           = 2 · atan( tan(55° / 2) · 0.46 )
           ≈ 27°

Twenty-seven degrees is narrow.
 The natural first idea — a ring of cards
around the camera — puts all but one of them outside the frustum: you see one
card face and the backs of its neighbours. There is no warning; the content is
simply not there.

The rule that follows: 
on iPhone, spend depth and height, not width.
 A
vertical stack receding into the distance reads well in portrait; a horizontal
carousel does not, unless the items are close to the camera and few.

Layout

Portrait phone

Landscape / iPad

Vertical stack, receding in Z

Works

Works

Depth-sorted deck toward camera

Works

Works

Horizontal ring or carousel

Falls out of frame

Works

Orbit controls silently override your camera

.realityViewCameraControls(.orbit)
 is the quickest way to make a scene
inspectable. Combined with 
content.cameraTarget
, it 
auto-frames the
target's bounds and discards the PerspectiveCamera transform you just set.

Two failures follow, both easy to hit:

// WRONG — the target is assigned before its contents exist.
// Entities built in an async task have not been added yet, so the target has
// no bounds. The camera frames nothing and parks at the origin, inside the
// scene, looking at the backs of the cards.
content.cameraTarget = gallery
Task { await populate(gallery) }

// RIGHT — populate first, then hand it over.
await populate(gallery)
content.cameraTarget = gallery

// WRONG — auto-framing with no floor on scene size.
// With one small entity the bounds are tiny, so the camera zooms in until that
// single card fills the screen.
content.cameraTarget = gallery

// RIGHT — give the target a minimum extent so small scenes frame sanely.
let bounds = gallery.visualBounds(relativeTo: nil)
if bounds.boundingRadius < 0.25 {
    gallery.addChild(makeInvisibleFramingBox(radius: 0.25))
}
content.cameraTarget = gallery

Text in 3D: 
MeshResource.generateText
 does not exist on iOS

It is macOS and visionOS only — it is not in the iOS SDK at all, so the fix is
not an availability guard, it is a different approach. Render a SwiftUI view to
an image and use it as a texture:

@MainActor
func makeLabel(_ text: String) throws -> ModelEntity {
    let renderer = ImageRenderer(content:
        Text(text)
            .font(.system(.title2, design: .rounded).weight(.semibold))
            .foregroundStyle(.white)
            .padding(24)
    )
    renderer.scale = 3

    guard let cgImage = renderer.cgImage else { throw LabelError.renderFailed }

    let texture = try TextureResource(image: cgImage, options: .init(semantic: .color))
    var material = UnlitMaterial()
    material.color = .init(texture: .init(texture))
    material.blending = .transparent(opacity: 1)

    let plane = MeshResource.generatePlane(width: 0.4, height: 0.1)
    return ModelEntity(mesh: plane, materials: [material])
}

UnlitMaterial
, not 
PhysicallyBasedMaterial
: rendered text should not pick up
scene lighting, or it dims as the camera moves and stops looking like text.

Scene
 is ambiguous when both frameworks are imported

RealityKit and SwiftUI each declare a 
Scene
. The 
@main
 App file is where
they collide, because that is where components and systems get registered:

// WRONG — `some Scene` no longer resolves.
import RealityKit
import SwiftUI

@main
struct GalleryApp: App {
    var body: some Scene { WindowGroup { GalleryView() } }   // ambiguous
}

// RIGHT — qualify it, or keep RealityKit out of this file entirely.
var body: some SwiftUI.Scene { WindowGroup { GalleryView() } }

Preferably the second: register components from the view that uses them, and
the 
@main
 file never imports RealityKit.

Checklist for non-AR 3D on iOS

[ ] 
content.camera = .virtual
 set — no camera prompt, no live feed
[ ] A 
PerspectiveCamera
 added, since opting out removes the default
[ ] A light rig added; PBR materials render black without one
[ ] Layout spends depth and height, not width, on portrait phones
[ ] 
cameraTarget
 assigned only after the target has contents
[ ] Small scenes given a minimum framing extent
[ ] No 
MeshResource.generateText
 — texture route for labels
[ ] 
SwiftUI.Scene
 qualified, or RealityKit kept out of the 
@main
 file
[ ] iOS 17 fallback via 
ARView(cameraMode: .nonAR)
 if the target allows it

See also: 
docs/frameworks/arkit.md
, 
docs/platforms/visionos.md
.

---

# SceneKit -- Legacy 3D Scenes, Animation, and AR Rendering
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-scenekit.html

Graphics, 3D, and Games · Reference guideSceneKit -- Legacy 3D Scenes, Animation, and AR RenderingRepository guidance for SceneKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Overview
1. Choosing SceneKit vs RealityKit vs Metal
2. SwiftUI SceneView
3. UIKit SCNView
4. Loading Assets
5. Nodes, Transforms, and Animation
6. Materials and Lighting
7. Hit Testing and Selection
8. Physics
9. ARKit with ARSCNView
10. Custom Rendering and Metal Interop
11. Performance Checklist
12. Common Pitfalls
13. Migration Notes to RealityKit
14. Review Checklist

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Create scene content
↓2
Configure camera and lighting
↓3
Animate nodes
↓4
Profile scene performance

02 / ArchitectureResponsibility boundariesBoundary 1
Scene graphBoundary 2
Animation and physicsBoundary 3
Scene rendererConnected responsibilities, not a required class hierarchy or an execution trace.
Overview

SceneKit is Apple's high-level scene graph framework for 3D content. It describes a scene as an 
SCNScene
 containing an 
SCNNode
 hierarchy, with geometry, cameras, lights, materials, animations, physics, and positional audio attached to nodes. SceneKit renders through 
SCNView
, SwiftUI's 
SceneView
, 
SCNLayer
, or 
SCNRenderer
.

Apple describes SceneKit as 
soft-deprecated
: existing apps continue to work, but new capabilities and API investment are expected in RealityKit instead. Use SceneKit when maintaining an existing app, when a project relies on 
.scnassets
, shader modifiers, 
SCNTechnique
, or 
ARSCNView
, or when migration risk is higher than the value of moving immediately.

On visionOS, SceneKit content belongs in 2D views or textures. Build immersive spatial experiences with RealityKit and 
RealityView
.

1. Choosing SceneKit vs RealityKit vs Metal

Need

Prefer

New AR, spatial, USDZ, physics, SharePlay sync

RealityKit

Existing .scnassets, SCNNode graphs, shader modifiers

SceneKit

Custom render passes, compute, low-level GPU ownership

Metal

AR camera tracking with legacy SceneKit rendering

ARKit + ARSCNView

Use RealityKit for new features unless the app already has substantial SceneKit investment.

2. SwiftUI 
SceneView

import SwiftUI
import SceneKit

struct ModelPreview: View {
    private let scene: SCNScene = {
        let scene = SCNScene()

        let node = SCNNode(geometry: SCNSphere(radius: 0.35))
        node.geometry?.firstMaterial?.diffuse.contents = UIColor.systemTeal
        node.geometry?.firstMaterial?.roughness.contents = 0.45
        scene.rootNode.addChildNode(node)

        let camera = SCNNode()
        camera.camera = SCNCamera()
        camera.position = SCNVector3(0, 0, 2.2)
        scene.rootNode.addChildNode(camera)

        let light = SCNNode()
        light.light = SCNLight()
        light.light?.type = .omni
        light.position = SCNVector3(0, 1.2, 1.5)
        scene.rootNode.addChildNode(light)

        scene.background.contents = UIColor.systemBackground
        return scene
    }()

    var body: some View {
        SceneView(
            scene: scene,
            options: [.allowsCameraControl, .autoenablesDefaultLighting]
        )
        .ignoresSafeArea()
    }
}

SceneView
 is convenient for previews and simple viewers. For advanced control, wrap 
SCNView
 so you can own renderer delegates, hit testing, camera configuration, and lifecycle.

3. UIKit 
SCNView

import SceneKit
import UIKit

final class SceneViewController: UIViewController {
    private let sceneView = SCNView()
    private let scene = SCNScene()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.addSubview(sceneView)
        sceneView.frame = view.bounds
        sceneView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        sceneView.scene = scene
        sceneView.allowsCameraControl = true
        sceneView.autoenablesDefaultLighting = true
        sceneView.backgroundColor = .systemBackground

        addCamera()
        addContent()
    }

    private func addCamera() {
        let cameraNode = SCNNode()
        cameraNode.camera = SCNCamera()
        cameraNode.position = SCNVector3(0, 1, 4)
        scene.rootNode.addChildNode(cameraNode)
        sceneView.pointOfView = cameraNode
    }

    private func addContent() {
        let box = SCNNode(geometry: SCNBox(width: 1, height: 1, length: 1, chamferRadius: 0.06))
        box.geometry?.firstMaterial?.diffuse.contents = UIColor.systemBlue
        scene.rootNode.addChildNode(box)

        let spin = SCNAction.repeatForever(.rotateBy(x: 0, y: .pi * 2, z: 0, duration: 4))
        box.runAction(spin)
    }
}

4. Loading Assets

import SceneKit

enum SceneAssetError: Error {
    case missingAsset(String)
    case missingNode(String)
}

func loadNode(named nodeName: String, from sceneName: String) throws -> SCNNode {
    guard let scene = SCNScene(named: sceneName) else {
        throw SceneAssetError.missingAsset(sceneName)
    }
    guard let node = scene.rootNode.childNode(withName: nodeName, recursively: true) else {
        throw SceneAssetError.missingNode(nodeName)
    }
    return node.clone()
}

Store SceneKit assets in 
.scnassets
. Keep asset names stable and isolate loading behind a small factory so the rest of the app does not know file names.

If an asset arrives as USDZ or needs Reality Composer Pro authoring, route new work through RealityKit instead of adding more SceneKit-only surface area.

5. Nodes, Transforms, and Animation

let ship = SCNNode()
ship.position = SCNVector3(0, 0, -2)
ship.eulerAngles = SCNVector3(0, Float.pi / 4, 0)
ship.scale = SCNVector3(0.5, 0.5, 0.5)

let moveUp = SCNAction.moveBy(x: 0, y: 0.4, z: 0, duration: 0.8)
moveUp.timingMode = .easeInEaseOut

let bob = SCNAction.sequence([moveUp, moveUp.reversed()])
ship.runAction(.repeatForever(bob), forKey: "idle-bob")

Prefer named action keys so you can stop or replace animations deterministically.

6. Materials and Lighting

let material = SCNMaterial()
material.lightingModel = .physicallyBased
material.diffuse.contents = UIColor.systemOrange
material.metalness.contents = 0.8
material.roughness.contents = 0.25

let sphere = SCNNode(geometry: SCNSphere(radius: 0.25))
sphere.geometry?.materials = [material]

let keyLight = SCNNode()
keyLight.light = SCNLight()
keyLight.light?.type = .area
keyLight.light?.intensity = 700
keyLight.position = SCNVector3(0, 2, 2)

Use physically based materials for modern assets. Avoid mixing many lighting models in one scene unless you are matching existing art.

7. Hit Testing and Selection

@objc
func handleTap(_ recognizer: UITapGestureRecognizer) {
    let point = recognizer.location(in: sceneView)
    let results = sceneView.hitTest(point, options: [
        .searchMode: SCNHitTestSearchMode.closest.rawValue,
        .boundingBoxOnly: false,
    ])

    guard let hit = results.first else { return }
    hit.node.geometry?.firstMaterial?.emission.contents = UIColor.systemYellow
}

For SwiftUI wrappers, keep gesture handling in a coordinator and send domain-level events back through closures.

8. Physics

let floor = SCNNode(geometry: SCNFloor())
floor.physicsBody = SCNPhysicsBody.static()

let ball = SCNNode(geometry: SCNSphere(radius: 0.2))
ball.position = SCNVector3(0, 2, 0)
ball.physicsBody = SCNPhysicsBody(type: .dynamic, shape: nil)
ball.physicsBody?.mass = 0.4
ball.physicsBody?.restitution = 0.7

scene.rootNode.addChildNode(floor)
scene.rootNode.addChildNode(ball)

Physics bodies are tied to node geometry and transforms. If you change geometry after creating the physics body, recreate the body or provide an explicit 
SCNPhysicsShape
.

9. ARKit with 
ARSCNView

import ARKit
import SceneKit

final class ARSceneController: UIViewController, ARSCNViewDelegate {
    private let sceneView = ARSCNView()

    override func viewDidLoad() {
        super.viewDidLoad()
        sceneView.delegate = self
        sceneView.scene = SCNScene()
        view = sceneView
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        guard ARWorldTrackingConfiguration.isSupported else { return }

        let configuration = ARWorldTrackingConfiguration()
        configuration.planeDetection = [.horizontal, .vertical]
        configuration.environmentTexturing = .automatic
        sceneView.session.run(configuration)
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        sceneView.session.pause()
    }

    func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
        guard let planeAnchor = anchor as? ARPlaneAnchor else { return }

        let plane = SCNPlane(width: CGFloat(planeAnchor.extent.x),
                             height: CGFloat(planeAnchor.extent.z))
        plane.firstMaterial?.diffuse.contents = UIColor.systemBlue.withAlphaComponent(0.2)

        let planeNode = SCNNode(geometry: plane)
        planeNode.eulerAngles.x = -.pi / 2
        planeNode.position = SCNVector3(planeAnchor.center.x, 0, planeAnchor.center.z)
        node.addChildNode(planeNode)
    }
}

New AR features should usually use RealityKit's 
ARView
; keep 
ARSCNView
 for legacy SceneKit renderers that already depend on 
SCNNode
 or 
SCNMaterial
.

10. Custom Rendering and Metal Interop

Use SceneKit customization in this order:

SCNMaterial
 and built-in physically based lighting.
SCNShadable
 shader modifiers for localized material changes.
SCNTechnique
 for multipass post-processing.
SCNRenderer
 inside an existing Metal pipeline.
A full Metal renderer when SceneKit's frame graph is the constraint.

let renderer = SCNRenderer(device: metalDevice, options: nil)
renderer.scene = scene

// In your Metal render loop:
renderer.render(atTime: currentTime,
                viewport: viewport,
                commandBuffer: commandBuffer,
                passDescriptor: renderPassDescriptor)

SceneKit can coexist with Metal, but once you need deterministic command-buffer ownership, resource heaps, tile shaders, or compute-heavy passes, move that work to Metal directly.

11. Performance Checklist

Keep draw calls low by merging static geometry where practical.
Reuse 
SCNMaterial
 instances for repeated objects.
Prefer lower-poly preview assets on mobile and load high-detail models only when needed.
Pause rendering when offscreen: 
sceneView.isPlaying = false
.
Avoid continuously mutating large node hierarchies from SwiftUI state updates.
Profile with Instruments and Xcode's GPU tools before rewriting rendering code.

12. Common Pitfalls

Treating soft-deprecated as removed
 -- existing SceneKit apps still run, but long-lived new 3D work should start in RealityKit.
Using SceneKit for new immersive visionOS apps
 -- use RealityKit and 
RealityView
 instead.
Forgetting camera and microphone purpose strings in AR
 -- AR sessions need 
NSCameraUsageDescription
; audio capture also needs microphone text.
Creating physics bodies before final geometry setup
 -- collision shapes can become stale.
Blocking the main thread while loading assets
 -- load heavy assets before presentation or behind a loading state.
Letting SwiftUI recreate scenes on every body pass
 -- store scene state in a model or wrapper.

13. Migration Notes to RealityKit

SceneKit

RealityKit

SCNNode

Entity

SCNGeometry

MeshResource / ModelComponent

SCNMaterial

SimpleMaterial, PhysicallyBasedMaterial, ShaderGraphMaterial

SCNAction

AnimationResource, systems, timeline animation

ARSCNView

ARView

.scnassets

USDZ / Reality Composer Pro packages

Migrate feature by feature. Start with leaf scenes or previews before replacing AR session ownership.

14. Review Checklist

[ ] New SceneKit work justified against RealityKit
[ ] 
.scnassets
 loading isolated behind factories
[ ] Scene lifecycle pauses when view disappears
[ ] AR usage includes camera purpose strings and device support checks
[ ] Materials reuse textures and avoid duplicate large allocations
[ ] Physics shapes match final geometry
[ ] SwiftUI wrappers keep SceneKit state out of 
body
[ ] Migration path documented for soft-deprecated SceneKit surfaces

See also: 
docs/frameworks/realitykit.md
, 
docs/frameworks/arkit.md
, 
docs/frameworks/metal.md
.

---

# Contacts Framework
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-services-contacts.html

System Integration · Reference guideContacts FrameworkRepository guidance for Contacts. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

CNContactStore Setup and Authorization
CNContactFetchRequest with Key Descriptors
Searching Contacts
Creating and Updating Contacts
Contact Images and Thumbnails
CNContactPickerViewController
Complete Contact Picker Example

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Explain contact access
↓2
Request authorization
↓3
Fetch needed contact fields
↓4
Handle restricted access

02 / ArchitectureResponsibility boundariesBoundary 1
AuthorizationBoundary 2
Contact storeBoundary 3
Contact presentationConnected responsibilities, not a required class hierarchy or an execution trace.
CNContactStore Setup and Authorization

Add to 
Info.plist
:
- 
NSContactsUsageDescription
 — required for contact access

import Contacts

@Observable
final class ContactManager {
    var contacts: [CNContact] = []
    var authorizationStatus: CNAuthorizationStatus = .notDetermined
    var error: Error?

    private let store = CNContactStore()

    /// Check current authorization status
    func checkAuthorization() {
        authorizationStatus = CNContactStore.authorizationStatus(for: .contacts)
    }

    /// Request access to contacts
    func requestAccess() async -> Bool {
        do {
            let granted = try await store.requestAccess(for: .contacts)
            authorizationStatus = granted ? .authorized : .denied
            return granted
        } catch {
            self.error = error
            authorizationStatus = .denied
            return false
        }
    }
}

CNContactFetchRequest with Key Descriptors

Key descriptors specify which contact properties to fetch. Only request what you need for performance.

extension ContactManager {

    /// Standard keys for displaying a contact list
    static let listKeys: [CNKeyDescriptor] = [
        CNContactIdentifierKey as CNKeyDescriptor,
        CNContactGivenNameKey as CNKeyDescriptor,
        CNContactFamilyNameKey as CNKeyDescriptor,
        CNContactOrganizationNameKey as CNKeyDescriptor,
        CNContactPhoneNumbersKey as CNKeyDescriptor,
        CNContactEmailAddressesKey as CNKeyDescriptor,
        CNContactImageDataAvailableKey as CNKeyDescriptor,
        CNContactThumbnailImageDataKey as CNKeyDescriptor,
        CNContactFormatter.descriptorForRequiredKeys(for: .fullName)
    ]

    /// Extended keys for a contact detail view
    static let detailKeys: [CNKeyDescriptor] = listKeys + [
        CNContactImageDataKey as CNKeyDescriptor,
        CNContactPostalAddressesKey as CNKeyDescriptor,
        CNContactBirthdayKey as CNKeyDescriptor,
        CNContactUrlAddressesKey as CNKeyDescriptor,
        CNContactSocialProfilesKey as CNKeyDescriptor,
        CNContactNoteKey as CNKeyDescriptor,
        CNContactJobTitleKey as CNKeyDescriptor,
        CNContactDepartmentNameKey as CNKeyDescriptor,
    ]

    /// Fetch all contacts
    func fetchAllContacts() throws {
        var results: [CNContact] = []
        let request = CNContactFetchRequest(keysToFetch: Self.listKeys)
        request.sortOrder = .userDefault

        try store.enumerateContacts(with: request) { contact, _ in
            results.append(contact)
        }
        contacts = results
    }
}

Searching Contacts

extension ContactManager {

    /// Search by name
    func searchByName(_ name: String) throws -> [CNContact] {
        let predicate = CNContact.predicateForContacts(matchingName: name)
        return try store.unifiedContacts(
            matching: predicate,
            keysToFetch: Self.listKeys
        )
    }

    /// Search by email
    func searchByEmail(_ email: String) throws -> [CNContact] {
        let predicate = CNContact.predicateForContacts(matchingEmailAddress: email)
        return try store.unifiedContacts(
            matching: predicate,
            keysToFetch: Self.listKeys
        )
    }

    /// Search by phone number
    func searchByPhone(_ phoneNumber: String) throws -> [CNContact] {
        let phoneValue = CNPhoneNumber(stringValue: phoneNumber)
        let predicate = CNContact.predicateForContacts(matching: phoneValue)
        return try store.unifiedContacts(
            matching: predicate,
            keysToFetch: Self.listKeys
        )
    }

    /// Fetch a single contact by identifier
    func fetchContact(identifier: String) throws -> CNContact {
        let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier])
        guard let contact = try store.unifiedContacts(
            matching: predicate,
            keysToFetch: Self.detailKeys
        ).first else {
            throw ContactError.notFound
        }
        return contact
    }

    /// Fetch contacts in a specific group
    func fetchContacts(inGroup groupIdentifier: String) throws -> [CNContact] {
        let predicate = CNContact.predicateForContactsInGroup(withIdentifier: groupIdentifier)
        return try store.unifiedContacts(
            matching: predicate,
            keysToFetch: Self.listKeys
        )
    }

    /// Fetch contacts in a specific container (iCloud, Google, etc.)
    func fetchContacts(inContainer containerIdentifier: String) throws -> [CNContact] {
        let predicate = CNContact.predicateForContactsInContainer(withIdentifier: containerIdentifier)
        return try store.unifiedContacts(
            matching: predicate,
            keysToFetch: Self.listKeys
        )
    }
}

enum ContactError: LocalizedError {
    case notFound, notAuthorized, saveFailed

    var errorDescription: String? {
        switch self {
        case .notFound: "Contact not found."
        case .notAuthorized: "Contacts access not authorized."
        case .saveFailed: "Failed to save contact."
        }
    }
}

Creating and Updating Contacts

extension ContactManager {

    /// Create a new contact
    func createContact(
        givenName: String,
        familyName: String,
        phoneNumbers: [(label: String, number: String)] = [],
        emailAddresses: [(label: String, email: String)] = [],
        organization: String? = nil,
        jobTitle: String? = nil,
        birthday: DateComponents? = nil,
        imageData: Data? = nil
    ) throws -> CNContact {
        let contact = CNMutableContact()
        contact.givenName = givenName
        contact.familyName = familyName

        contact.phoneNumbers = phoneNumbers.map {
            CNLabeledValue(
                label: $0.label,
                value: CNPhoneNumber(stringValue: $0.number)
            )
        }

        contact.emailAddresses = emailAddresses.map {
            CNLabeledValue(label: $0.label, value: $0.email as NSString)
        }

        if let organization { contact.organizationName = organization }
        if let jobTitle { contact.jobTitle = jobTitle }
        if let birthday { contact.birthday = birthday }
        if let imageData { contact.imageData = imageData }

        let saveRequest = CNSaveRequest()
        saveRequest.add(contact, toContainerWithIdentifier: nil) // Default container
        try store.execute(saveRequest)

        return contact
    }

    /// Update an existing contact
    func updateContact(_ contact: CNContact, updates: (CNMutableContact) -> Void) throws {
        guard let mutable = contact.mutableCopy() as? CNMutableContact else { return }
        updates(mutable)

        let saveRequest = CNSaveRequest()
        saveRequest.update(mutable)
        try store.execute(saveRequest)
    }

    /// Delete a contact
    func deleteContact(_ contact: CNContact) throws {
        guard let mutable = contact.mutableCopy() as? CNMutableContact else { return }
        let saveRequest = CNSaveRequest()
        saveRequest.delete(mutable)
        try store.execute(saveRequest)
    }

    /// Add a phone number to an existing contact
    func addPhoneNumber(to contact: CNContact, label: String, number: String) throws {
        try updateContact(contact) { mutable in
            let newPhone = CNLabeledValue(
                label: label,
                value: CNPhoneNumber(stringValue: number)
            )
            mutable.phoneNumbers.append(newPhone)
        }
    }
}

Contact Images and Thumbnails

import SwiftUI

extension ContactManager {
    /// Get a SwiftUI Image from contact thumbnail data
    static func contactImage(for contact: CNContact) -> Image? {
        if let data = contact.thumbnailImageData,
           let uiImage = UIImage(data: data) {
            return Image(uiImage: uiImage)
        }
        return nil
    }

    /// Get full-resolution contact image
    static func contactFullImage(for contact: CNContact) -> Image? {
        if let data = contact.imageData,
           let uiImage = UIImage(data: data) {
            return Image(uiImage: uiImage)
        }
        return nil
    }
}

struct ContactAvatarView: View {
    let contact: CNContact

    var body: some View {
        Group {
            if let image = ContactManager.contactImage(for: contact) {
                image
                    .resizable()
                    .scaledToFill()
            } else {
                Text(initials)
                    .font(.headline)
                    .foregroundStyle(.white)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .background(Color.accentColor.gradient)
            }
        }
        .frame(width: 44, height: 44)
        .clipShape(Circle())
    }

    private var initials: String {
        let first = contact.givenName.prefix(1)
        let last = contact.familyName.prefix(1)
        return "\(first)\(last)"
    }
}

CNContactPickerViewController

import SwiftUI
import ContactsUI

struct ContactPickerView: UIViewControllerRepresentable {
    @Environment(\.dismiss) private var dismiss
    var onSelect: (CNContact) -> Void

    func makeUIViewController(context: Context) -> CNContactPickerViewController {
        let picker = CNContactPickerViewController()
        picker.delegate = context.coordinator

        // Optional: filter contacts
        picker.predicateForEnablingContact = NSPredicate(
            format: "phoneNumbers.@count > 0"
        )

        // Optional: show only specific properties
        picker.displayedPropertyKeys = [
            CNContactGivenNameKey,
            CNContactFamilyNameKey,
            CNContactPhoneNumbersKey
        ]

        return picker
    }

    func updateUIViewController(_ uiViewController: CNContactPickerViewController, context: Context) {}

    func makeCoordinator() -> Coordinator {
        Coordinator(onSelect: onSelect, dismiss: dismiss)
    }

    final class Coordinator: NSObject, CNContactPickerDelegate {
        let onSelect: (CNContact) -> Void
        let dismiss: DismissAction

        init(onSelect: @escaping (CNContact) -> Void, dismiss: DismissAction) {
            self.onSelect = onSelect
            self.dismiss = dismiss
        }

        func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
            onSelect(contact)
        }

        func contactPickerDidCancel(_ picker: CNContactPickerViewController) {
            dismiss()
        }
    }
}

Complete Contact Picker Example

import SwiftUI
import Contacts

struct ContactListView: View {
    @State private var manager = ContactManager()
    @State private var searchText = ""
    @State private var showPicker = false
    @State private var selectedContact: CNContact?

    var filteredContacts: [CNContact] {
        guard !searchText.isEmpty else { return manager.contacts }
        return manager.contacts.filter { contact in
            let fullName = CNContactFormatter.string(from: contact, style: .fullName) ?? ""
            return fullName.localizedCaseInsensitiveContains(searchText)
        }
    }

    var body: some View {
        NavigationStack {
            List(filteredContacts, id: \.identifier) { contact in
                HStack(spacing: 12) {
                    ContactAvatarView(contact: contact)

                    VStack(alignment: .leading, spacing: 2) {
                        Text(CNContactFormatter.string(from: contact, style: .fullName) ?? "No Name")
                            .font(.body.weight(.medium))

                        if let phone = contact.phoneNumbers.first {
                            Text(phone.value.stringValue)
                                .font(.subheadline)
                                .foregroundStyle(.secondary)
                        }
                    }
                }
                .contentShape(Rectangle())
                .onTapGesture { selectedContact = contact }
            }
            .searchable(text: $searchText, prompt: "Search contacts")
            .navigationTitle("Contacts")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("Pick", systemImage: "person.crop.circle.badge.plus") {
                        showPicker = true
                    }
                }
            }
            .sheet(isPresented: $showPicker) {
                ContactPickerView { contact in
                    selectedContact = contact
                }
            }
            .sheet(item: $selectedContact) { contact in
                ContactDetailSheet(contact: contact)
            }
            .task {
                let granted = await manager.requestAccess()
                if granted {
                    try? manager.fetchAllContacts()
                }
            }
        }
    }
}

extension CNContact: @retroactive Identifiable {
    public var id: String { identifier }
}

struct ContactDetailSheet: View {
    let contact: CNContact
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        NavigationStack {
            List {
                Section {
                    HStack {
                        Spacer()
                        VStack(spacing: 8) {
                            ContactAvatarView(contact: contact)
                                .scaleEffect(2)
                                .padding(22)
                            Text(CNContactFormatter.string(from: contact, style: .fullName) ?? "")
                                .font(.title2.bold())
                            if !contact.organizationName.isEmpty {
                                Text(contact.organizationName)
                                    .foregroundStyle(.secondary)
                            }
                        }
                        Spacer()
                    }
                    .listRowBackground(Color.clear)
                }

                if !contact.phoneNumbers.isEmpty {
                    Section("Phone") {
                        ForEach(contact.phoneNumbers, id: \.identifier) { phone in
                            LabeledContent(
                                CNLabeledValue<NSString>.localizedString(forLabel: phone.label ?? ""),
                                value: phone.value.stringValue
                            )
                        }
                    }
                }

                if !contact.emailAddresses.isEmpty {
                    Section("Email") {
                        ForEach(contact.emailAddresses, id: \.identifier) { email in
                            LabeledContent(
                                CNLabeledValue<NSString>.localizedString(forLabel: email.label ?? ""),
                                value: email.value as String
                            )
                        }
                    }
                }
            }
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .confirmationAction) {
                    Button("Done") { dismiss() }
                }
            }
        }
    }
}

---

# EventKit
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-services-eventkit.html

System Integration · Reference guideEventKitRepository guidance for EventKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

EKEventStore Setup and Authorization
Reading Calendars and Events
Creating, Editing, and Deleting Events
Recurrence Rules
Reminders (EKReminder)
EKEventEditViewController (UIKit / SwiftUI Bridge)
Complete Calendar Integration Example

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Choose calendar or reminder task
↓2
Request scoped access
↓3
Read or save an item
↓4
Handle permission changes

02 / ArchitectureResponsibility boundariesBoundary 1
Event storeBoundary 2
Calendar or reminder dataBoundary 3
Editing UIConnected responsibilities, not a required class hierarchy or an execution trace.
EKEventStore Setup and Authorization

Add to 
Info.plist
:
- 
NSCalendarsUsageDescription
 — required for calendar access
- 
NSCalendarsFullAccessUsageDescription
 — iOS 17+ full access
- 
NSCalendarsWriteOnlyAccessUsageDescription
 — iOS 17+ write-only access
- 
NSRemindersUsageDescription
 — required for reminders access
- 
NSRemindersFullAccessUsageDescription
 — iOS 17+ reminders

import EventKit

@Observable
final class CalendarManager {
    var calendars: [EKCalendar] = []
    var events: [EKEvent] = []
    var authorizationStatus: EKAuthorizationStatus = .notDetermined
    var error: Error?

    private let store = EKEventStore()

    /// Request calendar access (iOS 17+)
    func requestAccess() async {
        do {
            if #available(iOS 17, *) {
                let granted = try await store.requestFullAccessToEvents()
                authorizationStatus = granted ? .fullAccess : .denied
            } else {
                let granted = try await store.requestAccess(to: .event)
                authorizationStatus = granted ? .authorized : .denied
            }
        } catch {
            self.error = error
            authorizationStatus = .denied
        }
    }

    /// Request reminders access (iOS 17+)
    func requestRemindersAccess() async throws -> Bool {
        if #available(iOS 17, *) {
            return try await store.requestFullAccessToReminders()
        } else {
            return try await store.requestAccess(to: .reminder)
        }
    }
}

Reading Calendars and Events

extension CalendarManager {

    func loadCalendars() {
        calendars = store.calendars(for: .event)
    }

    /// Fetch events within a date range
    func fetchEvents(from startDate: Date, to endDate: Date, calendars: [EKCalendar]? = nil) {
        let predicate = store.predicateForEvents(
            withStart: startDate,
            end: endDate,
            calendars: calendars  // nil = all calendars
        )
        events = store.events(matching: predicate)
            .sorted { $0.startDate < $1.startDate }
    }

    /// Fetch events for today
    func fetchTodayEvents() {
        let now = Date()
        let startOfDay = Calendar.current.startOfDay(for: now)
        let endOfDay = Calendar.current.date(byAdding: .day, value: 1, to: startOfDay)!
        fetchEvents(from: startOfDay, to: endOfDay)
    }

    /// Fetch events for the next N days
    func fetchUpcomingEvents(days: Int = 7) {
        let now = Date()
        let future = Calendar.current.date(byAdding: .day, value: days, to: now)!
        fetchEvents(from: now, to: future)
    }

    /// Get a single event by identifier
    func event(withIdentifier id: String) -> EKEvent? {
        store.event(withIdentifier: id)
    }
}

Creating, Editing, and Deleting Events

extension CalendarManager {

    /// Create a new event
    func createEvent(
        title: String,
        startDate: Date,
        endDate: Date,
        calendar: EKCalendar? = nil,
        location: String? = nil,
        notes: String? = nil,
        url: URL? = nil,
        alarms: [TimeInterval] = [-600] // 10 minutes before
    ) throws -> EKEvent {
        let event = EKEvent(eventStore: store)
        event.title = title
        event.startDate = startDate
        event.endDate = endDate
        event.calendar = calendar ?? store.defaultCalendarForNewEvents
        event.location = location
        event.notes = notes
        event.url = url

        // Add alarms
        for offset in alarms {
            event.addAlarm(EKAlarm(relativeOffset: offset))
        }

        try store.save(event, span: .thisEvent)
        return event
    }

    /// Edit an existing event
    func updateEvent(_ event: EKEvent, span: EKSpan = .thisEvent) throws {
        try store.save(event, span: span)
    }

    /// Delete an event
    func deleteEvent(_ event: EKEvent, span: EKSpan = .thisEvent) throws {
        try store.remove(event, span: span)
    }
}

Recurrence Rules

extension CalendarManager {

    /// Create a daily recurring event
    func createDailyEvent(title: String, startDate: Date, endDate: Date) throws -> EKEvent {
        let rule = EKRecurrenceRule(
            recurrenceWith: .daily,
            interval: 1,             // Every 1 day
            end: EKRecurrenceEnd(occurrenceCount: 30) // 30 occurrences
        )
        return try createRecurringEvent(title: title, start: startDate, end: endDate, rule: rule)
    }

    /// Create a weekly recurring event (e.g., every Monday and Wednesday)
    func createWeeklyEvent(title: String, startDate: Date, endDate: Date) throws -> EKEvent {
        let daysOfWeek = [
            EKRecurrenceDayOfWeek(.monday),
            EKRecurrenceDayOfWeek(.wednesday)
        ]
        let rule = EKRecurrenceRule(
            recurrenceWith: .weekly,
            interval: 1,
            daysOfTheWeek: daysOfWeek,
            daysOfTheMonth: nil,
            monthsOfTheYear: nil,
            weeksOfTheYear: nil,
            daysOfTheYear: nil,
            setPositions: nil,
            end: EKRecurrenceEnd(end: Calendar.current.date(byAdding: .month, value: 6, to: startDate)!)
        )
        return try createRecurringEvent(title: title, start: startDate, end: endDate, rule: rule)
    }

    /// Create a monthly recurring event (e.g., 15th of each month)
    func createMonthlyEvent(title: String, startDate: Date, endDate: Date) throws -> EKEvent {
        let rule = EKRecurrenceRule(
            recurrenceWith: .monthly,
            interval: 1,
            daysOfTheWeek: nil,
            daysOfTheMonth: [15 as NSNumber],
            monthsOfTheYear: nil,
            weeksOfTheYear: nil,
            daysOfTheYear: nil,
            setPositions: nil,
            end: nil // Repeats forever
        )
        return try createRecurringEvent(title: title, start: startDate, end: endDate, rule: rule)
    }

    private func createRecurringEvent(
        title: String, start: Date, end: Date, rule: EKRecurrenceRule
    ) throws -> EKEvent {
        let event = EKEvent(eventStore: store)
        event.title = title
        event.startDate = start
        event.endDate = end
        event.calendar = store.defaultCalendarForNewEvents
        event.addRecurrenceRule(rule)
        try store.save(event, span: .futureEvents)
        return event
    }
}

Reminders (EKReminder)

extension CalendarManager {

    func fetchReminders(in calendars: [EKCalendar]? = nil) async -> [EKReminder] {
        let predicate = store.predicateForReminders(in: calendars)
        return await withCheckedContinuation { continuation in
            store.fetchReminders(matching: predicate) { reminders in
                continuation.resume(returning: reminders ?? [])
            }
        }
    }

    func fetchIncompleteReminders(
        from start: Date? = nil,
        to end: Date? = nil
    ) async -> [EKReminder] {
        let predicate = store.predicateForIncompleteReminders(
            withDueDateStarting: start,
            ending: end,
            calendars: nil
        )
        return await withCheckedContinuation { continuation in
            store.fetchReminders(matching: predicate) { reminders in
                continuation.resume(returning: reminders ?? [])
            }
        }
    }

    func createReminder(
        title: String,
        dueDate: DateComponents? = nil,
        priority: Int = 0,
        notes: String? = nil,
        list: EKCalendar? = nil
    ) throws -> EKReminder {
        let reminder = EKReminder(eventStore: store)
        reminder.title = title
        reminder.dueDateComponents = dueDate
        reminder.priority = priority  // 0 = none, 1 = high, 5 = medium, 9 = low
        reminder.notes = notes
        reminder.calendar = list ?? store.defaultCalendarForNewReminders()

        try store.save(reminder, commit: true)
        return reminder
    }

    func completeReminder(_ reminder: EKReminder) throws {
        reminder.isCompleted = true
        reminder.completionDate = Date()
        try store.save(reminder, commit: true)
    }
}

EKEventEditViewController (UIKit / SwiftUI Bridge)

import SwiftUI
import EventKitUI

struct EventEditView: UIViewControllerRepresentable {
    @Environment(\.dismiss) private var dismiss
    let store: EKEventStore
    var event: EKEvent?

    func makeUIViewController(context: Context) -> EKEventEditViewController {
        let controller = EKEventEditViewController()
        controller.eventStore = store
        controller.event = event ?? EKEvent(eventStore: store)
        controller.editViewDelegate = context.coordinator
        return controller
    }

    func updateUIViewController(_ uiViewController: EKEventEditViewController, context: Context) {}

    func makeCoordinator() -> Coordinator {
        Coordinator(dismiss: dismiss)
    }

    final class Coordinator: NSObject, EKEventEditViewDelegate {
        let dismiss: DismissAction

        init(dismiss: DismissAction) {
            self.dismiss = dismiss
        }

        func eventEditViewController(
            _ controller: EKEventEditViewController,
            didCompleteWith action: EKEventEditViewAction
        ) {
            dismiss()
        }
    }
}

Complete Calendar Integration Example

import SwiftUI
import EventKit

struct CalendarIntegrationView: View {
    @State private var manager = CalendarManager()
    @State private var showingAddEvent = false
    @State private var newTitle = ""
    @State private var newStartDate = Date()
    @State private var newEndDate = Date().addingTimeInterval(3600)

    var body: some View {
        NavigationStack {
            Group {
                if manager.authorizationStatus == .notDetermined {
                    ContentUnavailableView(
                        "Calendar Access Required",
                        systemImage: "calendar",
                        description: Text("Grant access to view and manage events.")
                    )
                } else if manager.events.isEmpty {
                    ContentUnavailableView("No Events", systemImage: "calendar.badge.exclamationmark")
                } else {
                    eventList
                }
            }
            .navigationTitle("Calendar")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("Add", systemImage: "plus") {
                        showingAddEvent = true
                    }
                }
            }
            .sheet(isPresented: $showingAddEvent) {
                addEventSheet
            }
            .task {
                await manager.requestAccess()
                manager.loadCalendars()
                manager.fetchUpcomingEvents(days: 14)
            }
        }
    }

    private var eventList: some View {
        List {
            ForEach(manager.events, id: \.eventIdentifier) { event in
                VStack(alignment: .leading, spacing: 4) {
                    HStack {
                        Circle()
                            .fill(Color(cgColor: event.calendar.cgColor))
                            .frame(width: 10, height: 10)
                        Text(event.title)
                            .font(.headline)
                    }
                    Text(event.startDate, format: .dateTime)
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                    if let location = event.location, !location.isEmpty {
                        Label(location, systemImage: "mappin")
                            .font(.caption)
                            .foregroundStyle(.secondary)
                    }
                }
            }
            .onDelete { indexSet in
                for index in indexSet {
                    try? manager.deleteEvent(manager.events[index])
                }
                manager.fetchUpcomingEvents(days: 14)
            }
        }
    }

    private var addEventSheet: some View {
        NavigationStack {
            Form {
                TextField("Event Title", text: $newTitle)
                DatePicker("Start", selection: $newStartDate)
                DatePicker("End", selection: $newEndDate)
            }
            .navigationTitle("New Event")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { showingAddEvent = false }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") {
                        try? manager.createEvent(
                            title: newTitle,
                            startDate: newStartDate,
                            endDate: newEndDate
                        )
                        manager.fetchUpcomingEvents(days: 14)
                        showingAddEvent = false
                    }
                    .disabled(newTitle.isEmpty)
                }
            }
        }
    }
}

---

# PassKit & FinanceKit
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-services-passkit.html

Commerce and Wallet · Reference guidePassKit & FinanceKitRepository guidance for PassKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

PKPaymentRequest Setup
Checking Apple Pay Availability
SwiftUI PayWithApplePayButton (iOS 16+)
UIKit PKPaymentAuthorizationViewController
Payment Processing Flow
Wallet Passes (PKPass & PKPassLibrary)
FinanceKit: Apple Card/Cash Transactions (iOS 17+)
Complete Apple Pay Checkout Example

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Check payment capability
↓2
Present payment request
↓3
Send token to payment service
↓4
Complete authorization result

02 / ArchitectureResponsibility boundariesBoundary 1
Checkout UIBoundary 2
Payment authorizationBoundary 3
Payment processorConnected responsibilities, not a required class hierarchy or an execution trace.
PKPaymentRequest Setup

Configure Apple Pay with merchant details and supported networks.

import PassKit

func makePaymentRequest(amount: Decimal) -> PKPaymentRequest {
    let request = PKPaymentRequest()
    request.merchantIdentifier = "merchant.com.yourapp.pay"
    request.supportedNetworks = [.visa, .masterCard, .amex, .discover]
    request.merchantCapabilities = [.capability3DS, .capabilityDebit, .capabilityCredit]
    request.countryCode = "US"
    request.currencyCode = "USD"

    let item = PKPaymentSummaryItem(
        label: "My App Purchase",
        amount: NSDecimalNumber(decimal: amount)
    )
    let total = PKPaymentSummaryItem(
        label: "Your Company",
        amount: NSDecimalNumber(decimal: amount),
        type: .final
    )
    request.paymentSummaryItems = [item, total]

    // Optional: require shipping
    request.requiredShippingContactFields = [.postalAddress, .emailAddress]
    request.requiredBillingContactFields = [.postalAddress]

    // Shipping methods
    let standard = PKShippingMethod(label: "Standard", amount: NSDecimalNumber(value: 0))
    standard.identifier = "standard"
    standard.detail = "Arrives in 5-7 days"
    let express = PKShippingMethod(label: "Express", amount: NSDecimalNumber(value: 9.99))
    express.identifier = "express"
    express.detail = "Arrives in 1-2 days"
    request.shippingMethods = [standard, express]

    return request
}

Checking Apple Pay Availability

func canMakePayments() -> Bool {
    PKPaymentAuthorizationViewController.canMakePayments(
        usingNetworks: [.visa, .masterCard, .amex, .discover],
        capabilities: [.capability3DS]
    )
}

SwiftUI PayWithApplePayButton (iOS 16+)

import SwiftUI
import PassKit

struct CheckoutView: View {
    @State private var paymentStatus: PaymentStatus = .idle

    var body: some View {
        VStack(spacing: 24) {
            OrderSummaryView()

            PayWithApplePayButton(.checkout, action: handlePayment)
                .frame(height: 50)
                .payWithApplePayButtonStyle(.black)
                .padding(.horizontal)
        }
    }

    func handlePayment() {
        let request = makePaymentRequest(amount: 49.99)
        let controller = PKPaymentAuthorizationController(paymentRequest: request)
        controller.delegate = PaymentDelegate.shared
        controller.present()
    }
}

UIKit PKPaymentAuthorizationViewController

import UIKit
import PassKit

final class PaymentViewController: UIViewController, PKPaymentAuthorizationViewControllerDelegate {

    func startPayment(amount: Decimal) {
        guard PKPaymentAuthorizationViewController.canMakePayments() else {
            showSetupApplePay()
            return
        }

        let request = makePaymentRequest(amount: amount)
        guard let vc = PKPaymentAuthorizationViewController(paymentRequest: request) else { return }
        vc.delegate = self
        present(vc, animated: true)
    }

    func paymentAuthorizationViewController(
        _ controller: PKPaymentAuthorizationViewController,
        didAuthorizePayment payment: PKPayment,
        handler completion: @escaping (PKPaymentAuthorizationResult) -> Void
    ) {
        // Send payment.token.paymentData to your server
        Task {
            do {
                try await PaymentService.processPayment(token: payment.token)
                completion(PKPaymentAuthorizationResult(status: .success, errors: nil))
            } catch {
                let pkError = PKPaymentRequest.paymentShippingAddressUnserviceableError(
                    withLocalizedDescription: "Payment failed. Please try again."
                )
                completion(PKPaymentAuthorizationResult(status: .failure, errors: [pkError]))
            }
        }
    }

    func paymentAuthorizationViewControllerDidFinish(
        _ controller: PKPaymentAuthorizationViewController
    ) {
        controller.dismiss(animated: true)
    }

    private func showSetupApplePay() {
        let setup = PKPassLibrary().openPaymentSetup()
        if !setup {
            // Fallback: show manual card entry
        }
    }
}

Payment Processing Flow

enum PaymentService {
    static func processPayment(token: PKPaymentToken) async throws {
        // 1. Extract payment data
        let paymentData = token.paymentData  // Encrypted by Apple
        let method = token.paymentMethod
        let network = method.network?.rawValue ?? "unknown"
        let type = method.type  // .debit, .credit, .prepaid, .store

        // 2. Send encrypted token to your payment processor
        var request = URLRequest(url: URL(string: "https://api.yourserver.com/pay")!)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try JSONEncoder().encode([
            "paymentData": paymentData.base64EncodedString(),
            "network": network,
            "transactionId": token.transactionIdentifier
        ])

        let (data, response) = try await URLSession.shared.data(for: request)
        guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
            throw PaymentError.serverDeclined
        }
    }
}

enum PaymentError: LocalizedError {
    case serverDeclined, networkUnavailable

    var errorDescription: String? {
        switch self {
        case .serverDeclined: "Payment was declined. Please try another card."
        case .networkUnavailable: "No network connection. Please try again."
        }
    }
}

Wallet Passes (PKPass & PKPassLibrary)

import PassKit

final class WalletPassManager {
    private let library = PKPassLibrary()

    /// Add a pass from downloaded .pkpass data
    func addPass(data: Data) async throws {
        guard PKPassLibrary.isPassLibraryAvailable() else {
            throw WalletError.notAvailable
        }

        let pass = try PKPass(data: data)

        if library.containsPass(pass) {
            // Pass already in wallet — replace
            library.replacePass(with: pass)
        } else {
            // Show the add-pass UI
            guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
                  let root = scene.keyWindow?.rootViewController else { return }
            let addController = PKAddPassesViewController(pass: pass)
            root.present(addController!, animated: true)
        }
    }

    /// List all passes of a specific type
    func passes(ofType type: PKPassType = .barcode) -> [PKPass] {
        library.passes(of: type)
    }

    /// Remove a pass
    func removePass(_ pass: PKPass) {
        library.removePass(pass)
    }

    /// Download a .pkpass file from server
    func downloadPass(from url: URL) async throws -> Data {
        var request = URLRequest(url: url)
        request.setValue("application/vnd.apple.pkpass", forHTTPHeaderField: "Accept")
        let (data, _) = try await URLSession.shared.data(for: request)
        return data
    }
}

enum WalletError: LocalizedError {
    case notAvailable
    var errorDescription: String? { "Wallet is not available on this device." }
}

FinanceKit: Apple Card/Cash Transactions (iOS 17+)

import FinanceKit

@available(iOS 17, *)
final class FinanceManager {

    func requestAuthorization() async -> FinanceKit.AuthorizationStatus {
        await FinanceStore.shared.requestAuthorization()
    }

    func fetchTransactions() async throws -> [FinanceKit.Transaction] {
        let store = FinanceStore.shared

        let status = await store.requestAuthorization()
        guard status == .authorized else {
            throw FinanceError.notAuthorized
        }

        // Query recent Apple Card transactions
        let now = Date()
        let thirtyDaysAgo = Calendar.current.date(byAdding: .day, value: -30, to: now)!

        let query = TransactionQuery(
            startDate: thirtyDaysAgo,
            endDate: now
        )

        let transactions = try await store.transactions(query: query)
        return transactions
    }

    func fetchAccountBalances() async throws -> [FinanceKit.AccountBalance] {
        let store = FinanceStore.shared
        return try await store.accountBalances()
    }
}

enum FinanceError: LocalizedError {
    case notAuthorized
    var errorDescription: String? { "Finance data access not authorized." }
}

Complete Apple Pay Checkout Example

import SwiftUI
import PassKit

enum PaymentStatus {
    case idle, processing, success, failed(String)
}

@Observable
final class CheckoutViewModel {
    var items: [CartItem] = []
    var paymentStatus: PaymentStatus = .idle

    var total: Decimal {
        items.reduce(0) { $0 + $1.price * Decimal($1.quantity) }
    }

    var canPay: Bool {
        PKPaymentAuthorizationController.canMakePayments(
            usingNetworks: [.visa, .masterCard, .amex],
            capabilities: [.capability3DS]
        )
    }

    func checkout() async {
        paymentStatus = .processing

        let request = PKPaymentRequest()
        request.merchantIdentifier = "merchant.com.yourapp.pay"
        request.supportedNetworks = [.visa, .masterCard, .amex]
        request.merchantCapabilities = [.capability3DS]
        request.countryCode = "US"
        request.currencyCode = "USD"

        request.paymentSummaryItems = items.map {
            PKPaymentSummaryItem(
                label: $0.name,
                amount: NSDecimalNumber(decimal: $0.price * Decimal($0.quantity))
            )
        } + [
            PKPaymentSummaryItem(label: "Your Store", amount: NSDecimalNumber(decimal: total))
        ]

        let controller = PKPaymentAuthorizationController(paymentRequest: request)
        // Present and handle via delegate pattern
        controller.present()
    }
}

struct CartItem: Identifiable {
    let id = UUID()
    let name: String
    let price: Decimal
    var quantity: Int
}

struct ApplePayCheckoutView: View {
    @State private var viewModel = CheckoutViewModel()

    var body: some View {
        NavigationStack {
            List {
                ForEach(viewModel.items) { item in
                    HStack {
                        Text(item.name)
                        Spacer()
                        Text("\(item.quantity)x")
                        Text(item.price, format: .currency(code: "USD"))
                    }
                }

                Section {
                    HStack {
                        Text("Total").fontWeight(.bold)
                        Spacer()
                        Text(viewModel.total, format: .currency(code: "USD"))
                            .fontWeight(.bold)
                    }
                }
            }
            .navigationTitle("Checkout")
            .safeAreaInset(edge: .bottom) {
                if viewModel.canPay {
                    PayWithApplePayButton(.checkout) {
                        Task { await viewModel.checkout() }
                    }
                    .frame(height: 50)
                    .padding()
                }
            }
            .overlay {
                if case .processing = viewModel.paymentStatus {
                    ProgressView("Processing...")
                        .padding()
                        .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
                }
            }
        }
    }
}

---

# WeatherKit
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-services-weatherkit.html

System Integration · Reference guideWeatherKitRepository guidance for WeatherKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Setup Requirements
WeatherService Basics
Current Weather
Hourly Forecast
Daily Forecast
Weather Alerts
Minute-by-Minute Precipitation
Apple Attribution Requirements (Mandatory)
Caching Strategy
Complete Weather App Example

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Choose location and forecast
↓2
Request weather data
↓3
Cache appropriate results
↓4
Show attribution and fallback

02 / ArchitectureResponsibility boundariesBoundary 1
Location queryBoundary 2
Weather serviceBoundary 3
Forecast and attributionConnected responsibilities, not a required class hierarchy or an execution trace.
Setup Requirements

WeatherKit requires an 
Apple Developer Program membership
 and capability registration.

Enable 
WeatherKit
 capability in Xcode (Signing & Capabilities)
Register the WeatherKit service in App Store Connect > Identifiers > App Services
Add 
import WeatherKit
 to your source files

Rate limits: 500,000 calls/month included free. Additional calls require a paid tier.

WeatherService Basics

import WeatherKit
import CoreLocation

@Observable
final class WeatherManager {
    var currentWeather: CurrentWeather?
    var hourlyForecast: [HourWeather] = []
    var dailyForecast: [DayWeather] = []
    var alerts: [WeatherAlert] = []
    var minuteForecast: [MinuteWeather]?
    var attribution: WeatherAttribution?
    var error: Error?

    private let service = WeatherService.shared

    /// Fetch all weather data for a location
    func fetchWeather(for location: CLLocation) async {
        do {
            let weather = try await service.weather(for: location)

            currentWeather = weather.currentWeather
            hourlyForecast = Array(weather.hourlyForecast.prefix(24))
            dailyForecast = Array(weather.dailyForecast.prefix(10))
            alerts = weather.weatherAlerts ?? []
            minuteForecast = weather.minuteForecast.map { Array($0) }

            // Always fetch attribution (required by Apple)
            attribution = try await service.attribution
        } catch {
            self.error = error
        }
    }
}

Current Weather

func displayCurrentWeather(_ current: CurrentWeather) {
    let temp = current.temperature                    // Measurement<UnitTemperature>
    let apparentTemp = current.apparentTemperature
    let condition = current.condition                  // .clear, .cloudy, .rain, etc.
    let symbolName = current.symbolName                // SF Symbol name
    let humidity = current.humidity                    // 0.0 ... 1.0
    let windSpeed = current.wind.speed                // Measurement<UnitSpeed>
    let windDirection = current.wind.compassDirection  // .north, .northEast, etc.
    let uvIndex = current.uvIndex.value               // Int
    let pressure = current.pressure                   // Measurement<UnitPressure>
    let visibility = current.visibility               // Measurement<UnitLength>
    let dewPoint = current.dewPoint

    // Format for display
    let formatter = MeasurementFormatter()
    formatter.unitOptions = .providedUnit
    let tempString = temp.formatted(.measurement(width: .abbreviated))
    // e.g., "72°F" or "22°C" based on locale
}

Hourly Forecast

func processHourlyForecast(_ hours: [HourWeather]) {
    for hour in hours {
        let date = hour.date
        let temp = hour.temperature
        let condition = hour.condition
        let symbol = hour.symbolName
        let precipChance = hour.precipitationChance    // 0.0 ... 1.0
        let precipAmount = hour.precipitationAmount    // Measurement<UnitLength>
        let humidity = hour.humidity
        let windSpeed = hour.wind.speed
        let cloudCover = hour.cloudCover               // 0.0 ... 1.0
    }
}

Daily Forecast

func processDailyForecast(_ days: [DayWeather]) {
    for day in days {
        let date = day.date
        let highTemp = day.highTemperature
        let lowTemp = day.lowTemperature
        let condition = day.condition
        let symbol = day.symbolName
        let precipChance = day.precipitationChance
        let sunrise = day.sun.sunrise                 // Date?
        let sunset = day.sun.sunset                   // Date?
        let moonPhase = day.moon.phase                // .new, .full, .firstQuarter, etc.
        let moonrise = day.moon.moonrise
        let uvIndexMax = day.uvIndex.value
        let windMax = day.wind.speed
    }
}

Weather Alerts

func processAlerts(_ alerts: [WeatherAlert]) {
    for alert in alerts {
        let summary = alert.summary            // Human-readable summary
        let severity = alert.severity          // .minor, .moderate, .severe, .extreme
        let source = alert.source              // Issuing authority
        let region = alert.region              // Affected region name
        let detailsURL = alert.detailsURL     // URL for full details

        switch alert.severity {
        case .extreme:
            // Show prominent red banner
            break
        case .severe:
            // Show orange warning
            break
        case .moderate:
            // Show yellow advisory
            break
        case .minor:
            // Show informational notice
            break
        default:
            break
        }
    }
}

Minute-by-Minute Precipitation

/// Available only in select countries (US, UK, Ireland, etc.)
func processMinuteForecast(_ minutes: [MinuteWeather]?) {
    guard let minutes else {
        // Minute forecast not available for this location
        return
    }

    for minute in minutes {
        let date = minute.date
        let precipChance = minute.precipitationChance
        let precipIntensity = minute.precipitationIntensity // Measurement<UnitSpeed>
    }

    // Summarize: will it rain in the next hour?
    let precipitationExpected = minutes.contains { $0.precipitationChance > 0.5 }
    let firstRainMinute = minutes.first { $0.precipitationChance > 0.5 }
}

Apple Attribution Requirements (Mandatory)

Apple 
requires
 you to display the Apple Weather attribution in your app.

import SwiftUI
import WeatherKit

struct WeatherAttributionView: View {
    let attribution: WeatherAttribution?

    var body: some View {
        if let attribution {
            VStack(spacing: 4) {
                // Required: Apple Weather logo
                AsyncImage(url: attribution.combinedMarkDarkURL) { image in
                    image.resizable().scaledToFit()
                } placeholder: {
                    EmptyView()
                }
                .frame(height: 14)

                // Required: link to legal attribution page
                Link("Weather Data Sources", destination: attribution.legalPageURL)
                    .font(.caption2)
                    .foregroundStyle(.secondary)
            }
        }
    }
}

Caching Strategy

actor WeatherCache {
    static let shared = WeatherCache()

    private var cache: [String: CachedWeather] = [:]
    private let maxAge: TimeInterval = 900 // 15 minutes

    struct CachedWeather {
        let weather: Weather
        let timestamp: Date
    }

    func weather(for key: String) -> Weather? {
        guard let cached = cache[key],
              Date().timeIntervalSince(cached.timestamp) < maxAge else {
            cache[key] = nil
            return nil
        }
        return cached.weather
    }

    func store(_ weather: Weather, for key: String) {
        cache[key] = CachedWeather(weather: weather, timestamp: Date())
    }

    /// Create a cache key from coordinates (rounded to reduce duplicate calls)
    static func key(lat: Double, lon: Double) -> String {
        let roundedLat = (lat * 100).rounded() / 100
        let roundedLon = (lon * 100).rounded() / 100
        return "\(roundedLat),\(roundedLon)"
    }
}

Complete Weather App Example

import SwiftUI
import WeatherKit
import CoreLocation

@Observable
final class WeatherViewModel {
    var currentWeather: CurrentWeather?
    var hourly: [HourWeather] = []
    var daily: [DayWeather] = []
    var attribution: WeatherAttribution?
    var isLoading = false
    var errorMessage: String?

    private let service = WeatherService.shared

    func load(latitude: Double, longitude: Double) async {
        isLoading = true
        defer { isLoading = false }

        let location = CLLocation(latitude: latitude, longitude: longitude)
        let cacheKey = WeatherCache.key(lat: latitude, lon: longitude)

        // Check cache first
        if let cached = await WeatherCache.shared.weather(for: cacheKey) {
            applyWeather(cached)
            return
        }

        do {
            let weather = try await service.weather(for: location)
            await WeatherCache.shared.store(weather, for: cacheKey)
            applyWeather(weather)
            attribution = try await service.attribution
        } catch {
            errorMessage = error.localizedDescription
        }
    }

    private func applyWeather(_ weather: Weather) {
        currentWeather = weather.currentWeather
        hourly = Array(weather.hourlyForecast.prefix(24))
        daily = Array(weather.dailyForecast.prefix(10))
    }
}

struct WeatherAppView: View {
    @State private var viewModel = WeatherViewModel()

    var body: some View {
        ScrollView {
            VStack(spacing: 20) {
                // Current conditions
                if let current = viewModel.currentWeather {
                    CurrentWeatherCard(weather: current)
                }

                // Hourly
                if !viewModel.hourly.isEmpty {
                    HourlyForecastRow(hours: viewModel.hourly)
                }

                // Daily
                if !viewModel.daily.isEmpty {
                    DailyForecastList(days: viewModel.daily)
                }

                // Attribution (required)
                WeatherAttributionView(attribution: viewModel.attribution)
                    .padding(.top, 8)
            }
            .padding()
        }
        .overlay {
            if viewModel.isLoading {
                ProgressView()
            }
        }
        .task {
            await viewModel.load(latitude: 37.7749, longitude: -122.4194)
        }
    }
}

struct CurrentWeatherCard: View {
    let weather: CurrentWeather

    var body: some View {
        VStack(spacing: 8) {
            Image(systemName: weather.symbolName)
                .font(.system(size: 60))
                .symbolRenderingMode(.multicolor)

            Text(weather.temperature.formatted(.measurement(width: .abbreviated)))
                .font(.system(size: 56, weight: .thin, design: .rounded))

            Text(weather.condition.description)
                .font(.title3)
                .foregroundStyle(.secondary)

            HStack(spacing: 24) {
                Label(
                    weather.wind.speed.formatted(.measurement(width: .abbreviated)),
                    systemImage: "wind"
                )
                Label(
                    "\(Int(weather.humidity * 100))%",
                    systemImage: "humidity"
                )
                Label(
                    "UV \(weather.uvIndex.value)",
                    systemImage: "sun.max"
                )
            }
            .font(.subheadline)
            .foregroundStyle(.secondary)
        }
        .frame(maxWidth: .infinity)
        .padding()
        .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
    }
}

struct HourlyForecastRow: View {
    let hours: [HourWeather]

    var body: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 16) {
                ForEach(hours, id: \.date) { hour in
                    VStack(spacing: 6) {
                        Text(hour.date, format: .dateTime.hour())
                            .font(.caption)
                        Image(systemName: hour.symbolName)
                            .symbolRenderingMode(.multicolor)
                        Text(hour.temperature.formatted(.measurement(width: .abbreviated)))
                            .font(.callout.bold())
                    }
                }
            }
            .padding(.horizontal)
        }
    }
}

struct DailyForecastList: View {
    let days: [DayWeather]

    var body: some View {
        VStack(spacing: 12) {
            ForEach(days, id: \.date) { day in
                HStack {
                    Text(day.date, format: .dateTime.weekday(.abbreviated))
                        .frame(width: 40, alignment: .leading)
                    Image(systemName: day.symbolName)
                        .symbolRenderingMode(.multicolor)
                        .frame(width: 30)
                    Spacer()
                    Text(day.lowTemperature.formatted(.measurement(width: .abbreviated)))
                        .foregroundStyle(.secondary)
                    Text(day.highTemperature.formatted(.measurement(width: .abbreviated)))
                }
                .font(.callout)
            }
        }
        .padding()
        .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
    }
}

---

# StoreKit 2
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-storekit.html

Commerce and Wallet · Reference guideStoreKit 2Repository guidance for StoreKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Product and Product.SubscriptionInfo

Loading Products
Subscription Info

Purchase Flow (Product.purchase())
Transaction Verification and Listener
Subscription Management and Status
Offer Codes and Promotional Offers
StoreKit Configuration File for Testing

Xcode Configuration Steps
Configuration Options
Synced Configuration (Xcode 14+)

Testing in Sandbox Environment
Complete Purchase Flow Example
iOS 18+ & Marketplace Additions

PaymentMethodBinding
AdAttributionKit
External Purchases / Alternative Marketplaces (EU DMA)
MarketplaceKit

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Load products
↓2
Begin purchase
↓3
Verify transaction
↓4
Deliver entitlement and finish

02 / ArchitectureResponsibility boundariesBoundary 1
Storefront UIBoundary 2
Verified transactionsBoundary 3
Entitlement stateConnected responsibilities, not a required class hierarchy or an execution trace.
StoreKit 2 is Apple's modern Swift-native framework for in-app purchases and subscriptions. It replaces the original StoreKit with async/await APIs, automatic transaction verification, and a cleaner purchase flow.

Product and Product.SubscriptionInfo

Loading Products

import StoreKit

class StoreManager: ObservableObject {
    @Published var products: [Product] = []

    // Define product identifiers matching App Store Connect
    let productIDs: Set<String> = [
        "com.app.premium.monthly",
        "com.app.premium.yearly",
        "com.app.gems.100",
        "com.app.unlock.feature"
    ]

    func loadProducts() async {
        do {
            let storeProducts = try await Product.products(for: productIDs)

            // Sort by type and price
            products = storeProducts.sorted { $0.price < $1.price }

            for product in products {
                print("ID: \(product.id)")
                print("Display name: \(product.displayName)")
                print("Description: \(product.description)")
                print("Price: \(product.displayPrice)")
                print("Type: \(product.type)")  // .consumable, .nonConsumable, .autoRenewable, .nonRenewable
            }
        } catch {
            print("Failed to load products: \(error)")
        }
    }
}

Subscription Info

func checkSubscriptionInfo(for product: Product) async {
    guard let subscription = product.subscription else {
        print("Not a subscription product")
        return
    }

    // Subscription properties
    print("Period: \(subscription.subscriptionPeriod)")           // e.g., 1 month
    print("Group ID: \(subscription.subscriptionGroupID)")
    print("Is eligible for intro offer: \(subscription.isEligibleForIntroOffer)")

    // Check subscription status
    if let statuses = try? await subscription.status {
        for status in statuses {
            switch status.state {
            case .subscribed:
                print("Active subscription")
            case .expired:
                print("Subscription expired")
            case .revoked:
                print("Subscription revoked")
            case .inGracePeriod:
                print("In grace period")
            case .inBillingRetryPeriod:
                print("Billing retry")
            default:
                print("Unknown state")
            }

            // Access renewal info
            if let renewalInfo = try? status.renewalInfo.payloadValue {
                print("Will renew: \(renewalInfo.willAutoRenew)")
                print("Auto-renew product: \(renewalInfo.autoRenewPreference ?? "none")")
                print("Expiration reason: \(String(describing: renewalInfo.expirationReason))")
            }
        }
    }
}

Purchase Flow (Product.purchase())

class PurchaseManager: ObservableObject {
    @Published var purchasedProductIDs: Set<String> = []

    func purchase(_ product: Product) async throws -> Transaction? {
        // Optional: set purchase options
        let result = try await product.purchase(options: [
            .appAccountToken(UUID())  // Associate purchase with user account
        ])

        switch result {
        case .success(let verification):
            // Verify the transaction
            let transaction = try checkVerified(verification)

            // Deliver content
            await deliverContent(for: transaction)

            // CRITICAL: Always finish the transaction
            await transaction.finish()

            return transaction

        case .userCancelled:
            print("User cancelled the purchase")
            return nil

        case .pending:
            // Transaction requires approval (e.g., Ask to Buy)
            print("Purchase pending approval")
            return nil

        @unknown default:
            return nil
        }
    }

    func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .unverified(_, let error):
            throw StoreError.verificationFailed(error)
        case .verified(let value):
            return value
        }
    }

    func deliverContent(for transaction: Transaction) async {
        purchasedProductIDs.insert(transaction.productID)
        // Update your app's state, unlock features, add consumables, etc.
    }
}

enum StoreError: Error {
    case verificationFailed(VerificationResult<Transaction>.VerificationError)
    case purchaseFailed
}

Transaction Verification and Listener

Start the listener as early as possible (typically in your App init) to handle transactions completed outside of the purchase flow such as renewals, Ask to Buy approvals, and refunds.

@main
struct MyApp: App {
    @StateObject private var storeManager = StoreManager()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(storeManager)
                .task {
                    await storeManager.listenForTransactions()
                    await storeManager.checkEntitlements()
                }
        }
    }
}

extension StoreManager {
    func listenForTransactions() async {
        // Iterate through any transactions that are not yet finished
        for await result in Transaction.updates {
            do {
                let transaction = try checkVerified(result)
                await deliverContent(for: transaction)
                await transaction.finish()
            } catch {
                print("Transaction verification failed: \(error)")
            }
        }
    }

    // Check current entitlements on launch
    func checkEntitlements() async {
        for await result in Transaction.currentEntitlements {
            do {
                let transaction = try checkVerified(result)
                purchasedProductIDs.insert(transaction.productID)
            } catch {
                print("Entitlement verification failed: \(error)")
            }
        }
    }

    // Get the latest transaction for a specific product
    func latestTransaction(for productID: String) async -> Transaction? {
        guard let result = await Transaction.latest(for: productID) else {
            return nil
        }
        return try? checkVerified(result)
    }

    func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .unverified(_, let error):
            throw error
        case .verified(let value):
            return value
        }
    }
}

Subscription Management and Status

class SubscriptionManager: ObservableObject {
    @Published var subscriptionGroupStatus: Product.SubscriptionInfo.Status?

    func updateSubscriptionStatus(products: [Product]) async {
        guard let groupID = products.first?.subscription?.subscriptionGroupID else { return }

        do {
            let statuses = try await Product.SubscriptionInfo.status(for: groupID)

            for status in statuses {
                guard case .verified(let renewalInfo) = status.renewalInfo,
                      case .verified(let transaction) = status.transaction else {
                    continue
                }

                switch status.state {
                case .subscribed:
                    print("Subscribed to: \(transaction.productID)")
                    print("Expires: \(transaction.expirationDate?.formatted() ?? "never")")
                    subscriptionGroupStatus = status

                case .expired:
                    if renewalInfo.gracePeriodExpirationDate != nil {
                        print("In grace period")
                    }

                case .revoked:
                    print("Revoked on: \(transaction.revocationDate?.formatted() ?? "")")

                case .inBillingRetryPeriod:
                    print("Billing retry - still provide access")
                    subscriptionGroupStatus = status

                case .inGracePeriod:
                    print("Grace period - still provide access")
                    subscriptionGroupStatus = status

                default:
                    break
                }
            }
        } catch {
            print("Failed to check status: \(error)")
        }
    }

    // Show the system manage subscriptions sheet
    func showManageSubscriptions() async {
        guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
        do {
            try await AppStore.showManageSubscriptions(in: windowScene)
        } catch {
            print("Failed to show manage subscriptions: \(error)")
        }
    }
}

Offer Codes and Promotional Offers

extension StoreManager {
    // Present the system offer code redemption sheet
    func presentOfferCodeRedemption() async {
        guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
        do {
            try await AppStore.presentOfferCodeRedeemSheet(in: windowScene)
        } catch {
            print("Failed to present offer code sheet: \(error)")
        }
    }

    // Check introductory offer eligibility
    func checkIntroEligibility(for product: Product) async -> Bool {
        guard let subscription = product.subscription else { return false }
        return await subscription.isEligibleForIntroOffer
    }

    // Purchase with promotional offer (requires server-signed offer)
    func purchaseWithPromotionalOffer(
        _ product: Product,
        offerID: String,
        keyID: String,
        nonce: UUID,
        signature: Data,
        timestamp: Int
    ) async throws -> Transaction? {
        let offer = Product.PurchaseOption.promotionalOffer(
            offerID: offerID,
            keyID: keyID,
            nonce: nonce,
            signature: signature,
            timestamp: timestamp
        )

        let result = try await product.purchase(options: [offer])

        switch result {
        case .success(let verification):
            let transaction = try checkVerified(verification)
            await transaction.finish()
            return transaction
        case .userCancelled, .pending:
            return nil
        @unknown default:
            return nil
        }
    }
}

StoreKit Configuration File for Testing

Create a StoreKit configuration file in Xcode: File > New > File > StoreKit Configuration File.

Xcode Configuration Steps

Create a 
.storekit
 configuration file in your project
Add subscription groups and products with IDs matching your code
Set the configuration in your scheme: Edit Scheme > Run > Options > StoreKit Configuration
Products defined here are available in the simulator and SwiftUI previews

Configuration Options

Subscription Group
: Group related subscription tiers with upgrade/downgrade ordering
Introductory Offer
: Free trial, pay-up-front, or pay-as-you-go
Promotional Offer
: Discounted pricing for existing or lapsed subscribers
Localization
: Add localized display names and descriptions per region
Price
: Set price in your base currency; Xcode simulates other currencies

Synced Configuration (Xcode 14+)

Instead of a local file you can sync your StoreKit configuration directly from App Store Connect. This ensures product definitions match your live setup.

Testing in Sandbox Environment

// Sandbox testing on device:
// 1. Create sandbox tester in App Store Connect > Users and Access > Sandbox
// 2. Sign in on device: Settings > App Store > Sandbox Account
// 3. Sandbox subscriptions renew at accelerated rates:
//    - 1 week  = 3 minutes
//    - 1 month = 5 minutes
//    - 1 year  = 1 hour
// 4. Subscriptions auto-renew up to 6 times then expire

// StoreKit Testing in Xcode (local .storekit file):
// - Use Transaction Manager: Debug > StoreKit > Manage Transactions
// - Approve or decline Ask to Buy transactions
// - Refund transactions
// - Trigger billing retry and grace period scenarios
// - Speed up or expire subscriptions
// - Force interrupted purchases

#if DEBUG
extension StoreManager {
    func debugPrintAllTransactions() async {
        var transactions: [Transaction] = []
        for await result in Transaction.all {
            if let transaction = try? checkVerified(result) {
                transactions.append(transaction)
            }
        }
        print("Total transactions: \(transactions.count)")
        for t in transactions {
            print("  \(t.productID) - \(t.purchaseDate) - revoked: \(t.revocationDate != nil)")
        }
    }
}
#endif

Complete Purchase Flow Example

import SwiftUI
import StoreKit

struct PaywallView: View {
    @StateObject private var store = StoreManager()
    @State private var isPurchasing = false
    @State private var errorMessage: String?

    var body: some View {
        VStack(spacing: 20) {
            Text("Upgrade to Premium")
                .font(.largeTitle.bold())

            ForEach(store.products, id: \.id) { product in
                ProductCard(product: product) {
                    Task {
                        await purchaseProduct(product)
                    }
                }
                .disabled(isPurchasing || store.purchasedProductIDs.contains(product.id))
            }

            if let error = errorMessage {
                Text(error)
                    .foregroundColor(.red)
                    .font(.caption)
            }

            Button("Restore Purchases") {
                Task { await store.checkEntitlements() }
            }
        }
        .padding()
        .task {
            await store.loadProducts()
        }
    }

    func purchaseProduct(_ product: Product) async {
        isPurchasing = true
        defer { isPurchasing = false }

        do {
            if let transaction = try await store.purchase(product) {
                print("Purchased: \(transaction.productID)")
            }
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

struct ProductCard: View {
    let product: Product
    let onPurchase: () -> Void

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(product.displayName)
                .font(.headline)
            Text(product.description)
                .font(.subheadline)
                .foregroundColor(.secondary)

            HStack {
                Text(product.displayPrice)
                    .font(.title2.bold())
                if let subscription = product.subscription {
                    Text("/ \(subscription.subscriptionPeriod.debugDescription)")
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
                Spacer()
                Button("Subscribe", action: onPurchase)
                    .buttonStyle(.borderedProminent)
            }
        }
        .padding()
        .background(.regularMaterial)
        .cornerRadius(12)
    }
}

// SubscriptionStoreView (iOS 17+) - Apple's built-in paywall UI
struct SimplePaywall: View {
    var body: some View {
        SubscriptionStoreView(groupID: "your_group_id") {
            VStack {
                Image(systemName: "star.fill")
                    .font(.system(size: 60))
                    .foregroundStyle(.yellow)
                Text("Unlock Premium Features")
                    .font(.title.bold())
            }
        }
        .subscriptionStoreButtonLabel(.multiline)
        .subscriptionStorePickerItemBackground(.thinMaterial)
        .storeButton(.visible, for: .restorePurchases)
    }
}

iOS 18+ & Marketplace Additions

PaymentMethodBinding

PaymentMethodBinding
 allows apps to bind a payment method to an Apple Account for streamlined purchasing, particularly useful for services that manage recurring billing outside of the App Store.

import StoreKit

// Request payment method binding for an Apple Account
func bindPaymentMethod() async {
    do {
        let binding = try await PaymentMethodBinding.request(
            // Provide your app-specific payment method configuration
        )
        print("Payment method bound successfully: \(binding)")
    } catch {
        print("Payment method binding failed: \(error)")
    }
}

AdAttributionKit

AdAttributionKit
 is the modern replacement for 
SKAdNetwork
, providing privacy-preserving ad attribution for app install campaigns. It supports both StoreKit-rendered ads and view-through attribution.

import AdAttributionKit

// Register an app impression for attribution
func registerAdImpression() async {
    do {
        // Create an impression for a displayed ad
        let impression = try AdImpression(
            adNetworkID: "example123.adnetwork",
            sourceIdentifier: 5678,
            advertisedItemIdentifier: 1234567890,
            adType: .banner,
            adPurchaserName: "AdNetwork Inc.",
            impressionType: .view // or .storeKitRendered
        )

        // Register the impression with the system
        try await impression.handleImpression()
        print("Ad impression registered for attribution")
    } catch {
        print("Failed to register impression: \(error)")
    }
}

// For postbacks (server-side), AdAttributionKit sends cryptographically signed
// postbacks to ad networks after a conversion, similar to SKAdNetwork but with:
// - Improved reengagement attribution
// - Support for multiple postback windows
// - Developer postbacks for your own analytics

External Purchases / Alternative Marketplaces (EU DMA)

For apps distributed in the EU, iOS 18 introduces APIs for external purchase links and alternative marketplace support under the Digital Markets Act (DMA).

import StoreKit

// MARK: - External Purchase Link (EU only)

// Present an external purchase link that navigates the user
// to your website for completing the purchase
func presentExternalPurchaseLink() async {
    do {
        // Check if external purchases are available (EU region only)
        guard ExternalPurchaseLink.canOpen else {
            print("External purchase links not available in this region")
            return
        }

        // Open the external purchase flow
        // The system shows a confirmation sheet before navigating to your URL
        try await ExternalPurchaseLink.open(
            url: URL(string: "https://yourapp.com/subscribe")!
        )
    } catch {
        print("Failed to open external purchase link: \(error)")
    }
}

// MARK: - External Purchase (Alternative Billing)

// For apps that use alternative payment processing
func processExternalPurchase() async {
    do {
        guard ExternalPurchase.canMakePayments else {
            print("External purchases not available")
            return
        }

        // Notify the system about an external purchase for compliance tracking
        let token = try await ExternalPurchase.token
        // Send this token to your server for validation and to report
        // the transaction to Apple (required for commission compliance)
        print("External purchase token: \(token)")
    } catch {
        print("External purchase failed: \(error)")
    }
}

MarketplaceKit

MarketplaceKit
 supports alternative app marketplace distribution in the EU. Marketplace apps can distribute third-party apps outside of the App Store.

import MarketplaceKit

// MARK: - Alternative Marketplace Distribution

// Check if the current app was installed from an alternative marketplace
func checkMarketplaceInstallation() async {
    do {
        let appInstallation = try await AppInstallation.current

        // Verify the marketplace that distributed this app
        print("Marketplace: \(appInstallation.marketplace)")
        print("Install date: \(appInstallation.installDate)")

        // Marketplace-distributed apps can:
        // 1. Use alternative payment processors
        // 2. Distribute apps not available on the App Store
        // 3. Set their own curation and review policies
    } catch {
        print("Failed to check installation: \(error)")
    }
}

// For building a marketplace app itself:
// 1. Request the Marketplace entitlement from Apple
// 2. Implement app distribution via MarketplaceKit APIs
// 3. Handle app installation, updates, and license verification
// 4. Comply with Apple's marketplace requirements (notarization, etc.)

// Marketplace apps must handle:
struct MarketplaceAppManager {
    // Install an app from the marketplace catalog
    func installApp(bundleID: String, licenseToken: Data) async throws {
        // MarketplaceKit handles the installation flow
        // Apps must be notarized by Apple before distribution
    }

    // Check for and deliver app updates
    func checkForUpdates() async throws -> [AppUpdate] {
        // Query your marketplace server for available updates
        return []
    }
}

struct AppUpdate {
    let bundleID: String
    let version: String
    let downloadURL: URL
}

---

# Swift Charts
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-swift-charts.html

Core UI and Apps · Reference guideSwift ChartsRepository guidance for Swift Charts. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

1. Two decisions that determine whether the chart is any good
2. The pattern
3. Colors come from tokens, and encode meaning
4. Accessibility
5. Large datasets
Anti-Patterns
Checklist

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Choose the comparison
↓2
Map data to marks
↓3
Configure scales and labels
↓4
Verify accessible interpretation

02 / ArchitectureResponsibility boundariesBoundary 1
Source dataBoundary 2
Encodings and scalesBoundary 3
Chart presentationConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
 rendering any chart — line, bar, area, scatter, sector — or
when a chart scrolls badly, is unreadable to VoiceOver, or looks wrong in dark
mode.

Covers 
Chart
, the mark types, scales and axes, selection, accessibility via

AXChartDescriptor
, and what to do when the dataset is too large to plot
honestly.

Availability:
 Swift Charts iOS 16+; 
chartScrollableAxes
,

chartXSelection
, and 
chartScrollPosition
 
iOS 17+
; 
SectorMark
 (donut and
pie) 
iOS 17+
; 
chartGesture
 iOS 18+.

1. Two decisions that determine whether the chart is any good

Plot a value type keyed by a stable identity.
 Every mark needs an 
id
 that
survives a reload, or SwiftUI animates the wrong bars into each other on every
update. 
Identifiable
 on the model, 
ForEach
 over it — not indices.

Decide what "too much data" means before you hit it.
 Swift Charts will
happily try to draw 50,000 marks and drop frames doing it. There is no
virtualisation. The fix is to 
downsample before plotting
 (§5), not to hope. A
line chart 350 points wide cannot show more than about 350 distinguishable
values regardless of how many you hand it — plotting 50,000 is not more honest,
it is the same picture rendered slowly.

2. The pattern

import Charts
import SwiftUI

struct StepSample: Identifiable, Hashable, Sendable {
    let id: UUID
    let day: Date
    let steps: Int
    let source: String

    init(id: UUID = UUID(), day: Date, steps: Int, source: String) {
        self.id = id
        self.day = day
        self.steps = steps
        self.source = source
    }
}

struct StepChart: View {
    let samples: [StepSample]
    @State private var selectedDay: Date?

    private var selected: StepSample? {
        guard let selectedDay else { return nil }
        return samples.min {
            abs($0.day.timeIntervalSince(selectedDay))
                < abs($1.day.timeIntervalSince(selectedDay))
        }
    }

    var body: some View {
        Chart(samples) { sample in
            BarMark(
                // .value's first argument is the axis LABEL. It is user-facing
                // and read aloud by VoiceOver, so it is localized, not a
                // property name.
                x: .value(String(localized: "Day"), sample.day, unit: .day),
                y: .value(String(localized: "Steps"), sample.steps)
            )
            .foregroundStyle(by: .value(String(localized: "Source"), sample.source))
            .opacity(selected == nil || selected?.id == sample.id ? 1 : 0.4)
        }
        // Never let a bar chart's y-axis start anywhere but zero: a truncated
        // baseline makes a 3% difference look like a 300% one.
        .chartYScale(domain: .automatic(includesZero: true))
        .chartXAxis {
            AxisMarks(values: .stride(by: .day, count: 7)) { value in
                AxisGridLine()
                AxisValueLabel(format: .dateTime.month(.abbreviated).day())
            }
        }
        .chartYAxis {
            AxisMarks(position: .leading) { value in
                AxisGridLine()
                AxisValueLabel()
            }
        }
        .chartXSelection(value: $selectedDay)          // iOS 17+
        .chartLegend(position: .bottom, alignment: .leading)
        .frame(height: 220)                            // height only — never width
        .accessibilityLabel(String(localized: "Daily step count"))
    }
}

.frame(height:) and nothing else.
 A chart with a fixed width breaks on
every device it was not designed on, and inside a 
ScrollView
 it silently
clips. Height is a design decision; width belongs to the layout.

3. Colors come from tokens, and encode meaning

.chartForegroundStyleScale([
    String(localized: "Watch"): Color.appAccent,
    String(localized: "Phone"): Color.appSecondary
])

Two rules the default palette will not enforce for you:

Never encode meaning by hue alone.
 Roughly 8% of men cannot separate the
  default red/green pairing. Add a symbol (
.symbol(by:)
), a dash pattern, or a
  direct label. A chart whose only distinction is colour is unreadable to a
  measurable share of users and to anyone printing it.
Semantic colours must stay semantic across modes.
 
Color.red
 for "over
  budget" is fine; a literal 
Color(red: 0.9, green: 0.2, blue: 0.2)
 is not —
  it does not adapt, and on a dark background it vibrates. See
  
docs/design/design-tokens.md
.

4. Accessibility

A chart is a picture of numbers. VoiceOver users get nothing from the picture,
so the numbers must be reachable directly. There are two levels.

Level 1 — per-mark, and the cheap 80%.
 Every mark gets a label and a value:

BarMark(
    x: .value(String(localized: "Day"), sample.day, unit: .day),
    y: .value(String(localized: "Steps"), sample.steps)
)
.accessibilityLabel(sample.day.formatted(.dateTime.weekday(.wide)))
.accessibilityValue(String(localized: "\(sample.steps) steps"))

Level 2 — AXChartDescriptor, which enables Audio Graphs.
 This is what lets
a VoiceOver user 
hear
 the series as a tone sweep and navigate axes properly.
It is the difference between "a list of numbers" and "a chart".

import Accessibility
import SwiftUI

struct StepChartDescriptor: AXChartDescriptorRepresentable {
    let samples: [StepSample]

    func makeChartDescriptor() -> AXChartDescriptor {
        let steps = samples.map { Double($0.steps) }
        let days = samples.map(\.day)

        let xAxis = AXNumericDataAxisDescriptor(
            title: String(localized: "Day"),
            range: 0...Double(max(samples.count - 1, 1)),
            gridlinePositions: [],
            valueDescriptionProvider: { position in
                let index = Int(position.rounded())
                guard days.indices.contains(index) else { return "" }
                return days[index].formatted(.dateTime.month().day())
            }
        )

        let yAxis = AXNumericDataAxisDescriptor(
            title: String(localized: "Steps"),
            range: 0...(steps.max() ?? 1),
            gridlinePositions: [],
            valueDescriptionProvider: { value in
                String(localized: "\(Int(value)) steps")
            }
        )

        let series = AXDataSeriesDescriptor(
            name: String(localized: "Daily steps"),
            isContinuous: false,
            dataPoints: samples.enumerated().map { index, sample in
                AXDataPoint(x: Double(index), y: Double(sample.steps))
            }
        )

        return AXChartDescriptor(
            title: String(localized: "Daily step count"),
            summary: String(localized: "Steps recorded each day over the last month."),
            xAxis: xAxis,
            yAxis: yAxis,
            additionalAxes: [],
            series: [series]
        )
    }

    // Required, and required to actually update: returning without reassigning
    // leaves VoiceOver describing the previous dataset.
    func updateChartDescriptor(_ descriptor: AXChartDescriptor) {
        descriptor.series = makeChartDescriptor().series
    }
}

Attach it, and hide the decorative marks from the accessibility tree so
VoiceOver does not read 400 individual bars 
and
 the summary:

Chart(samples) { … }
    .accessibilityChartDescriptor(StepChartDescriptor(samples: samples))
    .accessibilityElement(children: .ignore)
    .accessibilityLabel(String(localized: "Daily step count"))

Also honour Dynamic Type: axis labels scale, and at accessibility sizes they
overlap. Reduce 
AxisMarks
 density as the size category grows rather than
pinning a font size.

5. Large datasets

The order of operations is: 
aggregate, then downsample, then plot.
 Nothing
else fixes it.

extension Array where Element == StepSample {
    /// Largest-Triangle-Three-Buckets preserves visual peaks and troughs that
    /// naive stride-sampling drops. Dropping every Nth point deletes the spike
    /// the user opened the chart to look at.
    func downsampled(to threshold: Int) -> [StepSample] {
        guard count > threshold, threshold > 2 else { return self }

        let bucketSize = Double(count - 2) / Double(threshold - 2)
        var result: [StepSample] = [self[0]]
        var previous = 0

        for bucket in 0..<(threshold - 2) {
            let start = Int(Double(bucket) * bucketSize) + 1
            let end = Swift.min(Int(Double(bucket + 1) * bucketSize) + 1, count - 1)
            let nextStart = end
            let nextEnd = Swift.min(Int(Double(bucket + 2) * bucketSize) + 1, count)

            let nextSlice = self[nextStart..<Swift.max(nextEnd, nextStart)]
            let avgX = nextSlice.isEmpty ? 0 :
                nextSlice.map(\.day.timeIntervalSince1970).reduce(0, +) / Double(nextSlice.count)
            let avgY = nextSlice.isEmpty ? 0 :
                nextSlice.map { Double($0.steps) }.reduce(0, +) / Double(nextSlice.count)

            var bestArea = -1.0
            var bestIndex = start

            for index in start..<Swift.max(end, start + 1) where indices.contains(index) {
                let area = abs(
                    (self[previous].day.timeIntervalSince1970 - avgX)
                        * (Double(self[index].steps) - Double(self[previous].steps))
                    - (self[previous].day.timeIntervalSince1970
                        - self[index].day.timeIntervalSince1970)
                        * (avgY - Double(self[previous].steps))
                ) / 2
                if area > bestArea {
                    bestArea = area
                    bestIndex = index
                }
            }

            result.append(self[bestIndex])
            previous = bestIndex
        }

        result.append(self[count - 1])
        return result
    }
}

Do this 
off the main actor
, in the model, not in 
body
:

@MainActor
@Observable
final class StepChartModel {
    private(set) var plotted: [StepSample] = []

    private let samples: any StepSampleLoading

    init(samples: any StepSampleLoading) { self.samples = samples }

    func load(pointBudget: Int = 400) async {
        // The load and the downsample both happen off the main actor. `body`
        // must never compute this — it runs on every layout pass.
        plotted = await samples.recent().downsampled(to: pointBudget)
    }
}

Other things that matter at scale:

.drawingGroup()
 flattens the chart into a single Metal layer. It helps
  with thousands of marks and 
hurts
 with dozens — measure before adding it.
Scrolling windows
: 
.chartScrollableAxes(.horizontal)
 plus
  
.chartXVisibleDomain(length:)
 keeps the plotted set bounded while the user
  pans (iOS 17+).
RectangleMark beats BarMark
 for dense heatmap-style data — fewer
  layout passes per mark.

Anti-Patterns

// WRONG — ForEach over indices.
// Identity is positional, so on every reload SwiftUI animates bar 3 into bar 4
// and the chart visibly scrambles.
Chart { ForEach(0..<samples.count, id: \.self) { i in BarMark(…, y: .value("", samples[i].steps)) } }

// RIGHT — stable identity from the model.
Chart(samples) { sample in BarMark(…) }

// WRONG — a bar chart with a truncated y-axis.
// A 3% difference is rendered as a 300% one. This is the single most common way
// a chart lies.
.chartYScale(domain: 9_500...10_000)

// RIGHT — bars are read as area, so the baseline must be zero.
.chartYScale(domain: .automatic(includesZero: true))

// WRONG — a property name as the axis label.
// It is user-facing and read aloud by VoiceOver. "stepCount" is not a word.
x: .value("stepCount", sample.steps)

// RIGHT
x: .value(String(localized: "Steps"), sample.steps)

// WRONG — meaning encoded by hue alone.
// Unreadable to roughly 8% of men, and to anyone who prints it.
.foregroundStyle(sample.isOverBudget ? .red : .green)

// RIGHT — a second channel carries the same information.
.foregroundStyle(by: .value(String(localized: "Status"), sample.status))
.symbol(by: .value(String(localized: "Status"), sample.status))

// WRONG — a fixed width.
// Breaks on every device it was not designed on, and clips inside a ScrollView.
.frame(width: 350, height: 200)

// RIGHT — height is a design decision; width belongs to the layout.
.frame(height: 200)

// WRONG — filtering or downsampling inside body.
// body runs on every layout pass, on the main actor. This is the hitch.
Chart(allSamples.filter { $0.day > cutoff }.sorted { … }) { … }

// RIGHT — the model prepares the data; body renders it.
Chart(model.plotted) { … }

// WRONG — plotting the raw dataset and hoping.
// There is no virtualisation. 50,000 marks drops frames and shows no more
// information than 400 does at that pixel width.
Chart(fiftyThousandSamples) { … }

// RIGHT
Chart(samples.downsampled(to: 400)) { … }

// WRONG — stride-sampling to reduce points.
// Deletes the spike the user opened the chart to look at.
let reduced = samples.enumerated().filter { $0.offset % 100 == 0 }.map(\.element)

// RIGHT — peak-preserving downsampling.
let reduced = samples.downsampled(to: 400)

// WRONG — no accessibility at all.
// A chart is a picture of numbers. Without a descriptor, VoiceOver gets the
// picture and none of the numbers.
Chart(samples) { BarMark(…) }

// RIGHT
Chart(samples) { BarMark(…) }
    .accessibilityChartDescriptor(StepChartDescriptor(samples: samples))

// WRONG — a descriptor attached but never updated.
// updateChartDescriptor that does nothing leaves VoiceOver reading the dataset
// from two loads ago, which is worse than no descriptor.
func updateChartDescriptor(_ descriptor: AXChartDescriptor) { }

// RIGHT
func updateChartDescriptor(_ descriptor: AXChartDescriptor) {
    descriptor.series = makeChartDescriptor().series
}

// WRONG — a chart with no empty state.
// Zero marks renders as blank axes, which reads as a broken screen.
Chart(samples) { … }

// RIGHT
if samples.isEmpty { ContentUnavailableView(…) } else { Chart(samples) { … } }

// WRONG — .drawingGroup() applied reflexively.
// It costs an offscreen render pass. On a 12-bar chart it is a regression.
Chart(twelveBars) { … }.drawingGroup()

// RIGHT — add it only after measuring, and only at thousands of marks.

// WRONG — SectorMark guarded at iOS 16.
// It does not exist before iOS 17; this does not compile against an iOS 16
// minimum, and guarding it at the wrong version drops working devices.
SectorMark(angle: .value("Share", slice.value))

// RIGHT
if #available(iOS 17, *) { SectorMark(angle: …) } else { BarMark(…) }

Checklist

[ ] Marks keyed by stable 
Identifiable
 identity, never by index
[ ] Bar and area charts include zero in the y domain
[ ] Axis labels localized — they are read aloud
[ ] Meaning carried by more than hue (symbol, dash, or label)
[ ] Colours from tokens; adapts in dark mode
[ ] 
.frame(height:)
 only
[ ] No filtering, sorting, or downsampling in 
body
[ ] Datasets over ~500 points downsampled peak-preservingly, off the main actor
[ ] 
AXChartDescriptor
 attached, and 
updateChartDescriptor
 actually updates
[ ] Decorative marks hidden from the accessibility tree
[ ] Axis density reduces at accessibility text sizes
[ ] An empty state that is not blank axes
[ ] iOS 17+ API (
SectorMark
, selection, scrolling) guarded at 
17
, not 26
[ ] 
#Preview
 for loaded, empty, single-point, and dark mode

---

# SwiftData
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-swiftdata.html

Data Management · Reference guideSwiftDataRepository guidance for SwiftData. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

@Model Macro and Schema Definition
ModelContainer and ModelContext
@Query for Fetching
#Predicate Macro for Type-Safe Queries
SortDescriptor

Bool is not Comparable, so you cannot sort by one
The rest

Relationships and Cascade Rules
Migration with VersionedSchema
SwiftData with CloudKit
iOS 18+ Additions

History API
@Index Macro
Custom Data Stores

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Define model schema
↓2
Configure model container
↓3
Query and mutate through context
↓4
Test persistence and migration

02 / ArchitectureResponsibility boundariesBoundary 1
Model containerBoundary 2
Model contextBoundary 3
Observable queriesConnected responsibilities, not a required class hierarchy or an execution trace.
@Model Macro and Schema Definition

import SwiftData

@Model
class Task {
    var id: UUID
    var title: String
    var notes: String
    var isCompleted: Bool
    var priority: Int
    var createdAt: Date
    var dueDate: Date?

    // Relationship
    var category: Category?
    var tags: [Tag]

    // Transient (not persisted)
    @Transient var isSelected = false

    // Unique constraint
    #Unique<Task>([\.id])

    init(title: String, priority: Int = 0) {
        self.id = UUID()
        self.title = title
        self.notes = ""
        self.isCompleted = false
        self.priority = priority
        self.createdAt = Date()
        self.tags = []
    }
}

@Model
class Category {
    var id: UUID
    var name: String
    var color: String

    // Inverse relationship with cascade delete
    @Relationship(deleteRule: .cascade, inverse: \Task.category)
    var tasks: [Task]

    init(name: String, color: String = "blue") {
        self.id = UUID()
        self.name = name
        self.color = color
        self.tasks = []
    }
}

@Model
class Tag {
    var id: UUID
    var name: String

    @Relationship(inverse: \Task.tags)
    var tasks: [Task]

    init(name: String) {
        self.id = UUID()
        self.name = name
        self.tasks = []
    }
}

ModelContainer and ModelContext

// App setup
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Task.self, Category.self, Tag.self])
    }
}

// Custom configuration
@main
struct MyApp: App {
    let container: ModelContainer

    init() {
        let schema = Schema([Task.self, Category.self, Tag.self])
        let config = ModelConfiguration(
            "MyApp",
            schema: schema,
            isStoredInMemoryOnly: false,
            allowsSave: true,
            groupContainer: .identifier("group.com.myapp.shared")
        )
        do {
            container = try ModelContainer(for: schema, configurations: [config])
        } catch {
            fatalError("Failed to create ModelContainer: \(error)")
        }
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }
}

// Using ModelContext directly
struct TaskListView: View {
    @Environment(\.modelContext) private var modelContext

    func addTask(title: String) {
        let task = Task(title: title)
        modelContext.insert(task)
        // SwiftData auto-saves; explicit save if needed:
        // try? modelContext.save()
    }

    func deleteTask(_ task: Task) {
        modelContext.delete(task)
    }
}

@Query for Fetching

struct TaskListView: View {
    // Basic query with sort
    @Query(sort: \Task.createdAt, order: .reverse)
    private var tasks: [Task]

    // Filtered and sorted query
    @Query(
        filter: #Predicate<Task> { !$0.isCompleted },
        sort: [
            SortDescriptor(\Task.priority, order: .reverse),
            SortDescriptor(\Task.createdAt, order: .reverse),
        ],
        animation: .default
    )
    private var pendingTasks: [Task]

    var body: some View {
        List(pendingTasks) { task in
            TaskRow(task: task)
        }
    }
}

// Dynamic query with init parameter
struct FilteredTaskList: View {
    @Query private var tasks: [Task]

    init(showCompleted: Bool, searchText: String) {
        let filter = #Predicate<Task> { task in
            (showCompleted || !task.isCompleted) &&
            (searchText.isEmpty || task.title.localizedStandardContains(searchText))
        }
        _tasks = Query(
            filter: filter,
            sort: \Task.createdAt,
            order: .reverse
        )
    }

    var body: some View {
        List(tasks) { task in
            TaskRow(task: task)
        }
    }
}

#Predicate Macro for Type-Safe Queries

// Simple predicate
let highPriority = #Predicate<Task> { $0.priority >= 2 }

// Compound predicate
let urgentIncomplete = #Predicate<Task> { task in
    !task.isCompleted && task.priority >= 2
}

// String search
let searchPredicate = #Predicate<Task> { task in
    task.title.localizedStandardContains("meeting")
}

// Date-based predicate
let today = Calendar.current.startOfDay(for: Date())
let dueTodayPredicate = #Predicate<Task> { task in
    if let dueDate = task.dueDate {
        return dueDate >= today
    }
    return false
}

// Using predicates with FetchDescriptor
func fetchOverdueTasks(context: ModelContext) throws -> [Task] {
    let now = Date()
    let descriptor = FetchDescriptor<Task>(
        predicate: #Predicate { task in
            !task.isCompleted && task.dueDate != nil && task.dueDate! < now
        },
        sortBy: [SortDescriptor(\.dueDate)]
    )
    return try context.fetch(descriptor)
}

// Fetch with limit
func fetchRecentTasks(context: ModelContext, limit: Int = 10) throws -> [Task] {
    var descriptor = FetchDescriptor<Task>(
        sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
    )
    descriptor.fetchLimit = limit
    return try context.fetch(descriptor)
}

// Count
func countIncompleteTasks(context: ModelContext) throws -> Int {
    let descriptor = FetchDescriptor<Task>(
        predicate: #Predicate { !$0.isCompleted }
    )
    return try context.fetchCount(descriptor)
}

SortDescriptor

Bool
 is not 
Comparable
, so you cannot sort by one

This is the first thing to know, because 
isPinned
 / 
isCompleted
 /

isFavourite
 is the most common sort key there is, and the compiler's
diagnostic sends you somewhere else entirely:

// WRONG — does not compile.
@Query(sort: [SortDescriptor(\Note.isPinned, order: .reverse)])
private var notes: [Note]

// error: initializer 'init(_:order:)' requires that 'Note'
//        inherit from 'NSObject'

SortDescriptor.init(_:order:)
 needs 
Value: Comparable
. Swift's 
Bool
 is
not 
Comparable
, so overload resolution falls through to the 
NSObject

key-path overload and reports 
that
 failure — pointing at a Core Data problem
you do not have. Nothing in the message mentions 
Bool
.

Two fixes, and the second is usually the right one:

// 1. Sort in Swift after fetching. Fine for a screen's worth of rows.
@Query(sort: \Note.updatedAt, order: .reverse) private var notes: [Note]

var ordered: [Note] {
    notes.sorted { ($0.isPinned ? 0 : 1) < ($1.isPinned ? 0 : 1) }
}

// 2. Store an Int rank alongside the Bool. This sorts in the store, so it
//    still works with pagination and with a fetch limit — which sorting in
//    Swift does not, because the limit is applied before your sort runs.
@Model
final class Note {
    var isPinned: Bool = false
    /// Kept in sync with `isPinned`; 0 sorts before 1.
    private(set) var pinRank: Int = 1

    func setPinned(_ pinned: Bool) {
        isPinned = pinned
        pinRank = pinned ? 0 : 1
    }
}

@Query(sort: [SortDescriptor(\Note.pinRank), SortDescriptor(\Note.updatedAt, order: .reverse)])
private var notes: [Note]

The same applies to any non-
Comparable
 property: an enum without a

Comparable
 conformance, or a custom struct.

The rest

// Single sort
@Query(sort: \Task.title) private var tasks: [Task]

// Multiple sorts — note the Int rank, not the Bool, for the first key.
@Query(sort: [
    SortDescriptor(\Task.completionRank),
    SortDescriptor(\Task.priority, order: .reverse),
    SortDescriptor(\Task.createdAt, order: .reverse),
])
private var tasks: [Task]

// Dynamic sorting
struct SortableTaskList: View {
    @State private var sortOrder = [SortDescriptor(\Task.createdAt, order: .reverse)]

    var body: some View {
        TaskListContent(sort: sortOrder)
            .toolbar {
                Menu("Sort") {
                    Button("By Date") {
                        sortOrder = [SortDescriptor(\Task.createdAt, order: .reverse)]
                    }
                    Button("By Priority") {
                        sortOrder = [SortDescriptor(\Task.priority, order: .reverse)]
                    }
                    Button("By Title") {
                        sortOrder = [SortDescriptor(\Task.title)]
                    }
                }
            }
    }
}

struct TaskListContent: View {
    @Query private var tasks: [Task]

    init(sort: [SortDescriptor<Task>]) {
        _tasks = Query(sort: sort)
    }

    var body: some View {
        List(tasks) { task in
            TaskRow(task: task)
        }
    }
}

Relationships and Cascade Rules

@Model
class Project {
    var name: String

    // Delete rule options: .cascade, .nullify, .deny, .noAction
    @Relationship(deleteRule: .cascade, inverse: \Milestone.project)
    var milestones: [Milestone]

    @Relationship(deleteRule: .nullify, inverse: \TeamMember.projects)
    var members: [TeamMember]

    init(name: String) {
        self.name = name
        self.milestones = []
        self.members = []
    }
}

@Model
class Milestone {
    var title: String
    var project: Project?

    init(title: String) {
        self.title = title
    }
}

@Model
class TeamMember {
    var name: String
    var projects: [Project] // Many-to-many

    init(name: String) {
        self.name = name
        self.projects = []
    }
}

// Working with relationships
func addMilestone(to project: Project, title: String, context: ModelContext) {
    let milestone = Milestone(title: title)
    milestone.project = project // Automatically updates project.milestones
}

Migration with VersionedSchema

// Version 1
enum SchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] { [TaskV1.self] }

    @Model
    class TaskV1 {
        var id: UUID
        var title: String
        var isCompleted: Bool
        init(title: String) {
            self.id = UUID()
            self.title = title
            self.isCompleted = false
        }
    }
}

// Version 2 — adds priority field
enum SchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] { [TaskV2.self] }

    @Model
    class TaskV2 {
        var id: UUID
        var title: String
        var isCompleted: Bool
        var priority: Int // New field
        init(title: String, priority: Int = 0) {
            self.id = UUID()
            self.title = title
            self.isCompleted = false
            self.priority = priority
        }
    }
}

// Migration plan
enum TaskMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] }

    static var stages: [MigrationStage] {
        [migrateV1toV2]
    }

    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self
    )
}

// Apply migration
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: SchemaV2.TaskV2.self, migrationPlan: TaskMigrationPlan.self)
    }
}

SwiftData with CloudKit

// CloudKit-enabled container
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Task.self, Category.self]) {
            // Container is configured — CloudKit syncs automatically
            // when the app's CloudKit container is set in entitlements
        }
    }
}

// Requirements for CloudKit compatibility:
// 1. All properties must have default values or be optional
// 2. No unique constraints (CloudKit doesn't support them)
// 3. All relationships must be optional
// 4. Enable "CloudKit" capability in Xcode
// 5. Set the CloudKit container identifier in entitlements

@Model
class CloudTask {
    var id: UUID = UUID()
    var title: String = ""
    var isCompleted: Bool = false
    var priority: Int = 0
    var createdAt: Date = Date()

    // Optional relationship for CloudKit
    var category: CloudCategory?

    init(title: String) {
        self.title = title
    }
}

iOS 18+ Additions

History API

The SwiftData History API enables tracking time-based model changes (inserts, updates, deletes) for server sync, auditing, or undo support. It uses 
HistoryDescriptor
 and the 
ModelContext
 history-fetching methods to retrieve changes since a given point in time.

import SwiftData

// Fetching model history for sync
func fetchChangesSinceLastSync(context: ModelContext, lastToken: DefaultHistoryToken?) async throws -> DefaultHistoryToken? {
    // Build a descriptor starting from the last known sync point
    var descriptor = HistoryDescriptor<DefaultHistoryTransaction>()
    if let lastToken {
        descriptor.predicate = #Predicate { transaction in
            transaction.token > lastToken
        }
    }

    // Fetch transactions from history
    let transactions = try context.fetchHistory(descriptor)

    for transaction in transactions {
        // Each transaction contains changes grouped atomically
        for change in transaction.changes {
            switch change {
            case let change as DefaultHistoryInsert<Task>:
                let modelID = change.persistentIdentifier
                print("Inserted Task: \(modelID)")
                // Sync insert to server

            case let change as DefaultHistoryUpdate<Task>:
                let modelID = change.persistentIdentifier
                let updatedProperties = change.updatedProperties
                print("Updated Task: \(modelID), fields: \(updatedProperties)")
                // Sync update to server

            case let change as DefaultHistoryDelete<Task>:
                let modelID = change.persistentIdentifier
                print("Deleted Task: \(modelID)")
                // Sync delete to server

            default:
                break
            }
        }
    }

    // Return the latest token to persist for next sync
    return transactions.last?.token
}

// Persisting the sync token
class SyncManager {
    private let tokenKey = "lastHistorySyncToken"

    func saveToken(_ token: DefaultHistoryToken) {
        let data = try? JSONEncoder().encode(token)
        UserDefaults.standard.set(data, forKey: tokenKey)
    }

    func loadToken() -> DefaultHistoryToken? {
        guard let data = UserDefaults.standard.data(forKey: tokenKey) else { return nil }
        return try? JSONDecoder().decode(DefaultHistoryToken.self, from: data)
    }

    func performSync(context: ModelContext) async throws {
        let lastToken = loadToken()
        if let newToken = try await fetchChangesSinceLastSync(context: context, lastToken: lastToken) {
            saveToken(newToken)
        }
    }
}

@Index Macro

The 
#Index
 macro defines database indexes on model properties, improving query performance for frequently searched or sorted fields.

import SwiftData

@Model
class Task {
    var id: UUID
    var title: String
    var isCompleted: Bool
    var priority: Int
    var createdAt: Date
    var dueDate: Date?
    var category: String

    // Single-property index for fast lookups by title
    #Index<Task>([\.title])

    // Compound index for queries that filter by completion status and sort by priority
    #Index<Task>([\.isCompleted, \.priority])

    // Index on createdAt for time-based sorting queries
    #Index<Task>([\.createdAt])

    // Compound index for category-based filtered and sorted queries
    #Index<Task>([\.category, \.dueDate])

    init(title: String, priority: Int = 0, category: String = "general") {
        self.id = UUID()
        self.title = title
        self.isCompleted = false
        self.priority = priority
        self.createdAt = Date()
        self.category = category
    }
}

// The indexes above optimize queries like:
// @Query(filter: #Predicate<Task> { !$0.isCompleted }, sort: \.priority)
// @Query(filter: #Predicate<Task> { $0.category == "work" }, sort: \.dueDate)

Custom Data Stores

The 
DataStore
 protocol allows SwiftData to use custom storage backends beyond the default SQLite/CoreData store. You can back SwiftData models with JSON files, remote APIs, or any custom persistence layer.

import SwiftData

// A custom data store backed by JSON files
actor JSONDataStore: DataStore {
    typealias Snapshot = DefaultSnapshot

    let configuration: DataStoreConfiguration
    let fileURL: URL

    init(_ configuration: DataStoreConfiguration, fileURL: URL) {
        self.configuration = configuration
        self.fileURL = fileURL
    }

    // Fetch models from the custom store
    func fetch<T: PersistentModel>(_ descriptor: FetchDescriptor<T>) throws -> [T] {
        // Read from your custom backend (JSON file, API, etc.)
        let data = try Data(contentsOf: fileURL)
        let snapshots = try JSONDecoder().decode([DefaultSnapshot].self, from: data)
        // Convert snapshots back to models
        // Implementation depends on your storage format
        return []
    }

    // Save changes to the custom store
    func save(_ insert: [DefaultSnapshot], _ update: [DefaultSnapshot], _ delete: [PersistentIdentifier]) throws {
        // Persist inserts, updates, and deletes to your custom backend
        var existing = loadExistingSnapshots()

        // Apply inserts
        existing.append(contentsOf: insert)

        // Apply updates
        for updated in update {
            if let index = existing.firstIndex(where: { $0.persistentIdentifier == updated.persistentIdentifier }) {
                existing[index] = updated
            }
        }

        // Apply deletes
        existing.removeAll { snapshot in
            delete.contains(snapshot.persistentIdentifier)
        }

        // Write back to storage
        let data = try JSONEncoder().encode(existing)
        try data.write(to: fileURL)
    }

    private func loadExistingSnapshots() -> [DefaultSnapshot] {
        guard let data = try? Data(contentsOf: fileURL) else { return [] }
        return (try? JSONDecoder().decode([DefaultSnapshot].self, from: data)) ?? []
    }
}

// Custom configuration for the data store
struct JSONStoreConfiguration: DataStoreConfiguration {
    var name: String
    var schema: Schema?
    var fileURL: URL

    init(name: String, schema: Schema? = nil, fileURL: URL) {
        self.name = name
        self.schema = schema
        self.fileURL = fileURL
    }
}

// Using the custom data store with ModelContainer
@main
struct MyApp: App {
    let container: ModelContainer

    init() {
        let schema = Schema([Task.self, Category.self])
        let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
        let storeURL = documentsURL.appendingPathComponent("data.json")

        let config = JSONStoreConfiguration(
            name: "JSONStore",
            schema: schema,
            fileURL: storeURL
        )

        do {
            container = try ModelContainer(for: schema, configurations: [config])
        } catch {
            fatalError("Failed to create ModelContainer with custom store: \(error)")
        }
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }
}

---

# TipKit
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-tipkit.html

Core UI and Apps · Reference guideTipKitRepository guidance for TipKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Tip Protocol (Title, Message, Image, Actions)
TipView and popoverTip Modifier
Tip Rules (Parameter-Based and Event-Based)
Tip.Event and Donation
MaxDisplayCount and Display Frequency
Tips.configure() Setup
Invalidation and Status
Updating Parameters at Runtime
Complete Onboarding Tips Example
Key Considerations

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Define a contextual tip
↓2
Configure display rules
↓3
Show eligible guidance
↓4
Invalidate after completion

02 / ArchitectureResponsibility boundariesBoundary 1
Tip definitionsBoundary 2
Eligibility rulesBoundary 3
Tip presentationConnected responsibilities, not a required class hierarchy or an execution trace.
TipKit is Apple's framework for creating contextual, rule-based tips that educate users about features in your app. Tips appear as inline views or popovers and are managed by a centralized system that handles display frequency, eligibility rules, and synchronization across devices via iCloud.

Tip Protocol (Title, Message, Image, Actions)

Define tips by conforming to the 
Tip
 protocol. Each tip has a title, optional message, image, and action buttons.

import TipKit

struct FavoritesTip: Tip {
    // Required: the tip title
    var title: Text {
        Text("Save Your Favorites")
    }

    // Optional: detailed message
    var message: Text? {
        Text("Tap the heart icon to save articles for later reading.")
    }

    // Optional: leading image
    var image: Image? {
        Image(systemName: "heart.fill")
    }

    // Optional: action buttons
    var actions: [Action] {
        Action(id: "learn-more", title: "Learn More")
        Action(id: "dismiss", title: "Got It")
    }
}

struct ShareTip: Tip {
    var title: Text {
        Text("Share with Friends")
    }

    var message: Text? {
        Text("Use the share button to send articles to friends and family.")
    }

    var image: Image? {
        Image(systemName: "square.and.arrow.up")
    }
}

struct FilterTip: Tip {
    var title: Text {
        Text("Filter Your Feed")
    }

    var message: Text? {
        Text("Swipe down to reveal filter options and find exactly what you need.")
    }

    var image: Image? {
        Image(systemName: "line.3.horizontal.decrease.circle")
    }
}

TipView and popoverTip Modifier

Display tips as inline views or popovers attached to any SwiftUI element.

import SwiftUI
import TipKit

struct ArticleListView: View {
    let favoritesTip = FavoritesTip()
    let shareTip = ShareTip()
    let filterTip = FilterTip()
    let articles: [Article]

    var body: some View {
        NavigationStack {
            List {
                // Inline tip — appears as a row in the list
                TipView(favoritesTip) { action in
                    if action.id == "learn-more" {
                        // Handle action
                    } else if action.id == "dismiss" {
                        favoritesTip.invalidate(reason: .actionPerformed)
                    }
                }

                ForEach(articles) { article in
                    ArticleRow(article: article)
                }
            }
            .navigationTitle("Articles")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        // Share action
                    } label: {
                        Image(systemName: "square.and.arrow.up")
                    }
                    // Popover tip — anchored to the share button
                    .popoverTip(shareTip, arrowEdge: .top)
                }

                ToolbarItem(placement: .topBarLeading) {
                    Button {
                        // Filter action
                    } label: {
                        Image(systemName: "line.3.horizontal.decrease.circle")
                    }
                    .popoverTip(filterTip)
                }
            }
        }
    }
}

// Customizing tip appearance
struct StyledTipView: View {
    let tip = FavoritesTip()

    var body: some View {
        VStack {
            // Style the inline TipView
            TipView(tip)
                .tipBackground(Color.blue.opacity(0.1))

            // Popover tip on custom view
            Image(systemName: "heart")
                .font(.largeTitle)
                .popoverTip(tip, arrowEdge: .bottom) { action in
                    if action.id == "dismiss" {
                        tip.invalidate(reason: .actionPerformed)
                    }
                }
        }
    }
}

Tip Rules (Parameter-Based and Event-Based)

Rules determine when a tip is eligible for display. Combine parameter rules and event rules for precise targeting.

import TipKit

struct ProFeatureTip: Tip {
    // Parameter-based rule — tip eligible when condition is met
    @Parameter
    static var isLoggedIn: Bool = false

    @Parameter
    static var hasUsedBasicFeatures: Bool = false

    var title: Text {
        Text("Unlock Pro Features")
    }

    var message: Text? {
        Text("Upgrade to access advanced analytics and priority support.")
    }

    var image: Image? {
        Image(systemName: "star.circle.fill")
    }

    // Rules that must ALL be true for the tip to display
    var rules: [Rule] {
        // Show only when user is logged in
        #Rule(Self.$isLoggedIn) { $0 == true }

        // Show only after user has explored basic features
        #Rule(Self.$hasUsedBasicFeatures) { $0 == true }
    }
}

// Event-based rules — tip appears after specific events occur
struct AdvancedSearchTip: Tip {
    // Define an event that can be donated multiple times
    static let searchPerformed = Event(id: "searchPerformed")

    var title: Text {
        Text("Try Advanced Search")
    }

    var message: Text? {
        Text("Use filters and operators to find exactly what you need.")
    }

    var image: Image? {
        Image(systemName: "magnifyingglass")
    }

    var rules: [Rule] {
        // Show after the user has searched at least 3 times
        #Rule(Self.searchPerformed) { event in
            event.donations.count >= 3
        }
    }
}

// Combined rules
struct WeeklyReportTip: Tip {
    static let appLaunched = Event(id: "appLaunched")

    @Parameter
    static var hasActiveSubscription: Bool = false

    var title: Text {
        Text("Check Your Weekly Report")
    }

    var message: Text? {
        Text("Your personalized weekly insights are ready to view.")
    }

    var rules: [Rule] {
        // Must have an active subscription
        #Rule(Self.$hasActiveSubscription) { $0 == true }

        // Must have launched the app at least 5 times
        #Rule(Self.appLaunched) { event in
            event.donations.count >= 5
        }
    }
}

Tip.Event and Donation

Donate events to track user actions. The system evaluates event-based rules against accumulated donations.

import TipKit
import SwiftUI

struct SearchView: View {
    @State private var searchText = ""
    let advancedSearchTip = AdvancedSearchTip()

    var body: some View {
        VStack {
            HStack {
                TextField("Search...", text: $searchText)
                    .textFieldStyle(.roundedBorder)

                Button("Search") {
                    performSearch()
                }
                .popoverTip(advancedSearchTip)
            }
            .padding()
        }
    }

    private func performSearch() {
        // Donate the event each time the user searches
        Task {
            await AdvancedSearchTip.searchPerformed.donate()
        }

        // Perform the actual search...
    }
}

// Donate events with associated values for richer rules
struct PurchaseTip: Tip {
    static let itemViewed = Event(id: "itemViewed")

    var title: Text {
        Text("Ready to Buy?")
    }

    var message: Text? {
        Text("Items in your recently viewed list are available at a discount.")
    }

    var rules: [Rule] {
        // Show after viewing 5+ items within the last 3 days
        #Rule(Self.itemViewed) { event in
            event.donations.filter {
                $0.date > Date.now.addingTimeInterval(-3 * 24 * 60 * 60)
            }.count >= 5
        }
    }
}

// Donating the event
func userViewedItem(_ item: Item) {
    Task {
        await PurchaseTip.itemViewed.donate()
    }
}

MaxDisplayCount and Display Frequency

Control how often tips appear to prevent user fatigue.

import TipKit

struct DailyTip: Tip {
    var title: Text {
        Text("Daily Insight")
    }

    var message: Text? {
        Text("Check your daily statistics in the dashboard.")
    }

    // Limit how many times this specific tip is shown
    var options: [TipOption] {
        // Show this tip a maximum of 3 times
        MaxDisplayCount(3)
    }
}

struct OneTimeTip: Tip {
    var title: Text {
        Text("Welcome!")
    }

    var message: Text? {
        Text("Swipe through to explore all features.")
    }

    var options: [TipOption] {
        // Show only once
        MaxDisplayCount(1)
    }
}

Tips.configure() Setup

Configure TipKit when your app launches. This sets global display frequency and data store options.

import SwiftUI
import TipKit

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .task {
                    configureTips()
                }
        }
    }

    private func configureTips() {
        do {
            try Tips.configure([
                // How often any tip can appear across the app
                .displayFrequency(.daily),

                // Use .immediate for testing (shows tips right away)
                // .displayFrequency(.immediate),

                // Data store location — use .applicationDefault for production
                .datastoreLocation(.applicationDefault)
            ])
        } catch {
            print("Failed to configure TipKit: \(error)")
        }
    }
}

// Display frequency options:
// .immediate    — no delay between tips (best for testing)
// .hourly       — at most one tip per hour
// .daily        — at most one tip per day
// .weekly       — at most one tip per week
// .monthly      — at most one tip per month

Invalidation and Status

Invalidate tips when users complete the associated action, or check tip status programmatically.

import TipKit
import SwiftUI

struct FeatureView: View {
    let featureTip = FavoritesTip()

    var body: some View {
        VStack {
            TipView(featureTip)

            Button("Add to Favorites") {
                addToFavorites()

                // Invalidate the tip — it won't appear again
                featureTip.invalidate(reason: .actionPerformed)
            }

            Button("Dismiss Tip") {
                // Tip can potentially reappear in the future
                featureTip.invalidate(reason: .tipClosed)
            }
        }
    }

    private func addToFavorites() {
        // Perform the action...
    }
}

// Check tip status programmatically
struct ConditionalTipView: View {
    let tip = ShareTip()

    var body: some View {
        VStack {
            switch tip.status {
            case .available:
                TipView(tip)
            case .invalidated(let reason):
                switch reason {
                case .actionPerformed:
                    Text("You've already used this feature!")
                        .foregroundStyle(.green)
                case .tipClosed:
                    EmptyView()
                case .maxDisplayCountExceeded:
                    EmptyView()
                default:
                    EmptyView()
                }
            case .pending:
                // Rules not yet met
                EmptyView()
            }
        }
    }
}

// Reset all tips (useful for testing or settings screen)
func resetAllTips() {
    try? Tips.resetDatastore()
}

// Show all tips immediately (testing)
func showAllTipsForTesting() {
    try? Tips.configure([
        .displayFrequency(.immediate),
        .datastoreLocation(.applicationDefault)
    ])
}

Updating Parameters at Runtime

Set parameters dynamically as the user interacts with your app.

import TipKit
import SwiftUI

struct LoginView: View {
    var body: some View {
        Button("Log In") {
            performLogin()
        }
    }

    private func performLogin() {
        // After successful login, update the parameter
        ProFeatureTip.isLoggedIn = true
    }
}

struct OnboardingCompletionView: View {
    var body: some View {
        Button("Complete Setup") {
            completeOnboarding()
        }
    }

    private func completeOnboarding() {
        ProFeatureTip.hasUsedBasicFeatures = true
    }
}

struct AppLaunchHandler {
    static func trackLaunch() {
        Task {
            await WeeklyReportTip.appLaunched.donate()
        }
    }
}

Complete Onboarding Tips Example

A full onboarding flow using TipKit with sequential tips that guide users through key features.

import TipKit
import SwiftUI

// MARK: - Onboarding Tips Definition

struct WelcomeTip: Tip {
    var title: Text { Text("Welcome to ReadIt") }
    var message: Text? { Text("Discover a curated feed of articles tailored to your interests.") }
    var image: Image? { Image(systemName: "hand.wave.fill") }
    var options: [TipOption] { MaxDisplayCount(1) }
}

struct SwipeActionTip: Tip {
    static let articleViewed = Event(id: "articleViewed")

    var title: Text { Text("Swipe for Quick Actions") }
    var message: Text? { Text("Swipe left on any article to save, share, or archive it.") }
    var image: Image? { Image(systemName: "hand.draw.fill") }
    var options: [TipOption] { MaxDisplayCount(2) }

    var rules: [Rule] {
        #Rule(Self.articleViewed) { $0.donations.count >= 2 }
    }
}

struct PersonalizeTip: Tip {
    static let savedArticle = Event(id: "savedArticle")

    @Parameter
    static var hasCompletedOnboarding: Bool = false

    var title: Text { Text("Personalize Your Feed") }
    var message: Text? { Text("Go to Settings to select your favorite topics and sources.") }
    var image: Image? { Image(systemName: "slider.horizontal.3") }
    var options: [TipOption] { MaxDisplayCount(1) }

    var actions: [Action] {
        Action(id: "go-to-settings", title: "Open Settings")
    }

    var rules: [Rule] {
        #Rule(Self.$hasCompletedOnboarding) { $0 == true }
        #Rule(Self.savedArticle) { $0.donations.count >= 1 }
    }
}

// MARK: - Main View with Onboarding Tips

struct OnboardingArticleListView: View {
    let welcomeTip = WelcomeTip()
    let swipeActionTip = SwipeActionTip()
    let personalizeTip = PersonalizeTip()

    @State private var articles: [Article] = Article.sampleData
    @State private var showSettings = false

    var body: some View {
        NavigationStack {
            List {
                // Welcome tip at the top
                TipView(welcomeTip)

                // Personalize tip with action handler
                TipView(personalizeTip) { action in
                    if action.id == "go-to-settings" {
                        showSettings = true
                        personalizeTip.invalidate(reason: .actionPerformed)
                    }
                }

                ForEach(articles) { article in
                    ArticleRowView(article: article)
                        .onTapGesture {
                            Task {
                                await SwipeActionTip.articleViewed.donate()
                            }
                        }
                        .swipeActions(edge: .trailing) {
                            Button {
                                saveArticle(article)
                            } label: {
                                Label("Save", systemImage: "bookmark")
                            }
                            .tint(.blue)

                            Button {
                                // Share
                            } label: {
                                Label("Share", systemImage: "square.and.arrow.up")
                            }
                            .tint(.green)
                        }
                }
            }
            .navigationTitle("ReadIt")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        showSettings = true
                    } label: {
                        Image(systemName: "gearshape")
                    }
                    .popoverTip(swipeActionTip)
                }
            }
            .sheet(isPresented: $showSettings) {
                SettingsView()
            }
        }
    }

    private func saveArticle(_ article: Article) {
        Task {
            await PersonalizeTip.savedArticle.donate()
        }
    }
}

// MARK: - Supporting Views

struct ArticleRowView: View {
    let article: Article

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            Text(article.title)
                .font(.headline)
            Text(article.summary)
                .font(.subheadline)
                .foregroundStyle(.secondary)
                .lineLimit(2)
            HStack {
                Text(article.source)
                    .font(.caption)
                    .foregroundStyle(.blue)
                Spacer()
                Text(article.date, style: .relative)
                    .font(.caption2)
                    .foregroundStyle(.tertiary)
            }
        }
        .padding(.vertical, 4)
    }
}

struct SettingsView: View {
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        NavigationStack {
            List {
                Section("Topics") {
                    ForEach(["Technology", "Science", "Design", "Business"], id: \.self) { topic in
                        Toggle(topic, isOn: .constant(false))
                    }
                }

                Section("Debug") {
                    Button("Reset All Tips") {
                        try? Tips.resetDatastore()
                    }
                }
            }
            .navigationTitle("Settings")
            .toolbar {
                ToolbarItem(placement: .confirmationAction) {
                    Button("Done") {
                        PersonalizeTip.hasCompletedOnboarding = true
                        dismiss()
                    }
                }
            }
        }
    }
}

// MARK: - Data Model

struct Article: Identifiable {
    let id = UUID()
    let title: String
    let summary: String
    let source: String
    let date: Date

    static var sampleData: [Article] {
        [
            Article(title: "SwiftUI 6 Announced", summary: "Apple reveals major updates to SwiftUI at WWDC.", source: "Apple Newsroom", date: .now.addingTimeInterval(-3600)),
            Article(title: "The Future of AI", summary: "How artificial intelligence is reshaping every industry.", source: "Tech Review", date: .now.addingTimeInterval(-7200)),
            Article(title: "Design Systems at Scale", summary: "Building consistent design systems for large organizations.", source: "Design Weekly", date: .now.addingTimeInterval(-10800))
        ]
    }
}

// MARK: - App Entry Point

@main
struct ReadItApp: App {
    var body: some Scene {
        WindowGroup {
            OnboardingArticleListView()
                .task {
                    try? Tips.configure([
                        .displayFrequency(.immediate),
                        .datastoreLocation(.applicationDefault)
                    ])
                }
        }
    }
}

Key Considerations

Availability
: TipKit requires iOS 17+, macOS 14+, watchOS 10+, tvOS 17+.
iCloud sync
: Tip state syncs across devices via iCloud by default. A tip dismissed on iPhone won't reappear on iPad.
Display frequency
: Set at the global level via 
Tips.configure()
. Individual tips respect 
MaxDisplayCount
 independently.
Testing
: Use 
Tips.resetDatastore()
 to clear all tip state. Use 
.displayFrequency(.immediate)
 during development.
Invalidation reasons
: 
.actionPerformed
 means the user completed the action (permanent). 
.tipClosed
 means the user dismissed the tip (may reappear if display count allows).
Performance
: Tips are lightweight. The system evaluates rules lazily and only renders tips when eligible.
Accessibility
: TipKit views automatically support VoiceOver and Dynamic Type.

---

# UserNotifications
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-usernotifications.html

System Integration · Reference guideUserNotificationsRepository guidance for UserNotifications. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

UNUserNotificationCenter and Requesting Permission
Local Notifications (UNNotificationRequest, Content, Triggers)
Time, Calendar, and Location Triggers

Time Interval Trigger
Calendar Trigger
Location Trigger

Notification Actions and Categories
Handling Notification Actions via Delegate
Remote Notifications (APNs Registration and Handling)
Notification Service Extension for Rich Notifications
Provisional and Critical Alerts
Managing Pending and Delivered Notifications
Complete Example: Notification Setup in App

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Explain notification purpose
↓2
Request authorization
↓3
Schedule or register delivery
↓4
Handle notification actions

02 / ArchitectureResponsibility boundariesBoundary 1
App schedulingBoundary 2
System deliveryBoundary 3
Action handlingConnected responsibilities, not a required class hierarchy or an execution trace.
The UserNotifications framework manages local and remote notifications in iOS. It provides a unified API for scheduling, delivering, and handling notifications with rich content, actions, and various trigger types.

UNUserNotificationCenter and Requesting Permission

import UserNotifications

class NotificationManager: ObservableObject {
    static let shared = NotificationManager()

    @Published var isAuthorized = false

    let center = UNUserNotificationCenter.current()

    func requestPermission() async -> Bool {
        do {
            let granted = try await center.requestAuthorization(
                options: [.alert, .sound, .badge, .providesAppNotificationSettings]
            )
            await MainActor.run { isAuthorized = granted }
            return granted
        } catch {
            print("Permission request failed: \(error)")
            return false
        }
    }

    func checkCurrentSettings() async {
        let settings = await center.notificationSettings()

        switch settings.authorizationStatus {
        case .authorized:
            print("Fully authorized")
        case .denied:
            print("Denied - direct user to Settings")
        case .provisional:
            print("Provisional (quiet delivery)")
        case .ephemeral:
            print("Ephemeral (App Clips)")
        case .notDetermined:
            print("Not yet requested")
        @unknown default:
            break
        }

        // Check individual setting states
        print("Alert: \(settings.alertSetting)")
        print("Badge: \(settings.badgeSetting)")
        print("Sound: \(settings.soundSetting)")
        print("Lock screen: \(settings.lockScreenSetting)")
        print("Notification center: \(settings.notificationCenterSetting)")
    }
}

Local Notifications (UNNotificationRequest, Content, Triggers)

extension NotificationManager {
    func scheduleLocalNotification() async throws {
        let content = UNMutableNotificationContent()
        content.title = "Reminder"
        content.subtitle = "Daily Check-in"
        content.body = "Time to log your progress for today."
        content.sound = .default
        content.badge = 1
        content.userInfo = ["screen": "journal", "entryID": "abc123"]

        // Thread identifier groups related notifications together
        content.threadIdentifier = "daily-reminders"

        // Relevance score (0.0 to 1.0) affects position in notification summary
        content.relevanceScore = 0.8

        // Interruption level (iOS 15+)
        content.interruptionLevel = .timeSensitive
        // .passive       - silently added to notification center
        // .active        - default: sound + banner (respects Focus)
        // .timeSensitive - breaks through most Focus filters
        // .critical      - plays sound even on mute (requires entitlement)

        // Attach an image
        if let imageURL = Bundle.main.url(forResource: "notification", withExtension: "png") {
            let attachment = try UNNotificationAttachment(
                identifier: "image",
                url: imageURL,
                options: [UNNotificationAttachmentOptionsTypeHintKey: "public.png"]
            )
            content.attachments = [attachment]
        }

        let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)

        let request = UNNotificationRequest(
            identifier: UUID().uuidString,
            content: content,
            trigger: trigger
        )

        try await center.add(request)
    }
}

Time, Calendar, and Location Triggers

Time Interval Trigger

extension NotificationManager {
    // Fire after a delay. For repeating, interval must be >= 60 seconds.
    func scheduleAfterDelay(seconds: TimeInterval, repeating: Bool = false) -> UNTimeIntervalNotificationTrigger {
        return UNTimeIntervalNotificationTrigger(timeInterval: seconds, repeats: repeating)
    }
}

Calendar Trigger

extension NotificationManager {
    // Fire at a specific date and time, optionally repeating
    func scheduleDailyReminder(hour: Int, minute: Int) async throws {
        let content = UNMutableNotificationContent()
        content.title = "Daily Reminder"
        content.body = "Don't forget to check in!"
        content.sound = .default

        var dateComponents = DateComponents()
        dateComponents.hour = hour
        dateComponents.minute = minute
        // Omitting day/month/year makes it repeat daily at this time

        let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)

        let request = UNNotificationRequest(
            identifier: "daily-reminder",
            content: content,
            trigger: trigger
        )
        try await center.add(request)
    }

    // Weekly trigger: every Monday at 9:00 AM
    func scheduleWeeklyReminder() async throws {
        let content = UNMutableNotificationContent()
        content.title = "Weekly Review"
        content.body = "Time for your weekly review."
        content.sound = .default

        var dateComponents = DateComponents()
        dateComponents.weekday = 2  // 1 = Sunday, 2 = Monday, ...
        dateComponents.hour = 9
        dateComponents.minute = 0

        let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
        let request = UNNotificationRequest(
            identifier: "weekly-review",
            content: content,
            trigger: trigger
        )
        try await center.add(request)
    }

    // One-time notification at a specific date
    func scheduleOnDate(_ date: Date) async throws {
        let content = UNMutableNotificationContent()
        content.title = "Scheduled Event"
        content.body = "Your event is starting now."
        content.sound = .default

        let components = Calendar.current.dateComponents(
            [.year, .month, .day, .hour, .minute],
            from: date
        )
        let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
        let request = UNNotificationRequest(
            identifier: "event-\(date.timeIntervalSince1970)",
            content: content,
            trigger: trigger
        )
        try await center.add(request)
    }
}

Location Trigger

import CoreLocation

extension NotificationManager {
    func scheduleLocationNotification() async throws {
        let content = UNMutableNotificationContent()
        content.title = "Welcome!"
        content.body = "You've arrived at the office."
        content.sound = .default

        let coordinate = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
        let region = CLCircularRegion(center: coordinate, radius: 100, identifier: "office")
        region.notifyOnEntry = true
        region.notifyOnExit = false

        let trigger = UNLocationNotificationTrigger(region: region, repeats: true)
        let request = UNNotificationRequest(
            identifier: "office-arrival",
            content: content,
            trigger: trigger
        )
        try await center.add(request)
    }
}

Notification Actions and Categories

extension NotificationManager {
    func registerCategories() {
        // Define actions
        let replyAction = UNTextInputNotificationAction(
            identifier: "REPLY",
            title: "Reply",
            options: [],
            textInputButtonTitle: "Send",
            textInputPlaceholder: "Type your reply..."
        )

        let markReadAction = UNNotificationAction(
            identifier: "MARK_READ",
            title: "Mark as Read",
            options: .destructive
        )

        let viewAction = UNNotificationAction(
            identifier: "VIEW",
            title: "View",
            options: .foreground  // Opens the app when tapped
        )

        // Define category grouping related actions
        let messageCategory = UNNotificationCategory(
            identifier: "MESSAGE",
            actions: [replyAction, markReadAction, viewAction],
            intentIdentifiers: [],
            hiddenPreviewsBodyPlaceholder: "New message",
            categorySummaryFormat: "%u more messages",
            options: [.customDismissAction]  // Notifies delegate when user dismisses
        )

        let reminderCategory = UNNotificationCategory(
            identifier: "REMINDER",
            actions: [
                UNNotificationAction(identifier: "SNOOZE", title: "Snooze 15 min", options: []),
                UNNotificationAction(identifier: "COMPLETE", title: "Mark Complete", options: .destructive)
            ],
            intentIdentifiers: [],
            options: []
        )

        center.setNotificationCategories([messageCategory, reminderCategory])
    }

    // Send a notification with a category
    func sendMessageNotification(from sender: String, message: String) async throws {
        let content = UNMutableNotificationContent()
        content.title = sender
        content.body = message
        content.categoryIdentifier = "MESSAGE"
        content.sound = .default
        content.threadIdentifier = "chat-\(sender)"

        let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
        let request = UNNotificationRequest(
            identifier: UUID().uuidString,
            content: content,
            trigger: trigger
        )
        try await center.add(request)
    }
}

Handling Notification Actions via Delegate

class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {

    // Called when user taps a notification or performs an action
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse,
        withCompletionHandler completionHandler: @escaping () -> Void
    ) {
        let actionIdentifier = response.actionIdentifier
        let userInfo = response.notification.request.content.userInfo

        switch actionIdentifier {
        case "REPLY":
            if let textResponse = response as? UNTextInputNotificationResponse {
                print("User replied: \(textResponse.userText)")
                // Send the reply to your server
            }
        case "MARK_READ":
            print("Marked as read")
        case "VIEW":
            print("Opening detail view")
            // Navigate to the relevant screen using userInfo
        case "SNOOZE":
            // Reschedule the notification for 15 minutes later
            Task { try? await rescheduleNotification(from: response.notification.request) }
        case "COMPLETE":
            print("Task marked complete")
        case UNNotificationDefaultActionIdentifier:
            // User tapped the notification banner itself
            if let screen = userInfo["screen"] as? String {
                print("Navigate to: \(screen)")
            }
        case UNNotificationDismissActionIdentifier:
            // User dismissed the notification (requires .customDismissAction)
            print("Notification dismissed")
        default:
            break
        }

        completionHandler()
    }

    // Called when a notification arrives while the app is in the foreground
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification,
        withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
    ) {
        // Show banner even when app is in foreground
        completionHandler([.banner, .sound, .badge, .list])
    }

    private func rescheduleNotification(from request: UNNotificationRequest) async throws {
        let content = request.content.mutableCopy() as! UNMutableNotificationContent
        let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 900, repeats: false)
        let newRequest = UNNotificationRequest(
            identifier: request.identifier + "-snoozed",
            content: content,
            trigger: trigger
        )
        try await UNUserNotificationCenter.current().add(newRequest)
    }
}

Remote Notifications (APNs Registration and Handling)

class AppDelegate: NSObject, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        UNUserNotificationCenter.current().delegate = NotificationDelegate()
        application.registerForRemoteNotifications()
        return true
    }

    // Called when APNs registration succeeds
    func application(
        _ application: UIApplication,
        didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
    ) {
        let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
        print("APNs device token: \(token)")
        // Send this token to your backend server
    }

    // Called when APNs registration fails
    func application(
        _ application: UIApplication,
        didFailToRegisterForRemoteNotificationsWithError error: Error
    ) {
        print("APNs registration failed: \(error)")
    }

    // Handle silent push notifications (content-available: 1)
    func application(
        _ application: UIApplication,
        didReceiveRemoteNotification userInfo: [AnyHashable: Any],
        fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
    ) {
        if let aps = userInfo["aps"] as? [String: Any],
           aps["content-available"] as? Int == 1 {
            Task {
                // Perform background data fetch
                completionHandler(.newData)
            }
        } else {
            completionHandler(.noData)
        }
    }
}

// Example APNs JSON payload (sent from your server):
// {
//   "aps": {
//     "alert": {
//       "title": "New Message",
//       "subtitle": "From John",
//       "body": "Hey, are you available?"
//     },
//     "badge": 3,
//     "sound": "default",
//     "category": "MESSAGE",
//     "mutable-content": 1,
//     "thread-id": "chat-john",
//     "interruption-level": "time-sensitive"
//   },
//   "messageId": "msg-123",
//   "senderId": "user-456"
// }

Notification Service Extension for Rich Notifications

Create a new target: File > New > Target > Notification Service Extension. This allows modifying remote notification content before display (download images, decrypt payloads, etc.). The extension has approximately 30 seconds to complete.

// NotificationService.swift (in the service extension target)
import UserNotifications

class NotificationService: UNNotificationServiceExtension {
    var contentHandler: ((UNNotificationContent) -> Void)?
    var bestAttemptContent: UNMutableNotificationContent?

    override func didReceive(
        _ request: UNNotificationRequest,
        withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
    ) {
        self.contentHandler = contentHandler
        bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent

        guard let content = bestAttemptContent else {
            contentHandler(request.content)
            return
        }

        // Download and attach an image from the payload
        if let imageURLString = content.userInfo["imageURL"] as? String,
           let imageURL = URL(string: imageURLString) {
            downloadAttachment(from: imageURL) { localURL in
                if let localURL = localURL,
                   let attachment = try? UNNotificationAttachment(
                       identifier: "image",
                       url: localURL
                   ) {
                    content.attachments = [attachment]
                }
                contentHandler(content)
            }
        } else {
            contentHandler(content)
        }
    }

    // Called if the extension is about to be terminated (time limit reached)
    override func serviceExtensionTimeWillExpire() {
        if let content = bestAttemptContent {
            contentHandler?(content)
        }
    }

    private func downloadAttachment(from url: URL, completion: @escaping (URL?) -> Void) {
        URLSession.shared.downloadTask(with: url) { localURL, _, error in
            guard let localURL = localURL, error == nil else {
                completion(nil)
                return
            }
            let tmpURL = FileManager.default.temporaryDirectory
                .appendingPathComponent(UUID().uuidString + ".jpg")
            try? FileManager.default.moveItem(at: localURL, to: tmpURL)
            completion(tmpURL)
        }.resume()
    }
}

Provisional and Critical Alerts

extension NotificationManager {
    // Provisional: delivered quietly without showing a permission prompt.
    // Notifications appear in Notification Center but not on Lock Screen or as banners.
    // The user can later promote to prominent or disable entirely.
    func requestProvisionalPermission() async -> Bool {
        do {
            return try await center.requestAuthorization(
                options: [.alert, .sound, .badge, .provisional]
            )
        } catch {
            return false
        }
    }

    // Critical alerts bypass Do Not Disturb and silent mode.
    // Requires a special entitlement from Apple (medical, security, public safety apps only).
    func requestCriticalPermission() async -> Bool {
        do {
            return try await center.requestAuthorization(
                options: [.alert, .sound, .badge, .criticalAlert]
            )
        } catch {
            return false
        }
    }

    // Send a critical alert
    func sendCriticalAlert(title: String, body: String) async throws {
        let content = UNMutableNotificationContent()
        content.title = title
        content.body = body
        content.interruptionLevel = .critical
        content.sound = UNNotificationSound.criticalSoundNamed(
            UNNotificationSoundName("alarm.caf"),
            withAudioVolume: 1.0
        )

        let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
        let request = UNNotificationRequest(
            identifier: "critical-\(UUID().uuidString)",
            content: content,
            trigger: trigger
        )
        try await center.add(request)
    }
}

Managing Pending and Delivered Notifications

extension NotificationManager {
    // List all pending (scheduled but not yet delivered) notifications
    func getPendingNotifications() async -> [UNNotificationRequest] {
        return await center.pendingNotificationRequests()
    }

    // Remove specific pending notifications by identifier
    func removePending(identifiers: [String]) {
        center.removePendingNotificationRequests(withIdentifiers: identifiers)
    }

    // Remove all pending notifications
    func removeAllPending() {
        center.removeAllPendingNotificationRequests()
    }

    // Remove delivered notifications from Notification Center
    func removeDelivered(identifiers: [String]) {
        center.removeDeliveredNotifications(withIdentifiers: identifiers)
    }

    // Clear the badge count
    func clearBadge() async throws {
        try await center.setBadgeCount(0)
    }
}

Complete Example: Notification Setup in App

import SwiftUI

@main
struct MyApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        WindowGroup {
            ContentView()
                .task {
                    let manager = NotificationManager.shared
                    let granted = await manager.requestPermission()
                    if granted {
                        manager.registerCategories()
                    }
                }
        }
    }
}

struct NotificationSettingsView: View {
    @StateObject private var manager = NotificationManager.shared
    @State private var dailyReminderEnabled = false
    @State private var reminderHour = 9

    var body: some View {
        Form {
            Section("Permissions") {
                Button("Request Notification Permission") {
                    Task { await manager.requestPermission() }
                }
                Text(manager.isAuthorized ? "Authorized" : "Not authorized")
                    .foregroundStyle(manager.isAuthorized ? .green : .red)
            }

            Section("Daily Reminder") {
                Toggle("Enable Daily Reminder", isOn: $dailyReminderEnabled)
                    .onChange(of: dailyReminderEnabled) { _, enabled in
                        Task {
                            if enabled {
                                try? await manager.scheduleDailyReminder(
                                    hour: reminderHour,
                                    minute: 0
                                )
                            } else {
                                manager.removePending(identifiers: ["daily-reminder"])
                            }
                        }
                    }

                if dailyReminderEnabled {
                    Picker("Hour", selection: $reminderHour) {
                        ForEach(0..<24, id: \.self) { hour in
                            Text("\(hour):00").tag(hour)
                        }
                    }
                }
            }

            Section("Debug") {
                Button("Send Test Notification") {
                    Task { try? await manager.scheduleLocalNotification() }
                }
                Button("Clear Badge") {
                    Task { try? await manager.clearBadge() }
                }
            }
        }
        .navigationTitle("Notifications")
    }
}

---

# VisionKit
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-visionkit.html

Core UI and Apps · Reference guideVisionKitRepository guidance for VisionKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Context
Pattern
Framework Choice
Availability and Privacy
Anti-Patterns
Checklist

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Choose scanning interaction
↓2
Check device support
↓3
Present scanning interface
↓4
Consume recognized items

02 / ArchitectureResponsibility boundariesBoundary 1
Camera interfaceBoundary 2
Recognition interactionBoundary 3
App contentConnected responsibilities, not a required class hierarchy or an execution trace.
Context

Use VisionKit when the app needs Apple-provided camera/document UI for scanning, data capture, Live Text-style interaction, or visual lookup workflows. Use Vision when you need lower-level image analysis requests. Use AVFoundation when you need a custom camera pipeline.

VisionKit is a UI-facing framework: it gives you system experiences that already handle camera presentation, capture affordances, and platform interaction patterns. Keep business logic outside the VisionKit view/controller and pass extracted results into an injected service.

Pattern

Wrap VisionKit behind a protocol so SwiftUI views can preview without camera hardware:

import Foundation

protocol DocumentScanning {
    func scanReceipt() async throws -> ScannedDocument
}

struct ScannedDocument: Sendable, Equatable {
    var pages: [Data]
    var recognizedText: String
}

The UI layer asks for a scan; it does not own parsing, persistence, or network upload:

import SwiftUI

@MainActor
@Observable
final class ReceiptScanModel {
    private let scanner: any DocumentScanning

    var document: ScannedDocument?
    var errorMessage: String?

    init(scanner: any DocumentScanning) {
        self.scanner = scanner
    }

    func scan() async {
        do {
            document = try await scanner.scanReceipt()
        } catch {
            errorMessage = "Scanning failed. Please try again."
        }
    }
}

For UIKit-backed VisionKit controllers, keep the delegate bridge small and mark UI updates as main-actor work. Do not store scanned images in 
UserDefaults
; persist them through the app's document store, SwiftData/Core Data metadata, or an encrypted file path.

Framework Choice

Need

Prefer

System document scanner UI

VisionKit

Live Text / visual lookup style interaction

VisionKit

Barcode/text/object analysis without system UI

Vision

Fully custom camera preview and capture

AVFoundation

OCR results feeding an on-device LLM

VisionKit or Vision -> Foundation Models, with private local context

Availability and Privacy

Gate VisionKit features by platform and API availability.
Provide camera usage copy when the flow opens camera capture.
Keep extracted text and images local unless the user explicitly exports or syncs them.
Show a manual fallback for unsupported devices, simulator runs, parental controls, or camera denial.

Anti-Patterns

// WRONG: force camera-only scanning with no fallback.
Button("Scan") {
    showScanner = true
}

// RIGHT: branch by availability and authorization, then offer import/manual entry.
if scannerIsAvailable {
    Button("Scan") {
        showScanner = true
    }
} else {
    Button("Import File") {
        showImporter = true
    }
}

// WRONG: upload recognized text automatically.
try await api.upload(scan.recognizedText)

// RIGHT: make export explicit and explain where content goes.
try await exporter.share(scan, destination: selectedDestination)

Checklist

[ ] VisionKit is chosen for system UI, not low-level image analysis.
[ ] Camera permission copy explains the user-visible scanning purpose.
[ ] The simulator and unsupported devices have a manual/import fallback.
[ ] Extracted text/images do not leave the device without explicit user action.
[ ] The SwiftUI screen previews with a fake 
DocumentScanning
 implementation.

---

# WidgetKit
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-widgetkit.html

Core UI and Apps · Reference guideWidgetKitRepository guidance for WidgetKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Widget Protocol and Configuration
TimelineProvider (Placeholder, Snapshot, Timeline)
TimelineEntry Design
Widget Families (systemSmall, Medium, Large, ExtraLarge)
Lock Screen Widgets (Accessory Families)
WidgetBundle for Multiple Widgets
Interactive Widgets (iOS 17+ Button/Toggle)
Live Activities and ActivityKit
App Intent Configuration
Reloading Widgets from the Main App
iOS 18+ Additions

Controls API (ControlWidget)
Control Center Placement and Sizing
Live Activity Intents (iOS 18)

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Define widget content
↓2
Produce timeline entries
↓3
Render each widget family
↓4
Request relevant reloads

02 / ArchitectureResponsibility boundariesBoundary 1
App dataBoundary 2
Timeline providerBoundary 3
Widget viewsConnected responsibilities, not a required class hierarchy or an execution trace.
WidgetKit enables glanceable, timely content on the Home Screen, Lock Screen, and StandBy mode. Widgets use SwiftUI for their views and a timeline-based system for updates.

Widget Protocol and Configuration

import WidgetKit
import SwiftUI

// Static configuration (no user configuration needed)
struct SimpleWidget: Widget {
    let kind: String = "SimpleWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: SimpleProvider()) { entry in
            SimpleWidgetView(entry: entry)
                .containerBackground(.fill.tertiary, for: .widget)
        }
        .configurationDisplayName("Daily Summary")
        .description("Shows your daily progress at a glance.")
        .supportedFamilies([
            .systemSmall, .systemMedium, .systemLarge,
            .accessoryCircular, .accessoryRectangular, .accessoryInline
        ])
        .contentMarginsDisabled()  // iOS 17+: opt out of default content margins
    }
}

// App Intent configuration (iOS 17+ user-configurable widget)
struct ConfigurableWidget: Widget {
    let kind: String = "ConfigurableWidget"

    var body: some WidgetConfiguration {
        AppIntentConfiguration(
            kind: kind,
            intent: SelectCategoryIntent.self,
            provider: ConfigurableProvider()
        ) { entry in
            ConfigurableWidgetView(entry: entry)
                .containerBackground(.fill.tertiary, for: .widget)
        }
        .configurationDisplayName("Category Widget")
        .description("Shows items from a selected category.")
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

TimelineProvider (Placeholder, Snapshot, Timeline)

struct SimpleEntry: TimelineEntry {
    let date: Date
    let title: String
    let value: Int
    let icon: String
}

struct SimpleProvider: TimelineProvider {
    // Shown while widget is loading. Must return synchronously.
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: .now, title: "Loading...", value: 0, icon: "star")
    }

    // Shown in the widget gallery and transient situations.
    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> Void) {
        if context.isPreview {
            // Return sample data for the gallery preview
            completion(SimpleEntry(date: .now, title: "Steps Today", value: 8432, icon: "figure.walk"))
        } else {
            // Fetch real data for transient display
            let entry = SimpleEntry(date: .now, title: "Steps Today", value: fetchStepCount(), icon: "figure.walk")
            completion(entry)
        }
    }

    // Provides the timeline of entries that drive the widget's display.
    func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> Void) {
        var entries: [SimpleEntry] = []
        let currentDate = Date()

        // Create entries for the next 5 hours
        for hourOffset in 0..<5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SimpleEntry(
                date: entryDate,
                title: "Steps Today",
                value: fetchStepCount() + (hourOffset * 500),
                icon: "figure.walk"
            )
            entries.append(entry)
        }

        // Timeline reload policies:
        // .atEnd     - reload after the last entry's date passes
        // .after(d)  - reload after a specific date
        // .never     - only reload when the app explicitly requests it
        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }

    private func fetchStepCount() -> Int { return 8432 }
}

// Async provider using AppIntentTimelineProvider (cleaner async/await API)
struct ConfigurableProvider: AppIntentTimelineProvider {
    typealias Entry = SimpleEntry
    typealias Intent = SelectCategoryIntent

    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: .now, title: "Loading...", value: 0, icon: "star")
    }

    func snapshot(for configuration: SelectCategoryIntent, in context: Context) async -> SimpleEntry {
        SimpleEntry(date: .now, title: configuration.category?.name ?? "All", value: 42, icon: "star")
    }

    func timeline(for configuration: SelectCategoryIntent, in context: Context) async -> Timeline<SimpleEntry> {
        let entries = [
            SimpleEntry(
                date: .now,
                title: configuration.category?.name ?? "All",
                value: 42,
                icon: "star"
            )
        ]
        return Timeline(entries: entries, policy: .after(.now.addingTimeInterval(3600)))
    }
}

TimelineEntry Design

// Rich timeline entry with multiple data points
struct DashboardEntry: TimelineEntry {
    let date: Date
    let tasks: [TaskItem]
    let completedCount: Int
    let totalCount: Int
    let streakDays: Int

    var completionPercentage: Double {
        guard totalCount > 0 else { return 0 }
        return Double(completedCount) / Double(totalCount)
    }

    static var preview: DashboardEntry {
        DashboardEntry(
            date: .now,
            tasks: [
                TaskItem(name: "Morning workout", isComplete: true),
                TaskItem(name: "Read 30 minutes", isComplete: false),
                TaskItem(name: "Meditate", isComplete: true)
            ],
            completedCount: 5,
            totalCount: 8,
            streakDays: 12
        )
    }
}

struct TaskItem: Identifiable {
    let id = UUID()
    let name: String
    let isComplete: Bool
}

Widget Families (systemSmall, Medium, Large, ExtraLarge)

struct SimpleWidgetView: View {
    var entry: SimpleEntry

    @Environment(\.widgetFamily) var family

    var body: some View {
        switch family {
        case .systemSmall:
            smallView
        case .systemMedium:
            mediumView
        case .systemLarge:
            largeView
        case .systemExtraLarge:
            extraLargeView  // iPad only
        case .accessoryCircular:
            circularView
        case .accessoryRectangular:
            rectangularView
        case .accessoryInline:
            inlineView
        @unknown default:
            smallView
        }
    }

    // Home Screen small (~169x169 pt)
    var smallView: some View {
        VStack(alignment: .leading, spacing: 4) {
            Image(systemName: entry.icon)
                .font(.title2)
                .foregroundStyle(.blue)
            Spacer()
            Text(entry.title)
                .font(.caption)
                .foregroundStyle(.secondary)
            Text("\(entry.value)")
                .font(.title.bold())
                .contentTransition(.numericText())
        }
        .padding()
        .widgetURL(URL(string: "myapp://steps"))
    }

    // Home Screen medium (~360x169 pt)
    var mediumView: some View {
        HStack {
            smallView
            Spacer()
            VStack(alignment: .trailing) {
                Text("Goal: 10,000")
                    .font(.caption)
                ProgressView(value: Double(entry.value), total: 10000)
                    .tint(.blue)
                Text("\(10000 - entry.value) remaining")
                    .font(.caption2)
                    .foregroundStyle(.secondary)
            }
            .padding()
        }
    }

    // Home Screen large (~360x376 pt)
    var largeView: some View {
        VStack(alignment: .leading, spacing: 12) {
            mediumView
            Divider()
            Text("Hourly Breakdown")
                .font(.headline)
            ForEach(0..<4) { hour in
                HStack {
                    Text("\(hour + 9):00")
                        .font(.caption.monospacedDigit())
                    ProgressView(value: Double.random(in: 0.2...1.0))
                        .tint(.blue)
                }
            }
            Spacer()
        }
        .padding()
    }

    var extraLargeView: some View {
        largeView // Customize further for iPad extra large
    }
}

Lock Screen Widgets (Accessory Families)

extension SimpleWidgetView {
    // Lock Screen circular gauge
    var circularView: some View {
        Gauge(value: Double(entry.value), in: 0...10000) {
            Image(systemName: entry.icon)
        } currentValueLabel: {
            Text("\(entry.value / 1000)k")
                .font(.caption2)
        }
        .gaugeStyle(.accessoryCircular)
    }

    // Lock Screen rectangular
    var rectangularView: some View {
        VStack(alignment: .leading) {
            Label("\(entry.value)", systemImage: entry.icon)
                .font(.headline)
            Text(entry.title)
                .font(.caption)
                .foregroundStyle(.secondary)
            ProgressView(value: Double(entry.value), total: 10000)
        }
    }

    // Lock Screen inline (single line of text beside clock)
    var inlineView: some View {
        Label("\(entry.value) \(entry.title)", systemImage: entry.icon)
    }
}

// Rendering mode for lock screen
// Lock Screen widgets are rendered in one of three modes:
// - .vibrant: tinted semi-transparent material (iOS Lock Screen)
// - .accented: tinted with user's chosen accent color (watchOS)
// - .fullColor: standard colors (Home Screen)
// Use @Environment(\.widgetRenderingMode) to adapt

WidgetBundle for Multiple Widgets

@main
struct MyWidgets: WidgetBundle {
    var body: some Widget {
        SimpleWidget()
        ConfigurableWidget()
        DashboardWidget()

        if #available(iOS 18, *) {
            ControlWidget()
        }
    }
}

Interactive Widgets (iOS 17+ Button/Toggle)

iOS 17 introduced interactive widgets with Button and Toggle that perform AppIntents directly from the widget.

import AppIntents

// App Intent for toggling a task
struct ToggleTaskIntent: AppIntent {
    static var title: LocalizedStringResource = "Toggle Task"

    @Parameter(title: "Task ID")
    var taskID: String

    init() {}

    init(taskID: String) {
        self.taskID = taskID
    }

    func perform() async throws -> some IntentResult {
        let store = TaskStore.shared
        store.toggleTask(id: taskID)

        // Reload the widget timeline to reflect the change
        WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget")

        return .result()
    }
}

struct IncrementCountIntent: AppIntent {
    static var title: LocalizedStringResource = "Increment Counter"

    func perform() async throws -> some IntentResult {
        CounterStore.shared.increment()
        WidgetCenter.shared.reloadTimelines(ofKind: "CounterWidget")
        return .result()
    }
}

// Widget view with interactive elements
struct InteractiveTaskWidget: View {
    let entry: DashboardEntry

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("Tasks")
                .font(.headline)

            ForEach(entry.tasks) { task in
                HStack {
                    // Interactive toggle button
                    Button(intent: ToggleTaskIntent(taskID: task.id.uuidString)) {
                        Image(systemName: task.isComplete ? "checkmark.circle.fill" : "circle")
                            .foregroundStyle(task.isComplete ? .green : .secondary)
                    }
                    .buttonStyle(.plain)

                    Text(task.name)
                        .strikethrough(task.isComplete)
                        .font(.subheadline)
                }
            }

            Spacer()

            // Interactive toggle
            Toggle(isOn: entry.completedCount > 0, intent: IncrementCountIntent()) {
                Text("Focus Mode")
            }
            .toggleStyle(.switch)
        }
        .padding()
    }
}

Live Activities and ActivityKit

import ActivityKit

// Define the attributes for the Live Activity
struct DeliveryAttributes: ActivityAttributes {
    // Dynamic content that changes over time
    struct ContentState: Codable, Hashable {
        var status: String
        var estimatedArrival: Date
        var driverName: String
        var currentStep: Int  // 0: preparing, 1: picked up, 2: nearby, 3: delivered
    }

    // Static content set at creation
    let orderNumber: String
    let restaurantName: String
}

class LiveActivityManager {
    var currentActivity: Activity<DeliveryAttributes>?

    func startDeliveryTracking(orderNumber: String, restaurant: String) throws {
        guard ActivityAuthorizationInfo().areActivitiesEnabled else {
            print("Live Activities not enabled")
            return
        }

        let attributes = DeliveryAttributes(
            orderNumber: orderNumber,
            restaurantName: restaurant
        )

        let initialState = DeliveryAttributes.ContentState(
            status: "Preparing your order",
            estimatedArrival: .now.addingTimeInterval(1800),
            driverName: "Alex",
            currentStep: 0
        )

        let content = ActivityContent(state: initialState, staleDate: nil)

        currentActivity = try Activity.request(
            attributes: attributes,
            content: content,
            pushType: .token  // .token for server push updates, nil for local-only
        )

        // Observe push token for server-driven updates
        if let activity = currentActivity {
            Task {
                for await token in activity.pushTokenUpdates {
                    let tokenString = token.map { String(format: "%02x", $0) }.joined()
                    print("Live Activity push token: \(tokenString)")
                    // Send this token to your server
                }
            }
        }
    }

    // Update the Live Activity locally
    func updateDelivery(status: String, step: Int, eta: Date) async {
        let updatedState = DeliveryAttributes.ContentState(
            status: status,
            estimatedArrival: eta,
            driverName: "Alex",
            currentStep: step
        )
        let content = ActivityContent(state: updatedState, staleDate: nil)
        await currentActivity?.update(content)
    }

    // End the Live Activity
    func endDelivery() async {
        let finalState = DeliveryAttributes.ContentState(
            status: "Delivered!",
            estimatedArrival: .now,
            driverName: "Alex",
            currentStep: 3
        )
        let content = ActivityContent(state: finalState, staleDate: nil)

        // Dismissal policies:
        // .default          - user can dismiss manually
        // .immediate        - disappears right away
        // .after(date)      - auto-dismiss after the specified date
        await currentActivity?.end(content, dismissalPolicy: .after(.now.addingTimeInterval(300)))
    }
}

// Live Activity UI (defined in your Widget extension target)
struct DeliveryLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: DeliveryAttributes.self) { context in
            // Lock Screen and banner presentation
            VStack(spacing: 12) {
                HStack {
                    Text(context.attributes.restaurantName)
                        .font(.headline)
                    Spacer()
                    Text("Order #\(context.attributes.orderNumber)")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }

                ProgressView(value: Double(context.state.currentStep), total: 3)
                    .tint(.green)

                HStack {
                    Label(context.state.status, systemImage: "bicycle")
                        .font(.subheadline)
                    Spacer()
                    Text(context.state.estimatedArrival, style: .timer)
                        .font(.subheadline.monospacedDigit())
                }
            }
            .padding()
            .activityBackgroundTint(.black.opacity(0.8))
            .activitySystemActionForegroundColor(.white)

        } dynamicIsland: { context in
            DynamicIsland {
                DynamicIslandExpandedRegion(.leading) {
                    Image(systemName: "bicycle")
                        .font(.title2)
                }
                DynamicIslandExpandedRegion(.trailing) {
                    Text(context.state.estimatedArrival, style: .timer)
                        .font(.caption.monospacedDigit())
                }
                DynamicIslandExpandedRegion(.center) {
                    Text(context.attributes.restaurantName)
                        .font(.headline)
                }
                DynamicIslandExpandedRegion(.bottom) {
                    VStack(spacing: 8) {
                        Text(context.state.status)
                            .font(.subheadline)
                        ProgressView(value: Double(context.state.currentStep), total: 3)
                            .tint(.green)
                    }
                }
            } compactLeading: {
                Image(systemName: "bicycle")
            } compactTrailing: {
                Text(context.state.estimatedArrival, style: .timer)
                    .font(.caption.monospacedDigit())
            } minimal: {
                Image(systemName: "bicycle")
            }
        }
    }
}

App Intent Configuration

import AppIntents

// Define a configurable entity for widget parameters
struct CategoryEntity: AppEntity {
    static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Category")
    static var defaultQuery = CategoryQuery()

    var id: String
    var name: String

    var displayRepresentation: DisplayRepresentation {
        DisplayRepresentation(title: "\(name)")
    }
}

struct CategoryQuery: EntityQuery {
    func entities(for identifiers: [String]) async throws -> [CategoryEntity] {
        allCategories().filter { identifiers.contains($0.id) }
    }

    func suggestedEntities() async throws -> [CategoryEntity] {
        allCategories()
    }

    func defaultResult() async -> CategoryEntity? {
        allCategories().first
    }

    private func allCategories() -> [CategoryEntity] {
        [
            CategoryEntity(id: "work", name: "Work"),
            CategoryEntity(id: "personal", name: "Personal"),
            CategoryEntity(id: "health", name: "Health")
        ]
    }
}

// Widget configuration intent
struct SelectCategoryIntent: WidgetConfigurationIntent {
    static var title: LocalizedStringResource = "Select Category"
    static var description = IntentDescription("Choose which category to display in the widget.")

    @Parameter(title: "Category")
    var category: CategoryEntity?

    @Parameter(title: "Show Completed", default: true)
    var showCompleted: Bool
}

Reloading Widgets from the Main App

import WidgetKit

class WidgetReloadManager {
    // Reload a specific widget by kind
    func reloadTaskWidget() {
        WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget")
    }

    // Reload all widgets belonging to this app
    func reloadAllWidgets() {
        WidgetCenter.shared.reloadAllTimelines()
    }

    // Get current widget configurations on the user's device
    func getWidgetInfo() {
        WidgetCenter.shared.getCurrentConfigurations { result in
            switch result {
            case .success(let widgets):
                for widget in widgets {
                    print("Kind: \(widget.kind), Family: \(widget.family)")
                }
            case .failure(let error):
                print("Error fetching configurations: \(error)")
            }
        }
    }
}

// Sharing data between the app and widget extension using App Groups.
// 1. Enable App Groups capability in both the app target and widget extension target
// 2. Use the shared container:

let sharedDefaults = UserDefaults(suiteName: "group.com.yourapp.shared")
sharedDefaults?.set(42, forKey: "stepCount")

let sharedContainer = FileManager.default.containerURL(
    forSecurityApplicationGroupIdentifier: "group.com.yourapp.shared"
)
// Write/read files in sharedContainer for larger data sets

iOS 18+ Additions

Controls API (ControlWidget)

iOS 18 introduces 
ControlWidget
 for adding interactive controls to Control Center and the Lock Screen. Controls are small, actionable widgets that can toggle states or trigger actions.

import WidgetKit
import SwiftUI
import AppIntents

// MARK: - Toggle Control (e.g., a light switch)

struct LightToggleControl: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(kind: "com.app.light-toggle") {
            ControlWidgetToggle(
                "Living Room",
                isOn: LightManager.shared.isLivingRoomOn,
                action: ToggleLightIntent(room: "living-room")
            ) { isOn in
                // The view displayed in the control
                Label(isOn ? "On" : "Off", systemImage: isOn ? "lightbulb.fill" : "lightbulb")
            }
            .tint(.yellow)
        }
        .displayName("Light Toggle")
        .description("Toggle a light on or off.")
    }
}

// App Intent that the toggle invokes
struct ToggleLightIntent: SetValueIntent {
    static var title: LocalizedStringResource = "Toggle Light"

    @Parameter(title: "Room")
    var room: String

    @Parameter(title: "Is On")
    var value: Bool

    init() {
        self.room = "living-room"
        self.value = false
    }

    init(room: String) {
        self.room = room
        self.value = false
    }

    func perform() async throws -> some IntentResult {
        LightManager.shared.setLight(room: room, isOn: value)
        return .result()
    }
}

// MARK: - Button Control (e.g., trigger an action)

struct CaffeineLogControl: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(kind: "com.app.caffeine-log") {
            ControlWidgetButton(action: LogCaffeineIntent()) {
                Label("Log Coffee", systemImage: "cup.and.saucer.fill")
            }
        }
        .displayName("Log Caffeine")
        .description("Quickly log a coffee.")
    }
}

struct LogCaffeineIntent: AppIntent {
    static var title: LocalizedStringResource = "Log Caffeine"

    func perform() async throws -> some IntentResult {
        CaffeineStore.shared.logCoffee()
        return .result()
    }
}

// MARK: - Configurable Control with AppIntentControlConfiguration

struct ConfigurableLightControl: ControlWidget {
    var body: some ControlWidgetConfiguration {
        AppIntentControlConfiguration(
            kind: "com.app.configurable-light",
            intent: SelectRoomIntent.self
        ) { configuration in
            ControlWidgetToggle(
                configuration.room?.name ?? "Light",
                isOn: LightManager.shared.isOn(room: configuration.room?.id ?? ""),
                action: ToggleLightIntent(room: configuration.room?.id ?? "")
            ) { isOn in
                Label(isOn ? "On" : "Off", systemImage: isOn ? "lightbulb.fill" : "lightbulb")
            }
            .tint(.orange)
        }
        .displayName("Room Light")
        .description("Toggle a specific room's light.")
    }
}

struct SelectRoomIntent: ControlConfigurationIntent {
    static var title: LocalizedStringResource = "Select Room"

    @Parameter(title: "Room")
    var room: RoomEntity?
}

// MARK: - Registering Controls in WidgetBundle

@main
struct MyWidgets: WidgetBundle {
    var body: some Widget {
        SimpleWidget()
        DashboardWidget()

        // iOS 18+ Control Center widgets
        if #available(iOS 18, *) {
            LightToggleControl()
            CaffeineLogControl()
            ConfigurableLightControl()
        }
    }
}

Control Center Placement and Sizing

Controls appear in the redesigned iOS 18 Control Center. Users add them from the Control Center gallery, just like Home Screen widgets.

Key characteristics:
- 
Small footprint
: Controls occupy a single Control Center cell (similar to the existing toggles like Wi-Fi, Bluetooth)
- 
Two types
: 
ControlWidgetToggle
 for on/off state, 
ControlWidgetButton
 for one-shot actions
- 
Lock Screen access
: Controls can also be placed on the Lock Screen as quick-action buttons (replacing the default flashlight/camera shortcuts)
- 
Tinting
: Use 
.tint()
 to set the active color of a toggle control
- 
Static vs Configurable
: Use 
StaticControlConfiguration
 for fixed controls, 
AppIntentControlConfiguration
 for user-configurable controls with parameters
- 
Value providers
: For toggles, provide a value source that returns the current state; SwiftData or App Groups work well for shared state

Live Activity Intents (iOS 18)

iOS 18 enables Live Activities to include App Intent-driven buttons and toggles directly on the Lock Screen and Dynamic Island.

import ActivityKit
import AppIntents
import SwiftUI
import WidgetKit

// Live Activity with interactive intent-driven buttons
struct OrderTrackingLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: OrderAttributes.self) { context in
            // Lock Screen / banner view with interactive buttons
            VStack(spacing: 12) {
                HStack {
                    Text(context.attributes.storeName)
                        .font(.headline)
                    Spacer()
                    Text(context.state.status)
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                }

                ProgressView(value: context.state.progress)
                    .tint(.blue)

                HStack(spacing: 16) {
                    // Intent-driven button on the Live Activity
                    Button(intent: ContactDriverIntent(orderID: context.attributes.orderID)) {
                        Label("Contact Driver", systemImage: "phone.fill")
                            .font(.caption)
                    }
                    .buttonStyle(.bordered)
                    .tint(.green)

                    Button(intent: CancelOrderIntent(orderID: context.attributes.orderID)) {
                        Label("Cancel", systemImage: "xmark.circle")
                            .font(.caption)
                    }
                    .buttonStyle(.bordered)
                    .tint(.red)
                }
            }
            .padding()

        } dynamicIsland: { context in
            DynamicIsland {
                DynamicIslandExpandedRegion(.bottom) {
                    Button(intent: ContactDriverIntent(orderID: context.attributes.orderID)) {
                        Label("Contact Driver", systemImage: "phone.fill")
                    }
                    .buttonStyle(.bordered)
                }
                DynamicIslandExpandedRegion(.center) {
                    Text(context.state.status)
                }
                DynamicIslandExpandedRegion(.leading) {
                    Image(systemName: "bag.fill")
                }
                DynamicIslandExpandedRegion(.trailing) {
                    Text(context.state.eta, style: .timer)
                }
            } compactLeading: {
                Image(systemName: "bag.fill")
            } compactTrailing: {
                Text(context.state.eta, style: .timer)
                    .font(.caption2)
            } minimal: {
                Image(systemName: "bag.fill")
            }
        }
    }
}

struct ContactDriverIntent: AppIntent {
    static var title: LocalizedStringResource = "Contact Driver"

    @Parameter(title: "Order ID")
    var orderID: String

    init() { self.orderID = "" }
    init(orderID: String) { self.orderID = orderID }

    func perform() async throws -> some IntentResult {
        // Open the call screen or in-app chat for the driver
        return .result()
    }
}

struct OrderAttributes: ActivityAttributes {
    struct ContentState: Codable, Hashable {
        var status: String
        var progress: Double
        var eta: Date
    }
    let orderID: String
    let storeName: String
}

---

# MCP Usage Examples
https://nagarjuna2997.github.io/ios-agent-skill/guides/mcp-examples.html

Mcp · Reference guideMCP Usage ExamplesRepository guidance for MCP Usage Examples. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

1. Onboarding onto an unfamiliar codebase
2. Diagnosing a data race

🟠 Sources/Views/FeedView.swift:7 — Task.detached drops actor isolation, priority, and task-locals.
🟠 Sources/Views/Card.swift:14 — glassEffect was introduced in iOS 26 but is guarded at iOS 27.
🔴 Sources/LocationService.swift:1 — Uses location but Info.plist has no NSLocationWhenInUseUsageDescription.
🔴 PrivacyInfo.xcprivacy:1 — No PrivacyInfo.xcprivacy found in the project.
🟠 Sources/Views/Toolbar.swift:22 — Icon-only button has no accessibility label.

8. Reading the project without asking
9. Checking the skill itself
10. In CI
What to expect

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Select a concrete review question
↓2
Call the narrowest tool
↓3
Inspect structured findings
↓4
Verify a focused fix

02 / ArchitectureResponsibility boundariesBoundary 1
Client requestBoundary 2
Review toolBoundary 3
Evidence-backed responseConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
 you want to see what a session with 
ios-agent-mcp
 actually
looks like, or how to combine it with the skill's subagents.

Every transcript below is the server's real output format.

1. Onboarding onto an unfamiliar codebase

You:
 I just inherited this Swift project. What am I looking at?

Claude calls 
analyze_swift_project
:

# Swift Project Analysis

## Structure

- **Swift files:** 12
- **Lines:** 1,998
- **Deployment target:** iOS 17
- **Swift tools version:** 5.9
- **Test files:** found

## Shape

- **UI framework:** unknown
- **Architecture:** Clean Architecture
  - evidence: 1 use-case protocol(s) or a UseCases/ directory; 1 repository protocol(s)
- **Dependency injection:** protocol existentials injected through init
- **Third-party dependencies:** none detected

- **Frameworks:** Accelerate, Foundation, Observation, SwiftData, XCTest

## Findings by category

| Category | 🔴 Blocker | 🟠 Serious | 🟡 Minor | Tool |
|---|---:|---:|---:|---|
| Concurrency | 0 | 0 | 0 | `review_swift_concurrency` |
| Architecture | 0 | 0 | 0 | `review_swift_architecture` |
| SwiftUI | 0 | 0 | 0 | `review_swiftui` |
| Availability | 0 | 0 | 0 | `check_availability_guards` |
| Memory | 0 | 0 | 0 | `review_swift_memory` |
| Security | 0 | 0 | 0 | `review_swift_security` |
| Performance | 0 | 0 | 0 | `review_swift_performance` |
| Testing | 0 | 0 | 0 | `review_swift_testing` |
| App Store | 0 | 0 | 0 | `audit_app_store_readiness` |

**No findings in any category.**

That is genuinely the current output of running the server against this repo's
own 
samples/SkillPatterns
, pasted rather than composed — the package builds
and its tests pass on macOS CI on every push.

UI framework: unknown
 is correct, not a gap: the sample deliberately contains
no SwiftUI view code so it can build on a plain runner with no simulator.

Why start here:
 the table tells you which of the ten tools is worth running.
Running all of them on a large project buries the signal.

2. Diagnosing a data race

You:
 Users report the feed occasionally shows stale posts. Anything obviously wrong?

review_swift_concurrency
:

### 🔴 Sources/Views/FeedView.swift:3 — @Observable type is not @MainActor-isolated.

```swift
@Observable

Why it matters:
 @Observable grants no isolation. SwiftUI reads this state
during layout while any task may write it — a data race under Swift 5 mode, a
compile error under Swift 6.

Fix:
 Annotate the type: 
@MainActor @Observable final class …
. Annotate the
type, not individual members — per-member isolation leaves gaps.

Rule observable-without-mainactor · see docs/swift/swift-concurrency.md

🟠 Sources/Views/FeedView.swift:7 — Task.detached drops actor isolation, priority, and task-locals.

Two findings that together explain the symptom: unisolated state plus a detached
task writing to it.

---

## 3. The finding people don't expect

> **You:** We shipped Liquid Glass last release but users on iOS 26 say they don't see it.

`check_availability_guards`:

🟠 Sources/Views/Card.swift:14 — glassEffect was introduced in iOS 26 but is guarded at iOS 27.

Why it matters:
 Every device on iOS 26–26 falls back unnecessarily, losing
the feature for a large installed base. This is invisible when testing on a
current device.

Fix:
 Guard on iOS 26, the version where the symbol was introduced — not the
newest SDK.

The code compiled, shipped, and worked on the developer's phone. Nothing but a
rule catches this.

---

## 4. Pre-submission audit

> **You:** We submit tomorrow. Anything that will get rejected?

`audit_app_store_readiness`:

🔴 Sources/LocationService.swift:1 — Uses location but Info.plist has no NSLocationWhenInUseUsageDescription.

Why it matters:
 iOS terminates the app the moment the permission is
requested, and App Review rejects the submission.

🔴 PrivacyInfo.xcprivacy:1 — No PrivacyInfo.xcprivacy found in the project.

🟠 Sources/Views/Toolbar.swift:22 — Icon-only button has no accessibility label.

Two of these are hard rejections; the third is an accessibility failure a
reviewer may also catch.

---

## 5. Combined with the skill's subagents

The MCP server finds *what* is wrong. The subagents in `.claude/agents/` decide
*what to do*. A useful pairing:

analyze_swift_project        → where the problems are
ios-plan                     → a fix plan, respecting existing seams
main agent                   → execute
review_swift_concurrency     → confirm the category is now clean
swift-reviewer               → build + tests, with real output

Step 4 and step 5 are doing different jobs, and both matter:

- The **MCP tool** proves the *pattern* is gone. It is deterministic and cannot
  be argued with.
- The **reviewer subagent** proves the *code still works*, by running the build
  and pasting the output.

A clean static report on code that no longer compiles is worthless. Neither
check substitutes for the other. See `../orchestration/verification.md`.

---

## 6. The four checks people run after something goes wrong

These were added in 2.1.0, and each answers a question the first six could not.

### Memory — "the screen is gone but it is still updating"

> **You:** deinit never runs on my ClockModel.

Claude → review_swift_memory

🟠 Sources/ClockModel.swift:12 — Repeating Timer captures self strongly.
   Why: the run loop retains the timer, the timer retains the block, and the
        block retains self. The cycle is self -> Timer -> closure -> self, and
        ARC cannot break it.
   Fix: capture 
[weak self]
, and invalidate() when the owner goes away.

Deliberately narrow: a closure capturing `self` is **not** a leak, and flagging
every one would bury the handful that are. The rules fire only on APIs that
*store* the closure — repeating `Timer`, block `NotificationCenter` observers,
Combine `sink`, stored closure properties, non-`weak` delegates.

### Security — before handling credentials

> **You:** security pass before we ship the login screen.

Claude → review_swift_security

🔴 Sources/Session.swift:31 — Credential written to UserDefaults.
   Why: UserDefaults is an unencrypted plist in the app container. It is
        readable on a jailbroken device AND included in unencrypted backups,
        so the token leaves the device entirely.
   Fix: Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly.

`hardcoded-secret` skips the placeholders people legitimately commit —
`YOUR_API_KEY`, `<your-key>`, `changeme`. A rule that cries wolf on those is a
rule everyone learns to ignore.

### Testing — when the suite is green and you do not believe it

The inverse of every other tool: it runs **only** on test files.

Claude → review_swift_testing

🟠 Tests/FeedTests.swift:22 — Test contains no assertion.
   Why: it passes as long as nothing throws, so it reports success whether the
        behavior is right or wrong.
   Fix: assert the outcome, or delete it. A test that cannot fail is not a test.

🔴 Tests/FeedTests.swift:41 — 
await
 inside an XCTAssert autoclosure.
   Why: XCTAssert takes an autoclosure, which cannot contain an await. This
        does not compile.
   Fix: 
let value = try await subject.run()
, then assert on 
value
.

### Performance — when scrolling stutters

Claude → review_swift_performance

🟠 Sources/FeedView.swift:24 — Formatter allocated inside 
body
.
   Why: body re-runs on every state change, and constructing a DateFormatter is
        one of the most expensive routine operations on the platform. In a list
        this is the hitch.
   Fix: hoist to a 
static let
, or use 
.formatted()
.

Rules that only matter on the render path are scoped to `var body: some View`
and stay quiet elsewhere — the same `DateFormatter()` is 🟠 inside `body` and
🟡 outside it.

---

## 7. Branching on a result without parsing prose

Every review tool returns `structuredContent` next to the markdown, so a
workflow can act on the numbers:

```jsonc
{
  "summary": "Security Review: 1 blocker, 2 serious across 34 files.",
  "score": 91,
  "counts": { "blocker": 1, "serious": 2, "minor": 0, "total": 3 },
  "files_checked": 34,
  "issues": [ /* file, line, severity, rule, consequence, fix */ ],
  "suggestions": ["hardcoded-secret: Move it server-side…"]
}

suggestions
 groups by rule rather than repeating every issue's fix — forty
literal-spacing findings produce one instruction, not forty.

On score:
 it is a published formula, not a judgement —

100 × (1 − penalty/capacity)
 where 
penalty = 10·blockers + 3·serious +
1·minor
 and 
capacity = files × 10
. Because it is a 
density
, it is
comparable across runs on one project and 
not
 between projects: a UI-heavy
app and a networking library have different rule surfaces. Use 
counts
 for
anything that matters.

8. Reading the project without asking

Point the server at a project once, in the client config:

{
  "mcpServers": {
    "ios-agent": {
      "command": "npx",
      "args": ["-y", "ios-agent-mcp", "--project", "/Users/you/Projects/MyApp"]
    }
  }
}

Then 
ios://project/info
 is readable without the model deciding to call
anything:

{
  "project_root": "/Users/you/Projects/MyApp",
  "available": true,
  "swift_files": 84,
  "deployment_target": "17.0",
  "ui_framework": "SwiftUI",
  "architecture": "MVVM",
  "architecture_evidence": ["12 ViewModel/Model type(s)", "Views/ and ViewModels/ directories"],
  "uses_dependency_injection": true,
  "has_tests": true
}

The 
evidence
 ships with the verdict. "MVVM" alone is a guess presented as a
fact; the list underneath is a claim you can check. When the signals are weak it
says 
not determined
 rather than picking the most popular answer.

Every payload also reports 
project_root
, because the root is implicit — it
comes from 
--project
, 
IOS_AGENT_PROJECT
, or the directory the client
happened to spawn the server in, and a reader who cannot see which one won has
no way to tell an empty project from a wrong path.

There is no ios://project/build-status.
 It would have to run 
xcodebuild
,
which needs macOS and Xcode and breaks the 
filesystem: read, network: none

contract that lets this install anywhere in ~26 KB.

9. Checking the skill itself

lint_skill
 is the one tool that does not read Swift. Use it when a subagent
never gets invoked, or before publishing a skill:

Claude → lint_skill

🔴 .claude/agents/swift-reviewer.md:4 — described as read-only but granted Write.
   Why: the main agent delegates on the strength of that promise. A reviewer
        that can edit will fix what it was meant to report, and the separation
        of duties the review depends on is gone with nothing in the output to
        show it.
   Fix: remove Write from `tools`, or stop describing the agent as read-only.

Run against this repository it found a real defect on its first run: one of the
ten subagents described what it 
is
 without saying when to 
use
 it, making it
measurably less likely to be selected than its nine peers.

10. In CI

The server is for interactive use, but the same rules run headless via

templates/hooks/forbid-antipatterns.sh
 — the analyzers were derived from it.
Use the hook in CI and pre-commit, and the MCP server when you want an agent to
explain and fix what it found.

What to expect

A clean report is not a passing build.
 Every tool says so in its own footer.
Static analysis cannot type-check, run, or prove behavior.

Fewer findings on unconventional layouts.
 Architecture rules infer layers
from directory names. A project that does not use 
Views/
 or 
Domain/
 gets
fewer architecture findings — not wrong ones.

Test and mock files are exempt
 from app-code-only rules, deliberately. So is

Package.swift
. 
review_swift_testing
 is the exception that proves it — it is
the only tool that runs 
exclusively
 on test files.

score is a density, not a grade.
 Comparable across runs on one project,
meaningless between projects. The formula is published in 
tools.md
 so the
number is reproducible rather than a vibe.

---

# Installing the iOS Agent MCP Server
https://nagarjuna2997.github.io/ios-agent-skill/guides/mcp-installation.html

Mcp · Reference guideInstalling the iOS Agent MCP ServerRepository guidance for Installing the iOS Agent MCP Server. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

One install, one MCP connection
Choose your client
Codex
ChatGPT plugin and remote MCP
Gemini CLI
Claude Code
Claude Desktop
Platform notes

Windows
macOS and Linux
Avoiding a fetch on every launch

From source
Requirements
Verifying it works
Troubleshooting
Privacy
Xcode 27
Muse Code

Verified compatibility, 2026-09-16
Recheck on upgrades

Support scope and optional source installer

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Choose an MCP client
↓2
Configure server command
↓3
Start a project connection
↓4
Confirm tool discovery

02 / ArchitectureResponsibility boundariesBoundary 1
AI clientBoundary 2
MCP connectionBoundary 3
Local serverConnected responsibilities, not a required class hierarchy or an execution trace.
One install, one MCP connection

claude mcp add ios-agent -- npx -y ios-agent-mcp@latest

The default server exposes 36 tools in 2.7.0: 12 review/metadata tools, 8 Apple reference tools, 14 simulator tools, 
create_app
, and the local 
prepare_issue_report
 tool. App scaffolding and simulator packages install automatically as dependencies; no separate installation or MCP connection is needed. Remove the separate knowledge/simulator connections if you previously configured them to avoid duplicate tools.

Create a starter directly:

npx -y ios-agent-mcp@latest new MyApp --brief "A reading list with local storage" --xcodegen

Requires Node.js 20+. Simulator operations require macOS and Xcode; XcodeGen is required to generate an Xcode project from the starter specification. The agent implements app features using the starter, source tools and verification tools. One install is not autonomous app generation. The default connection now includes tools that write files and operate the simulator; review and reference tools remain read-only.

Load this when:
 setting up 
ios-agent-mcp
 in Claude Code, Claude Desktop,
ChatGPT/Codex, Gemini, or another MCP-capable client.

The server includes Swift analysis and App Intents review plus 
lint_skill
, which checks a
skill repository's own metadata. Full tool reference: 
tools.md
.

It also serves three 
resources
 (
ios://project/info
, 
.../dependencies
,

.../issues
), which need a project root. Resolution order:

--project PATH
 in the client config
IOS_AGENT_PROJECT
the nearest ancestor of the working directory holding a 
.ios-agent/

   directory — the marker 
ios-agent
 writes (
docs/tooling/project-scaffolding.md
)
the working directory itself

The analysis tools only 
read
 that marker; it never creates one, while app creation and simulator tools have separate write/runtime effects. 
ios://project/info

reports 
resolved_from
 alongside the path, because an implicit root is
otherwise unfalsifiable — "no Swift files" reads identically whether the project
is empty or the server is pointed at the wrong directory.

Tools take an explicit path argument and need no configuration.
Worked sessions: 
examples.md
.

Choose your client

Client

Install path

Capabilities

Claude Code/Desktop

Local stdio MCP configuration below

Unified local tools

Codex

Codex MCP CLI/config or the release plugin ZIP

Local analysis, knowledge and app-building skill

ChatGPT

Portable skills-only plugin ZIP; optional hosted knowledge MCP

Bundled workflows/references; implementation needs a coding environment

Muse Code

Local stdio setup below; existing AGENTS.md and Claude-format skills

MCP discovery and Stop command hook verified; model session unverified

Gemini CLI

GitHub extension or local MCP configuration

GEMINI instructions, analysis and knowledge tools

Codex

codex mcp add ios-agent -- npx -y ios-agent-mcp@latest

Equivalent 
config.toml
 entry:

[mcp_servers.ios-agent]
command = "npx"
args = ["-y", "ios-agent-mcp@latest"]

Pass 
--project
 and an absolute app path when project resource discovery needs an explicit root. The release plugin ZIP is an alternative; use one method to avoid duplicate tools.

ChatGPT plugin and remote MCP

The GitHub release includes 
ios-agent-chatgpt.zip
, a self-contained skills-only plugin with the Apple references and app/icon workflow. It contains no local-process MCP configuration, so it does not pretend a browser can run 
npx
 on your Mac. Upload/import it through a supported plugin development or submission flow for your account. Public marketplace listing remains subject to publisher verification and platform review.

The optional 
knowledge MCP server
 supports Streamable HTTP. Deploy it at a stable HTTPS URL, then connect 
/mcp
 through ChatGPT’s supported developer-mode workflow. OpenAI also supports Secure MCP Tunnel for developer-mode access to a private stdio or HTTP server; see the 
official connection guide
. This release does not create a tunnel or expose your Mac. Account/workspace policy may limit developer mode. The repository does not invent a production endpoint. Its remote tools serve public references and plans; actual local project analysis stays in the local MCP server.

Gemini CLI

Gemini CLI 0.49.0 validated the extension manifest and connected to the 2.7.0 server release candidate in an isolated project. A real model-driven app-building session remains unverified.

gemini mcp add ios-agent -- npx -y ios-agent-mcp@latest

The command above connects to the published package. For bundled 
GEMINI.md
 guidance, the repository’s 
gemini-extension.json
 is an alternative; check that its pinned npm version is published before running 
gemini extensions install https://github.com/Nagarjuna2997/ios-agent-skill
. Alternatively use the Claude Desktop 
mcpServers
 object below in Gemini CLI settings. Use one method; the web chat is a different product and does not load CLI extensions.

Official client references: 
Codex MCP
, 
OpenAI plugin packaging
, 
ChatGPT connection/testing
, 
Gemini extension format
.

Claude Code

claude mcp add ios-agent -- npx -y ios-agent-mcp

Verify:

claude mcp list

Claude Desktop

Edit 
~/Library/Application Support/Claude/claude_desktop_config.json
:

{
  "mcpServers": {
    "ios-agent": {
      "command": "npx",
      "args": ["-y", "ios-agent-mcp"]
    }
  }
}

Restart Claude Desktop. The tools appear under the connectors icon.

Platform notes

Config file locations differ by OS. The command itself is the same everywhere.

Claude Desktop config

macOS

~/Library/Application Support/Claude/claude_desktop_config.json

Windows

%APPDATA%\Claude\claude_desktop_config.json

Linux

~/.config/Claude/claude_desktop_config.json

Windows

npx
 is a shell script, not an executable, so some MCP clients cannot spawn it
directly. If the server fails to start with no error, wrap it in 
cmd
:

{
  "mcpServers": {
    "ios-agent": {
      "command": "cmd",
      "args": ["/c", "npx", "-y", "ios-agent-mcp"]
    }
  }
}

Use forward slashes or escaped backslashes in any absolute path — raw 
\
 in
JSON is an escape character:

"args": ["C:/Users/you/ios-agent-skill/mcp-server/dist/unified.js"]

macOS and Linux

The plain 
npx
 form works. If 
npx
 is not on the client's 
PATH
 (GUI apps do
not inherit your shell profile), use an absolute path to 
node
:

which node    # e.g. /opt/homebrew/bin/node

{
  "command": "/opt/homebrew/bin/node",
  "args": ["/absolute/path/to/mcp-server/dist/unified.js"]
}

This is the single most common cause of "the server won't start" on macOS.

Avoiding a fetch on every launch

npx -y
 re-resolves the package each time. Install once instead:

npm install -g ios-agent-mcp

{ "command": "ios-agent-mcp", "args": [] }

From source

git clone https://github.com/Nagarjuna2997/ios-agent-skill.git
cd ios-agent-skill/mcp-server
npm install && npm run build

Then point the client at the built entry point:

{
  "mcpServers": {
    "ios-agent": {
      "command": "node",
      "args": ["/absolute/path/to/ios-agent-skill/mcp-server/dist/unified.js"]
    }
  }
}

Use an 
absolute
 path — a relative one breaks the moment the client's working
directory differs.

Requirements

Node 20+
. Check with 
node --version
.
Review/reference tools do not require Xcode. Simulator builds and tests require macOS/Xcode.
Local review/reference tools read files. Builds may fetch dependencies, and simulator previews serve on loopback.

Verifying it works

Ask the agent:

Analyze the Swift project at /path/to/MyApp

You should get a structure summary and a per-category finding table. If instead
you get "No Swift files found", the path is wrong — pass the folder containing

Package.swift
 or the 
.xcodeproj
, not a subfolder.

Troubleshooting

Symptom

Cause

Tools do not appear

Client not restarted, or malformed JSON in the config

"Path does not exist"

Relative path passed — use an absolute one

"No Swift files found"

Pointed at a build directory, or the wrong folder

Everything is clean and you doubt it

Run analyze_swift_project — it reports the file count it scanned

npx fetches every launch

Install globally: npm i -g ios-agent-mcp, then use ios-agent-mcp as the command

Build outputs are skipped deliberately: 
.build
, 
DerivedData
, 
Pods
,

Carthage
, 
node_modules
, and 
*.xcodeproj
 bundles. So is 
Package.swift
 —
it is build configuration, not app source.

Privacy

Review and local-reference tools read files. App creation writes a new starter; simulator tools execute Xcode and manage devices. Builds may fetch dependencies, and preview serves on loopback. The MCP client can send tool outputs to its model provider; review that client's settings.

Xcode 27

Use the 
in-Xcode agent setup
. Xcode uses its own agent configuration directories. This setup is documented from Apple’s released guidance; runtime acceptance remains unverified on the current Xcode 26.6 host.

Muse Code

Muse Code joins Claude, Codex and Gemini CLI through the same unified local MCP
server. No separate server package, plugin ZIP or 
MUSE.md
 is needed.

Install the server outside the agent sandbox, using Node.js 20 or later:

npm install -g ios-agent-mcp@latest

Merge this into 
~/.config/muse/settings.json
; preserve your other settings and
existing server entries. Do not replace the whole file. 
schema_version
 is required.

{
  "schema_version": 1,
  "mcpServers": {
    "ios-agent": {
      "command": "ios-agent-mcp",
      "args": []
    }
  }
}

Copyable template
 · 
Settings-key verification note
 · 
Hook verification scope
. If Muse cannot find
the command, use the absolute executable path printed by 
command -v ios-agent-mcp
.
Restart Muse after updating settings. This avoids fetching a package inside Muse's
default proxy-only sandbox. It does not disable sandboxing or grant network access.

For local instructions, use the repository's 
AGENTS.md
. For skill discovery,
keep the original frontmatter-bearing 
SKILL.md
 under your project's
project-local .claude/skills/ios-agent-skill folder with its companion references. Do not rename the
frontmatter-stripped 
AGENTS.md
 to 
SKILL.md
. Trust only workspaces you recognize.

Verified compatibility, 2026-09-16

Muse Code 
1.3.0 (1.3.0-R3233.1)
 connected to the published 
ios-agent-mcp 2.7.0

using stdio and discovered 
36 tools
, including reviews, local references,

create_app
 and simulator tools. Initialization and tool discovery used the actual
Muse executable with its local 
echo
 provider. No account credentials or model
request were needed. 
muse init
 and discovery of the repository's Claude-format
skill were separately verified earlier.

A command-based 
Stop hook executed
 in an isolated test. The

optional maintainer hook template
 runs
this repository's existing verification script. It is for sessions rooted in this
repository only: confirm the working directory before enabling it. Do not copy it
into an unrelated user's app or apply it globally across projects. Hook commands
execute shell code, so inspect commands before merging them into your settings.

Not verified:
 model-directed tool invocation, PreToolUse/PostToolUse ports,
verification observer behavior, sandboxed simulator operations and end-to-end
app creation by Muse. Tool discovery is not evidence that a model completed an app.
No observer toggle or privacy/pricing-tier claim is supplied without verification.

Recorded verification result
.
The harness asserts four representative tools and records the complete discovered
catalog and installed server package version.

Recheck on upgrades

From this repository, with Muse and the MCP package already installed:

node scripts/verify-muse.mjs /absolute/path/to/muse /absolute/path/to/ios-agent-mcp/dist/unified.js

This uses temporary configuration, disables foreign personal context, and checks
MCP discovery plus a Stop hook without a model call. It prints versioned JSON
results and removes the temporary files. It does not edit your Muse settings.
Rerun it when either client or server changes; record model-based checks separately.
Updating npm's 
@latest
 does not automatically upgrade an existing global install:
rerun 
npm install -g ios-agent-mcp@latest
 when choosing to upgrade.

Official sources: 
Meta announcement
,

configuration
, and

extensions
. The latter documentation
requires sign-in; the compatibility claims above come from executable tests.

Support scope and optional source installer

Active client families are Claude, ChatGPT/Codex, Gemini CLI and Muse. Other
standard MCP clients may work, but compatibility is not a maintained support
claim. Request another client through GitHub Issues and use 👍 reactions to
register demand; feasibility and verification also determine priorities.

The optional 
install.sh
 requires an explicit 
--client
. It clones source skills
for Claude, Codex or Muse, and prints dedicated instructions for Gemini/ChatGPT.
It does not configure MCP. Existing installs update only when the checkout has
the expected origin, a clean worktree and the main branch.

---

# Apple Knowledge MCP
https://nagarjuna2997.github.io/ios-agent-skill/guides/mcp-knowledge-server.html

Mcp · Reference guideApple Knowledge MCPRepository guidance for Apple Knowledge MCP. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Context
Pattern
Anti-Patterns

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Search for the topic
↓2
Inspect reference outline
↓3
Read bounded sections
↓4
Apply source-backed guidance

02 / ArchitectureResponsibility boundariesBoundary 1
Knowledge requestBoundary 2
Local reference indexBoundary 3
Source excerptsConnected responsibilities, not a required class hierarchy or an execution trace.
Context

ios-agent-knowledge
 is the public-reference companion to 
ios-agent-mcp
. Both binaries ship in 
ios-agent-mcp@2.4.0
. The analyzer keeps its eleven local-project tools; the knowledge server has eight separate tools and never exposes project paths over HTTP.

Pattern

Tool

Result

search_apple_technologies

Ranked matches in the 405-technology snapshot

get_apple_technology

Compact local routes by default; explicit full guide view

search_local_references

Local guides and reusable source matches without bodies

get_reference_outline

Heading offsets for focused section reads

read_local_reference

Exact source/guide content with bounded output and continuation

get_apple_updates

Search of 96 update/release-note landing pages

plan_ios_app

Implementation workflow and CLI executable/argument array

plan_app_icon

Editable foreground-layer specification and Icon Composer workflow

Start a local MCP connection:

npx -y --package=ios-agent-mcp@2.4.0 ios-agent-knowledge

For a remotely hosted ChatGPT MCP connection, run the HTTP transport behind your host’s HTTPS ingress:

npx -y --package=ios-agent-mcp@2.4.0 ios-agent-knowledge --http --host 0.0.0.0 --port 3000

The MCP route is 
/mcp
; readiness is 
/health
. Without 
--http
, the process uses stdio. The default HTTP host is loopback. The Dockerfile at 
mcp-server/Dockerfile.knowledge
 builds this public-reference service from the repository root.

This service accepts only search strings, indexed reference IDs/ranges, catalog IDs and app/icon briefs. It reads a bundled allowlisted knowledge file, never arbitrary caller-supplied filesystem paths. It has no source upload, code execution, build or publishing tools, makes no runtime network requests, and does not store briefs. Configure HTTPS, request limits and operational logging on the deployment host. Account-specific or private-data tools would need a separate authentication design.

The data has explicit snapshot dates. Refresh source snapshots in the repository and rebuild before release. A successful HTTP handshake proves protocol compatibility, not that an arbitrary host or client account has been configured.

Read 
docs/tooling/offline-source-library.md
 for offline search, source reuse, attribution and verification limits.

Anti-Patterns

Exposing the local analyzer to the internet with arbitrary filesystem paths.
Advertising a localhost address as a published ChatGPT endpoint.
Treating 
plan_ios_app
 as an autonomous code generator; the coding agent performs implementation.
Describing catalog snapshots as live Apple updates.

Sources: 
OpenAI MCP server guidance
, 
plugin submission
. A public directory submission needs a stable HTTPS endpoint and publisher verification; a skills-only plugin bundle is also supported.

---

# Official MCP Registry publication
https://nagarjuna2997.github.io/ios-agent-skill/guides/mcp-registry.html

Mcp · Reference guideOfficial MCP Registry publicationRepository guidance for Official MCP Registry publication. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Align namespace and version
↓2
Publish tested npm artifact
↓3
Authenticate namespace ownership
↓4
Publish and verify record

02 / ArchitectureResponsibility boundariesBoundary 1
Package metadataBoundary 2
Registry publicationBoundary 3
Public registry recordConnected responsibilities, not a required class hierarchy or an execution trace.
The namespace is 
io.github.Nagarjuna2997/ios-agent-skill
. 
mcp-server/package.json
 carries the matching 
mcpName
; 
mcp-server/server.json
 declares the npm artifact and stdio transport. The existing version-sync script keeps package and registry versions aligned.

The 
official publication flow
 requires the matching npm version to be public before registry submission, plus authentication proving control of the namespace. Metadata alone does not publish a listing.

From 
mcp-server
, after package checks and npm publication:

node scripts/sync-version.mjs --check
mcp-publisher login github
mcp-publisher publish

Keep authentication files outside the repository. Verify the returned registry record and version before claiming acceptance. Downstream directories have their own ingestion schedule; a successful registry publication does not establish a PulseMCP listing.

---

# MCP Tool Reference
https://nagarjuna2997.github.io/ios-agent-skill/guides/mcp-tools.html

Mcp · Reference guideMCP Tool ReferenceRepository guidance for MCP Tool Reference. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Choosing a tool
Severity
analyze_swift_project
review_swift_concurrency
review_swift_architecture
review_swiftui
check_availability_guards
audit_app_store_readiness
review_swift_memory
review_swift_security
review_swift_testing
review_swift_performance
lint_skill
Structured output
Limits
review_app_intents

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Choose review scope
↓2
Run the relevant analyzer
↓3
Interpret severity and location
↓4
Validate the proposed change

02 / ArchitectureResponsibility boundariesBoundary 1
Project inputBoundary 2
Static review rulesBoundary 3
Structured findingsConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
 choosing which 
ios-agent-mcp
 tool to call, or interpreting
a finding it returned.

Every tool takes one argument — an 
absolute
 path to the project root:

{ "path": "/Users/you/Projects/MyApp" }

Ten of the eleven analyze Swift source. 
lint_skill
 is the exception: it reads a
skill repository's own metadata, so its path is the folder containing

SKILL.md
.

Choosing a tool

You want to…

Tool

Understand an unfamiliar codebase

analyze_swift_project

Diagnose a data race or migrate to Swift 6

review_swift_concurrency

Find out why a screen can't be previewed

review_swift_architecture

Review SwiftUI views and state

review_swiftui

Check before shipping, or after an SDK bump

check_availability_guards

Prepare an App Store submission

audit_app_store_readiness

Memory grows, or deinit never runs

review_swift_memory

Handle credentials, or run a security pass

review_swift_security

Tests are flaky, or a green suite looks too easy

review_swift_testing

Scrolling stutters or launch is slow

review_swift_performance

Author or review an Agent Skill, or work out why a subagent never gets invoked

lint_skill

Start with 
analyze_swift_project
 — it reports counts per category and names
the tool to run for each, so you do not run all ten blindly. 
lint_skill
 sits
outside that flow; it inspects skill metadata, not Swift.

Severity

Meaning

Act

🔴 blocker

Crash, data race, or App Review rejection

Before shipping

🟠 serious

Real defect — untestable code, accessibility failure, deprecated API

This sprint

🟡 minor

Maintainability and consistency

When touching the file

Findings are sorted most severe first.

analyze_swift_project

Structure — Swift file count, line count, deployment target, Swift tools
version, frameworks in use, whether tests exist — plus a finding count for every
category with the tool that explains it.

It also reports the project's 
shape
: UI framework, inferred architecture,
third-party dependencies, and whether dependency injection is in use.

The architecture read always ships its 
evidence
. "MVVM" alone is a guess
presented as a fact; "MVVM — 12 ViewModel types, Views/ and ViewModels/
directories" is a claim you can check. When the signals are weak it says

not determined
 rather than picking the most popular answer.

Dependency injection is detected from 
any Protocol
 parameters in initializers,
not from a directory named 
DI/
 — a project that only ever constructs concrete
types has no seam, whatever it calls itself.

review_swift_concurrency

Rule

Severity

observable-without-mainactor

🔴

type-named-task

🔴

task-detached

🟠

dispatchqueue-main-async

🟠

unchecked-sendable

🟠

task-in-onappear

🟠

empty-catch

🟠

redundant-mainactor-run

🟡

nonisolated-unsafe

🟡

observable-not-final

🟡

Background: 
../swift/swift-concurrency.md
, 
../../patterns/mvvm.md
.

review_swift_architecture

Rule

Severity

live-default-dependency

🔴

domain-imports-ui

🔴

presentation-names-data-type

🟠

singleton-in-viewmodel

🟠

nested-navigation-stack

🟠

deprecated-navigationview

🟠

Background: 
../../patterns/clean-architecture.md
.

review_swiftui

Rule

Severity

environmentobject

🟠

force-try

🟠

fixed-font-size

🟠

any-view, fixed-height, literal-spacing, deprecated-corner-radius, material-possibly-on-solid, view-state-on-model, legacy-observableobject

🟡

Only runs on files that 
import SwiftUI
.

Background: 
../swiftui/state-and-data-flow.md
, 
../design/design-tokens.md
.

check_availability_guards

Rule

Severity

missing-availability-guard

🔴

missing-runtime-model-check

🔴

over-restrictive-guard

🟠

over-restrictive-guard
 is the one worth understanding. Guarding an 
iOS 26

API at 
#available(iOS 27, *)
 compiles, ships, and silently sends every iOS 26
device down your fallback path. It is invisible when testing on a current
device, which is why a rule catches it.

missing-runtime-model-check
 covers the other half: an 
@available
 guard proves
the 
symbol
 exists; it does not prove Foundation Models is usable on this
device, in this region, with Apple Intelligence enabled.

Background: 
../compatibility-matrix.md
, 
../frameworks/foundation-models.md
.

audit_app_store_readiness

Rule

Severity

missing-purpose-string

🔴

missing-privacy-manifest

🔴

unlabeled-icon-button

🟠

hardcoded-string, print-logging

🟡

missing-privacy-manifest
 only fires on 
apps
 (an Info.plist or 
.xcodeproj

is present). A library is never submitted to App Review.

Background: 
../../checklists/app-store-submission.md
, 
../frameworks/accessibility.md
.

review_swift_memory

Rule

Severity

timer-retain-cycle, notification-observer-retain, sink-retain-cycle

🟠

strong-delegate, stored-closure-captures-self, long-lived-task-captures-self

🟠

unowned-self

🟠

Deliberately narrow. A closure capturing 
self
 is 
not
 a leak — most closures
are consumed immediately. A leak needs the closure to be 
stored
 by something
the object itself owns, so these rules fire on the specific storing APIs rather
than on 
self.
 inside any closure, which would bury the real findings.

unowned-self
 is a crash rather than a leak: unlike 
weak
 it does not nil out,
so an escaping closure running after deallocation traps instead of no-oping.

review_swift_security

Rule

Severity

hardcoded-secret, secret-in-userdefaults, ats-disabled

🔴

tls-validation-bypassed, keychain-always-accessible

🔴

cleartext-http, weak-hash, non-cryptographic-randomness, secret-logged

🟠

javascript-string-interpolation

🟠

keychain-migrates-to-new-device

🟡

hardcoded-secret
 skips the placeholders people legitimately commit
(
YOUR_API_KEY
, 
<your-key>
, 
changeme
) and values under 8 characters —
flagging those trains readers to ignore the rule, which is worse than not having
it. 
cleartext-http
 allows 
localhost
 and loopback.

review_swift_testing

The inverse of every other analyzer: it runs 
only
 on test files.

Rule

Severity

await-inside-xctassert

🔴

test-sleeps, test-without-assertion, network-in-test, no-tests

🟠

force-try-in-test, shared-mutable-test-state, long-test-timeout

🟡

assert-true-on-equality, sparse-tests

🟡

A flaky or vacuous test is worse than a missing one: it costs the same to run
and reports success either way. 
test-without-assertion
 catches the case that
passes as long as nothing throws; 
await-inside-xctassert
 catches code that
does not compile at all, because 
XCTAssert
 takes an autoclosure.

review_swift_performance

Rule

Severity

blocking-io-in-body

🔴

formatter-allocated-in-body, collection-work-in-body, foreach-over-indices

🟠

eager-stack-in-scrollview, image-decode-in-body

🟠

asyncimage-without-frame, geometryreader-wraps-body, formatter-allocated-repeatedly

🟡

The unifying rule: 
body
 runs many times per second, on the main actor, for
reasons you do not control. Anything expensive inside it is multiplied by a
number nobody measured. Rules that only matter on that path are scoped to the

var body: some View
 block and are silent elsewhere — the same 
DateFormatter()

is 🟠 inside 
body
 and 🟡 outside it.

lint_skill

The one tool that does not read Swift. It checks whether an Agent Skill
repository is 
well-formed and internally consistent
 — the class of defect
that produces no error anywhere, just an instruction nobody follows.

Rule

Severity

skill-file-missing, skill-missing-frontmatter, skill-frontmatter-missing-key

🔴

agent-missing-frontmatter

🔴

agent-read-only-holds-write-tool

🔴

skill-version-not-semver, skill-name-not-kebab-case, skill-description-too-long

🟠

skill-unknown-tool, agent-unknown-tool

🟠

agent-name-filename-mismatch, agent-missing-name, agent-missing-description, agent-missing-tools, agent-name-not-kebab-case

🟠

mirror-out-of-sync, broken-doc-reference

🟠

skill-description-too-short, agent-description-lacks-trigger

🟡

Three of these are worth calling out, because each fails 
silently
:

agent-read-only-holds-write-tool
 — a subagent whose description promises
  it is read-only while its frontmatter grants 
Edit
 or 
Write
. The main agent
  delegates on the strength of that promise. A reviewer that can edit will fix
  what it was meant to report, and the separation of duties the review depends
  on is gone with nothing in the output to show it.
agent-name-filename-mismatch
 — rename the file, forget the frontmatter,
  and every delegation prompt references an identifier the loader never
  registered.
*-unknown-tool
 — a misspelled tool name is not granted 
and not
  reported
. The agent's prompt assumes a capability it does not have, and only
  finds out at the moment it tries to use it.

Mirror checking is self-calibrating.
 Files like 
CLAUDE.md
 and 
AGENTS.md

are compared against 
SKILL.md
's body only when at least one of them already
matches byte-for-byte. That proves the repository generates its mirrors; without
it, a project with a hand-written 
CLAUDE.md
 would be told all supported mirrors had
drifted. When no mirror matches, the check is skipped and the report says so.

The report opens with what it inspected — SKILL.md found or not, agents counted,
mirrors compared, references resolved — because a clean report that never states
its scope is indistinguishable from a check that never ran.

Background: 
../orchestration/subagents.md
, 
../orchestration/verification.md
.

Structured output

Every review tool returns 
both
 halves of a result:

content
 — markdown, for a human reading the transcript.
structuredContent
 — typed data, declared via 
outputSchema
, for a workflow
  that must branch on the result without regexing prose.

{
  "summary": "Security Review: 1 blocker, 2 serious across 34 files.",
  "score": 91,
  "counts": { "blocker": 1, "serious": 2, "minor": 0, "total": 3 },
  "files_checked": 34,
  "issues": [ { "file": "…", "line": 12, "severity": "blocker", "rule": "…", "fix": "…" } ],
  "suggestions": [ "hardcoded-secret: Move it server-side…" ]
}

About score.
 It is a defect 
density
, computed as

100 × (1 − penalty / capacity)
 where 
penalty = 10·blockers + 3·serious +
1·minor
 and 
capacity = files × 10
, clamped to 0–100. The formula is fixed and
published so the number is reproducible rather than a vibe.

It is 
not comparable between projects
 — a UI-heavy app and a networking
library have different rule surfaces. Use it as a direction of travel for one
codebase, and use 
counts
 for anything that matters.

suggestions
 deduplicates by rule rather than restating every issue's 
fix
:
forty literal-spacing findings produce one instruction, not forty.

Limits

Static analysis.
 It reads source; it does not build, run, or type-check.
A clean report is not a passing build — run 
swift build
 and 
swift test
.

lint_skill checks form, not content.
 It can prove a subagent is declared
correctly and that its tool grant matches its stated contract. It cannot judge
whether the instructions in it are any good.

Heuristics on paths.
 Layer rules infer the presentation and domain layers
from directory names (
Views/
, 
Presentation/
, 
Domain/
). An unconventional
layout produces fewer architecture findings, not wrong ones.

Exemptions are deliberate.
 Test, mock, stub, fake, and preview files skip
the app-code-only rules; 
Package.swift
 is skipped entirely. Doubling the noise
would halve the chance anyone reads the output.

Caps.
 2000 files and 512 KB per file, so a huge monorepo returns something
rather than hanging.

review_app_intents

Read-only project review. Inputs: absolute 
path
, optional booleans 
appleIntelligence
 and 
onscreenContent
 (both default false). Returns the same structured findings as other reviewers. SiriKit migration, missing schema and missing onscreen association are minor advisories with scope limitations; no blanket SiriKit deprecation is asserted. See 
rule semantics
.

---

# vNext MCP Analysis Tools
https://nagarjuna2997.github.io/ios-agent-skill/guides/mcp-vnext-analysis-tools.html

Mcp · Reference guidevNext MCP Analysis ToolsRepository guidance for vNext MCP Analysis Tools. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Context
Implemented in the 2.6.0 source
Tool Plan
Output Contract
Rule Design
Static vs. Runtime Boundary
Anti-Patterns

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Define a supported rule
↓2
Specify evidence and limits
↓3
Implement focused checks
↓4
Verify false-positive cases

02 / ArchitectureResponsibility boundariesBoundary 1
Rule contractBoundary 2
Analyzer implementationBoundary 3
Finding schemaConnected responsibilities, not a required class hierarchy or an execution trace.
Context

Use this as the contract for expanding 
ios-agent-mcp
 beyond the current Swift engineering analyzers. These tools remain static, read-only project reviewers. Runtime behavior belongs in 
ios-simulator-mcp
.

Implemented in the 2.6.0 source

review_app_intents
: SiriKit migration advice plus opt-in schema and onscreen entity association checks. See 
integration contract
. Parameter-summary/localization/identifier analysis remains future work.

Tool Plan

Tool

Focus

Typical signals

review_ui_ux

Visual structure, native conventions, state coverage

hardcoded spacing, fixed fonts, missing loading/empty/error states, inconsistent radii, excessive GeometryReader

review_motion

SwiftUI animation correctness

broad .animation, missing value dependency, infinite animation, layout-heavy animation, Reduce Motion gaps

review_accessibility

Semantic and inclusive design

missing labels, small tap targets, fixed sizes, contrast-risk colors, ignored Dynamic Type

review_haptics

Meaningful feedback

haptics fired on appear, duplicated feedback, no reduced-sensory fallback, heavy impact for minor state

review_realitykit

3D scene safety and native fit

unbounded asset loads, missing collision/input components, ARKit without permission copy, no fallback

review_metal

GPU rendering safety

per-frame allocation, missing drawable guard, unsafe buffer sizing, no pixel-format/depth consistency

review_webkit

Web/native interop safety

untyped JS bridge, broad navigation, injected secrets, WKWebView used where native controls fit better

review_foundation_models

Foundation Models usage

missing availability gates, no graceful fallback, unsafe prompt logging, absent evaluation path

review_core_ai

Core AI model integration

model lifecycle, privacy boundaries, device capability checks, background work isolation

| 
review_ai_security
 | AI privacy and misuse resistance | prompt injection surfaces, secret leakage, unbounded tool calls, unsafe retrieval context |
| 
review_ai_evaluations
 | Evaluation coverage | no datasets, no code-based evaluators, no regression gate, no failure taxonomy |
| 
review_networking
 | Network correctness and resilience | unbounded retries, no cancellation, live API defaults in previews/tests, missing offline state |
| 
review_persistence
 | Data and storage safety | UserDefaults secrets, main-actor I/O, missing migrations, model objects crossing actor boundaries |
| 
review_storekit
 | Purchase readiness | missing restore path, unverified transactions, no pending/refund handling, live StoreKit in tests |
| 
review_permissions
 | Permission and entitlement correctness | missing purpose strings, overbroad entitlements, no denied/restricted state, privacy manifest gaps |

Output Contract

Every new review tool should match the existing analyzer shape:

{
  "summary": "Motion review found 3 issues.",
  "score": 82,
  "counts": {
    "critical": 0,
    "high": 1,
    "medium": 2,
    "low": 0
  },
  "files_checked": 18,
  "issues": [
    {
      "rule": "animation-without-reduced-motion",
      "severity": "high",
      "file": "Sources/App/HomeView.swift",
      "line": 42,
      "message": "Animated transition has no Reduce Motion alternative.",
      "why": "Users who disable motion can still receive large movement.",
      "fix": "Read accessibilityReduceMotion and switch to opacity or no animation."
    }
  ],
  "suggestions": [
    "Centralize animation tokens for spring duration and response."
  ]
}

Rule Design

Prefer high-signal rules over style opinions:

Flag literal spacing only when repeated enough to indicate no token system, or when it creates inconsistent layout.
Flag fixed font sizes when used in user-facing text without a Dynamic Type path.
Flag animation problems when they can cause incorrect behavior, inaccessible motion, or performance cost.
Flag AI issues when they expose privacy, availability, evaluation, or tool-call safety risk.
Do not score "premium design" from static code alone. Leave aesthetic confirmation to the Visual Iteration Loop.

Static vs. Runtime Boundary

Static analyzer can say

Runtime loop must prove

A view uses fixed font sizes

Text actually fits at accessibility sizes

A button appears to lack a label

VoiceOver announces the correct label and trait

A Metal renderer allocates per frame

GPU frame pacing is stable on the simulator/device

A splash view ignores Reduce Motion

The captured intro avoids large motion when Reduce Motion is enabled

A Foundation Models flow lacks evaluations

The model meets pass/fail thresholds on a dataset

Anti-Patterns

// WRONG: add a review tool whose finding is only "looks bad".
Why: static analyzers need reproducible evidence.

// RIGHT: detect concrete code patterns, then route aesthetic verification to screenshots.

// WRONG: call App Intents or model providers during static review.
Why: ios-agent-mcp is read-only and network-free.

// RIGHT: inspect declarations, availability gates, privacy handling, and test/evaluation files.

// WRONG: create separate output shapes per tool.
Why: clients cannot branch reliably.

// RIGHT: reuse summary, score, counts, files_checked, issues, and suggestions.

---

# Dynamic Workflows and Large-Scale Jobs
https://nagarjuna2997.github.io/ios-agent-skill/guides/orchestration-dynamic-workflows.html

Orchestration · Reference guideDynamic Workflows and Large-Scale JobsRepository guidance for Dynamic Workflows and Large-Scale Jobs. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

1. The scale ladder
2. /batch — many isolated changes

Making a batch succeed
Worktrees

3. Dynamic workflows — orchestration in code

When not to script it

4. Failure handling at scale
5. Cost
6. Anti-patterns
Checklist before scaling out

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Partition independent work
↓2
Assign bounded tasks
↓3
Collect results and failures
↓4
Verify the integrated outcome

02 / ArchitectureResponsibility boundariesBoundary 1
CoordinatorBoundary 2
Isolated workersBoundary 3
Integration checksConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
 the work is too large for a handful of delegated tasks —
a codebase-wide migration, a rule applied across dozens of modules, or a batch of
independent changes that each want their own PR.

There is a scale at which conversational delegation stops working. Somewhere
around ten parallel units, the main agent's context becomes the bottleneck: it
is spending more effort tracking who is doing what than doing anything. Past
that point, the orchestration belongs in a script.

1. The scale ladder

Pick the cheapest rung that fits. Climbing early costs more than it saves.

Scale

Approach

Coordination lives in

1–2 files

Do it inline

Your own context

3–8 units, related

Delegate to subagents in one session

The main agent

Repeated until a condition

A loop, optionally with a verifier

The loop contract

5–30 isolated changes, each wanting its own PR

/batch

The batch tooling

Dozens of units, custom logic, non-linear

A dynamic workflow (script + SDK)

Your script

Two questions decide it:

Are the units independent?
 If unit 7 needs unit 3's result, it is not a
   batch — it is a pipeline, and the dependency has to be encoded somewhere.
Does each unit want its own review boundary?
 Thirty commits on one branch
   is unreviewable; thirty PRs is thirty reviews someone can actually do.

2. 
/batch
 — many isolated changes

/batch
 is a packaged use of subagents plus 
git worktrees
, aimed at roughly
5–30 isolated changes that each become their own PR. Each unit gets a fresh
subagent working in its own worktree, so the changes cannot collide on disk.

Good fits:

Apply one mechanical rule across many modules — "add 
@MainActor
 to every
  view model", "replace literal spacing with 
Space.*
 tokens".
Fix the same class of bug in many independent places.
Bump a dependency across several packages.
Migrate N files to a new API where each file stands alone.

Bad fits:

Units that share files.
 Two workers editing 
AppDelegate.swift
 will
  produce conflicting PRs.
Units with ordering constraints.
 Batch has no dependency graph.
Exploratory work.
 Batch executes a known change; it does not decide what
  the change should be. Plan first (
ios-plan
), then batch.
One large refactor.
 That is one PR, not thirty.

Making a batch succeed

The per-unit prompt must be 
complete and self-contained
 — each worker starts
cold and cannot see the others.

For <module>:
  goal:     every @Observable type the UI renders is @MainActor final class
  scope:    only files under <module>/ — do not touch shared/ or Package.swift
  rules:    behavior must not change; add nothing but the isolation annotations
  verify:   swift build && swift test --filter <module>
  report:   the diff, plus the real output of the verify command
  if the module has no such types: report "no change needed" and stop

That last line matters. Without an explicit no-op path, workers invent work to
justify their existence.

Worktrees

Each unit gets its own working copy, so parallel writes are safe:

git worktree add ../wt-cart -b batch/cart-mainactor
git worktree add ../wt-orders -b batch/orders-mainactor
# … worker per worktree …
git worktree remove ../wt-cart

Isolation is the whole point. Without it, parallel writers corrupt each other's
work in ways that are extremely hard to diagnose after the fact.

3. Dynamic workflows — orchestration in code

When the job needs logic that does not fit a fixed batch — conditional
branching, fan-out that depends on discovered results, retries with different
strategies, a dependency graph — move the orchestration into a script that
drives many subagents through the Agent SDK.

The shape:

discover  -> a read-only pass produces the work list
plan      -> group into independent units; identify ordering constraints
fan out   -> one worker per unit, in parallel, each isolated
verify    -> an independent verifier per unit
gather    -> collect verdicts; retry, escalate, or report

# orchestrate.py — illustrative shape, not a copy-paste script.
# The point is the control flow, not the API surface.

units = discover_units()                 # e.g. every module with a view model

results = []
for unit in units:                       # parallelize as your runner allows
    worker_report = run_agent(
        agent="swift-refactorer",
        prompt=build_unit_prompt(unit),  # complete and self-contained
        worktree=make_worktree(unit),
    )

    review = run_agent(
        agent="swift-reviewer",          # cold, read-only, no stake
        prompt=f"Verify this change in {unit.path}. Run the build and tests "
               f"and return their real output.\n\n{worker_report.diff}",
        worktree=worker_report.worktree,
    )

    results.append((unit, worker_report, review))

report(results)                          # verdicts + evidence, not narrative

Three properties make this work, and all three are easy to get wrong:

Each unit is self-contained.
 Workers start cold. Everything they need is
   in the prompt.
The verifier is separate from the worker.
 Same rule as everywhere else in
   
verification.md
 — the thing declaring success is not the thing that wants
   success.
The script owns the state.
 Not the model's memory. The script knows what
   ran, what passed, and what to retry.

When 
not
 to script it

Scripted orchestration is a real system with real maintenance cost. If the job
runs once and has under ten units, a session with subagents is cheaper and
easier to steer. Write the script when the workflow recurs, or when the unit
count makes conversational tracking unreliable.

4. Failure handling at scale

At thirty units, some will fail. Decide the policy up front:

Policy

Behavior

Use when

Fail fast

Stop the whole run on first failure

Units share risk; a failure implies a bad plan

Isolate and continue

Mark it failed, keep going, report at the end

Units are genuinely independent

Retry once, then isolate

One retry with the failure fed back

Flaky infra, transient toolchain issues

Default to 
isolate and continue
 for independent units, with a summary at the
end that lists every failure. Twenty-eight successes and two clearly-reported
failures is a good run. Twenty-eight successes and two silently-skipped units is
a bad one that looks identical from the outside — which is exactly why the final
report must enumerate failures explicitly.

Never let a unit "succeed" by doing nothing.
 A worker that finds no work
should say "no change needed" — that is a distinct outcome from "changed and
verified", and collapsing the two hides coverage gaps.

5. Cost

Every subagent starts cold, which means re-establishing context it does not
inherit. Thirty subagents that each read the same five files pay that cost thirty
times.

Reduce it by:

Putting shared context in the prompt
 rather than making each worker
  rediscover it. One 
ios-explore
 pass up front, its findings pasted into every
  unit prompt, beats thirty independent explorations.
Using cheaper models for mechanical units.
 
sonnet
 for a token
  replacement; reserve 
inherit
/
opus
 for planning and review.
Scoping tightly.
 A worker told exactly which files to touch does not spend
  its budget searching.
Not delegating what is inline-sized.
 The most common waste is spawning an
  agent for a two-call task.

6. Anti-patterns

# 1. Batching units that share files.
-> Conflicting PRs, and neither worker knows.

# 2. Batching work with ordering constraints.
-> Batch has no dependency graph. Sequence it, or encode the order in a script.

# 3. Batching exploratory work.
-> Plan first. Batch executes a decided change.

# 4. Scripting a one-off ten-unit job.
-> A session with subagents is cheaper and easier to redirect.

# 5. Workers that grade themselves at scale.
-> Thirty self-assessed successes is thirty unverified claims.

# 6. No explicit no-op path.
-> Workers invent work rather than report "nothing to do".

# 7. Silently dropping failed units.
-> Enumerate every failure in the final report.

# 8. Thirty cold agents rediscovering the same context.
-> One discovery pass, results pasted into each prompt.

# 9. Parallel writers without worktrees.
-> Corrupted working tree, unattributable damage.

Checklist before scaling out

[ ] The units are genuinely independent — no shared files, no ordering.
[ ] Each unit prompt is complete and self-contained (workers start cold).
[ ] Each unit has an explicit no-op path.
[ ] Each unit has a 
verify
 command and returns its real output.
[ ] Parallel writers are isolated in worktrees.
[ ] The verifier is separate from the worker.
[ ] A failure policy is chosen: fail fast, isolate, or retry-then-isolate.
[ ] The final report enumerates failures, not just successes.
[ ] Shared context was discovered once, not thirty times.

---

# Hooks — Deterministic Enforcement
https://nagarjuna2997.github.io/ios-agent-skill/guides/orchestration-hooks.html

Orchestration · Reference guideHooks — Deterministic EnforcementRepository guidance for Hooks — Deterministic Enforcement. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

1. Hook vs. CI vs. reviewer
2. Events
3. The exit-code contract
4. Configuration
5. This repository's hooks
6. Hooks for iOS projects
7. Design rules
8. Anti-patterns
Checklist for a new hook

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Select a lifecycle event
↓2
Inspect tool input safely
↓3
Run bounded checks
↓4
Return the documented exit status

02 / ArchitectureResponsibility boundariesBoundary 1
Client eventBoundary 2
Hook scriptBoundary 3
Allow or block resultConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
 a rule must hold every time rather than usually, you are
repeating the same correction to an agent, or you are deciding between a hook,
a CI check, and a reviewer subagent.

A hook is a shell command that runs automatically at a fixed lifecycle point.
It is not a suggestion the model weighs — it executes, and its exit code decides
what happens next. That makes it the correct home for anything a script can
decide.

1. Hook vs. CI vs. reviewer

Three enforcement layers. Use the cheapest one that can decide the question.

Layer

Decides

Cost

Feedback speed

Hook

Rules a script can evaluate

~free

Immediate — model self-corrects mid-turn

CI

Same, plus full build/test

minutes

After push

Reviewer subagent

Judgment: is this correct, does it match intent

tokens

End of task

The ordering matters. Spending a reviewer subagent on "did you use

DispatchQueue.main.async
" is waste — a grep answers that for free, instantly,
and cannot be argued with. Reserve model judgment for what rules cannot express.

Rule of thumb:
 if you can write the check as a grep or an exit code, it is a
hook. If it needs to understand intent, it is a reviewer.

2. Events

Event

Fires

Typical use

PreToolUse

Before a tool runs

Block edits to generated or protected files

PostToolUse

After a tool succeeds

Format, lint, check the file just written

UserPromptSubmit

On each user message

Inject project context

Stop

Before the turn ends

Build/test verification

SubagentStop

When a subagent finishes

Validate a subagent's output

SessionStart

Session begins

Environment setup, load state

SessionEnd

Session ends

Cleanup

PreCompact

Before context compaction

Persist state that must survive

PreToolUse
, 
PostToolUse
, and 
Stop
 cover almost every practical need.

3. The exit-code contract

This is the whole interface:

Exit

Meaning

0

Pass. stdout goes to the transcript.

2

Block. stderr is fed back to the model as the reason.

other

Non-blocking error, surfaced to the user.

Exit 2 is what makes hooks valuable: the model reads the failure and fixes it
without the user having to notice, let alone intervene.

# The message on stderr IS the instruction the model acts on.
# Say what is wrong AND what to do instead.
echo "BLOCKED: CLAUDE.md is generated from SKILL.md." >&2
echo "Edit SKILL.md, then run ./scripts/sync-mirrors.sh" >&2
exit 2

A blocking message that only says "not allowed" wastes the round trip. Every
exit-2 message names the fix.

Hooks receive JSON on stdin — 
tool_name
, 
tool_input
, 
cwd
, 
session_id
,
and for 
PostToolUse
 also 
tool_response
. Parse it rather than guessing:

INPUT="$(cat)"
FILE_PATH="$(printf '%s' "$INPUT" | python3 -c '
import json, sys
print(json.load(sys.stdin).get("tool_input", {}).get("file_path", ""))
')"

4. Configuration

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/swift-format.sh",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

matcher
 is a regex over the tool name. Omit it to match every invocation of
  the event.
Always use 
$CLAUDE_PROJECT_DIR
 for paths — a relative path breaks the moment
  the working directory differs.
Set a 
timeout
. A hook that hangs blocks the session.

5. This repository's hooks

Wired in 
.claude/settings.json
, implemented in 
scripts/hooks/
:

Hook

Event

Effect

guard-generated-files.sh

PreToolUse

Denies edits to the 24 generated mirror files, points at SKILL.md

sync-mirrors-on-edit.sh

PostToolUse

Regenerates all mirrors whenever SKILL.md changes

verify-repo.sh

Stop

Runs the CI checks — mirror sync, frontmatter, doc references, subagent frontmatter

Together these make an entire class of mistake impossible rather than merely
discouraged: a hand-edited mirror is blocked, a forgotten sync is automatic, and
a broken doc reference cannot survive the end of a turn.

6. Hooks for iOS projects

Drop-in templates live in 
templates/hooks/
:

Hook

Event

Effect

swift-format.sh

PostToolUse

SwiftFormat + SwiftLint autocorrect on the edited file

forbid-antipatterns.sh

PostToolUse

Blocks SKILL.md's banned patterns with line numbers and the fix

build-check.sh

Stop

Builds (and tests) before the turn ends

forbid-antipatterns.sh
 catches, among others: 
DispatchQueue.main.async
,

Task.detached
, 
@Observable
 without 
@MainActor
, empty 
catch
, 
try!
,

NavigationView
, 
AnyView
, fixed font sizes, live-implementation default
arguments, 
print()
, and a type named 
Task
. Test and mock files are exempt
from the app-code-only rules.

Installation is in 
templates/hooks/README.md
.

7. Design rules

Fail fast, and say what to do.
 A hook that blocks without naming the fix
costs a round trip and teaches nothing.

Be conservative about blocking.
 A false positive on exit 2 stops legitimate
work. When a rule has real exceptions, either exempt them by path (as

forbid-antipatterns.sh
 does for test and mock files) or downgrade to a warning
on exit 0.

Keep them fast.
 
PostToolUse
 runs on every edit. Check the single file that
changed, never the whole tree.

Degrade gracefully.
 A missing formatter or absent toolchain is not a failure
— exit 0 and say the check was skipped. 
build-check.sh
 prints 
UNVERIFIED

rather than implying a build that never ran, which keeps it honest under the
evidence contract in 
verification.md
.

Never let a hook fabricate a pass.
 A check that cannot run reports that it
could not run. Silence reads as success and is the one failure mode that makes
the whole layer untrustworthy.

8. Anti-patterns

# 1. Using a reviewer subagent for what a grep decides.
-> Tokens spent on something free and deterministic.

# 2. Blocking without naming the fix.
echo "Not allowed" >&2; exit 2
-> The model does not know what to do next.

# 3. A PostToolUse hook that scans the whole repo.
-> Runs on every edit. Check only the changed file.

# 4. Treating a missing tool as a failure.
swiftformat ... || exit 2
-> The project may not use it. Exit 0 and skip.

# 5. A hook with no timeout.
-> One hang blocks the session.

# 6. Relative paths in the command.
"command": "./hooks/check.sh"
-> Breaks whenever cwd differs. Use $CLAUDE_PROJECT_DIR.

# 7. A check that silently passes when it could not run.
-> Reports success for work nobody verified.

# 8. Enforcing style opinions with exit 2.
-> Reserve blocking for correctness. Formatting is auto-fixed, not blocked.

Checklist for a new hook

[ ] The rule is genuinely deterministic — no judgment required.
[ ] It checks only what changed, not the whole tree.
[ ] Exit 2 messages name both the problem and the fix.
[ ] Legitimate exceptions are exempted by path, not by weakening the rule.
[ ] A missing tool or toolchain exits 0 and says the check was skipped.
[ ] A 
timeout
 is set.
[ ] The command uses 
$CLAUDE_PROJECT_DIR
.
[ ] It has been run against both a passing and a failing input.

---

# Loops
https://nagarjuna2997.github.io/ios-agent-skill/guides/orchestration-looping.html

Orchestration · Reference guideLoopsRepository guidance for Loops. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

1. The four loop patterns
2. Every loop declares four things
3. Goal-based loops

Detecting a stall
Never fake termination

4. Turn-based loops
5. Time-based loops
6. Proactive loops
7. Loops and subagents
8. Anti-patterns
Checklist before starting a loop

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Declare a goal and budget
↓2
Run one bounded attempt
↓3
Evaluate evidence
↓4
Stop or retry within limits

02 / ArchitectureResponsibility boundariesBoundary 1
Loop controllerBoundary 2
Work attemptBoundary 3
Completion predicateConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
 work must repeat until some condition holds — a failing
suite driven to green, a migration applied file by file, a CI run watched, a
recurring check — or when you are about to write "keep going until…".

A loop is a repeated cycle of work that continues until a 
stop condition
 is
met. The stop condition is the entire design. A loop without one is not a loop,
it is a runaway process.

1. The four loop patterns

Pattern

Stops when

Use for

Turn-based

A fixed number of iterations completes

Bounded batch work: 12 files to migrate

Goal-based

A measurable condition becomes true

"Until swift test is green"

Time-based

A schedule fires, or a deadline passes

Recurring checks, watching CI

Proactive

An external event arrives

Reacting to a webhook, a PR comment, a failure

Most real work is 
goal-based with a turn cap
 — repeat until the goal is met,
but never more than N times, so a goal that turns out to be unreachable
terminates instead of burning tokens forever.

2. Every loop declares four things

Before the first iteration, state these. If you cannot fill all four in, you do
not yet have a loop you should run.

GOAL:      swift test exits 0 with no skipped tests
CHECK:     $ swift test 2>&1 | tail -20   (the exact command, run every iteration)
MAX:       6 iterations
ON-STALL:  if two consecutive iterations produce the same failure, stop and report

GOAL
 — an outcome, not an activity. "Tests pass" is a goal. "Work on the
  tests" is not.
CHECK
 — a real command whose output decides whether to continue. Not your
  own judgment. If the check is "does it look right", the loop cannot terminate
  reliably.
MAX
 — a hard iteration cap. Always.
ON-STALL
 — what counts as no progress, and what to do about it.

3. Goal-based loops

The common case. Structure:

1. Run CHECK. Capture the output.
2. If the goal is met -> stop, report the passing output.
3. If MAX iterations reached -> stop, report the last failure and what you tried.
4. If the failure is identical to the previous iteration -> stop (stalled).
5. Otherwise: fix ONE thing, then go to 1.

Step 5 is where loops go wrong. Fixing several things per iteration means that
when the check still fails you cannot tell which change helped, which regressed,
and which did nothing.

Detecting a stall

Three signals that a loop should stop even though the goal is unmet:

Identical failure twice.
 Your change did not affect the failure. Continuing
  will not help.
Oscillation.
 Failure A → fix → failure B → fix → failure A. You are
  trading one problem for another; the design is wrong, not the code.
Growing blast radius.
 Each iteration touches more files than the last. You
  are chasing symptoms.

On any of these: stop and report. A loop that halts with "I could not get past
this, here is the failure and the three approaches I tried" is a good outcome. A
loop that runs twenty iterations and reports success is usually lying.

Never fake termination

# WRONG — these make the check pass without meeting the goal.
- Deleting or skipping the failing test
- Widening a catch until the error disappears
- Adding a sleep until a race stops reproducing
- Loosening an assertion to match wrong output
- Force-unwrapping to get past a compile error

# RIGHT
Stop. Report the failure, the root cause if you found it, and what you tried.

If the only way to satisfy the stop condition is to weaken the check, the loop
has failed and must say so.

4. Turn-based loops

Bounded, known work. The cap 
is
 the stop condition.

GOAL:  all 14 view models in Features/ are @MainActor-isolated
CHECK: $ grep -rLn "@MainActor" --include="*ViewModel.swift" Features/
MAX:   14 (one per file)

Work one unit per iteration and verify each before moving on. Batching all
fourteen and verifying once at the end means a single failure invalidates the
whole pass with no way to tell which file caused it.

5. Time-based loops

Two distinct cases, and the difference matters:

Fixed schedule
 — a recurring task. Use a scheduled trigger rather than a
sleeping session: "check the release branch for new failures every weekday at
09:00".

Self-paced polling
 — waiting for something to change. Choose the interval
from how fast the thing actually changes:

Waiting on

Interval

A CI run that takes ~8 minutes

one check at ~8 min, not eight at 1 min

A deploy

matched to the deploy's typical duration

Nothing specific (idle tick)

20–30 minutes

A signal that will notify you anyway

a long fallback (20 min+), not polling

Never poll with sleep for something the harness will wake you for.
 If
work is tracked and will notify you on completion, waiting in a sleep loop is
pure waste. Use a long fallback so the loop survives a hang, and let the
notification do the work.

6. Proactive loops

Driven by external events rather than your own schedule: a PR comment arrives, a
CI job fails, a webhook fires.

Rules:

Every event gets a visible outcome.
 Either an action taken, or a stated
  reason for not acting. Silently dropping an event is how a "watched" PR sits
  broken for a day.
Deduplicate.
 The same event can arrive twice. So can an echo of your own
  action. Neither is a new request.
Do not narrate every round.
 Report when a round resolves the task, hits a
  blocker, or raises a question.
The loop ends when the underlying thing is done
 — the PR merges, the job
  goes green — not when you run out of events.

7. Loops and subagents

Two ways to combine them, with different properties:

Loop inside one agent
 — the agent iterates itself. Cheaper, keeps context
across iterations, but the agent is grading its own work at every step.

Loop with a verifier
 — the worker fixes, a fresh 
swift-reviewer
 subagent
checks, the loop continues on the reviewer's verdict. More expensive, and
substantially more trustworthy: the thing deciding "done" is not the thing that
wants to be done.

Use a verifier loop when the cost of a false "it works" is high — anything you
will not manually check before it ships. See 
verification.md
.

iterate:
  worker  -> makes one change
  reviewer -> runs the build and tests, returns VERDICT + real output
  if VERDICT == pass -> stop
  if MAX reached or stalled -> stop and report
  else -> feed the reviewer's findings back to the worker

8. Anti-patterns

# 1. No stop condition.
"Keep improving the code."
-> Improve until what is true? Unbounded.

# 2. Self-assessed check.
CHECK: "does the code look correct now?"
-> Not a check. Use a command with an exit code.

# 3. No iteration cap.
-> One unreachable goal burns the whole budget.

# 4. Multiple changes per iteration.
-> You cannot attribute the result to a cause.

# 5. Polling with sleep for work that notifies you.
while true; do sleep 30; check_status; done
-> Wasted turns. Use a long fallback and let the notification wake you.

# 6. Ignoring a stall.
Same failure, iteration 3, 4, 5, 6…
-> Stop at 2. Report.

# 7. Weakening the check to terminate.
-> That is not success, and reporting it as success is a false claim.

# 8. Reporting success without the final check's output.
-> Paste the passing output. Otherwise it is an assertion.

Checklist before starting a loop

[ ] GOAL is a measurable outcome, not an activity.
[ ] CHECK is a real command with an exit code, run every iteration.
[ ] MAX iteration cap is set.
[ ] Stall detection is defined (identical failure, oscillation, growth).
[ ] One change per iteration.
[ ] The termination report includes the final check's real output.
[ ] For high-stakes work, an independent verifier decides "done", not the worker.

---

# Main Agent Router
https://nagarjuna2997.github.io/ios-agent-skill/guides/orchestration-router.html

Orchestration · Reference guideMain Agent RouterRepository guidance for Main Agent Router. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

1. The decision
2. When to delegate

Choosing the specialist

3. Standard sequences

Feature (multi-file)
Bug
Drive a red suite to green
Codebase-wide mechanical change

4. What the main agent stays responsible for
5. Cost discipline
6. Non-negotiables
7. Quick reference

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Classify the requested change
↓2
Select necessary specialists
↓3
Delegate bounded reviews
↓4
Integrate verified findings

02 / ArchitectureResponsibility boundariesBoundary 1
Main agentBoundary 2
Specialist reviewersBoundary 3
Final evidenceConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
 you are the main agent deciding how to execute a request —
do it yourself, delegate it, loop on it, or scale it out.

This is the entry point to 
docs/orchestration/
. It answers one question:

given this request, what shape of execution does it need?

The default is 
do it yourself
. Everything below is an exception that has to
earn its cost.

1. The decision

Work top to bottom. Take the first row that matches.

If the request…

Then

Doc

Touches 1–2 files you can already see

Do it inline. No delegation.

—

Is a question you can answer from files you have read

Answer it.

—

Requires sweeping many files to locate something

Delegate to ios-explore

subagents.md

Is a multi-file feature, migration, or architecture change

ios-plan first, then execute

subagents.md

Produced code that will ship

swift-reviewer after

verification.md

Is a failure whose cause is not obvious

swift-debugger

subagents.md

Is a behavior-preserving cleanup

swift-refactorer

subagents.md

Is documentation, README, or CHANGELOG work

ios-docs

subagents.md

Involves Foundation Models, Apple Intelligence, or on-device LLM work

foundation-models

../frameworks/foundation-models.md

Is an accessibility audit — VoiceOver, Dynamic Type, contrast

accessibility-reviewer

../frameworks/accessibility.md

Is a performance investigation — hitches, memory, main-actor contention

performance-reviewer

../../checklists/performance.md

Is migrating legacy SwiftUI/UIKit to modern APIs

swiftui-modernization

subagents.md

Must repeat until a condition holds

A loop with a stop condition

looping.md

Is 5–30 isolated changes each wanting its own PR

/batch

dynamic-workflows.md

Is dozens of units with branching or dependencies

A dynamic workflow

dynamic-workflows.md

Needs workers to talk to each other

Reconsider. That is agent teams — experimental, off by default

subagents.md §6

2. When to delegate

Delegate when at least one is true:

Context cost.
 The investigation would read more files than you want in
  context. One paragraph of findings beats forty files.
Independence.
 The work needs judging by something that did not write it.
Parallelism.
 Several genuinely independent read-only investigations.
Isolation.
 The work should happen in a worktree, not your working tree.

Do 
not
 delegate because a task sounds big. "Thorough", "multiple angles",
"several parts", and "check everything" are not delegation triggers — they are
descriptions of ordinary work. A subagent starts cold and must be told
everything; for anything you could finish in a few tool calls, that overhead
exceeds the benefit.

Choosing the specialist

Where is X? / which files do Y?        -> ios-explore      (read-only, parallel-safe)
How should I build X?                  -> ios-plan         (read-only, returns a plan)
Is this change correct? Does it build? -> swift-reviewer   (read + Bash, no writes)
Why is this broken?                    -> swift-debugger   (reproduce, fix, prove)
Clean this up without changing behavior-> swift-refactorer (baseline, change, re-verify)
Write/update docs                      -> ios-docs         (structure + mirror sync)
Doesn't fit any of these               -> general-purpose  (built-in)

3. Standard sequences

Feature (multi-file)

1. ios-explore    — find the existing seams and conventions      [parallel-safe]
2. ios-plan       — a step-by-step plan with file paths and verify commands
3. main agent     — execute the plan
4. swift-reviewer — cold verification: build + tests + real output
5. main agent     — route findings back, or report done with evidence

Skip steps 1–2 when the feature is small and the codebase is already familiar.
Never skip step 4 for code that ships.

Bug

1. swift-debugger — reproduce, isolate, root cause, fix, prove
2. swift-reviewer — independent confirmation, if the fix is non-trivial

The debugger already proves its own fix by re-running the failing command. A
second reviewer is for fixes that touch shared code or change behavior beyond
the bug.

Drive a red suite to green

GOAL:  swift test exits 0
CHECK: $ swift test 2>&1 | tail -20
MAX:   6
loop:
  swift-debugger  — one root cause per iteration
  swift-reviewer  — verdict + real output
  stop on: pass | MAX | identical failure twice

See 
looping.md
 for stall detection and the rules against faking termination.

Codebase-wide mechanical change

1. ios-explore — enumerate every affected file (this is the work list)
2. ios-plan    — group into independent units; flag shared files
3. /batch      — one worker per unit, own worktree, own PR
4. per-unit    — swift-reviewer verdict before the PR opens

If step 2 finds that units share files, it is not a batch. Sequence it instead.

4. What the main agent stays responsible for

Delegation moves work, not accountability. The main agent always owns:

The user's actual request.
 Subagents see a slice; you see the whole thing.
Deciding what "done" means
, and confirming evidence supports it.
Reconciling reports.
 Subagents cannot talk to each other. If explore found
  something plan needs, 
you
 carry it across.
Reporting faithfully
 — including failures, skips, and anything a subagent
  marked UNVERIFIED. Never launder a subagent's uncertainty into your own
  confidence.
Not fabricating pending results.
 A background subagent that has not
  reported has no result. Say it is still running.

Subagent reports are input, not truth. If one says "tests pass" with no pasted
output, that is an unverified claim — treat it as such.

5. Cost discipline

Each subagent starts cold. Before spawning, ask:

Could I do this in two or three tool calls? → Do it.
Does an already-running subagent have this context? → Continue that one
   instead of starting fresh.
Will I re-explain more than the task is worth? → Do it inline.
Are these units really independent? → If not, do not parallelize.

Then reduce the cost of the ones you do spawn:

One discovery pass, its findings pasted into every downstream prompt — not N
  agents rediscovering the same five files.
sonnet
 for mechanical work, 
inherit
 for reasoning work.
Tight scope: name the files, state what is out of scope.

6. Non-negotiables

These hold no matter which path you took:

Evidence, not assertion.
 Every "it works" carries the command output that
  proves it. 
verification.md
.
The author does not grade the work
 for anything that ships.
Every loop has a stop condition and an iteration cap.
 
looping.md
.
Parallel writers are isolated
 in worktrees, or they are not parallel.
Failures are reported plainly
, never softened or buried.
Hooks and CI decide what they can decide.
 Do not spend a reviewer subagent
  on something a grep in a hook settles for free.

7. Quick reference

inline                   1–2 files, context already loaded    ← the default
ios-explore              "where is X" across many files       read-only, parallel
ios-plan                 multi-file feature or migration      read-only
swift-reviewer           verify work you or another agent did no write tools
swift-debugger           something is broken, cause unclear   reproduce → fix → prove
swift-refactorer         cleanup with no behavior change      green baseline required
ios-docs                 prose about code                     enforces doc structure
foundation-models        on-device / PCC LLM work             availability-aware
swiftui-modernization    legacy → modern API migration        behavior-preserving
accessibility-reviewer   VoiceOver, Dynamic Type, contrast    read-only audit
performance-reviewer     hitches, memory, actor contention    measures, never guesses
loop                     repeat until a measured condition    needs GOAL/CHECK/MAX
/batch                   5–30 isolated PRs                    worktree per unit
dynamic workflow         dozens of units, branching logic     orchestration in a script

---

# Subagents
https://nagarjuna2997.github.io/ios-agent-skill/guides/orchestration-subagents.html

Orchestration · Reference guideSubagentsRepository guidance for Subagents. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

1. Why delegate at all
2. Defining a subagent

The description is the interface

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Define a narrow responsibility
↓2
Supply scoped context
↓3
Execute isolated work
↓4
Return findings to coordinator

02 / ArchitectureResponsibility boundariesBoundary 1
Delegation contractBoundary 2
Subagent contextBoundary 3
Coordinator reviewConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
 a task is large enough to split, a search would flood your
context, you need work verified by something other than the agent that wrote it,
or you are defining a new specialist in 
.claude/agents/
.

A subagent is a separate Claude instance with its 
own context window
, its

own system prompt
, and optionally a 
restricted tool set
. The main agent
delegates a task to it, the subagent works in isolation, and it returns a single
final report.

1. Why delegate at all

Three reasons, in order of how often they apply:

Context preservation.
 A search that reads forty files costs forty files
   of your context. Delegated to a subagent, it costs one paragraph of findings.
   This is the most common reason and the most undervalued.
Independent verification.
 An agent that wrote code is a bad judge of
   whether that code works — it is predisposed to see its own intent rather than
   what it typed. A fresh subagent has no such stake. See 
verification.md
.
Parallelism.
 Independent read-only investigations run concurrently
   instead of serially.

Delegation is not free. Each subagent starts 
cold
 — it does not inherit your
conversation, the file you just read, or the decision the user made three turns
ago. Everything it needs must be in the prompt you give it. For a task you could
finish in two tool calls, spawning a subagent is slower and worse.

Do the work inline when:
 it is a couple of files, you already have the
context loaded, or the task is a single edit. "Thorough", "multiple angles", and
"several parts" are not by themselves reasons to delegate.

2. Defining a subagent

Subagents are markdown files with YAML frontmatter. Project-level definitions
live in 
.claude/agents/
; user-level ones in 
~/.claude/agents/
. Project
definitions take precedence when names collide.

---
name: swift-reviewer
description: Independent verifier for Swift changes. Use after code is written to check it against the repo rules and prove it builds. Runs builds and tests and returns real output.
tools: Read, Grep, Glob, Bash
model: inherit
---

You are an independent reviewer. …system prompt…

Field

Required

Notes

name

yes

Lowercase kebab-case, unique. This is how you invoke it.

description

yes

This is the routing signal. The main agent selects a subagent by matching the task against this text.

tools

no

Comma-separated allowlist. Omit to inherit all tools.

model

no

sonnet, opus, haiku, or inherit. Defaults to the configured subagent model.

The description is the interface

The main agent picks a subagent by reading descriptions, not by reading system
prompts. A vague description means the subagent never gets invoked, or gets
invoked for the wrong things.

# WEAK — nothing here says when to use it.
description: Reviews code.

# STRONG — names the trigger, the input, and the output.
description: Independent verifier for Swift/iOS changes. Use after another agent
  has written code, to check it against repository rules and prove it builds and
  tests pass. Runs builds and tests and returns their real output.

Write descriptions in the form *"
. Use when . Returns

."*

### Restrict tools deliberately

Tool restriction is a correctness feature, not just a safety one. A subagent
with no write tools **cannot** accidentally edit while investigating, which is
what makes it safe to run several in parallel.

| Subagent kind | Tools | Why |
|---------------|-------|-----|
| Search / explore | `Read, Grep, Glob` | Genuinely read-only; parallel-safe |
| Planner | `Read, Grep, Glob` | Produces a plan, never code |
| Reviewer | `Read, Grep, Glob, Bash` | Needs to run builds; must not edit |
| Debugger | `Read, Grep, Glob, Bash, Edit` | Must reproduce and fix |
| Refactorer | `Read, Grep, Glob, Edit, Write, Bash` | Edits, and verifies with tests |
| Docs | `Read, Grep, Glob, Edit, Write, Bash` | Writes prose, runs repo checks |

Giving every subagent every tool defeats the point. A reviewer with `Edit` will
eventually fix what it should have reported.

#### Verifying the restriction actually holds

Run `./scripts/eval-agents.sh`. It prints the grant matrix and fails when a
declaration contradicts the instructions that rely on it:

$ ./scripts/eval-agents.sh
AGENT                   TOOLS                                BOUNDARY
ios-explore             Read, Grep, Glob                     read-only (enforced + declared)
swift-reviewer          Read, Grep, Glob, Bash               read-only (enforced + declared)
swift-refactorer        Read, Grep, Glob, Edit, Write, Bash  read-write
…
OK — 10 agents, every declared boundary is consistent with its instructions.

The tempting design is to prompt each agent to edit something and check that it
refuses. **That does not test the boundary.** Tool restriction is enforced by
the harness from the `tools:` line before the model is consulted, so a prompt
check passes for an agent whose frontmatter wrongly grants `Write` — the model
simply chose not to use it — and it is non-deterministic besides. What decides
the boundary is the declaration, so that is what gets checked, against the
prompt depending on it:

| Rule | Catches |
|------|---------|
| `read-only-holds-write-tool` | A description promising read-only while `tools` grants `Edit`/`Write` |
| `write-instruction-without-write-tool` | Instructions to modify files with no write tool granted |
| `command-without-bash` | A command in the prompt the agent cannot run |
| `bash-never-used` | `Bash` granted with nothing in the prompt to run |
| `unknown-tool` | A misspelled tool name — not granted, and not reported |
| `name-filename-mismatch` | Delegation prompts pointing at an unregistered identifier |
| `no-tools` | No `tools:` at all, which inherits everything |

Read-only status is taken from the **description**, never the body. Bodies are
full of scoped prohibitions that mean something else — `ios-docs` says "never
edit a *generated* file", `swift-refactorer` says "do not change *access
levels*" — and reading those as a read-only contract flags three correctly
configured agents. The description is what the main agent reads when it decides
to delegate, so it is the only place the promise counts.

`./scripts/eval-agents.sh --self-test` builds agents each broken in exactly one
way and asserts every rule fires, including a regression case for the four real
phrasings above. A check that cannot fail is not a check.

### Model selection

- `inherit` — matches the main agent. Use for reasoning-heavy work: planning,
  review, debugging.
- `sonnet` — cheaper and faster. Use for mechanical work: search, refactors,
  documentation.
- `haiku` — for high-volume trivial classification.

---

## 3. This repository's subagents

Defined in `.claude/agents/`:

| Subagent | Tools | Model | Use for |
|----------|-------|-------|---------|
| `ios-explore` | read-only | sonnet | "Where is X?" across a Swift codebase |
| `ios-plan` | read-only | inherit | Multi-file features, migrations, architecture decisions |
| `swift-reviewer` | read + Bash | inherit | Verifying work someone else did |
| `swift-debugger` | read + Bash + Edit | inherit | A failure whose cause is not obvious |
| `swift-refactorer` | read + write + Bash | sonnet | Behavior-preserving cleanups |
| `ios-docs` | read + write + Bash | sonnet | Docs, DocC, README, CHANGELOG |

They are prefixed `ios-` / `swift-` deliberately: Claude Code ships built-in
subagents including a **general-purpose** one, and in some configurations
`Explore` and `Plan`. Prefixing avoids shadowing a built-in whose behavior you
did not intend to replace, and makes it obvious in a transcript which one ran.

See `router.md` for when the main agent should reach for each.

---

## 4. Writing the delegation prompt

A subagent starts cold. This is the failure mode that makes delegation look
useless: an under-specified prompt produces a confident, irrelevant report.

Every delegation prompt carries:

1. **The goal**, as an outcome rather than an activity.
2. **The context it cannot see** — decisions already made, constraints, the
   relevant file paths you already found.
3. **The boundary** — what is explicitly out of scope.
4. **The return format** — what you need back, and in what shape.

# WEAK
"Look at the networking code."

# STRONG
"Find every place that constructs a URLRequest in Sources/, and report which
ones set an Authorization header and which do not.

Context: we are adding a shared auth interceptor and need to know what would
be duplicated. The APIClient at Sources/Data/APIClient.swift:44 is already
known — I need the ones outside it.

Out of scope: test targets, and anything under Vendor/.

Return: a table of file:line, the endpoint, and whether it sets auth."

The `Out of scope` line matters more than it looks. Without it, subagents
reliably expand the task.

---

## 5. Parallelism

Read-only subagents parallelize cleanly. Launch several in one turn when the
investigations are genuinely independent:

- "Where is the auth token stored?"
- "Which screens call the orders endpoint?"
- "What is our current deployment target and Swift language mode?"

**Do not parallelize writes.** Two subagents editing the same file will clobber
each other, and neither will know. When several agents must change code, either
sequence them or give each an isolated git worktree — see
`dynamic-workflows.md`.

---

## 6. Subagents are not agent teams

This distinction is load-bearing and frequently confused.

|  | Subagents | Agent teams |
|--|-----------|-------------|
| Communication | Report **only** to the main agent | Workers message each other directly |
| Topology | Hub and spoke | Peer to peer |
| Coordination | The main agent is the sole orchestrator | Emergent between workers |
| Availability | Generally available | **Experimental, disabled by default** |

Subagents **cannot** talk to each other. If subagent A discovers something
subagent B needs, that information travels A → main agent → B, and only if the
main agent passes it along. Design your delegation around that: do not split a
task in a way that requires two workers to negotiate.

If you actually need peer-to-peer worker communication, that is the agent-teams
feature, it is experimental, and it is off unless explicitly enabled. Do not
assume it in a workflow you expect to work today.

---

## 7. Anti-patterns

# 1. Delegating what you could do in two tool calls.
"Spawn a subagent to read Package.swift."
-> Just read it. Cold start costs more than the read.

# 2. Spawning a fresh agent for a task an existing one has context for.
-> Continue the existing subagent instead of starting cold again.

# 3. Assuming the subagent can see your conversation.
"Fix the bug we discussed."
-> It has no idea what you discussed. Restate it.

# 4. Parallel writers on the same files.
-> Sequence them, or isolate each in a worktree.

# 5. A reviewer that also edits.
-> Then it is grading its own work. Restrict its tools.

# 6. Trusting a report with no evidence.
"The subagent said tests pass."
-> Did it paste the output? If not, it asserted, it did not verify.

# 7. Predicting a background subagent's result.
-> A pending agent has no result. Say it is still running.

# 8. One mega-subagent with every tool and a vague description.
-> It never gets routed correctly and cannot be reasoned about.

---

## Checklist for a new subagent definition

- [ ] `name` is lowercase kebab-case and does not shadow a built-in.
- [ ] `description` states what it does, when to use it, and what it returns.
- [ ] `tools` is the minimum set the job needs — read-only if it investigates.
- [ ] The system prompt states the **return format** explicitly.
- [ ] The system prompt includes the evidence contract (`verification.md`).
- [ ] The system prompt states what is out of scope.
- [ ] It does not assume access to the main agent's conversation.
- [ ] `./scripts/eval-agents.sh` passes — the grant matches what the prompt does.

---

# Verification and the Evidence Contract
https://nagarjuna2997.github.io/ios-agent-skill/guides/orchestration-verification.html

Orchestration · Reference guideVerification and the Evidence ContractRepository guidance for Verification and the Evidence Contract. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

1. The rule
2. Every claim carries a label
3. What counts as evidence

Evidence for iOS specifically
When you genuinely cannot build

4. Separation of duties

Three layers, cheapest first

5. The report format
6. Reporting failure
7. Anti-patterns
Checklist before reporting "done"

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
State a concrete claim
↓2
Collect matching evidence
↓3
Review independently
↓4
Report limits and outcome

02 / ArchitectureResponsibility boundariesBoundary 1
ImplementationBoundary 2
Independent checksBoundary 3
Evidence reportConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
 you are about to report that something works, reviewing
another agent's output, closing a loop, or deciding whether a task is done.

This is the most important document in 
docs/orchestration/
. Everything else
scales work out; this is what stops the scaled-out work from being confidently
wrong.

1. The rule

Never assert that something works. Show the output that proves it.

"The tests pass" is a claim. This is evidence:

$ swift test
Test Suite 'All tests' passed at 2026-07-27 14:02:11.
     Executed 47 tests, with 0 failures (0 unexpected) in 2.314 seconds

The difference is not stylistic. An agent that has written code has a strong
prior that the code is correct — it wrote it 
intending
 to be correct, and it
reads its own output through that intent. Requiring pasted command output
replaces that prior with a fact.

2. Every claim carries a label

Each factual claim in a report falls into exactly one bucket, and the report
says which:

Label

Means

Requires

VERIFIED

A command was run; this is its real output

The command and its output, verbatim

INSPECTED

The code was read and reasoned about

The file:line that was read

UNVERIFIED

Could not be checked

The reason (no scheme, no simulator, no network)

A report with zero VERIFIED claims and no explanation of why is a failed report,
regardless of how confident it sounds.

UNVERIFIED is a legitimate, useful result.
 "I could not build this — there is
no Xcode on this machine, so the isolation fix is INSPECTED only" is honest and
actionable. Quietly implying it built is not.

3. What counts as evidence

Ordered by strength:

Test output
 — the suite ran, with counts and pass/fail state.
Build output
 — it compiles, with the exact command shown.
Command output
 — a lint run, a script, a grep whose emptiness is the point.
A screenshot
 — for UI work, the actual rendered result.
A file diff
 — what changed, when the change itself is the deliverable.
A file:line citation
 — for claims about what code does.

What does 
not
 count:

"It should work now."
"The change is correct."
"I verified the logic."
A summary of what a command would print.
A test you wrote but did not run.

Evidence for iOS specifically

# Build — SPM
swift build 2>&1 | tail -40

# Build — Xcode. List schemes first; never guess one.
xcodebuild -list -project MyApp.xcodeproj
xcodebuild build -scheme "MyApp" \
  -destination 'platform=iOS Simulator,name=iPhone 16' 2>&1 | tail -40

# Tests
swift test 2>&1 | tail -60
xcodebuild test -scheme "MyApp" \
  -destination 'platform=iOS Simulator,name=iPhone 16' 2>&1 | tail -60

# Lint
swiftlint lint --quiet
swiftformat --lint .

# Rule checks whose empty output IS the evidence
grep -rn "DispatchQueue.main.async" Sources/          # expect: nothing
grep -rn "APIClient\|URLSession" Sources/Presentation/ # expect: nothing

When a grep is the check, 
show that it returned nothing
. An empty result you
did not display is indistinguishable from a check you did not run.

When you genuinely cannot build

Common on Linux CI, in containers without Xcode, or in a docs-only repository.
Say it once, plainly, and downgrade the affected claims:

UNVERIFIED — no Xcode toolchain in this environment (`xcodebuild: command not
found`). All Swift samples are INSPECTED against the framework docs in
docs/frameworks/, not compiled. A human should build before merging.

Then verify what you 
can
: markdown structure, cross-references, script syntax
(
bash -n
), JSON/YAML validity. Partial verification honestly labelled beats
none.

4. Separation of duties

The agent that did the work does not decide whether the work is done.

Setup

Trust level

Use when

Worker self-checks

Low

Trivial, reversible changes

Worker self-checks with pasted output

Medium

Most routine work

Independent swift-reviewer subagent

High

Anything shipping, anything you will not manually read

Reviewer + deterministic hooks/CI

Highest

Rules that must never regress

The reviewer must:
- Have 
no write tools
 — so it cannot fix what it should report.
- Start 
cold
 — so it evaluates the diff, not the author's intent.
- Return a 
verdict plus evidence
, not a narrative.

worker    -> writes the change
reviewer  -> cold context, read + Bash only, runs the build and tests
           -> VERDICT: pass | pass-with-findings | fail  + real output
main agent -> routes findings back, or accepts

Three layers, cheapest first

Hooks
 — deterministic, run automatically, no model judgment. Formatting,
   forbidden patterns, mirror sync. See 
../../.claude/settings.json
 and
   
templates/hooks/
.
CI
 — deterministic, runs on push. Build, tests, repo consistency.
Reviewer subagent
 — model judgment for what rules cannot express:
   is this correct, does it match intent, is the test meaningful.

Never use a reviewer subagent for something a hook can decide. A hook is free,
instant, and cannot be talked out of its opinion.

5. The report format

Every agent in this repository returns this shape:

VERDICT: <done | blocked | partial>

EVIDENCE
$ <command>
<real output>

$ <command>
<real output>

WHAT CHANGED
- path/to/File.swift:88 — <what and why>

NOT VERIFIED
- <claim> — <why it could not be checked>

FOLLOW-UPS
- <anything deliberately left, so nobody assumes it is covered>

NOT VERIFIED
 is not optional. An empty section is fine; omitting the section
suggests everything was verified, which is rarely true.

6. Reporting failure

Report outcomes faithfully. Specifically:

If tests fail, say so and paste the failure.
If you skipped a step, say which and why.
If you could not reproduce a bug, say that — do not ship a speculative fix as
  a confirmed one.
If part of the scope is blocked, finish everything else in full and state
  exactly what you left out.

A partial result honestly labelled is more useful than a complete-looking result
that is wrong, because the human can act on the first and will be misled by the
second.

7. Anti-patterns

# 1. Asserting without evidence.
"Fixed — tests pass now."
-> Paste the output.

# 2. Reporting a test you wrote but never ran.
"Added a test covering the cancellation path."
-> Did it pass? Did it fail before the fix?

# 3. Implying a build you could not run.
"The code compiles cleanly."   (on a box with no Xcode)
-> UNVERIFIED, and say why.

# 4. A grep check with no output shown.
"Confirmed no DispatchQueue usage remains."
-> Show the empty grep.

# 5. The author reviewing their own work and passing it.
-> Cold reviewer, no write tools.

# 6. Burying a failure in a success summary.
"Done. (One test is still red but it seems unrelated.)"
-> Lead with the failure. "Seems unrelated" needs evidence too.

# 7. Predicting a pending subagent's result.
"The reviewer will confirm this is fine."
-> It has not reported. Say it is still running.

# 8. A green check achieved by weakening the check.
-> Deleting a test, widening a catch, or loosening an assertion is not a pass.

Checklist before reporting "done"

[ ] The final check's real output is pasted, not summarized.
[ ] Every claim is labelled VERIFIED, INSPECTED, or UNVERIFIED.
[ ] Anything unverifiable in this environment says so, with the reason.
[ ] The verifier is not the author, for anything that ships.
[ ] Failures are stated plainly, not softened.
[ ] Skipped or out-of-scope work is listed explicitly.
[ ] No test was deleted, skipped, or loosened to reach green.

---

# iOS Platform Guide
https://nagarjuna2997.github.io/ios-agent-skill/guides/platforms-ios.html

Platforms · Reference guideiOS Platform GuideRepository guidance for iOS. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

App Lifecycle

UIApplicationDelegate (UIKit)
SceneDelegate (UIKit Multi-Window)
SwiftUI App Lifecycle

Background Tasks

BGTaskScheduler

Deep Linking

Universal Links
Custom URL Schemes

Share Extensions
HealthKit Basics
Core Haptics
Document-Based Apps
Key iOS Capabilities Summary

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Handle app lifecycle
↓2
Route a user action
↓3
Perform platform work
↓4
Restore meaningful state

02 / ArchitectureResponsibility boundariesBoundary 1
App lifecycleBoundary 2
Feature servicesBoundary 3
System capabilitiesConnected responsibilities, not a required class hierarchy or an execution trace.
App Lifecycle

UIApplicationDelegate (UIKit)

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        // Configure services, SDKs, appearance
        return true
    }

    func application(
        _ application: UIApplication,
        configurationForConnecting connectingSceneSession: UISceneSession,
        options: UIScene.ConnectionOptions
    ) -> UISceneConfiguration {
        UISceneConfiguration(name: "Default", sessionRole: connectingSceneSession.role)
    }
}

SceneDelegate (UIKit Multi-Window)

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
        guard let windowScene = scene as? UIWindowScene else { return }
        window = UIWindow(windowScene: windowScene)
        window?.rootViewController = MainViewController()
        window?.makeKeyAndVisible()

        // Handle incoming URL
        if let urlContext = options.urlContexts.first {
            handleDeepLink(urlContext.url)
        }
    }

    func sceneDidBecomeActive(_ scene: UIScene) { }
    func sceneWillResignActive(_ scene: UIScene) { }
    func sceneDidEnterBackground(_ scene: UIScene) { }
}

SwiftUI App Lifecycle

@main
struct MyApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    @Environment(\.scenePhase) private var scenePhase

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .onChange(of: scenePhase) { oldPhase, newPhase in
            switch newPhase {
            case .active:    print("App is active")
            case .inactive:  print("App is inactive")
            case .background:
                scheduleBackgroundTasks()
            @unknown default: break
            }
        }
    }
}

Background Tasks

BGTaskScheduler

// 1. Register in Info.plist under BGTaskSchedulerPermittedIdentifiers:
//    ["com.app.refresh", "com.app.processing"]

// 2. Register handlers at launch
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.app.refresh", using: nil) { task in
        self.handleAppRefresh(task: task as! BGAppRefreshTask)
    }
    BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.app.processing", using: nil) { task in
        self.handleProcessing(task: task as! BGProcessingTask)
    }
    return true
}

// 3. Schedule
func scheduleBackgroundTasks() {
    let refreshRequest = BGAppRefreshTaskRequest(identifier: "com.app.refresh")
    refreshRequest.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)

    let processingRequest = BGProcessingTaskRequest(identifier: "com.app.processing")
    processingRequest.requiresNetworkConnectivity = true
    processingRequest.requiresExternalPower = false

    try? BGTaskScheduler.shared.submit(refreshRequest)
    try? BGTaskScheduler.shared.submit(processingRequest)
}

// 4. Handle
func handleAppRefresh(task: BGAppRefreshTask) {
    scheduleBackgroundTasks() // Reschedule

    let operation = RefreshOperation()
    task.expirationHandler = { operation.cancel() }

    operation.completionBlock = {
        task.setTaskCompleted(success: !operation.isCancelled)
    }
    OperationQueue().addOperation(operation)
}

Deep Linking

Universal Links

// apple-app-site-association (hosted at https://example.com/.well-known/)
{
    "applinks": {
        "apps": [],
        "details": [
            {
                "appID": "TEAMID.com.example.app",
                "paths": ["/product/*", "/user/*"],
                "components": [
                    { "/": "/product/*", "comment": "Product pages" }
                ]
            }
        ]
    }
}

// SwiftUI handling
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onOpenURL { url in
                    DeepLinkRouter.shared.handle(url)
                }
        }
    }
}

// Router
@Observable
class DeepLinkRouter {
    static let shared = DeepLinkRouter()
    var selectedTab: Tab = .home
    var navigationPath = NavigationPath()

    func handle(_ url: URL) {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return }
        let pathComponents = components.path.split(separator: "/").map(String.init)

        switch pathComponents.first {
        case "product":
            if let id = pathComponents[safe: 1] {
                selectedTab = .shop
                navigationPath.append(Route.product(id: id))
            }
        case "user":
            if let id = pathComponents[safe: 1] {
                selectedTab = .profile
                navigationPath.append(Route.profile(id: id))
            }
        default: break
        }
    }
}

Custom URL Schemes

// Info.plist: CFBundleURLSchemes = ["myapp"]
// Usage: myapp://action/param

func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    guard let url = URLContexts.first?.url else { return }
    // Parse myapp://product/123
    if url.host == "product", let id = url.pathComponents[safe: 1] {
        navigateToProduct(id: id)
    }
}

Share Extensions

// ShareViewController.swift (in Share Extension target)
import UIKit
import Social

class ShareViewController: SLComposeServiceViewController {
    override func isContentValid() -> Bool {
        return !contentText.isEmpty
    }

    override func didSelectPost() {
        guard let items = extensionContext?.inputItems as? [NSExtensionItem] else {
            extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
            return
        }

        for item in items {
            for provider in item.attachments ?? [] {
                if provider.hasItemConformingToTypeIdentifier("public.url") {
                    provider.loadItem(forTypeIdentifier: "public.url") { [weak self] data, error in
                        if let url = data as? URL {
                            self?.saveSharedURL(url)
                        }
                        self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
                    }
                }
            }
        }
    }

    private func saveSharedURL(_ url: URL) {
        // Save to App Group shared container
        let defaults = UserDefaults(suiteName: "group.com.example.app")
        var urls = defaults?.stringArray(forKey: "sharedURLs") ?? []
        urls.append(url.absoluteString)
        defaults?.set(urls, forKey: "sharedURLs")
    }
}

HealthKit Basics

import HealthKit

class HealthKitManager {
    private let store = HKHealthStore()

    func requestAuthorization() async throws {
        guard HKHealthStore.isHealthDataAvailable() else {
            throw HealthError.notAvailable
        }

        let readTypes: Set<HKObjectType> = [
            HKQuantityType(.stepCount),
            HKQuantityType(.heartRate),
            HKCategoryType(.sleepAnalysis)
        ]
        let writeTypes: Set<HKSampleType> = [
            HKQuantityType(.stepCount)
        ]

        try await store.requestAuthorization(toShare: writeTypes, read: readTypes)
    }

    func fetchTodaySteps() async throws -> Double {
        let stepsType = HKQuantityType(.stepCount)
        let startOfDay = Calendar.current.startOfDay(for: .now)
        let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: .now)

        let descriptor = HKStatisticsQueryDescriptor(
            predicate: .init(quantityType: stepsType, predicate: predicate),
            options: .cumulativeSum
        )
        let result = try await descriptor.result(for: store)
        return result?.sumQuantity()?.doubleValue(for: .count()) ?? 0
    }
}

Core Haptics

import CoreHaptics

class HapticsManager {
    private var engine: CHHapticEngine?

    func prepareEngine() {
        guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return }
        engine = try? CHHapticEngine()
        try? engine?.start()

        engine?.resetHandler = { [weak self] in
            try? self?.engine?.start()
        }
    }

    func playSuccessPattern() {
        let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.5)
        let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0)

        let events = [
            CHHapticEvent(eventType: .hapticTransient, parameters: [intensity, sharpness], relativeTime: 0),
            CHHapticEvent(eventType: .hapticTransient, parameters: [intensity, sharpness], relativeTime: 0.15),
        ]

        let pattern = try? CHHapticPattern(events: events, parameters: [])
        let player = try? engine?.makePlayer(with: pattern!)
        try? player?.start(atTime: 0)
    }

    /// Simple feedback using UIKit
    static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle = .medium) {
        UIImpactFeedbackGenerator(style: style).impactOccurred()
    }

    static func notification(_ type: UINotificationFeedbackGenerator.FeedbackType) {
        UINotificationFeedbackGenerator().notificationOccurred(type)
    }
}

// SwiftUI usage
Button("Tap") { }
    .sensoryFeedback(.success, trigger: didSucceed)

Document-Based Apps

import SwiftUI
import UniformTypeIdentifiers

// Define document type
struct MarkdownDocument: FileDocument {
    static var readableContentTypes: [UTType] { [.plainText] }

    var text: String

    init(text: String = "") {
        self.text = text
    }

    init(configuration: ReadConfiguration) throws {
        guard let data = configuration.file.regularFileContents,
              let string = String(data: data, encoding: .utf8)
        else { throw CocoaError(.fileReadCorruptFile) }
        text = string
    }

    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        let data = Data(text.utf8)
        return .init(regularFileWithContents: data)
    }
}

// App entry point
@main
struct DocApp: App {
    var body: some Scene {
        DocumentGroup(newDocument: MarkdownDocument()) { file in
            TextEditor(text: file.$document.text)
                .font(.system(.body, design: .monospaced))
        }
    }
}

Key iOS Capabilities Summary

Feature

Framework

Min iOS

Background Tasks

BackgroundTasks

13.0

Universal Links

Associated Domains

9.0

HealthKit

HealthKit

8.0

Core Haptics

CoreHaptics

13.0

SwiftUI App Lifecycle

SwiftUI

14.0

Observation framework

Observation

17.0

Swift Testing

Testing

16.0+

Interactive Widgets

WidgetKit

17.0

---

# macOS Platform Guide
https://nagarjuna2997.github.io/ios-agent-skill/guides/platforms-macos.html

Platforms · Reference guidemacOS Platform GuideRepository guidance for macOS. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Menu Bar and NSMenu

Main Menu Customization
Menu Bar Extra (Status Bar App)

NSWindow Customization
Toolbar and Sidebar Patterns
Document-Based Apps
Sandboxing and Entitlements

Common Entitlements
Security-Scoped Bookmarks

AppKit-SwiftUI Interop

NSViewRepresentable
Hosting SwiftUI in AppKit

Settings / Preferences Window
Drag and Drop
macOS-Specific Patterns Summary

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Choose window structure
↓2
Route commands and documents
↓3
Respect sandbox boundaries
↓4
Test desktop interactions

02 / ArchitectureResponsibility boundariesBoundary 1
App and windowsBoundary 2
Commands and documentsBoundary 3
Sandboxed servicesConnected responsibilities, not a required class hierarchy or an execution trace.
Menu Bar and NSMenu

Main Menu Customization

// SwiftUI CommandMenu
@main
struct MyMacApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .commands {
            // Replace existing group
            CommandGroup(replacing: .newItem) {
                Button("New Document") { createDocument() }
                    .keyboardShortcut("n")
                Button("New from Template...") { showTemplates() }
                    .keyboardShortcut("n", modifiers: [.command, .shift])
            }

            // Add custom menu
            CommandMenu("Tools") {
                Button("Run Analysis") { runAnalysis() }
                    .keyboardShortcut("r", modifiers: [.command, .shift])
                Divider()
                Toggle("Auto-Save", isOn: $autoSave)
            }

            // Toolbar commands
            CommandGroup(after: .toolbar) {
                Button("Toggle Sidebar") { toggleSidebar() }
                    .keyboardShortcut("s", modifiers: [.command, .control])
            }
        }
    }
}

Menu Bar Extra (Status Bar App)

@main
struct StatusBarApp: App {
    var body: some Scene {
        MenuBarExtra("My App", systemImage: "star.fill") {
            VStack(spacing: 12) {
                Text("Status: Active")
                    .font(.headline)
                Divider()
                Button("Open Dashboard") { openDashboard() }
                    .keyboardShortcut("d")
                Button("Preferences...") { openPreferences() }
                    .keyboardShortcut(",")
                Divider()
                Button("Quit") { NSApplication.shared.terminate(nil) }
                    .keyboardShortcut("q")
            }
            .padding()
        }
        .menuBarExtraStyle(.window) // .menu for simple dropdown
    }
}

NSWindow Customization

// SwiftUI window styling
WindowGroup {
    ContentView()
}
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentSize)
.defaultSize(width: 800, height: 600)
.defaultPosition(.center)

// Full NSWindow control via NSViewRepresentable
struct WindowAccessor: NSViewRepresentable {
    func makeNSView(context: Context) -> NSView {
        let view = NSView()
        DispatchQueue.main.async {
            if let window = view.window {
                window.isMovableByWindowBackground = true
                window.titlebarAppearsTransparent = true
                window.titleVisibility = .hidden
                window.styleMask.insert(.fullSizeContentView)
                window.backgroundColor = .clear
                window.isOpaque = false

                // Custom traffic light position
                let buttons = [window.standardWindowButton(.closeButton),
                               window.standardWindowButton(.miniaturizeButton),
                               window.standardWindowButton(.zoomButton)]
                buttons.compactMap { $0 }.enumerated().forEach { i, button in
                    button.frame.origin = CGPoint(x: 12 + CGFloat(i) * 20, y: 12)
                }
            }
        }
        return view
    }

    func updateNSView(_ nsView: NSView, context: Context) {}
}

Toolbar and Sidebar Patterns

struct ContentView: View {
    @State private var selectedItem: SidebarItem? = .inbox
    @State private var columnVisibility = NavigationSplitViewVisibility.all

    var body: some View {
        NavigationSplitView(columnVisibility: $columnVisibility) {
            // Sidebar
            List(selection: $selectedItem) {
                Section("Favorites") {
                    Label("Inbox", systemImage: "tray")
                        .tag(SidebarItem.inbox)
                    Label("Sent", systemImage: "paperplane")
                        .tag(SidebarItem.sent)
                }
                Section("Folders") {
                    Label("Archive", systemImage: "archivebox")
                        .tag(SidebarItem.archive)
                }
            }
            .listStyle(.sidebar)
            .navigationSplitViewColumnWidth(min: 180, ideal: 220, max: 300)
        } content: {
            // Content list
            if let selectedItem {
                ItemListView(category: selectedItem)
            }
        } detail: {
            // Detail
            DetailView()
        }
        .navigationTitle("Mail")
        .toolbar {
            ToolbarItemGroup(placement: .primaryAction) {
                Button(action: compose) {
                    Label("Compose", systemImage: "square.and.pencil")
                }
            }
            ToolbarItem(placement: .navigation) {
                Button(action: toggleSidebar) {
                    Label("Sidebar", systemImage: "sidebar.leading")
                }
            }
        }
    }
}

Document-Based Apps

@main
struct TextEditorApp: App {
    var body: some Scene {
        DocumentGroup(newDocument: TextDocument()) { file in
            DocumentEditorView(document: file.$document)
        }
        .commands {
            TextFormattingCommands()
        }

        // Additional window types
        Window("Activity Log", id: "activity") {
            ActivityLogView()
        }
        .keyboardShortcut("0", modifiers: [.command, .shift])
        .defaultSize(width: 400, height: 300)
    }
}

// Reference-type document (for complex data)
@Observable
class ProjectDocument: ReferenceFileDocument {
    static var readableContentTypes: [UTType] { [.json] }

    var project: Project

    required init(configuration: ReadConfiguration) throws {
        guard let data = configuration.file.regularFileContents else {
            throw CocoaError(.fileReadCorruptFile)
        }
        project = try JSONDecoder().decode(Project.self, from: data)
    }

    func snapshot(contentType: UTType) throws -> Data {
        try JSONEncoder().encode(project)
    }

    func fileWrapper(snapshot: Data, configuration: WriteConfiguration) throws -> FileWrapper {
        FileWrapper(regularFileWithContents: snapshot)
    }
}

Sandboxing and Entitlements

Common Entitlements

<!-- App.entitlements -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>com.apple.security.app-sandbox</key>           <true/>
    <key>com.apple.security.files.user-selected.read-write</key> <true/>
    <key>com.apple.security.files.bookmarks.app-scope</key>      <true/>
    <key>com.apple.security.network.client</key>         <true/>
    <key>com.apple.security.network.server</key>         <true/>
    <key>com.apple.security.print</key>                  <true/>
</dict>
</plist>

Security-Scoped Bookmarks

// Persist access to user-selected files
func saveBookmark(for url: URL) throws {
    let bookmarkData = try url.bookmarkData(
        options: .withSecurityScope,
        includingResourceValuesForKeys: nil,
        relativeTo: nil
    )
    UserDefaults.standard.set(bookmarkData, forKey: "savedFile")
}

func resolveBookmark() throws -> URL {
    guard let data = UserDefaults.standard.data(forKey: "savedFile") else {
        throw AppError.noBookmark
    }
    var isStale = false
    let url = try URL(
        resolvingBookmarkData: data,
        options: .withSecurityScope,
        relativeTo: nil,
        bookmarkDataIsStale: &isStale
    )
    guard url.startAccessingSecurityScopedResource() else {
        throw AppError.accessDenied
    }
    // Remember to call url.stopAccessingSecurityScopedResource() when done
    return url
}

AppKit-SwiftUI Interop

NSViewRepresentable

struct WebView: NSViewRepresentable {
    let url: URL

    func makeNSView(context: Context) -> WKWebView {
        let webView = WKWebView()
        webView.navigationDelegate = context.coordinator
        return webView
    }

    func updateNSView(_ webView: WKWebView, context: Context) {
        webView.load(URLRequest(url: url))
    }

    func makeCoordinator() -> Coordinator { Coordinator() }

    class Coordinator: NSObject, WKNavigationDelegate {
        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            print("Page loaded")
        }
    }
}

// NSViewControllerRepresentable
struct LegacyEditorView: NSViewControllerRepresentable {
    func makeNSViewController(context: Context) -> EditorViewController {
        EditorViewController()
    }

    func updateNSViewController(_ controller: EditorViewController, context: Context) {}
}

Hosting SwiftUI in AppKit

let swiftUIView = SettingsContentView()
let hostingController = NSHostingController(rootView: swiftUIView)

// As a popover
let popover = NSPopover()
popover.contentViewController = hostingController
popover.behavior = .transient
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .maxY)

Settings / Preferences Window

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup { ContentView() }

        Settings {
            TabView {
                GeneralSettingsView()
                    .tabItem { Label("General", systemImage: "gear") }
                AppearanceSettingsView()
                    .tabItem { Label("Appearance", systemImage: "paintbrush") }
                AdvancedSettingsView()
                    .tabItem { Label("Advanced", systemImage: "gearshape.2") }
            }
            .frame(width: 450, height: 300)
        }
    }
}

struct GeneralSettingsView: View {
    @AppStorage("launchAtLogin") private var launchAtLogin = false
    @AppStorage("checkForUpdates") private var checkForUpdates = true

    var body: some View {
        Form {
            Toggle("Launch at Login", isOn: $launchAtLogin)
            Toggle("Check for Updates Automatically", isOn: $checkForUpdates)
            Picker("Default View", selection: $defaultView) {
                Text("List").tag(ViewMode.list)
                Text("Grid").tag(ViewMode.grid)
            }
        }
        .padding()
    }
}

Drag and Drop

struct DragDropView: View {
    @State private var items: [DragItem] = []
    @State private var isTargeted = false

    var body: some View {
        VStack {
            // Draggable items
            ForEach(items) { item in
                Text(item.name)
                    .draggable(item) // Requires Transferable conformance
            }

            // Drop target
            RoundedRectangle(cornerRadius: 12)
                .fill(isTargeted ? Color.accentColor.opacity(0.2) : Color.gray.opacity(0.1))
                .frame(height: 200)
                .overlay { Text("Drop files here") }
                .dropDestination(for: URL.self) { urls, location in
                    handleDrop(urls: urls)
                    return true
                } isTargeted: { targeted in
                    isTargeted = targeted
                }
        }
    }
}

// Transferable conformance
struct DragItem: Identifiable, Codable, Transferable {
    let id: UUID
    let name: String

    static var transferRepresentation: some TransferRepresentation {
        CodableRepresentation(contentType: .data)
        ProxyRepresentation(exporting: \.name) // Fallback as plain text
    }
}

// NSFilePromiseProvider for files (AppKit interop)
class FilePromiseProvider: NSFilePromiseProvider {
    override func fileType() -> String { UTType.png.identifier }

    override func operationQueue() -> OperationQueue { .main }

    override func provideFile(at url: URL) throws {
        let data = generateFileData()
        try data.write(to: url)
    }
}

macOS-Specific Patterns Summary

Feature

API

Notes

Menu Bar Extra

MenuBarExtra

SwiftUI-native in macOS 13+

Settings Window

Settings scene

Replaces NSPreferencesWindow

Sidebar

NavigationSplitView

3-column layout

Toolbar

.toolbar modifier

Placement: .primaryAction, .navigation

Drag & Drop

Transferable

SwiftUI-native; NSFilePromiseProvider for files

Entitlements

.entitlements file

Required for sandboxed apps

Window styling

.windowStyle

.hiddenTitleBar, .plain

Keyboard shortcuts

.keyboardShortcut

Global via CommandMenu

---

# tvOS Platform Guide
https://nagarjuna2997.github.io/ios-agent-skill/guides/platforms-tvos.html

Platforms · Reference guidetvOS Platform GuideRepository guidance for tvOS. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Focus Engine and Focusable Views

Understanding the Focus System
Focus Sections and Custom Navigation
Focus-Aware Styling

TVUIKit Components

Lockup Views (UIKit)
Monogram and Caption Button

Top Shelf Extensions

Static Top Shelf
Inset Top Shelf

Siri Remote Handling

Gesture Recognition
Game Controller Support

Media Playback on TV
Multi-User Support
tvOS Design Guidelines

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Build focusable content
↓2
Define focus behavior
↓3
Handle remote input
↓4
Test playback and navigation

02 / ArchitectureResponsibility boundariesBoundary 1
Focus systemBoundary 2
Content viewsBoundary 3
Remote actionsConnected responsibilities, not a required class hierarchy or an execution trace.
Focus Engine and Focusable Views

Understanding the Focus System

tvOS uses a focus-based navigation model. The Siri Remote trackpad moves focus between views; pressing the trackpad selects the focused item.

struct ContentGridView: View {
    @FocusState private var focusedItem: String?

    let items = ["Movies", "Shows", "Music", "Podcasts"]

    var body: some View {
        HStack(spacing: 40) {
            ForEach(items, id: \.self) { item in
                CardView(title: item)
                    .focusable()
                    .focused($focusedItem, equals: item)
                    .scaleEffect(focusedItem == item ? 1.1 : 1.0)
                    .shadow(radius: focusedItem == item ? 20 : 5)
                    .animation(.spring(duration: 0.3), value: focusedItem)
            }
        }
        .padding(60)
        .defaultFocus($focusedItem, "Movies")
    }
}

Focus Sections and Custom Navigation

struct CustomFocusView: View {
    @FocusState private var section: Section?

    enum Section: Hashable {
        case sidebar, content, detail
    }

    var body: some View {
        HStack {
            // Sidebar
            VStack {
                ForEach(menuItems) { item in
                    MenuButton(item: item)
                }
            }
            .focusSection()
            .focused($section, equals: .sidebar)

            // Content area
            LazyVGrid(columns: columns) {
                ForEach(contentItems) { item in
                    ContentCard(item: item)
                }
            }
            .focusSection()
            .focused($section, equals: .content)
        }
        .focusScope(namespace)
        .onMoveCommand { direction in
            handleDirectionalInput(direction)
        }
    }

    func handleDirectionalInput(_ direction: MoveCommandDirection) {
        switch direction {
        case .left:  section = .sidebar
        case .right: section = .content
        default: break
        }
    }
}

Focus-Aware Styling

struct FocusableCard: View {
    let title: String
    let imageURL: URL
    @Environment(\.isFocused) var isFocused

    var body: some View {
        VStack {
            AsyncImage(url: imageURL) { image in
                image.resizable().aspectRatio(contentMode: .fill)
            } placeholder: {
                Color.gray
            }
            .frame(width: 300, height: 170)
            .clipShape(RoundedRectangle(cornerRadius: 12))
            .shadow(color: .black.opacity(isFocused ? 0.4 : 0.1),
                    radius: isFocused ? 20 : 5,
                    y: isFocused ? 10 : 2)

            Text(title)
                .font(isFocused ? .headline : .subheadline)
                .foregroundStyle(isFocused ? .primary : .secondary)
        }
        .scaleEffect(isFocused ? 1.05 : 1.0)
        .animation(.easeInOut(duration: 0.2), value: isFocused)
    }
}

TVUIKit Components

Lockup Views (UIKit)

import TVUIKit

class PosterViewController: UIViewController {
    func createLockupView() -> TVLockupView {
        let lockup = TVLockupView()

        // Content image
        let imageView = UIImageView(image: UIImage(named: "poster"))
        imageView.contentMode = .scaleAspectFill
        imageView.clipsToBounds = true
        imageView.frame = CGRect(x: 0, y: 0, width: 240, height: 360)
        lockup.contentView.addSubview(imageView)

        // Header
        let headerLabel = UILabel()
        headerLabel.text = "NEW"
        headerLabel.font = .systemFont(ofSize: 16, weight: .bold)
        lockup.headerView = headerLabel

        // Footer
        let footerLabel = UILabel()
        footerLabel.text = "Movie Title"
        footerLabel.font = .systemFont(ofSize: 20)
        lockup.footerView = footerLabel

        lockup.contentSize = CGSize(width: 240, height: 360)
        return lockup
    }
}

Monogram and Caption Button

// Monogram view for user profiles
let monogram = TVMonogramView()
monogram.title = "John Doe"
monogram.subtitle = "Family Member"
monogram.image = UIImage(named: "profile")

// Caption button
let captionButton = TVCaptionButtonView()
captionButton.contentImage = UIImage(systemName: "play.fill")
captionButton.title = "Play"
captionButton.subtitle = "From Beginning"

Top Shelf Extensions

Static Top Shelf

import TVServices

class ContentProvider: TVTopShelfContentProvider {
    override func loadTopShelfContent() async -> TVTopShelfContent? {
        // Sectioned content
        var sections: [TVTopShelfItemCollection<TVTopShelfSectionedItem>] = []

        let continueWatching = TVTopShelfItemCollection<TVTopShelfSectionedItem>(items: await fetchContinueWatching())
        continueWatching.title = "Continue Watching"

        let recommended = TVTopShelfItemCollection<TVTopShelfSectionedItem>(items: await fetchRecommended())
        recommended.title = "Recommended for You"

        sections = [continueWatching, recommended]
        return TVTopShelfSectionedContent(sections: sections)
    }

    private func fetchContinueWatching() async -> [TVTopShelfSectionedItem] {
        // Fetch from your data source
        return movies.map { movie in
            let item = TVTopShelfSectionedItem(identifier: movie.id)
            item.title = movie.title
            item.setImageURL(movie.posterURL, for: .screenScale1x)
            item.setImageURL(movie.posterURL2x, for: .screenScale2x)
            item.playAction = TVTopShelfAction(url: URL(string: "myapp://play/\(movie.id)")!)
            item.displayAction = TVTopShelfAction(url: URL(string: "myapp://detail/\(movie.id)")!)
            return item
        }
    }
}

Inset Top Shelf

func loadTopShelfContent() async -> TVTopShelfContent? {
    let items: [TVTopShelfInsetItem] = await fetchFeatured().map { featured in
        let item = TVTopShelfInsetItem(identifier: featured.id)
        item.title = featured.title
        item.setImageURL(featured.wideImageURL, for: .screenScale1x)
        item.setImageURL(featured.wideImageURL2x, for: .screenScale2x)
        item.imageShape = .extraWide  // 16:9 aspect ratio
        item.playAction = TVTopShelfAction(url: URL(string: "myapp://play/\(featured.id)")!)
        return item
    }
    return TVTopShelfInsetContent(items: items)
}

Siri Remote Handling

Gesture Recognition

struct RemoteAwareView: View {
    @State private var position = CGPoint(x: 400, y: 300)

    var body: some View {
        Canvas { context, size in
            let rect = CGRect(x: position.x - 25, y: position.y - 25, width: 50, height: 50)
            context.fill(Circle().path(in: rect), with: .color(.blue))
        }
        .onPlayPauseCommand { togglePlayback() }
        .onExitCommand { handleBack() }
        .onMoveCommand { direction in
            withAnimation(.spring) {
                switch direction {
                case .up:    position.y -= 50
                case .down:  position.y += 50
                case .left:  position.x -= 50
                case .right: position.x += 50
                @unknown default: break
                }
            }
        }
    }
}

Game Controller Support

import GameController

class RemoteInputManager: ObservableObject {
    @Published var isConnected = false

    func setupGameController() {
        NotificationCenter.default.addObserver(
            self, selector: #selector(controllerConnected),
            name: .GCControllerDidConnect, object: nil
        )

        // Siri Remote as game controller
        GCController.startWirelessControllerDiscovery()
    }

    @objc func controllerConnected(_ notification: Notification) {
        guard let controller = notification.object as? GCController else { return }
        isConnected = true

        if let micro = controller.microGamepad {
            micro.dpad.valueChangedHandler = { pad, x, y in
                // Trackpad touch position (-1...1)
                print("Touch: \(x), \(y)")
            }
            micro.buttonA.pressedChangedHandler = { _, _, pressed in
                if pressed { self.handleSelect() }
            }
            micro.buttonMenu.pressedChangedHandler = { _, _, pressed in
                if pressed { self.handleMenu() }
            }
        }
    }
}

Media Playback on TV

import AVKit

struct PlayerView: UIViewControllerRepresentable {
    let mediaURL: URL

    func makeUIViewController(context: Context) -> AVPlayerViewController {
        let controller = AVPlayerViewController()
        let player = AVPlayer(url: mediaURL)
        controller.player = player

        // Configure for TV experience
        controller.showsPlaybackControls = true
        controller.allowsPictureInPicturePlayback = false
        controller.updatesNowPlayingInfoCenter = true
        controller.skippingBehavior = .skipItem
        controller.requiresLinearPlayback = false

        // Metadata
        let metadata = AVMutableMetadataItem()
        metadata.identifier = .commonIdentifierTitle
        metadata.value = "Movie Title" as NSString
        player.currentItem?.externalMetadata = [metadata]

        // Info panel customization
        controller.transportBarCustomMenuItems = [
            UIAction(title: "Audio", image: UIImage(systemName: "speaker.wave.3")) { _ in },
            UIAction(title: "Subtitles", image: UIImage(systemName: "captions.bubble")) { _ in }
        ]

        player.play()
        return controller
    }

    func updateUIViewController(_ controller: AVPlayerViewController, context: Context) {}
}

// Interstitial content (ads, recaps)
struct ContentWithInterstitials {
    func configureInterstitials(for player: AVPlayer) {
        let interstitialEvent = AVPlayerInterstitialEvent(
            primaryItem: player.currentItem!,
            time: CMTime(seconds: 300, preferredTimescale: 1)  // At 5 minutes
        )
        interstitialEvent.templateItems = [
            AVPlayerItem(url: URL(string: "https://example.com/ad.mp4")!)
        ]

        let controller = AVPlayerInterstitialEventController(primaryPlayer: player)
        controller.events = [interstitialEvent]
    }
}

Multi-User Support

import TVUIKit

class UserManager {
    func getCurrentUser() async -> TVUserManager.User? {
        // tvOS supports multiple user profiles
        let userManager = TVUserManager()

        return await withCheckedContinuation { continuation in
            userManager.presentProfilePreferencesPanel { result in
                switch result {
                case .success(let user):
                    continuation.resume(returning: user)
                case .failure:
                    continuation.resume(returning: nil)
                @unknown default:
                    continuation.resume(returning: nil)
                }
            }
        }
    }
}

// User-specific data isolation
struct UserProfileView: View {
    let userIdentifier: String

    var body: some View {
        // Load user-specific preferences and watch history
        VStack {
            Text("Welcome back")
                .font(.title)
            ContinueWatchingRow(userId: userIdentifier)
            RecommendationsRow(userId: userIdentifier)
        }
    }
}

tvOS Design Guidelines

Aspect

Recommendation

Viewing distance

Design for 10-foot experience (large text, images)

Safe area

Respect 60pt insets on all sides

Focus feedback

Always provide visual feedback for focus changes

Text size

Minimum 31pt for body text

Animations

Use spring animations for focus transitions

Navigation depth

Keep to 3-4 levels maximum

Loading states

Show placeholder content immediately

Background

Use layered images for parallax depth effect

Audio

Always provide descriptive audio option

Top Shelf

Update content regularly for discovery

---

# visionOS Platform Guide
https://nagarjuna2997.github.io/ios-agent-skill/guides/platforms-visionos.html

Platforms · Reference guidevisionOS Platform GuideRepository guidance for visionOS. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

WindowGroup, ImmersiveSpace, and Volume

Scene Types
Opening and Dismissing Spaces

RealityView and RealityKit Entities

Custom Components and Systems

Model3D for 3D Model Display
Hand Tracking and Gestures

ARKit Hand Tracking (Advanced)

Eye Tracking
Spatial Audio
SharePlay in Spatial Apps
Ornaments and Attachments
Passthrough and Mixed Reality
visionOS Design Guidelines

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Choose window or space
↓2
Place spatial content
↓3
Handle supported interactions
↓4
Test comfort and transitions

02 / ArchitectureResponsibility boundariesBoundary 1
Window or immersive spaceBoundary 2
Spatial entitiesBoundary 3
Input and comfortConnected responsibilities, not a required class hierarchy or an execution trace.
WindowGroup, ImmersiveSpace, and Volume

Scene Types

@main
struct SpatialApp: App {
    @State private var appModel = AppModel()

    var body: some Scene {
        // Standard 2D window
        WindowGroup {
            ContentView()
                .environment(appModel)
        }

        // 3D volumetric window
        WindowGroup(id: "globe") {
            GlobeView()
                .environment(appModel)
        }
        .windowStyle(.volumetric)
        .defaultSize(width: 0.5, height: 0.5, depth: 0.5, in: .meters)

        // Full immersive space
        ImmersiveSpace(id: "solarSystem") {
            SolarSystemView()
                .environment(appModel)
        }
        .immersionStyle(selection: $appModel.immersionStyle, in: .mixed, .progressive, .full)
    }
}

@Observable
class AppModel {
    var immersionStyle: ImmersionStyle = .mixed
    var isImmersiveSpaceOpen = false
}

Opening and Dismissing Spaces

struct ContentView: View {
    @Environment(\.openWindow) private var openWindow
    @Environment(\.dismissWindow) private var dismissWindow
    @Environment(\.openImmersiveSpace) private var openImmersiveSpace
    @Environment(\.dismissImmersiveSpace) private var dismissImmersiveSpace
    @Environment(AppModel.self) private var appModel

    var body: some View {
        VStack(spacing: 20) {
            Button("Open Globe") {
                openWindow(id: "globe")
            }

            Button("Enter Solar System") {
                Task {
                    let result = await openImmersiveSpace(id: "solarSystem")
                    switch result {
                    case .opened:   appModel.isImmersiveSpaceOpen = true
                    case .error:    print("Failed to open immersive space")
                    case .userCancelled: break
                    @unknown default: break
                    }
                }
            }

            if appModel.isImmersiveSpaceOpen {
                Button("Exit Immersive") {
                    Task {
                        await dismissImmersiveSpace()
                        appModel.isImmersiveSpaceOpen = false
                    }
                }
            }
        }
    }
}

RealityView and RealityKit Entities

import RealityKit

struct ImmersiveSceneView: View {
    @State private var earthEntity: Entity?

    var body: some View {
        RealityView { content, attachments in
            // Load USDZ model
            if let earth = try? await Entity(named: "Earth", in: realityKitContentBundle) {
                earth.position = [0, 1.5, -2]
                earth.scale = [0.5, 0.5, 0.5]

                // Add rotation animation
                let rotation = FromToByAnimation<Transform>(
                    from: .init(rotation: simd_quatf(angle: 0, axis: [0, 1, 0])),
                    to: .init(rotation: simd_quatf(angle: .pi * 2, axis: [0, 1, 0])),
                    duration: 30,
                    bindTarget: .transform
                )
                let resource = try! AnimationResource.generate(with: rotation)
                earth.playAnimation(resource, transitionDuration: 0, startsPaused: false)

                content.add(earth)
                earthEntity = earth
            }

            // Add lighting
            let light = PointLight()
            light.light.intensity = 50000
            light.position = [2, 3, 0]
            content.add(light)

            // Add SwiftUI attachment
            if let panel = attachments.entity(for: "infoPanel") {
                panel.position = [0.5, 1.5, -1.5]
                content.add(panel)
            }
        } update: { content, attachments in
            // Update entities when state changes
        } attachments: {
            Attachment(id: "infoPanel") {
                InfoPanelView()
                    .frame(width: 400, height: 300)
                    .glassBackgroundEffect()
            }
        }
    }
}

Custom Components and Systems

import RealityKit

// Custom component
struct SpinComponent: Component {
    var speed: Float = 1.0
    var axis: SIMD3<Float> = [0, 1, 0]
}

// System to process spinning entities
struct SpinSystem: System {
    static let query = EntityQuery(where: .has(SpinComponent.self))

    init(scene: RealityKit.Scene) {}

    func update(context: SceneUpdateContext) {
        for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) {
            guard let spin = entity.components[SpinComponent.self] else { continue }
            let angle = spin.speed * Float(context.deltaTime)
            entity.transform.rotation *= simd_quatf(angle: angle, axis: spin.axis)
        }
    }
}

// Register at app launch
struct SpatialApp: App {
    init() {
        SpinComponent.registerComponent()
        SpinSystem.registerSystem()
    }
}

Model3D for 3D Model Display

import RealityKit

struct ModelShowcaseView: View {
    @State private var selectedModel = "Shoe"

    var body: some View {
        VStack {
            // Simple model display
            Model3D(named: selectedModel, bundle: realityKitContentBundle) { model in
                model
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(depth: 200)
            } placeholder: {
                ProgressView()
            }
            .frame(width: 300, height: 300)
            .dragRotation(pitchLimit: .degrees(45))

            // Model picker
            Picker("Model", selection: $selectedModel) {
                Text("Shoe").tag("Shoe")
                Text("Chair").tag("Chair")
                Text("Globe").tag("Globe")
            }
            .pickerStyle(.segmented)
        }
    }
}

// Model from URL
struct RemoteModelView: View {
    var body: some View {
        Model3D(url: URL(string: "https://example.com/model.usdz")!) { phase in
            switch phase {
            case .empty:
                ProgressView()
            case .success(let model):
                model.resizable().aspectRatio(contentMode: .fit)
            case .failure(let error):
                Text("Failed: \(error.localizedDescription)")
            @unknown default:
                EmptyView()
            }
        }
    }
}

Hand Tracking and Gestures

import RealityKit

// Standard gestures on entities
struct InteractiveSceneView: View {
    @State private var selectedEntity: Entity?

    var body: some View {
        RealityView { content in
            let sphere = ModelEntity(
                mesh: .generateSphere(radius: 0.1),
                materials: [SimpleMaterial(color: .blue, isMetallic: true)]
            )
            sphere.position = [0, 1.5, -1]
            sphere.generateCollisionShapes(recursive: false)
            sphere.components.set(InputTargetComponent())
            sphere.components.set(HoverEffectComponent())
            content.add(sphere)
        }
        .gesture(
            TapGesture()
                .targetedToAnyEntity()
                .onEnded { value in
                    selectedEntity = value.entity
                }
        )
        .gesture(
            DragGesture()
                .targetedToAnyEntity()
                .onChanged { value in
                    value.entity.position = value.convert(value.location3D, from: .local, to: value.entity.parent!)
                }
        )
        .gesture(
            MagnifyGesture()
                .targetedToAnyEntity()
                .onChanged { value in
                    let scale = Float(value.magnification)
                    value.entity.scale = [scale, scale, scale]
                }
        )
        .gesture(
            RotateGesture3D()
                .targetedToAnyEntity()
                .onChanged { value in
                    let rotation = value.rotation
                    value.entity.orientation = simd_quatf(rotation)
                }
        )
    }
}

ARKit Hand Tracking (Advanced)

import ARKit

@Observable
class HandTrackingManager {
    let session = ARKitSession()
    let handTracking = HandTrackingProvider()

    var leftHandPosition: SIMD3<Float>?
    var rightHandPosition: SIMD3<Float>?

    func startTracking() async {
        guard HandTrackingProvider.isSupported else { return }
        try? await session.run([handTracking])

        for await update in handTracking.anchorUpdates {
            let anchor = update.anchor
            guard anchor.isTracked else { continue }

            let indexTip = anchor.handSkeleton?.joint(.indexFingerTip)
            guard let tipTransform = indexTip, tipTransform.isTracked else { continue }

            let position = (anchor.originFromAnchorTransform * tipTransform.anchorFromJointTransform).columns.3
            let pos = SIMD3<Float>(position.x, position.y, position.z)

            switch anchor.chirality {
            case .left:  leftHandPosition = pos
            case .right: rightHandPosition = pos
            }
        }
    }
}

Eye Tracking

// Eye tracking requires user permission and entitlement
// com.apple.developer.arkit.eye-tracking

struct EyeTrackingView: View {
    @State private var hoveredItem: String?

    var body: some View {
        HStack(spacing: 40) {
            ForEach(["Photos", "Videos", "Music"], id: \.self) { item in
                Text(item)
                    .font(.title)
                    .padding(30)
                    .background(
                        RoundedRectangle(cornerRadius: 16)
                            .fill(hoveredItem == item ? .blue.opacity(0.3) : .clear)
                    )
                    .hoverEffect(.highlight) // System hover effect
                    .onHover { isHovering in
                        hoveredItem = isHovering ? item : nil
                    }
            }
        }
    }
}

// Custom hover effects
struct CustomHoverView: View {
    var body: some View {
        Text("Look at me")
            .padding()
            .hoverEffect { effect, isActive, _ in
                effect
                    .scaleEffect(isActive ? 1.1 : 1.0)
                    .animation(.spring(duration: 0.3), value: isActive)
            }
    }
}

Spatial Audio

import RealityKit

struct SpatialAudioScene: View {
    var body: some View {
        RealityView { content in
            // Create audio source entity
            let audioEntity = Entity()
            audioEntity.position = [2, 1.5, -3]

            // Load and configure spatial audio
            let resource = try! AudioFileResource.load(
                named: "ambience.wav",
                configuration: .init(
                    loadingStrategy: .preload,
                    shouldLoop: true
                )
            )

            let audioController = audioEntity.prepareAudio(resource)
            audioController.gain = -10 // dB
            audioController.play()

            // Spatial audio is automatic — position in 3D space determines directionality
            content.add(audioEntity)

            // Ambient audio (non-spatial, everywhere)
            let ambientEntity = Entity()
            ambientEntity.spatialAudio = SpatialAudioComponent(directivity: .beam(focus: 0))
            content.add(ambientEntity)
        }
    }
}

SharePlay in Spatial Apps

import GroupActivities

struct WatchTogetherActivity: GroupActivity {
    static let activityIdentifier = "com.app.watchtogether"

    var metadata: GroupActivityMetadata {
        var meta = GroupActivityMetadata()
        meta.title = "Watch Together"
        meta.type = .watchTogether
        meta.supportsContinuationOnTV = true
        return meta
    }
}

@Observable
class SharePlayManager {
    var session: GroupSession<WatchTogetherActivity>?

    func startSharing() async {
        let activity = WatchTogetherActivity()
        let result = await activity.prepareForActivation()

        switch result {
        case .activationPreferred:
            _ = try? await activity.activate()
        case .activationDisabled:
            break
        default: break
        }
    }

    func configureSession() async {
        for await session in WatchTogetherActivity.sessions() {
            self.session = session
            session.join()

            // Spatial Persona template
            let template = SpatialTemplate(
                elements: [
                    .seat(position: .app.offsetBy(x: -0.5)),
                    .seat(position: .app.offsetBy(x: 0.5))
                ]
            )
            session.spatialTemplatePreference = .init(template)

            // Receive messages
            for await message in session.messenger.messages(of: SyncMessage.self) {
                handleMessage(message)
            }
        }
    }
}

Ornaments and Attachments

struct OrnamentedWindow: View {
    @State private var showControls = true

    var body: some View {
        VStack {
            Text("Main Content")
                .font(.largeTitle)
        }
        .frame(width: 600, height: 400)
        .ornament(
            visibility: showControls ? .visible : .hidden,
            attachmentAnchor: .scene(.bottom)
        ) {
            HStack(spacing: 20) {
                Button(action: {}) {
                    Label("Previous", systemImage: "backward.fill")
                }
                Button(action: {}) {
                    Label("Play", systemImage: "play.fill")
                }
                Button(action: {}) {
                    Label("Next", systemImage: "forward.fill")
                }
            }
            .padding()
            .glassBackgroundEffect()
        }
        .ornament(attachmentAnchor: .scene(.trailing)) {
            VStack {
                Button(action: {}) { Image(systemName: "heart") }
                Button(action: {}) { Image(systemName: "square.and.arrow.up") }
                Button(action: {}) { Image(systemName: "info.circle") }
            }
            .padding()
            .glassBackgroundEffect()
        }
    }
}

Passthrough and Mixed Reality

struct MixedRealityView: View {
    @State private var showPassthrough = true

    var body: some View {
        RealityView { content in
            // Content appears in the user's real environment
            let anchor = AnchorEntity(.plane(.horizontal, classification: .table, minimumBounds: [0.3, 0.3]))

            let box = ModelEntity(
                mesh: .generateBox(size: 0.2, cornerRadius: 0.02),
                materials: [SimpleMaterial(color: .blue, isMetallic: true)]
            )
            box.position.y = 0.1
            box.generateCollisionShapes(recursive: false)
            box.components.set(InputTargetComponent())
            anchor.addChild(box)
            content.add(anchor)

            // Occlusion — virtual objects hidden behind real objects
            let occlusionMaterial = OcclusionMaterial()
            let floor = ModelEntity(mesh: .generatePlane(width: 5, depth: 5), materials: [occlusionMaterial])
            content.add(floor)
        }
        .upperLimbVisibility(showPassthrough ? .automatic : .hidden)
    }
}

// World sensing
struct WorldSensingView: View {
    var body: some View {
        RealityView { content in
            // Requires WorldSensing entitlement
            let arSession = ARKitSession()
            let worldTracking = WorldTrackingProvider()
            let planeDetection = PlaneDetectionProvider()

            try? await arSession.run([worldTracking, planeDetection])

            for await update in planeDetection.anchorUpdates {
                let plane = update.anchor
                // Place content on detected surfaces
            }
        }
    }
}

visionOS Design Guidelines

Aspect

Recommendation

Window placement

Let system place windows; user repositions

Glass material

Use .glassBackgroundEffect() for window backgrounds

Depth

Use subtle depth; avoid extreme z-positioning

Ergonomics

Place content in comfortable viewing range (1-3m)

Eye comfort

Avoid rapid movement; use gentle animations

Hover effects

Always provide .hoverEffect for interactive elements

Ornaments

Use for controls related to window content

Immersion

Start with .mixed; let user choose deeper immersion

Hand gestures

Support standard tap, drag, magnify, rotate

Spatial audio

Position audio sources to match visual positions

---

# watchOS Platform Guide
https://nagarjuna2997.github.io/ios-agent-skill/guides/platforms-watchos.html

Platforms · Reference guidewatchOS Platform GuideRepository guidance for watchOS. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

WatchKit App Structure

SwiftUI App Lifecycle (watchOS 7+)
Navigation Patterns

Complications (WidgetKit on watchOS)
Watch Connectivity (WCSession)
HealthKit Workouts
Digital Crown
Always-On Display
Background App Refresh
watchOS Design Guidelines

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Define a short watch task
↓2
Coordinate local and phone data
↓3
Handle background transitions
↓4
Test accessible watch UI

02 / ArchitectureResponsibility boundariesBoundary 1
Watch interfaceBoundary 2
Watch servicesBoundary 3
Phone connectivityConnected responsibilities, not a required class hierarchy or an execution trace.
WatchKit App Structure

SwiftUI App Lifecycle (watchOS 7+)

@main
struct MyWatchApp: App {
    @WKApplicationDelegateAdaptor(AppDelegate.self) var delegate

    var body: some Scene {
        WindowGroup {
            NavigationStack {
                ContentView()
            }
        }
    }
}

class AppDelegate: NSObject, WKApplicationDelegate {
    func applicationDidFinishLaunching() {
        // Setup HealthKit, WCSession, etc.
        WatchConnectivityManager.shared.activate()
    }

    func applicationDidBecomeActive() { }

    func applicationWillResignActive() { }

    func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
        for task in backgroundTasks {
            switch task {
            case let refreshTask as WKApplicationRefreshBackgroundTask:
                scheduleNextRefresh()
                refreshTask.setTaskCompletedWithSnapshot(false)
            case let snapshotTask as WKSnapshotRefreshBackgroundTask:
                snapshotTask.setTaskCompleted(restoredDefaultState: true, estimatedSnapshotExpiration: .distantFuture, userInfo: nil)
            case let urlTask as WKURLSessionRefreshBackgroundTask:
                urlTask.setTaskCompletedWithSnapshot(false)
            default:
                task.setTaskCompletedWithSnapshot(false)
            }
        }
    }
}

Navigation Patterns

struct ContentView: View {
    var body: some View {
        NavigationStack {
            List {
                NavigationLink("Workout") { WorkoutView() }
                NavigationLink("Activity") { ActivityView() }
                NavigationLink("Settings") { SettingsView() }
            }
            .navigationTitle("My App")
        }
    }
}

// Tab-based layout
struct TabContentView: View {
    var body: some View {
        TabView {
            DashboardView()
            WorkoutListView()
            SettingsView()
        }
        .tabViewStyle(.verticalPage)
    }
}

Complications (WidgetKit on watchOS)

import WidgetKit
import SwiftUI

struct StepsWidget: Widget {
    let kind = "StepsWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: StepsProvider()) { entry in
            StepsWidgetView(entry: entry)
        }
        .configurationDisplayName("Steps")
        .description("Track your daily steps.")
        .supportedFamilies([
            .accessoryCircular,
            .accessoryRectangular,
            .accessoryInline,
            .accessoryCorner
        ])
    }
}

struct StepsEntry: TimelineEntry {
    let date: Date
    let steps: Int
    let goal: Int
}

struct StepsProvider: TimelineProvider {
    func placeholder(in context: Context) -> StepsEntry {
        StepsEntry(date: .now, steps: 5000, goal: 10000)
    }

    func getSnapshot(in context: Context, completion: @escaping (StepsEntry) -> Void) {
        completion(StepsEntry(date: .now, steps: 7500, goal: 10000))
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<StepsEntry>) -> Void) {
        Task {
            let steps = await HealthKitManager.shared.fetchTodaySteps()
            let entry = StepsEntry(date: .now, steps: Int(steps), goal: 10000)
            let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: .now)!
            let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
            completion(timeline)
        }
    }
}

struct StepsWidgetView: View {
    let entry: StepsEntry
    @Environment(\.widgetFamily) var family

    var body: some View {
        switch family {
        case .accessoryCircular:
            Gauge(value: Double(entry.steps), in: 0...Double(entry.goal)) {
                Text("\(entry.steps)")
            }
            .gaugeStyle(.accessoryCircularCapacity)

        case .accessoryRectangular:
            VStack(alignment: .leading) {
                Text("Steps")
                    .font(.headline)
                    .widgetAccentable()
                Text("\(entry.steps) / \(entry.goal)")
                    .font(.caption)
                ProgressView(value: Double(entry.steps), total: Double(entry.goal))
            }

        case .accessoryInline:
            Text("Steps: \(entry.steps)")

        default:
            Text("\(entry.steps)")
        }
    }
}

Watch Connectivity (WCSession)

import WatchConnectivity

class WatchConnectivityManager: NSObject, ObservableObject, WCSessionDelegate {
    static let shared = WatchConnectivityManager()

    @Published var receivedMessage: [String: Any] = [:]

    func activate() {
        guard WCSession.isSupported() else { return }
        WCSession.default.delegate = self
        WCSession.default.activate()
    }

    // MARK: - Send data (phone <-> watch)

    /// Immediate messaging (both apps must be reachable)
    func sendMessage(_ message: [String: Any]) {
        guard WCSession.default.isReachable else { return }
        WCSession.default.sendMessage(message, replyHandler: { reply in
            print("Reply: \(reply)")
        }, errorHandler: { error in
            print("Error: \(error)")
        })
    }

    /// Background transfer - application context (latest state)
    func updateContext(_ context: [String: Any]) {
        try? WCSession.default.updateApplicationContext(context)
    }

    /// Background transfer - user info queue (guaranteed delivery)
    func transferUserInfo(_ info: [String: Any]) {
        WCSession.default.transferUserInfo(info)
    }

    /// File transfer
    func transferFile(_ url: URL, metadata: [String: Any]? = nil) {
        WCSession.default.transferFile(url, metadata: metadata)
    }

    // MARK: - WCSessionDelegate

    func session(_ session: WCSession, activationDidCompleteWith state: WCSessionActivationState, error: Error?) {
        print("WCSession activated: \(state.rawValue)")
    }

    func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
        DispatchQueue.main.async { self.receivedMessage = message }
    }

    func session(_ session: WCSession, didReceiveApplicationContext context: [String: Any]) {
        DispatchQueue.main.async { self.receivedMessage = context }
    }

    // iOS only
    #if os(iOS)
    func sessionDidBecomeInactive(_ session: WCSession) { }
    func sessionDidDeactivate(_ session: WCSession) {
        WCSession.default.activate()
    }
    #endif
}

HealthKit Workouts

import HealthKit

@Observable
class WorkoutManager: NSObject, HKWorkoutSessionDelegate, HKLiveWorkoutBuilderDelegate {
    let store = HKHealthStore()
    var session: HKWorkoutSession?
    var builder: HKLiveWorkoutBuilder?

    var heartRate: Double = 0
    var activeCalories: Double = 0
    var elapsedTime: TimeInterval = 0
    var isActive = false

    func startWorkout(type: HKWorkoutActivityType) async throws {
        let config = HKWorkoutConfiguration()
        config.activityType = type
        config.locationType = .outdoor

        session = try HKWorkoutSession(healthStore: store, configuration: config)
        builder = session?.associatedWorkoutBuilder()

        session?.delegate = self
        builder?.delegate = self
        builder?.dataSource = HKLiveWorkoutDataSource(healthStore: store, workoutConfiguration: config)

        let start = Date()
        session?.startActivity(with: start)
        try await builder?.beginCollection(at: start)
        isActive = true
    }

    func pause() { session?.pause() }
    func resume() { session?.resume() }

    func endWorkout() async throws {
        session?.end()
        try await builder?.endCollection(at: .now)
        try await builder?.finishWorkout()
        isActive = false
    }

    // MARK: - HKWorkoutSessionDelegate

    func workoutSession(_ session: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {
        DispatchQueue.main.async {
            self.isActive = toState == .running
        }
    }

    func workoutSession(_ session: HKWorkoutSession, didFailWithError error: Error) { }

    // MARK: - HKLiveWorkoutBuilderDelegate

    func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) { }

    func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
        for type in collectedTypes {
            guard let quantityType = type as? HKQuantityType else { continue }
            let stats = workoutBuilder.statistics(for: quantityType)

            DispatchQueue.main.async {
                switch quantityType {
                case HKQuantityType(.heartRate):
                    self.heartRate = stats?.mostRecentQuantity()?.doubleValue(for: .count().unitDivided(by: .minute())) ?? 0
                case HKQuantityType(.activeEnergyBurned):
                    self.activeCalories = stats?.sumQuantity()?.doubleValue(for: .kilocalorie()) ?? 0
                default: break
                }
            }
        }
    }
}

Digital Crown

struct CrownScrollView: View {
    @State private var crownValue: Double = 0
    @State private var isCrownIdle = true

    var body: some View {
        VStack {
            Text("Value: \(crownValue, specifier: "%.1f")")
                .font(.largeTitle)

            Gauge(value: crownValue, in: 0...100) {
                Text("Level")
            }
            .gaugeStyle(.accessoryLinearCapacity)
        }
        .focusable()
        .digitalCrownRotation(
            $crownValue,
            from: 0,
            through: 100,
            by: 1,
            sensitivity: .medium,
            isContinuous: false,
            isHapticFeedbackEnabled: true
        )
        .onChange(of: crownValue) {
            isCrownIdle = false
        }
    }
}

// Crown with detents
struct DetentCrownView: View {
    @State private var selectedIndex: Int = 0
    let options = ["Small", "Medium", "Large", "Extra Large"]

    var body: some View {
        Text(options[selectedIndex])
            .font(.title3)
            .focusable()
            .digitalCrownRotation(
                detent: $selectedIndex,
                from: 0,
                through: options.count - 1,
                by: 1,
                sensitivity: .low,
                isContinuous: false,
                isHapticFeedbackEnabled: true
            ) { event in
                // Detent feedback
            }
    }
}

Always-On Display

struct WorkoutActiveView: View {
    @Environment(\.isLuminanceReduced) var isLuminanceReduced

    let heartRate: Double
    let duration: TimeInterval

    var body: some View {
        VStack {
            if isLuminanceReduced {
                // Simplified always-on display
                TimelineView(.periodic(from: .now, by: 1)) { context in
                    Text(duration.formatted(.time(pattern: .hourMinuteSecond)))
                        .font(.title2)
                }
                Text("\(Int(heartRate)) BPM")
                    .foregroundStyle(.red)
            } else {
                // Full interactive display
                TimelineView(.periodic(from: .now, by: 1)) { context in
                    Text(duration.formatted(.time(pattern: .hourMinuteSecond)))
                        .font(.system(size: 40, weight: .bold, design: .rounded))
                }
                HStack {
                    Image(systemName: "heart.fill")
                        .foregroundStyle(.red)
                    Text("\(Int(heartRate)) BPM")
                }
                .font(.title3)
            }
        }
    }
}

Background App Refresh

// Schedule refresh
func scheduleNextRefresh() {
    WKExtension.shared().scheduleBackgroundRefresh(
        withPreferredDate: Date(timeIntervalSinceNow: 15 * 60),
        userInfo: nil
    ) { error in
        if let error { print("Scheduling failed: \(error)") }
    }
}

// Handle in AppDelegate.handle(_:) — see App Structure section above

// Background URLSession
func scheduleBackgroundDownload() {
    let config = URLSessionConfiguration.background(withIdentifier: "com.app.watch.background")
    config.isDiscretionary = false
    config.sessionSendsLaunchEvents = true

    let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
    let task = session.downloadTask(with: URL(string: "https://api.example.com/data")!)
    task.resume()
}

watchOS Design Guidelines

Aspect

Recommendation

Glance-ability

Show key info at a glance; minimize scrolling

Interactions

Keep to 2-3 taps maximum per task

Text input

Prefer voice, scribble, or preset options

Complications

Always provide at least one complication family

Haptics

Use WKInterfaceDevice.default().play(.success) for feedback

Layout

Use vertical stacks; avoid horizontal scrolling

Always-on

Reduce luminance, hide seconds, dim colors

---

# SwiftUI Animations
https://nagarjuna2997.github.io/ios-agent-skill/guides/swiftui-animations.html

Design and Motion · Reference guideSwiftUI AnimationsRepository guidance for SwiftUI Animation. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Implicit Animations (.animation modifier)
Explicit Animations (withAnimation)
Animation Types

Built-in Animations
Animation Modifiers

Transitions

Custom Transitions

matchedGeometryEffect
PhaseAnimator (iOS 17+)
KeyframeAnimator (iOS 17+)
.contentTransition() (iOS 16+)
.sensoryFeedback() (iOS 17+)
Symbol Effects (iOS 17+)
Transaction and Animation Completion

Transaction
Animation Completion (iOS 17+)

Practical Animation Patterns

Staggered List Animation
Loading Shimmer
Breathing / Pulsing Effect

Performance Tips
iOS 17/18 Advanced Visual Effects

visualEffect Modifier (iOS 17+)

Complete Parallax Scroll Example
Position-Based Rotation and Scale

scrollTransition (iOS 17+)

Complete Example: Cards That Scale Up as They Enter
Horizontal Scroll with Rotation

MeshGradient (iOS 18+)

Complete Animated Mesh Gradient Background
Mesh Gradient with Color Cycling

TextRenderer Protocol (iOS 18+)

TextRenderer Protocol Basics
Complete Custom Text Animation: Per-Character Fade-In
Typing Effect with TextRenderer
Wave Text Animation

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Identify state transition
↓2
Choose animation semantics
↓3
Apply a scoped animation
↓4
Respect reduced motion

02 / ArchitectureResponsibility boundariesBoundary 1
State changeBoundary 2
Animation transactionBoundary 3
View transitionConnected responsibilities, not a required class hierarchy or an execution trace.
Complete reference for implicit and explicit animations, transitions, matched geometry, phase animators, keyframe animators, and haptics.

Implicit Animations (.animation modifier)

Attach an animation to a view that triggers whenever a tracked value changes.

struct ImplicitExample: View {
    @State private var isExpanded = false

    var body: some View {
        VStack {
            RoundedRectangle(cornerRadius: isExpanded ? 20 : 50)
                .fill(.blue)
                .frame(
                    width: isExpanded ? 300 : 100,
                    height: isExpanded ? 200 : 100
                )
                .animation(.easeInOut(duration: 0.4), value: isExpanded)

            Button("Toggle") { isExpanded.toggle() }
        }
    }
}

Important:
 Always use the 
value:
 parameter version. The parameterless 
.animation(.easeInOut)
 is deprecated and applies to all state changes, causing unexpected behavior.

Explicit Animations (withAnimation)

Wrap state changes in 
withAnimation
 to animate all views affected by those changes.

struct ExplicitExample: View {
    @State private var offset: CGFloat = 0
    @State private var opacity: Double = 1
    @State private var scale: CGFloat = 1

    var body: some View {
        Circle()
            .fill(.blue)
            .frame(width: 80, height: 80)
            .offset(y: offset)
            .opacity(opacity)
            .scaleEffect(scale)

        Button("Animate") {
            withAnimation(.spring(duration: 0.6, bounce: 0.3)) {
                offset = offset == 0 ? -100 : 0
                opacity = opacity == 1 ? 0.5 : 1
                scale = scale == 1 ? 1.5 : 1
            }
        }
    }
}

// Async version
Button("Animate and Continue") {
    Task {
        await withAnimation(.easeInOut(duration: 0.5)) {
            showContent = true
        }.value
        // This runs after animation completes
        loadData()
    }
}

Animation Types

Built-in Animations

// Timing curves
.animation(.linear(duration: 0.3), value: trigger)
.animation(.easeIn(duration: 0.3), value: trigger)
.animation(.easeOut(duration: 0.3), value: trigger)
.animation(.easeInOut(duration: 0.3), value: trigger)

// Spring animations (iOS 17+ simplified)
.animation(.spring, value: trigger)                    // Default spring
.animation(.bouncy, value: trigger)                    // High bounce
.animation(.bouncy(duration: 0.5, extraBounce: 0.2), value: trigger)
.animation(.snappy, value: trigger)                    // Quick and snappy
.animation(.snappy(duration: 0.3, extraBounce: 0.1), value: trigger)
.animation(.smooth, value: trigger)                    // Smooth, no bounce
.animation(.smooth(duration: 0.4), value: trigger)

// Spring with full control
.animation(.spring(response: 0.5, dampingFraction: 0.7, blendDuration: 0), value: trigger)
.animation(.spring(duration: 0.5, bounce: 0.3), value: trigger)

// Interactive spring (no overshoot)
.animation(.interactiveSpring(response: 0.3, dampingFraction: 0.8), value: trigger)

// Custom timing curve
.animation(.timingCurve(0.2, 0.8, 0.2, 1.0, duration: 0.5), value: trigger)

Animation Modifiers

// Delay
.animation(.easeInOut(duration: 0.5).delay(0.2), value: trigger)

// Repeat
.animation(.easeInOut(duration: 1).repeatForever(autoreverses: true), value: trigger)
.animation(.linear(duration: 2).repeatCount(3, autoreverses: false), value: trigger)

// Speed
.animation(.spring.speed(2), value: trigger)

// Combine
.animation(
    .spring(duration: 0.6, bounce: 0.3)
    .delay(0.1)
    .speed(1.2),
    value: trigger
)

Transitions

Transitions define how a view appears and disappears when inserted/removed from the hierarchy.

struct TransitionExample: View {
    @State private var showDetail = false

    var body: some View {
        VStack {
            if showDetail {
                DetailCard()
                    .transition(.opacity)                    // Fade
                    .transition(.slide)                      // Slide from leading
                    .transition(.scale)                      // Scale from center
                    .transition(.scale(scale: 0.5, anchor: .bottom))
                    .transition(.move(edge: .bottom))        // Slide from edge
                    .transition(.push(from: .bottom))        // Push (iOS 16+)
                    .transition(.offset(x: 0, y: 200))

                    // Combined
                    .transition(.opacity.combined(with: .scale))
                    .transition(.move(edge: .bottom).combined(with: .opacity))

                    // Asymmetric (different for insert vs remove)
                    .transition(.asymmetric(
                        insertion: .push(from: .trailing),
                        removal: .push(from: .leading)
                    ))
            }

            Button("Toggle") {
                withAnimation(.spring(duration: 0.5, bounce: 0.3)) {
                    showDetail.toggle()
                }
            }
        }
    }
}

Custom Transitions

struct SlideAndFade: Transition {
    func body(content: Content, phase: TransitionPhase) -> some View {
        content
            .opacity(phase.isIdentity ? 1 : 0)
            .offset(y: phase.isIdentity ? 0 : 30)
            .scaleEffect(phase.isIdentity ? 1 : 0.95)
    }
}

extension AnyTransition {
    static var slideAndFade: AnyTransition {
        .modifier(
            active: SlideAndFadeModifier(opacity: 0, offset: 30),
            identity: SlideAndFadeModifier(opacity: 1, offset: 0)
        )
    }
}

struct SlideAndFadeModifier: ViewModifier {
    let opacity: Double
    let offset: CGFloat

    func body(content: Content) -> some View {
        content
            .opacity(opacity)
            .offset(y: offset)
    }
}

matchedGeometryEffect

Creates hero animations between views that share an identity.

struct HeroAnimation: View {
    @Namespace private var animation
    @State private var isExpanded = false
    @State private var selectedItem: Item?

    var body: some View {
        ZStack {
            if let item = selectedItem {
                // Expanded view
                VStack {
                    Image(item.imageName)
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .matchedGeometryEffect(id: "image-\(item.id)", in: animation)
                        .frame(height: 300)
                        .clipShape(RoundedRectangle(cornerRadius: 20))

                    Text(item.title)
                        .font(.title)
                        .matchedGeometryEffect(id: "title-\(item.id)", in: animation)

                    Text(item.description)
                        .padding()

                    Spacer()
                }
                .background(.background)
                .onTapGesture {
                    withAnimation(.spring(duration: 0.5, bounce: 0.2)) {
                        selectedItem = nil
                    }
                }
            } else {
                // Grid view
                ScrollView {
                    LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))]) {
                        ForEach(items) { item in
                            VStack {
                                Image(item.imageName)
                                    .resizable()
                                    .aspectRatio(contentMode: .fill)
                                    .matchedGeometryEffect(id: "image-\(item.id)", in: animation)
                                    .frame(height: 150)
                                    .clipShape(RoundedRectangle(cornerRadius: 12))

                                Text(item.title)
                                    .font(.headline)
                                    .matchedGeometryEffect(id: "title-\(item.id)", in: animation)
                            }
                            .onTapGesture {
                                withAnimation(.spring(duration: 0.5, bounce: 0.2)) {
                                    selectedItem = item
                                }
                            }
                        }
                    }
                    .padding()
                }
            }
        }
    }
}

Key rules:

- Both source and destination must be visible at transition time (use 
ZStack
 and conditional rendering).
- Use the same 
id
 and 
Namespace
 across both states.
- Only one view per 
id
 should be in the hierarchy at a time (use 
isSource: false
 if needed).

PhaseAnimator (iOS 17+)

Cycles through a sequence of phases, applying different modifiers at each phase.

// Continuous animation
PhaseAnimator([false, true]) { phase in
    Image(systemName: "heart.fill")
        .font(.largeTitle)
        .foregroundStyle(.red)
        .scaleEffect(phase ? 1.2 : 1.0)
        .opacity(phase ? 1.0 : 0.7)
} animation: { phase in
    phase ? .easeIn(duration: 0.3) : .easeOut(duration: 0.5)
}

// Multi-phase animation
enum PulsePhase: CaseIterable {
    case initial, grow, shrink, fade

    var scale: CGFloat {
        switch self {
        case .initial: 1.0
        case .grow: 1.3
        case .shrink: 0.9
        case .fade: 1.0
        }
    }

    var opacity: Double {
        switch self {
        case .initial: 1.0
        case .grow: 0.8
        case .shrink: 0.6
        case .fade: 1.0
        }
    }
}

PhaseAnimator(PulsePhase.allCases) { phase in
    Circle()
        .fill(.blue)
        .frame(width: 100, height: 100)
        .scaleEffect(phase.scale)
        .opacity(phase.opacity)
} animation: { phase in
    switch phase {
    case .initial: .spring(duration: 0.3)
    case .grow: .easeOut(duration: 0.4)
    case .shrink: .easeIn(duration: 0.2)
    case .fade: .easeInOut(duration: 0.3)
    }
}

// Trigger-based (animates once when trigger changes)
@State private var triggerCount = 0

PhaseAnimator([0.0, 1.0], trigger: triggerCount) { scale in
    Image(systemName: "checkmark.circle.fill")
        .font(.system(size: 60))
        .scaleEffect(scale == 0 ? 0.5 : 1.0)
        .opacity(scale)
} animation: { _ in
    .spring(duration: 0.5, bounce: 0.4)
}

Button("Complete") { triggerCount += 1 }

KeyframeAnimator (iOS 17+)

Define complex multi-property animations with precise timing using keyframe tracks.

struct BounceValues {
    var scale: CGFloat = 1.0
    var yOffset: CGFloat = 0
    var rotation: Angle = .zero
}

KeyframeAnimator(initialValue: BounceValues(), trigger: bounceCount) { values in
    Image(systemName: "star.fill")
        .font(.system(size: 60))
        .foregroundStyle(.yellow)
        .scaleEffect(values.scale)
        .offset(y: values.yOffset)
        .rotationEffect(values.rotation)
} keyframes: { _ in
    KeyframeTrack(\.scale) {
        SpringKeyframe(1.5, duration: 0.2)
        SpringKeyframe(0.8, duration: 0.15)
        SpringKeyframe(1.0, duration: 0.3)
    }

    KeyframeTrack(\.yOffset) {
        LinearKeyframe(-50, duration: 0.15)
        SpringKeyframe(0, duration: 0.4, spring: .bouncy)
    }

    KeyframeTrack(\.rotation) {
        LinearKeyframe(.degrees(0), duration: 0.1)
        CubicKeyframe(.degrees(15), duration: 0.15)
        CubicKeyframe(.degrees(-10), duration: 0.15)
        SpringKeyframe(.degrees(0), duration: 0.3)
    }
}

Keyframe types:

- 
LinearKeyframe
 -- constant speed between keyframes.
- 
SpringKeyframe
 -- spring dynamics to reach the value.
- 
CubicKeyframe
 -- cubic Bezier curve interpolation.
- 
MoveKeyframe
 -- instant jump to value (no interpolation).

.contentTransition() (iOS 16+)

Animate text and numeric changes.

@State private var count = 0

Text("\(count)")
    .font(.largeTitle)
    .contentTransition(.numericText(countsDown: false))
    // The text morphs between numeric values

Button("Increment") {
    withAnimation(.snappy) {
        count += 1
    }
}

// Identity transition for text changes
Text(statusMessage)
    .contentTransition(.interpolate)  // Smooth morphing between text states

.sensoryFeedback() (iOS 17+)

Trigger haptic feedback tied to state changes.

Button("Success") {
    showSuccess = true
}
.sensoryFeedback(.success, trigger: showSuccess)

Toggle("Setting", isOn: $isEnabled)
    .sensoryFeedback(.selection, trigger: isEnabled)

// Feedback types
.sensoryFeedback(.impact, trigger: value)           // Physical tap
.sensoryFeedback(.impact(weight: .heavy), trigger: value)
.sensoryFeedback(.impact(intensity: 0.8), trigger: value)
.sensoryFeedback(.selection, trigger: value)         // Light selection tick
.sensoryFeedback(.success, trigger: value)           // Success pattern
.sensoryFeedback(.warning, trigger: value)           // Warning pattern
.sensoryFeedback(.error, trigger: value)             // Error pattern
.sensoryFeedback(.increase, trigger: value)          // Value increasing
.sensoryFeedback(.decrease, trigger: value)          // Value decreasing

// Conditional feedback
.sensoryFeedback(.success, trigger: taskComplete) { oldValue, newValue in
    newValue == true  // Only trigger when becoming true
}

Symbol Effects (iOS 17+)

Built-in animations for SF Symbols.

Image(systemName: "wifi")
    .symbolEffect(.variableColor.iterative)

Image(systemName: "bell")
    .symbolEffect(.bounce, value: notificationCount)

Image(systemName: "arrow.down.circle")
    .symbolEffect(.pulse, isActive: isDownloading)

Image(systemName: "checkmark.circle")
    .symbolEffect(.appear, isActive: showCheck)
    .symbolEffect(.disappear, isActive: hideCheck)

// Replace symbol with animation
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
    .contentTransition(.symbolEffect(.replace))

Transaction and Animation Completion

Transaction

Override animations for specific state changes.

// Disable animation for specific change
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
    showPanel = false
}

// Custom transaction
var transaction = Transaction(animation: .spring(duration: 0.3))
withTransaction(transaction) {
    selectedTab = .home
}

// Transaction modifier on a view
Text("No animation here")
    .transaction { transaction in
        transaction.animation = nil  // Suppress animations for this view
    }

Animation Completion (iOS 17+)

withAnimation(.easeInOut(duration: 0.5)) {
    isVisible = true
} completion: {
    // Runs when animation finishes
    loadNextStep()
}

Practical Animation Patterns

Staggered List Animation

struct StaggeredList: View {
    @State private var items: [Item] = []
    @State private var visibleItems: Set<UUID> = []

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 12) {
                ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
                    ItemRow(item: item)
                        .opacity(visibleItems.contains(item.id) ? 1 : 0)
                        .offset(y: visibleItems.contains(item.id) ? 0 : 20)
                        .onAppear {
                            withAnimation(.spring(duration: 0.4).delay(Double(index) * 0.05)) {
                                visibleItems.insert(item.id)
                            }
                        }
                }
            }
            .padding()
        }
    }
}

Loading Shimmer

struct ShimmerView: View {
    @State private var phase: CGFloat = -1

    var body: some View {
        RoundedRectangle(cornerRadius: 8)
            .fill(.gray.opacity(0.3))
            .overlay {
                RoundedRectangle(cornerRadius: 8)
                    .fill(
                        LinearGradient(
                            colors: [.clear, .white.opacity(0.4), .clear],
                            startPoint: .init(x: phase, y: 0.5),
                            endPoint: .init(x: phase + 1, y: 0.5)
                        )
                    )
            }
            .onAppear {
                withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
                    phase = 2
                }
            }
    }
}

Breathing / Pulsing Effect

struct PulsingDot: View {
    @State private var isPulsing = false

    var body: some View {
        Circle()
            .fill(.green)
            .frame(width: 12, height: 12)
            .overlay {
                Circle()
                    .stroke(.green, lineWidth: 2)
                    .scaleEffect(isPulsing ? 2.5 : 1)
                    .opacity(isPulsing ? 0 : 1)
            }
            .onAppear {
                withAnimation(.easeOut(duration: 1.5).repeatForever(autoreverses: false)) {
                    isPulsing = true
                }
            }
    }
}

Performance Tips

Prefer 
withAnimation
 over 
.animation(value:)
 when only some state changes should animate.
Use 
.drawingGroup()
 on complex animated views to rasterize into a single Metal layer.
Avoid animating 
GeometryReader
 size changes -- they cause expensive relayouts.
Use 
Animation.spring
 over custom spring parameters when possible -- the system optimizes it.
Profile with Instruments (Core Animation template) to verify 60fps.
Reduce use of 
blur
 and 
shadow
 modifiers during active animations.

iOS 17/18 Advanced Visual Effects

visualEffect Modifier (iOS 17+)

The 
.visualEffect
 modifier lets you read a view's geometry proxy and apply visual transforms without triggering layout passes. Unlike 
GeometryReader
, it never changes the size or position of the view in the layout -- it only applies render-time effects like scale, offset, rotation, opacity, and blur.

// Basic usage -- read size and position for visual-only transforms
Rectangle()
    .fill(.blue)
    .frame(width: 200, height: 200)
    .visualEffect { content, proxy in
        content
            .offset(y: proxy.frame(in: .global).minY * 0.1)
            .scaleEffect(proxy.size.width / 300)
    }

When to use visualEffect instead of GeometryReader:

- Parallax scrolling effects
- Scale or rotation based on scroll position
- Blur or opacity that depends on a view's position
- Any visual tweak that should not alter layout

Complete Parallax Scroll Example

struct ParallaxScrollView: View {
    let images = ["photo1", "photo2", "photo3", "photo4", "photo5"]

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 16) {
                ForEach(images, id: \.self) { imageName in
                    ParallaxCard(imageName: imageName)
                }
            }
            .padding()
        }
    }
}

struct ParallaxCard: View {
    let imageName: String

    var body: some View {
        RoundedRectangle(cornerRadius: 20)
            .fill(.gray.opacity(0.2))
            .frame(height: 250)
            .overlay {
                Image(imageName)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .frame(height: 300) // Slightly taller than container
                    .visualEffect { content, proxy in
                        let frame = proxy.frame(in: .scrollView(axis: .vertical))
                        let distance = min(0, frame.minY)
                        return content
                            .offset(y: -distance * 0.4)
                    }
            }
            .clipShape(RoundedRectangle(cornerRadius: 20))
            .shadow(color: .black.opacity(0.15), radius: 10, y: 5)
    }
}

Position-Based Rotation and Scale

struct VisualEffectGallery: View {
    let colors: [Color] = [.red, .orange, .yellow, .green, .blue, .purple]

    var body: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 16) {
                ForEach(colors, id: \.self) { color in
                    RoundedRectangle(cornerRadius: 16)
                        .fill(color.gradient)
                        .frame(width: 200, height: 280)
                        .visualEffect { content, proxy in
                            let frame = proxy.frame(in: .scrollView(axis: .horizontal))
                            let midX = frame.midX
                            let screenMidX = UIScreen.main.bounds.width / 2
                            let distance = abs(midX - screenMidX)
                            let maxDistance: CGFloat = 300
                            let normalizedDistance = min(distance / maxDistance, 1.0)

                            return content
                                .scaleEffect(1.0 - normalizedDistance * 0.15)
                                .rotation3DEffect(
                                    .degrees(Double(midX - screenMidX) / 10),
                                    axis: (x: 0, y: 1, z: 0)
                                )
                                .opacity(1.0 - Double(normalizedDistance) * 0.3)
                        }
                }
            }
            .padding(.horizontal, 80)
        }
        .scrollTargetBehavior(.viewAligned)
    }
}

scrollTransition (iOS 17+)

The 
.scrollTransition
 modifier animates views as they enter and leave the visible area of a 
ScrollView
. It provides a 
phase
 value that indicates where the view is relative to the viewport.

Phase values:

- 
.topLeading
 -- the view is above or to the leading side of the viewport (about to enter from top/leading).
- 
.identity
 -- the view is fully within the visible area.
- 
.bottomTrailing
 -- the view is below or to the trailing side of the viewport (about to exit bottom/trailing).

The closure receives a VisualEffect, not a View.
 This is the single most
important fact about the modifier and it is easy to miss, because the parameter
is conventionally named 
content
 and every example uses it like a view.

VisualEffect
 supports only effects that can be applied to already-rendered
content on the GPU:

Supported inside scrollTransition

Not supported

.opacity(_:)

.shadow(...)

.scaleEffect(_:anchor:)

.background(...) / .overlay(...)

.rotationEffect(_:anchor:)

.blur(radius:) on some OS versions

.rotation3DEffect(_:axis:anchor:perspective:)

.foregroundStyle(...)

.offset(x:y:)

.clipShape(...) / .mask(...)

.brightness, .saturation, .contrast, .grayscale, .hueRotation

any layout modifier

.blendMode(_:)

anything that changes the view's identity or size

Reaching for 
.shadow
 inside the closure — the obvious way to make scrolling
cards read as receding — produces one of Swift's worst diagnostics:

// WRONG — .shadow is not a VisualEffect member.
.scrollTransition { content, phase in
    content
        .opacity(phase.isIdentity ? 1 : 0.4)
        .shadow(radius: phase.isIdentity ? 20 : 4)
}

// error: the compiler is unable to type-check this expression
//        in reasonable time; try breaking up the expression into
//        distinct sub-expressions

That message points at expression complexity, which is not the problem. The
problem is a missing overload, and no amount of breaking up the expression will
fix it.

// RIGHT — the shadow lives outside the transition, driven by the same phase
// through a plain modifier on the view itself.
RoundedRectangle(cornerRadius: 16)
    .fill(.blue.gradient)
    .shadow(color: .black.opacity(0.15), radius: 12, y: 6)
    .scrollTransition { content, phase in
        content
            .opacity(phase.isIdentity ? 1 : 0.4)
            .scaleEffect(phase.isIdentity ? 1 : 0.92)
    }

If a shadow genuinely has to change with scroll position, drive it from

GeometryReader
 or 
onScrollGeometryChange
 and apply it as a normal modifier —
it will cost more, which is precisely why 
VisualEffect
 excludes it.

// Basic fade and scale on scroll
ScrollView {
    LazyVStack(spacing: 16) {
        ForEach(0..<20) { index in
            RoundedRectangle(cornerRadius: 16)
                .fill(.blue.gradient)
                .frame(height: 120)
                .overlay {
                    Text("Card \(index)")
                        .font(.title2)
                        .fontWeight(.bold)
                        .foregroundStyle(.white)
                }
                .scrollTransition { content, phase in
                    content
                        .opacity(phase.isIdentity ? 1.0 : 0.3)
                        .scaleEffect(phase.isIdentity ? 1.0 : 0.85)
                }
        }
    }
    .padding()
}

Complete Example: Cards That Scale Up as They Enter

struct ScrollTransitionDemo: View {
    struct CardItem: Identifiable {
        let id = UUID()
        let title: String
        let subtitle: String
        let color: Color
    }

    let items: [CardItem] = [
        CardItem(title: "Design", subtitle: "Create beautiful interfaces", color: .blue),
        CardItem(title: "Develop", subtitle: "Build with SwiftUI", color: .purple),
        CardItem(title: "Test", subtitle: "Ensure quality everywhere", color: .orange),
        CardItem(title: "Deploy", subtitle: "Ship to the App Store", color: .green),
        CardItem(title: "Monitor", subtitle: "Track performance", color: .red),
        CardItem(title: "Iterate", subtitle: "Improve continuously", color: .teal),
        CardItem(title: "Scale", subtitle: "Grow your user base", color: .indigo),
        CardItem(title: "Optimize", subtitle: "Fine-tune performance", color: .pink),
    ]

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 20) {
                ForEach(items) { item in
                    HStack(spacing: 16) {
                        Circle()
                            .fill(item.color.gradient)
                            .frame(width: 56, height: 56)
                            .overlay {
                                Image(systemName: "star.fill")
                                    .foregroundStyle(.white)
                                    .font(.title3)
                            }

                        VStack(alignment: .leading, spacing: 4) {
                            Text(item.title)
                                .font(.headline)
                            Text(item.subtitle)
                                .font(.subheadline)
                                .foregroundStyle(.secondary)
                        }

                        Spacer()

                        Image(systemName: "chevron.right")
                            .foregroundStyle(.tertiary)
                    }
                    .padding()
                    .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
                    .scrollTransition(.animated(.spring(duration: 0.4))) { content, phase in
                        content
                            .opacity(phase.isIdentity ? 1.0 : 0.0)
                            .scaleEffect(phase.isIdentity ? 1.0 : 0.75)
                            .offset(y: phase == .bottomTrailing ? 30 : phase == .topLeading ? -30 : 0)
                            .blur(radius: phase.isIdentity ? 0 : 2)
                    }
                }
            }
            .padding()
        }
    }
}

Horizontal Scroll with Rotation

ScrollView(.horizontal, showsIndicators: false) {
    HStack(spacing: 12) {
        ForEach(0..<10) { index in
            RoundedRectangle(cornerRadius: 20)
                .fill(Color(hue: Double(index) / 10, saturation: 0.7, brightness: 0.9).gradient)
                .frame(width: 200, height: 280)
                .scrollTransition(.interactive) { content, phase in
                    content
                        .rotation3DEffect(
                            .degrees(phase.value * 25),
                            axis: (x: 0, y: 1, z: 0)
                        )
                        .scaleEffect(phase.isIdentity ? 1.0 : 0.9)
                }
        }
    }
    .padding(.horizontal)
}
.scrollTargetBehavior(.viewAligned)

MeshGradient (iOS 18+)

MeshGradient
 creates a two-dimensional gradient defined by a grid of control points, each with an associated color. The colors blend smoothly across the mesh, creating organic, flowing gradients far more complex than linear or radial gradients.

// Static mesh gradient
MeshGradient(
    width: 3,
    height: 3,
    points: [
        [0.0, 0.0], [0.5, 0.0], [1.0, 0.0],
        [0.0, 0.5], [0.5, 0.5], [1.0, 0.5],
        [0.0, 1.0], [0.5, 1.0], [1.0, 1.0]
    ],
    colors: [
        .red,    .purple, .indigo,
        .orange, .pink,   .blue,
        .yellow, .mint,   .teal
    ]
)
.frame(width: 300, height: 300)
.clipShape(RoundedRectangle(cornerRadius: 24))

Complete Animated Mesh Gradient Background

struct AnimatedMeshBackground: View {
    @State private var animationPhase: CGFloat = 0.0

    var body: some View {
        TimelineView(.animation) { context in
            let time = context.date.timeIntervalSinceReferenceDate

            MeshGradient(
                width: 3,
                height: 3,
                points: [
                    [0.0, 0.0],
                    [Float(0.5 + 0.3 * cos(time * 0.7)), 0.0],
                    [1.0, 0.0],

                    [0.0, Float(0.5 + 0.2 * sin(time * 0.5))],
                    [Float(0.5 + 0.2 * sin(time * 0.8)), Float(0.5 + 0.2 * cos(time * 0.6))],
                    [1.0, Float(0.5 + 0.3 * sin(time * 0.4))],

                    [0.0, 1.0],
                    [Float(0.5 + 0.2 * sin(time * 0.9)), 1.0],
                    [1.0, 1.0]
                ],
                colors: [
                    .red,    .purple, .indigo,
                    .orange, .pink,   .blue,
                    .yellow, .mint,   .teal
                ]
            )
            .ignoresSafeArea()
        }
    }
}

// Usage as a background
struct MeshGradientScreen: View {
    var body: some View {
        ZStack {
            AnimatedMeshBackground()

            VStack(spacing: 16) {
                Text("Welcome")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                    .foregroundStyle(.white)

                Text("Beautiful animated gradients")
                    .font(.title3)
                    .foregroundStyle(.white.opacity(0.8))
            }
        }
    }
}

Mesh Gradient with Color Cycling

struct ColorCyclingMesh: View {
    let baseColors: [Color] = [
        .red, .orange, .yellow,
        .green, .mint, .teal,
        .blue, .indigo, .purple
    ]

    var body: some View {
        TimelineView(.animation) { context in
            let time = context.date.timeIntervalSinceReferenceDate
            let shift = Int(time * 2) % baseColors.count

            MeshGradient(
                width: 3,
                height: 3,
                points: [
                    [0.0, 0.0], [0.5, 0.0], [1.0, 0.0],
                    [0.0, 0.5], [0.5, 0.5], [1.0, 0.5],
                    [0.0, 1.0], [0.5, 1.0], [1.0, 1.0]
                ],
                colors: (0..<9).map { index in
                    baseColors[(index + shift) % baseColors.count]
                }
            )
            .ignoresSafeArea()
        }
    }
}

TextRenderer Protocol (iOS 18+)

The 
TextRenderer
 protocol lets you customize how each glyph or line of text is drawn. You conform to 
TextRenderer
 and implement 
draw(layout:in:)
, where you receive the resolved text layout and a 
GraphicsContext
. This enables per-character animations like typing effects, wave motion, and fade-in sequences.

TextRenderer Protocol Basics

import SwiftUI

struct WaveTextRenderer: TextRenderer {
    var timeOffset: Double

    var animatableData: Double {
        get { timeOffset }
        set { timeOffset = newValue }
    }

    func draw(layout: Text.Layout, in context: inout GraphicsContext) {
        for line in layout {
            for run in line {
                for (index, glyph) in run.enumerated() {
                    var copy = context
                    let yOffset = sin(Double(index) * 0.5 + timeOffset) * 5
                    copy.translateBy(x: 0, y: yOffset)
                    copy.draw(glyph)
                }
            }
        }
    }
}

Complete Custom Text Animation: Per-Character Fade-In

import SwiftUI

struct FadeInTextRenderer: TextRenderer {
    var progress: Double

    var animatableData: Double {
        get { progress }
        set { progress = newValue }
    }

    func draw(layout: Text.Layout, in context: inout GraphicsContext) {
        var characterIndex = 0
        var totalCharacters = 0

        // Count total characters
        for line in layout {
            for run in line {
                for _ in run {
                    totalCharacters += 1
                }
            }
        }

        // Draw each character with staggered opacity
        for line in layout {
            for run in line {
                for glyph in run {
                    let threshold = Double(characterIndex) / Double(max(totalCharacters - 1, 1))
                    let characterProgress = max(0, min(1, (progress - threshold) * Double(totalCharacters) / 3.0))

                    var copy = context
                    copy.opacity = characterProgress
                    let yShift = (1.0 - characterProgress) * 10
                    copy.translateBy(x: 0, y: yShift)
                    copy.draw(glyph)

                    characterIndex += 1
                }
            }
        }
    }
}

struct FadeInTextDemo: View {
    @State private var progress: Double = 0.0

    var body: some View {
        VStack(spacing: 40) {
            Text("Hello, SwiftUI!")
                .font(.largeTitle)
                .fontWeight(.bold)
                .textRenderer(FadeInTextRenderer(progress: progress))

            Button("Animate") {
                progress = 0
                withAnimation(.easeInOut(duration: 1.5)) {
                    progress = 1.0
                }
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}

Typing Effect with TextRenderer

struct TypingTextRenderer: TextRenderer {
    var visibleCount: Int

    var animatableData: Double {
        get { Double(visibleCount) }
        set { visibleCount = Int(newValue) }
    }

    func draw(layout: Text.Layout, in context: inout GraphicsContext) {
        var characterIndex = 0
        for line in layout {
            for run in line {
                for glyph in run {
                    if characterIndex < visibleCount {
                        context.draw(glyph)
                    }
                    characterIndex += 1
                }
            }
        }
    }
}

struct TypingEffectDemo: View {
    @State private var visibleCount = 0
    private let message = "Welcome to the future of text rendering."

    var body: some View {
        VStack(spacing: 30) {
            Text(message)
                .font(.title2)
                .fontWeight(.medium)
                .textRenderer(TypingTextRenderer(visibleCount: visibleCount))
                .frame(maxWidth: .infinity, alignment: .leading)

            Button("Start Typing") {
                visibleCount = 0
                withAnimation(.linear(duration: 2.0)) {
                    visibleCount = message.count
                }
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}

Wave Text Animation

struct WaveTextDemo: View {
    @State private var wavePhase: Double = 0

    var body: some View {
        Text("SwiftUI Waves")
            .font(.largeTitle)
            .fontWeight(.heavy)
            .textRenderer(WaveTextRenderer(timeOffset: wavePhase))
            .onAppear {
                withAnimation(.linear(duration: 2).repeatForever(autoreverses: false)) {
                    wavePhase = .pi * 2
                }
            }
    }
}

Key rules for TextRenderer:

- The 
animatableData
 property is required if you want SwiftUI to interpolate your renderer over time.
- 
Text.Layout
 provides an iterable hierarchy: layout -> lines -> runs -> glyphs.
- Use 
GraphicsContext.draw(_:)
 to render individual glyphs. You can modify the context (translate, rotate, set opacity) before each draw call.
- TextRenderer only works with 
Text
 views. It does not apply to 
TextField
, 
Label
, or other text-containing views.

---

# Routing, Deep Links, and State Restoration
https://nagarjuna2997.github.io/ios-agent-skill/guides/swiftui-deep-linking-and-routing.html

Swiftui · Reference guideRouting, Deep Links, and State RestorationRepository guidance for Routing, Deep Links, and State Restoration. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

1. Typed Routes
2. The Router

Wiring it once, at the root
Views send intent
Anti-patterns

3. Deep Links

Parse into routes, then apply
Applying
Links that arrive before the app is ready
Configuration
Testing the parser

4. State Restoration

Persisting per scene

5. Multiple Stacks (Tabs)
6. Router vs Coordinator
Checklist

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Parse incoming URL
↓2
Validate a typed route
↓3
Update navigation state
↓4
Restore or reject safely

02 / ArchitectureResponsibility boundariesBoundary 1
URL parserBoundary 2
Typed routerBoundary 3
Navigation stackConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
 adding navigation to more than two screens, handling a URL
scheme or universal link, restoring navigation state across launches, or
reviewing anything that mutates a 
NavigationPath
.

docs/swiftui/navigation.md
 covers the navigation 
APIs
.

patterns/coordinator.md
 covers the coordinator 
pattern
.
This document covers the piece that breaks in production: making a single typed
route model the only way navigation state changes, so a deep link, a button tap,
and a restored session all go through the same code.

1. Typed Routes

Model every destination as a value. Strings and 
Any
 in a 
NavigationPath
 are
how you get silent no-op links.

// Navigation/Route.swift
enum Route: Hashable, Codable, Sendable {
    case productDetail(id: Product.ID)
    case category(Product.Category)
    case orderHistory
    case orderDetail(id: Order.ID)
    case settings
    case profile(userID: User.ID)
}

// Modal presentation is a different axis — model it separately.
enum Sheet: Identifiable, Hashable {
    case checkout(cart: [CartItem])
    case editProfile
    case filter

    var id: Self { self }
}

enum FullScreenRoute: Identifiable, Hashable {
    case onboarding
    case paywall(trigger: String)

    var id: Self { self }
}

Codable
 conformance is what makes state restoration possible (§4). Add it now
even if you do not restore yet — retrofitting it later means touching every case.

2. The Router

One 
@MainActor @Observable
 object owns navigation state. Views send it intent;
they never mutate a path directly.

// Navigation/Router.swift
@MainActor
@Observable
final class Router {
    var path = NavigationPath()
    var sheet: Sheet?
    var fullScreen: FullScreenRoute?

    // MARK: Stack

    func push(_ route: Route) {
        path.append(route)
    }

    func pop() {
        guard !path.isEmpty else { return }
        path.removeLast()
    }

    func popToRoot() {
        path.removeLast(path.count)
    }

    /// Replaces the entire stack — used by deep links so a link never
    /// accumulates screens on top of wherever the user happened to be.
    func replaceStack(with routes: [Route]) {
        var newPath = NavigationPath()
        for route in routes { newPath.append(route) }
        path = newPath
    }

    // MARK: Modals

    func present(_ sheet: Sheet) { self.sheet = sheet }
    func present(_ route: FullScreenRoute) { fullScreen = route }
    func dismissModal() { sheet = nil; fullScreen = nil }
}

Wiring it once, at the root

struct RootView: View {
    @State private var router = Router()

    var body: some View {
        NavigationStack(path: $router.path) {
            ProductListView()
                .navigationDestination(for: Route.self) { route in
                    destination(for: route)
                }
        }
        .environment(router)
        .sheet(item: $router.sheet) { sheet in
            switch sheet {
            case .checkout(let cart): CheckoutView(cart: cart)
            case .editProfile:        EditProfileView()
            case .filter:             FilterView()
            }
        }
        .fullScreenCover(item: $router.fullScreen) { route in
            switch route {
            case .onboarding:            OnboardingView()
            case .paywall(let trigger):  PaywallView(trigger: trigger)
            }
        }
    }

    // One exhaustive switch. The compiler catches every route you forget to handle.
    @ViewBuilder
    private func destination(for route: Route) -> some View {
        switch route {
        case .productDetail(let id):  ProductDetailView(id: id)
        case .category(let category): CategoryView(category: category)
        case .orderHistory:           OrderHistoryView()
        case .orderDetail(let id):    OrderDetailView(id: id)
        case .settings:               SettingsView()
        case .profile(let userID):    ProfileView(userID: userID)
        }
    }
}

Views send intent

struct ProductRow: View {
    @Environment(Router.self) private var router
    let product: Product

    var body: some View {
        Button {
            router.push(.productDetail(id: product.id))
        } label: {
            ProductRowContent(product: product)
        }
    }
}

NavigationLink(value:)
 is equally fine for a plain push and keeps the row
accessible for free. Use the router when the destination depends on logic
(auth checks, A/B branching, analytics side effects).

Anti-patterns

// WRONG — a screen that owns its own NavigationStack cannot be pushed
// onto another one. You get a nested stack and a doubled navigation bar.
struct ProductDetailView: View {
    var body: some View { NavigationStack { … } }   // only the ROOT has a stack
}

// WRONG — deprecated, and it silently breaks programmatic navigation.
NavigationView { … }
NavigationLink(destination: DetailView(), isActive: $isActive) { … }

// WRONG — untyped path. A mismatched type is a silent no-op, not an error.
path.append("product-\(id)")

// WRONG — every view reaching into the path directly. Now nothing can
// enforce an invariant like "checkout requires a signed-in user".
@Environment(Router.self) var router
router.path.append(Route.checkout)     // use router.push(_:)

// WRONG — navigation state on a screen's view model. Two screens now
// disagree about what is on the stack.
@Observable final class ProductListViewModel { var path = NavigationPath() }

3. Deep Links

Parse into routes, then apply

Separate 
parsing
 (pure, testable, no UI) from 
applying
 (mutates the
router). This is what makes deep links unit-testable without launching the app.

// Navigation/DeepLink.swift
struct DeepLink: Equatable {
    var tab: AppTab
    var routes: [Route]
    var sheet: Sheet?
}

enum DeepLinkParser {
    /// Pure function: URL in, intent out. No side effects, no router.
    static func parse(_ url: URL) -> DeepLink? {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
            return nil
        }

        switch (components.scheme, components.host) {
        // Custom scheme: myshop://product/1234
        case ("myshop", let host?):
            return parsePath(host: host, segments: pathSegments(components), query: components)

        // Universal link: https://shop.example.com/product/1234
        case ("https", "shop.example.com"):
            var segments = pathSegments(components)
            guard !segments.isEmpty else { return DeepLink(tab: .home, routes: []) }
            let host = segments.removeFirst()
            return parsePath(host: host, segments: segments, query: components)

        default:
            return nil
        }
    }

    private static func pathSegments(_ components: URLComponents) -> [String] {
        components.path.split(separator: "/").map(String.init)
    }

    private static func parsePath(
        host: String,
        segments: [String],
        query components: URLComponents
    ) -> DeepLink? {
        switch host {
        case "product":
            guard let raw = segments.first, let id = Product.ID(uuidString: raw) else { return nil }
            return DeepLink(tab: .home, routes: [.productDetail(id: id)])

        case "category":
            guard let raw = segments.first,
                  let category = Product.Category(rawValue: raw) else { return nil }
            return DeepLink(tab: .home, routes: [.category(category)])

        case "orders":
            // /orders            -> the list
            // /orders/<uuid>     -> the list, then the detail pushed on top,
            //                       so Back goes somewhere sensible.
            guard let raw = segments.first else {
                return DeepLink(tab: .orders, routes: [.orderHistory])
            }
            guard let id = Order.ID(uuidString: raw) else { return nil }
            return DeepLink(tab: .orders, routes: [.orderHistory, .orderDetail(id: id)])

        case "checkout":
            return DeepLink(tab: .home, routes: [], sheet: .checkout(cart: []))

        default:
            return nil                                   // unknown -> ignore, never crash
        }
    }
}

Two details that matter:

A deep link builds the whole stack, not just the leaf.
 Landing on an order
  detail with an empty back stack strands the user.
An unrecognised URL returns nil.
 It must never crash and never navigate
  somewhere arbitrary. Log it and stay put.

Applying

extension Router {
    func handle(_ url: URL) {
        guard let link = DeepLinkParser.parse(url) else {
            Logger.navigation.warning("Unhandled deep link: \(url, privacy: .public)")
            return
        }
        apply(link)
    }

    func apply(_ link: DeepLink) {
        dismissModal()                       // never open a link behind a sheet
        selectedTab = link.tab
        replaceStack(with: link.routes)
        if let sheet = link.sheet { present(sheet) }
    }
}

@main
struct ShopApp: App {
    @State private var router = Router()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(router)
                .onOpenURL { router.handle($0) }
                // Universal links arrive as a user activity, not onOpenURL.
                .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
                    guard let url = activity.webpageURL else { return }
                    router.handle(url)
                }
        }
    }
}

Links that arrive before the app is ready

A link can land during launch, before sign-in resolves. Queue it rather than
dropping it:

@MainActor
@Observable
final class Router {
    private var pendingLink: DeepLink?
    var isReady = false {
        didSet { if isReady { flushPendingLink() } }
    }

    func apply(_ link: DeepLink) {
        guard isReady else { pendingLink = link; return }
        // … as above
    }

    private func flushPendingLink() {
        guard let link = pendingLink else { return }
        pendingLink = nil
        apply(link)
    }
}

Set 
isReady = true
 once the session is loaded and the root UI is on screen.
The same queue handles links that require authentication: present the login
sheet, keep 
pendingLink
, flush it after a successful sign-in.

Configuration

<!-- Info.plist — custom scheme -->
<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLName</key>
        <string>com.example.myshop</string>
        <key>CFBundleURLSchemes</key>
        <array><string>myshop</string></array>
    </dict>
</array>

Universal links additionally need the 
Associated Domains
 capability
(
applinks:shop.example.com
) and an 
apple-app-site-association
 file served
from 
https://shop.example.com/.well-known/
 over HTTPS with no redirects.
Prefer universal links: they work when the app is not installed, and they cannot
be hijacked by another app registering the same scheme.

Testing the parser

Because parsing is pure, this needs no simulator:

@Suite("DeepLinkParser")
struct DeepLinkParserTests {
    private func url(_ string: String) -> URL { URL(string: string)! }

    @Test("product link builds a single-screen stack")
    func productLink() throws {
        let id = UUID()
        let link = try #require(DeepLinkParser.parse(url("myshop://product/\(id)")))
        #expect(link.tab == .home)
        #expect(link.routes == [.productDetail(id: id)])
    }

    @Test("order detail keeps the list underneath it")
    func orderDetailStack() throws {
        let id = UUID()
        let link = try #require(DeepLinkParser.parse(url("myshop://orders/\(id)")))
        #expect(link.routes == [.orderHistory, .orderDetail(id: id)])
    }

    @Test("universal link parses the same as the custom scheme")
    func universalLink() throws {
        let id = UUID()
        let custom = DeepLinkParser.parse(url("myshop://product/\(id)"))
        let web = DeepLinkParser.parse(url("https://shop.example.com/product/\(id)"))
        #expect(custom == web)
    }

    @Test("malformed links are ignored, not fatal", arguments: [
        "myshop://product/not-a-uuid",
        "myshop://nonsense",
        "https://evil.example.com/product/123",
        "myshop://"
    ])
    func malformed(_ string: String) {
        #expect(DeepLinkParser.parse(url(string)) == nil)
    }
}

4. State Restoration

NavigationPath
 has a 
CodableRepresentation
 that works when 
every
 route
in it is 
Codable
 and 
Hashable
. That is the payoff for §1.

extension Router {
    /// nil when the path holds a non-Codable value — fail quietly, never crash.
    var encodedPath: Data? {
        guard let representation = path.codable else { return nil }
        return try? JSONEncoder().encode(representation)
    }

    func restorePath(from data: Data) {
        guard let representation = try? JSONDecoder()
            .decode(NavigationPath.CodableRepresentation.self, from: data)
        else { return }
        path = NavigationPath(representation)
    }
}

Persisting per scene

struct RootView: View {
    @Environment(Router.self) private var router
    @SceneStorage("navigation.path") private var storedPath: Data?

    var body: some View {
        NavigationStack(path: Bindable(router).path) {
            ProductListView()
                .navigationDestination(for: Route.self) { destination(for: $0) }
        }
        .task {
            if let storedPath { router.restorePath(from: storedPath) }
        }
        .onChange(of: router.path) {
            storedPath = router.encodedPath
        }
    }
}

@SceneStorage
 is the right tool: it is per-window (correct on iPad and
visionOS, where two windows have different stacks) and the system clears it when
the user explicitly closes the scene.

Restore defensively.
 A restored route may point at a product that has since
been deleted or a screen behind a subscription that has lapsed. Destination
views must handle a missing entity with a 
ContentUnavailableView
, not a force
unwrap. Validate before restoring anything sensitive:

func restorePath(from data: Data, isSignedIn: Bool) {
    guard isSignedIn else { return }        // never restore into a gated screen
    // … decode as above
}

5. Multiple Stacks (Tabs)

Each tab owns its own path. A single shared 
NavigationPath
 across tabs will
push the wrong screen into the wrong tab.

enum AppTab: String, Hashable, CaseIterable, Codable {
    case home, search, orders, profile
}

@MainActor
@Observable
final class Router {
    var selectedTab: AppTab = .home
    private var paths: [AppTab: NavigationPath] = [:]

    subscript(tab: AppTab) -> NavigationPath {
        get { paths[tab] ?? NavigationPath() }
        set { paths[tab] = newValue }
    }

    func push(_ route: Route, in tab: AppTab? = nil) {
        let target = tab ?? selectedTab
        paths[target, default: NavigationPath()].append(route)
    }

    /// Tapping the active tab again pops it to root — expected iOS behaviour.
    func select(_ tab: AppTab) {
        if selectedTab == tab {
            paths[tab] = NavigationPath()
        } else {
            selectedTab = tab
        }
    }
}

struct AppTabView: View {
    @Environment(Router.self) private var router

    var body: some View {
        @Bindable var router = router

        TabView(selection: Binding(
            get: { router.selectedTab },
            set: { router.select($0) }        // routes through the pop-to-root rule
        )) {
            ForEach(AppTab.allCases, id: \.self) { tab in
                NavigationStack(path: Binding(
                    get: { router[tab] },
                    set: { router[tab] = $0 }
                )) {
                    rootView(for: tab)
                        .navigationDestination(for: Route.self) { destination(for: $0) }
                }
                .tabItem { Label(tab.title, systemImage: tab.systemImage) }
                .tag(tab)
            }
        }
    }
}

6. Router vs Coordinator

Use

When

NavigationLink(value:) alone

1–3 screens, no deep links, no conditional destinations

A single Router (this document)

Most apps: typed routes, deep links, restoration

Coordinators (patterns/coordinator.md)

Independent flows with their own lifecycle — onboarding, auth, checkout — especially when a team owns each flow as a module

Do not start with coordinators. Start with a 
Router
, and split a flow out only
when its navigation rules stop fitting in one exhaustive switch.

Checklist

[ ] Exactly one 
NavigationStack
 per tab, owned by the root — never inside a
      pushed screen.
[ ] Routes are a 
Hashable, Codable, Sendable
 enum; nothing is appended as a
      
String
.
[ ] Views call 
router.push(_:)
; nothing mutates 
path
 directly.
[ ] 
DeepLinkParser.parse
 is pure and unit-tested, including malformed input.
[ ] Deep links build a full back stack and dismiss any open modal first.
[ ] An unknown URL logs and no-ops. It never crashes and never navigates blind.
[ ] Universal links handled via 
onContinueUserActivity
, not just 
onOpenURL
.
[ ] Links arriving before the app is ready are queued, not dropped.
[ ] Restored destinations tolerate deleted entities and revoked access.
[ ] 
Router
 is 
@MainActor @Observable final class
.

---

# SwiftUI Gestures
https://nagarjuna2997.github.io/ios-agent-skill/guides/swiftui-gestures.html

Swiftui · Reference guideSwiftUI GesturesRepository guidance for SwiftUI Gestures. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

TapGesture

Shortcut Modifier
Explicit TapGesture
SpatialTapGesture (iOS 16+)

LongPressGesture

Shortcut Modifier
Explicit LongPressGesture

DragGesture

DragGesture Value Properties
Swipe-to-Dismiss Card
Drag with Velocity (iOS 17+)

MagnifyGesture (Pinch to Zoom)
RotateGesture
Gesture Composition

.simultaneously
.sequenced
.exclusively

@GestureState

Complex GestureState

Gesture Modifiers and Priority

.highPriorityGesture
.simultaneousGesture
Gesture mask

Custom Gesture Modifiers
Gesture with Animation Integration

Interactive Spring-Back
Pull-to-Refresh with Custom Gesture

Practical Patterns

Dismiss Gesture for Modal
Coordinate Multiple Gestures

Tips and Best Practices

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Choose intended gesture
↓2
Track transient gesture state
↓3
Commit the resulting action
↓4
Resolve competing gestures

02 / ArchitectureResponsibility boundariesBoundary 1
Input recognizersBoundary 2
Transient stateBoundary 3
Committed model stateConnected responsibilities, not a required class hierarchy or an execution trace.
Complete reference for tap, long press, drag, magnify, rotate gestures, composition, gesture state, and integration with animations.

TapGesture

Shortcut Modifier

// Single tap
Text("Tap me")
    .onTapGesture {
        print("Tapped")
    }

// Double tap
Image("photo")
    .onTapGesture(count: 2) {
        isZoomed.toggle()
    }

// Triple tap
Text("Secret")
    .onTapGesture(count: 3) {
        showDebugMenu = true
    }

Explicit TapGesture

let doubleTap = TapGesture(count: 2)
    .onEnded {
        withAnimation(.spring(duration: 0.3, bounce: 0.4)) {
            isFavorited.toggle()
        }
    }

Image(systemName: isFavorited ? "heart.fill" : "heart")
    .font(.largeTitle)
    .foregroundStyle(isFavorited ? .red : .gray)
    .gesture(doubleTap)

SpatialTapGesture (iOS 16+)

Provides the location of the tap.

let spatialTap = SpatialTapGesture()
    .onEnded { value in
        let location = value.location  // CGPoint
        addAnnotation(at: location)
    }

Canvas { context, size in
    for annotation in annotations {
        context.fill(
            Circle().path(in: CGRect(origin: annotation.point, size: CGSize(width: 20, height: 20))),
            with: .color(.blue)
        )
    }
}
.gesture(spatialTap)

LongPressGesture

Shortcut Modifier

Text("Long press me")
    .onLongPressGesture(minimumDuration: 0.5) {
        showContextActions = true
    }

// With pressing state
Text("Hold")
    .onLongPressGesture(minimumDuration: 1.0) {
        // Completed
        performAction()
    } onPressingChanged: { isPressing in
        // Fires immediately when press starts/stops
        withAnimation {
            isHighlighted = isPressing
        }
    }

Explicit LongPressGesture

struct LongPressButton: View {
    @GestureState private var isDetectingLongPress = false
    @State private var completedLongPress = false

    var longPress: some Gesture {
        LongPressGesture(minimumDuration: 1.0)
            .updating($isDetectingLongPress) { currentState, gestureState, transaction in
                gestureState = currentState
                transaction.animation = .easeIn(duration: 0.2)
            }
            .onEnded { finished in
                completedLongPress = finished
            }
    }

    var body: some View {
        Circle()
            .fill(isDetectingLongPress ? .red : .blue)
            .frame(width: 80, height: 80)
            .scaleEffect(isDetectingLongPress ? 1.2 : 1.0)
            .gesture(longPress)
    }
}

DragGesture

The most versatile gesture for building interactive UIs.

struct DraggableCard: View {
    @State private var offset = CGSize.zero

    var body: some View {
        RoundedRectangle(cornerRadius: 16)
            .fill(.blue)
            .frame(width: 200, height: 120)
            .offset(offset)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        offset = value.translation
                    }
                    .onEnded { value in
                        withAnimation(.spring) {
                            offset = .zero
                        }
                    }
            )
    }
}

DragGesture Value Properties

DragGesture(minimumDistance: 10, coordinateSpace: .local)
    .onChanged { value in
        // Current drag state
        let translation = value.translation      // CGSize (total offset from start)
        let location = value.location            // CGPoint (current finger position)
        let startLocation = value.startLocation  // CGPoint (where drag began)
        let predictedEndLocation = value.predictedEndLocation    // CGPoint
        let predictedEndTranslation = value.predictedEndTranslation  // CGSize
        let velocity = value.velocity            // CGSize (iOS 17+, points per second)
    }

Swipe-to-Dismiss Card

struct SwipeCard: View {
    @State private var offset = CGSize.zero
    @State private var opacity: Double = 1
    let onDismiss: () -> Void

    var body: some View {
        CardContent()
            .offset(x: offset.width)
            .rotationEffect(.degrees(Double(offset.width / 20)))
            .opacity(opacity)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        offset = value.translation
                        opacity = 1 - abs(Double(value.translation.width / 300))
                    }
                    .onEnded { value in
                        if abs(value.translation.width) > 150 {
                            // Dismiss
                            withAnimation(.easeOut(duration: 0.3)) {
                                offset.width = value.translation.width > 0 ? 500 : -500
                                opacity = 0
                            }
                            DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                                onDismiss()
                            }
                        } else {
                            // Spring back
                            withAnimation(.spring(duration: 0.4, bounce: 0.3)) {
                                offset = .zero
                                opacity = 1
                            }
                        }
                    }
            )
    }
}

Drag with Velocity (iOS 17+)

DragGesture()
    .onEnded { value in
        let velocity = value.velocity
        let speed = sqrt(velocity.width * velocity.width + velocity.height * velocity.height)

        if speed > 500 {
            // Fast swipe -- project final position
            withAnimation(.spring(duration: 0.4)) {
                position = value.predictedEndTranslation
            }
        } else {
            // Slow drag -- snap back
            withAnimation(.spring) {
                position = .zero
            }
        }
    }

MagnifyGesture (Pinch to Zoom)

Renamed from 
MagnificationGesture
 in iOS 17.

struct ZoomableImage: View {
    @State private var currentScale: CGFloat = 1.0
    @GestureState private var gestureScale: CGFloat = 1.0

    var magnification: some Gesture {
        MagnifyGesture()
            .updating($gestureScale) { value, gestureState, _ in
                gestureState = value.magnification
            }
            .onEnded { value in
                currentScale *= value.magnification
                currentScale = min(max(currentScale, 0.5), 5.0) // Clamp
            }
    }

    var body: some View {
        Image("landscape")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .scaleEffect(currentScale * gestureScale)
            .gesture(magnification)
            .onTapGesture(count: 2) {
                withAnimation(.spring) {
                    currentScale = 1.0
                }
            }
    }
}

RotateGesture

Renamed from 
RotationGesture
 in iOS 17.

struct RotatableView: View {
    @State private var currentAngle: Angle = .zero
    @GestureState private var gestureAngle: Angle = .zero

    var rotation: some Gesture {
        RotateGesture()
            .updating($gestureAngle) { value, gestureState, _ in
                gestureState = value.rotation
            }
            .onEnded { value in
                currentAngle += value.rotation
            }
    }

    var body: some View {
        Image(systemName: "arrow.up")
            .font(.system(size: 60))
            .rotationEffect(currentAngle + gestureAngle)
            .gesture(rotation)
    }
}

Gesture Composition

.simultaneously

Both gestures are recognized at the same time.

struct ZoomAndRotate: View {
    @State private var scale: CGFloat = 1.0
    @State private var angle: Angle = .zero
    @GestureState private var gestureScale: CGFloat = 1.0
    @GestureState private var gestureAngle: Angle = .zero

    var body: some View {
        let magnify = MagnifyGesture()
            .updating($gestureScale) { value, state, _ in
                state = value.magnification
            }
            .onEnded { value in
                scale *= value.magnification
            }

        let rotate = RotateGesture()
            .updating($gestureAngle) { value, state, _ in
                state = value.rotation
            }
            .onEnded { value in
                angle += value.rotation
            }

        Image("photo")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .scaleEffect(scale * gestureScale)
            .rotationEffect(angle + gestureAngle)
            .gesture(magnify.simultaneously(with: rotate))
    }
}

.sequenced

Second gesture only activates after the first succeeds.

// Long press then drag
struct LongPressDrag: View {
    @State private var offset = CGSize.zero
    @GestureState private var isLongPressing = false

    var body: some View {
        let longPressThenDrag = LongPressGesture(minimumDuration: 0.5)
            .sequenced(before: DragGesture())
            .updating($isLongPressing) { value, state, _ in
                switch value {
                case .first(true):
                    state = true   // Long press active
                case .second(true, let drag):
                    state = true   // Dragging
                    if let drag {
                        // Can't use @GestureState for offset here
                        // but we can use it for visual feedback
                    }
                default:
                    state = false
                }
            }
            .onEnded { value in
                guard case .second(true, let drag?) = value else { return }
                offset = drag.translation
            }

        Circle()
            .fill(isLongPressing ? .red : .blue)
            .frame(width: 80, height: 80)
            .offset(offset)
            .scaleEffect(isLongPressing ? 1.2 : 1.0)
            .animation(.spring, value: isLongPressing)
            .gesture(longPressThenDrag)
    }
}

.exclusively

Only one gesture is recognized; the first to start wins.

let tap = TapGesture()
    .onEnded { handleTap() }

let longPress = LongPressGesture(minimumDuration: 0.5)
    .onEnded { _ in handleLongPress() }

// Long press takes priority; tap recognized only if long press fails
view.gesture(longPress.exclusively(before: tap))

@GestureState

A property wrapper that automatically resets to its initial value when the gesture ends. Ideal for transient visual feedback during a gesture.

struct PressableButton: View {
    @GestureState private var isPressed = false

    var body: some View {
        Text("Press Me")
            .padding()
            .background(isPressed ? .blue.opacity(0.8) : .blue)
            .foregroundStyle(.white)
            .clipShape(Capsule())
            .scaleEffect(isPressed ? 0.95 : 1.0)
            .animation(.spring(duration: 0.2), value: isPressed)
            .gesture(
                LongPressGesture(minimumDuration: .infinity)
                    .updating($isPressed) { currentState, gestureState, _ in
                        gestureState = true
                    }
            )
    }
}

Key difference from @State:
 
@GestureState
 resets automatically when the gesture ends. With 
@State
 you must manually reset in 
.onEnded
.

Complex GestureState

struct DragState {
    var translation: CGSize = .zero
    var isDragging: Bool = false
}

@GestureState private var dragState = DragState()

DragGesture()
    .updating($dragState) { value, state, _ in
        state = DragState(
            translation: value.translation,
            isDragging: true
        )
    }

Gesture Modifiers and Priority

.highPriorityGesture

Takes priority over child view gestures.

VStack {
    Button("Child Button") { childAction() }
}
.highPriorityGesture(
    TapGesture().onEnded { parentAction() }
)
// Parent tap overrides the child button

.simultaneousGesture

Recognized alongside child view gestures.

ScrollView {
    Content()
}
.simultaneousGesture(
    DragGesture().onChanged { value in
        // Track drag without blocking ScrollView scrolling
        trackDragPosition(value.location)
    }
)

Gesture mask

// Only apply gesture to specific subviews
.gesture(dragGesture, including: .subviews)  // .all, .gesture, .subviews, .none

Custom Gesture Modifiers

Encapsulate reusable gesture logic.

struct DraggableModifier: ViewModifier {
    @State private var offset = CGSize.zero
    let axis: Axis?
    let onDragEnd: ((CGSize) -> Void)?

    func body(content: Content) -> some View {
        content
            .offset(constrainedOffset)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        offset = value.translation
                    }
                    .onEnded { value in
                        onDragEnd?(value.translation)
                        withAnimation(.spring) {
                            offset = .zero
                        }
                    }
            )
    }

    var constrainedOffset: CGSize {
        switch axis {
        case .horizontal:
            CGSize(width: offset.width, height: 0)
        case .vertical:
            CGSize(width: 0, height: offset.height)
        case nil:
            offset
        }
    }
}

extension View {
    func draggable(axis: Axis? = nil, onDragEnd: ((CGSize) -> Void)? = nil) -> some View {
        modifier(DraggableModifier(axis: axis, onDragEnd: onDragEnd))
    }
}

// Usage
Card().draggable(axis: .horizontal) { translation in
    if translation.width > 100 { markAsRead() }
}

Gesture with Animation Integration

Interactive Spring-Back

struct InteractiveCard: View {
    @State private var offset = CGSize.zero
    @State private var isDragging = false

    var body: some View {
        RoundedRectangle(cornerRadius: 20)
            .fill(.blue.gradient)
            .frame(width: 280, height: 180)
            .shadow(
                color: .black.opacity(isDragging ? 0.3 : 0.1),
                radius: isDragging ? 20 : 8,
                y: isDragging ? 10 : 4
            )
            .offset(offset)
            .scaleEffect(isDragging ? 1.05 : 1.0)
            .rotationEffect(.degrees(Double(offset.width / 20)))
            .gesture(
                DragGesture()
                    .onChanged { value in
                        withAnimation(.interactiveSpring) {
                            offset = value.translation
                            isDragging = true
                        }
                    }
                    .onEnded { value in
                        withAnimation(.spring(duration: 0.5, bounce: 0.4)) {
                            offset = .zero
                            isDragging = false
                        }
                    }
            )
    }
}

Pull-to-Refresh with Custom Gesture

struct PullToRefresh: View {
    @State private var pullOffset: CGFloat = 0
    @State private var isRefreshing = false
    let threshold: CGFloat = 80

    var body: some View {
        VStack(spacing: 0) {
            // Pull indicator
            ZStack {
                if isRefreshing {
                    ProgressView()
                } else {
                    Image(systemName: "arrow.down")
                        .rotationEffect(.degrees(pullOffset > threshold ? 180 : 0))
                        .animation(.spring, value: pullOffset > threshold)
                }
            }
            .frame(height: max(0, pullOffset))
            .opacity(min(1, pullOffset / threshold))

            // Content
            ScrollView {
                LazyVStack {
                    ForEach(items) { item in
                        ItemRow(item: item)
                    }
                }
            }
        }
        .gesture(
            DragGesture()
                .onChanged { value in
                    if value.translation.height > 0 && !isRefreshing {
                        pullOffset = value.translation.height * 0.5 // Resistance
                    }
                }
                .onEnded { value in
                    if pullOffset > threshold {
                        withAnimation(.spring) { pullOffset = threshold }
                        isRefreshing = true
                        Task {
                            await refresh()
                            withAnimation(.spring) {
                                isRefreshing = false
                                pullOffset = 0
                            }
                        }
                    } else {
                        withAnimation(.spring) { pullOffset = 0 }
                    }
                }
        )
    }
}

Practical Patterns

Dismiss Gesture for Modal

struct DismissableSheet: View {
    @State private var dragOffset: CGFloat = 0
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        VStack {
            Capsule()
                .fill(.secondary)
                .frame(width: 40, height: 5)
                .padding(.top, 8)

            SheetContent()
        }
        .offset(y: max(0, dragOffset))
        .gesture(
            DragGesture()
                .onChanged { value in
                    dragOffset = value.translation.height
                }
                .onEnded { value in
                    if value.translation.height > 150 || value.velocity.height > 500 {
                        dismiss()
                    } else {
                        withAnimation(.spring(duration: 0.3)) {
                            dragOffset = 0
                        }
                    }
                }
        )
    }
}

Coordinate Multiple Gestures

struct PhotoViewer: View {
    @State private var scale: CGFloat = 1
    @State private var offset = CGSize.zero
    @State private var angle: Angle = .zero

    var body: some View {
        Image("photo")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .scaleEffect(scale)
            .offset(offset)
            .rotationEffect(angle)
            .gesture(
                MagnifyGesture()
                    .onChanged { value in
                        scale = value.magnification
                    }
                    .simultaneously(with:
                        RotateGesture()
                            .onChanged { value in
                                angle = value.rotation
                            }
                    )
                    .simultaneously(with:
                        DragGesture()
                            .onChanged { value in
                                offset = value.translation
                            }
                    )
            )
            .onTapGesture(count: 2) {
                withAnimation(.spring) {
                    scale = 1
                    offset = .zero
                    angle = .zero
                }
            }
    }
}

Tips and Best Practices

Use 
@GestureState
 for transient gesture feedback -- it resets automatically and is more efficient than 
@State
.
Always add 
withAnimation
 in 
.onEnded
 for smooth spring-back effects.
Use 
.simultaneousGesture
 when you need to observe gestures without blocking child interactions (e.g., tracking scroll position).
Set 
minimumDistance
 on 
DragGesture
 to avoid conflicting with scroll gestures (default is 10pt).
Combine 
.sensoryFeedback()
 with gestures for polished interactions.
Test gestures on real devices -- simulators do not support multi-touch or haptics.
For complex gesture interactions, profile with Instruments to ensure the main thread stays responsive.

---

# SwiftUI iOS 27 Interactions
https://nagarjuna2997.github.io/ios-agent-skill/guides/swiftui-ios-27-interactions.html

Swiftui · Reference guideSwiftUI iOS 27 InteractionsRepository guidance for SwiftUI iOS 27 Interactions. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Overview
1. Reordering in Lists, Stacks, Grids, and Custom Layouts
2. Swipe Actions Outside List
3. Adaptive Toolbars
4. Testing
5. Review Checklist
Released API review (2026-09-16)

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Identify interaction requirement
↓2
Check SDK declarations
↓3
Implement supported behavior
↓4
Verify fallback and accessibility

02 / ArchitectureResponsibility boundariesBoundary 1
Interaction intentBoundary 2
Availability boundaryBoundary 3
Adaptive controlsConnected responsibilities, not a required class hierarchy or an execution trace.
Overview

SwiftUI's iOS 27 generation adds interaction APIs for custom containers that previously required 
List
 or hand-rolled drag/swipe behavior: reorderable content, swipe actions in arbitrary row containers, and richer toolbar adaptation.

iOS 27 is released. Guard new APIs with availability; this guide’s iOS 27 examples still require compilation with Xcode 27. 
Release verification
.

1. Reordering in Lists, Stacks, Grids, and Custom Layouts

Use 
reorderable()
 on the 
ForEach
 that produces reorderable views, then place a 
reorderContainer
 on the enclosing container.

import SwiftUI

@available(iOS 27.0, macOS 27.0, *)
struct PhotoGrid: View {
    @State private var photos: [Photo] = []

    var body: some View {
        LazyVGrid(columns: [.init(.adaptive(minimum: 120))]) {
            ForEach(photos) { photo in
                PhotoTile(photo: photo)
            }
            .reorderable()
        }
        .reorderContainer(for: Photo.self) { difference in
            apply(difference)
        }
    }

    private func apply(_ difference: ReorderDifference<Photo.ID, ReorderableSingleCollectionIdentifier>) {
        // Update the source of truth from the system-provided difference.
    }
}

Rules:

Item identifiers must be stable, 
Hashable
, and 
Sendable
.
Update the data source in the 
move
 closure; do not rely on view order alone.
Disable reordering while saves or sync merges are in flight.
For multiple sections, use 
reorderable(collectionID:)
.

2. Swipe Actions Outside 
List

Use the new 
swipeActions
 overload when you need presentation state, and add 
swipeActionsContainer()
 to the scroll/container view.

@available(iOS 27.0, *)
struct MessagesView: View {
    @State private var swipedMessageID: Message.ID?
    let messages: [Message]

    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(messages) { message in
                    MessageRow(message: message)
                        .swipeActions(edge: .trailing, allowsFullSwipe: true) {
                            Button(role: .destructive) {
                                delete(message)
                            } label: {
                                Label("Delete", systemImage: "trash")
                            }
                        } onPresentationChanged: { isPresented in
                            swipedMessageID = isPresented ? message.id : nil
                        }
                }
            }
        }
        .swipeActionsContainer()
    }
}

List
 already coordinates swipe actions. Use 
swipeActionsContainer()
 for 
ScrollView
, stacks, grids, and custom row layouts so only one row is open and scrolling/tapping dismisses actions.

3. Adaptive Toolbars

The iOS 27 toolbar direction is adaptive: content should survive compact widths, overflow, user customization, and platform differences.

Review toolbars for:

semantic placement instead of hardcoded positions
stable labels and SF Symbols
priority for actions that must remain visible
overflow behavior for secondary actions
pinned trailing items only when the task requires persistent access
keyboard and pointer alternatives on iPad/macOS

Avoid toolbar-only workflows. Every critical action still needs an accessible path when it moves to overflow.

4. Testing

Test these interaction surfaces on:

compact iPhone portrait
iPhone landscape
iPad split view
iPad Stage Manager / resizable windows
Dynamic Type accessibility sizes
VoiceOver
pointer/keyboard input

For reordering, include sync and persistence tests. For swipe actions, include dismissal, full-swipe, destructive confirmation, and VoiceOver action alternatives.

5. Review Checklist

[ ] iOS 27 APIs have availability guards
[ ] Reorder identifiers are stable, 
Hashable
, and 
Sendable
[ ] Reorder closure updates the source of truth
[ ] Reordering disables during conflicting saves/sync
[ ] Custom swipe rows use 
swipeActionsContainer()
[ ] Destructive swipe actions have undo or confirmation where needed
[ ] Toolbar actions remain reachable in compact/overflow states
[ ] Interaction tests cover iPad resizability and accessibility

See also: 
docs/swiftui/views-and-controls.md
, 
docs/swiftui/layout.md
, 
docs/tooling/device-hub.md
.

Released API review (2026-09-16)

Apple’s SwiftUI updates
 document 
reorderable()
 with 
reorderContainer(for:isEnabled:move:)
, and URL-based documents using 
ReadableDocument
, 
WritableDocument
, and 
URLDocumentConfiguration
. Keep the actual availability guards; older document protocols and container approaches do not become deprecated merely because these additions exist.

The static SwiftUI reviewer’s regression tests accept these new names. No speculative deprecation list was added. Prefetching and widget styling require API-specific review and runtime tests; a lexical scan cannot establish their performance or Siri behavior.

---

# SwiftUI Layout System
https://nagarjuna2997.github.io/ios-agent-skill/guides/swiftui-layout.html

Swiftui · Reference guideSwiftUI Layout SystemRepository guidance for SwiftUI Layout System. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

VStack, HStack, ZStack

VStack (Vertical)
HStack (Horizontal)
ZStack (Overlay)

Spacer and Divider
Grid and GridRow (iOS 16+)
ViewThatFits (iOS 16+)
GeometryReader and GeometryProxy

Reading Scroll Position

LazyVGrid and LazyHGrid

Grid Item Types
LazyVGrid Examples
LazyHGrid

Frame, Padding, Offset

.frame()
.padding()
.offset()

Safe Area

safeAreaInset (iOS 15+)
safeAreaPadding (iOS 17+)
Ignoring Safe Area

Custom Layout Protocol (iOS 16+)

Animated Layout Transitions

ScrollView and ScrollViewReader

ScrollViewReader
scrollPosition (iOS 17+)
containerRelativeFrame (iOS 17+)
scrollTransition (iOS 17+)

ContentUnavailableView (iOS 17+)
Layout Tips and Patterns

Proportional Layout
Adaptive Layout Based on Size Class
Alignment Guides

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Identify content constraints
↓2
Choose a layout container
↓3
Adapt to available space
↓4
Test text and window resizing

02 / ArchitectureResponsibility boundariesBoundary 1
Parent proposalBoundary 2
Child measurementsBoundary 3
Positioned contentConnected responsibilities, not a required class hierarchy or an execution trace.
Complete reference for stacks, grids, geometry, custom layouts, scroll views, and spatial positioning.

VStack, HStack, ZStack

The fundamental building blocks of SwiftUI layout.

VStack (Vertical)

VStack(alignment: .leading, spacing: 12) {
    Text("Title").font(.headline)
    Text("Subtitle").font(.subheadline)
    Text("Body text goes here").font(.body)
}

Alignment options:
 
.leading
, 
.center
 (default), 
.trailing
, 
.listRowSeparatorLeading
, 
.listRowSeparatorTrailing

HStack (Horizontal)

HStack(alignment: .firstTextBaseline, spacing: 8) {
    Image(systemName: "star.fill")
    Text("4.8")
        .font(.title)
    Text("(128 reviews)")
        .font(.caption)
        .foregroundStyle(.secondary)
}

Alignment options:
 
.top
, 
.center
 (default), 
.bottom
, 
.firstTextBaseline
, 
.lastTextBaseline

ZStack (Overlay)

ZStack(alignment: .bottomTrailing) {
    Image("photo")
        .resizable()
        .aspectRatio(contentMode: .fill)
        .frame(width: 200, height: 200)

    Text("NEW")
        .font(.caption)
        .padding(6)
        .background(.red)
        .foregroundStyle(.white)
        .clipShape(Capsule())
        .padding(8)
}
.clipShape(RoundedRectangle(cornerRadius: 12))

Alignment:
 Any combination of horizontal and vertical -- 
.topLeading
, 
.top
, 
.topTrailing
, 
.leading
, 
.center
, 
.trailing
, 
.bottomLeading
, 
.bottom
, 
.bottomTrailing
.

Spacer and Divider

// Spacer pushes content apart
HStack {
    Text("Leading")
    Spacer()              // Fills all available space
    Text("Trailing")
}

HStack {
    Text("Item")
    Spacer(minLength: 20) // Minimum 20pt gap
    Text("Value")
}

// Divider -- thin line separator
VStack {
    Text("Section 1")
    Divider()
        .background(.blue)   // Custom color
    Text("Section 2")
}

// Horizontal divider in HStack
HStack {
    Text("Left")
    Divider()
        .frame(height: 30)
    Text("Right")
}

Grid and GridRow (iOS 16+)

Fixed layout grid with aligned columns -- unlike LazyVGrid, renders all content immediately.

Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {
    GridRow {
        Text("Name")
            .gridColumnAlignment(.trailing)
        Text("John Doe")
    }
    GridRow {
        Text("Email")
        Text("john@example.com")
    }
    GridRow {
        Text("Bio")
        Text("A longer piece of text that spans the available width")
    }

    Divider()
        .gridCellUnsizedAxes(.horizontal) // Span full width

    GridRow {
        Color.clear
            .gridCellColumns(2)           // Span multiple columns
            .frame(height: 1)
    }

    GridRow {
        Text("Actions")
        HStack {
            Button("Edit") { }
            Button("Delete", role: .destructive) { }
        }
    }
}

ViewThatFits (iOS 16+)

Picks the first child view that fits the available space.

ViewThatFits(in: .horizontal) {
    // First choice: full layout
    HStack {
        Image(systemName: "star.fill")
        Text("Add to Favorites")
        Spacer()
        Text("128 people favorited this")
    }

    // Second choice: compact layout
    HStack {
        Image(systemName: "star.fill")
        Text("Favorite")
        Spacer()
        Text("128")
    }

    // Third choice: icon only
    Image(systemName: "star.fill")
}

Commonly used for adaptive layouts that work across iPhone SE to iPad.

GeometryReader and GeometryProxy

Reads the size and position of the parent container.

GeometryReader { proxy in
    let width = proxy.size.width
    let height = proxy.size.height

    VStack {
        Rectangle()
            .fill(.blue)
            .frame(width: width * 0.8, height: height * 0.3)

        Text("Width: \(Int(width)), Height: \(Int(height))")
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity)
}

Reading Scroll Position

ScrollView {
    GeometryReader { proxy in
        Color.clear.preference(
            key: ScrollOffsetKey.self,
            value: proxy.frame(in: .named("scroll")).minY
        )
    }
    .frame(height: 0)

    LazyVStack { /* content */ }
}
.coordinateSpace(name: "scroll")
.onPreferenceChange(ScrollOffsetKey.self) { offset in
    headerOpacity = min(1, max(0, -offset / 100))
}

struct ScrollOffsetKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

Warning:
 
GeometryReader
 is greedy -- it takes all proposed space. Wrap it carefully or use it inside 
.background
/
.overlay
 to avoid layout issues.

// Better pattern: read size without affecting layout
Text("Content")
    .background {
        GeometryReader { proxy in
            Color.clear
                .onAppear { contentSize = proxy.size }
                .onChange(of: proxy.size) { _, newSize in
                    contentSize = newSize
                }
        }
    }

LazyVGrid and LazyHGrid

Grid Item Types

// Fixed: exact width
GridItem(.fixed(100))

// Flexible: fills available space within range
GridItem(.flexible(minimum: 80, maximum: 200))

// Adaptive: fits as many as possible within range
GridItem(.adaptive(minimum: 100, maximum: 150))

LazyVGrid Examples

// Two equal columns
let twoColumns = [
    GridItem(.flexible()),
    GridItem(.flexible())
]

// Three fixed columns
let threeFixed = [
    GridItem(.fixed(100)),
    GridItem(.fixed(100)),
    GridItem(.fixed(100))
]

// Adaptive (responsive)
let adaptive = [
    GridItem(.adaptive(minimum: 120))
]

ScrollView {
    LazyVGrid(columns: adaptive, spacing: 16) {
        ForEach(items) { item in
            VStack {
                AsyncImage(url: item.imageURL) { image in
                    image.resizable().aspectRatio(contentMode: .fill)
                } placeholder: {
                    Color.gray.opacity(0.3)
                }
                .frame(height: 150)
                .clipShape(RoundedRectangle(cornerRadius: 8))

                Text(item.title)
                    .font(.caption)
                    .lineLimit(1)
            }
        }
    }
    .padding()
}

LazyHGrid

let rows = [
    GridItem(.fixed(80)),
    GridItem(.fixed(80))
]

ScrollView(.horizontal) {
    LazyHGrid(rows: rows, spacing: 12) {
        ForEach(items) { item in
            ItemCell(item: item)
        }
    }
    .padding()
}

Frame, Padding, Offset

.frame()

// Exact size
Text("Fixed").frame(width: 200, height: 50)

// Flexible constraints
Text("Flexible")
    .frame(minWidth: 100, maxWidth: 300, minHeight: 44)

// Fill available width
Text("Full Width")
    .frame(maxWidth: .infinity, alignment: .leading)

// Fill available space
Color.blue
    .frame(maxWidth: .infinity, maxHeight: .infinity)

// Ideal size (proposed when parent uses .fixedSize)
Text("Ideal").frame(idealWidth: 200, idealHeight: 100)

// fixedSize prevents truncation
Text("This long text will not be truncated")
    .fixedSize(horizontal: true, vertical: false)

.padding()

Text("Padded")
    .padding()                           // Default on all edges (~16pt)
    .padding(20)                         // Custom amount all edges
    .padding(.horizontal, 16)           // Specific edge set
    .padding(.top, 8)                   // Single edge
    .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))

.offset()

Moves the view visually without changing its layout position.

Circle()
    .fill(.blue)
    .frame(width: 50, height: 50)
    .offset(x: 20, y: -10)  // Moves right 20, up 10

// Common pattern: notification badge
ZStack(alignment: .topTrailing) {
    Image(systemName: "bell")
        .font(.title)
    Text("3")
        .font(.caption2)
        .padding(4)
        .background(.red)
        .foregroundStyle(.white)
        .clipShape(Circle())
        .offset(x: 8, y: -8)
}

Safe Area

safeAreaInset (iOS 15+)

Adds content in the safe area without overlapping.

ScrollView {
    LazyVStack {
        ForEach(messages) { message in
            MessageRow(message: message)
        }
    }
}
.safeAreaInset(edge: .bottom) {
    HStack {
        TextField("Message", text: $newMessage)
            .textFieldStyle(.roundedBorder)
        Button("Send", systemImage: "arrow.up.circle.fill") {
            sendMessage()
        }
    }
    .padding()
    .background(.bar)
}

safeAreaPadding (iOS 17+)

Adds padding within the safe area.

ScrollView(.horizontal) {
    LazyHStack {
        ForEach(items) { item in
            ItemCard(item: item)
        }
    }
}
.safeAreaPadding(.horizontal, 16)  // Content scrolls edge-to-edge but starts padded

Ignoring Safe Area

Color.blue
    .ignoresSafeArea()                    // All edges
    .ignoresSafeArea(.keyboard)           // Only keyboard
    .ignoresSafeArea(.container, edges: .bottom) // Only bottom

Custom Layout Protocol (iOS 16+)

Create completely custom layout logic.

struct RadialLayout: Layout {
    var radius: CGFloat
    var startAngle: Angle = .zero

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let maxSize = subviews.reduce(CGSize.zero) { currentMax, subview in
            let size = subview.sizeThatFits(.unspecified)
            return CGSize(
                width: max(currentMax.width, size.width),
                height: max(currentMax.height, size.height)
            )
        }
        return CGSize(
            width: (radius + maxSize.width) * 2,
            height: (radius + maxSize.height) * 2
        )
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let angleStep = Angle.degrees(360.0 / Double(subviews.count))

        for (index, subview) in subviews.enumerated() {
            let angle = startAngle + angleStep * Double(index)
            let x = bounds.midX + radius * cos(angle.radians)
            let y = bounds.midY + radius * sin(angle.radians)

            subview.place(
                at: CGPoint(x: x, y: y),
                anchor: .center,
                proposal: .unspecified
            )
        }
    }
}

// Usage
RadialLayout(radius: 100) {
    ForEach(0..<8) { i in
        Circle()
            .fill(Color(hue: Double(i) / 8, saturation: 0.8, brightness: 0.9))
            .frame(width: 40, height: 40)
    }
}

Animated Layout Transitions

struct ContentView: View {
    @State private var useRadial = false

    var body: some View {
        let layout = useRadial ? AnyLayout(RadialLayout(radius: 120)) : AnyLayout(HStackLayout())

        layout {
            ForEach(items) { item in
                ItemView(item: item)
            }
        }
        .animation(.spring, value: useRadial)

        Button("Toggle Layout") { useRadial.toggle() }
    }
}

ScrollView and ScrollViewReader

ScrollViewReader

Programmatic scrolling to specific views.

ScrollViewReader { proxy in
    ScrollView {
        LazyVStack {
            ForEach(messages) { message in
                MessageRow(message: message)
                    .id(message.id)
            }
        }
    }
    .onChange(of: messages.count) { _, _ in
        withAnimation {
            proxy.scrollTo(messages.last?.id, anchor: .bottom)
        }
    }
}

scrollPosition (iOS 17+)

@State private var scrollPosition: String?

ScrollView {
    LazyVStack {
        ForEach(items) { item in
            ItemCard(item: item)
                .id(item.id)
        }
    }
    .scrollTargetLayout()
}
.scrollPosition(id: $scrollPosition)
.onChange(of: scrollPosition) { _, id in
    print("Visible item: \(id ?? "none")")
}

containerRelativeFrame (iOS 17+)

Size views relative to the scroll container.

ScrollView(.horizontal) {
    LazyHStack(spacing: 16) {
        ForEach(items) { item in
            ItemCard(item: item)
                .containerRelativeFrame(.horizontal, count: 1, spacing: 16)
                // Full width card, one at a time
        }
    }
    .scrollTargetLayout()
}
.scrollTargetBehavior(.paging) // .paging or .viewAligned
.scrollIndicators(.hidden)

scrollTransition (iOS 17+)

Apply effects as views enter/exit the scroll viewport.

ScrollView {
    LazyVStack(spacing: 16) {
        ForEach(items) { item in
            ItemCard(item: item)
                .scrollTransition { content, phase in
                    content
                        .opacity(phase.isIdentity ? 1 : 0.3)
                        .scaleEffect(phase.isIdentity ? 1 : 0.8)
                        .blur(radius: phase.isIdentity ? 0 : 2)
                }
        }
    }
}

ContentUnavailableView (iOS 17+)

Standard empty state view.

// Built-in search empty state
ContentUnavailableView.search

// Search with query
ContentUnavailableView.search(text: searchText)

// Custom
ContentUnavailableView {
    Label("No Favorites", systemImage: "heart.slash")
} description: {
    Text("Items you favorite will appear here.")
} actions: {
    Button("Browse Items") { showBrowse = true }
        .buttonStyle(.borderedProminent)
}

Layout Tips and Patterns

Proportional Layout

GeometryReader { proxy in
    HStack(spacing: 0) {
        LeftPanel()
            .frame(width: proxy.size.width * 0.3)
        RightPanel()
            .frame(width: proxy.size.width * 0.7)
    }
}

Adaptive Layout Based on Size Class

struct AdaptiveView: View {
    @Environment(\.horizontalSizeClass) private var sizeClass

    var body: some View {
        if sizeClass == .compact {
            VStack { content }
        } else {
            HStack { content }
        }
    }

    @ViewBuilder
    var content: some View {
        SidebarView()
        DetailView()
    }
}

Alignment Guides

HStack(alignment: .customCenter) {
    Text("Label")
        .alignmentGuide(.customCenter) { d in d[VerticalAlignment.center] }
    Circle()
        .fill(.blue)
        .frame(width: 10, height: 10)
        .alignmentGuide(.customCenter) { d in d[VerticalAlignment.center] }
}

extension VerticalAlignment {
    struct CustomCenter: AlignmentID {
        static func defaultValue(in context: ViewDimensions) -> CGFloat {
            context[VerticalAlignment.center]
        }
    }
    static let customCenter = VerticalAlignment(CustomCenter.self)
}

---

# SwiftUI Navigation
https://nagarjuna2997.github.io/ios-agent-skill/guides/swiftui-navigation.html

Swiftui · Reference guideSwiftUI NavigationRepository guidance for SwiftUI Navigation. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

NavigationStack

Value-Based NavigationLink with .navigationDestination
NavigationPath (Programmatic Navigation)
Typed path (homogeneous)

NavigationSplitView

Two-Column
Three-Column

Sheets, Full Screen Covers, and Popovers

.sheet()
Presentation Detents (Bottom Sheet Sizes)
.fullScreenCover()
.popover()

Alerts and Confirmation Dialogs

.alert()
.confirmationDialog()

.inspector() (iOS 17+)
TabView

Basic TabView
TabView with Enum
iOS 18+ Tab API

Deep Linking with URL Handling
Toolbar and Navigation Bar Customization
Dismiss and Navigation Actions
Type-Safe Routing Pattern
iOS 18 Zoom Navigation Transitions

Core Concepts
Basic Usage
Complete Example: Photo Grid with Zoom Transition
Using Zoom Transitions with Lists
Customizing the Transition Source Appearance
When to Use Zoom Transitions vs matchedGeometryEffect

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Choose navigation structure
↓2
Represent selection as data
↓3
Present destination
↓4
Handle dismissal and restoration

02 / ArchitectureResponsibility boundariesBoundary 1
Navigation stateBoundary 2
Destination mappingBoundary 3
Presented viewsConnected responsibilities, not a required class hierarchy or an execution trace.
Complete reference for NavigationStack, NavigationSplitView, sheets, alerts, tabs, deep linking, and programmatic navigation.

NavigationStack

The primary navigation container (iOS 16+). Replaces the deprecated 
NavigationView
.

struct ContentView: View {
    var body: some View {
        NavigationStack {
            List(items) { item in
                NavigationLink(item.title) {
                    DetailView(item: item)
                }
            }
            .navigationTitle("Items")
            .navigationBarTitleDisplayMode(.large) // .inline, .large, .automatic
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("Add", systemImage: "plus") { addItem() }
                }
            }
        }
    }
}

Value-Based NavigationLink with .navigationDestination

The preferred pattern -- decouples the link from its destination.

struct ContentView: View {
    var body: some View {
        NavigationStack {
            List {
                NavigationLink("Show Profile", value: Route.profile("user-123"))
                NavigationLink("Settings", value: Route.settings)

                ForEach(items) { item in
                    NavigationLink(value: item) {
                        ItemRow(item: item)
                    }
                }
            }
            .navigationDestination(for: Item.self) { item in
                ItemDetailView(item: item)
            }
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .profile(let id):
                    ProfileView(userId: id)
                case .settings:
                    SettingsView()
                }
            }
        }
    }
}

enum Route: Hashable {
    case profile(String)
    case settings
    case detail(Item)
}

NavigationPath (Programmatic Navigation)

A type-erased path that supports heterogeneous value types.

@Observable
class Router {
    var path = NavigationPath()

    func goToProfile(_ id: String) {
        path.append(Route.profile(id))
    }

    func goToDetail(_ item: Item) {
        path.append(item)
    }

    func popToRoot() {
        path = NavigationPath()
    }

    func pop() {
        if !path.isEmpty {
            path.removeLast()
        }
    }
}

struct AppView: View {
    @State private var router = Router()

    var body: some View {
        NavigationStack(path: $router.path) {
            HomeView()
                .navigationDestination(for: Route.self) { route in
                    routeView(for: route)
                }
                .navigationDestination(for: Item.self) { item in
                    ItemDetailView(item: item)
                }
        }
        .environment(router)
    }

    @ViewBuilder
    func routeView(for route: Route) -> some View {
        switch route {
        case .profile(let id): ProfileView(userId: id)
        case .settings: SettingsView()
        case .detail(let item): ItemDetailView(item: item)
        }
    }
}

// Deep push from anywhere
struct SomeChildView: View {
    @Environment(Router.self) private var router

    var body: some View {
        Button("Go to Profile") {
            router.goToProfile("user-456")
        }
    }
}

Typed path (homogeneous)

@State private var path: [Item] = []

NavigationStack(path: $path) {
    List(items) { item in
        NavigationLink(value: item) { Text(item.title) }
    }
    .navigationDestination(for: Item.self) { item in
        DetailView(item: item)
    }
}

NavigationSplitView

Multi-column navigation for iPad and Mac. Falls back to stack on iPhone.

Two-Column

struct TwoColumnView: View {
    @State private var selectedItem: Item?

    var body: some View {
        NavigationSplitView {
            // Sidebar
            List(items, selection: $selectedItem) { item in
                NavigationLink(value: item) {
                    Text(item.title)
                }
            }
            .navigationTitle("Items")
        } detail: {
            if let item = selectedItem {
                ItemDetailView(item: item)
            } else {
                ContentUnavailableView("No Selection",
                    systemImage: "doc.text",
                    description: Text("Select an item from the sidebar"))
            }
        }
    }
}

Three-Column

struct ThreeColumnView: View {
    @State private var selectedCategory: Category?
    @State private var selectedItem: Item?

    var body: some View {
        NavigationSplitView {
            // Sidebar
            List(categories, selection: $selectedCategory) { cat in
                Label(cat.name, systemImage: cat.icon)
            }
            .navigationTitle("Categories")
        } content: {
            // Content list
            if let category = selectedCategory {
                List(category.items, selection: $selectedItem) { item in
                    Text(item.title)
                }
                .navigationTitle(category.name)
            }
        } detail: {
            // Detail
            if let item = selectedItem {
                ItemDetailView(item: item)
            } else {
                ContentUnavailableView.search
            }
        }
        .navigationSplitViewStyle(.balanced) // .balanced, .prominentDetail, .automatic
        .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 350)
    }
}

Sheets, Full Screen Covers, and Popovers

.sheet()

@State private var showSettings = false
@State private var selectedItem: Item?

var body: some View {
    VStack {
        Button("Settings") { showSettings = true }

        ForEach(items) { item in
            Button(item.title) { selectedItem = item }
        }
    }
    // Boolean-driven
    .sheet(isPresented: $showSettings) {
        SettingsView()
            .presentationDetents([.medium, .large])       // iOS 16+
            .presentationDragIndicator(.visible)
            .presentationCornerRadius(24)                  // iOS 16.4+
            .presentationBackground(.ultraThinMaterial)    // iOS 16.4+
            .interactiveDismissDisabled()                  // Prevent swipe dismiss
    }
    // Item-driven (auto-dismisses when nil)
    .sheet(item: $selectedItem) { item in
        ItemDetailView(item: item)
    }
}

Presentation Detents (Bottom Sheet Sizes)

.sheet(isPresented: $showSheet) {
    SheetContent()
        .presentationDetents([.height(200), .medium, .large])
        .presentationDetents([.fraction(0.25), .medium])

        // Custom detent
        .presentationDetents([.custom(MyDetent.self)])
}

struct MyDetent: CustomPresentationDetent {
    static func height(in context: Context) -> CGFloat? {
        max(context.maxDetentValue * 0.3, 300)
    }
}

.fullScreenCover()

@State private var showOnboarding = false

.fullScreenCover(isPresented: $showOnboarding) {
    OnboardingView()
}

.popover()

@State private var showPopover = false

Button("Info") { showPopover = true }
    .popover(isPresented: $showPopover, arrowEdge: .top) {
        VStack {
            Text("Helpful info")
            Text("More details here")
        }
        .padding()
        .frame(minWidth: 200)
    }

Alerts and Confirmation Dialogs

.alert()

@State private var showAlert = false
@State private var error: AppError?

// Boolean-driven
.alert("Delete Item?", isPresented: $showAlert) {
    Button("Delete", role: .destructive) { deleteItem() }
    Button("Cancel", role: .cancel) { }
} message: {
    Text("This action cannot be undone.")
}

// Error-driven with item binding
.alert("Error", isPresented: .constant(error != nil), presenting: error) { _ in
    Button("Retry") { retry() }
    Button("OK", role: .cancel) { error = nil }
} message: { error in
    Text(error.localizedDescription)
}

.confirmationDialog()

Action sheet style on iPhone, popover on iPad.

@State private var showDialog = false

.confirmationDialog("Sort By", isPresented: $showDialog, titleVisibility: .visible) {
    Button("Name") { sort = .name }
    Button("Date") { sort = .date }
    Button("Size") { sort = .size }
    Button("Cancel", role: .cancel) { }
} message: {
    Text("Choose how to sort your items")
}

.inspector() (iOS 17+)

A trailing column overlay for supplementary content.

@State private var showInspector = false

NavigationStack {
    ContentView()
        .toolbar {
            Button("Inspector", systemImage: "info.circle") {
                showInspector.toggle()
            }
        }
        .inspector(isPresented: $showInspector) {
            InspectorView()
                .inspectorColumnWidth(min: 200, ideal: 300, max: 400)
        }
}

TabView

Basic TabView

struct MainTabView: View {
    @State private var selectedTab = 0

    var body: some View {
        TabView(selection: $selectedTab) {
            HomeView()
                .tabItem {
                    Label("Home", systemImage: "house")
                }
                .tag(0)

            SearchView()
                .tabItem {
                    Label("Search", systemImage: "magnifyingglass")
                }
                .tag(1)

            ProfileView()
                .tabItem {
                    Label("Profile", systemImage: "person")
                }
                .tag(2)
                .badge(3)  // Notification badge
        }
    }
}

TabView with Enum

enum AppTab: String, CaseIterable {
    case home, search, favorites, profile

    var title: String { rawValue.capitalized }
    var icon: String {
        switch self {
        case .home: "house"
        case .search: "magnifyingglass"
        case .favorites: "heart"
        case .profile: "person"
        }
    }
}

struct MainView: View {
    @State private var selectedTab: AppTab = .home

    var body: some View {
        TabView(selection: $selectedTab) {
            ForEach(AppTab.allCases, id: \.self) { tab in
                NavigationStack {
                    tabContent(for: tab)
                }
                .tabItem { Label(tab.title, systemImage: tab.icon) }
                .tag(tab)
            }
        }
    }
}

iOS 18+ Tab API

TabView {
    Tab("Home", systemImage: "house") {
        HomeView()
    }

    Tab("Search", systemImage: "magnifyingglass") {
        SearchView()
    }

    TabSection("Library") {
        Tab("Favorites", systemImage: "heart") {
            FavoritesView()
        }
        Tab("Downloads", systemImage: "arrow.down.circle") {
            DownloadsView()
        }
    }

    Tab("Profile", systemImage: "person") {
        ProfileView()
    }
}
.tabViewStyle(.sidebarAdaptable) // Sidebar on iPad, tab bar on iPhone

Deep Linking with URL Handling

@main
struct MyApp: App {
    @State private var router = Router()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(router)
                .onOpenURL { url in
                    router.handle(url)
                }
        }
    }
}

@Observable
class Router {
    var path = NavigationPath()
    var selectedTab: AppTab = .home

    func handle(_ url: URL) {
        // myapp://items/123
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
              let host = components.host else { return }

        path = NavigationPath() // Reset

        switch host {
        case "items":
            selectedTab = .home
            if let id = components.path.split(separator: "/").first.map(String.init) {
                path.append(Route.detail(id))
            }
        case "profile":
            selectedTab = .profile
        case "settings":
            selectedTab = .home
            path.append(Route.settings)
        default:
            break
        }
    }
}

Info.plist URL scheme:

<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>myapp</string>
        </array>
    </dict>
</array>

Toolbar and Navigation Bar Customization

.toolbar {
    // Placement options
    ToolbarItem(placement: .topBarLeading) {
        Button("Edit") { }
    }
    ToolbarItem(placement: .topBarTrailing) {
        Menu("Sort", systemImage: "arrow.up.arrow.down") {
            Button("Name") { }
            Button("Date") { }
        }
    }
    ToolbarItem(placement: .bottomBar) {
        Text("\(items.count) items")
    }
    ToolbarItem(placement: .primaryAction) {
        Button("Add", systemImage: "plus") { }
    }

    // Group multiple items
    ToolbarItemGroup(placement: .topBarTrailing) {
        Button("Filter", systemImage: "line.3.horizontal.decrease.circle") { }
        Button("Add", systemImage: "plus") { }
    }
}
.toolbarBackground(.visible, for: .navigationBar)
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbarTitleDisplayMode(.inline)

// Searchable
.searchable(text: $searchText, prompt: "Search items")
.searchSuggestions {
    ForEach(suggestions) { suggestion in
        Text(suggestion.title)
            .searchCompletion(suggestion.title)
    }
}
.searchScopes($scope) {
    Text("All").tag(SearchScope.all)
    Text("Recent").tag(SearchScope.recent)
}

Dismiss and Navigation Actions

struct SheetView: View {
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        NavigationStack {
            Form { /* content */ }
                .navigationTitle("New Item")
                .toolbar {
                    ToolbarItem(placement: .cancellationAction) {
                        Button("Cancel") { dismiss() }
                    }
                    ToolbarItem(placement: .confirmationAction) {
                        Button("Save") {
                            save()
                            dismiss()
                        }
                    }
                }
        }
    }
}

Type-Safe Routing Pattern

enum Route: Hashable {
    case itemList(Category)
    case itemDetail(Item)
    case profile(userId: String)
    case settings
    case settingsDetail(SettingsSection)
}

@Observable
class Router {
    var path = NavigationPath()
    var sheet: Sheet?
    var alert: AlertItem?

    enum Sheet: Identifiable {
        case newItem
        case editItem(Item)
        var id: String {
            switch self {
            case .newItem: "newItem"
            case .editItem(let item): "edit-\(item.id)"
            }
        }
    }

    func push(_ route: Route) { path.append(route) }
    func pop() { if !path.isEmpty { path.removeLast() } }
    func popToRoot() { path = NavigationPath() }
    func present(_ sheet: Sheet) { self.sheet = sheet }
}

struct RootView: View {
    @State private var router = Router()

    var body: some View {
        NavigationStack(path: $router.path) {
            HomeScreen()
                .navigationDestination(for: Route.self) { route in
                    destination(for: route)
                }
        }
        .sheet(item: $router.sheet) { sheet in
            sheetContent(for: sheet)
        }
        .environment(router)
    }

    @ViewBuilder
    func destination(for route: Route) -> some View {
        switch route {
        case .itemList(let category): ItemListView(category: category)
        case .itemDetail(let item): ItemDetailView(item: item)
        case .profile(let id): ProfileView(userId: id)
        case .settings: SettingsView()
        case .settingsDetail(let section): SettingsDetailView(section: section)
        }
    }

    @ViewBuilder
    func sheetContent(for sheet: Router.Sheet) -> some View {
        switch sheet {
        case .newItem: NewItemView()
        case .editItem(let item): EditItemView(item: item)
        }
    }
}

iOS 18 Zoom Navigation Transitions

iOS 18 introduces the 
NavigationTransition
 protocol and built-in zoom transitions that create fluid, context-preserving animations between source and destination views. This replaces many custom 
matchedGeometryEffect
 patterns for navigation-based hero animations.

Core Concepts

The zoom transition system has three parts:

.navigationTransition(.zoom(sourceID:in:))
 -- applied to the destination view to define how it animates in.
.matchedTransitionSource(id:in:)
 -- applied to the source view (a grid cell, list row, etc.) to mark the origin of the zoom.
@Namespace
 -- a shared namespace that links the source and destination together.

Unlike 
matchedGeometryEffect
, which requires manual 
ZStack
 layering and conditional rendering, zoom navigation transitions work directly with 
NavigationStack
 and 
NavigationLink
. The system handles the interpolation between source and destination frames automatically.

Basic Usage

struct PhotoGridView: View {
    @Namespace private var zoomTransition

    let photos: [Photo]

    var body: some View {
        NavigationStack {
            ScrollView {
                LazyVGrid(columns: [GridItem(.adaptive(minimum: 100), spacing: 2)], spacing: 2) {
                    ForEach(photos) { photo in
                        NavigationLink(value: photo) {
                            AsyncImage(url: photo.thumbnailURL) { image in
                                image
                                    .resizable()
                                    .aspectRatio(contentMode: .fill)
                            } placeholder: {
                                Color.gray.opacity(0.3)
                            }
                            .frame(minHeight: 100)
                            .clipped()
                        }
                        .matchedTransitionSource(id: photo.id, in: zoomTransition)
                    }
                }
            }
            .navigationTitle("Photos")
            .navigationDestination(for: Photo.self) { photo in
                PhotoDetailView(photo: photo)
                    .navigationTransition(.zoom(sourceID: photo.id, in: zoomTransition))
            }
        }
    }
}

Complete Example: Photo Grid with Zoom Transition

import SwiftUI

struct Photo: Identifiable, Hashable {
    let id: UUID
    let imageName: String
    let title: String
    let date: Date
}

struct PhotoGalleryView: View {
    @Namespace private var zoomNamespace

    let photos: [Photo] = [
        Photo(id: UUID(), imageName: "photo1", title: "Sunset", date: .now),
        Photo(id: UUID(), imageName: "photo2", title: "Mountain", date: .now),
        Photo(id: UUID(), imageName: "photo3", title: "Ocean", date: .now),
        Photo(id: UUID(), imageName: "photo4", title: "Forest", date: .now),
        Photo(id: UUID(), imageName: "photo5", title: "City", date: .now),
        Photo(id: UUID(), imageName: "photo6", title: "Desert", date: .now),
    ]

    private let columns = [
        GridItem(.adaptive(minimum: 120), spacing: 4)
    ]

    var body: some View {
        NavigationStack {
            ScrollView {
                LazyVGrid(columns: columns, spacing: 4) {
                    ForEach(photos) { photo in
                        NavigationLink(value: photo) {
                            PhotoThumbnail(photo: photo)
                        }
                        .buttonStyle(.plain)
                        .matchedTransitionSource(id: photo.id, in: zoomNamespace)
                    }
                }
                .padding(4)
            }
            .navigationTitle("Gallery")
            .navigationDestination(for: Photo.self) { photo in
                PhotoDetailView(photo: photo)
                    .navigationTransition(.zoom(sourceID: photo.id, in: zoomNamespace))
            }
        }
    }
}

struct PhotoThumbnail: View {
    let photo: Photo

    var body: some View {
        Image(photo.imageName)
            .resizable()
            .aspectRatio(contentMode: .fill)
            .frame(minHeight: 120)
            .clipShape(RoundedRectangle(cornerRadius: 8))
    }
}

struct PhotoDetailView: View {
    let photo: Photo

    var body: some View {
        ScrollView {
            VStack(spacing: 16) {
                Image(photo.imageName)
                    .resizable()
                    .aspectRatio(contentMode: .fit)

                VStack(alignment: .leading, spacing: 8) {
                    Text(photo.title)
                        .font(.title)
                        .fontWeight(.bold)

                    Text(photo.date, style: .date)
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                }
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(.horizontal)

                Spacer()
            }
        }
        .navigationTitle(photo.title)
        .navigationBarTitleDisplayMode(.inline)
    }
}

Using Zoom Transitions with Lists

Zoom transitions also work with 
List
 rows:

struct ItemListView: View {
    @Namespace private var listZoom

    let items: [Item]

    var body: some View {
        NavigationStack {
            List(items) { item in
                NavigationLink(value: item) {
                    HStack {
                        Image(systemName: item.icon)
                            .font(.title2)
                            .frame(width: 44, height: 44)
                            .background(.blue.opacity(0.1))
                            .clipShape(RoundedRectangle(cornerRadius: 10))

                        VStack(alignment: .leading) {
                            Text(item.title)
                                .font(.headline)
                            Text(item.subtitle)
                                .font(.subheadline)
                                .foregroundStyle(.secondary)
                        }
                    }
                }
                .matchedTransitionSource(id: item.id, in: listZoom)
            }
            .navigationTitle("Items")
            .navigationDestination(for: Item.self) { item in
                ItemDetailView(item: item)
                    .navigationTransition(.zoom(sourceID: item.id, in: listZoom))
            }
        }
    }
}

Customizing the Transition Source Appearance

You can style the matched transition source with a clip shape and additional modifiers:

NavigationLink(value: photo) {
    PhotoThumbnail(photo: photo)
}
.matchedTransitionSource(id: photo.id, in: zoomNamespace) { source in
    source
        .clipShape(RoundedRectangle(cornerRadius: 16))
        .shadow(radius: 4)
}

When to Use Zoom Transitions vs matchedGeometryEffect

Scenario

Use

NavigationStack push/pop with grid or list

Zoom transition -- built-in, automatic, works with NavigationLink

Custom overlay expansions (ZStack-based)

matchedGeometryEffect -- full control over both states

Tab switches with shared elements

matchedGeometryEffect -- zoom transitions only work within NavigationStack

Sheet or full-screen cover presentations

matchedGeometryEffect -- zoom transitions are navigation-only

iOS 17 and earlier support required

matchedGeometryEffect -- zoom transitions require iOS 18+

Simple navigation hero animations

Zoom transition -- far less boilerplate, fewer edge cases

Key differences:

- 
Zoom transitions
 are managed entirely by the navigation system. You do not need 
ZStack
, conditional rendering, or manual animation triggers.
- 
matchedGeometryEffect
 requires you to manage both source and destination visibility yourself and wrap state changes in 
withAnimation
.
- Zoom transitions only work with 
NavigationStack
 push/pop. They do not work with sheets, full-screen covers, or custom presentation controllers.
- Zoom transitions gracefully degrade on older OS versions (the standard push animation plays instead).

---

# SwiftUI State and Data Flow
https://nagarjuna2997.github.io/ios-agent-skill/guides/swiftui-state-and-data-flow.html

Swiftui · Reference guideSwiftUI State and Data FlowRepository guidance for SwiftUI State and Data Flow. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

@State
@Binding
@Observable (iOS 17+, Observation Framework)
@Observable and Actor Isolation

Where the @MainActor goes
Observable models that are not view models

Observation Traps in Child Views

Trap 1: reads outside body are never tracked
Trap 2: passing the whole model down over-invalidates the child
Trap 3: the wrong wrapper in the child detaches it from the parent
Trap 4: mutating observable state during body
Trap 5: a collection element replaced wholesale invalidates every row

Async Boundaries in Views

.task over Task { } in onAppear
State written after an await may be stale
Overlapping calls need an explicit in-flight task

@Bindable (iOS 17+)
@ObservableObject and @Published (Legacy, pre-iOS 17)

@StateObject vs @ObservedObject

@Environment

Custom EnvironmentKey
Environment with @Observable (iOS 17+)

@EnvironmentObject (Legacy)
@AppStorage and @SceneStorage

@AppStorage
@SceneStorage

@Query (SwiftData Integration)
Data Flow Patterns and Best Practices

When to Use Which Property Wrapper
Recommended Architecture (iOS 17+)
State Hoisting Pattern
Action Closure Pattern
Avoiding Common Pitfalls
Property Wrapper Decision Checklist
Anti-Patterns Summary

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Choose a state owner
↓2
Pass values or bindings
↓3
Mutate through one owner
↓4
Observe dependent views

02 / ArchitectureResponsibility boundariesBoundary 1
State ownerBoundary 2
Binding or environmentBoundary 3
Dependent viewConnected responsibilities, not a required class hierarchy or an execution trace.
Complete reference for property wrappers, the Observation framework, and data flow patterns.

@State

Owns mutable state local to a view. SwiftUI manages storage; the view re-renders when the value changes.

struct CounterView: View {
    @State private var count = 0
    @State private var items: [String] = []

    var body: some View {
        VStack {
            Text("Count: \(count)")
            Button("Increment") { count += 1 }
            Button("Add Item") { items.append("Item \(items.count)") }
        }
    }
}

Rules:

- Always mark 
@State
 properties 
private
.
- Do not initialize 
@State
 from an initializer parameter when the view may be recreated -- use 
@Binding
 or a model instead.
- Works with value types (structs, enums, primitives) and, on iOS 17+, with 
@Observable
 classes.

@Binding

A two-way reference to state owned by a parent view. Does not own the data.

struct ToggleRow: View {
    let title: String
    @Binding var isOn: Bool

    var body: some View {
        Toggle(title, isOn: $isOn)
    }
}

struct SettingsView: View {
    @State private var wifiEnabled = true

    var body: some View {
        ToggleRow(title: "Wi-Fi", isOn: $wifiEnabled)
    }
}

Constant binding (for previews or tests):

ToggleRow(title: "Preview", isOn: .constant(true))

Custom binding:

let binding = Binding<Bool>(
    get: { preferences.isDarkMode },
    set: { preferences.isDarkMode = $0 }
)
Toggle("Dark Mode", isOn: binding)

@Observable (iOS 17+, Observation Framework)

The Observation framework is the default for all new code.
 
ObservableObject

+ 
@Published
 is legacy: it is coarser (any published change invalidates every
observing view), it needs a wrapper on every property, and it forces

@StateObject
/
@ObservedObject
 distinctions that 
@State
 handles on its own.
Use it only when you must support iOS 16 or earlier, or when integrating with an
existing Combine pipeline — and label those as legacy where they appear.

Every sample in this document uses 
@Observable
 with 
@MainActor
 on any type
the UI renders. That pairing is not stylistic: see 
@Observable and Actor
Isolation
 below for why the annotation is required rather than optional.

import Observation

@Observable
class UserProfile {
    var name = ""
    var email = ""
    var avatarURL: URL?

    // Computed properties are automatically tracked
    var isComplete: Bool {
        !name.isEmpty && !email.isEmpty
    }
}

struct ProfileView: View {
    // Just use @State for owned observable objects
    @State private var profile = UserProfile()

    var body: some View {
        Form {
            TextField("Name", text: $profile.name)
            TextField("Email", text: $profile.email)
            if profile.isComplete {
                Text("Profile complete!")
            }
        }
    }
}

// Pass to child views as plain parameters -- no wrapper needed
struct ProfileHeader: View {
    var profile: UserProfile  // Automatically tracks changes

    var body: some View {
        Text(profile.name)
    }
}

Key advantages over ObservableObject:

- Fine-grained tracking: only properties actually read by a view trigger re-renders.
- No need for 
@Published
 on every property.
- Child views do not need 
@ObservedObject
 -- just accept as a regular parameter.
- Works with 
@State
 for ownership.

@Observable and Actor Isolation

@Observable
 is 
not
 an isolation annotation. It generates observation
plumbing and nothing else. An 
@Observable
 class with no other annotation is

nonisolated
, which means any task on any thread may mutate it while SwiftUI is
reading it during layout.

// WRONG -- nonisolated observable state mutated from a background task.
@Observable
final class SearchModel {
    var results: [Result] = []

    func search(_ query: String) {
        Task.detached {
            let found = await api.search(query)
            self.results = found        // races with SwiftUI's read of `results`
        }
    }
}

// RIGHT -- isolate the whole type to the main actor.
@MainActor
@Observable
final class SearchModel {
    private(set) var results: [Result] = []
    private let api: any SearchService

    init(api: any SearchService) { self.api = api }

    func search(_ query: String) async {
        results = await api.search(query)   // resumes on the main actor
    }
}

Rule: any @Observable type that a view renders is @MainActor.
 Under
Swift 6 language mode the compiler enforces this; under Swift 5 mode it is a
silent data race.

Isolating the type costs nothing at runtime for UI work. 
await api.search(query)

still runs its network work off the main actor -- the main actor is occupied only
while your own statements execute, not across the suspension.

Where the 
@MainActor
 goes

// Preferred -- one annotation, whole type isolated, no accidental gaps.
@MainActor @Observable final class ViewModel { … }

// Fragile -- per-member isolation leaves `items` nonisolated and mutable
// from anywhere, which is exactly the race you were trying to prevent.
@Observable final class ViewModel {
    var items: [Item] = []
    @MainActor func reload() async { … }
}

Mark the type 
final
 as well: 
@Observable
 on a non-final class allows a
subclass to add unobserved stored properties, and it costs a dynamic dispatch on
every access.

Observable models that are not view models

A shared 
@Observable
 model touched from background work should be an 
actor

that publishes to a 
@MainActor
 projection, not a nonisolated observable:

actor SyncEngine {
    func pendingCount() async -> Int { … }
}

@MainActor
@Observable
final class SyncStatusModel {
    private(set) var pending = 0
    private let engine: SyncEngine

    init(engine: SyncEngine) { self.engine = engine }

    func refresh() async {
        pending = await engine.pendingCount()
    }
}

Observation Traps in Child Views

@Observable
 tracks the properties a view reads 
during body evaluation
.
Everything below follows from that one sentence.

Trap 1: reads outside 
body
 are never tracked

// WRONG -- `status` is read in onAppear, so no dependency is registered and
// the label never updates when status changes.
struct StatusBadge: View {
    let monitor: ConnectionMonitor          // @Observable
    @State private var label = ""

    var body: some View {
        Text(label)
            .onAppear { label = monitor.status.description }
    }
}

// RIGHT -- read it in body.
struct StatusBadge: View {
    let monitor: ConnectionMonitor

    var body: some View {
        Text(monitor.status.description)
    }
}

The same applies to reads inside 
Task { }
 closures, gesture handlers, and any
helper method not called from 
body
. If a view must react to a property it does
not render, observe it explicitly:

.onChange(of: monitor.status) { _, newValue in
    analytics.record(newValue)
}

Trap 2: passing the whole model down over-invalidates the child

// WRONG -- CartBadge re-renders on EVERY change to the store, including
// unrelated properties like isLoading or searchText.
struct CartBadge: View {
    let store: AppStore
    var body: some View { Text("\(store.cart.count)") }
}

// RIGHT -- pass only the value the child renders.
struct CartBadge: View {
    let count: Int
    var body: some View { Text("\(count)") }
}

Fine-grained tracking works at the 
property
 level, not the 
object
 level: a
child that reads 
store.cart
 is invalidated by any write to 
store.cart
, but a
child handed 
count: Int
 is invalidated only when that number actually changes.

Trap 3: the wrong wrapper in the child detaches it from the parent

// WRONG -- @State in a child captures the object ONCE. If the parent later
// hands down a different instance, the child keeps rendering the old one.
struct DetailView: View {
    @State private var model: ItemModel
    init(model: ItemModel) { _model = State(initialValue: model) }
}

// RIGHT -- borrow it. Plain `let` for read-only, @Bindable when you need `$`.
struct DetailView: View {
    let model: ItemModel                    // read-only
}

struct EditView: View {
    @Bindable var model: ItemModel          // needs TextField bindings
}

In the child you need to…

Use

Read properties only

let model: Model

Create $ bindings

@Bindable var model: Model

Own and create the object

@State private var model = Model()

Read it from far up the tree

@Environment(Model.self) private var model

Trap 4: mutating observable state during 
body

Writing to observed state while SwiftUI is evaluating 
body
 causes
"Modifying state during view update" warnings and undefined update behaviour.

// WRONG
var body: some View {
    if model.items.isEmpty { model.loadPlaceholder() }   // mutation during update
    return List(model.items) { … }
}

// RIGHT -- move it into a lifecycle modifier.
var body: some View {
    List(model.items) { … }
        .task { await model.loadIfNeeded() }
}

Trap 5: a collection element replaced wholesale invalidates every row

// If `items` is [Item] (value type), replacing the array re-evaluates every row.
// For large lists where individual elements change frequently, make the element
// an @Observable reference type so only the changed row re-renders.
@Observable final class RowModel: Identifiable {
    let id: UUID
    var isFavorite: Bool
    init(id: UUID, isFavorite: Bool) { self.id = id; self.isFavorite = isFavorite }
}

struct FeedView: View {
    let rows: [RowModel]
    var body: some View {
        List(rows) { row in
            RowView(row: row)               // only the toggled row re-renders
        }
    }
}

This is a trade-off, not a rule: value-type elements are simpler and correct by
default. Reach for observable elements only when profiling shows list-wide
invalidation is the bottleneck.

Async Boundaries in Views

.task
 over 
Task { }
 in 
onAppear

// WRONG -- the task outlives the view. It keeps running after the screen is
// popped and can write to a model whose UI no longer exists.
.onAppear { Task { await model.load() } }

// RIGHT -- .task is bound to the view's lifetime and cancelled on disappear.
.task { await model.load() }

// RIGHT -- .task(id:) cancels and restarts when the id changes.
.task(id: selectedID) { await model.loadDetail(selectedID) }

Because 
.task
 cancels, every async method it calls must treat

CancellationError
 as a deliberate no-op rather than a user-facing failure:

func load() async {
    do {
        items = try await service.fetch()
    } catch is CancellationError {
        return                              // superseded or dismissed
    } catch {
        errorMessage = error.localizedDescription
    }
}

State written after an await may be stale

Actor isolation guarantees exclusivity 
between
 suspension points, not across
them. Anything you captured before an 
await
 may be out of date after it.

// WRONG -- `index` was computed before the await; the array may have been
// reloaded, filtered, or reordered in the meantime.
func toggle(at index: Int) async {
    let item = items[index]
    try? await service.save(item)
    items[index].isSynced = true            // may write to the wrong row -- or crash
}

// RIGHT -- re-resolve by identity after the await.
func toggle(id: UUID) async {
    guard let item = items.first(where: { $0.id == id }) else { return }
    try? await service.save(item)
    guard let index = items.firstIndex(where: { $0.id == id }) else { return }
    items[index].isSynced = true
}

Overlapping calls need an explicit in-flight task

@MainActor
@Observable
final class FeedModel {
    private(set) var posts: [Post] = []
    private var loadTask: Task<Void, Never>?

    func load() async {
        loadTask?.cancel()                  // supersede the previous load
        let task = Task { await performLoad() }
        loadTask = task
        await task.value
    }

    private func performLoad() async {
        do {
            let fetched = try await service.posts()
            try Task.checkCancellation()    // don't publish a superseded result
            posts = fetched
        } catch {
            // handle
        }
    }
}

Without this, a slow first request can land 
after
 a fast second one and
overwrite fresh data with stale data -- a bug that only reproduces on bad
networks.

@Bindable (iOS 17+)

Creates bindings to properties of an 
@Observable
 object that is not owned via 
@State
.

struct EditProfileView: View {
    @Bindable var profile: UserProfile  // passed in, not owned

    var body: some View {
        Form {
            TextField("Name", text: $profile.name)
            TextField("Email", text: $profile.email)
        }
    }
}

// Parent
struct ParentView: View {
    @State private var profile = UserProfile()

    var body: some View {
        EditProfileView(profile: profile)
    }
}

Use 
@Bindable
 when you receive an 
@Observable
 object as a parameter and need 
$
 binding syntax.

@ObservableObject and @Published (Legacy, pre-iOS 17)

class SettingsStore: ObservableObject {
    @Published var fontSize: Double = 14
    @Published var isDarkMode = false
    @Published var username = ""
}

@StateObject vs @ObservedObject

struct ParentView: View {
    // @StateObject: OWNS the object. Created once, survives re-renders.
    @StateObject private var store = SettingsStore()

    var body: some View {
        ChildView(store: store)
    }
}

struct ChildView: View {
    // @ObservedObject: BORROWS the object. Does not own it.
    @ObservedObject var store: SettingsStore

    var body: some View {
        Text("Font: \(store.fontSize)")
    }
}

Critical rule:
 Use 
@StateObject
 for creation, 
@ObservedObject
 for injection. Using 
@ObservedObject
 for creation causes the object to be recreated on every parent re-render.

@Environment

Reads values from the SwiftUI environment.

struct DetailView: View {
    @Environment(\.dismiss) private var dismiss
    @Environment(\.colorScheme) private var colorScheme
    @Environment(\.horizontalSizeClass) private var sizeClass
    @Environment(\.dynamicTypeSize) private var typeSize
    @Environment(\.locale) private var locale
    @Environment(\.calendar) private var calendar
    @Environment(\.openURL) private var openURL
    @Environment(\.isSearching) private var isSearching
    @Environment(\.editMode) private var editMode

    var body: some View {
        VStack {
            Text(colorScheme == .dark ? "Dark Mode" : "Light Mode")
            Button("Done") { dismiss() }
            Button("Open Link") {
                openURL(URL(string: "https://apple.com")!)
            }
        }
    }
}

Custom EnvironmentKey

// 1. Define the key
struct ThemeKey: EnvironmentKey {
    static let defaultValue = AppTheme.standard
}

// 2. Extend EnvironmentValues
extension EnvironmentValues {
    var theme: AppTheme {
        get { self[ThemeKey.self] }
        set { self[ThemeKey.self] = newValue }
    }
}

// 3. Set in parent
ContentView()
    .environment(\.theme, AppTheme.premium)

// 4. Read in child
struct ThemedButton: View {
    @Environment(\.theme) private var theme

    var body: some View {
        Button("Action") { }
            .tint(theme.accentColor)
    }
}

Environment with @Observable (iOS 17+)

@Observable
class AppSettings {
    var accentColor: Color = .blue
    var fontSize: Double = 16
}

// Inject via environment
ContentView()
    .environment(AppSettings())

// Read in any descendant
struct ChildView: View {
    @Environment(AppSettings.self) private var settings

    var body: some View {
        // For bindings, use @Bindable locally
        @Bindable var settings = settings
        Slider(value: $settings.fontSize, in: 12...24)
    }
}

@EnvironmentObject (Legacy)

Injects an 
ObservableObject
 into the view hierarchy.

class AuthManager: ObservableObject {
    @Published var isLoggedIn = false
    @Published var currentUser: User?
}

// Inject at root
ContentView()
    .environmentObject(AuthManager())

// Read in any descendant
struct ProfileView: View {
    @EnvironmentObject var auth: AuthManager

    var body: some View {
        if let user = auth.currentUser {
            Text(user.name)
        }
    }
}

Warning:
 Crashes at runtime if the object is not provided in the hierarchy. Prefer 
@Environment
 with 
@Observable
 on iOS 17+.

@AppStorage and @SceneStorage

@AppStorage

Reads and writes to 
UserDefaults
. The view updates when the value changes.

struct SettingsView: View {
    @AppStorage("username") private var username = "Guest"
    @AppStorage("isDarkMode") private var isDarkMode = false
    @AppStorage("fontSize") private var fontSize = 14.0
    @AppStorage("selectedTab") private var selectedTab = 0

    // Custom suite
    @AppStorage("token", store: UserDefaults(suiteName: "group.com.app.shared"))
    private var token = ""

    var body: some View {
        Form {
            TextField("Username", text: $username)
            Toggle("Dark Mode", isOn: $isDarkMode)
            Slider(value: $fontSize, in: 10...30)
        }
    }
}

Supported types:
 
Bool
, 
Int
, 
Double
, 
String
, 
URL
, 
Data
, and 
RawRepresentable
 where 
RawValue
 is 
Int
 or 
String
.

enum AppTab: String {
    case home, search, profile
}

@AppStorage("currentTab") private var currentTab: AppTab = .home

@SceneStorage

Persists state per scene (restored after app relaunch). Ideal for scroll positions, selected tabs, draft text.

struct EditorView: View {
    @SceneStorage("draft") private var draft = ""
    @SceneStorage("scrollPosition") private var scrollPosition: String?

    var body: some View {
        TextEditor(text: $draft)
    }
}

@Query (SwiftData Integration)

Fetches model objects from SwiftData and keeps the view updated.

import SwiftData

@Model
class Task {
    var title: String
    var isComplete: Bool
    var createdAt: Date

    init(title: String, isComplete: Bool = false) {
        self.title = title
        self.isComplete = isComplete
        self.createdAt = .now
    }
}

struct TaskListView: View {
    @Query(sort: \Task.createdAt, order: .reverse)
    private var tasks: [Task]

    // With filter
    @Query(filter: #Predicate<Task> { !$0.isComplete },
           sort: \Task.createdAt)
    private var pendingTasks: [Task]

    @Environment(\.modelContext) private var context

    var body: some View {
        List(tasks) { task in
            Text(task.title)
        }
    }
}

// Dynamic queries with init
struct FilteredTaskList: View {
    @Query private var tasks: [Task]

    init(showComplete: Bool) {
        let predicate = #Predicate<Task> { task in
            showComplete || !task.isComplete
        }
        _tasks = Query(filter: predicate, sort: \Task.createdAt)
    }

    var body: some View {
        List(tasks) { task in Text(task.title) }
    }
}

Data Flow Patterns and Best Practices

When to Use Which Property Wrapper

Wrapper

Ownership

Use Case

iOS

@State

Owns

Simple value types, local UI state

13+

@State + @Observable

Owns

Observable model created by this view

17+

@Binding

Borrows

Two-way reference to parent state

13+

@Bindable

Borrows

Bindings to Observable object properties

17+

@Environment

Reads

System or custom environment values

13+

@Environment(Type.self)

Reads

Observable objects via environment

17+

@AppStorage

Owns

UserDefaults-backed persistence

14+

@SceneStorage

Owns

Per-scene state restoration

14+

@Query

Reads

SwiftData model queries

17+

@StateObject

Owns

ObservableObject creation (legacy)

14+

@ObservedObject

Borrows

ObservableObject injection (legacy)

13+

@EnvironmentObject

Reads

ObservableObject via environment (legacy)

13+

Recommended Architecture (iOS 17+)

// Model layer -- isolated, final, protocol-injected.
@MainActor
@Observable
final class Store {
    private(set) var items: [Item] = []
    private(set) var isLoading = false
    var errorMessage: String?

    private let api: any ItemService

    init(api: any ItemService) { self.api = api }

    func fetchItems() async {
        isLoading = true
        defer { isLoading = false }
        do {
            items = try await api.getItems()
            errorMessage = nil
        } catch is CancellationError {
            return
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

// App entry point
@main
struct MyApp: App {
    @State private var store = Store(api: LiveItemService())

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(store)
        }
    }
}

// Feature view
struct ItemListView: View {
    @Environment(Store.self) private var store

    var body: some View {
        List(store.items) { item in
            ItemRow(item: item)
        }
        .overlay {
            if store.isLoading {
                ProgressView()
            }
        }
        .task {
            await store.fetchItems()
        }
    }
}

State Hoisting Pattern

Keep state at the lowest common ancestor that needs it.

// Parent owns the state
struct FilterableList: View {
    @State private var searchText = ""
    @State private var sortOrder: SortOrder = .name

    var body: some View {
        VStack {
            SearchBar(text: $searchText)           // Binding down
            SortPicker(selection: $sortOrder)       // Binding down
            ResultsList(query: searchText, sort: sortOrder)  // Values down
        }
    }
}

Action Closure Pattern

Pass actions down instead of state up.

struct ItemRow: View {
    let item: Item
    let onDelete: () -> Void
    let onToggle: (Bool) -> Void

    var body: some View {
        HStack {
            Text(item.title)
            Spacer()
            Toggle("", isOn: Binding(
                get: { item.isComplete },
                set: { onToggle($0) }
            ))
        }
        .swipeActions {
            Button(role: .destructive) { onDelete() } label: {
                Label("Delete", systemImage: "trash")
            }
        }
    }
}

Avoiding Common Pitfalls

// BAD: Creating @StateObject/@State Observable in a child view that gets recreated
struct ParentView: View {
    @State private var toggle = false
    var body: some View {
        VStack {
            ChildView()  // ChildView recreated when toggle changes
            Button("Toggle") { toggle.toggle() }
        }
    }
}

struct ChildView: View {
    // BAD with ObservableObject -- this resets on every parent re-render
    @ObservedObject var vm = ViewModel()
    // GOOD -- use @StateObject instead
    @StateObject private var vm = ViewModel()
}

// On iOS 17+, @State with @Observable handles this correctly:
struct ChildView: View {
    @State private var vm = ViewModel()  // Survives re-renders
}

Property Wrapper Decision Checklist

Before writing any state declaration, answer these in order:

Does this view create the value, or receive it?
 Creates → 
@State
.
   Receives → 
@Binding
 (value), 
let
 (read-only object), 
@Bindable

   (object you need 
$
 on), or 
@Environment
 (from far up the tree).
Is it transient UI state (sheet flags, draft text, focus, scroll offset)?

   → 
@State
 on the view. It does 
not
 belong on a view model.
Is it screen state derived from data (loaded items, error, isLoading)?

   → a 
@MainActor @Observable
 model owned by 
@State
 at the screen root.
Is it app-wide (session, theme, feature flags)?
 → 
@Observable
 injected
   with 
.environment(_:)
, read with 
@Environment(Type.self)
.
Does it need to survive relaunch?
 → 
@AppStorage
 (user preference) or
   
@SceneStorage
 (per-scene restoration). Neither is a place for a model.
Is it persisted model data?
 → 
@Query
 with SwiftData.

Anti-Patterns Summary

// 1. Observable model without isolation.
@Observable final class VM { var items: [Item] = [] }        // race
@MainActor @Observable final class VM { … }                  // correct

// 2. Non-final observable class -- allows unobserved subclass state.
@Observable class VM { }                                     // avoid
@Observable final class VM { }                               // correct

// 3. View-local transient state stored on the model.
@Observable final class VM { var showSheet = false }         // belongs in @State

// 4. @State in a child for an object the parent owns.
@State private var model: Model                              // detaches
@Bindable var model: Model                                   // correct

// 5. Reading observable properties outside body and expecting updates.
.onAppear { label = model.title }                            // never re-reads
Text(model.title)                                            // correct

// 6. Mutating observed state during body evaluation.
var body: some View { model.prepare(); return List(…) }      // warning + UB

// 7. Unstructured Task in onAppear instead of .task.
.onAppear { Task { await model.load() } }                    // leaks past dismissal

// 8. Hand-rolled main-thread hops inside an already-isolated type.
DispatchQueue.main.async { self.items = new }                // obsolete
await MainActor.run { self.items = new }                     // redundant
items = new                                                  // correct

// 9. @EnvironmentObject / @ObservedObject in new iOS 17+ code.
@EnvironmentObject var auth: AuthManager                     // legacy, crashes if unset
@Environment(AuthManager.self) private var auth              // correct

---

# SwiftUI Views and Controls
https://nagarjuna2997.github.io/ios-agent-skill/guides/swiftui-views-and-controls.html

Core UI and Apps · Reference guideSwiftUI Views and ControlsRepository guidance for SwiftUI. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Text and Labels

Text
Label
Image and AsyncImage

Buttons and Toggles

Button
Toggle
Picker
Slider, Stepper, DatePicker, ColorPicker

Text Input
Lists and Collections

List
ForEach
ScrollView and Lazy Stacks/Grids

Form, Section, GroupBox, DisclosureGroup
Menus, Context Menus, Links
Progress and Gauge
Custom Views and ViewModifier
View Lifecycle
Common Modifiers Reference
Key Patterns

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Choose a semantic control
↓2
Bind it to feature state
↓3
Handle user action
↓4
Verify labels and disabled states

02 / ArchitectureResponsibility boundariesBoundary 1
Control semanticsBoundary 2
Feature stateBoundary 3
Action handlerConnected responsibilities, not a required class hierarchy or an execution trace.
Comprehensive reference for every major SwiftUI view, control, modifier, and lifecycle hook.

Text and Labels

Text

Displays one or more lines of read-only text.

Text("Hello, World!")
    .font(.title)
    .fontWeight(.bold)
    .foregroundStyle(.primary)
    .lineLimit(2)
    .truncationMode(.tail)
    .multilineTextAlignment(.center)

// Markdown support
Text("**Bold** and *italic* and [link](https://apple.com)")

// String interpolation with formatting
Text("Price: \(price, format: .currency(code: "USD"))")
Text("Date: \(date, format: .dateTime.month().day().year())")
Text(timerInterval: Date.now...Date.now.addingTimeInterval(300))

// Concatenation
Text("Hello ").bold() + Text("World").foregroundStyle(.blue)

// AttributedString
var attributed = AttributedString("Styled")
attributed.foregroundColor = .red
attributed.font = .largeTitle
Text(attributed)

Label

Pairs an icon with a title. Adapts rendering based on context (toolbar shows icon only, list shows both).

Label("Favorites", systemImage: "heart.fill")
Label("Custom", image: "myIcon")
Label {
    Text("Downloads")
        .font(.headline)
} icon: {
    Image(systemName: "arrow.down.circle")
        .foregroundStyle(.blue)
}

// Label styles
Label("Item", systemImage: "star")
    .labelStyle(.titleAndIcon)  // .titleOnly, .iconOnly, .automatic

Image and AsyncImage

// SF Symbols
Image(systemName: "star.fill")
    .symbolRenderingMode(.multicolor)
    .font(.system(size: 40))
    .symbolEffect(.bounce, value: isFavorite)  // iOS 17+

// Asset catalog
Image("photo")
    .resizable()
    .aspectRatio(contentMode: .fill)
    .frame(width: 200, height: 200)
    .clipShape(RoundedRectangle(cornerRadius: 16))

// AsyncImage (remote images)
AsyncImage(url: URL(string: "https://example.com/photo.jpg")) { phase in
    switch phase {
    case .empty:
        ProgressView()
    case .success(let image):
        image
            .resizable()
            .aspectRatio(contentMode: .fill)
    case .failure:
        Image(systemName: "photo.badge.exclamationmark")
            .foregroundStyle(.secondary)
    @unknown default:
        EmptyView()
    }
}
.frame(width: 300, height: 200)

Buttons and Toggles

Button

// Basic
Button("Tap Me") { doSomething() }

// With role
Button("Delete", role: .destructive) { deleteItem() }
Button("Cancel", role: .cancel) { dismiss() }

// Custom label
Button {
    performAction()
} label: {
    HStack {
        Image(systemName: "plus.circle.fill")
        Text("Add Item")
    }
    .font(.headline)
    .padding()
    .background(.blue)
    .foregroundStyle(.white)
    .clipShape(Capsule())
}

// Button styles
Button("Bordered") { }
    .buttonStyle(.bordered)       // .automatic, .plain, .borderless
    .tint(.green)

Button("Prominent") { }
    .buttonStyle(.borderedProminent)
    .controlSize(.large)          // .mini, .small, .regular, .large, .extraLarge

// Repeat behavior (iOS 17+)
Button("Increment", repeatBehavior: .enabled) { count += 1 }

Toggle

@State private var isOn = false

Toggle("Airplane Mode", isOn: $isOn)
Toggle(isOn: $isOn) {
    Label("Wi-Fi", systemImage: "wifi")
}
.toggleStyle(.switch)   // .switch, .button, .checkbox (macOS)
.tint(.orange)

Picker

@State private var selection = "Red"
let colors = ["Red", "Green", "Blue"]

// Inline
Picker("Color", selection: $selection) {
    ForEach(colors, id: \.self) { Text($0) }
}
.pickerStyle(.segmented)  // .menu, .wheel, .inline, .navigationLink, .palette

// With enum
enum Flavor: String, CaseIterable, Identifiable {
    case chocolate, vanilla, strawberry
    var id: Self { self }
}

@State private var flavor: Flavor = .chocolate

Picker("Flavor", selection: $flavor) {
    ForEach(Flavor.allCases) { flavor in
        Text(flavor.rawValue.capitalized).tag(flavor)
    }
}

Slider, Stepper, DatePicker, ColorPicker

// Slider
@State private var speed = 50.0
Slider(value: $speed, in: 0...100, step: 5) {
    Text("Speed")
} minimumValueLabel: { Text("0") }
  maximumValueLabel: { Text("100") }

// Stepper
@State private var quantity = 1
Stepper("Quantity: \(quantity)", value: $quantity, in: 1...99)
Stepper("Custom") { quantity += 5 } onDecrement: { quantity -= 5 }

// DatePicker
@State private var date = Date.now
DatePicker("Date", selection: $date, displayedComponents: [.date, .hourAndMinute])
    .datePickerStyle(.graphical)  // .compact, .wheel, .graphical

DatePicker("Range", selection: $date, in: Date.now...)

// ColorPicker
@State private var color = Color.blue
ColorPicker("Theme", selection: $color, supportsOpacity: true)

Text Input

// TextField
@State private var name = ""
TextField("Enter name", text: $name)
    .textFieldStyle(.roundedBorder)
    .textContentType(.name)
    .autocorrectionDisabled()
    .textInputAutocapitalization(.words)
    .submitLabel(.done)
    .onSubmit { saveProfile() }

// With format
@State private var amount = 0.0
TextField("Amount", value: $amount, format: .currency(code: "USD"))
    .keyboardType(.decimalPad)

// With prompt and axis (iOS 16+)
TextField("Bio", text: $bio, prompt: Text("Tell us about yourself"), axis: .vertical)
    .lineLimit(3...6)

// SecureField
@State private var password = ""
SecureField("Password", text: $password)
    .textContentType(.password)

// TextEditor (multiline)
@State private var notes = ""
TextEditor(text: $notes)
    .frame(minHeight: 100)
    .scrollContentBackground(.hidden)  // iOS 16+ to customize background
    .background(Color(.systemGray6))
    .clipShape(RoundedRectangle(cornerRadius: 8))

// Focused state
@FocusState private var isNameFocused: Bool
TextField("Name", text: $name)
    .focused($isNameFocused)

Button("Focus") { isNameFocused = true }

Lists and Collections

List

// Static
List {
    Text("Row 1")
    Text("Row 2")
    Text("Row 3")
}

// Dynamic
struct Item: Identifiable {
    let id = UUID()
    var title: String
}

List(items) { item in
    Text(item.title)
}

// Mixed static and dynamic
List {
    Section("Favorites") {
        ForEach(favorites) { item in
            Text(item.title)
        }
    }
    Section("All Items") {
        ForEach(allItems) { item in
            Text(item.title)
        }
    }
}
.listStyle(.insetGrouped)  // .plain, .grouped, .sidebar, .inset, .insetGrouped

// Swipe actions
List {
    ForEach(items) { item in
        Text(item.title)
            .swipeActions(edge: .trailing, allowsFullSwipe: true) {
                Button(role: .destructive) { delete(item) } label: {
                    Label("Delete", systemImage: "trash")
                }
                Button { pin(item) } label: {
                    Label("Pin", systemImage: "pin")
                }
                .tint(.yellow)
            }
            .swipeActions(edge: .leading) {
                Button { archive(item) } label: {
                    Label("Archive", systemImage: "archivebox")
                }
                .tint(.blue)
            }
    }
    .onDelete { indexSet in items.remove(atOffsets: indexSet) }
    .onMove { from, to in items.move(fromOffsets: from, toOffset: to) }
}

// List customization
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listSectionSeparator(.hidden)

ForEach

// With Identifiable
ForEach(items) { item in ItemRow(item: item) }

// With id keypath
ForEach(names, id: \.self) { name in Text(name) }

// With index
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
    Text("\(index): \(item.title)")
}

// Range
ForEach(0..<5) { i in Text("Item \(i)") }

ScrollView and Lazy Stacks/Grids

// Vertical scroll
ScrollView {
    LazyVStack(spacing: 16) {
        ForEach(items) { item in
            ItemCard(item: item)
        }
    }
    .padding()
}

// Horizontal scroll
ScrollView(.horizontal, showsIndicators: false) {
    LazyHStack(spacing: 12) {
        ForEach(items) { item in
            ItemCard(item: item)
                .containerRelativeFrame(.horizontal, count: 3, spacing: 12)
        }
    }
    .scrollTargetLayout()  // iOS 17+
}
.scrollTargetBehavior(.viewAligned)  // iOS 17+ snapping

// Grid
let columns = [
    GridItem(.flexible()),
    GridItem(.flexible()),
    GridItem(.flexible())
]

ScrollView {
    LazyVGrid(columns: columns, spacing: 16) {
        ForEach(items) { item in
            ItemCell(item: item)
        }
    }
}

// Adaptive grid
let adaptiveColumns = [GridItem(.adaptive(minimum: 120, maximum: 200))]

// Pinned headers
ScrollView {
    LazyVStack(pinnedViews: [.sectionHeaders]) {
        ForEach(sections) { section in
            Section {
                ForEach(section.items) { item in ItemRow(item: item) }
            } header: {
                Text(section.title)
                    .font(.headline)
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding()
                    .background(.bar)
            }
        }
    }
}

Form, Section, GroupBox, DisclosureGroup

Form {
    Section("Profile") {
        TextField("Name", text: $name)
        DatePicker("Birthday", selection: $birthday, displayedComponents: .date)
        Picker("Role", selection: $role) {
            ForEach(Role.allCases) { Text($0.rawValue).tag($0) }
        }
    }

    Section {
        Toggle("Notifications", isOn: $notificationsEnabled)
        Toggle("Sound", isOn: $soundEnabled)
    } header: {
        Text("Preferences")
    } footer: {
        Text("Enable notifications to receive updates.")
    }
}

// GroupBox
GroupBox("Statistics") {
    LabeledContent("Downloads", value: "1,234")
    LabeledContent("Rating", value: "4.8")
}

// LabeledContent for key-value display
LabeledContent("Version") {
    Text("2.1.0").foregroundStyle(.secondary)
}

// DisclosureGroup
@State private var isExpanded = false
DisclosureGroup("Advanced Settings", isExpanded: $isExpanded) {
    Toggle("Debug Mode", isOn: $debugMode)
    Slider(value: $cacheSize, in: 0...1000)
}

Menus, Context Menus, Links

// Menu (pull-down button)
Menu("Options") {
    Button("Duplicate", systemImage: "doc.on.doc") { duplicate() }
    Button("Rename", systemImage: "pencil") { rename() }
    Divider()
    Button("Delete", systemImage: "trash", role: .destructive) { delete() }
    Menu("Sort By") {
        Button("Name") { sort(.name) }
        Button("Date") { sort(.date) }
        Button("Size") { sort(.size) }
    }
}
.menuStyle(.borderedProminent)

// Context menu (long press)
Text("Hold me")
    .contextMenu {
        Button("Copy", systemImage: "doc.on.doc") { copy() }
        Button("Share", systemImage: "square.and.arrow.up") { share() }
    } preview: {
        ItemPreview(item: item)  // Custom preview
            .frame(width: 300, height: 400)
    }

// Link
Link("Visit Apple", destination: URL(string: "https://apple.com")!)
Link(destination: URL(string: "https://apple.com")!) {
    Label("Apple", systemImage: "safari")
}

// ShareLink (iOS 16+)
ShareLink(item: URL(string: "https://apple.com")!) {
    Label("Share", systemImage: "square.and.arrow.up")
}

Progress and Gauge

// Indeterminate
ProgressView()
ProgressView("Loading...")

// Determinate
ProgressView("Downloading", value: progress, total: 100)
    .progressViewStyle(.linear)  // .circular, .linear

// Gauge (iOS 16+)
Gauge(value: batteryLevel, in: 0...100) {
    Text("Battery")
} currentValueLabel: {
    Text("\(Int(batteryLevel))%")
} minimumValueLabel: {
    Text("0")
} maximumValueLabel: {
    Text("100")
}
.gaugeStyle(.accessoryCircular)  // .linearCapacity, .accessoryLinear, .accessoryCircularCapacity
.tint(Gradient(colors: [.red, .yellow, .green]))

Custom Views and ViewModifier

// Custom View
struct ProfileCard: View {
    let name: String
    let role: String

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(name).font(.headline)
            Text(role).font(.subheadline).foregroundStyle(.secondary)
        }
        .padding()
        .background(.ultraThinMaterial)
        .clipShape(RoundedRectangle(cornerRadius: 12))
    }
}

// ViewModifier
struct CardStyle: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding()
            .background(.ultraThinMaterial)
            .clipShape(RoundedRectangle(cornerRadius: 12))
            .shadow(color: .black.opacity(0.1), radius: 8, y: 4)
    }
}

extension View {
    func cardStyle() -> some View {
        modifier(CardStyle())
    }
}

// Usage
Text("Styled").cardStyle()

// ViewModifier with configuration
struct ShimmerModifier: ViewModifier {
    let isActive: Bool
    @State private var phase: CGFloat = 0

    func body(content: Content) -> some View {
        content
            .overlay {
                if isActive {
                    LinearGradient(
                        colors: [.clear, .white.opacity(0.3), .clear],
                        startPoint: .init(x: phase - 0.5, y: 0.5),
                        endPoint: .init(x: phase + 0.5, y: 0.5)
                    )
                    .onAppear {
                        withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
                            phase = 1.5
                        }
                    }
                }
            }
            .clipShape(RoundedRectangle(cornerRadius: 8))
    }
}

View Lifecycle

struct ContentView: View {
    @State private var data: [Item] = []

    var body: some View {
        List(data) { item in
            Text(item.title)
        }
        // Called when view appears
        .onAppear { loadCachedData() }

        // Called when view disappears
        .onDisappear { saveState() }

        // Async task tied to view lifetime (cancelled on disappear)
        .task {
            data = await fetchItems()
        }

        // Task with ID (restarts when id changes)
        .task(id: selectedCategory) {
            data = await fetchItems(for: selectedCategory)
        }

        // React to value changes (iOS 17+ syntax)
        .onChange(of: searchText) { oldValue, newValue in
            performSearch(newValue)
        }

        // React to value changes (iOS 14-16 syntax)
        .onChange(of: searchText) { newValue in
            performSearch(newValue)
        }

        // Scene phase
        .onChange(of: scenePhase) { _, phase in
            if phase == .background { saveData() }
        }

        // Receive notifications
        .onReceive(NotificationCenter.default.publisher(for: .didUpdate)) { _ in
            refresh()
        }
    }
}

Common Modifiers Reference

// Typography
.font(.title)                    // .largeTitle, .title, .title2, .title3, .headline,
                                 // .subheadline, .body, .callout, .footnote, .caption, .caption2
.font(.system(size: 18, weight: .semibold, design: .rounded))
.fontDesign(.rounded)            // .default, .rounded, .serif, .monospaced
.fontWidth(.expanded)            // .compressed, .condensed, .standard, .expanded
.bold()
.italic()
.underline()
.strikethrough()
.kerning(2)
.tracking(1)

// Colors and styles
.foregroundStyle(.primary)       // Supports ShapeStyle: colors, gradients, hierarchical
.foregroundStyle(.blue, .secondary)  // Primary, secondary content
.tint(.blue)

// Layout
.padding()                       // All edges, default spacing
.padding(.horizontal, 16)
.padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.frame(width: 200, height: 100)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(minHeight: 44)

// Background and overlay
.background(.ultraThinMaterial)  // .regularMaterial, .thickMaterial, .bar
.background { RoundedRectangle(cornerRadius: 12).fill(.blue.gradient) }
.background(in: RoundedRectangle(cornerRadius: 12))  // Clips background to shape
.overlay { Badge().offset(x: 10, y: -10) }
.overlay(alignment: .topTrailing) { NotificationBadge() }
.border(.gray, width: 1)

// Shape and clipping
.clipShape(Circle())
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.clipShape(Capsule())
.mask { LinearGradient(colors: [.black, .clear], startPoint: .top, endPoint: .bottom) }

// Shadow
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)

// Opacity and visibility
.opacity(0.8)
.hidden()                        // Hides but preserves layout space

// Interaction
.disabled(isLoading)
.allowsHitTesting(false)
.contentShape(Rectangle())      // Expand tappable area

// Accessibility
.accessibilityLabel("Close button")
.accessibilityHint("Dismisses the dialog")
.accessibilityAddTraits(.isButton)
.accessibilityHidden(true)

// Conditional modifiers (via extension)
extension View {
    @ViewBuilder
    func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
        if condition { transform(self) } else { self }
    }
}

// Redaction
.redacted(reason: .placeholder)
.privacySensitive()              // Redacted in inactive app states

// Environment overrides
.environment(\.colorScheme, .dark)
.dynamicTypeSize(.large ... .accessibility3)
.environment(\.layoutDirection, .rightToLeft)

Key Patterns

Prefer 
LazyVStack
/
LazyHStack
 inside 
ScrollView
 for large collections over 
List
 when you need custom styling.
Use 
ViewModifier
 to encapsulate reusable styling rather than View extensions with many chained modifiers.
Always provide 
.accessibilityLabel
 for icon-only buttons.
Use 
.task {}
 instead of 
.onAppear
 for async work -- it automatically cancels on disappear.
Prefer 
.foregroundStyle
 over the deprecated 
.foregroundColor
.
Use 
.background(in: shape)
 over 
.background().clipShape(shape)
 when possible.

---

# Evaluations -- Testing Intelligence-Powered Features
https://nagarjuna2997.github.io/ios-agent-skill/guides/testing-evaluations.html

AI and Machine Learning · Reference guideEvaluations -- Testing Intelligence-Powered FeaturesRepository guidance for Evaluations. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Overview
1. What an Evaluation Contains
2. Minimal Evaluation Shape
3. Dataset Design
4. Evaluator Types
5. Tool-Calling Evaluations
6. Swift Testing Integration
7. Ship Criteria
8. Review Checklist

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Define success criteria
↓2
Freeze representative cases
↓3
Run the same evaluation
↓4
Inspect failures before shipping

02 / ArchitectureResponsibility boundariesBoundary 1
Evaluation datasetBoundary 2
System under testBoundary 3
Scoring and evidenceConnected responsibilities, not a required class hierarchy or an execution trace.
Overview

The Evaluations framework measures the quality of AI-powered features with executable Swift definitions. Use it to compare prompt strategies, catch regressions, test tool calls, evaluate model-as-judge criteria, and attach results to Swift Testing.

iOS 27 / Xcode 27 are released. This guide has not yet been compiled with Xcode 27; keep evaluation code close to tests and verify exact signatures in that SDK.

1. What an Evaluation Contains

Every useful evaluation has four parts:

Part

Purpose

Dataset

Realistic samples, expected values, edge cases, and adversarial inputs

Subject

The feature under test, not just the raw model

Evaluators

Code-based checks, model-as-judge checks, or tool-call trajectory checks

Aggregation

Summary metrics that decide whether the build can ship

Avoid vague criteria like "good answer." Convert them into measurable claims: "returns valid JSON," "uses only allowed tags," "calls 
SearchNotes
 before 
CreateSummary
," or "scores 4/5 or higher for factual grounding."

2. Minimal Evaluation Shape

import Evaluations
import FoundationModels
import Testing

@available(iOS 27.0, macOS 27.0, *)
struct TaggingEvaluation: Evaluation {
    let dataset = ArrayLoader(samples: [
        ModelSample(prompt: "A quiet mystery set in a coastal town.", expected: ["mystery"]),
        ModelSample(prompt: "A practical guide to sourdough bread.", expected: ["cooking"]),
        ModelSample(prompt: "A memoir about training for a marathon.", expected: ["memoir"]),
    ])

    let subject = BookTaggingService()

    var evaluators: [Evaluator<[String]>] {
        [
            Evaluator("contains expected tag") { sample, response in
                let passed = sample.expected.allSatisfy(response.contains)
                return Metric("expected_tag_present", passed)
            },
            Evaluator("bounded tag count") { _, response in
                Metric("tag_count_valid", (3...8).contains(response.count))
            },
        ]
    }
}

The illustrative code below has not been compiled with the released Xcode 27 SDK. Preserve the architecture: samples in, feature response out, metrics aggregated.

3. Dataset Design

Build datasets like product specifications:

Golden paths:
 the most common user requests.
Boundary cases:
 empty input, max length, ambiguous language, unsupported locale.
Safety cases:
 sensitive requests, dangerous recommendations, privacy-sensitive data.
Regression cases:
 every fixed production bug becomes a sample.
Tool cases:
 expected tool names, call order, and argument constraints.

Keep sample data deterministic and reviewable. Generated synthetic samples are useful for scale, but hand-curated samples should define the release gate.

4. Evaluator Types

Evaluator

Use When

Code-based Evaluator

Correctness has a computable definition: schema, range, exact match, contains, count

ModelJudgeEvaluator

Tone, helpfulness, relevance, or clarity needs rubric scoring

ToolCallEvaluator

The feature is agentic and correctness depends on tool selection/order/arguments

Start with code-based checks. Add model-as-judge only for qualities that code cannot score reliably, and calibrate judge scores against human review.

5. Tool-Calling Evaluations

Tool failures are often silent product failures. Evaluate:

selected tool name
argument names and values
call ordering
whether a tool should not be called
recovery after tool errors
final answer grounding in tool output

let expectation = TrajectoryExpectation([
    .tool("search_notes", arguments: [
        "query": .contains("project deadline"),
    ]),
    .tool("summarize_results"),
])

let evaluator = ToolCallEvaluator(expectation: expectation)

If a tool has side effects, use test doubles. Evaluation runs must never create real reminders, send messages, spend money, or mutate production data.

6. Swift Testing Integration

Run important evaluations in CI:

@Test(.evaluation(TaggingEvaluation()))
func bookTaggingQuality(context: EvaluationContext<TaggingEvaluation>) async throws {
    let result = try await context.result
    #expect(result.summary.metric("expected_tag_present").passRate >= 0.95)
}

Attach detailed result tables as artifacts so a failing run tells reviewers which samples regressed.

7. Ship Criteria

Define thresholds before prompt tuning:

required pass rate per metric
maximum latency or token budget
minimum tool-call correctness
allowed fallback rate
model-as-judge threshold and confidence range
reviewed dataset size and coverage categories

When changing prompts, models, tool schemas, or Dynamic Profiles, rerun the full evaluation suite. Do not merge "it looks better" changes without metric movement.

8. Review Checklist

[ ] Dataset includes golden, boundary, safety, and regression samples
[ ] Metrics are measurable and named
[ ] Tool calls have expected trajectories and argument validation
[ ] Model-as-judge rubric is calibrated against human examples
[ ] CI fails on threshold regression
[ ] Evaluation artifacts are attached for diagnosis
[ ] Production side effects are replaced with test doubles
[ ] Prompt/model/tool changes update or rerun evaluations

See also: 
docs/frameworks/foundation-models.md
, 
docs/tooling/foundation-models-instruments.md
, 
docs/testing/mocking-strategy.md
.

---

# App-building loop (preview)
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-app-building-loop.html

Tooling · Reference guideApp-building loop (preview)Repository guidance for App-building loop (preview). Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Try the complete reading-list demo
Plan and implement your own app
Evidence and resume
Validation status

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Write acceptance criteria
↓2
Implement a bounded step
↓3
Run checks and capture evidence
↓4
Resume or repair within limits

02 / ArchitectureResponsibility boundariesBoundary 1
Saved planBoundary 2
Build and test runnerBoundary 3
Evidence and progressConnected responsibilities, not a required class hierarchy or an execution trace.
One client first: the local Claude Code CLI plans and edits; 
ios-agent-mcp loop
 runs your acceptance commands and records progress. This is a CLI workflow alongside the unified MCP server, not an additional MCP tool or a guarantee of autonomous app creation.

Try the complete reading-list demo

Requires Node.js 20+, Python 3, macOS, Xcode with an installed iOS simulator runtime. The Xcode project is checked in; XcodeGen is only needed when changing 
project.yml
.

From a checkout:

cd mcp-server
npm ci
npm run build
cd ../samples/ReadingList
node ../../mcp-server/dist/unified.js loop init --project . --brief BRIEF.md --checks checks.json --plan plan.json --attempts 2
node ../../mcp-server/dist/unified.js loop resume --project . --verify-only
node ../../mcp-server/dist/unified.js loop status --project .

The supplied plan makes this verification-only example work without an AI login. Five XCTest tests exercise persistence, search, progress, failed storage, and the UI journey. Six screenshot artifacts cover empty, add, library, detail, no results, and error states. See 
the demo
.

Plan and implement your own app

Start with a project, an app brief, and trusted executable acceptance checks. Use the demo JSON files as a schema example. Each criterion maps to existing check IDs; screen names map to screenshot-producing checks. Check commands run directly with argument arrays, without a shell, in the project directory. You must author meaningful tests for your intended behavior; a successful process alone cannot establish that an arbitrary brief is satisfied.

node /path/to/ios-agent-skill/mcp-server/dist/unified.js loop init --project /path/to/app --brief BRIEF.md --checks checks.json --attempts 3
node /path/to/ios-agent-skill/mcp-server/dist/unified.js loop resume --project /path/to/app

Without 
--plan
, init asks the installed, authenticated 
claude
 CLI for a structured plan. Review 
.ios-agent/loop/state.json
 before resuming. Resume runs checks first, sends failing log tails and the plan to Claude, then reruns checks after edits. Repairs are limited to 1–10 attempts, 12 Claude turns per attempt, and a ten-minute timeout per Claude invocation. Each check has its own timeout (maximum 30 minutes). This bounds attempts and time, not monetary cost. Claude subscription/API usage is governed by your own local CLI configuration.

The repair adapter exposes Read, Glob, Grep, Edit and Write; it disables external MCP connections and does not grant shell commands. Your configured acceptance commands run with your local user permissions. Inspect them before running; this workflow is not a security sandbox. List tests, scripts and project settings in 
protectedFiles
 so the loop detects changed acceptance files instead of accepting weakened tests. Dependencies must already be installed.

Evidence and resume

Artifacts must use canonical relative paths under 
.ios-agent/evidence/
. Symlink evidence paths are rejected. Checks must create fresh, nonempty artifacts; demo verification additionally validates PNG signatures.
State, logs and evidence are stored in 
.ios-agent/
. Add that directory to your project's 
.gitignore
; logs may contain source code, local paths or command output. The loop does not publish them or copy credentials. Claude receives the brief, configuration and failing log excerpts through your local authenticated CLI.
Successful checks are reused only when source fingerprints and saved evidence/log hashes match. Source changes invalidate cached results. Generated directories such as 
.build
, 
build
, 
DerivedData
, 
node_modules
, 
.git
, 
.ios-agent
 and Xcode user/workspace metadata are excluded; do not keep implementation source there.
Interruptions preserve progress and terminate active process groups on macOS/Linux. Resume rechecks interrupted work. Windows process cleanup is limited to the direct child; iOS simulator verification requires macOS.
Frozen verification files cannot change mid-run. To intentionally revise the acceptance contract, archive 
.ios-agent/loop
 and initialize a new run. Do not edit saved results to claim completion.
Completion means every configured check passed with retained artifacts. Screenshots need human visual inspection; they are not automatic proof of visual quality, full accessibility or App Store readiness.

Validation status

Engine tests cover cached evidence, source changes, frozen checks, retry exhaustion, interruption and a deterministic repair adapter. The demo has real simulator tests. On 2026-09-16, a real authenticated Claude Code repair passed a bounded integration fixture: the initial check failed, Claude edited the source text, and the frozen acceptance check passed on the first repair attempt. This verifies the live repair adapter, not full iOS app generation or model-generated planning. Full end-to-end app-building and the cross-client benchmark remain unverified. No API keys or credentials are included in the repository.

---

# App Description Workflow
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-app-description-workflow.html

Tooling · Reference guideApp Description WorkflowRepository guidance for App Description Workflow. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Context
Goal
Input Contract
Output Shape
Color Generation Rules
Prompt Template
User Update Loop
Anti-Patterns

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Collect the app brief
↓2
Extract features and constraints
↓3
Produce a structured plan
↓4
Confirm acceptance criteria

02 / ArchitectureResponsibility boundariesBoundary 1
User descriptionBoundary 2
Planning contractBoundary 3
Implementation tasksConnected responsibilities, not a required class hierarchy or an execution trace.
Context

Use this when the user describes an app in natural language and expects the AI
to turn that description into a complete iOS build plan, implementation prompt,
and visual direction.

This workflow is for prompts and product direction. It does not replace Xcode
project creation, static MCP review, simulator screenshots, or user feedback.

Goal

Given one user description, produce:

A clear app brief.
A complete implementation prompt the coding agent can execute.
A design system derived from the description.
A color palette with accessible foreground choices.
A first-screen plan and navigation model.
A feedback loop so the user can update the result by saying what should
   change.

Input Contract

Accept short, messy descriptions. Do not require the user to fill a long form.

Example:

Create a fitness app for beginners. It should track workouts, show progress,
feel energetic, and use blue and green.

If details are missing, infer reasonable defaults and label them as assumptions.
Ask a question only when the missing detail blocks implementation, such as
whether the app needs authentication, payments, health data, or backend sync.

Output Shape

Return the generated prompt in this order:

APP BRIEF
- Product type:
- Target users:
- Primary outcome:
- Core flows:
- Assumptions:

BUILD PROMPT
Build a native iOS app named <Name> using SwiftUI...

FEATURES
- ...

ARCHITECTURE
- ...

DESIGN SYSTEM
- Personality:
- Typography:
- Spacing:
- Components:

COLOR SYSTEM
- Primary:
- Secondary:
- Accent:
- Background:
- Surface:
- Text:
- Success/warning/error:
- Foreground decisions:

SCREENS
- ...

IMPLEMENTATION ORDER
1. ...

VERIFICATION
- Static MCP checks:
- Build/test command:
- Simulator screenshot path:

The 
BUILD PROMPT
 must be ready to paste into an AI coding agent. It should
name the framework choices, file boundaries, data models, screens, design
tokens, previews, and verification commands.

Color Generation Rules

Derive colors from the app description, not from a generic favorite palette.

Map product language to color direction:

Description signal

Likely direction

finance, trust, business, security

blues, deep greens, neutral surfaces

health, fitness, outdoors, growth

greens, teals, energetic accents

food, cooking, hospitality

warm reds, oranges, tomato, cream surfaces

education, productivity, notes

calm blues, indigo, focused neutrals

kids, creativity, games

brighter multi-color accents with restraint

luxury, fashion, portfolio

black, ivory, metallic accents, high contrast

meditation, sleep, wellness

muted lavender, sage, soft blue, low glare

developer, AI, automation

electric blue, violet, graphite, cyan accents

Always produce a tokenized palette:

enum AppTheme {
    static let primary = Color(hex: "#0A6EBD")
    static let secondary = Color(hex: "#18A058")
    static let accent = Color(hex: "#F59E0B")
    static let background = Color(.systemBackground)
    static let surface = Color(.secondarySystemBackground)
    static let text = Color(.label)
}

Rules:

Use semantic system colors for background, surface, and body text wherever
  possible.
Never scatter raw hex values at call sites. Put all custom colors in one
  theme type.
Pick foreground text for colored surfaces by contrast measurement, not by
  guessing white.
If black and white both fail 4.5:1 on a small label, darken or lighten the
  fill color.
Provide light and dark mode behavior.
Keep palettes domain-appropriate. A banking app should not look like a toy;
  a game should not look like enterprise settings.

Prompt Template

Use this exact template when turning a description into an executable prompt:

You are building a native iOS app from this user description:
"<USER_DESCRIPTION>"

Create a production-ready SwiftUI implementation with:
- iOS deployment target: iOS 17+ unless the user asks otherwise
- Architecture: MVVM with protocol-based dependencies
- State: @MainActor @Observable view models for UI-rendered state
- Navigation: typed routes and deep-link-ready structure
- Design: tokenized colors, spacing, typography, radius, shadows
- Accessibility: Dynamic Type, VoiceOver labels, 44pt targets, contrast checks
- Previews: every main state must render without network or disk
- Testing: unit-testable use cases and injected repositories

Derived product brief:
- Name:
- Audience:
- Problem:
- Primary user journey:
- Secondary flows:
- Data model:
- Offline behavior:

Derived visual direction:
- Personality:
- Color tokens:
- Typography:
- Component style:
- Motion:

Implement:
1. App entry point
2. Theme/design tokens
3. Models
4. Repository protocols and mock repositories
5. View models
6. Screens
7. Reusable components
8. Previews
9. Tests where practical

Do not:
- Use raw colors, spacing, or font sizes in views
- Create live dependencies inside view models
- Use @Observable without @MainActor for UI state
- Claim the app works without build/test/screenshot evidence

User Update Loop

When the user gives feedback, update only the affected parts.

Examples:

User says

Update

"make it more premium"

Typography, spacing, surface treatment, color saturation, motion restraint

"change to red and black"

Color tokens, gradients, button fills, chart/category colors, contrast notes

"for kids"

Tone, iconography, larger touch targets, playful accents, simpler navigation

"add AI chat"

Feature list, architecture, privacy rules, Foundation Models availability

"make it for gym trainers"

Audience, workflows, data model, dashboard hierarchy

After updating, return:

UPDATED SECTIONS
- ...

UNCHANGED SECTIONS
- ...

NEXT EXECUTION PROMPT
...

Do not regenerate the whole plan unless the user's feedback changes the product
category or primary user journey.

Anti-Patterns

// WRONG: ask the user 15 setup questions before doing anything.
Why: the user gave enough signal to create a first useful prompt.

// RIGHT: infer defaults, label assumptions, and ask only blocking questions.

// WRONG: "Use blue because blue looks modern."
Why: color must follow the product domain and accessibility contrast.

// RIGHT: derive a palette from the app's audience, emotional tone, and task
context, then choose foreground colors by measurement.

// WRONG: rewrite the full prompt after every small user change.
Why: it loses continuity and makes user feedback feel ignored.

// RIGHT: update the affected prompt sections and preserve the rest.

---

# Device Hub
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-device-hub.html

Developer Tools · Reference guideDevice HubRepository guidance for Device Hub. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

September 2026 Duo check
1. What it replaces
2. The matrix that actually matters

iOS 27: app resizability

3. Accessibility testing
4. Reproducing a device-specific bug
5. Multi-device workflows
Anti-Patterns
Checklist

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Choose a device matrix
↓2
Discover available runtimes
↓3
Reproduce the same scenario
↓4
Compare recorded evidence

02 / ArchitectureResponsibility boundariesBoundary 1
Device inventoryBoundary 2
Scenario runnerBoundary 3
Device-specific evidenceConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
 reproducing a device-specific bug, managing simulators,
running accessibility checks, or testing across several devices at once.

Device Hub (Xcode 27) brings devices and simulators together in one place, so you
can diagnose and reproduce issues, inspect device state, and run testing
workflows without leaving Xcode.

Verification status (2026-09-16):
 Xcode 27 is released. Device Hub UI workflows still require hands-on verification; this repository’s local runtime checks use Xcode 26.6.

September 2026 Duo check

Apple describes Duo support in Device Hub as upcoming. Use the runtime MCP’s 
simulator_environment
 to inspect the selected Xcode and installed profiles. See 
docs/platforms/iphone-duo.md
 for the dated source and adaptation checks; do not infer simulator availability from the hardware announcement.

1. What it replaces

Previously these were four separate places — Devices and Simulators, the
Accessibility Inspector, 
xcrun simctl
, and Console. Device Hub consolidates
them:

Task

Before

Now

See connected devices and simulators

Devices and Simulators window

Device Hub

Inspect device state

Console + Settings on device

Device Hub

Reproduce a bug on a specific model

Manual scheme juggling

Device Hub

Run one test across several devices

Scripted xcodebuild loops

Device Hub

The practical gain is fewer context switches while chasing a device-specific
bug — which is most of the cost of chasing one.

2. The matrix that actually matters

Testing "on a simulator" is not testing. This skill's rules produce bugs that
only appear on specific configurations:

Configuration

Catches

Smallest supported device (iPhone SE)

Truncation, layouts that assume height

Largest + landscape

Stretched layouts, wasted space

iPad

Missing NavigationSplitView, size-class assumptions, app resizability

Dark mode

Gray-on-gray, invisible shadows, unreadable pills

Accessibility text sizes

Clipped rows, fixed heights, unreflowed HStacks

RTL (Arabic, Hebrew)

Hardcoded leading/trailing, mirrored icons

Oldest supported OS

APIs used without an @available guard

Physical device

Performance, camera, motion, biometrics, real network

The last one is not optional. A simulator has your Mac's CPU and no radio;
main-actor contention and network flakiness both hide there.

iOS 27: app resizability

Rebuilding against the iOS 27 SDK 
automatically opts your app into
resizability
 on iPad and in iPhone Mirroring. This is a behavior change you
inherit without asking for it.

That makes iPad and resized-window testing mandatory on an SDK bump, not a nice
to have. Anything that assumed a fixed width will surface here:

// FRAGILE — assumes a width that resizing invalidates.
.frame(width: 390)
GeometryReader { geo in
    if geo.size.width > 700 { … }        // a resized window crosses this constantly
}

// ROBUST — adapts.
@Environment(\.horizontalSizeClass) private var sizeClass
ViewThatFits(in: .horizontal) { wideLayout; narrowLayout }

Verify: launch on iPad, drag the window through every width, and confirm nothing
clips, overlaps, or jumps layout mid-drag.

3. Accessibility testing

Device Hub surfaces accessibility inspection alongside the device, which makes
it practical to check per-device rather than once at the end.

What to verify — these map to the rules in 
docs/design/design-tokens.md
:

VoiceOver
 reaches every interactive element in a sensible order, and each
  has a meaningful label. Decorative images are hidden.
Dynamic Type
 at 
.accessibility5
 — no clipping, rows reflow.
Contrast
 ≥ 4.5:1 body, 3:1 large text and controls.
Tap targets
 ≥ 44×44pt at every text size.
Reduce Motion
 and 
Reduce Transparency
 are honoured.

// The three environment values every screen should respect.
@Environment(\.dynamicTypeSize) private var typeSize
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency

Automate what you can so it does not depend on remembering:

@Test("no element is missing an accessibility label")
func labels() throws {
    let app = XCUIApplication()
    app.launch()
    for button in app.buttons.allElementsBoundByIndex {
        #expect(!button.label.isEmpty)
    }
}

Full reference: 
docs/frameworks/accessibility.md
.

4. Reproducing a device-specific bug

1. Reproduce it on the exact reported configuration — model, OS, text size,
   appearance, language. Not an approximation.
2. Inspect device state at the moment of failure.
3. Narrow: does it reproduce on a simulator? A different model? A different
   OS version? Each "no" is information about the cause.
4. Fix.
5. Re-verify on the original configuration, then on the matrix above.

Step 1 is the one people skip. "It probably also happens on the 15 Pro" is a
guess, and the whole point of a device-specific bug is that the device is
specific.

This is the 
swift-debugger
 method — reproduce, isolate, fix, prove — applied to
hardware. See 
.claude/agents/swift-debugger.md
.

5. Multi-device workflows

Running one test across several devices at once is worth it when:

You are verifying a 
layout
 change (widths and text sizes vary).
You are verifying an 
availability
 guard (OS versions vary).
You are checking a 
performance
 regression (hardware varies).

It is not worth it for pure logic tests — those are the same everywhere, and a
unit test is faster than a UI test on five devices. Reserve the matrix for what
actually varies by device.

Anti-Patterns

# 1. "Works on my simulator."
   No radio, your Mac's CPU. Performance and network bugs hide there.

# 2. Testing one device size.
   iPhone SE and iPad Pro are different products.

# 3. Skipping iPad after an SDK bump.
   iOS 27 auto-opts you into resizability. Fixed-width assumptions break.

# 4. Accessibility as a pre-release pass.
   Cheap per screen during development; expensive as a sweep at the end.

# 5. Approximating the reported configuration.
   The device is the variable. Reproduce it exactly.

# 6. Running the full matrix on logic-only tests.
   Slow, and it tells you nothing a unit test would not.

# 7. Testing only in English.
   German truncates, Arabic mirrors.

Checklist

[ ] Verified on the smallest and largest supported devices.
[ ] Verified on iPad, including dragging the window across widths (iOS 27
      resizability).
[ ] Verified in light and dark mode.
[ ] Verified at 
.accessibility5
 text size.
[ ] Verified in one RTL language.
[ ] Verified on the oldest supported OS version.
[ ] Verified on at least one physical device.
[ ] VoiceOver reaches every control with a meaningful label.
[ ] A reported bug was reproduced on its exact configuration before fixing.

---

# Foundation Models `fm` CLI
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-fm-cli.html

Developer Tools · Reference guideFoundation Models `fm` CLIRepository guidance for fm CLI. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Overview
1. Use Cases
2. Safe Workflow
3. Agent Integration Pattern
4. What Not to Do
5. Review Checklist

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Define a local model experiment
↓2
Inspect available CLI support
↓3
Run a scoped prompt
↓4
Record output and limitations

02 / ArchitectureResponsibility boundariesBoundary 1
Experiment inputBoundary 2
Local model CLIBoundary 3
Evaluation recordConnected responsibilities, not a required class hierarchy or an execution trace.
Overview

Apple announced the 
fm
 command-line tool for prompting Foundation Models from the terminal. This fits agent workflows: use it to explore prompts, inspect model behavior, and produce quick repros before moving a prompt into app code or Evaluations.

Do not hardcode undocumented flags in automation. Use the installed tool's 
fm --help
 output for exact syntax in the current Xcode seed.

1. Use Cases

quick prompt exploration
comparing short instruction variants
creating repro steps for prompt bugs
generating sample outputs for evaluation design
validating model availability on a development Mac
pairing with the Foundation Models Python SDK for scripts

Do not treat manual CLI output as a release gate. Promote important cases into 
docs/testing/evaluations.md
.

2. Safe Workflow

xcrun --find fm
fm --help

Then run the current seed's documented prompt/evaluation commands. Capture:

Xcode version
OS version
model/provider route if shown
prompt
instructions
output
token/latency data if shown

Keep prompt experiments under 
work/
 or another ignored scratch location. Do not commit transcripts that contain private user data.

3. Agent Integration Pattern

Draft a prompt in a plain text file.
Run it through 
fm
 manually.
Save only sanitized outputs that illustrate a failure or improvement.
Convert the prompt into app code with availability checks.
Add an Evaluations dataset before merging.
Profile the real app path with Instruments.

This keeps terminal exploration useful without letting it replace app-level testing.

4. What Not to Do

Do not rely on undocumented CLI output formats for stable CI parsing.
Do not paste real user data into terminal prompts.
Do not commit large generated transcripts.
Do not assume CLI behavior matches a device with different model availability.
Do not ship prompt changes based only on one or two successful CLI samples.

5. Review Checklist

[ ] CLI experiment includes Xcode/OS seed info
[ ] Exact 
fm --help
 syntax was checked locally
[ ] Private data removed from prompt/output artifacts
[ ] Important cases promoted to Evaluations
[ ] Real app path profiled with Foundation Models Instruments

See also: 
docs/frameworks/foundation-models.md
, 
docs/testing/evaluations.md
, 
docs/tooling/foundation-models-instruments.md
.

---

# Foundation Models Instruments
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-foundation-models-instruments.html

Developer Tools · Reference guideFoundation Models InstrumentsRepository guidance for Foundation Models Instruments. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Overview
1. When to Profile
2. Recording Workflow
3. What to Inspect
4. Optimization Playbook
5. PR Evidence Template
6. Review Checklist

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Reproduce a model request
↓2
Record an Instruments trace
↓3
Inspect latency and resources
↓4
Measure a focused change

02 / ArchitectureResponsibility boundariesBoundary 1
App workloadBoundary 2
Instruments recordingBoundary 3
Performance evidenceConnected responsibilities, not a required class hierarchy or an execution trace.
Overview

The Foundation Models instrument in Instruments shows how an app uses language models at runtime: prompts, responses, token use, tool calls, tool latency, session timelines, and bottlenecks. Use it before optimizing prompts, Dynamic Profiles, tools, context windows, or Private Cloud Compute usage.

1. When to Profile

Profile whenever:

a Foundation Models feature feels slow
token usage grows unexpectedly
a 
LanguageModelSession
 hits context limits
tool calls take too long or return too much data
Dynamic Profiles swap models/instructions unexpectedly
PCC/server-model routing changes product cost or latency
evaluation failures need runtime traces

Do not optimize prompts from intuition. Record the runtime behavior first.

2. Recording Workflow

Open the Xcode project.
Choose Product > Profile.
Select the Foundation Models template in Instruments.
Record while exercising the exact AI feature path.
Capture screenshots or export the trace for PR evidence.

Before recording, make sure the device is not thermally constrained and no background workload is skewing latency.

3. What to Inspect

Signal

Why It Matters

Input tokens

Long instructions, schemas, and tool descriptions can dominate context

Output tokens

Long answers can increase latency and power use

Cached tokens

Reused prefixes can reduce repeated work

Tool calls

Slow tools or verbose outputs often cause model delays

Session timeline

Reveals repeated calls, retries, or unexpected profile changes

Model/provider

Confirms on-device vs PCC/server routing

Errors

Shows context overflow, model unavailability, or tool failure

Pair this with 
tokenCount(for:)
 and 
contextSize
 checks in code when available.

4. Optimization Playbook

Finding

Fix

Huge instructions

Split feature roles, shorten policies, use Dynamic Profiles

Huge tool schemas

Reduce tool count, tighten argument types, avoid redundant descriptions

Huge tool output

Return compact structured summaries, page results, cap search count

Repeated cold latency

Use prewarm(promptPrefix:) where appropriate

Context overflow

Summarize transcript, start a new session, or split work

Wrong model route

Inspect profile/model selection and availability branches

PCC cost/latency too high

Route smaller tasks to SystemLanguageModel

Always rerun evaluations after prompt or tool-schema changes. Lower token count is not a win if quality regresses.

5. PR Evidence Template

Foundation Models profiling:
- Device / OS:
- Feature path:
- Input token range:
- Output token range:
- Tool calls:
- Slowest tool:
- On-device/PCC/server route:
- Optimization made:
- Evaluation result after optimization:

6. Review Checklist

[ ] A trace exists for slow or complex AI flows
[ ] Token use is measured before and after prompt/tool changes
[ ] Tool outputs are bounded and summarized
[ ] Dynamic Profile routing is visible in evidence
[ ] Context overflow has an explicit mitigation
[ ] Performance changes are paired with Evaluations results

See also: 
docs/frameworks/foundation-models.md
, 
docs/testing/evaluations.md
, 
docs/frameworks/core-spotlight-rag.md
.

---

# From an Idea to a Running Apple App
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-idea-to-app.html

Tooling · Reference guideFrom an Idea to a Running Apple AppRepository guidance for From an Idea to a Running Apple App. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Context
Pattern

One command to start
Build and see it
Finish the app identity
Client capabilities

Anti-Patterns

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Clarify the product idea
↓2
Define screens and states
↓3
Implement a vertical slice
↓4
Verify acceptance criteria

02 / ArchitectureResponsibility boundariesBoundary 1
Product briefBoundary 2
Feature implementationBoundary 3
Acceptance evidenceConnected responsibilities, not a required class hierarchy or an execution trace.
Context

Use this when the user gives an app description and wants the coding agent to implement it. The CLI creates a project starting point; the agent implements the requested features and verifies them with Xcode.

Pattern

One command to start

npx -y @nagarjuna2002/ios-agent@0.2.0 new MyApp --brief "A reading tracker with offline reading sessions and weekly goals" --xcodegen

The output includes SwiftUI source, an editable implementation brief, an XcodeGen project specification, and separate SVG icon layers. It preserves an existing nonempty project unless explicitly instructed; even force mode does not silently overwrite generated-file collisions.

Open the new folder in Claude Code, Codex, or Gemini CLI with this skill installed. Ask the agent to implement 
App/APP_BRIEF.md
 through build and visual verification. It should translate the idea into screens, data models, persistence, user flows, error states, and a small testable first release. Build the actual requested product instead of stopping at the starter screen.

Build and see it

On macOS with Xcode and XcodeGen installed:

cd MyApp/App
xcodegen generate --spec project.yml
open MyApp.xcodeproj
xcodebuild -project MyApp.xcodeproj -scheme MyApp -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -derivedDataPath ../.ios-agent/build build CODE_SIGNING_ALLOWED=NO

Use Xcode’s destination list for simulator tests. The optional simulator MCP can help run and inspect the app; it requires local Xcode and is not exposed by the public knowledge server. Keep account credentials, service configuration and signing identities in the user’s environment.

Finish the app identity

Customize the generated background, foreground and accent SVGs in the app’s IconLayers folder. Follow 
Icon Composer
 to create a native icon with editable groups and appearance variants, then assign it to the target and verify it in a build. The starter layers are intentionally generic artwork.

Client capabilities

Claude Code, Codex and Gemini CLI can perform local implementation when granted filesystem/terminal access. The portable ChatGPT plugin supplies the same workflow and bundled references. ChatGPT without a coding environment can prepare a plan and code artifacts; a local macOS environment must perform iOS simulator builds. A remote MCP knowledge endpoint supplies public reference tools and never gets access to the user’s local source tree.

Anti-Patterns

Calling the app complete just because scaffolding succeeded.
Claiming automatic App Store publication, signing, backend provisioning or native icon creation.
Replacing an existing app’s architecture with the starter scaffold.
Presenting mock services or placeholder test assertions as production features.

---

# iOS Simulator MCP
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-ios-simulator-mcp.html

Developer Tools · Reference guideiOS Simulator MCPRepository guidance for iOS Simulator MCP. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Context
Package Boundary
Sidebar preview and current device discovery

Optional MCP configuration

Current Package
Planned Tool Surface
Safety Rules
Evidence Contract
Anti-Patterns

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Discover available simulator
↓2
Build install and launch
↓3
Exercise the requested screen
↓4
Capture bounded evidence

02 / ArchitectureResponsibility boundariesBoundary 1
MCP clientBoundary 2
Simulator tool boundaryBoundary 3
Xcode and simulatorConnected responsibilities, not a required class hierarchy or an execution trace.
Context

Use this when the task needs evidence from a running app: screenshots, videos, logs, deep links, accessibility trees, gestures, app install/launch, or repeated design iteration.

Do not add these tools to 
ios-agent-mcp
. The existing package is intentionally a read-only static analyzer that runs without Xcode, without a simulator, without network, and without writes. Simulator control has a different runtime contract: macOS, Xcode, a booted simulator, and user-visible side effects.

Package Boundary

Package

Contract

Good for

ios-agent-mcp

Read project files, analyze Swift, return findings

Concurrency, architecture, SwiftUI, security, testing, performance, App Store readiness

ios-simulator-mcp

Drive Xcode and a booted simulator

Build, test, install, launch, open native Simulator and preview screenshots

The split keeps a Swift linter lightweight while still giving agents a path to runtime validation.

Sidebar preview and current device discovery

Published package: 
@nagarjuna2002/ios-simulator-mcp@0.2.0
 (the scoped name belongs to this repository; the unscoped npm name belongs to a different publisher).

Use 
simulator_environment
 to inspect installed Xcode, runtimes and actual device types. For iPhone Duo, see 
docs/platforms/iphone-duo.md
; do not claim a profile exists before discovery confirms it.

Call 
simulator_list
 and choose a real UDID.
Call 
simulator_show
 to boot, wait and open the native Simulator window.
Build, install and launch the app with the existing runtime tools.
Call 
simulator_preview_start
. Open the returned loopback URL in the client's browser/sidebar, or any browser on the same Mac.
Interact in the native Simulator window. The browser shows refreshed screenshots with pause and fit controls; it does not send taps or keyboard input.
Call 
simulator_preview_stop
 when done. This removes preview screenshots and closes its listener; it leaves the Simulator running.

From a terminal, for a device that is already booted:

npx -y @nagarjuna2002/ios-simulator-mcp@0.2.0 --viewer DEVICE-UDID

Open the printed URL. Stop with Ctrl-C. It is an opt-in local viewer, not a cloud simulator or a public sharing link. The browser viewer does not upload screenshots. When 
screenshot
 uses 
includeImage: true
, pixels are sent to the MCP client and may be processed by its model/provider. The tokenized URL grants access to simulator screenshots while running, so treat it as private.

Optional MCP configuration

Add this server to a local MCP-capable client (Claude Desktop/Code, Codex or Gemini CLI) on a Mac with Xcode:

{
  "mcpServers": {
    "ios-simulator": {
      "command": "npx",
      "args": ["-y", "@nagarjuna2002/ios-simulator-mcp@0.2.0"]
    }
  }
}

Adapt the surrounding configuration format to the client. The portable knowledge plugin stays independent of Xcode; simulator control is opt-in. Hosted ChatGPT cannot reach another computer's localhost just by installing this package. Use a local client for runtime control; screenshots can be returned as MCP images by calling 
screenshot
 with 
includeImage: true
.

Current Package

The first executable slice now lives in 
ios-simulator-mcp/
. It is deliberately
separate from 
ios-agent-mcp
 because it requires macOS, Xcode, and simulator
side effects.

Install from source:

cd ios-simulator-mcp
npm install
npm run build
node dist/index.js --help

Implemented tools:

Tool

Purpose

Backend

simulator_environment

Inspect Xcode, runtimes and device profiles

xcodebuild, xcode-select, simctl list

simulator_show

Boot, wait and open native Simulator

bootstatus, open

simulator_preview_start / simulator_preview_stop

Start/stop private loopback screenshot viewer

local HTTP + simctl io screenshot

simulator_list

List available device instances

xcrun simctl list --json

simulator_boot

Boot a simulator by UDID

xcrun simctl boot

simulator_shutdown

Shut down a booted simulator

xcrun simctl shutdown

build_project

Build an app or test target

xcodebuild build

run_tests

Run tests with captured result output

xcodebuild test

install_app

Install an .app bundle

xcrun simctl install

launch_app

Launch by bundle identifier

xcrun simctl launch

terminate_app

Terminate by bundle identifier

xcrun simctl terminate

open_deep_link

Open a URL or universal link

xcrun simctl openurl

screenshot

Capture a PNG for review

xcrun simctl io screenshot

Planned Tool Surface

Next, extend the deterministic Apple-tooling tier:

Tool

Purpose

Backend

record_video

Record and stop simulator video

xcrun simctl io recordVideo

stream_logs

Capture app logs with predicates

xcrun simctl spawn log stream

reset_app_state

Reset one app's container and permissions

app uninstall/install or container cleanup

Add UI-driving tools only after the backend spike chooses XCUITest, WebDriverAgent, or idb:

Tool

Purpose

inspect_accessibility_tree

Return visible elements, labels, roles, frames, enabled state

tap

Tap by accessibility id, text, or coordinates

double_tap

Double tap an element or point

long_press

Long press with a duration

swipe

Swipe by direction or coordinate path

type_text

Type into a focused or targeted field

wait_for_element

Poll until an element appears, disappears, or changes state

Safety Rules

Never erase all simulator content without explicit user approval.
Prefer resetting one app's state over resetting the entire simulator.
Return exact command output, screenshot paths, video paths, and log excerpts as evidence.
Capture simulator identity in every result: device name, UDID, runtime, app bundle id, and build configuration.
Time-bound every long-running command and return handles for recording/log streams.
Do not claim visual correctness from a build alone. A visual task needs a screenshot or video.
Do not type secrets into apps unless the user explicitly provides test credentials for that run.

Evidence Contract

Every runtime tool should return structured data suitable for workflow branching:

{
  "status": "passed",
  "device": {
    "name": "iPhone 17 Pro",
    "udid": "SIMULATOR-UDID",
    "runtime": "iOS 27.0"
  },
  "app": {
    "bundle_id": "com.example.App",
    "configuration": "Debug"
  },
  "artifacts": {
    "screenshot": "artifacts/screens/home.png",
    "video": "artifacts/videos/flow.mov",
    "logs": "artifacts/logs/app.log"
  },
  "summary": "Launched app and captured home screen."
}

For failure results, include the failing command, exit code, stderr, and the nearest useful artifact. A failed build with no artifact is still useful evidence; a visual claim with no artifact is not.

Anti-Patterns

// WRONG: add simulator control to ios-agent-mcp
Why: makes every static-analysis install depend on Xcode and a booted simulator.

// RIGHT: create ios-simulator-mcp with a macOS/Xcode runtime contract.

// WRONG: treat screenshot capture as enough for UI automation.
Why: screenshots prove pixels, not element identity or accessibility state.

// RIGHT: pair screenshots with accessibility-tree inspection once a UI backend is chosen.

// WRONG: expose "reset simulator" as a casual command.
Why: erasing content is destructive and surprises users.

// RIGHT: expose reset_app_state first, and require approval for simulator erase.

---

# AI-assisted issue previews
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-issue-reporting.html

Tooling · Reference guideAI-assisted issue previewsRepository guidance for AI-assisted issue previews. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Local developer feedback on every failed step
Private feedback from the AI chat — source preview only

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Identify a significant package problem
↓2
Prepare a category-only preview
↓3
Let developer review the public draft
↓4
Developer submits if desired

02 / ArchitectureResponsibility boundariesBoundary 1
Local failure categoryBoundary 2
Report previewBoundary 3
User-controlled GitHub issueConnected responsibilities, not a required class hierarchy or an execution trace.
Availability:
 ios-agent-mcp 2.7.0 and later, with 36 unified tools.

When an iOS Agent operation fails, the AI can prepare a report using

prepare_issue_report
. Report problems with this package, not ordinary errors
in the user's app. A preview is not a diagnosis or proof of a package bug.

Example MCP arguments:

{
  "feature": "asset-generation",
  "symptom": "invalid-output",
  "client": "claude",
  "platform": "macos",
  "reproducibility": "repeated"
}

Report only significant missing/incorrect guidance or a blocking package failure.
   Minor warnings and normal bugs in the generated app do not belong here.
Show the complete category-only preview and say the GitHub issue will be public.
If the developer requested reporting for this issue or enabled opening major-issue
   drafts for this session, the AI opens the prefilled 
submissionUrl
 through its
   client's browser capability. Otherwise ask before opening. If browser access is
   unavailable, display the link. Never claim the tool itself launches a browser.
The developer reviews the draft, signs into GitHub if needed, and clicks Submit.
   Opening the draft does not submit it. No automatic submission or private backend
   is needed. Do not open repeat drafts after dismissal or for the same failure.

The source also supports 
missing-guidance
 and 
incorrect-guidance
 symptoms;
these additions are pending the next npm release. Use 
feature: "local-references"

for knowledge gaps. Disclose missing knowledge and label any outside research as
external, rather than attributing it to this repository.

The tool takes fixed enums only. Unknown fields and arbitrary text are rejected.
It does not read files, environment variables, project names or machine identity;
collect logs; open URLs; write a report file; or send network requests. It returns
package version, selected categories, a non-user-specific grouping key, preview
text and URLs. Missing client/platform values stay 
unknown
.

Opening the URLs sends the fixed fields to GitHub. Submitting creates a public
issue. The grouping key only aids search: this tool does not search remotely,
assert that duplicates are absent, or suppress duplicates automatically.

Local developer feedback on every failed step

The unified server appends troubleshooting guidance to returned unified tool errors, preserving the original diagnostic and structured result. The agent should explain the observed error, distinguish evidence from guesses, attempt a bounded fix within the approved task, and show the verification result. Compiler/test failures returned as successful tool calls and thrown transport errors are covered by agent instructions rather than this error-result decorator. Actual presentation depends on the coding client and agent following those instructions.

This feedback stays in the coding session, not in the generated app or the public website. Redact secrets before repeating diagnostics. It does not transmit additional details or confirm a GitHub submission. Repeated failures get local attention and public submission remains separately authorized. For an actionable public bug report, separately review and authorize a minimal synthetic reproduction; never attach private app code or raw logs automatically.

Private feedback from the AI chat — source preview only

The source adds 
private_feedback
 as a 37th tool. It is not in npm 2.7.0 and the
receiver is not deployed. Without 
IOS_AGENT_PRIVATE_FEEDBACK_URL
, it returns

not-configured
 and sends nothing. There is no automatic public fallback.

Once an operator deploys the receiver, the user does not need a GitHub account:

The AI calls 
private_feedback
 with 
action: "preview"
 and a 
report
 containing
   the same fixed categories shown above. No raw errors, logs, app source or names.
Show the complete payload, destination and privacy notice in chat. Ask whether
   to send it privately; declining must not interrupt development or cause repeated prompts.
Only after explicit approval, call it with 
action: "submit"
, the returned
   
previewId
, and 
userApproved: true
. Approval expires after 15 minutes and is
   consumed on the first attempt. The AI must never assert approval on its own.
Tell the user whether the receiver confirmed it. An unknown/network result is
   not success; do not retry silently. No issue URL or private issue content is returned.

Example preview arguments:

{"action":"preview","report":{"feature":"simulator","symptom":"timeout","client":"claude"}}

Only the category fields and package version leave the device. The hosting provider
sees the request IP and may retain access logs; repository collaborators and GitHub
can access reports. Private repository access is not end-to-end encryption. Category
reports show patterns, not enough detail to prove or fix every bug. Any detailed
reproduction needs a separately approved privacy review.

The receiver rechecks that its fixed destination is private before every submission,
reserves uncertain requests against duplication, and applies bounded limits. It cannot
independently prove that an AI obtained consent; client instructions and the coding
client’s tool-approval controls remain important. There is no background failure hook
or automatic report on every tool call. See the 
operator setup
.

---

# Build from local source and Apple guidance
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-offline-source-library.html

Tooling · Reference guideBuild from local source and Apple guidanceRepository guidance for Build from local source and Apple guidance. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Context
Pattern

Where the complete code lives
Apple guidance and ownership

Anti-Patterns

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Search a narrow topic
↓2
Read the matching outline
↓3
Retrieve bounded source sections
↓4
Preserve source attribution

02 / ArchitectureResponsibility boundariesBoundary 1
Local source filesBoundary 2
Search indexBoundary 3
Bounded retrievalConnected responsibilities, not a required class hierarchy or an execution trace.
Context

Use this workflow when implementing an Apple app without repeatedly browsing documentation or feeding entire manuals to an AI. The repository contains original implementation guides, editable source files, app templates, and testable Swift packages. Apple links identify the source of API guidance and support checking changed APIs; the local implementations can be read without opening those links.

This is not Apple's private framework implementation or a mirror of every Apple documentation article. The 405-technology directory includes services, tools and legacy APIs as well as Swift frameworks. A directory entry is not a promise of a complete working example for that technology. Generated discovery guides remain labelled as such.

Pattern

Use a clone for offline work after installation. Search runs locally with Node and does not call an AI or make network requests:

node scripts/query-library.mjs search "Persistence" source
node scripts/query-library.mjs outline docs/frameworks/swiftdata.md
node scripts/query-library.mjs read samples/SkillPatterns/Sources/SkillPatterns/Persistence.swift 0 6000

search
 returns up to eight file paths, titles and sizes, without file bodies. 
outline
 returns headings with character offsets. 
read
 returns exact content, a content hash and 
nextOffset
; continue from that offset until it is null when the complete source is needed. Offsets are JavaScript UTF-16 string indexes, not bytes. No excerpt is silently presented as a complete file.

The same library is bundled inside the knowledge MCP package:

Call 
search_local_references
 with a feature/API query and 
kind: "source"
 for reusable code, or 
kind: "guide"
 for explanations.
Use 
get_reference_outline
 to select a guide section.
Call 
read_local_reference
 with its exact returned path and offset. The default body budget is 6,000 characters; the maximum is 16,000 per call. Follow 
nextOffset
 only if necessary.
Read sibling package manifests, dependencies and tests before adapting a source file. Preserve error handling, actor isolation and availability requirements.
Build and test the resulting app with the installed SDK. A source file being indexed does not prove it builds independently.

get_apple_technology
 now defaults to an overview with local guide/source routes. Request 
view: "full"
 only when the entire guide and topic map are needed. The repository's Markdown examples also contain explicitly labelled wrong patterns; retain their surrounding explanation and do not automatically extract every fenced block as production Swift.

Where the complete code lives

samples/SkillPatterns/
: Swift package containing persistence, streams, routing, observation, composition and signal-processing implementations with tests.
samples/AppleRecipes/
: original Apple API implementations with their own manifest, tests and build evidence. See its README for exact platform coverage.
templates/ios-app/
: an app's screens, models, repositories and test starting points.
templates/common-patterns/
: editable networking, persistence, authentication, design and navigation source; adaptation and app-level testing are required.
cli/src/
: complete source of the app scaffolder.
mcp-server/src/
: complete source of the analysis and local knowledge servers.

See 
docs/apple/local-library.md
 for the generated file inventory. The index contains metadata only. Content has one canonical source file; the MCP bundle stores identical content once by SHA-256. Plugin archives carry those same canonical files so they work without fetching individual guides.

Apple guidance and ownership

Original code in this repository is covered by the repository's MIT license, subject to any file-specific notices. Apple documentation links in the guides are attribution and freshness references. Apple SDK binaries, internal source and proprietary manuals are not relicensed as part of this project. Use the installed SDK and the linked official guidance for availability or behavior that changed after the snapshot.

Anti-Patterns

WRONG: load the whole catalog and all guides for each feature. RIGHT: search first, then fetch the relevant section or source.
WRONG: label link coverage as implementation coverage. RIGHT: identify actual source paths and show build/test evidence.
WRONG: concatenate all Swift fences into one target. RIGHT: choose the correct implementation, its dependencies and tests; keep labelled anti-patterns out of production.
WRONG: promise a fixed token reduction. RIGHT: limit returned characters and measure the selected model's actual usage. Characters are not tokens.

---

# Project Scaffolding and Layout
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-project-scaffolding.html

Tooling · Reference guideProject Scaffolding and LayoutRepository guidance for Project Scaffolding and Layout. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

1. The layout
Create from a description
2. The rule that decides where anything goes

The one exception, and why it is not a violation

3. Why hiding internals is not cosmetic

What the reference tools actually do

4. One declaration, four behaviours

The same move, applied to the command surface
Exit codes are part of the interface
What a --fix flag may repair

5. Root discovery — how two processes agree

Interop

6. Cross-platform
Anti-Patterns
Checklist

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Choose project identity
↓2
Generate the project layout
↓3
Keep tool files separated
↓4
Build the generated project

02 / ArchitectureResponsibility boundariesBoundary 1
User-owned appBoundary 2
Tool-owned workspaceBoundary 3
Generated project configurationConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
 designing what a tool writes into a user's project, deciding
where caches and state belong, or reviewing a scaffold that puts more than a
handful of entries at the project root.

Covers the layout 
ios-agent
 generates, the rule that decides where any new
file goes, why hiding internals is not cosmetic, and how the CLI and the MCP
server agree on a project root without configuring each other.

Implementation: 
cli/
 (the 
ios-agent
 package). Every claim here is enforced
by a test in 
cli/test/
.

1. The layout

MyApp/
├── App/                    # the user's source — the only directory they edit
│   ├── MyApp/
│   └── MyAppTests/
├── README.md
├── LICENSE
├── .gitignore
└── .ios-agent/             # tool-owned; deleting it loses nothing
    ├── .gitignore          # generated
    ├── config.json         # tracked
    ├── state.json
    ├── metadata.json
    ├── cache/
    ├── logs/
    ├── build/
    ├── screenshots/
    ├── templates/          # tracked
    ├── plugins/            # tracked
    └── tmp/

With 
--minimal
, the whole project is 
MyApp/App/
, and 
.ios-agent/

materialises the first time a command needs it.

Create from a description

ios-agent new TeaLog --brief 'An offline tea journal with tasting notes' --xcodegen
cd TeaLog/App
xcodegen generate --spec project.yml
open TeaLog.xcodeproj

--brief
 saves the description and an implementation checklist in

App/APP_BRIEF.md
 for your coding agent. It does not call an AI service or
implement the described features. Both flags are optional and work with

--minimal
; the visible root remains 
App/
, 
README.md
, and 
LICENSE

(or only 
App/
 in minimal mode).

--xcodegen
 writes an editable 
App/project.yml
, 
App/BUILD.md
, and separate
SVG starters under 
App/<Name>/IconLayers/
. The specification includes an iOS
17+ SwiftUI app, a unit-test target, and a shared scheme. The included test is a
placeholder to replace before shipping. Requires macOS, Xcode 15+ with an iOS
simulator runtime, and XcodeGen installed separately. The CLI neither installs
nor invokes them. Use an XcodeGen version compatible with your Xcode.

The layer folder contains background, foreground, and accent SVGs plus a manifest
and import instructions. Import them into 
Icon Composer

using a compatible Xcode installation, customize the appearance, save a native
icon, configure the target, and validate it in Xcode. The manifest is our source
layer inventory, not Apple's format; no native 
.icon
 is generated or validated.
The starter layers are excluded from app resources.

--force
 permits a non-empty destination but refuses existing generated file
paths or symlink destinations before writing. It never overwrites your source,
brief, specification, README, or configuration. Choose a new app directory when
regenerating a starter.

Project configuration follows the 
XcodeGen specification
.
The CLI emits no 
.xcodeproj
; running XcodeGen creates the real project.

2. The rule that decides where anything goes

Split by authorship, not by importance.

If a 
human
 writes it, it is visible. If the 
tool
 writes it, it is
hidden.

Importance is the tempting axis and it is the wrong one, because it has no
edge. Everything feels important to whoever added it, so a layout sorted by
importance grows a new root directory per release until the project root is a
list of implementation details. Authorship has a sharp edge: either a person
typed it or a program emitted it, and nobody argues about which.

Two consequences fall out immediately, and both are worth more than the tidiness:

clean needs no confirmation prompt.
 Nothing in 
.ios-agent/
 was authored,
so there is nothing to lose. A prompt would be theatre.

Deleting .ios-agent/ is a supported recovery step.
 "Delete it and re-run"
is only safe advice if it is structurally true, and here it is — which turns the
most common support answer in this class of tool from a risk into a fix.

The one exception, and why it is not a violation

config.json
 is tracked, and a human may edit it. That is the same bargain

.git/config
 makes: tool-managed by default, legible and editable by anyone who
opens it. It stays inside 
.ios-agent/
 because it is 
maintained
 by the tool —

ios-agent init
 writes it, and future commands will update it.

If it ever becomes a file people are expected to hand-edit as the primary
workflow, it should move to the root and become visible, the way 
pubspec.yaml
,

app.json
, 
firebase.json
, and 
Cargo.toml
 all are. 
No professional tool
hides configuration its users are expected to author
 — a hidden file is one
they cannot discover, and one that reads as noise in a pull request.

3. Why hiding internals is not cosmetic

Four concrete costs, none of them aesthetic:

A root directory is an API.
 Anything visible gets referenced — in a script,
a CI job, a README someone wrote. 
cache/
 at the root will be depended on
within a release, and then it cannot be renamed. Inside 
.ios-agent/
, the whole
tree stays private and refactorable.

Every root entry is a question the user has to answer.
 Nine directories at
the root is nine decisions about whether to touch each one, made by someone who
wanted to write a view. One 
App/
 is no decision at all.

Review noise trains people to skim.
 A generated 
metadata/
 that changes on
every build turns pull requests into scroll-past exercises, and reviewers who
learn to skim generated files skim the real ones too.

.gitignore drift is a silent failure.
 With internals at the root, every new
tool directory needs a matching root 
.gitignore
 line that someone must
remember. They forget, a cache lands in the repository, and nobody notices until
a clone is slow. The generated 
.ios-agent/.gitignore
 uses ignore-everything
then unignore, so a directory added tomorrow is ignored the moment it is
declared.

What the reference tools actually do

Tool

Visible, human-authored

Hidden, tool-authored

Flutter

pubspec.yaml, lib/

.dart_tool/

Expo

app.json, app/

.expo/

Firebase CLI

firebase.json

.firebase/

Cargo

Cargo.toml, src/

target/

npm

package.json, src/

node_modules/

Xcode

project.pbxproj

xcuserdata/, DerivedData

git

—

.git/

The pattern is unanimous, and the split is authorship every time. Note also that
every one of them keeps its 
hand-edited config visible at the root
 — which
is the point in §2 about where 
config.json
 would have to move.

4. One declaration, four behaviours

The design's leverage is not the directory name; it is that everything derives
from a single table (
cli/src/layout.ts
):

export const INTERNAL_ENTRIES: readonly InternalEntry[] = [
  { name: "config.json", kind: "file",      tracked: true,  eager: true,  purpose: "…" },
  { name: "cache",       kind: "directory", tracked: false, eager: false, purpose: "…" },
  // …
];

Adding a future feature — simulator state, plugin cache, build artifacts — is
one row. That row then automatically produces:

its entry in the generated 
.ios-agent/.gitignore
inclusion in, or exclusion from, 
ios-agent clean
a path in 
ios-agent where --json
a 
doctor
 check that it has not leaked to the project root

The alternative — a constant here, a gitignore line there, a 
clean
 list
somewhere else — is three places that must be edited together and eventually are
not. The failure mode is specific and quiet: a new cache directory that 
clean

skips and git happily commits.

This is also why the tests assert the 
relationship
 rather than the contents:

test("clean never targets a tracked entry", () => {
  const disposable = new Set(disposableEntries().map((e) => e.name));
  for (const name of INTERNAL_ENTRIES.filter((e) => e.tracked).map((e) => e.name)) {
    assert.ok(!disposable.has(name), `${name} is both tracked and disposable`);
  }
});

That test does not care what the entries are. It fails the day someone adds a
row that is both tracked and deletable, which is the bug that would cost a user
their template overrides.

The same move, applied to the command surface

COMMANDS
 in 
cli/src/commands.ts
 is the second instance of the pattern.
Dispatch, 
help
, and the bash and zsh completion scripts all read one table, so
help text cannot describe a flag the parser rejects and completions cannot offer
a command that no longer exists. Hand-maintained help drifts, and the drift is
invisible until a user follows the documentation and gets an error.

The test is the same shape as the layout one — it asserts coverage rather than
content:

test("completions cover every dispatchable command", () => {
  for (const shell of ["bash", "zsh"]) {
    const script = capture(run(["completions", shell]));
    for (const name of commandNames()) assert.ok(script.includes(name));
  }
});

Exit codes are part of the interface

Code

Meaning

0

Success

1

Usage error, or no project found

2

doctor found problems

doctor
 returns 2 rather than 1 so a caller can distinguish "the project is
unhealthy" from "you invoked it wrong". Collapsing both into 1 forces scripts to
parse stderr, which is exactly what 
--json
 exists to prevent.

What a 
--fix
 flag may repair

Only defects with a 
derivable correct value
: a stale generated gitignore, a
missing internal directory, a config behind the current layout version. A
missing 
App/
 is reported and left alone — creating it would invent a project
structure the user never asked for.

The line is worth stating explicitly because it is easy to cross by accident: a

--fix
 that guesses turns a diagnostic into a source of surprise changes, and
users stop trusting the command that was supposed to be the safe one.

5. Root discovery — how two processes agree

.ios-agent/
 doubles as a 
root marker
, exactly as 
.git/
 does. Both the
CLI and the MCP server walk up from the working directory until they find one:

1. an explicit --project / argument
2. IOS_AGENT_HOME (CLI) or IOS_AGENT_PROJECT (server)
3. the nearest ancestor containing .ios-agent/
4. cwd  (server only — the CLI reports failure instead)

This is why 
ios-agent where
 works from 
App/MyApp/Views/
, and it is why the
MCP server no longer analyses whatever subtree a client happened to spawn it in.

Both report how the root was resolved
, not just what it is:

{ "project_root": "/Users/you/MyApp", "resolved_from": "marker" }

An implicit root is unfalsifiable. Without 
resolved_from
, "0 Swift files" is
identical whether the project is empty or the tool is pointed at the wrong
directory — and that ambiguity is the most expensive minute in using a tool like
this.

The server reads the marker; it never creates it.
 That keeps

ios-agent-mcp
's declared 
filesystem: read, network: none
 contract intact.
Scaffolding writes, so it lives in a separate package rather than quietly
turning the analyzer into something that mutates your project.

Interop

Other tools should ask rather than hardcode:

ios-agent where --json

One process owns the layout; everything else queries it. A rename then stays a
change in one package instead of a coordinated release across several.

6. Cross-platform

Per-project vs. user-level caches are different things.
 Anything shared
across projects — downloaded templates, SDK metadata, plugin code — belongs in
the user-level cache, or every project duplicates it and every fresh clone
re-downloads it. 
.ios-agent/cache/
 is only for data derived from 
this

project.

Platform

User-level cache

macOS

~/Library/Caches/ios-agent

Windows

%LOCALAPPDATA%\ios-agent\Cache

Linux/other

$XDG_CACHE_HOME/ios-agent, else ~/.cache/ios-agent

~/.ios-agent
 is not on that list deliberately: a dotfile in 
$HOME
 is the
convention every platform has since moved away from, and on macOS and Windows it
is excluded from the OS's own cache-eviction handling.

Windows specifics:

A leading dot does not hide a directory in Explorer. 
doctor
 says so and
  suggests 
attrib +h
, rather than pretending the name is enough.
CON
, 
PRN
, 
AUX
, 
NUL
, 
COM1
–
COM9
, 
LPT1
–
LPT9
 are reserved
  regardless of extension. 
validateProjectName
 rejects them on every platform,
  so a project created on macOS still checks out on Windows.
Tracked paths in 
config.json
 are stored POSIX-style. That file crosses
  machines by design, and 
App\MyApp
 is unreadable on the Mac that builds it.

macOS specifics:
 the default filesystem is case-insensitive but
case-
preserving
, so 
App/
 and 
app/
 collide on a Mac and not on Linux CI.
Never generate two paths differing only in case.

Anti-Patterns

# WRONG — internals at the project root.
MyApp/
├── cache/          # depended on by a script within a release, now frozen
├── logs/           # in every pull request
├── metadata/       # regenerated on build, diffed by humans forever
├── config/
├── generated/
└── App/            # the one directory the user wanted, 1 of 6

# RIGHT — one hidden directory.
MyApp/
├── App/
└── .ios-agent/

// WRONG — the internal directory name written out at a call site.
const cache = path.join(root, ".ios-agent", "cache");

// A rename half-lands: the CLI writes to the new directory while the MCP
// server still reads the old one, and the symptom is an empty result with no
// error anywhere.

// RIGHT — derive from the layout.
const { cache } = layoutFor(root);

// WRONG — clean deletes a hand-maintained list.
const REMOVE = ["cache", "logs", "tmp"];

// It drifts from the gitignore the first time someone adds a directory to one
// and not the other. Then either a cache gets committed, or clean deletes a
// tracked template override.

// RIGHT — derive both from one declaration.
for (const entry of disposableEntries()) { … }

// WRONG — the tool falls back to cwd and says nothing.
const root = process.cwd();

// "0 Swift files" now means either an empty project or a wrong directory, and
// nothing in the output distinguishes them.

// RIGHT — resolve, and report how.
const { root, source } = resolveRootFrom(argv, env);   // "flag" | "marker" | "cwd"

// WRONG — a per-project cache for data that is not project-specific.
const templates = path.join(root, ".ios-agent", "cache", "templates");

// Ten projects, ten copies, ten downloads.

// RIGHT — user-level, per platform.
const templates = path.join(globalCacheDir(), "templates");

// WRONG — ~/.ios-agent as the user-level location.
const home = path.join(os.homedir(), ".ios-agent");

// Ignored by macOS cache eviction and by Windows roaming rules, and wrong on
// every platform's own convention.

// RIGHT — the documented per-platform directory, overridable.
globalCacheDir(process.env, process.platform);

// WRONG — the project name accepted as given.
fs.mkdirSync(path.join(parent, name));

// "../evil" escapes the parent, "my-app" is not a Swift type name, and "CON"
// produces a directory Windows cannot open.

// RIGHT — validate before anything exists on disk.
validateProjectName(name);

// WRONG — scaffolding into a directory with contents already in it.
fs.mkdirSync(root, { recursive: true });
write(readme);

// Silently overwrites a README someone spent an afternoon on.

// RIGHT — refuse, and make --force explicit and non-destructive.
if (existing.length > 0 && !force) throw new ScaffoldError(…);

// WRONG — a single source directory, to be widened later.
{ "sourceDir": "App/MyApp" }

// Multiple apps were always coming, and the widening is a breaking change to
// every consumer of a tracked file.

// RIGHT — a list from day one, even with one entry.
{ "apps": [{ "name": "MyApp", "path": "App/MyApp", "platforms": ["iOS"] }] }

# WRONG — writing a fake .xcodeproj or claiming source files are a built app.

# RIGHT — emit Swift sources and an optional XcodeGen specification.
# Let Xcode or XcodeGen create the real project, then verify its build.

Checklist

[ ] Exactly one tool-owned directory, and it is hidden
[ ] Nothing the tool writes appears at the project root — asserted by a test
[ ] Every internal path derives from one declaration, not a string literal
[ ] The gitignore is generated from that declaration, not hand-maintained
[ ] What 
clean
 deletes is the complement of what is tracked, provably
[ ] Deleting the internal directory loses nothing a human authored
[ ] The internal directory is created lazily, not as an empty promise
[ ] Root discovery walks up from cwd, like git
[ ] Every path-reporting output states how the root was resolved
[ ] A machine-readable 
where --json
 exists, so nothing else hardcodes the name
[ ] Read-only consumers stay read-only — scaffolding lives in its own package
[ ] Cross-project caches are user-level, per each platform's own convention
[ ] Windows reserved names rejected on every platform
[ ] Tracked config stores POSIX-style paths
[ ] Project names validated before anything is written
[ ] A non-empty target directory is refused unless explicitly forced
[ ] Config carries a layout version, and a newer one is refused rather than rewritten
[ ] Lists are lists from day one where more than one is coming
[ ] 
doctor
 fails when the layout is broken — proven by mutation tests
[ ] 
--fix
 repairs only what has a derivable correct value, and never invents structure
[ ] Help text and shell completions are generated from the command table, not maintained
[ ] Exit codes distinguish usage errors from an unhealthy project
[ ] 
--json
 is available on every command a script would call

---

# Visual Iteration Loop
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-visual-iteration-loop.html

Tooling · Reference guideVisual Iteration LoopRepository guidance for Visual Iteration Loop. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Context
Pattern
Stop Conditions
Visual Review Checklist
Artifact Naming
Anti-Patterns

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Define a visual acceptance check
↓2
Capture the current screen
↓3
Compare against the design
↓4
Fix and recapture within budget

02 / ArchitectureResponsibility boundariesBoundary 1
Design targetBoundary 2
Runtime screenshotBoundary 3
Review and repairConnected responsibilities, not a required class hierarchy or an execution trace.
Context

Use this when the user asks for a screen to look premium, polished, more native, more accessible, more animated, or closer to a reference. Static Swift review can find many issues, but visual quality needs a loop that sees the app running.

The workflow is:

Design -> Build -> See -> Improve

Pattern

Design
: route to 
ui-ux-designer
, 
motion-designer
, 
3d-experience-designer
, or 
accessibility-reviewer
 depending on the requested surface.
Build
: compile with Xcode and run the narrowest test target available.
See
: launch the app in the simulator, capture screenshots or video, and inspect the accessibility tree.
Review
: compare the artifact against the request, platform conventions, Dynamic Type, contrast, spacing, reduced motion, and tap targets.
Improve
: patch the SwiftUI/UIKit/RealityKit/Metal code and repeat until the stop condition is met.

Stop Conditions

Declare the stop condition before the first edit. Good examples:

GOAL: Checkout screen matches the provided reference while keeping native iOS controls.
CHECK: Build passes, home-to-checkout flow launches, screenshots captured at compact and large text sizes.
MAX: 3 visual passes.
ON-STALL: Return the best screenshot and the remaining issues.

Visual Review Checklist

Area

Questions

Hierarchy

Is the primary action obvious within two seconds? Does visual weight match importance?

Spacing

Are margins, gutters, and component gaps consistent with tokens?

Typography

Does the screen use Dynamic Type and avoid fixed font sizes?

Color

Does contrast pass, and do semantic roles survive dark mode?

Motion

Does animation clarify state, avoid fighting layout, and honor Reduce Motion?

Haptics

Are haptics tied to meaningful state transitions, not decoration?

Accessibility

Are labels, traits, focus order, tap targets, and content size tested?

Runtime

Does the captured screenshot/video prove the actual app state?

Artifact Naming

Use stable paths so iterations can be compared:

artifacts/visual/home-pass-01-compact.png
artifacts/visual/home-pass-01-accessibility-xxl.png
artifacts/visual/home-pass-02-compact.png
artifacts/visual/home-pass-02.mov

When visual diffing is added, compare the same route, device, appearance, locale, and content-size category. A screenshot from a different simulator configuration is not a clean comparison.

Anti-Patterns

// WRONG: "I made it premium" after editing SwiftUI.
Why: visual quality was not observed.

// RIGHT: build, launch, capture, review, then report with artifacts.

// WRONG: animate every entrance and every state change.
Why: excessive motion makes the app feel slower and can violate Reduce Motion.

// RIGHT: animate hierarchy changes, confirmations, and spatial continuity.

// WRONG: judge accessibility from a screenshot only.
Why: labels, traits, focus order, and hit targets are semantic/runtime facts.

// RIGHT: pair screenshot review with accessibility-tree inspection and Dynamic Type captures.

---

# Xcode 27 Coding Agents
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-xcode-27-agents.html

Developer Tools · Reference guideXcode 27 Coding AgentsRepository guidance for Xcode Agents. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

1. What they are
2. Xcode agent vs. Claude Code
3. The same verification contract applies
4. Agent-assisted localization
5. Agent-assisted testing
6. Instruments
7. Keeping agents on this skill's rules
Anti-Patterns
Checklist
Connect this server inside Xcode 27

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Check installed Xcode support
↓2
Connect the chosen agent
↓3
Scope the requested edit
↓4
Verify with local build evidence

02 / ArchitectureResponsibility boundariesBoundary 1
Xcode workspaceBoundary 2
Agent connectionBoundary 3
Build and review toolsConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
 working inside Xcode's agent features, deciding what to
delegate to an in-Xcode agent versus a CLI agent, or setting up agent-assisted
localization or testing.

Xcode 27 has coding agents built in, powered by a model of your choice. This
document covers what they are good at, what they are not, and how the discipline
in 
docs/orchestration/
 applies inside Xcode.

Verification status (2026-09-16):
 Xcode 27 is released. The local verification host has Xcode 26.6, so in-editor agent connection, menu paths and delegated reviewer behavior remain untested. CLI MCP tests do not establish Xcode integration.

1. What they are

Agents in Xcode work across the development cycle — prototyping, filling in
implementation, and polishing — and work the same whether you are solo or on a
team. Xcode supplies the agent with 
project context
: your targets, your
schemes, your code style, and your string catalogs.

That project context is the real difference from a general-purpose agent in a
terminal. An in-Xcode agent knows the build graph. An external agent has to
rediscover it.

Strength

Because

Localization and string catalogs

It sees the catalogs and Apple's language-specific style guidance

Scaffolding UI and boilerplate

It knows the project's targets and conventions

Test authoring

It can run the tests it writes

Diagnosing a build failure

It has the actual compiler output

Weakness

Because

Cross-repo work

Scoped to the Xcode project

Long-running orchestration

No worktrees, no batch fan-out

Non-Swift toolchain work

Not what it is built for

2. Xcode agent vs. Claude Code

These are complements, not competitors. Route by the shape of the work.

Work

Use

"Add German and update the string catalog"

Xcode agent — it owns catalogs

"Why does this fail on iPad only?"

Xcode agent — Device Hub is right there

"Write tests for this view model"

Xcode agent — can run them immediately

"Apply this rule across 30 modules"

Claude Code — /batch, worktrees, PRs

"Restructure our architecture layers"

Claude Code — plan/review subagents

"Audit the repo against our skill rules"

Claude Code — hooks and CI

Rule of thumb: 
inside one project and one build graph → Xcode. Across files,
repos, or PRs → Claude Code.

3. The same verification contract applies

An agent in Xcode is still an agent. Everything in

docs/orchestration/verification.md
 holds:

Never accept "done" without evidence.
 In Xcode the evidence is right
  there — the build result and the test navigator. Look at it.
The author does not grade the work.
 If the agent wrote the code 
and
 the
  test, both encode the same misunderstanding. Read the test yourself and ask
  whether it would fail against the old behavior.
A green build is not a passing feature.
 It compiles. That is one claim.

Agent says                          What you check
"Added tests, they pass."      →    Run them. Read them. Would they fail before?
"Fixed the layout issue."      →    Run it on the device size that broke.
"Localized to 8 languages."    →    Check pluralization and RTL, not just presence.
"Build succeeds."              →    True and insufficient.

4. Agent-assisted localization

The strongest first use, because it is high-volume, low-ambiguity, and verifiable.

Agents can add languages, update string catalogs, and translate strings, using
Apple-provided language-specific style guidance and your app's context.

What still needs you:

Plural variants.
 Agents handle language-specific plural forms, but the
  
rule
 per string is a product decision. Check the catalog's variants.
Context strings.
 A key named 
title
 translates badly without a comment.
  Add comments before running the agent, not after.
RTL layout.
 Translation is not layout. Verify Arabic and Hebrew visually.
Truncation.
 German and Finnish run long. Check at accessibility text sizes.

// Give the agent something to work with — the comment IS the context.
Text("cart.checkout.button", comment: "Button that starts checkout. Keep under 15 characters.")

See 
docs/design/interaction-standards.md
 §6 for the localization standards the
output must meet.

5. Agent-assisted testing

Agents write tests and can run them. Two failure modes to watch:

Tests that assert the implementation, not the behavior.
 A test that mirrors
the code line for line passes forever and catches nothing.

// WEAK — restates the implementation.
#expect(viewModel.items.count == viewModel.repository.items.count)

// STRONG — asserts the behavior the user depends on.
#expect(viewModel.errorMessage != nil)     // a failed load must surface

Tests written to pass rather than to catch.
 Ask of any generated test: 
would
this have failed before the fix?
 If not, delete it.

Use 
docs/testing/mocking-strategy.md
 for what to substitute, and

checklists/testing.md
 for coverage shape.

6. Instruments

Xcode 27's profiling improvements matter for the concurrency rules this skill
enforces:

Instrument

Shows

Swift Concurrency

Async task scheduling, actor contention, thread usage

Time Profiler

CPU bottlenecks, with a "top functions" view

System Trace

System-level view of threads and hardware

Run comparisons

The measured impact of a change

The Swift Concurrency instrument is the tool for the failure this skill warns
about most: main-actor contention from CPU work that should have been

nonisolated
 or on an actor. Do not guess at isolation performance — measure it.
See 
docs/swift/swift-concurrency.md
.

7. Keeping agents on this skill's rules

The rules in 
SKILL.md
 apply to code an Xcode agent writes too, but Xcode does
not read 
CLAUDE.md
. Two ways to keep them enforced:

A pre-commit hook or CI
, so the rules bind regardless of which agent wrote
   the code. 
templates/hooks/forbid-antipatterns.sh
 runs standalone:

bash
   # .git/hooks/pre-commit
   for file in $(git diff --cached --name-only --diff-filter=ACM | grep '\.swift$'); do
     echo "{\"tool_input\":{\"file_path\":\"$PWD/$file\"}}" \
       | .claude/hooks/forbid-antipatterns.sh || exit 1
   done

Review with swift-reviewer
 before the PR, which checks the same rules
   with model judgment where a grep cannot reach.

Deterministic enforcement is what makes multi-agent work safe: it does not matter
which agent wrote the line if the rule is checked mechanically. See

docs/orchestration/hooks.md
.

Anti-Patterns

# 1. Accepting a diff you have not read because the build went green.
   Compiling is one claim, not a review.

# 2. Letting the agent write both the code and its only test.
   Same misunderstanding, encoded twice.

# 3. Shipping generated translations without checking plurals, RTL, or truncation.

# 4. Using an in-Xcode agent for a 30-module migration.
   No worktrees, no isolation. Use /batch.

# 5. Using Claude Code for string-catalog work.
   Xcode owns the catalogs and the style guidance.

# 6. Assuming Xcode agents follow SKILL.md.
   They do not read it. Enforce with hooks or CI.

# 7. Guessing at concurrency performance.
   The Swift Concurrency instrument measures actor contention directly.

Checklist

[ ] The work is scoped to one project — otherwise route it to 
/batch
.
[ ] Generated diffs are read, not just built.
[ ] Generated tests would have failed before the change.
[ ] Localization output checked for plurals, RTL, and truncation.
[ ] String keys have comments before the agent runs.
[ ] This skill's rules are enforced by a hook or CI, not by hoping.
[ ] Isolation performance is measured, not assumed.

Connect this server inside Xcode 27

Apple documents separate 
in-Xcode agent environments
. A terminal agent’s working MCP setup does not automatically configure the IDE agent.

Select the agent in Xcode’s Intelligence settings. Its configuration lives under 
~/Library/Developer/Xcode/CodingAssistant
: 
ClaudeAgentConfig
 for Claude, 
codex
 for Codex, and 
gemini
 for Gemini.
Merge one 
ios-agent
 stdio entry into that agent’s configuration; retain existing settings. Use an absolute executable path if Xcode cannot find Node on its GUI PATH. The server command is 
npx
 with args 
["-y", "ios-agent-mcp@latest", "--project", "/absolute/path/to/App"]
. An installed 
ios-agent-mcp
 executable avoids a package fetch during the session.
Reload the agent, list its available tools, then call 
analyze_swift_project
 and 
review_app_intents
 on that absolute project path. Verify returned filenames and file counts.
Ask the IDE agent to read the project’s 
AGENTS.md
/
CLAUDE.md
, make a small change, build and run a relevant test. Record the selected agent, Xcode build, tool results and test output. Check that a reviewer can inspect the actual diff; do not assume Claude-specific subagent definitions automatically load in every IDE agent.

Apple also supports agent plug-ins through Intelligence settings → Plug-ins. This repository does not claim its existing client ZIPs have passed Xcode’s plug-in import. No Xcode-only artifact is published without that check.

Current result:
 local stdio MCP tests pass. The above 
Xcode 27 session remains untested
 because the verification host has Xcode 26.6. No global agent configuration was changed during this documentation pass. Apple’s own external-tools server (
mcpbridge
/
mcp-server
) is a separate integration; do not confuse it with this project’s 
ios-agent
 server.

---

# Xcode Memory Debugging
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-xcode-memory-debugging.html

Tooling · Reference guideXcode Memory DebuggingRepository guidance for Xcode Memory Debugging. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Context
First-pass triage
Memory Graph workflow
Instruments Allocations workflow
Sanitizers and runtime diagnostics
Crash logs, jetsam, and MetricKit
Reducing memory
Metal Memory viewer
Report template

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Reproduce memory growth
↓2
Inspect memory graph or trace
↓3
Identify ownership issue
↓4
Re-measure after a focused fix

02 / ArchitectureResponsibility boundariesBoundary 1
Object ownershipBoundary 2
Runtime diagnosticBoundary 3
Memory evidenceConnected responsibilities, not a required class hierarchy or an execution trace.
Context

Load this when an iOS, iPadOS, macOS, watchOS, tvOS, or visionOS app has rising memory, a suspected leak, a retain cycle, 
deinit
 not firing, 
EXC_BAD_ACCESS
, jetsam termination, slow scrolling caused by allocation churn, image/cache pressure, or Metal resource growth.

Apple's Xcode and Instruments tools provide the evidence. This file tells agents what to ask for, what to inspect, and how to report the result.

First-pass triage

Reproduce the issue in a clean run.
Record device or simulator, OS version, scheme, build configuration, and the exact interaction.
Watch Xcode's Debug navigator memory report for current and peak memory.
Capture whether Xcode shows normal, warning, or termination-risk memory pressure.
Separate Swift object leaks from large non-object allocations such as images, decoded video frames, Core Animation backing stores, Metal textures, and malloc buffers.

If the issue only happens on device, do not close it based on simulator evidence.

Memory Graph workflow

Use Xcode Debug Memory Graph when objects stay alive after their owner should be gone.

Checklist:

Navigate to the feature and back out.
Press the Debug Memory Graph button.
Search for the model, view controller, coordinator, task owner, delegate, or cache type.
Inspect incoming strong references.
Look for closure captures, delegates declared strong, timers, notification observers, Combine subscriptions, task handles, and global singletons.
Enable Malloc Stack in the scheme Diagnostics tab when allocation stack traces are needed.
Export the memory graph when sharing evidence.

Report:

INSPECTED: Memory Graph shows FeedViewModel retained by SearchCoordinator.onComplete closure.
Fix: capture self weakly or clear onComplete in stop().
Still unverified: no device rerun after patch.

Instruments Allocations workflow

Use Instruments Allocations when memory grows but the owner is unclear, allocations churn during scrolling, or the issue involves malloc, images, buffers, or anonymous virtual memory.

Checklist:

Profile with the Allocations instrument.
Mark a generation before entering the feature.
Exercise the feature.
Mark another generation after leaving.
Inspect persistent allocations by type, size, count, and responsible stack.
Compare before and after the fix with the same interaction.

Prefer this over guessing from code when the problem is "memory keeps going up."

Sanitizers and runtime diagnostics

Use scheme Diagnostics or test plans to enable the right tool:

Address Sanitizer: memory access errors such as use-after-free and buffer overflow.
Thread Sanitizer: data races in supported simulator runs.
Main Thread Checker: system APIs used off the main thread.
Undefined Behavior Sanitizer: C-family undefined behavior; do not describe it as a Swift-only checker.
Guard Malloc: targeted investigation of memory access crashes when the overhead is acceptable.

Sanitizers change timing and resource use. A sanitizer finding is strong evidence. A clean sanitizer run is not proof that no bug exists.

Crash logs, jetsam, and MetricKit

Use crash reports and device logs when the issue comes from TestFlight, App Store, or a build without debugger entitlements.

Checklist:

Symbolicate crash reports before making code claims.
Distinguish 
EXC_BAD_ACCESS
, language exception aborts, watchdog terminations, and jetsam.
Treat jetsam as memory pressure evidence, not as a normal crash.
Use Xcode Organizer and MetricKit for production peak-memory and memory-at-suspension trends.
Attach the relevant OS version, app version, device, and stack or report summary.

Reducing memory

Start with the largest measured source, not the easiest code to edit.

Common fixes:

Decode images to display size instead of full camera size.
Downsample thumbnails.
Bound in-memory caches by count and cost.
Release feature-scoped resources when leaving the flow.
Stream large files instead of reading them fully.
Cancel long-running async work.
Avoid retaining entire response models when only a small projection is needed.
Reuse buffers carefully in image, audio, and Metal paths.
Label Metal resources and release temporary render targets promptly.

Metal Memory viewer

Use Xcode GPU capture and the Metal Memory viewer when GPU resources grow, AR sessions stutter, texture memory spikes, or render passes allocate unexpected buffers.

Checklist:

Capture a GPU frame near the memory spike.
Inspect textures, buffers, heaps, and render targets by allocated size.
Verify resource labels are meaningful.
Look for per-frame resource creation.
Export GPU trace or CSV when sharing evidence.
Cross-reference local Metal code in 
docs/frameworks/metal.md
.

Report template

Status: INSPECTED / VERIFIED / UNVERIFIED
Environment: device/simulator, OS, Xcode, build configuration
Symptom: what grows, crashes, or remains retained
Evidence: Memory Graph / Allocations / sanitizer / crash log / jetsam / MetricKit / Metal Memory viewer
Likely cause: ownership path, allocation source, or resource lifetime
Change made: exact code or configuration change
Remaining risk: what was not rerun or not available

---

# UIKit Essentials
https://nagarjuna2997.github.io/ios-agent-skill/guides/uikit-uikit-essentials.html

Core UI and Apps · Reference guideUIKit EssentialsRepository guidance for UIKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

UIViewController Lifecycle
UIView and Auto Layout

Programmatic Constraints with Anchors
UIStackView

UITableView with Diffable Data Source
UICollectionView Compositional Layout
UINavigationController and UITabBarController
Keyboard Handling and UIResponder Chain

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Choose view controller boundary
↓2
Compose constrained views
↓3
Connect model updates
↓4
Test lifecycle transitions

02 / ArchitectureResponsibility boundariesBoundary 1
View controllerBoundary 2
View hierarchyBoundary 3
Model servicesConnected responsibilities, not a required class hierarchy or an execution trace.
UIViewController Lifecycle

class MyViewController: UIViewController {

    // Called once when the view is loaded into memory
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        fetchInitialData()
    }

    // Called every time the view is about to appear (animated or not)
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        navigationController?.setNavigationBarHidden(false, animated: animated)
    }

    // Called after the view has fully appeared on screen
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        startAnimations()
    }

    // Called when the view is about to be removed from the hierarchy
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        stopAnimations()
    }

    // Called after the view has been removed
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        cancelPendingRequests()
    }

    // Called when the view controller's view is released from memory
    deinit {
        NotificationCenter.default.removeObserver(self)
    }
}

UIView and Auto Layout

Programmatic Constraints with Anchors

class ProfileView: UIViewController {

    private let avatarImageView: UIImageView = {
        let iv = UIImageView()
        iv.translatesAutoresizingMaskIntoConstraints = false
        iv.contentMode = .scaleAspectFill
        iv.clipsToBounds = true
        iv.layer.cornerRadius = 40
        return iv
    }()

    private let nameLabel: UILabel = {
        let label = UILabel()
        label.translatesAutoresizingMaskIntoConstraints = false
        label.font = .preferredFont(forTextStyle: .headline)
        label.adjustsFontForContentSizeCategory = true
        return label
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(avatarImageView)
        view.addSubview(nameLabel)

        NSLayoutConstraint.activate([
            avatarImageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
            avatarImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            avatarImageView.widthAnchor.constraint(equalToConstant: 80),
            avatarImageView.heightAnchor.constraint(equalToConstant: 80),

            nameLabel.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 12),
            nameLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
        ])
    }
}

UIStackView

func createFormStack() -> UIStackView {
    let emailField = UITextField()
    emailField.placeholder = "Email"
    emailField.borderStyle = .roundedRect

    let passwordField = UITextField()
    passwordField.placeholder = "Password"
    passwordField.borderStyle = .roundedRect
    passwordField.isSecureTextEntry = true

    let loginButton = UIButton(type: .system)
    loginButton.setTitle("Log In", for: .normal)
    loginButton.configuration = .filled()

    let stack = UIStackView(arrangedSubviews: [emailField, passwordField, loginButton])
    stack.axis = .vertical
    stack.spacing = 16
    stack.alignment = .fill
    stack.distribution = .fill
    stack.translatesAutoresizingMaskIntoConstraints = false
    return stack
}

UITableView with Diffable Data Source

class ContactsViewController: UIViewController {

    enum Section { case main }

    struct Contact: Hashable {
        let id: UUID
        let name: String
        let email: String
    }

    private var tableView: UITableView!
    private var dataSource: UITableViewDiffableDataSource<Section, Contact>!

    override func viewDidLoad() {
        super.viewDidLoad()
        configureTableView()
        configureDataSource()
        applyInitialSnapshot()
    }

    private func configureTableView() {
        tableView = UITableView(frame: view.bounds, style: .insetGrouped)
        tableView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        view.addSubview(tableView)
    }

    private func configureDataSource() {
        dataSource = UITableViewDiffableDataSource<Section, Contact>(
            tableView: tableView
        ) { tableView, indexPath, contact in
            let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
            var content = cell.defaultContentConfiguration()
            content.text = contact.name
            content.secondaryText = contact.email
            cell.contentConfiguration = content
            return cell
        }
    }

    private func applyInitialSnapshot() {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Contact>()
        snapshot.appendSections([.main])
        snapshot.appendItems([
            Contact(id: UUID(), name: "Alice", email: "alice@example.com"),
            Contact(id: UUID(), name: "Bob", email: "bob@example.com"),
        ])
        dataSource.apply(snapshot, animatingDifferences: true)
    }
}

UICollectionView Compositional Layout

class AppStoreViewController: UIViewController {

    enum Section: Int, CaseIterable {
        case featured, categories, topApps
    }

    private func createLayout() -> UICollectionViewCompositionalLayout {
        return UICollectionViewCompositionalLayout { sectionIndex, environment in
            guard let section = Section(rawValue: sectionIndex) else { return nil }
            switch section {
            case .featured:
                return self.createFeaturedSection()
            case .categories:
                return self.createCategoriesSection()
            case .topApps:
                return self.createTopAppsSection()
            }
        }
    }

    // Full-width horizontally scrolling section
    private func createFeaturedSection() -> NSCollectionLayoutSection {
        let itemSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .fractionalHeight(1.0)
        )
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        item.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8)

        let groupSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(0.9),
            heightDimension: .absolute(250)
        )
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

        let section = NSCollectionLayoutSection(group: group)
        section.orthogonalScrollingBehavior = .groupPagingCentered
        section.contentInsets = NSDirectionalEdgeInsets(top: 16, leading: 0, bottom: 16, trailing: 0)
        return section
    }

    // Grid of categories
    private func createCategoriesSection() -> NSCollectionLayoutSection {
        let itemSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(0.5),
            heightDimension: .absolute(60)
        )
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        item.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4)

        let groupSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .absolute(60)
        )
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

        let section = NSCollectionLayoutSection(group: group)
        section.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)

        let header = NSCollectionLayoutBoundarySupplementaryItem(
            layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(44)),
            elementKind: UICollectionView.elementKindSectionHeader,
            alignment: .top
        )
        section.boundarySupplementaryItems = [header]
        return section
    }

    // Vertical list section
    private func createTopAppsSection() -> NSCollectionLayoutSection {
        let itemSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .absolute(70)
        )
        let item = NSCollectionLayoutItem(layoutSize: itemSize)

        let groupSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .absolute(70)
        )
        let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])

        let section = NSCollectionLayoutSection(group: group)
        section.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)
        return section
    }
}

UINavigationController and UITabBarController

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
        guard let windowScene = scene as? UIWindowScene else { return }
        window = UIWindow(windowScene: windowScene)

        // Tab bar with navigation controllers
        let homeNav = UINavigationController(rootViewController: HomeViewController())
        homeNav.tabBarItem = UITabBarItem(title: "Home", image: UIImage(systemName: "house"), tag: 0)

        let searchNav = UINavigationController(rootViewController: SearchViewController())
        searchNav.tabBarItem = UITabBarItem(title: "Search", image: UIImage(systemName: "magnifyingglass"), tag: 1)

        let profileNav = UINavigationController(rootViewController: ProfileViewController())
        profileNav.tabBarItem = UITabBarItem(title: "Profile", image: UIImage(systemName: "person"), tag: 2)

        let tabBar = UITabBarController()
        tabBar.viewControllers = [homeNav, searchNav, profileNav]
        tabBar.tabBar.tintColor = .systemBlue

        window?.rootViewController = tabBar
        window?.makeKeyAndVisible()
    }
}

// Programmatic navigation
class HomeViewController: UIViewController {
    func showDetail(for item: Item) {
        let detailVC = DetailViewController(item: item)
        navigationController?.pushViewController(detailVC, animated: true)
    }

    func presentModal() {
        let modalVC = ModalViewController()
        modalVC.modalPresentationStyle = .pageSheet
        if let sheet = modalVC.sheetPresentationController {
            sheet.detents = [.medium(), .large()]
            sheet.prefersGrabberVisible = true
        }
        present(modalVC, animated: true)
    }
}

Keyboard Handling and UIResponder Chain

class FormViewController: UIViewController {

    @IBOutlet weak var scrollView: UIScrollView!

    override func viewDidLoad() {
        super.viewDidLoad()

        // Register for keyboard notifications
        NotificationCenter.default.addObserver(
            self, selector: #selector(keyboardWillShow),
            name: UIResponder.keyboardWillShowNotification, object: nil
        )
        NotificationCenter.default.addObserver(
            self, selector: #selector(keyboardWillHide),
            name: UIResponder.keyboardWillHideNotification, object: nil
        )

        // Dismiss keyboard on tap
        let tap = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
        tap.cancelsTouchesInView = false
        view.addGestureRecognizer(tap)
    }

    @objc private func keyboardWillShow(_ notification: Notification) {
        guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
              let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double
        else { return }

        let insets = UIEdgeInsets(top: 0, left: 0, bottom: frame.height, right: 0)
        UIView.animate(withDuration: duration) {
            self.scrollView.contentInset = insets
            self.scrollView.scrollIndicatorInsets = insets
        }
    }

    @objc private func keyboardWillHide(_ notification: Notification) {
        guard let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double
        else { return }
        UIView.animate(withDuration: duration) {
            self.scrollView.contentInset = .zero
            self.scrollView.scrollIndicatorInsets = .zero
        }
    }

    @objc private func dismissKeyboard() {
        view.endEditing(true)
    }
}

// UITextField delegate for field navigation
extension FormViewController: UITextFieldDelegate {
    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
        if let nextField = view.viewWithTag(textField.tag + 1) as? UITextField {
            nextField.becomeFirstResponder()
        } else {
            textField.resignFirstResponder()
        }
        return true
    }
}

---

# Native vs. Web Animation and 3D
https://nagarjuna2997.github.io/ios-agent-skill/guides/web-native-vs-web-animation.html

Design and Motion · Reference guideNative vs. Web Animation and 3DRepository guidance for Native vs Web Animation, WebKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
 · 
All guidesOn this pageVisual overview: workflow and architecture

Context
Decision Tree
Translation Table
WKWebView Is Appropriate When
Native Is Preferred When
Anti-Patterns

Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result1
Identify rendering environment
↓2
Compare native and web needs
↓3
Choose an explicit boundary
↓4
Test accessibility and performance

02 / ArchitectureResponsibility boundariesBoundary 1
Native UIBoundary 2
Web content boundaryBoundary 3
Animation runtimeConnected responsibilities, not a required class hierarchy or an execution trace.
Context

Use this when the user asks for Anime.js, GSAP, Framer Motion, Three.js, PixiJS, p5.js, Matter.js, WebGL, WebGPU, CSS animation, or a JavaScript-style visual effect inside an Apple-platform app.

The default answer is not to add web dependencies to a native iOS app. First translate the intent into native Apple frameworks. Use 
WKWebView
 only when the actual web runtime is the requirement.

Decision Tree

User asks for a web animation or 3D library
        |
        v
Is the app already a web app or must it render existing web content?
        | yes
        v
Use WKWebView with a narrow bridge and content-security rules.
        |
        no
        v
Translate the visual intent to native SwiftUI, UIKit, RealityKit, SpriteKit, or Metal.

Translation Table

Web vocabulary

Native Apple route

Anime.js timelines, stagger, keyframes

SwiftUI KeyframeAnimator, PhaseAnimator, explicit phase state

GSAP timelines and scroll triggers

SwiftUI animation state, scroll phase/offset, Core Animation when UIKit layers are involved

Framer Motion transitions

SwiftUI transitions, matched geometry, navigation transitions

Three.js product scenes

RealityKit, Model3D, RealityView, Metal only for custom rendering

PixiJS sprites and particles

SpriteKit, SwiftUI Canvas, or Metal particles

Matter.js physics

SpriteKit physics, RealityKit physics

p5.js generative visuals

SwiftUI Canvas, Core Graphics, Metal for GPU-heavy work

CSS transforms and filters

SwiftUI transforms, materials, visualEffect, layer effects

WebGL/WebGPU shaders

Metal shaders and compute

WKWebView Is Appropriate When

the product already ships a web surface and the app is a native shell
the user has a real existing Three.js/GSAP asset that must run unchanged
the same visual must be shared with a web product
the interaction depends on browser APIs or DOM layout
a remote web team owns the experience

If you choose 
WKWebView
, isolate it:

disable arbitrary navigation unless explicitly needed
use typed message handlers instead of stringly bridge calls
validate every message from JavaScript
avoid injecting secrets into the page context
document offline behavior and loading/error states

Native Is Preferred When

the effect is decorative or UI-level
the screen must feel like a platform-native iOS view
accessibility, Dynamic Type, Reduce Motion, or VoiceOver are central
the effect needs camera, AR, haptics, widgets, App Intents, or SwiftData
App Store review risk is higher with remote executable behavior

Anti-Patterns

// WRONG: install Three.js because the user said "3D card".
Why: a simple tilt belongs in SwiftUI transforms.

// RIGHT: use SwiftUI for perspective-only effects; use RealityKit for real 3D assets.

// WRONG: put a checkout, login, or settings screen in WKWebView for animation.
Why: native controls, accessibility, autofill, and platform trust matter more than animation syntax.

// RIGHT: build the screen natively and translate the animation vocabulary.

// WRONG: bridge JavaScript by accepting arbitrary command strings.
Why: it creates injection and authorization bugs.

// RIGHT: expose a small typed message schema and reject unknown actions.

---

# iOS Agent Skill — Swift Reviews & Xcode Simulator MCP
https://nagarjuna2997.github.io/ios-agent-skill/

The developer playground
Good ideas deserve

working apps.
An open-source iOS development toolkit: Swift code reviews, local Apple references, app scaffolding and Xcode Simulator tools for your coding agent.
Choose your AI 
Explore the source
Claude · ChatGPT / Codex · Gemini CLI · Muse

Drag an object. On touch, drag sideways. With a keyboard, use arrow keys; Escape resets.
Illustrative artwork, not a recorded app run.
Reset objects
Freeze objects
Meet iOS Agent Skill
Swift guidance.

Tools to check the work.
iOS Agent Skill is an MIT-licensed repository and the 
ios-agent-mcp npm server
 for developers building or reviewing Apple apps with AI coding assistants.
Use it when an agent needs focused Apple references, file-located Swift review findings, an editable app starter, or a way to build, test and capture simulator screens. Your coding agent makes the changes; you keep your app’s source and branding.
Choose a client and follow its setup guide
, or 
read the practical questions below
.
Make it.

Run it. Check it.
Use trusted sourcesWork with local Swift source and attributed Apple guides.
Build with your agentGive it focused context and the tools to make progress.
See it runningBuild, test and inspect the result in your simulator.

One idea. A little further with every step.
Watch the workflow

come together.
From a brief to a build you can actually check.

Your agent does the work. These tools help it stay grounded.
Skip to installation 

A clear brief. Editable source.

Small changes. Actual checks.

Real Reading List simulator capture · iOS 26.5

01 / SHAPE IT
Start with

something worth making.
A reading list. A daily habit. Your next useful idea. Describe the screens, the behavior, and what “working” should mean.
The app starter gives your agent editable source and a brief. Relevant Apple guides and Swift examples help it plan the next move.
See the idea-to-app workflow

02 / BUILD IT
Make a change.

Learn from the result.
Let your coding agent implement one feature, review the relevant Swift, and run the checks. A failure is the next thing to understand.
Keep the diagnostic, explain the smallest fix, and verify again. Bounded retries keep a broken build from becoming an endless loop.
Explore the build-and-fix loop

03 / CHECK IT
Don’t just hear

that it works. See it.
Build and test with Xcode, then inspect the simulator. Compare the result with the acceptance criteria you started with.
This Reading List capture is from a real iOS 26.5 simulator acceptance run using synthetic book data. It demonstrates this screen—not a live AI session or an iOS 27 run.
Run the demo yourself
Recorded simulator evidence, not a live preview.

A little less guesswork
From the first idea

to the last check.
Keep your favorite AI. Give it a focused toolkit for the work of building an Apple app.

01Start an appAn editable Swift starter, implementation brief and optional XcodeGen specification.

02Reuse Apple knowledgeSearch local Swift source and guides in bounded sections, plus a dated directory of Apple technologies and release notes.

03Review Swift codeFile-located findings for concurrency, architecture, SwiftUI, availability, security, performance and App Intents.

04Generate design assetsNamed light/dark/high-contrast colors and an opaque 1024px app icon rendered locally from editable SVG layers.

05Verify on a simulatorBuild, test, install, launch and capture screenshots through Xcode.

Your agent implements the app and verifies the result. Local simulator tools require a Mac with Xcode. The features above are available in ios-agent-mcp 2.7.0.
One package. Your choice.
Meet your agent’s

new toolkit.
Choose your coding client. Connect the server. Start with one small, verifiable task.
Node.js 20+ for the local server.

No separate simulator package to install.
Using ChatGPT web? See connection options 
Claude
Codex
Gemini CLI
Muse
01 / CONNECT YOUR CLIENT
claude mcp add ios-agent -- npx -y ios-agent-mcp@latest

Run from your app folder, then reconnect Claude Code.
Claude setup and usage guide 
02 / GIVE IT A SMALL FIRST TASK
“Review this app’s Swift concurrency. Show each finding with its file and line. Propose the smallest fix, then help me test it.”

Open source. Open about the limits.
Real tools.

Proof over promises.
Inspect the source, review the rules, and ask your agent for actual build and test results.
A quality or token-saving advantage has not been established. The verification notes tell you what’s tested and what still needs work.
Read the evidence 
MIT
Open source. Yours to build on.
Local first
Source and guides, retrieved in focused sections.
npm
See package downloads
Before you install
What can you

use it for?
Concrete workflows, with the requirements and limits up front.

How do I review Swift code with an MCP server?Connect ios-agent-mcp to your local coding client, open your app folder, and ask for a focused concurrency, SwiftUI, architecture or availability review. Findings include file locations so you can inspect a proposed change. These checks are heuristics; validate fixes with the Swift compiler and your tests.Try the review workflow

Can an AI agent build and run my app in iOS Simulator?The bundled tools can build, test, install, launch and capture screenshots using a local Mac with Xcode. Start with a configured Xcode project, an available simulator and a connected client. A screenshot confirms what was displayed, not every behavior.Inspect the Reading List demo and acceptance checks

Does it work with Claude, Codex, Gemini CLI and Muse?There are setup guides for all four client families. Gemini’s extension and local connection are verified; Muse’s tool discovery and Stop hook are verified. Those checks do not establish a complete model-driven app-building session in every client.Compare setup paths
 · 
Read verification records

Can I use it from ChatGPT in a browser?ChatGPT web needs its own supported connection path. A local stdio MCP command is not a browser installation. The guide separates portable skills from a separately configured HTTPS or private-tunnel MCP connection, subject to your account and workspace policy.Read ChatGPT connection options

Is it free, and what do I need?The repository is MIT-licensed. The local server requires Node.js 20 or later; build, test and simulator tools additionally need macOS and Xcode. Your chosen AI service may have its own costs. XcodeGen is required when generating an Xcode project from the optional starter specification.Check installation requirements

Does it replace Xcode or guarantee a finished app?No. It supplies reusable guidance and tools alongside your coding agent and Xcode. Static reviews can miss issues or produce false positives. Token savings and an advantage over other workflows have not been established. Source references, tests and recorded checks let you evaluate it yourself.See evidence and limitations

For release changes, read the 
published releases
 and 
changelog
. Source-only features are not automatically available in the npm release.
Built in the open
Good work grows

with good feedback.
See the mentions, discussions and people helping this project get better.
Meet the community 
Your source. Your app.
What will you

make next?
Let’s build it 
Explore or star on GitHub

---

# Install iOS Agent MCP — Claude, Codex, Gemini CLI & Muse
https://nagarjuna2997.github.io/ios-agent-skill/install.html

One package · your choice of client
Install it.

Put it to work.
Choose the AI you use. Local coding clients connect to the same MCP server; ChatGPT has a separate connection path.
Claude
Codex
ChatGPT
Gemini CLI
Muse Code
Local server: Node.js 20+. Simulator tools also require macOS and Xcode. Run setup from your app folder. Review tool permissions before allowing file changes or simulator actions.Claude
In Claude Code, connect the local server:
claude mcp add ios-agent -- npx -y ios-agent-mcp@latest
Copy
Restart or reconnect your Claude session and check that the MCP tools are available.Ask the agent to list the connected tools, search only the relevant local references, and review a small Swift file first. For changes, ask it to build and test, show the command results, and explain unresolved failures.
Claude Desktop:
 use the 
Desktop JSON setup
 instead of the CLI command. Preserve existing settings.Codex
Connect the server to your local Codex coding environment:
codex mcp add ios-agent -- npx -y ios-agent-mcp@latest
Copy
Open your app folder in Codex and reconnect the MCP server if needed.Ask the agent to list the connected tools, search only the relevant local references, and review a small Swift file first. For changes, ask it to build and test, show the command results, and explain unresolved failures.
Use the local coding environment for source edits and Xcode workflows. The ChatGPT web setup below is different.ChatGPT
ChatGPT web cannot run the local 
npx
 command on your Mac directly.
For guidance, use the skills-only release package through a supported import or plugin-development flow available to your account.For connected tools, configure an HTTPS knowledge MCP endpoint or a supported private MCP tunnel. This project does not operate a public endpoint for you.Follow the 
official connection guide (new tab)
. Account and workspace policy may limit access.Ask for a scoped implementation plan and relevant Apple references. Use an appropriately connected local coding environment for builds, simulator execution and evidence.
The hosted knowledge server serves references and plans. It is not a remotely hosted Mac or simulator.
Full ChatGPT setup ↗Gemini CLI
Connect the published server from your project folder:
gemini mcp add ios-agent -- npx -y ios-agent-mcp@latest
Copy
Restart Gemini CLI and confirm the server is connected.Ask the agent to list the connected tools, search only the relevant local references, and review a small Swift file first. For changes, ask it to build and test, show the command results, and explain unresolved failures.
This is for Gemini CLI, not Gemini web chat. The repository extension is an alternative for bundled guidance; check its version pin is published before installing it. Use one setup method to avoid duplicate tools.Muse Code
Install the server outside the agent sandbox:
npm install -g ios-agent-mcp@latest
Copy
Merge this into 
~/.config/muse/settings.json
. Keep your other settings and existing servers.
{
  "schema_version": 1,
  "mcpServers": {
    "ios-agent": { "command": "ios-agent-mcp", "args": [] }
  }
}
Restart Muse. If the executable is not found, use its absolute path from 
command -v ios-agent-mcp
.Ask the agent to list the connected tools, search only the relevant local references, and review a small Swift file first. For changes, ask it to build and test, show the command results, and explain unresolved failures.
Muse tool discovery and a Stop hook have been verified; a complete model-driven app-building session has not.Start small. Verify the result.
Try this prompt after your local MCP connection is working:
Review this app’s Swift concurrency. Search only the relevant local references.
Explain each finding with its file and line, and separate heuristics from confirmed bugs.
Propose the smallest fix. After approval, implement it, run the relevant tests,
and report what passed and what remains unverified.
When a step fails, ask the agent to show the relevant diagnostic, explain its next fix and report the result of verification. Keep this feedback in the development session, not in the app’s interface. Public issue reporting is separate: prepare a local preview, then review and authorize submission. A private chat-based option is being prepared in source; it is not deployed or available in npm 2.7.0.
For a new app, describe screens, persistence and acceptance criteria first. A starter is scaffolding; your agent still implements the features. Prefer one feature and one verification loop at a time.
Do not assume a connected tool means the app is complete. Ask for actual build/test results and simulator evidence where applicable.
Troubleshooting and full client documentation ↗

---

# Apple development guide library
https://nagarjuna2997.github.io/ios-agent-skill/library.html

Learn from the sourceFrameworks. Features.

Practical decisions.Explore 99 tracked technologies and 103 source guides. These are maintained references, not a claim that every example has been compiled. Each guide retains its own availability and verification notes.Start with a tested walkthrough →Find a framework, feature or workflowAuthentication, Security, and PrivacyApp Store Submission ChecklistPrivacy Manifest 
DesignProfessional UI/UX SystemDesign 
DesignGenerate real asset catalogsDesign 
DesigniOS Color System -- Complete Guide for Stunning SwiftUI UIsDesign 
DesignDesign Tokens, Adaptive Color, and Liquid GlassDesign 
DesigniOS Font Catalog — Ultimate ReferenceDesign 
DesignLayer-by-Layer Icons with Icon ComposerDesign 
DesignInteraction StandardsDesign 
Design and MotionAdopting Liquid GlassLiquid Glass 
Design and MotionStunning UI Patterns -- Complete SwiftUI Pattern LibraryHuman Interface Guidelines 
DesignThird-Party Animation IntegrationDesign 
DesigniOS Typography System -- Complete Guide for Stunning SwiftUI TextDesign 
Core UI and AppsActivityKit & Live ActivitiesActivityKit 
Core UI and AppsApp ClipsApp Clips 
Core UI and AppsApp IntentsApp Intents 
AI and Machine LearningApple IntelligenceApple Intelligence 
Graphics, 3D, and GamesARKit -- Complete Guide for Augmented Reality on iOS and iPadOSARKit 
Authentication, Security, and PrivacyAuthenticationServicesAuthenticationServices 
Camera, Media, and AudioAVFoundationAVFoundation 
System IntegrationBackgroundTasksBackgroundTasks 
Data ManagementCloudKitCloudKit 
AI and Machine LearningCore AI -- Custom On-Device AI Models on Apple SiliconCore AI 
Data ManagementCore DataCore Data 
System IntegrationCoreLocationCore Location 
AI and Machine LearningCore Spotlight RAG -- Private App-Local Retrieval for Foundation ModelsCore Spotlight RAG, Core Spotlight 
Authentication, Security, and PrivacyCryptoKitCryptoKit 
Data ManagementSwiftData and Core Data ConcurrencyData Concurrency 
Authentication, Security, and PrivacyDevice Integrity (DeviceCheck & App Attest)Device Integrity 
Core UI and AppsExtended Apple FrameworksQuickLook, LinkPresentation, UniformTypeIdentifiers, PDFKit, PencilKit, Core Animation, Core Haptics, MLX, File Provider, SQLite, Multipeer Connectivity, Nearby Interaction, Network Extension, SpriteKit, GameplayKit, Game Controller, RoomPlan, Object Capture, AVKit, Core Media, ReplayKit, MusicKit, ShazamKit, App Store Server API, Wallet Orders, Security Framework, AccessorySetupKit, ExternalAccessory, SensorKit 
AI and Machine LearningFoundation ModelsFoundation Models 
Data ManagementFoundation FrameworkFoundation 
Networking and ConnectivityCoreBluetoothCore Bluetooth 
Health, Hardware, and SensorsCoreMotionCore Motion 
Health, Hardware, and SensorsCoreNFCCore NFC 
Health, Hardware, and SensorsHealthKitHealthKit 
System IntegrationHomeKit and MatterHomeKit 
Authentication, Security, and PrivacyLocalAuthenticationLocalAuthentication 
System IntegrationMapKitMapKit 
Graphics, 3D, and GamesMetal -- GPU Rendering, Compute, and Performance-Critical GraphicsMetal 
AI and Machine LearningCoreML -- Complete Guide for On-Device Machine LearningCore ML 
AI and Machine LearningNaturalLanguage Framework -- Complete Guide for Text Processing and NLPNatural Language 
AI and Machine LearningSound AnalysisSound Analysis 
AI and Machine LearningSpeech Framework -- Complete Guide for Speech Recognition and TranscriptionSpeech 
AI and Machine LearningTranslationTranslation 
AI and Machine LearningVision Framework -- Complete Guide for Image Analysis and Computer VisionVision 
Networking and ConnectivityNetwork.frameworkNetwork Framework 
Networking and ConnectivityNetworkingNetworking 
System IntegrationOSLog & MetricKitOSLog 
Core UI and AppsPhotosUI & AVKitPhotosUI, Photos 
Graphics, 3D, and GamesRealityKit -- Complete Guide for 3D Rendering, AR, and Spatial AppsRealityKit 
Graphics, 3D, and GamesSceneKit -- Legacy 3D Scenes, Animation, and AR RenderingSceneKit 
System IntegrationContacts FrameworkContacts 
System IntegrationEventKitEventKit 
Commerce and WalletPassKit & FinanceKitPassKit 
System IntegrationWeatherKitWeatherKit 
Commerce and WalletStoreKit 2StoreKit 
Core UI and AppsSwift ChartsSwift Charts 
Data ManagementSwiftDataSwiftData 
Core UI and AppsTipKitTipKit 
System IntegrationUserNotificationsUserNotifications 
Core UI and AppsVisionKitVisionKit 
Core UI and AppsWidgetKitWidgetKit 
McpMCP Usage ExamplesMcp 
McpInstalling the iOS Agent MCP ServerMcp 
McpApple Knowledge MCPMcp 
McpOfficial MCP Registry publicationMcp 
McpMCP Tool ReferenceMcp 
McpvNext MCP Analysis ToolsMcp 
OrchestrationDynamic Workflows and Large-Scale JobsOrchestration 
OrchestrationHooks — Deterministic EnforcementOrchestration 
OrchestrationLoopsOrchestration 
OrchestrationMain Agent RouterOrchestration 
OrchestrationSubagentsOrchestration 
OrchestrationVerification and the Evidence ContractOrchestration 
PlatformsiOS Platform GuideiOS 
PlatformsmacOS Platform GuidemacOS 
PlatformstvOS Platform GuidetvOS 
PlatformsvisionOS Platform GuidevisionOS 
PlatformswatchOS Platform GuidewatchOS 
Design and MotionSwiftUI AnimationsSwiftUI Animation 
SwiftuiRouting, Deep Links, and State RestorationSwiftui 
SwiftuiSwiftUI GesturesSwiftui 
SwiftuiSwiftUI iOS 27 InteractionsSwiftui 
SwiftuiSwiftUI Layout SystemSwiftui 
SwiftuiSwiftUI NavigationSwiftui 
SwiftuiSwiftUI State and Data FlowSwiftui 
Core UI and AppsSwiftUI Views and ControlsSwiftUI 
AI and Machine LearningEvaluations -- Testing Intelligence-Powered FeaturesEvaluations 
ToolingApp-building loop (preview)Tooling 
ToolingApp Description WorkflowTooling 
Developer ToolsDevice HubDevice Hub 
Developer ToolsFoundation Models `fm` CLIfm CLI 
Developer ToolsFoundation Models InstrumentsFoundation Models Instruments 
ToolingFrom an Idea to a Running Apple AppTooling 
Developer ToolsiOS Simulator MCPiOS Simulator MCP 
ToolingAI-assisted issue previewsTooling 
ToolingBuild from local source and Apple guidanceTooling 
ToolingProject Scaffolding and LayoutTooling 
ToolingVisual Iteration LoopTooling 
Developer ToolsXcode 27 Coding AgentsXcode Agents 
ToolingXcode Memory DebuggingTooling 
Core UI and AppsUIKit EssentialsUIKit 
Design and MotionNative vs. Web Animation and 3DNative vs Web Animation, WebKit

---

# Site map
https://nagarjuna2997.github.io/ios-agent-skill/pages.html

Site mapEvery public page on this website.Pages
Practical Swift & AI development guidesDaily community monitorCommunity & MentionsiOS Agent Skill — Swift Reviews & Xcode Simulator MCPApple development guide librarySite mapFrom first prompt to shipped app: guided Swift seriesInstall guides
Install iOS Agent MCP — Claude, Codex, Gemini CLI & MuseBlog
How can design tokens generate light, dark and…How should I review availability guards when moving a…ChatGPT and Codex: plan the app, then verify it locallyChoose an AI client for your iOS workflowHow can Claude Code hooks protect generated files and…Claude Code: turn a Swift finding into a tested patchGemini CLI: connect once and review one iOS featureGenerate light and dark asset catalogs without a paid…Which Muse Code MCP settings actually work with a local…Muse Code: what our integration actually verifiesReview AI-generated Swift before you trust itHow can I catch Swift concurrency review findings before…SwiftUI: review state transitions before polishing the…Five checks for an AI-built SwiftUI appDocs
Documentation indexApp Store Submission ChecklistGenerate real asset catalogsiOS Color System -- Complete Guide for Stunning SwiftUI UIsDesign Tokens, Adaptive Color, and Liquid GlassiOS Font Catalog — Ultimate ReferenceLayer-by-Layer Icons with Icon ComposerInteraction StandardsAdopting Liquid GlassProfessional UI/UX SystemStunning UI Patterns -- Complete SwiftUI Pattern LibraryThird-Party Animation IntegrationiOS Typography System -- Complete Guide for Stunning…ActivityKit & Live ActivitiesApp ClipsApp IntentsApple IntelligenceARKit -- Complete Guide for Augmented Reality on iOS and…AuthenticationServicesAVFoundationBackgroundTasksCloudKitCore AI -- Custom On-Device AI Models on Apple SiliconCore DataCoreLocationCore Spotlight RAG -- Private App-Local Retrieval for…CryptoKitSwiftData and Core Data ConcurrencyDevice Integrity (DeviceCheck & App Attest)Extended Apple FrameworksFoundation ModelsFoundation FrameworkCoreBluetoothCoreMotionCoreNFCHealthKitHomeKit and MatterLocalAuthenticationMapKitMetal -- GPU Rendering, Compute, and Performance-Critical…CoreML -- Complete Guide for On-Device Machine LearningNaturalLanguage Framework -- Complete Guide for Text…Sound AnalysisSpeech Framework -- Complete Guide for Speech Recognition…TranslationVision Framework -- Complete Guide for Image Analysis and…Network.frameworkNetworkingOSLog & MetricKitPhotosUI & AVKitRealityKit -- Complete Guide for 3D Rendering, AR, and…SceneKit -- Legacy 3D Scenes, Animation, and AR RenderingContacts FrameworkEventKitPassKit & FinanceKitWeatherKitStoreKit 2Swift ChartsSwiftDataTipKitUserNotificationsVisionKitWidgetKitMCP Usage ExamplesInstalling the iOS Agent MCP ServerApple Knowledge MCPOfficial MCP Registry publicationMCP Tool ReferencevNext MCP Analysis ToolsDynamic Workflows and Large-Scale JobsHooks — Deterministic EnforcementLoopsMain Agent RouterSubagentsVerification and the Evidence ContractiOS Platform GuidemacOS Platform GuidetvOS Platform GuidevisionOS Platform GuidewatchOS Platform GuideSwiftUI AnimationsRouting, Deep Links, and State RestorationSwiftUI GesturesSwiftUI iOS 27 InteractionsSwiftUI Layout SystemSwiftUI NavigationSwiftUI State and Data FlowSwiftUI Views and ControlsEvaluations -- Testing Intelligence-Powered FeaturesApp-building loop (preview)App Description WorkflowDevice HubFoundation Models `fm` CLIFoundation Models InstrumentsFrom an Idea to a Running Apple AppiOS Simulator MCPAI-assisted issue previewsBuild from local source and Apple guidanceProject Scaffolding and LayoutVisual Iteration LoopXcode 27 Coding AgentsXcode Memory DebuggingUIKit EssentialsNative vs. Web Animation and 3DWhat "vibe coding" a Swift app actually means, and what…What you need before an AI can build you an iOS app: Mac,…Choosing a coding agent for iOS in 2026: Claude Code,…Your first hour: install Xcode, run the simulator, build…How coding agents read a project: AGENTS.md, CLAUDE.md,…Skills, plugins, MCP servers, hooks: the four words…Build a SwiftUI to-do app with Claude Code: the brief,…Compare the same to-do app task in Codex without changing…Prepare an Antigravity app-building trial and identify…Prepare a Muse app-building trial beyond tool discoveryWriting an app brief an agent can build from: ten…Generating an Xcode project the agent can edit: XcodeGen…Running and seeing your app: simulator boot, install,…What to do when the build fails: reading Xcode errors…Review Swift 6 settings and actor isolation before…Getting an agent to run checks: hooks, Stop conditions…Making the agent prove it: screenshots as evidence, not…Reviewing agent output before you commit: a focused…Git for vibe coders: branches per prompt, checkpoints,…Keeping the agent inside your design: tokens, no…SwiftUI prompt practice: 25 scoped exercises and…When a SwiftUI agent task fails: ten failure categories…Local persistence contracts: Reading List JSON storage…Localization with an agent: what to check before it…Search, filtering and empty states that pass…Networking and Codable with an agent: the concurrency…Navigation in SwiftUI: what agents get wrong with…Adding widgets with App Intents: a timeline and…Designing Siri actions with App Intents and testable…On-device AI: Foundation Models readiness and fallback…Notifications, background tasks and the permissions…Deep links and universal links, and using them for…App icons in 2026: Liquid Glass, Icon Composer, and…Color systems that survive dark mode and high contrast,…Typography in SwiftUI that respects Dynamic Type: rules…Motion and haptics: what to ask an agent for, and what…Making an agent-built app look intentional, not templatedPreparing repeatable simulator captures for App Store…From Figma to SwiftUI with an agent: variables to tokens…Swift Testing vs XCTest: what to have the agent generate…UI tests an agent can write and maintainAccessibility review of an agent-built app: the checks…Performance: hangs, launch time and the patterns agents…Memory: retain cycles agents create in closures and CombineSecurity review of an agent-built app: keychain, ATS,…Static review vs the compiler: what heuristics catch and…App Store readiness: the checks before you upload an…Privacy manifests, permissions strings and the things…Preparing for TestFlight: signing, builds and account…Investigating App Review feedback without guessing the…Versioning, changelogs and release notes the agent can…CI for an iOS repo with agent-written code: what to run…Subagents for iOS: splitting review, build and design…Preparing to verify Xcode 27 agents with external skillsKeeping up with Apple: refreshing references after each…Planning a safe Swift 6 and iOS 27 migrationCoordinating parallel coding work with isolated checkoutsWriting your own review rule for a Swift codebaseWriting your own skill for a Swift project: SKILL.md…Building a Claude Code plugin from a skillBuilding a Codex plugin from the same skillCross-client packaging: verified Muse scope and an…Verifying a skill across clients: the client verification…Measuring whether a skill helps: a paired benchmark…Ask for the finding, not the fix: why review-first…When the agent says "done", run the tests yourself onceSmall prompts, one screen at a time, commit between eachKeep signing and account changes separate from code repairsReading agent transcripts: the three phrases that mean it…Turn every failure into a rule: how findings become reviewsVerify Muse observer behavior before recommending itCodex worktrees for parallel screensTreat background builds as running work, not completed…Claude Code plan mode before any multi-file Swift changeAsk the agent to search local Apple references before the…Ask about actor ownership before adding MainActorWhat to put in .gitignore for agent-built Xcode projects"Show me the screenshot" as a standing instructionPin your Xcode version in AGENTS.md and stop the agent…One deep link per screen makes every screenshot automatableKeep design tokens in one file and tell the agent it's…Review the XcodeGen spec before regenerating the projectUse `#available` audits before every beta upgradeHooks beat reminders: enforce, don't request

---

# From first prompt to shipped app: guided Swift series
https://nagarjuna2997.github.io/ios-agent-skill/series.html

← Back to all articlesGuided learning · Series roadmapFrom first prompt

to shipped app.A step-by-step Swift learning path, from opening Xcode to reviewing, testing and shipping an app.All 84 lessons are available to read, with a diagram in each. These educational guides distinguish recorded results from practice exercises and blocked hands-on labs. Read each lesson’s evidence section before treating an example as verified.0 · Orientation
1 · First app with an agent
2 · Making the agent reliable
3 · Real features
4 · Design and assets
5 · Quality and testing
6 · Shipping
7 · Pro workflows
8 · Tips and tricksLevel 0
Orientation (for people who have never opened Xcode)
0.1
What "vibe coding" a Swift app actually means, and what it doesn't
Read lesson0.2
What you need before an AI can build you an iOS app: Mac, Xcode, Apple ID, simulator
Read lesson0.3
Choosing a coding agent for iOS in 2026: Claude Code, Codex, Antigravity, Muse Code, Xcode's own
Read lesson0.4
Your first hour: install Xcode, run the simulator, build the template app without AI
Read lesson0.5
How coding agents read a project: AGENTS.md, CLAUDE.md, GEMINI.md, and what to put in them
Read lesson0.6
Skills, plugins, MCP servers, hooks: the four words you'll see everywhere, explained once
Read lesson
Related reading available now
Choose an AI client
What to do next: Level 1 →Level 1
First app with an agent (day one)
1.1
Build a SwiftUI to-do app with Claude Code: the brief, checks and repair workflow
Read lesson1.2
Compare the same to-do app task in Codex without changing the acceptance criteria
Read lesson1.3
Prepare an Antigravity app-building trial and identify missing setup
Read lesson1.4
Prepare a Muse app-building trial beyond tool discovery
Read lesson1.5
Writing an app brief an agent can build from: ten practice briefs
Read lesson1.6
Generating an Xcode project the agent can edit: XcodeGen vs opening .xcodeproj by hand
Read lesson1.7
Running and seeing your app: simulator boot, install, launch, screenshot from the agent
Read lesson1.8
What to do when the build fails: reading Xcode errors with an agent
Read lesson
Related reading available now
Plan an app and verify locallyFive simulator checks
What to do next: Level 2 →Level 2
Making the agent reliable (week one)
2.1
Review Swift 6 settings and actor isolation before changing agent-generated code
Read lesson2.2
Getting an agent to run checks: hooks, Stop conditions and verification limits
Read lesson2.3
Making the agent prove it: screenshots as evidence, not "done"
Read lesson2.4
Reviewing agent output before you commit: a focused checklist
Read lesson2.5
Git for vibe coders: branches per prompt, checkpoints, and undoing an agent's mess
Read lesson2.6
Keeping the agent inside your design: tokens, no hardcoded colors, no fixed fonts
Read lesson2.7
SwiftUI prompt practice: 25 scoped exercises and acceptance checks
Read lesson2.8
When a SwiftUI agent task fails: ten failure categories to distinguish
Read lesson
Related reading available now
Generated-file guards and Stop checksReview concurrency findings
What to do next: Level 3 →Level 3
Real features (weeks two to four)
3.1
Local persistence contracts: Reading List JSON storage and a SwiftData alternative
Read lesson3.2
Search, filtering and empty states that pass accessibility review
Read lesson3.3
Networking and Codable with an agent: the concurrency mistakes to catch
Read lesson3.4
Navigation in SwiftUI: what agents get wrong with NavigationStack
Read lesson3.5
Adding widgets with App Intents: a timeline and verification plan
Read lesson3.6
Designing Siri actions with App Intents and testable domain operations
Read lesson3.7
On-device AI: Foundation Models readiness and fallback design
Read lesson3.8
Notifications, background tasks and the permissions agents forget
Read lesson3.9
Deep links and universal links, and using them for automated screenshots
Read lesson3.10
Localization with an agent: what to check before it "translates" everything
Read lesson
Related reading available now
Review state transitions
What to do next: Level 4 →Level 4
Design and assets
4.1
App icons in 2026: Liquid Glass, Icon Composer, and generating them from SVG layers
Read lesson4.2
Color systems that survive dark mode and high contrast, generated from tokens
Read lesson4.3
Typography in SwiftUI that respects Dynamic Type: rules an agent can follow
Read lesson4.4
Motion and haptics: what to ask an agent for, and what reduced-motion users need
Read lesson4.5
Making an agent-built app look intentional, not templated
Read lesson4.6
Preparing repeatable simulator captures for App Store screenshots
Read lesson4.7
From Figma to SwiftUI with an agent: variables to tokens to code
Read lesson
Related reading available now
Generate and compile color assets
What to do next: Level 5 →Level 5
Quality and testing
5.1
Swift Testing vs XCTest: what to have the agent generate in 2026
Read lesson5.2
UI tests an agent can write and maintain
Read lesson5.3
Accessibility review of an agent-built app: the checks that matter
Read lesson5.4
Performance: hangs, launch time and the patterns agents introduce
Read lesson5.5
Memory: retain cycles agents create in closures and Combine
Read lesson5.6
Security review of an agent-built app: keychain, ATS, secrets in source
Read lesson5.7
Static review vs the compiler: what heuristics catch and what they miss
Read lesson
Related reading available now
Review before accepting changes
What to do next: Level 6 →Level 6
Shipping
6.1
App Store readiness: the checks before you upload an agent-built app
Read lesson6.2
Privacy manifests, permissions strings and the things agents leave blank
Read lesson6.3
Preparing for TestFlight: signing, builds and account boundaries
Read lesson6.4
Investigating App Review feedback without guessing the cause
Read lesson6.5
Versioning, changelogs and release notes the agent can draft
Read lesson6.6
CI for an iOS repo with agent-written code: what to run on every PR
Read lesson
What to do next: Level 7 →Level 7
Pro workflows
7.1
Subagents for iOS: splitting review, build and design across agents
Read lesson7.2
Coordinating parallel coding work with isolated checkouts
Read lesson7.3
Writing your own review rule for a Swift codebase
Read lesson7.4
Writing your own skill for a Swift project: SKILL.md structure that agents follow
Read lesson7.5
Building a Claude Code plugin from a skill
Read lesson7.6
Building a Codex plugin from the same skill
Read lesson7.7
Cross-client packaging: verified Muse scope and an Antigravity test boundary
Read lesson7.8
Verifying a skill across clients: the client verification record method
Read lesson7.9
Measuring whether a skill helps: a paired benchmark protocol
Read lesson7.10
Preparing to verify Xcode 27 agents with external skills
Read lesson7.11
Keeping up with Apple: refreshing references after each beta without breaking rules
Read lesson7.12
Planning a safe Swift 6 and iOS 27 migration
Read lesson
Related reading available now
Inspect a client verification record
What to do next: Level 8 →Level 8
Tips and tricks (short posts, one idea each)
8.1
Ask for the finding, not the fix: why review-first prompts produce better Swift
Read lesson8.2
Ask about actor ownership before adding MainActor
Read lesson8.3
"Show me the screenshot" as a standing instruction
Read lesson8.4
Pin your Xcode version in AGENTS.md and stop the agent guessing APIs
Read lesson8.5
One deep link per screen makes every screenshot automatable
Read lesson8.6
Keep design tokens in one file and tell the agent it's the only source of color
Read lesson8.7
Review the XcodeGen spec before regenerating the project
Read lesson8.8
Use `#available` audits before every beta upgrade
Read lesson8.9
Hooks beat reminders: enforce, don't request
Read lesson8.10
When the agent says "done", run the tests yourself once
Read lesson8.11
Small prompts, one screen at a time, commit between each
Read lesson8.12
Keep signing and account changes separate from code repairs
Read lesson8.13
Reading agent transcripts: the three phrases that mean it guessed
Read lesson8.14
Turn every failure into a rule: how findings become reviews
Read lesson8.15
Verify Muse observer behavior before recommending it
Read lesson8.16
Codex worktrees for parallel screens
Read lesson8.17
Treat background builds as running work, not completed tasks
Read lesson8.18
Claude Code plan mode before any multi-file Swift change
Read lesson8.19
Ask the agent to search local Apple references before the web
Read lesson8.20
What to put in .gitignore for agent-built Xcode projects
Read lesson
Explore all available articles →

---

# What "vibe coding" a Swift app actually means, and what…
https://nagarjuna2997.github.io/ios-agent-skill/series/0.1.html

← Guided series
 · 
All articlesLesson 0.1 · Guided learningWhat "vibe coding" a Swift app actually means, and what it doesn'tOn this page

Start with an observable promise
The development loop
Divide responsibility deliberately
Exercise: turn an adjective into a check
What this lesson establishes

“Vibe coding” usually describes asking an AI to turn an idea into software and steering the result through conversation. For a Swift app, the useful version still includes reading changes, compiling, testing behavior and checking the interface. A fluent completion message is not an acceptance test.

Start with an observable promise

“Make a beautiful to-do app” leaves important decisions unstated. Does a task survive a restart? Can the user undo deletion? Does an empty search explain what happened? The agent can fill these gaps, but its choices may not be yours.

Try this smaller brief:

Create a local task list with a title and completion state.
An empty title cannot be saved.
Tasks survive terminating and reopening the app.
Searching never deletes stored tasks.
Support larger text and meaningful accessibility labels.
Before editing, identify the existing scheme and storage design.

Each sentence gives you something to inspect. The purpose is not to find magic wording; it is to make disagreement visible before a large implementation exists.

The development loop

The evidence loop1
Describe behavior
↓2
Inspect proposed change
↓3
Build and exercise
↓4
Accept or return failure

The decision in this diagram is based on evidence. When a test fails, return the actual failure to the agent with the acceptance criterion it violated. When a build cannot run, stop at “implementation written, build unverified.” Do not silently turn that into “finished.”

Divide responsibility deliberately

Participant

Useful work

What it cannot establish alone

Developer

Choose behavior, inspect tradeoffs, accept changes

That a plausible implementation actually runs

Coding agent

Inspect source, propose edits, invoke available tools

That an unexecuted check passed

Swift compiler

Check the program against a selected toolchain

That search or persistence matches the brief

Tests and simulator

Exercise selected behavior and presentation

That every device and every input works

For your first feature, retain a checkpoint before the edit. Ask for one change, review the diff, and run the relevant check. If the agent also rewrites navigation or adds a networking dependency, ask how those changes relate to the requested feature. A small diff makes both review and rollback easier.

Exercise: turn an adjective into a check

Take “fast, accessible search” and split it into independent questions. What data set will you test? What does no match look like? Can VoiceOver identify the search field? Does clearing the query restore the list? Avoid inventing a performance number until you have a measurement method and a representative device.

Write those answers in your brief. Ask the agent to identify missing decisions without editing any files. Then pick one transition to implement. You now have a useful collaboration boundary instead of an unlimited “build everything” instruction.

What this lesson establishes

This is an orientation lesson, not an app-generation benchmark. It makes no claim about reduced cost, universal correctness or a client finishing an app unattended. The project's 
verification contract
 explains how to distinguish inspected code from executed results.
What to do next
Next: What you need before an AI can build you an iOS app: Mac, Xcode, Apple ID, simulator

---

# What you need before an AI can build you an iOS app: Mac,…
https://nagarjuna2997.github.io/ios-agent-skill/series/0.2.html

← Guided series
 · 
All articlesLesson 0.2 · Guided learningWhat you need before an AI can build you an iOS app: Mac, Xcode, Apple ID, simulatorOn this page

Separate three kinds of access
Inspect the installed toolchain
Readiness checklist
Add the agent last
Completion checkpoint

For the local workflow in this series, start with a Mac that can run your selected Xcode, a compatible simulator runtime and enough storage for build products. Add a coding client only after a normal app build works. This separates environment failures from generated-code failures.

Separate three kinds of access

Apple tools, an Apple developer account and an AI subscription are different things. Buying access to one does not configure the others. Running a simulator build is also different from signing an app for a physical phone or distributing it through TestFlight.

Apple explains the distinction between a free developer account and program membership in its 
account overview
. Check the current membership requirements when you reach distribution; this lesson does not ask you to purchase anything.

Local development prerequisites1
Mac + selected Xcode
↓2
Available iOS runtime
↓3
Known project + scheme
↓4
Baseline before agent

Inspect the installed toolchain

Open Terminal and run these read-only checks:

xcodebuild -version
xcode-select -p
xcrun simctl list devices available

The first identifies Xcode, the second identifies the selected developer directory, and the third lists usable simulator devices. Keep their meanings separate. An installed Xcode application does not necessarily mean a command-line session has selected it; a simulator application does not necessarily mean the runtime you need is installed.

For this series preparation, the local check reported Xcode 26.6, build 17F113. Available devices used an iOS 26.5 runtime, and none was initially booted. These are facts about the test Mac, not minimum system requirements for your machine.

Readiness checklist

Check

Passing evidence

If it fails

Xcode selection

Version and developer directory are available

Open Xcode and finish its setup; inspect the selected tools

Simulator runtime

An available device is listed

Install a compatible runtime through Xcode settings

Project

A project/workspace and scheme are known

Open the project and inspect its schemes

Build

The selected scheme compiles

Diagnose the first meaningful build error

Agent connection

Expected tools are discoverable

Check the client configuration and executable path

Do not paste account passwords, signing credentials or authentication tokens into a debugging conversation. Most environment diagnosis needs a version, a command and a redacted error, not account access.

Add the agent last

The repository's local npm tools require Node.js 20 or later. That dependency belongs to the MCP server, not to Swift itself. You can learn Xcode and run a Swift project before installing it. When you add the server, use the 
setup instructions
 for your actual client rather than copying another client's configuration.

Keep a short environment record in the project: Xcode version, deployment target, simulator runtime, scheme and the build command you used. This gives later failures a baseline. If the baseline changes after an update, investigate that change before rewriting the app.

Completion checkpoint

You are ready for the next lesson when you can name the selected Xcode version and find an available simulator. A successful account login is not the completion criterion here. Apple's 
run-destination guide
 explains the next step from available device to running app.
What to do next
Next: Choosing a coding agent for iOS in 2026: Claude Code, Codex, Antigravity, Muse Code, Xcode's own

---

# Choosing a coding agent for iOS in 2026: Claude Code,…
https://nagarjuna2997.github.io/ios-agent-skill/series/0.3.html

← Guided series
 · 
All articlesLesson 0.3 · Guided learningChoosing a coding agent for iOS in 2026: Claude Code, Codex, Antigravity, Muse Code, Xcode's ownOn this page

Decide where execution happens
What the project records actually say
Run a fair trial
Keep cost evidence honest
Choose a starting point

Choose a coding client by where the work will run, which tools it can actually call and what evidence you can inspect. A brand comparison is less useful than a small reproducible task in your own development environment.

Decide where execution happens

A browser conversation can help refine an app brief. A local coding environment can inspect files and invoke local commands when configured and authorized. Those are different execution paths. A model knowing Swift does not give it access to your Mac's Xcode installation.

Choose by execution needs1
Need local build?
↓2
Check client access
↓3
Try identical small task
↓4
Compare actual evidence

For local iOS work, first confirm that the client can see the intended project and that you understand how changes and command results are displayed. Then test one read-only review. Do not begin by letting several clients edit the same checkout.

What the project records actually say

Client

Available project evidence

Boundary

Claude Code

A bounded text-repair check

Not a complete app-generation benchmark

Codex

Local setup instructions

No same-brief comparison is presented in this lesson

Gemini CLI

Extension validation and stdio connection record

Model-driven task remains a separate check

Muse Code

Tool discovery and Stop hook with echo provider

Not proof of model repair or observer behavior

Antigravity and Xcode agents

Topics requested for later comparison

Not tested in this lesson

These rows are intentionally not a ranking. Read the 
client setup guide
 and the 
Muse connection walkthrough
 for the exact scope of the available records. A checkmark beside “connected” should never be read as “best at building apps.”

Run a fair trial

Use a disposable branch and a small synthetic feature. Keep the brief, starting commit, toolchain, acceptance criteria and retry limit constant. Record the client and model versions, elapsed time, tool failures, final diff and test outcomes. If a client cannot run a required command, record that limitation rather than substituting a different acceptance test.

Inspect this project without editing it.
Identify the app target and existing test command.
Explain one small change to the empty state.
State what you can verify here and what you cannot.

This first prompt tests whether the client identifies real project context. Follow it with the same small implementation task only after the inspection is accurate.

Keep cost evidence honest

A subscription price is not the cost of a run. Retries, model choice, account limits and included usage make casual comparisons misleading. Record whatever usage information the client actually exposes and identify missing measurements. This article has no measured per-run cost table because that paired experiment has not been performed.

Choose a starting point

Use the client you can already operate reliably, connect one tool path, and keep the task small. Switch because of an observed constraint—such as unavailable local execution or poor failure reporting—not because an untested comparison claims a universal winner. The later same-app lessons must earn their conclusions through comparable runs.
What to do next
Next: Your first hour: install Xcode, run the simulator, build the template app without AI

---

# Your first hour: install Xcode, run the simulator, build…
https://nagarjuna2997.github.io/ios-agent-skill/series/0.4.html

← Guided series
 · 
All articlesLesson 0.4 · Guided learningYour first hour: install Xcode, run the simulator, build the template app without AIOn this page

Create a small baseline
Keep a command-line baseline too
Diagnose the first meaningful failure
Change one visible thing
Verification boundary

Recorded baseline result

Before asking an agent to fix Swift, establish that your Mac can build and run an app without an agent. This gives you a baseline for separating installation, project and implementation problems.

Create a small baseline

In Xcode, start a new iOS app project using the SwiftUI interface option available in your installed version. Choose a local folder and a neutral project name such as FirstRun. Select an available iPhone simulator as the destination, then use Product → Run. Exact template controls can change between Xcode versions; follow the options shown by your installed toolchain.

Apple's 
simulator instructions
 describe selecting a simulated run destination. The important result is an app window that responds, not merely a successful project-creation dialog.

A baseline you can diagnose1
Create or open project
↓2
Select simulator
↓3
Build → install → launch
↓4
Change text and repeat

Keep a command-line baseline too

For an existing checkout, list the schemes before choosing a build command:

xcodebuild -list -project YourApp.xcodeproj

Replace the project name with the actual file. Do not run a copied command against a guessed scheme. Once you know the scheme, a simulator build can separate compilation from device signing:

xcodebuild -project YourApp.xcodeproj \
  -scheme YourScheme -sdk iphonesimulator \
  -derivedDataPath /tmp/first-run-build \
  CODE_SIGNING_ALLOWED=NO build

This command builds; it does not install or launch. A successful build therefore does not complete the entire exercise. Return to Xcode to run on your selected simulator and inspect the screen.

Diagnose the first meaningful failure

Symptom

Check first

Avoid

Developer tools cannot be found

Selected Xcode and first-launch setup

Asking the agent to rewrite Swift

Requested destination is unavailable

Installed runtime and available devices

Repeatedly using an obsolete device identifier

Scheme cannot be found

Project/workspace and scheme list

Guessing the scheme from the folder name

A long build log may contain many consequences of one error. Read the first relevant failure in its target context. Keep the command, selected SDK and a short redacted diagnostic together when asking for help.

Change one visible thing

After the baseline runs, change the initial text, rebuild and confirm the visible change. This establishes that you are editing the project you launched. Save a version-control checkpoint before introducing an agent. If later changes fail, you now have a known starting point to compare against.

Verification boundary

The command-line preparation for this lesson used the repository's existing Reading List project with Xcode 26.6, rather than a newly created Xcode GUI template. Its build result is recorded separately in the series evidence. The new-project GUI walkthrough above is instructional guidance, not a claim that a fresh installation and template flow were recorded end to end. Do not treat it as the completed Level 1 agent tutorial.

Recorded baseline result

The existing Reading List sample built successfully, installed and launched on an iOS 26.5 simulator. The first screenshot was captured before the app appeared and showed the home screen; a second capture showed the app's empty state. This is why a successful launch command alone is not visual verification.

Existing Reading List sample on iOS 26.5; not a newly generated template.

Read the baseline evidence record
. Persistence, search behavior and accessibility were not retested in this baseline run.
What to do next
Next: How coding agents read a project: AGENTS.md, CLAUDE.md, GEMINI.md, and what to put in them

---

# How coding agents read a project: AGENTS.md, CLAUDE.md,…
https://nagarjuna2997.github.io/ios-agent-skill/series/0.5.html

← Guided series
 · 
All articlesLesson 0.5 · Guided learningHow coding agents read a project: AGENTS.md, CLAUDE.md, GEMINI.md, and what to put in themOn this page

Put stable decisions in the project
A useful starting document
Separate guidance from enforcement
Avoid conflicting mirrors
Check that the instructions are useful

Project instruction files explain durable constraints to a coding agent: how to build, where state belongs and what must be verified. They are not a substitute for source code, tests or permissions.

Put stable decisions in the project

A conversation is a poor place to keep the only copy of your build command. Put recurring facts in a short instruction file next to the project. Keep task-specific goals in the current brief. This avoids burying the important constraints under yesterday's debugging transcript.

Where project knowledge belongs1
Stable rules → instructions
↓2
Current goal → brief
↓3
Executable checks → scripts
↓4
Results → evidence record

For Codex, OpenAI documents 
AGENTS.md instructions
. Claude Code documents 
CLAUDE.md project memory
, and Gemini CLI documents 
GEMINI.md context files
. File discovery and precedence belong to the individual client; do not assume renaming one file guarantees identical behavior everywhere.

A useful starting document

# Project workflow
- Inspect the existing project and scheme before editing.
- Keep UI-observed state on the project's intended actor.
- Use named color assets and support Dynamic Type.
- Preserve loading, empty, content and error states.
- Run the relevant build and tests after a change.
- Report commands, failures and checks that could not run.
- Do not modify signing settings as an incidental fix.

This example describes a workflow. Add your actual project paths and tested commands after you have established them. Do not copy a deployment target or scheme from another app merely to make the file look complete.

Separate guidance from enforcement

Need

Put it in

Why

Stable architecture decision

Project instructions

The agent needs context before editing

Current feature requirements

Task brief

They change between tasks

Repeatable validation

Test or script

It can produce an executable result

Prevent editing a generated file

Supported guard/hook

A reminder alone does not enforce a boundary

The repository's instruction entry point asks agents to retrieve relevant local source before expanding context, preserve state transitions and report failures. Those are useful constraints to study; copying the whole library into every app instruction file would make the important local facts harder to find.

Avoid conflicting mirrors

If several clients share a project, choose a canonical instruction source and a documented way to synchronize the client-specific files. After a change, compare the mirrors. A stale file can tell one client to follow a rule that another client no longer sees.

Do not add private credentials to instructions. They are ordinary files that may be committed, indexed or read by tools. Prefer references to the project's established secret-loading mechanism, without including the values.

Check that the instructions are useful

Ask the client to identify the relevant project instructions and summarize the build/check requirements before editing. Inspect the answer against the files. Then run a small task and verify the resulting behavior; a correct summary is not proof that every instruction was enforced.

The exercise is complete when another developer can read the file and reproduce the intended check without recovering context from your chat history.
What to do next
Next: Skills, plugins, MCP servers, hooks: the four words you'll see everywhere, explained once

---

# Skills, plugins, MCP servers, hooks: the four words…
https://nagarjuna2997.github.io/ios-agent-skill/series/0.6.html

← Guided series
 · 
All articlesLesson 0.6 · Guided learningSkills, plugins, MCP servers, hooks: the four words you'll see everywhere, explained onceOn this page

Follow one change through the system
Four terms, four questions
Where failures belong
A small installation exercise
Completion checkpoint

A skill explains a reusable procedure. A plugin packages capabilities for a client. An MCP server exposes callable tools or resources. A hook runs at a supported lifecycle event. These mechanisms can work together, but they solve different problems.

Follow one change through the system

Imagine asking an agent to review a Swift file. Project instructions establish the app's constraints. A skill can describe which checks matter. An MCP tool can inspect the file and return findings. A supported hook can run a deterministic check at the appropriate event. You still inspect the change and its evidence.

How extension layers cooperate1
Plugin packages capabilities
↓2
Skill supplies procedure
↓3
MCP exposes operations
↓4
Hook invokes event checks

The arrows describe responsibility, not a guarantee that every client loads every component. A plugin may distribute a skill and a server configuration together, but the installed client must still discover and execute them correctly.

Four terms, four questions

Mechanism

Question it answers

Concrete example

Skill

How should this task be approached?

Review concurrency before accepting a Swift change

Plugin

How are capabilities packaged for this client?

A manifest plus skill and tool configuration

MCP server

Which operations can the agent call?

Review a Swift file and return finding locations

Hook

What runs at this lifecycle event?

Check protected generated files or run verification

Do not install all four simply because the words appear in a guide. Start with the need. If you only need guidance, a tool server may be unnecessary. If you need a simulator screenshot, prose alone cannot take one; the environment needs an executable operation and the required local tools.

Where failures belong

If the skill is missing, investigate discovery and task routing. If tools are absent, inspect the MCP connection. If a hook does not run, inspect the client's event and configuration support. If the tool runs but the app does not compile, inspect the actual compiler failure. Reinstalling a plugin does not automatically fix a Swift error.

Anthropic's 
customization guide
 distinguishes several of these extension mechanisms. Use the documentation for your client before copying event names or configuration structures.

A small installation exercise

Open the project's 
setup guide
 and choose one client. Configure one connection path. Reconnect the client, ask it to list the available tools, and perform one read-only operation against synthetic source. Record the client and package version. Do not equate a tool list with a completed app-building workflow.

Next, read the 
hook walkthrough
. Notice that it distinguishes executing a script directly from having a client dispatch that hook. This distinction is useful whenever a configuration example looks correct but has not been tested through the actual runtime.

Completion checkpoint

You should now be able to explain which component provides guidance, which provides execution and which provides lifecycle enforcement. If an integration fails, name the layer before changing it. That habit makes later build/test loops easier to debug and keeps setup complexity proportional to the task.
What to do next
Next: Build a SwiftUI to-do app with Claude Code: the brief, checks and repair workflow

---

# Build a SwiftUI to-do app with Claude Code: the brief,…
https://nagarjuna2997.github.io/ios-agent-skill/series/1.1.html

← Guided series
 · 
All articlesLesson 1.1 · Guided learningBuild a SwiftUI to-do app with Claude Code: the brief, checks and repair workflowOn this page

Work through the example
Implementation reference

Plan and implement your own app

Acceptance and failure review
Evidence and limits

A first Claude session needs a fixed brief, a clean project and a bounded repair budget. The useful deliverable is not a confident transcript: it is a diff whose build and behavior can be inspected.

Empty task list → Add valid title → Save locally → Relaunch and verify1
Empty task list
↓2
Add valid title
↓3
Save locally
↓4
Relaunch and verify

Work through the example

Ask Claude to preserve the acceptance checks while implementing title validation, completion and persistence. Reject a repair that merely weakens a test.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

Plan and implement your own app

Start with a project, an app brief, and trusted executable acceptance checks. Use the demo JSON files as a schema example. Each criterion maps to existing check IDs; screen names map to screenshot-producing checks. Check commands run directly with argument arrays, without a shell, in the project directory. You must author meaningful tests for your intended behavior; a successful process alone cannot establish that an arbitrary brief is satisfied.

node /path/to/ios-agent-skill/mcp-server/dist/unified.js loop init --project /path/to/app --brief BRIEF.md --checks checks.json --attempts 3
node /path/to/ios-agent-skill/mcp-server/dist/unified.js loop resume --project /path/to/app

Without 
--plan
, init asks the installed, authenticated 
claude
 CLI for a structured plan. Review 
.ios-agent/loop/state.json
 before resuming. Resume runs checks first, sends failing log tails and the plan to Claude, then reruns checks after edits. Repairs are limited to 1–10 attempts, 12 Claude turns per attempt, and a ten-minute timeout per Claude invocation. Each check has its own timeout (maximum 30 minutes). This bounds attempts and time, not monetary cost. Claude subscription/API usage is governed by your own local CLI configuration.

The repair adapter exposes Read, Glob, Grep, Edit and Write; it disables external MCP connections and does not grant shell commands. Your configured acceptance commands run with your local user permissions. Inspect them before running; this workflow is not a security sandbox. List tests, scripts and project settings in 
protectedFiles
 so the loop detects changed acceptance files instead of accepting weakened tests. Dependencies must already be installed.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Empty task list

Confirm the input and environment

Preserve the failure and return to this step

Add valid title

Inspect the intermediate artifact

Preserve the failure and return to this step

Save locally

Run the focused check

Preserve the failure and return to this step

Relaunch and verify

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

A timed Claude to-do app run has not been completed. The Reading List run is a separate reference, not this experiment.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Compare the same to-do app task in Codex without changing the acceptance criteria

---

# Compare the same to-do app task in Codex without changing…
https://nagarjuna2997.github.io/ios-agent-skill/series/1.2.html

← Guided series
 · 
All articlesLesson 1.2 · Guided learningCompare the same to-do app task in Codex without changing the acceptance criteriaOn this page

Work through the example
Implementation reference

Protocol

Acceptance and failure review
Evidence and limits

A comparison with Codex only means something when the starting project and acceptance criteria remain the same. Changing the brief after one client struggles measures two tasks rather than two implementations.

Same starting commit → Same task brief → Independent test run → Compare differences1
Same starting commit
↓2
Same task brief
↓3
Independent test run
↓4
Compare differences

Work through the example

Keep the first client's output in a separate branch. Compare data ownership, error handling and failed assertions before counting changed lines.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

Protocol

20 fixed prompts; one trial per client × prompt × condition.
Clients: Claude Code and Codex, using each account's default model without a model override.
Baseline gets the task and source-file constraints. Treatment additionally gets
  the skill entry point plus local guides, templates, examples and retrieval scripts.
No MCP server or subagent bundle is supplied to the model. This tests the
  guidance treatment, not the entire product.
Each task gets an empty 
Sources/Solution.swift
, Swift 6 and Foundation.
  No secrets or personal app source is used.
Hidden assertions are introduced after the client exits, compiled separately
  with the generated file, and executed independently. The client cannot edit them.
Alternate baseline-first and skill-first by task. Each call has a timeout.
Claude uses safe-mode, no session persistence and file tools only; Codex uses
  ephemeral mode, ignores user config and disables automatic project docs.
  Client tools/default prompts still differ, so compare conditions within a client.
Report compilation, assertions, static review blockers and client-reported token
  usage. Cache/input token accounting differs by client; do not compare raw token
  totals across providers or convert them into savings/cost without pricing data.
Provider failures/timeouts are recorded separately from acceptance failures.
  A missing token field or failed scorer is unknown, not zero.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Same starting commit

Confirm the input and environment

Preserve the failure and return to this step

Same task brief

Inspect the intermediate artifact

Preserve the failure and return to this step

Independent test run

Run the focused check

Preserve the failure and return to this step

Compare differences

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

No paired to-do app run or cost comparison is available; do not infer a winning client.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Prepare an Antigravity app-building trial and identify missing setup

---

# Prepare an Antigravity app-building trial and identify…
https://nagarjuna2997.github.io/ios-agent-skill/series/1.3.html

← Guided series
 · 
All articlesLesson 1.3 · Guided learningPrepare an Antigravity app-building trial and identify missing setupOn this page

Work through the example
Implementation reference

6. Reporting failure

Acceptance and failure review
Evidence and limits

An unavailable client is a setup boundary, not evidence that a model cannot build an app. Preserve the same task contract while documenting exactly which execution step cannot be performed.

Check executable → Verify authentication → Discover local tools → Run shared contract1
Check executable
↓2
Verify authentication
↓3
Discover local tools
↓4
Run shared contract

Work through the example

Prepare the brief and acceptance checks without substituting a different client. A later run must record the installed version and actual failures.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

6. Reporting failure

Report outcomes faithfully. Specifically:

If tests fail, say so and paste the failure.
If you skipped a step, say which and why.
If you could not reproduce a bug, say that — do not ship a speculative fix as
  a confirmed one.
If part of the scope is blocked, finish everything else in full and state
  exactly what you left out.

A partial result honestly labelled is more useful than a complete-looking result
that is wrong, because the human can act on the first and will be misled by the
second.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Check executable

Confirm the input and environment

Preserve the failure and return to this step

Verify authentication

Inspect the intermediate artifact

Preserve the failure and return to this step

Discover local tools

Run the focused check

Preserve the failure and return to this step

Run shared contract

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

Antigravity is not installed in this environment. Its app-building tutorial is blocked; no commands or behavior are invented here.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Prepare a Muse app-building trial beyond tool discovery

---

# Prepare a Muse app-building trial beyond tool discovery
https://nagarjuna2997.github.io/ios-agent-skill/series/1.4.html

← Guided series
 · 
All articlesLesson 1.4 · Guided learningPrepare a Muse app-building trial beyond tool discoveryOn this page

Work through the example
Implementation reference

Muse Code
Verified compatibility, 2026-09-16
Recheck on upgrades

Acceptance and failure review
Evidence and limits

Muse tool discovery and a model-driven build are separate milestones. The existing echo-provider record proves a connection and a Stop hook, not that Muse wrote or repaired the same to-do app.

Installed executable → MCP discovery → Model task → Independent checks1
Installed executable
↓2
MCP discovery
↓3
Model task
↓4
Independent checks

Work through the example

Read the bounded connection record before attempting edits. Keep sandbox configuration errors separate from compiler diagnostics.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

Muse Code

Muse Code joins Claude, Codex and Gemini CLI through the same unified local MCP
server. No separate server package, plugin ZIP or 
MUSE.md
 is needed.

Install the server outside the agent sandbox, using Node.js 20 or later:

npm install -g ios-agent-mcp@latest

Merge this into 
~/.config/muse/settings.json
; preserve your other settings and
existing server entries. Do not replace the whole file. 
schema_version
 is required.

{
  "schema_version": 1,
  "mcpServers": {
    "ios-agent": {
      "command": "ios-agent-mcp",
      "args": []
    }
  }
}

Copyable template
 · 
Settings-key verification note
 · 
Hook verification scope
. If Muse cannot find
the command, use the absolute executable path printed by 
command -v ios-agent-mcp
.
Restart Muse after updating settings. This avoids fetching a package inside Muse's
default proxy-only sandbox. It does not disable sandboxing or grant network access.

For local instructions, use the repository's 
AGENTS.md
. For skill discovery,
keep the original frontmatter-bearing 
SKILL.md
 under your project's
project-local .claude/skills/ios-agent-skill folder with its companion references. Do not rename the
frontmatter-stripped 
AGENTS.md
 to 
SKILL.md
. Trust only workspaces you recognize.

Verified compatibility, 2026-09-16

Muse Code 
1.3.0 (1.3.0-R3233.1)
 connected to the published 
ios-agent-mcp 2.7.0

using stdio and discovered 
36 tools
, including reviews, local references,

create_app
 and simulator tools. Initialization and tool discovery used the actual
Muse executable with its local 
echo
 provider. No account credentials or model
request were needed. 
muse init
 and discovery of the repository's Claude-format
skill were separately verified earlier.

A command-based 
Stop hook executed
 in an isolated test. The

optional maintainer hook template
 runs
this repository's existing verification script. It is for sessions rooted in this
repository only: confirm the working directory before enabling it. Do not copy it
into an unrelated user's app or apply it globally across projects. Hook commands
execute shell code, so inspect commands before merging them into your settings.

Not verified:
 model-directed tool invocation, PreToolUse/PostToolUse ports,
verification observer behavior, sandboxed simulator operations and end-to-end
app creation by Muse. Tool discovery is not evidence that a model completed an app.
No observer toggle or privacy/pricing-tier claim is supplied without verification.

Recorded verification result
.
The harness asserts four representative tools and records the complete discovered
catalog and installed server package version.

Recheck on upgrades

From this repository, with Muse and the MCP package already installed:

node scripts/verify-muse.mjs /absolute/path/to/muse /absolute/path/to/ios-agent-mcp/dist/unified.js

This uses temporary configuration, disables foreign personal context, and checks
MCP discovery plus a Stop hook without a model call. It prints versioned JSON
results and removes the temporary files. It does not edit your Muse settings.
Rerun it when either client or server changes; record model-based checks separately.
Updating npm's 
@latest
 does not automatically upgrade an existing global install:
rerun 
npm install -g ios-agent-mcp@latest
 when choosing to upgrade.

Official sources: 
Meta announcement
,

configuration
, and

extensions
. The latter documentation
requires sign-in; the compatibility claims above come from executable tests.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Installed executable

Confirm the input and environment

Preserve the failure and return to this step

MCP discovery

Inspect the intermediate artifact

Preserve the failure and return to this step

Model task

Run the focused check

Preserve the failure and return to this step

Independent checks

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

Muse is not on the current PATH, and the existing echo test cannot stand in for the requested app run.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Writing an app brief an agent can build from: ten practice briefs

---

# Writing an app brief an agent can build from: ten…
https://nagarjuna2997.github.io/ios-agent-skill/series/1.5.html

← Guided series
 · 
All articlesLesson 1.5 · Guided learningWriting an app brief an agent can build from: ten practice briefsOn this page

Work through the example
Implementation reference

Input Contract

Acceptance and failure review
Ten briefs to practice with
Evidence and limits

A usable brief specifies state transitions, data lifetime and exclusions. Ten different app ideas can share that structure without receiving the same implementation.

User goal → Observable behavior → Data and failure states → Acceptance criteria1
User goal
↓2
Observable behavior
↓3
Data and failure states
↓4
Acceptance criteria

Work through the example

For each brief, write one success check and one failure check. Keep optional polish out of the first implementation slice.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

Input Contract

Accept short, messy descriptions. Do not require the user to fill a long form.

Example:

Create a fitness app for beginners. It should track workouts, show progress,
feel energetic, and use blue and green.

If details are missing, infer reasonable defaults and label them as assumptions.
Ask a question only when the missing detail blocks implementation, such as
whether the app needs authentication, payments, health data, or backend sync.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

User goal

Confirm the input and environment

Preserve the failure and return to this step

Observable behavior

Inspect the intermediate artifact

Preserve the failure and return to this step

Data and failure states

Run the focused check

Preserve the failure and return to this step

Acceptance criteria

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Ten briefs to practice with

These are authored exercises, not claimed outputs from ten model runs. Keep the first release local and small.

Idea

Core behavior

Acceptance check

Task list

Add and complete a task

Completion survives reopening

Reading list

Save and search books

Clearing search restores all books

Water log

Record a drink

Daily total changes once per entry

Pantry list

Mark an item used

Used item is excluded from available stock

Study cards

Reveal an answer

Navigation never changes the card data

Habit tracker

Mark a day complete

Repeated taps do not duplicate the day

Expense notes

Save an amount and note

Invalid numeric input cannot be saved

Travel checklist

Group packing items

Completion is independent in each trip

Workout journal

Record a set

A cancelled edit preserves the old value

Plant diary

Record watering

A new event appears with the chosen date

For each idea, add an empty state, an error recovery path and one explicit exclusion. For example, the reading list does not need accounts or a cloud backend in its first slice. That exclusion is a useful constraint, not missing ambition.

Evidence and limits

The example briefs are editorial exercises; they are not ten recorded model-generated plans.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Generating an Xcode project the agent can edit: XcodeGen vs opening .xcodeproj by hand

---

# Generating an Xcode project the agent can edit: XcodeGen…
https://nagarjuna2997.github.io/ios-agent-skill/series/1.6.html

← Guided series
 · 
All articlesLesson 1.6 · Guided learningGenerating an Xcode project the agent can edit: XcodeGen vs opening .xcodeproj by handOn this page

Work through the example
Implementation reference

1. The layout

Acceptance and failure review
Evidence and limits

A project specification is easier to inspect than a large opaque project-file diff, but generation is still a build input. Review the generated target, files and deployment settings before trusting a successful command.

Description → Project specification → Generated project → Build scheme1
Description
↓2
Project specification
↓3
Generated project
↓4
Build scheme

Work through the example

Compare the checked-in Reading List project.yml with its Xcode project. Identify the app target and test targets without changing signing settings.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

1. The layout

MyApp/
├── App/                    # the user's source — the only directory they edit
│   ├── MyApp/
│   └── MyAppTests/
├── README.md
├── LICENSE
├── .gitignore
└── .ios-agent/             # tool-owned; deleting it loses nothing
    ├── .gitignore          # generated
    ├── config.json         # tracked
    ├── state.json
    ├── metadata.json
    ├── cache/
    ├── logs/
    ├── build/
    ├── screenshots/
    ├── templates/          # tracked
    ├── plugins/            # tracked
    └── tmp/

With 
--minimal
, the whole project is 
MyApp/App/
, and 
.ios-agent/

materialises the first time a command needs it.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Description

Confirm the input and environment

Preserve the failure and return to this step

Project specification

Inspect the intermediate artifact

Preserve the failure and return to this step

Generated project

Run the focused check

Preserve the failure and return to this step

Build scheme

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

XcodeGen is not on the current PATH; the checked-in project builds without regeneration.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Running and seeing your app: simulator boot, install, launch, screenshot from the agent

---

# Running and seeing your app: simulator boot, install,…
https://nagarjuna2997.github.io/ios-agent-skill/series/1.7.html

← Guided series
 · 
All articlesLesson 1.7 · Guided learningRunning and seeing your app: simulator boot, install, launch, screenshot from the agentOn this page

Work through the example
Implementation reference

Evidence Contract

Acceptance and failure review
Evidence and limits
Recorded sample check

Simulator orchestration has five distinct checkpoints: discover, boot, install, launch and capture. A process identifier after launch does not prove the screen you requested is visible.

Find available device → Boot runtime → Install and launch → Capture intended state1
Find available device
↓2
Boot runtime
↓3
Install and launch
↓4
Capture intended state

Work through the example

Use the recorded Reading List baseline to distinguish installation from visual readiness. Capture again if the first image still shows the home screen.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

Evidence Contract

Every runtime tool should return structured data suitable for workflow branching:

{
  "status": "passed",
  "device": {
    "name": "iPhone 17 Pro",
    "udid": "SIMULATOR-UDID",
    "runtime": "iOS 27.0"
  },
  "app": {
    "bundle_id": "com.example.App",
    "configuration": "Debug"
  },
  "artifacts": {
    "screenshot": "artifacts/screens/home.png",
    "video": "artifacts/videos/flow.mov",
    "logs": "artifacts/logs/app.log"
  },
  "summary": "Launched app and captured home screen."
}

For failure results, include the failing command, exit code, stderr, and the nearest useful artifact. A failed build with no artifact is still useful evidence; a visual claim with no artifact is not.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Find available device

Confirm the input and environment

Preserve the failure and return to this step

Boot runtime

Inspect the intermediate artifact

Preserve the failure and return to this step

Install and launch

Run the focused check

Preserve the failure and return to this step

Capture intended state

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

A real baseline build, installation, launch and screenshot are recorded; this is not a newly generated to-do app.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.

Recorded sample check

On Xcode 26.6 with an iOS 26.5 simulator, the Reading List verification script passed 3 unit tests and 2 UI tests. It exported six screenshots. This image is one of those outputs, not an AI-generated mockup. The sample uses JSON persistence and does not establish all-client app generation or a complete accessibility audit.

What to do next
Next: What to do when the build fails: reading Xcode errors with an agent

---

# What to do when the build fails: reading Xcode errors…
https://nagarjuna2997.github.io/ios-agent-skill/series/1.8.html

← Guided series
 · 
All articlesLesson 1.8 · Guided learningWhat to do when the build fails: reading Xcode errors with an agentOn this page

Work through the example
Implementation reference

Evidence and resume

Acceptance and failure review
Evidence and limits

Build failures become manageable when you preserve the first relevant diagnostic and the exact command. Repeatedly changing unrelated Swift files destroys the context needed to explain the failure.

Reproduce command → Identify first error → Patch one cause → Repeat same check1
Reproduce command
↓2
Identify first error
↓3
Patch one cause
↓4
Repeat same check

Work through the example

Classify a failure as environment, project configuration, compiler or behavior before suggesting a fix. Record a bounded retry limit.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

Evidence and resume

Artifacts must use canonical relative paths under 
.ios-agent/evidence/
. Symlink evidence paths are rejected. Checks must create fresh, nonempty artifacts; demo verification additionally validates PNG signatures.
State, logs and evidence are stored in 
.ios-agent/
. Add that directory to your project's 
.gitignore
; logs may contain source code, local paths or command output. The loop does not publish them or copy credentials. Claude receives the brief, configuration and failing log excerpts through your local authenticated CLI.
Successful checks are reused only when source fingerprints and saved evidence/log hashes match. Source changes invalidate cached results. Generated directories such as 
.build
, 
build
, 
DerivedData
, 
node_modules
, 
.git
, 
.ios-agent
 and Xcode user/workspace metadata are excluded; do not keep implementation source there.
Interruptions preserve progress and terminate active process groups on macOS/Linux. Resume rechecks interrupted work. Windows process cleanup is limited to the direct child; iOS simulator verification requires macOS.
Frozen verification files cannot change mid-run. To intentionally revise the acceptance contract, archive 
.ios-agent/loop
 and initialize a new run. Do not edit saved results to claim completion.
Completion means every configured check passed with retained artifacts. Screenshots need human visual inspection; they are not automatic proof of visual quality, full accessibility or App Store readiness.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Reproduce command

Confirm the input and environment

Preserve the failure and return to this step

Identify first error

Inspect the intermediate artifact

Preserve the failure and return to this step

Patch one cause

Run the focused check

Preserve the failure and return to this step

Repeat same check

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

The diagnostic exercises are examples, not a measured ranking of the ten most common errors.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Review Swift 6 settings and actor isolation before changing agent-generated code

---

# Review Swift 6 settings and actor isolation before…
https://nagarjuna2997.github.io/ios-agent-skill/series/2.1.html

← Guided series
 · 
All articlesLesson 2.1 · Guided learningReview Swift 6 settings and actor isolation before changing agent-generated codeOn this page

Work through the example
Implementation reference

@Observable grants no isolation

Acceptance and failure review
Evidence and limits

Swift language mode, SDK availability and actor isolation answer different questions. Enabling checks reveals mismatches; it does not repair ownership or make CPU work safe on the main actor.

Inspect build settings → Locate isolation boundary → Apply minimal correction → Compile and exercise1
Inspect build settings
↓2
Locate isolation boundary
↓3
Apply minimal correction
↓4
Compile and exercise

Work through the example

Review a model's state mutations before adding MainActor. A detached task and an async function are not interchangeable escape hatches.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

@Observable
 grants no isolation

@Observable
 is a macro that generates observation plumbing. It says nothing
about which actor owns the state. An unannotated 
@Observable
 class is

nonisolated
, so any task may mutate it while SwiftUI reads it.

@Observable final class Model { var items: [Item] = [] }     // nonisolated — racy
@MainActor @Observable final class Model { … }               // correct

Under Swift 6 language mode the first form produces isolation errors as soon as
you touch it from an async context. Under Swift 5 mode with strict concurrency
checking set to 
minimal
, it compiles silently and races at runtime.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Inspect build settings

Confirm the input and environment

Preserve the failure and return to this step

Locate isolation boundary

Inspect the intermediate artifact

Preserve the failure and return to this step

Apply minimal correction

Run the focused check

Preserve the failure and return to this step

Compile and exercise

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

The local concurrency fixture has recorded static before/after findings; it is not proof that one setting fixes every migration.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Getting an agent to run checks: hooks, Stop conditions and verification limits

---

# Getting an agent to run checks: hooks, Stop conditions…
https://nagarjuna2997.github.io/ios-agent-skill/series/2.2.html

← Guided series
 · 
All articlesLesson 2.2 · Guided learningGetting an agent to run checks: hooks, Stop conditions and verification limitsOn this page

Work through the example
Implementation reference

1. Hook vs. CI vs. reviewer

Acceptance and failure review
Evidence and limits

A reminder can be forgotten by an agent; a supported hook can run a deterministic check. Neither replaces CI, and a hook configuration is only tested when the real client dispatches it.

Tool event → Hook handler → Exit status → Continue or stop1
Tool event
↓2
Hook handler
↓3
Exit status
↓4
Continue or stop

Work through the example

Use a synthetic protected file to test both denial and allowance. Then deliberately fail verification and inspect whether the client stops.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

1. Hook vs. CI vs. reviewer

Three enforcement layers. Use the cheapest one that can decide the question.

Layer

Decides

Cost

Feedback speed

Hook

Rules a script can evaluate

~free

Immediate — model self-corrects mid-turn

CI

Same, plus full build/test

minutes

After push

Reviewer subagent

Judgment: is this correct, does it match intent

tokens

End of task

The ordering matters. Spending a reviewer subagent on "did you use

DispatchQueue.main.async
" is waste — a grep answers that for free, instantly,
and cannot be argued with. Reserve model judgment for what rules cannot express.

Rule of thumb:
 if you can write the check as a grep or an exit code, it is a
hook. If it needs to understand intent, it is a reviewer.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Tool event

Confirm the input and environment

Preserve the failure and return to this step

Hook handler

Inspect the intermediate artifact

Preserve the failure and return to this step

Exit status

Run the focused check

Preserve the failure and return to this step

Continue or stop

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

Claude script-level checks and Muse Stop discovery have different scopes. Full cross-client enforcement remains unverified.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Making the agent prove it: screenshots as evidence, not "done"

---

# Making the agent prove it: screenshots as evidence, not…
https://nagarjuna2997.github.io/ios-agent-skill/series/2.3.html

← Guided series
 · 
All articlesLesson 2.3 · Guided learningMaking the agent prove it: screenshots as evidence, not "done"On this page

Work through the example
Implementation reference

Visual Review Checklist

Acceptance and failure review
Evidence and limits
Recorded sample check

A screenshot answers a visual question about one state on one device. Pair it with an acceptance criterion and a reproducible route to that state, rather than treating any image as completion.

Choose state → Navigate deterministically → Capture screen → Compare criterion1
Choose state
↓2
Navigate deterministically
↓3
Capture screen
↓4
Compare criterion

Work through the example

Name an empty, loading and content state. Record what each screenshot can show and what needs a behavioral or accessibility test.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

Visual Review Checklist

Area

Questions

Hierarchy

Is the primary action obvious within two seconds? Does visual weight match importance?

Spacing

Are margins, gutters, and component gaps consistent with tokens?

Typography

Does the screen use Dynamic Type and avoid fixed font sizes?

Color

Does contrast pass, and do semantic roles survive dark mode?

Motion

Does animation clarify state, avoid fighting layout, and honor Reduce Motion?

Haptics

Are haptics tied to meaningful state transitions, not decoration?

Accessibility

Are labels, traits, focus order, tap targets, and content size tested?

Runtime

Does the captured screenshot/video prove the actual app state?

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Choose state

Confirm the input and environment

Preserve the failure and return to this step

Navigate deterministically

Inspect the intermediate artifact

Preserve the failure and return to this step

Capture screen

Run the focused check

Preserve the failure and return to this step

Compare criterion

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

The baseline screenshot proves an empty screen was visible; it does not prove persistence or VoiceOver navigation.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.

Recorded sample check

On Xcode 26.6 with an iOS 26.5 simulator, the Reading List verification script passed 3 unit tests and 2 UI tests. It exported six screenshots. This image is one of those outputs, not an AI-generated mockup. The sample uses JSON persistence and does not establish all-client app generation or a complete accessibility audit.

What to do next
Next: Reviewing agent output before you commit: a focused checklist

---

# Reviewing agent output before you commit: a focused…
https://nagarjuna2997.github.io/ios-agent-skill/series/2.4.html

← Guided series
 · 
All articlesLesson 2.4 · Guided learningReviewing agent output before you commit: a focused checklistOn this page

Work through the example
Implementation reference

3. What counts as evidence
Evidence for iOS specifically
When you genuinely cannot build

Acceptance and failure review
Evidence and limits

Review the diff in an order that exposes expensive mistakes early: scope, data flow, errors, tests, then polish. A long list of stylistic suggestions is less useful than one reproducible blocker.

Inspect scope → Review risky boundaries → Run focused checks → Accept or revise1
Inspect scope
↓2
Review risky boundaries
↓3
Run focused checks
↓4
Accept or revise

Work through the example

Ask for a file location, supporting context and the smallest proposed change. Treat a clean heuristic report as limited evidence.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

3. What counts as evidence

Ordered by strength:

Test output
 — the suite ran, with counts and pass/fail state.
Build output
 — it compiles, with the exact command shown.
Command output
 — a lint run, a script, a grep whose emptiness is the point.
A screenshot
 — for UI work, the actual rendered result.
A file diff
 — what changed, when the change itself is the deliverable.
A file:line citation
 — for claims about what code does.

What does 
not
 count:

"It should work now."
"The change is correct."
"I verified the logic."
A summary of what a command would print.
A test you wrote but did not run.

Evidence for iOS specifically

### Build — SPM
swift build 2>&1 | tail -40

### Build — Xcode. List schemes first; never guess one.
xcodebuild -list -project MyApp.xcodeproj
xcodebuild build -scheme "MyApp" \
  -destination 'platform=iOS Simulator,name=iPhone 16' 2>&1 | tail -40

### Tests
swift test 2>&1 | tail -60
xcodebuild test -scheme "MyApp" \
  -destination 'platform=iOS Simulator,name=iPhone 16' 2>&1 | tail -60

### Lint
swiftlint lint --quiet
swiftformat --lint .

### Rule checks whose empty output IS the evidence
grep -rn "DispatchQueue.main.async" Sources/          # expect: nothing
grep -rn "APIClient\|URLSession" Sources/Presentation/ # expect: nothing

When a grep is the check, 
show that it returned nothing
. An empty result you
did not display is indistinguishable from a check you did not run.

When you genuinely cannot build

Common on Linux CI, in containers without Xcode, or in a docs-only repository.
Say it once, plainly, and downgrade the affected claims:

UNVERIFIED — no Xcode toolchain in this environment (`xcodebuild: command not
found`). All Swift samples are INSPECTED against the framework docs in
docs/frameworks/, not compiled. A human should build before merging.

Then verify what you 
can
: markdown structure, cross-references, script syntax
(
bash -n
), JSON/YAML validity. Partial verification honestly labelled beats
none.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Inspect scope

Confirm the input and environment

Preserve the failure and return to this step

Review risky boundaries

Inspect the intermediate artifact

Preserve the failure and return to this step

Run focused checks

Run the focused check

Preserve the failure and return to this step

Accept or revise

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

This is a review procedure, not a guaranteed five-minute duration.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Git for vibe coders: branches per prompt, checkpoints, and undoing an agent's mess

---

# Git for vibe coders: branches per prompt, checkpoints,…
https://nagarjuna2997.github.io/ios-agent-skill/series/2.5.html

← Guided series
 · 
All articlesLesson 2.5 · Guided learningGit for vibe coders: branches per prompt, checkpoints, and undoing an agent's messOn this page

Work through the example
Implementation reference

2. /batch — many isolated changes
Making a batch succeed
Worktrees

Acceptance and failure review
Evidence and limits

A checkpoint gives you a known state to compare and recover. A branch labels a line of work; a worktree gives another checkout. Neither automatically isolates simulator data or external resources.

Clean baseline → New branch or worktree → Small reviewed change → Test then integrate1
Clean baseline
↓2
New branch or worktree
↓3
Small reviewed change
↓4
Test then integrate

Work through the example

Inspect git status before asking an agent to edit. Commit coherent work, and review untracked files before switching tasks.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

2. 
/batch
 — many isolated changes

/batch
 is a packaged use of subagents plus 
git worktrees
, aimed at roughly
5–30 isolated changes that each become their own PR. Each unit gets a fresh
subagent working in its own worktree, so the changes cannot collide on disk.

Good fits:

Apply one mechanical rule across many modules — "add 
@MainActor
 to every
  view model", "replace literal spacing with 
Space.*
 tokens".
Fix the same class of bug in many independent places.
Bump a dependency across several packages.
Migrate N files to a new API where each file stands alone.

Bad fits:

Units that share files.
 Two workers editing 
AppDelegate.swift
 will
  produce conflicting PRs.
Units with ordering constraints.
 Batch has no dependency graph.
Exploratory work.
 Batch executes a known change; it does not decide what
  the change should be. Plan first (
ios-plan
), then batch.
One large refactor.
 That is one PR, not thirty.

Making a batch succeed

The per-unit prompt must be 
complete and self-contained
 — each worker starts
cold and cannot see the others.

For <module>:
  goal:     every @Observable type the UI renders is @MainActor final class
  scope:    only files under <module>/ — do not touch shared/ or Package.swift
  rules:    behavior must not change; add nothing but the isolation annotations
  verify:   swift build && swift test --filter <module>
  report:   the diff, plus the real output of the verify command
  if the module has no such types: report "no change needed" and stop

That last line matters. Without an explicit no-op path, workers invent work to
justify their existence.

Worktrees

Each unit gets its own working copy, so parallel writes are safe:

git worktree add ../wt-cart -b batch/cart-mainactor
git worktree add ../wt-orders -b batch/orders-mainactor
### … worker per worktree …
git worktree remove ../wt-cart

Isolation is the whole point. Without it, parallel writers corrupt each other's
work in ways that are extremely hard to diagnose after the fact.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Clean baseline

Confirm the input and environment

Preserve the failure and return to this step

New branch or worktree

Inspect the intermediate artifact

Preserve the failure and return to this step

Small reviewed change

Run the focused check

Preserve the failure and return to this step

Test then integrate

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

The workflow is source guidance; a four-client isolation experiment is not claimed.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Keeping the agent inside your design: tokens, no hardcoded colors, no fixed fonts

---

# Keeping the agent inside your design: tokens, no…
https://nagarjuna2997.github.io/ios-agent-skill/series/2.6.html

← Guided series
 · 
All articlesLesson 2.6 · Guided learningKeeping the agent inside your design: tokens, no hardcoded colors, no fixed fontsOn this page

Work through the example
Implementation reference

1. The Three-Tier Token Architecture
Implementation
Injecting the theme
Tier 3 — component tokens as ViewModifiers
Anti-patterns

Acceptance and failure review
Evidence and limits

Design tokens preserve intent across appearances. A semantic name such as primaryText describes purpose; a literal hex value describes only one rendering choice.

Primitive palette → Semantic roles → Component usage → Appearance checks1
Primitive palette
↓2
Semantic roles
↓3
Component usage
↓4
Appearance checks

Work through the example

Trace one button from its semantic color to its light, dark and high-contrast values. Remove literal colors only after understanding their role.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

1. The Three-Tier Token Architecture

Never let a raw color or number appear at a call site. Tokens flow in one
direction through three tiers:

Tier 1 — Primitive   Tier 2 — Semantic        Tier 3 — Component
(raw values)          (intent)                  (usage)
blue500  #0A84FF  →   accent                →   Button.background
gray900  #1C1C1E  →   textPrimary           →   Card.titleColor
space4   16pt     →   spacing.contentInset  →   Card.padding

Rules

Views reference 
Tier 3 or Tier 2 only
. A view that names 
blue500
 is a bug.
Tier 1 is 
private
 to the token module. It has no dark-mode variant — it is
  literally just a number.
Tier 2 is where light/dark, high-contrast, and theme switching resolve.
Adding a theme means adding one Tier 2 implementation, not editing views.

Implementation

// DesignSystem/Tokens/Primitives.swift
// Tier 1 — raw values. Never referenced from a View.
enum Primitive {
    static let blue500  = Color(hex: 0x0A84FF)
    static let blue600  = Color(hex: 0x0060DF)
    static let indigo500 = Color(hex: 0x5E5CE6)
    static let red500   = Color(hex: 0xFF3B30)
    static let green500 = Color(hex: 0x34C759)
    static let amber500 = Color(hex: 0xFF9F0A)

    // Spacing scale — a 4pt rhythm. Nothing else is permitted.
    static let space1: CGFloat = 4
    static let space2: CGFloat = 8
    static let space3: CGFloat = 12
    static let space4: CGFloat = 16
    static let space5: CGFloat = 24
    static let space6: CGFloat = 32
    static let space7: CGFloat = 48

    // Radii
    static let radiusS: CGFloat = 8
    static let radiusM: CGFloat = 16
    static let radiusL: CGFloat = 24
}

// THE canonical hex initialiser for this skill. Defined here, in the token
// layer, and nowhere else — `color-system.md` and
// `templates/common-patterns/design-system.swift` reference this file rather
// than redeclaring it. Two files declaring `init(hex: String)` with different
// bodies is not a style disagreement: copy both into one target and the
// compiler rejects it with `invalid redeclaration of 'init(hex:)'`.
extension Color {
    /// Hex as an integer literal: `Color(hex: 0x6C63FF)`.
    ///
    /// Preferred over the string form because a typo is a compile error rather
    /// than a runtime surprise — `0x6C63FZ` does not build, `"6C63FZ"` does.
    init(hex: UInt32, opacity: Double = 1) {
        self.init(
            .sRGB,
            red:   Double((hex >> 16) & 0xFF) / 255,
            green: Double((hex >>  8) & 0xFF) / 255,
            blue:  Double( hex        & 0xFF) / 255,
            opacity: opacity
        )
    }

    /// Hex as a string: `Color(hex: "6C63FF")`, with or without `#`,
    /// 6 digits (RGB) or 8 (RRGGBBAA).
    ///
    /// Exists because designers hand over strings and remote themes arrive as
    /// JSON. A malformed value traps in DEBUG and renders **magenta** in
    /// release — never black. The earlier versions of this initialiser fell
    /// back to black, which is indistinguishable from a deliberate colour and
    /// so shipped unnoticed; magenta appears nowhere in any of these palettes
    /// and is impossible to mistake for intent.
    init(hex string: String, opacity: Double = 1) {
        let cleaned = string
            .trimmingCharacters(in: .whitespacesAndNewlines)
            .replacingOccurrences(of: "#", with: "")

        var value: UInt64 = 0
        let scanned = Scanner(string: cleaned).scanHexInt64(&value)

        switch (scanned, cleaned.count) {
        case (true, 6):
            self.init(hex: UInt32(truncatingIfNeeded: value), opacity: opacity)
        case (true, 8):
            let alpha = Double(value & 0xFF) / 255
            self.init(hex: UInt32(truncatingIfNeeded: value >> 8), opacity: opacity * alpha)
        default:
            assertionFailure("Malformed hex colour literal: \(string)")
            self.init(hex: 0xFF00FF, opacity: opacity)
        }
    }
}

// DesignSystem/Tokens/Theme.swift
// Tier 2 — semantic intent. This is the swappable layer.
protocol Theme: Sendable {
    // Surfaces
    var background: Color { get }        // the page
    var surface: Color { get }           // cards, sheets
    var surfaceElevated: Color { get }   // popovers, menus

    // Content — must meet contrast against the surface it sits on
    var textPrimary: Color { get }
    var textSecondary: Color { get }
    var textOnAccent: Color { get }

    // Intent
    var accent: Color { get }
    var accentPressed: Color { get }
    var destructive: Color { get }
    var success: Color { get }
    var warning: Color { get }

    // Separators and elevation
    var separator: Color { get }
    var shadow: Color { get }
}

struct OceanTheme: Theme {
    // Apple's semantic colors already resolve light/dark AND increased contrast.
    // Prefer them for surfaces and text; reserve custom hex for brand accents.
    var background      = Color(.systemBackground)
    var surface         = Color(.secondarySystemBackground)
    var surfaceElevated = Color(.tertiarySystemBackground)

    var textPrimary   = Color(.label)
    var textSecondary = Color(.secondaryLabel)
    var textOnAccent  = Color.white

    var accent        = Primitive.blue500
    var accentPressed = Primitive.blue600
    var destructive   = Color(.systemRed)
    var success       = Color(.systemGreen)
    var warning       = Color(.systemOrange)

    var separator = Color(.separator)
    var shadow    = Color.black.opacity(0.08)
}

// DesignSystem/Tokens/Spacing.swift — Tier 2 for layout
enum Space {
    static let hairline    = Primitive.space1   // icon-to-label
    static let tight       = Primitive.space2   // within a control
    static let element     = Primitive.space3   // between related elements
    static let contentInset = Primitive.space4  // card padding, screen margins
    static let section     = Primitive.space5   // between sections
    static let major       = Primitive.space6   // above a page title
}

enum Radius {
    static let control = Primitive.radiusS      // buttons, chips
    static let card    = Primitive.radiusM      // cards, tiles
    static let sheet   = Primitive.radiusL      // modals
}

Injecting the theme

private struct ThemeKey: EnvironmentKey {
    static let defaultValue: any Theme = OceanTheme()
}

extension EnvironmentValues {
    var theme: any Theme {
        get { self[ThemeKey.self] }
        set { self[ThemeKey.self] = newValue }
    }
}

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            RootView().environment(\.theme, OceanTheme())
        }
    }
}

Tier 3 — component tokens as ViewModifiers

struct CardStyle: ViewModifier {
    @Environment(\.theme) private var theme

    func body(content: Content) -> some View {
        content
            .padding(Space.contentInset)
            .background(theme.surface, in: .rect(cornerRadius: Radius.card))
            .shadow(color: theme.shadow, radius: 8, y: 4)
    }
}

extension View {
    func cardStyle() -> some View { modifier(CardStyle()) }
}

// Usage — no raw values anywhere.
VStack(alignment: .leading, spacing: Space.element) {
    Text("Monthly total").font(.headline).foregroundStyle(theme.textPrimary)
    Text("$1,240").font(.largeTitle.bold()).foregroundStyle(theme.accent)
}
.cardStyle()

Anti-patterns

// WRONG — raw values at the call site. Changing the brand means grepping.
.padding(16)
.background(Color(red: 0.04, green: 0.52, blue: 1.0))
.cornerRadius(16)

// WRONG — a Tier 1 primitive leaking into a view.
.foregroundStyle(Primitive.blue500)

// WRONG — a semantic name that describes appearance, not intent.
var lightGray: Color { … }        // what happens in dark mode?
var textSecondary: Color { … }    // correct

// RIGHT
.padding(Space.contentInset)
.background(theme.accent, in: .rect(cornerRadius: Radius.card))

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Primitive palette

Confirm the input and environment

Preserve the failure and return to this step

Semantic roles

Inspect the intermediate artifact

Preserve the failure and return to this step

Component usage

Run the focused check

Preserve the failure and return to this step

Appearance checks

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

Generated asset structure has evidence; visual quality still needs checks in the actual app.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: SwiftUI prompt practice: 25 scoped exercises and acceptance checks

---

# SwiftUI prompt practice: 25 scoped exercises and…
https://nagarjuna2997.github.io/ios-agent-skill/series/2.7.html

← Guided series
 · 
All articlesLesson 2.7 · Guided learningSwiftUI prompt practice: 25 scoped exercises and acceptance checksOn this page

Work through the example
Implementation reference

Prompt Template

Acceptance and failure review
Twenty-five practice prompts
Evidence and limits

A prompt library is useful when each prompt names inputs, boundaries and a check. Without recorded outputs, it is a set of exercises rather than evidence that a phrase reliably improves SwiftUI.

Choose one behavior → Supply project context → Request bounded change → Inspect result1
Choose one behavior
↓2
Supply project context
↓3
Request bounded change
↓4
Inspect result

Work through the example

Adapt the supplied prompts to a known scheme and one screen. Preserve failed attempts rather than deleting them from a success story.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

Prompt Template

Use this exact template when turning a description into an executable prompt:

You are building a native iOS app from this user description:
"<USER_DESCRIPTION>"

Create a production-ready SwiftUI implementation with:
- iOS deployment target: iOS 17+ unless the user asks otherwise
- Architecture: MVVM with protocol-based dependencies
- State: @MainActor @Observable view models for UI-rendered state
- Navigation: typed routes and deep-link-ready structure
- Design: tokenized colors, spacing, typography, radius, shadows
- Accessibility: Dynamic Type, VoiceOver labels, 44pt targets, contrast checks
- Previews: every main state must render without network or disk
- Testing: unit-testable use cases and injected repositories

Derived product brief:
- Name:
- Audience:
- Problem:
- Primary user journey:
- Secondary flows:
- Data model:
- Offline behavior:

Derived visual direction:
- Personality:
- Color tokens:
- Typography:
- Component style:
- Motion:

Implement:
1. App entry point
2. Theme/design tokens
3. Models
4. Repository protocols and mock repositories
5. View models
6. Screens
7. Reusable components
8. Previews
9. Tests where practical

Do not:
- Use raw colors, spacing, or font sizes in views
- Create live dependencies inside view models
- Use @Observable without @MainActor for UI state
- Claim the app works without build/test/screenshot evidence

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Choose one behavior

Confirm the input and environment

Preserve the failure and return to this step

Supply project context

Inspect the intermediate artifact

Preserve the failure and return to this step

Request bounded change

Run the focused check

Preserve the failure and return to this step

Inspect result

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Twenty-five practice prompts

Use the request column as a starting sentence; supply the actual file and behavior. These are not labelled tested because this chapter does not contain 25 recorded model sessions.

Topic

Request

Check

Empty library

Describe the first useful action

No items is distinct from failure

Empty search

Add a clear-query action

Stored data remains intact

Loading

Show progress without duplicate requests

One request per intended action

Error

Preserve useful data on failure

Retry has a defined effect

Add form

Reject blank titles

Whitespace-only input fails

Edit form

Support cancellation

Original data is unchanged

Deletion

Explain recovery behavior

Deletion affects only selected item

Persistence

Use an injected store

Relaunch preserves data

Search

Define matching rules

Case behavior is intentional

Navigation

Use stable route identity

Missing item has a fallback

Deep link

Validate input

Unknown route does not crash

Dark mode

Use semantic tokens

Text remains readable

High contrast

Use explicit assets

Variants resolve correctly

Dynamic Type

Allow content to grow

Long labels do not clip

VoiceOver label

Name the action meaningfully

Label describes the control

Focus

Preserve a sensible destination

Dismissal restores context

Cancellation

Stop obsolete work

Stale result cannot replace current data

Networking

Validate status before decoding

Error payload is not success

Previews

Inject deterministic fixtures

No production disk or network access

Unit test

Assert one domain transition

Test fails for a broken transition

UI test

Use stable identifiers

Wait for observable state

Screenshots

Name state and destination

Image matches requested state

Refactor

Preserve public behavior

Same acceptance suite passes

Review

Locate one finding

Supporting source is included

Completion

Separate executed from unverified

No unsupported success claim

Evidence and limits

The prompts below are practice prompts, not 25 completed comparative runs.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: When a SwiftUI agent task fails: ten failure categories to distinguish

---

# When a SwiftUI agent task fails: ten failure categories…
https://nagarjuna2997.github.io/ios-agent-skill/series/2.8.html

← Guided series
 · 
All articlesLesson 2.8 · Guided learningWhen a SwiftUI agent task fails: ten failure categories to distinguishOn this page

Work through the example
Implementation reference

6. Reporting failure

Acceptance and failure review
Ten failure categories
Evidence and limits

A failed task should describe the environment, observed behavior and unresolved condition. It should not become a universal claim that all agents are unable to perform that task.

Expected behavior → Actual output → Reproduction → Bounded conclusion1
Expected behavior
↓2
Actual output
↓3
Reproduction
↓4
Bounded conclusion

Work through the example

Write a failure note that another developer can reproduce using synthetic data. Separate missing access from incorrect implementation.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

6. Reporting failure

Report outcomes faithfully. Specifically:

If tests fail, say so and paste the failure.
If you skipped a step, say which and why.
If you could not reproduce a bug, say that — do not ship a speculative fix as
  a confirmed one.
If part of the scope is blocked, finish everything else in full and state
  exactly what you left out.

A partial result honestly labelled is more useful than a complete-looking result
that is wrong, because the human can act on the first and will be misled by the
second.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Expected behavior

Confirm the input and environment

Preserve the failure and return to this step

Actual output

Inspect the intermediate artifact

Preserve the failure and return to this step

Reproduction

Run the focused check

Preserve the failure and return to this step

Bounded conclusion

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Ten failure categories

These categories distinguish causes; they are not a prevalence ranking or a list of impossible tasks.

Observed category

First diagnostic step

Executable missing

Check the installed command path

Expired authentication

Use the client’s local sign-in flow

Missing runtime

Inspect available simulator runtimes

Wrong scheme

List schemes in the selected project

Unavailable API

Inspect SDK and deployment target

Isolation mismatch

Trace ownership and mutation sites

Stale response

Exercise cancellation and replacement

Lost persistence

Terminate and reopen a test store

UI clipping

Use long text and larger type

False-positive finding

Reduce to positive and negative fixtures

Evidence and limits

No evidence supports a universal list of ten things agents cannot do; the lesson teaches failure classification instead.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Local persistence contracts: Reading List JSON storage and a SwiftData alternative

---

# Local persistence contracts: Reading List JSON storage…
https://nagarjuna2997.github.io/ios-agent-skill/series/3.1.html

← Guided series
 · 
All articlesLesson 3.1 · Guided learningLocal persistence contracts: Reading List JSON storage and a SwiftData alternativeOn this page

Work through the example
Implementation reference

@Model Macro and Schema Definition

Acceptance and failure review
Evidence and limits

The repository's Reading List sample persists JSON, not SwiftData. Use it to study the persistence contract, then treat a SwiftData implementation as a separate storage adapter with its own tests.

View model → Store protocol → SwiftData adapter → Relaunch verification1
View model
↓2
Store protocol
↓3
SwiftData adapter
↓4
Relaunch verification

Work through the example

Test create, edit, delete and reopen against a temporary store. Inject storage rather than letting previews access production data.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

@Model Macro and Schema Definition

import SwiftData

@Model
class Task {
    var id: UUID
    var title: String
    var notes: String
    var isCompleted: Bool
    var priority: Int
    var createdAt: Date
    var dueDate: Date?

    // Relationship
    var category: Category?
    var tags: [Tag]

    // Transient (not persisted)
    @Transient var isSelected = false

    // Unique constraint
    #Unique<Task>([\.id])

    init(title: String, priority: Int = 0) {
        self.id = UUID()
        self.title = title
        self.notes = ""
        self.isCompleted = false
        self.priority = priority
        self.createdAt = Date()
        self.tags = []
    }
}

@Model
class Category {
    var id: UUID
    var name: String
    var color: String

    // Inverse relationship with cascade delete
    @Relationship(deleteRule: .cascade, inverse: \Task.category)
    var tasks: [Task]

    init(name: String, color: String = "blue") {
        self.id = UUID()
        self.name = name
        self.color = color
        self.tasks = []
    }
}

@Model
class Tag {
    var id: UUID
    var name: String

    @Relationship(inverse: \Task.tags)
    var tasks: [Task]

    init(name: String) {
        self.id = UUID()
        self.name = name
        self.tasks = []
    }
}

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

View model

Confirm the input and environment

Preserve the failure and return to this step

Store protocol

Inspect the intermediate artifact

Preserve the failure and return to this step

SwiftData adapter

Run the focused check

Preserve the failure and return to this step

Relaunch verification

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

The Reading List acceptance suite does not verify a SwiftData migration.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Search, filtering and empty states that pass accessibility review

---

# Localization with an agent: what to check before it…
https://nagarjuna2997.github.io/ios-agent-skill/series/3.10.html

← Guided series
 · 
All articlesLesson 3.10 · Guided learningLocalization with an agent: what to check before it "translates" everythingOn this page

Work through the example
Implementation reference

6. Localization Approach
String Catalogs Setup
Date and Number Formatting
RTL and Layout Considerations

Acceptance and failure review
Evidence and limits

Localization changes layout and meaning, not just strings. Preserve placeholders and plural rules, then exercise long text, right-to-left presentation and missing translations.

Source string → Context and placeholders → Translation review → Layout checks1
Source string
↓2
Context and placeholders
↓3
Translation review
↓4
Layout checks

Work through the example

Give the agent the screen context for each string. Compare accessibility labels and visible labels after translation.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

6. Localization Approach

String Catalogs Setup

All user-facing strings must use 
String(localized:)
. Never hardcode display text.

Xcode creates a 
Localizable.xcstrings
 file (String Catalog) that automatically extracts strings.

// MARK: - Correct: Localized Strings
let title = String(localized: "welcome.title")
let message = String(localized: "welcome.message")

// With default value
let greeting = String(localized: "greeting", defaultValue: "Hello there!")

// MARK: - String Interpolation
let itemCount = 5
let label = String(localized: "\(itemCount) items remaining")

// MARK: - Pluralization (handled in .xcstrings catalog)
// In the String Catalog, define plural variants:
//   "item_count" -> one: "%lld item", other: "%lld items"
let countLabel = String(localized: "\(itemCount) items")

// MARK: - Table-based organization
let settingsTitle = String(localized: "title", table: "Settings")

Date and Number Formatting

// MARK: - Locale-Aware Formatting
struct FormattingExamples: View {
    let price: Decimal = 49.99
    let eventDate = Date()
    let progress = 0.756

    var body: some View {
        VStack(alignment: .leading) {
            // Currency — adapts to user locale
            Text(price, format: .currency(code: "USD"))

            // Date — adapts to locale conventions
            Text(eventDate, format: .dateTime.month(.wide).day().year())

            // Relative date
            Text(eventDate, format: .relative(presentation: .named))

            // Percentage
            Text(progress, format: .percent.precision(.fractionLength(1)))

            // Measurement
            Text(Measurement(value: 72, unit: UnitTemperature.fahrenheit),
                 format: .measurement(width: .abbreviated))
        }
    }
}

RTL and Layout Considerations

// MARK: - RTL-Safe Layout
struct RTLSafeView: View {
    @Environment(\.layoutDirection) var layoutDirection

    var body: some View {
        HStack {
            // Use .leading/.trailing, never .left/.right
            Image(systemName: "arrow.forward")
                .flipsForRightToLeftLayoutDirection(true)
            Text(String(localized: "next"))
        }
        .frame(maxWidth: .infinity, alignment: .leading) // Flips automatically in RTL
    }
}

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Source string

Confirm the input and environment

Preserve the failure and return to this step

Context and placeholders

Inspect the intermediate artifact

Preserve the failure and return to this step

Translation review

Run the focused check

Preserve the failure and return to this step

Layout checks

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

Machine-generated translations still need language review; no multi-language audit is claimed.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: App icons in 2026: Liquid Glass, Icon Composer, and generating them from SVG layers

---

# Search, filtering and empty states that pass…
https://nagarjuna2997.github.io/ios-agent-skill/series/3.2.html

← Guided series
 · 
All articlesLesson 3.2 · Guided learningSearch, filtering and empty states that pass accessibility reviewOn this page

Work through the example
Implementation reference

@State

Acceptance and failure review
Evidence and limits
Recorded sample check

An empty library and an empty search result need different explanations. The first invites creation; the second preserves the collection while helping the user recover from a query.

Stored collection → Search query → Filtered result → Clear to recover1
Stored collection
↓2
Search query
↓3
Filtered result
↓4
Clear to recover

Work through the example

Add a known item, search for a missing title, clear the query and verify that the item returns. Check the labels separately from appearance.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

@State

Owns mutable state local to a view. SwiftUI manages storage; the view re-renders when the value changes.

struct CounterView: View {
    @State private var count = 0
    @State private var items: [String] = []

    var body: some View {
        VStack {
            Text("Count: \(count)")
            Button("Increment") { count += 1 }
            Button("Add Item") { items.append("Item \(items.count)") }
        }
    }
}

Rules:

- Always mark 
@State
 properties 
private
.
- Do not initialize 
@State
 from an initializer parameter when the view may be recreated -- use 
@Binding
 or a model instead.
- Works with value types (structs, enums, primitives) and, on iOS 17+, with 
@Observable
 classes.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Stored collection

Confirm the input and environment

Preserve the failure and return to this step

Search query

Inspect the intermediate artifact

Preserve the failure and return to this step

Filtered result

Run the focused check

Preserve the failure and return to this step

Clear to recover

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

Reading List contains corresponding checks; a full accessibility audit is a separate task.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.

Recorded sample check

On Xcode 26.6 with an iOS 26.5 simulator, the Reading List verification script passed 3 unit tests and 2 UI tests. It exported six screenshots. This image is one of those outputs, not an AI-generated mockup. The sample uses JSON persistence and does not establish all-client app generation or a complete accessibility audit.

What to do next
Next: Networking and Codable with an agent: the concurrency mistakes to catch

---

# Networking and Codable with an agent: the concurrency…
https://nagarjuna2997.github.io/ios-agent-skill/series/3.3.html

← Guided series
 · 
All articlesLesson 3.3 · Guided learningNetworking and Codable with an agent: the concurrency mistakes to catchOn this page

Work through the example
Implementation reference

URLSession Async/Await Patterns

Acceptance and failure review
Evidence and limits

Networking introduces transport failure, decoding failure, cancellation and stale results. A successful response path alone cannot explain what the screen does after a request is replaced or cancelled.

Request → Validate response → Decode model → Publish current result1
Request
↓2
Validate response
↓3
Decode model
↓4
Publish current result

Work through the example

Inject a network service and exercise a delayed response, malformed payload and cancellation. Keep UI mutations on the intended actor.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

URLSession Async/Await Patterns

// GET request
func fetchUsers() async throws -> [User] {
    let url = URL(string: "https://api.example.com/users")!
    let (data, response) = try await URLSession.shared.data(from: url)

    guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
        throw APIError.badResponse
    }
    return try JSONDecoder().decode([User].self, from: data)
}

// POST request
func createUser(_ user: CreateUserRequest) async throws -> User {
    var request = URLRequest(url: URL(string: "https://api.example.com/users")!)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try JSONEncoder().encode(user)

    let (data, response) = try await URLSession.shared.data(for: request)

    guard let http = response as? HTTPURLResponse, http.statusCode == 201 else {
        throw APIError.badResponse
    }
    return try JSONDecoder().decode(User.self, from: data)
}

// Download with progress using AsyncBytes
func downloadWithProgress(from url: URL) async throws -> Data {
    let (bytes, response) = try await URLSession.shared.bytes(from: url)

    guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
        throw APIError.badResponse
    }

    let totalBytes = Int(http.expectedContentLength)
    var data = Data(capacity: totalBytes)

    for try await byte in bytes {
        data.append(byte)
        let progress = Double(data.count) / Double(totalBytes)
        await MainActor.run { self.downloadProgress = progress }
    }
    return data
}

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Request

Confirm the input and environment

Preserve the failure and return to this step

Validate response

Inspect the intermediate artifact

Preserve the failure and return to this step

Decode model

Run the focused check

Preserve the failure and return to this step

Publish current result

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

This chapter's networking exercise is not a new recorded network integration test.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Navigation in SwiftUI: what agents get wrong with NavigationStack

---

# Navigation in SwiftUI: what agents get wrong with…
https://nagarjuna2997.github.io/ios-agent-skill/series/3.4.html

← Guided series
 · 
All articlesLesson 3.4 · Guided learningNavigation in SwiftUI: what agents get wrong with NavigationStackOn this page

Work through the example
Implementation reference

NavigationStack
Value-Based NavigationLink with .navigationDestination
NavigationPath (Programmatic Navigation)
Typed path (homogeneous)

Acceptance and failure review
Evidence and limits

Navigation state should identify destinations rather than duplicate entire mutable models. A deep link, restored route and tapped row should converge on a coherent destination model.

User action or URL → Validated route → Navigation state → Destination1
User action or URL
↓2
Validated route
↓3
Navigation state
↓4
Destination

Work through the example

Describe what happens when the requested item no longer exists. Test back navigation after search and restoration.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

NavigationStack

The primary navigation container (iOS 16+). Replaces the deprecated 
NavigationView
.

struct ContentView: View {
    var body: some View {
        NavigationStack {
            List(items) { item in
                NavigationLink(item.title) {
                    DetailView(item: item)
                }
            }
            .navigationTitle("Items")
            .navigationBarTitleDisplayMode(.large) // .inline, .large, .automatic
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("Add", systemImage: "plus") { addItem() }
                }
            }
        }
    }
}

Value-Based NavigationLink with .navigationDestination

The preferred pattern -- decouples the link from its destination.

struct ContentView: View {
    var body: some View {
        NavigationStack {
            List {
                NavigationLink("Show Profile", value: Route.profile("user-123"))
                NavigationLink("Settings", value: Route.settings)

                ForEach(items) { item in
                    NavigationLink(value: item) {
                        ItemRow(item: item)
                    }
                }
            }
            .navigationDestination(for: Item.self) { item in
                ItemDetailView(item: item)
            }
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .profile(let id):
                    ProfileView(userId: id)
                case .settings:
                    SettingsView()
                }
            }
        }
    }
}

enum Route: Hashable {
    case profile(String)
    case settings
    case detail(Item)
}

NavigationPath (Programmatic Navigation)

A type-erased path that supports heterogeneous value types.

@Observable
class Router {
    var path = NavigationPath()

    func goToProfile(_ id: String) {
        path.append(Route.profile(id))
    }

    func goToDetail(_ item: Item) {
        path.append(item)
    }

    func popToRoot() {
        path = NavigationPath()
    }

    func pop() {
        if !path.isEmpty {
            path.removeLast()
        }
    }
}

struct AppView: View {
    @State private var router = Router()

    var body: some View {
        NavigationStack(path: $router.path) {
            HomeView()
                .navigationDestination(for: Route.self) { route in
                    routeView(for: route)
                }
                .navigationDestination(for: Item.self) { item in
                    ItemDetailView(item: item)
                }
        }
        .environment(router)
    }

    @ViewBuilder
    func routeView(for route: Route) -> some View {
        switch route {
        case .profile(let id): ProfileView(userId: id)
        case .settings: SettingsView()
        case .detail(let item): ItemDetailView(item: item)
        }
    }
}

// Deep push from anywhere
struct SomeChildView: View {
    @Environment(Router.self) private var router

    var body: some View {
        Button("Go to Profile") {
            router.goToProfile("user-456")
        }
    }
}

Typed path (homogeneous)

@State private var path: [Item] = []

NavigationStack(path: $path) {
    List(items) { item in
        NavigationLink(value: item) { Text(item.title) }
    }
    .navigationDestination(for: Item.self) { item in
        DetailView(item: item)
    }
}

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

User action or URL

Confirm the input and environment

Preserve the failure and return to this step

Validated route

Inspect the intermediate artifact

Preserve the failure and return to this step

Navigation state

Run the focused check

Preserve the failure and return to this step

Destination

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

The source guide supplies patterns; no blanket claim is made that a navigation review catches every routing bug.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Adding widgets with App Intents: a timeline and verification plan

---

# Adding widgets with App Intents: a timeline and…
https://nagarjuna2997.github.io/ios-agent-skill/series/3.5.html

← Guided series
 · 
All articlesLesson 3.5 · Guided learningAdding widgets with App Intents: a timeline and verification planOn this page

Work through the example
Implementation reference

TimelineProvider (Placeholder, Snapshot, Timeline)

Acceptance and failure review
Evidence and limits

A widget reads a constrained snapshot of app state and updates through its timeline and supported interactions. It is not the full app view running indefinitely in the background.

Shared snapshot → Timeline entry → Widget view → Intent-triggered change1
Shared snapshot
↓2
Timeline entry
↓3
Widget view
↓4
Intent-triggered change

Work through the example

Separate storage shared with the app from preview fixtures. Validate size families and empty data before adding styling.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

TimelineProvider (Placeholder, Snapshot, Timeline)

struct SimpleEntry: TimelineEntry {
    let date: Date
    let title: String
    let value: Int
    let icon: String
}

struct SimpleProvider: TimelineProvider {
    // Shown while widget is loading. Must return synchronously.
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: .now, title: "Loading...", value: 0, icon: "star")
    }

    // Shown in the widget gallery and transient situations.
    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> Void) {
        if context.isPreview {
            // Return sample data for the gallery preview
            completion(SimpleEntry(date: .now, title: "Steps Today", value: 8432, icon: "figure.walk"))
        } else {
            // Fetch real data for transient display
            let entry = SimpleEntry(date: .now, title: "Steps Today", value: fetchStepCount(), icon: "figure.walk")
            completion(entry)
        }
    }

    // Provides the timeline of entries that drive the widget's display.
    func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> Void) {
        var entries: [SimpleEntry] = []
        let currentDate = Date()

        // Create entries for the next 5 hours
        for hourOffset in 0..<5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SimpleEntry(
                date: entryDate,
                title: "Steps Today",
                value: fetchStepCount() + (hourOffset * 500),
                icon: "figure.walk"
            )
            entries.append(entry)
        }

        // Timeline reload policies:
        // .atEnd     - reload after the last entry's date passes
        // .after(d)  - reload after a specific date
        // .never     - only reload when the app explicitly requests it
        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }

    private func fetchStepCount() -> Int { return 8432 }
}

// Async provider using AppIntentTimelineProvider (cleaner async/await API)
struct ConfigurableProvider: AppIntentTimelineProvider {
    typealias Entry = SimpleEntry
    typealias Intent = SelectCategoryIntent

    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: .now, title: "Loading...", value: 0, icon: "star")
    }

    func snapshot(for configuration: SelectCategoryIntent, in context: Context) async -> SimpleEntry {
        SimpleEntry(date: .now, title: configuration.category?.name ?? "All", value: 42, icon: "star")
    }

    func timeline(for configuration: SelectCategoryIntent, in context: Context) async -> Timeline<SimpleEntry> {
        let entries = [
            SimpleEntry(
                date: .now,
                title: configuration.category?.name ?? "All",
                value: 42,
                icon: "star"
            )
        ]
        return Timeline(entries: entries, policy: .after(.now.addingTimeInterval(3600)))
    }
}

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Shared snapshot

Confirm the input and environment

Preserve the failure and return to this step

Timeline entry

Inspect the intermediate artifact

Preserve the failure and return to this step

Widget view

Run the focused check

Preserve the failure and return to this step

Intent-triggered change

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

The installed Xcode 26.6 cannot verify iOS 27-only widget styling; that lab is blocked.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Designing Siri actions with App Intents and testable domain operations

---

# Designing Siri actions with App Intents and testable…
https://nagarjuna2997.github.io/ios-agent-skill/series/3.6.html

← Guided series
 · 
All articlesLesson 3.6 · Guided learningDesigning Siri actions with App Intents and testable domain operationsOn this page

Work through the example
Implementation reference

AppIntent Protocol and perform()

Acceptance and failure review
Evidence and limits

An app intent exposes a meaningful action with parameters and a result. Start from the user action and entity identity, then validate the declarations against the installed SDK.

User request → Intent parameters → Domain operation → Result and presentation1
User request
↓2
Intent parameters
↓3
Domain operation
↓4
Result and presentation

Work through the example

Keep the domain operation testable outside the intent. Test missing entities and permission denial rather than assuming invocation succeeds.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

AppIntent Protocol and perform()

Every App Intent conforms to the 
AppIntent
 protocol and implements a 
perform()
 method that returns an 
IntentResult
.

import AppIntents

struct OpenArticleIntent: AppIntent {
    // Title shown in Shortcuts and Siri
    static var title: LocalizedStringResource = "Open Article"
    static var description: IntentDescription = "Opens a specific article in the app."

    // The system can open your app when this intent runs
    static var openAppWhenRun: Bool = true

    @Parameter(title: "Article Name")
    var articleName: String

    @MainActor
    func perform() async throws -> some IntentResult & ProvidesDialog {
        // Look up the article and navigate to it
        guard let article = ArticleStore.shared.find(byName: articleName) else {
            throw ArticleError.notFound(articleName)
        }

        NavigationManager.shared.navigate(to: article)

        return .result(dialog: "Opening \"\(article.title)\"")
    }
}

enum ArticleError: Error, CustomLocalizedStringResourceConvertible {
    case notFound(String)

    var localizedStringResource: LocalizedStringResource {
        switch self {
        case .notFound(let name):
            return "Could not find article \"\(name)\""
        }
    }
}

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

User request

Confirm the input and environment

Preserve the failure and return to this step

Intent parameters

Inspect the intermediate artifact

Preserve the failure and return to this step

Domain operation

Run the focused check

Preserve the failure and return to this step

Result and presentation

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

Do not infer Siri behavior from a static review or claim new SDK deprecations without checking Apple documentation.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: On-device AI: Foundation Models readiness and fallback design

---

# On-device AI: Foundation Models readiness and fallback…
https://nagarjuna2997.github.io/ios-agent-skill/series/3.7.html

← Guided series
 · 
All articlesLesson 3.7 · Guided learningOn-device AI: Foundation Models readiness and fallback designOn this page

Work through the example
Implementation reference

10. Availability

Acceptance and failure review
Evidence and limits

Model-backed features need both an API availability check and a runtime readiness decision. A deployment guard alone does not establish that a model is usable on the current device.

SDK guard → Runtime readiness → Model session → Useful fallback1
SDK guard
↓2
Runtime readiness
↓3
Model session
↓4
Useful fallback

Work through the example

Define the non-AI path first. Keep tool arguments validated and distinguish generated output from app-owned facts.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

10. Availability

Two versions are in play. Do not collapse them.

// Baseline framework + on-device model.
@available(iOS 26.0, macOS 26.0, *)

// PCC model, Dynamic Profiles, image attachments, custom LanguageModel providers.
@available(iOS 27.0, *)

func makeSession() -> LanguageModelSession? {
    if #available(iOS 27.0, *) {
        return LanguageModelSession(profile: CookingProfile(state: state))
    } else if #available(iOS 26.0, *) {
        return LanguageModelSession(instructions: fallbackInstructions)
    } else {
        return nil          // feature hidden entirely below iOS 26
    }
}

An app supporting iOS 17+ (this skill's baseline) must treat every Foundation
Models feature as additive. The non-AI path is the product; the AI path is an
enhancement.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

SDK guard

Confirm the input and environment

Preserve the failure and return to this step

Runtime readiness

Inspect the intermediate artifact

Preserve the failure and return to this step

Model session

Run the focused check

Preserve the failure and return to this step

Useful fallback

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

A simulator or static sample cannot establish every supported physical-device model configuration.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Notifications, background tasks and the permissions agents forget

---

# Notifications, background tasks and the permissions…
https://nagarjuna2997.github.io/ios-agent-skill/series/3.8.html

← Guided series
 · 
All articlesLesson 3.8 · Guided learningNotifications, background tasks and the permissions agents forgetOn this page

Work through the example
Implementation reference

BGTaskScheduler Registration

Acceptance and failure review
Evidence and limits

Background execution is a system-granted opportunity, not an unlimited timer. Permissions and scheduling behavior belong in the acceptance plan before an agent writes the happy path.

User permission → Register capability → Schedule work → Handle expiration1
User permission
↓2
Register capability
↓3
Schedule work
↓4
Handle expiration

Work through the example

Test cancellation and expiration through a controlled seam. Make the foreground app explain the last known state instead of promising exact execution time.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

BGTaskScheduler Registration

Register task identifiers in 
Info.plist
 under 
BGTaskSchedulerPermittedIdentifiers
:

<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
    <string>com.yourapp.refresh</string>
    <string>com.yourapp.db-cleanup</string>
    <string>com.yourapp.sync</string>
</array>

Register handlers at app launch (before the end of the first 
applicationDidFinishLaunching
 call or in the 
@main
 App 
init
):

import BackgroundTasks

@main
struct MyApp: App {
    init() {
        registerBackgroundTasks()
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }

    private func registerBackgroundTasks() {
        BGTaskScheduler.shared.register(
            forTaskWithIdentifier: "com.yourapp.refresh",
            using: nil  // nil = main queue
        ) { task in
            guard let task = task as? BGAppRefreshTask else { return }
            handleAppRefresh(task: task)
        }

        BGTaskScheduler.shared.register(
            forTaskWithIdentifier: "com.yourapp.db-cleanup",
            using: nil
        ) { task in
            guard let task = task as? BGProcessingTask else { return }
            handleDatabaseCleanup(task: task)
        }

        BGTaskScheduler.shared.register(
            forTaskWithIdentifier: "com.yourapp.sync",
            using: nil
        ) { task in
            guard let task = task as? BGProcessingTask else { return }
            handleSync(task: task)
        }
    }
}

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

User permission

Confirm the input and environment

Preserve the failure and return to this step

Register capability

Inspect the intermediate artifact

Preserve the failure and return to this step

Schedule work

Run the focused check

Preserve the failure and return to this step

Handle expiration

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

A source review cannot prove that the operating system will schedule a task at a specific time.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Deep links and universal links, and using them for automated screenshots

---

# Deep links and universal links, and using them for…
https://nagarjuna2997.github.io/ios-agent-skill/series/3.9.html

← Guided series
 · 
All articlesLesson 3.9 · Guided learningDeep links and universal links, and using them for automated screenshotsOn this page

Work through the example
Implementation reference

Routing, Deep Links, and State Restoration

Acceptance and failure review
Evidence and limits

A deep link is a reproducible route to app state only when inputs are validated and test data exists. Keep routing separate from parsing and from data loading.

Incoming URL → Validate fields → Resolve route → Render state1
Incoming URL
↓2
Validate fields
↓3
Resolve route
↓4
Render state

Work through the example

Use a synthetic identifier and an explicit not-found state. Do not place account tokens in screenshot URLs.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

Routing, Deep Links, and State Restoration

Load this when:
 adding navigation to more than two screens, handling a URL
scheme or universal link, restoring navigation state across launches, or
reviewing anything that mutates a 
NavigationPath
.

docs/swiftui/navigation.md
 covers the navigation 
APIs
.

patterns/coordinator.md
 covers the coordinator 
pattern
.
This document covers the piece that breaks in production: making a single typed
route model the only way navigation state changes, so a deep link, a button tap,
and a restored session all go through the same code.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Incoming URL

Confirm the input and environment

Preserve the failure and return to this step

Validate fields

Inspect the intermediate artifact

Preserve the failure and return to this step

Resolve route

Run the focused check

Preserve the failure and return to this step

Render state

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

Custom URL routing and universal-link association are different checks; one does not validate the other.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Localization with an agent: what to check before it "translates" everything

---

# App icons in 2026: Liquid Glass, Icon Composer, and…
https://nagarjuna2997.github.io/ios-agent-skill/series/4.1.html

← Guided series
 · 
All articlesLesson 4.1 · Guided learningApp icons in 2026: Liquid Glass, Icon Composer, and generating them from SVG layersOn this page

Work through the example
Implementation reference

Pattern
Make the layer plan first
Compose and annotate
Integrate with the app
What an agent must report

Acceptance and failure review
Evidence and limits

Editable icon layers and a flattened PNG solve different needs. Keep the layers as source, inspect the final silhouette at small sizes and validate the catalog consumed by the app.

Layer sources → Composite preview → Catalog asset → Device inspection1
Layer sources
↓2
Composite preview
↓3
Catalog asset
↓4
Device inspection

Work through the example

Separate a generated placeholder from a release icon. Review transparency, safe composition and the installed toolchain's accepted format.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

Pattern

Make the layer plan first

Define the app’s recognizable symbol and a single visual idea. Separate the background, supporting shape, primary symbol, and optional accent. Use semantic names and stable ordering. For example, a reading app can use a background fill, a book silhouette, a page mark, and a small bookmark accent; each foreground shape remains editable independently.

Prefer clean vector foregrounds. Use SVG or transparent PNG assets for import, exported on the same canvas so positions remain aligned. Keep text outlined. For raster art, retain transparent backgrounds around foreground artwork. Keep imported background art opaque and full bleed. Do not rasterize all pieces into one image.

A useful project handoff contains:

IconLayers/
  01-base.svg
  02-symbol.svg
  03-accent.svg
  manifest.json
  README.md

These filenames are a recommended project convention, not an Apple file-format requirement. The CLI’s 
--xcodegen
 starter creates editable layers as a starting point; replace their shapes and colors for the actual brand.

Compose and annotate

Launch Icon Composer from Xcode’s developer tools menu or the standalone app. Start a new document, choose the supported platforms, and set its background fill. Import foreground artwork, organize it into no more than four groups, and arrange depth from back to front. Use its material controls for highlights, refraction, translucency, and shadow rather than painting those effects into every asset.

Tune Default, Dark, and Mono appearances. Inspect at small sizes and against different surrounding backgrounds. Keep the silhouette recognizable when decorative detail disappears. Save the native 
.icon
 document, reopen it, and check that its layers and appearance settings remain editable.

Integrate with the app

Add the saved icon document to the Xcode project and associate it with the app target’s icon setting. Build with the actual SDK and inspect the installed icon. Use Apple’s asset-catalog image-stack workflow for platforms whose icon format differs, including tvOS and visionOS; do not assume the same Composer workflow applies to every platform.

Export flattened images only for marketing, previews, or compatibility workflows that specifically require them. Retain the editable source and native document alongside those exports.

What an agent must report

List the artwork files, layer ordering, appearance variants checked, native document path if created, and Xcode verification performed. If Icon Composer is unavailable, deliver the SVG/PNG layer pack plus import instructions and explicitly leave native 
.icon
 verification pending. Do not invent an undocumented 
.icon
 schema or rename a JSON file to make it appear native.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Layer sources

Confirm the input and environment

Preserve the failure and return to this step

Composite preview

Inspect the intermediate artifact

Preserve the failure and return to this step

Catalog asset

Run the focused check

Preserve the failure and return to this step

Device inspection

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

A PNG export is not automatically a native Icon Composer bundle or App Review approval.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Color systems that survive dark mode and high contrast, generated from tokens

---

# Color systems that survive dark mode and high contrast,…
https://nagarjuna2997.github.io/ios-agent-skill/series/4.2.html

← Guided series
 · 
All articlesLesson 4.2 · Guided learningColor systems that survive dark mode and high contrast, generated from tokensOn this page

Work through the example
Implementation reference

Evidence and limits

Acceptance and failure review
Evidence and limits

A token transform can be deterministic even when color choices need human judgment. Supply explicit light, dark and high-contrast values and inspect the generated catalog before merging it.

Explicit tokens → Validate names → Generate catalog → Compile and inspect1
Explicit tokens
↓2
Validate names
↓3
Generate catalog
↓4
Compile and inspect

Work through the example

Use a new output directory because generation refuses to overwrite an existing catalog. Compare all four appearances.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

Evidence and limits

The CLI tests verify schema rejection, appearance slots, alpha conversion,
non-overwrite behavior, ordered raster pixels, RGB output and 1024×1024 dimensions.
On macOS, generated colors and the iOS app-icon set were compiled using 
xcrun
actool
 against the installed simulator SDK. This does not verify a native 
.icon
,
a full screenshot capture pipeline, symbol availability or visual accessibility.

Catalog format: 
Apple named colors

and 
appearance variants
.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Explicit tokens

Confirm the input and environment

Preserve the failure and return to this step

Validate names

Inspect the intermediate artifact

Preserve the failure and return to this step

Generate catalog

Run the focused check

Preserve the failure and return to this step

Compile and inspect

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

The existing asset walkthrough includes actool evidence; it does not establish contrast in every screen.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Typography in SwiftUI that respects Dynamic Type: rules an agent can follow

---

# Typography in SwiftUI that respects Dynamic Type: rules…
https://nagarjuna2997.github.io/ios-agent-skill/series/4.3.html

← Guided series
 · 
All articlesLesson 4.3 · Guided learningTypography in SwiftUI that respects Dynamic Type: rules an agent can followOn this page

Work through the example
Implementation reference

5. Dynamic Type Support
@ScaledMetric
minimumScaleFactor

Acceptance and failure review
Evidence and limits

Dynamic Type is a layout requirement as much as a font choice. A scalable font inside a fixed-height container can still clip at accessibility sizes.

Semantic text style → Scaled content → Flexible layout → Largest-size check1
Semantic text style
↓2
Scaled content
↓3
Flexible layout
↓4
Largest-size check

Work through the example

Test the longest label at the largest supported text setting. Prefer wrapping or a changed layout to shrinking meaningful text.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

5. Dynamic Type Support

@ScaledMetric

Scale arbitrary numeric values proportionally to the user's Dynamic Type setting.

struct ScaledMetricDemo: View {
    @ScaledMetric(relativeTo: .title) var iconSize: CGFloat = 28
    @ScaledMetric(relativeTo: .body) var spacing: CGFloat = 12
    @ScaledMetric(relativeTo: .body) var cardPadding: CGFloat = 16

    var body: some View {
        HStack(spacing: spacing) {
            Image(systemName: "person.circle.fill")
                .font(.system(size: iconSize))
                .foregroundStyle(.blue)

            VStack(alignment: .leading, spacing: 4) {
                Text("John Appleseed")
                    .font(.headline)
                Text("iOS Developer")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }
        }
        .padding(cardPadding)
        .background(Color(.secondarySystemBackground))
        .cornerRadius(16)
    }
}

minimumScaleFactor

Prevent text from being clipped while still supporting Dynamic Type.

struct ScaleFactorDemo: View {
    var body: some View {
        Text("This very long title will shrink instead of truncating")
            .font(.title)
            .minimumScaleFactor(0.5)
            .lineLimit(1)
            .padding()
    }
}

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Semantic text style

Confirm the input and environment

Preserve the failure and return to this step

Scaled content

Inspect the intermediate artifact

Preserve the failure and return to this step

Flexible layout

Run the focused check

Preserve the failure and return to this step

Largest-size check

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

Source examples are guidance; screenshots and VoiceOver checks must come from the actual app.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Motion and haptics: what to ask an agent for, and what reduced-motion users need

---

# Motion and haptics: what to ask an agent for, and what…
https://nagarjuna2997.github.io/ios-agent-skill/series/4.4.html

← Guided series
 · 
All articlesLesson 4.4 · Guided learningMotion and haptics: what to ask an agent for, and what reduced-motion users needOn this page

Work through the example
Implementation reference

1. Animation Standards
Default Curves and Durations
When to Use Which Animation
Transition Standards

Acceptance and failure review
Evidence and limits

Motion should explain a state change, and haptics should reinforce a meaningful event. Reduced-motion settings require a deliberate alternative rather than disabling unrelated feedback.

User action → State change → Motion preference → Appropriate feedback1
User action
↓2
State change
↓3
Motion preference
↓4
Appropriate feedback

Work through the example

Compare the same interaction with reduced motion enabled. Preserve the result and focus order even if the transition changes.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

1. Animation Standards

Default Curves and Durations

Category

Duration

Curve

Usage

Micro interaction

0.2s

.easeOut

Toggles, button presses, icon changes

Navigation transition

0.35s

.spring(response: 0.35, dampingFraction: 0.85)

Push, pop, tab switches

Content loading

0.3s

.easeInOut

Skeleton to content, fade-in

Dismissal

0.25s

.easeIn

Sheet dismiss, alert close, toast exit

Bouncy spring

--

.bouncy

Playful UI: reactions, badges, celebrations

Snappy spring

--

.snappy

Responsive controls: sliders, toggles

Smooth spring

--

.smooth

Elegant reveals: cards, overlays

When to Use Which Animation

Use Case

Animation

Rationale

Button tap feedback

.easeOut, 0.2s

Quick acknowledgment, no lingering

Toggle switch

.snappy

Responsive mechanical feel

Card expand/collapse

.spring(response: 0.35, dampingFraction: 0.85)

Natural, physical motion

Pull-to-refresh

.bouncy

Playful rubber-band feel

Modal presentation

.smooth

Elegant, unhurried entrance

Error shake

.default.repeatCount(3)

Attention-grabbing without being jarring

Skeleton shimmer

.easeInOut, 1.2s, repeat

Smooth continuous loop

Item deletion

.easeIn, 0.25s

Quick exit, attention moves forward

List reorder

.snappy

Keeps up with the finger

Hero transition

matchedGeometryEffect

Spatial continuity between screens

Transition Standards

// MARK: - Sheet Presentation (use system default)
.sheet(isPresented: $showSettings) {
    SettingsView()
}

// MARK: - Full Screen Cover with Custom Transition
.fullScreenCover(isPresented: $showOnboarding) {
    OnboardingView()
        .transition(.opacity.combined(with: .move(edge: .bottom)))
}

// MARK: - Navigation Push (system default)
NavigationStack {
    List(items) { item in
        NavigationLink(value: item) {
            ItemRow(item: item)
        }
    }
    .navigationDestination(for: Item.self) { item in
        ItemDetailView(item: item)
    }
}

// MARK: - Hero Transition with matchedGeometryEffect
struct HeroTransitionExample: View {
    @Namespace private var heroNamespace
    @State private var isExpanded = false

    var body: some View {
        if isExpanded {
            DetailCard(namespace: heroNamespace)
                .onTapGesture {
                    withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
                        isExpanded = false
                    }
                }
        } else {
            ThumbnailCard(namespace: heroNamespace)
                .onTapGesture {
                    withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
                        isExpanded = true
                    }
                }
        }
    }
}

struct ThumbnailCard: View {
    var namespace: Namespace.ID

    var body: some View {
        RoundedRectangle(cornerRadius: 12)
            .fill(.blue.gradient)
            .matchedGeometryEffect(id: "card", in: namespace)
            .frame(width: 120, height: 120)
            .overlay {
                Text("Tap")
                    .matchedGeometryEffect(id: "title", in: namespace)
            }
    }
}

struct DetailCard: View {
    var namespace: Namespace.ID

    var body: some View {
        RoundedRectangle(cornerRadius: 24)
            .fill(.blue.gradient)
            .matchedGeometryEffect(id: "card", in: namespace)
            .frame(maxWidth: .infinity, maxHeight: 400)
            .overlay {
                Text("Detail View")
                    .matchedGeometryEffect(id: "title", in: namespace)
            }
            .padding()
    }
}

// MARK: - Custom Asymmetric Transition
extension AnyTransition {
    static var slideAndFade: AnyTransition {
        .asymmetric(
            insertion: .move(edge: .trailing).combined(with: .opacity),
            removal: .move(edge: .leading).combined(with: .opacity)
        )
    }
}

// Usage:
struct CustomTransitionExample: View {
    @State private var showContent = false

    var body: some View {
        VStack {
            if showContent {
                ContentView()
                    .transition(.slideAndFade)
            }
            Button("Toggle") {
                withAnimation(.easeInOut(duration: 0.3)) {
                    showContent.toggle()
                }
            }
        }
    }
}

// MARK: - Phased Animation for Multi-Step Effects
struct PhasedAnimationExample: View {
    @State private var trigger = false

    var body: some View {
        Image(systemName: "bell.fill")
            .font(.system(size: 32))
            .phaseAnimator([false, true], trigger: trigger) { content, phase in
                content
                    .scaleEffect(phase ? 1.2 : 1.0)
                    .rotationEffect(.degrees(phase ? 15 : 0))
            } animation: { phase in
                phase ? .bouncy : .snappy
            }
            .onTapGesture { trigger.toggle() }
    }
}

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

User action

Confirm the input and environment

Preserve the failure and return to this step

State change

Inspect the intermediate artifact

Preserve the failure and return to this step

Motion preference

Run the focused check

Preserve the failure and return to this step

Appropriate feedback

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

No physical-device haptic evaluation is included in the simulator baseline.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Making an agent-built app look intentional, not templated

---

# Making an agent-built app look intentional, not templated
https://nagarjuna2997.github.io/ios-agent-skill/series/4.5.html

← Guided series
 · 
All articlesLesson 4.5 · Guided learningMaking an agent-built app look intentional, not templatedOn this page

Work through the example
Implementation reference

Review Order

Acceptance and failure review
Evidence and limits

An intentional interface has a clear hierarchy, stable spacing and coherent states. Adding gradients to an unresolved flow makes it decorated rather than understandable.

Task hierarchy → Content and controls → Tokens and spacing → State review1
Task hierarchy
↓2
Content and controls
↓3
Tokens and spacing
↓4
State review

Work through the example

Choose one primary action per screen and explain where secondary actions belong. Compare empty and content states together.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

Review Order

Primary task: can the user see what to do in two seconds?
Hierarchy: do size, weight, color, and position match importance?
Layout: are alignment, rhythm, gutters, and touch targets consistent?
Type: semantic styles first; fixed sizes only with a clear reason.
State: loading, empty, error, success, offline, disabled.
Adaptation: compact/regular widths, iPad resizability, keyboard, pointer.
Accessibility: VoiceOver labels, focus order, Dynamic Type, contrast, Reduce Motion.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Task hierarchy

Confirm the input and environment

Preserve the failure and return to this step

Content and controls

Inspect the intermediate artifact

Preserve the failure and return to this step

Tokens and spacing

Run the focused check

Preserve the failure and return to this step

State review

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

The pattern library is a starting point, not evidence that a particular redesign improved usability.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Preparing repeatable simulator captures for App Store screenshots

---

# Preparing repeatable simulator captures for App Store…
https://nagarjuna2997.github.io/ios-agent-skill/series/4.6.html

← Guided series
 · 
All articlesLesson 4.6 · Guided learningPreparing repeatable simulator captures for App Store screenshotsOn this page

Work through the example
Implementation reference

Artifact Naming

Acceptance and failure review
Evidence and limits

Screenshot delivery needs a repeatable state, device destination and naming scheme. An attractive image at the wrong size or with synthetic content presented as real is not a submission-ready asset.

Select device → Seed safe content → Navigate state → Capture and inspect1
Select device
↓2
Seed safe content
↓3
Navigate state
↓4
Capture and inspect

Work through the example

Record the runtime and dimensions for each output. Verify current App Store display requirements before exporting release media.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

Artifact Naming

Use stable paths so iterations can be compared:

artifacts/visual/home-pass-01-compact.png
artifacts/visual/home-pass-01-accessibility-xxl.png
artifacts/visual/home-pass-02-compact.png
artifacts/visual/home-pass-02.mov

When visual diffing is added, compare the same route, device, appearance, locale, and content-size category. A screenshot from a different simulator configuration is not a clean comparison.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Select device

Confirm the input and environment

Preserve the failure and return to this step

Seed safe content

Inspect the intermediate artifact

Preserve the failure and return to this step

Navigate state

Run the focused check

Preserve the failure and return to this step

Capture and inspect

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

The baseline screenshot is demonstration evidence, not a complete App Store screenshot set.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: From Figma to SwiftUI with an agent: variables to tokens to code

---

# From Figma to SwiftUI with an agent: variables to tokens…
https://nagarjuna2997.github.io/ios-agent-skill/series/4.7.html

← Guided series
 · 
All articlesLesson 4.7 · Guided learningFrom Figma to SwiftUI with an agent: variables to tokens to codeOn this page

Work through the example
Implementation reference

Optional Figma handoff

Acceptance and failure review
Evidence and limits

A design handoff works best when variable meaning survives the transfer. Map design variables to semantic tokens, validate the token schema and inspect the app instead of copying raw colors everywhere.

Design variables → Semantic token mapping → Asset catalog → SwiftUI usage1
Design variables
↓2
Semantic token mapping
↓3
Asset catalog
↓4
SwiftUI usage

Work through the example

Document one variable's role and four appearances. Resolve missing variants explicitly rather than guessing a dark conversion.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

Optional Figma handoff

Use Figma's own integration to read variables if you already use it. Map semantic
color names and light/dark/high-contrast modes to the JSON fields above. Resolve
aliases to explicit sRGB hex values first. The same JSON can be authored by hand;
Figma and its MCP are optional. There is no maintained Figma parser here.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Design variables

Confirm the input and environment

Preserve the failure and return to this step

Semantic token mapping

Inspect the intermediate artifact

Preserve the failure and return to this step

Asset catalog

Run the focused check

Preserve the failure and return to this step

SwiftUI usage

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

No live Figma import session was run; this is the supported handoff contract, not a custom parser.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Swift Testing vs XCTest: what to have the agent generate in 2026

---

# Swift Testing vs XCTest: what to have the agent generate…
https://nagarjuna2997.github.io/ios-agent-skill/series/5.1.html

← Guided series
 · 
All articlesLesson 5.1 · Guided learningSwift Testing vs XCTest: what to have the agent generate in 2026On this page

Work through the example
Implementation reference

Unit Testing ViewModels with Mocks

Acceptance and failure review
Choose a test layer before a framework
Evidence and limits

Choose test tools by the layer under test. Pure model behavior and UI automation have different lifecycles; migrating names alone does not improve the assertions.

Behavior contract → Test layer → Controlled dependency → Assertion1
Behavior contract
↓2
Test layer
↓3
Controlled dependency
↓4
Assertion

Work through the example

Keep existing XCTest coverage while considering Swift Testing for suitable tests. Do not replace UI automation with a model-level assertion.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

Unit Testing ViewModels with Mocks

[ ] Define protocols for all dependencies (repository, service, API client)
[ ] Create mock implementations that allow stubbing return values and tracking calls
[ ] Test each ViewModel method in isolation
[ ] Verify state changes (loading, error, data) after each action
[ ] Test edge cases: empty data, nil values, concurrent calls

// MARK: - Mock Repository

final class MockArticleRepository: ArticleRepositoryProtocol, @unchecked Sendable {
    var stubbedArticles: [Article] = []
    var shouldFail = false
    var createCallCount = 0
    var lastCreatedArticle: Article?

    func getAll() async throws -> [Article] {
        if shouldFail { throw RepositoryError.offline }
        return stubbedArticles
    }

    func getById(_ id: String) async throws -> Article? {
        if shouldFail { throw RepositoryError.notFound }
        return stubbedArticles.first { $0.id == id }
    }

    func create(_ entity: Article) async throws -> Article {
        if shouldFail { throw RepositoryError.invalidResponse }
        createCallCount += 1
        lastCreatedArticle = entity
        stubbedArticles.append(entity)
        return entity
    }

    func update(_ entity: Article) async throws -> Article {
        if shouldFail { throw RepositoryError.invalidResponse }
        if let index = stubbedArticles.firstIndex(where: { $0.id == entity.id }) {
            stubbedArticles[index] = entity
        }
        return entity
    }

    func delete(_ id: String) async throws {
        if shouldFail { throw RepositoryError.notFound }
        stubbedArticles.removeAll { $0.id == id }
    }
}

// MARK: - ViewModel Tests

@Suite("Create Article Flow")
struct CreateArticleViewModelTests {

    @Test("creates article and resets form")
    func createSuccess() async {
        let mock = MockArticleRepository()
        let vm = CreateArticleViewModel(repository: mock)
        vm.title = "New Article"
        vm.body = "Content here"

        await vm.save()

        #expect(mock.createCallCount == 1)
        #expect(mock.lastCreatedArticle?.title == "New Article")
        #expect(vm.title.isEmpty) // form reset
        #expect(vm.isSaved)
    }

    @Test("shows validation error when title is empty")
    func validationError() async {
        let mock = MockArticleRepository()
        let vm = CreateArticleViewModel(repository: mock)
        vm.title = ""
        vm.body = "Content"

        await vm.save()

        #expect(mock.createCallCount == 0)
        #expect(vm.titleError == .emptyField(fieldName: "Title"))
    }
}

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Behavior contract

Confirm the input and environment

Preserve the failure and return to this step

Test layer

Inspect the intermediate artifact

Preserve the failure and return to this step

Controlled dependency

Run the focused check

Preserve the failure and return to this step

Assertion

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Choose a test layer before a framework

Apple explains that Swift Testing and XCTest can coexist in a project in its 
Swift Testing overview
. Keep UI automation separate from pure model assertions. A storage round-trip can be tested without tapping a screen; a save button’s interaction with the form needs a UI-level check.

The example mock above is source guidance, not a production concurrency guarantee. Its mutable 
@unchecked Sendable
 state requires externally controlled access; do not use that annotation to bypass races in parallel tests. Prefer isolation that matches the dependency contract and create independent fixtures per test.

Evidence and limits

Apple documents coexistence; no complete project migration is claimed here.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: UI tests an agent can write and maintain

---

# UI tests an agent can write and maintain
https://nagarjuna2997.github.io/ios-agent-skill/series/5.2.html

← Guided series
 · 
All articlesLesson 5.2 · Guided learningUI tests an agent can write and maintainOn this page

Work through the example
Implementation reference

Pattern

Acceptance and failure review
Evidence and limits
Recorded sample check

A maintainable UI test drives stable identifiers and waits for observable state. Arbitrary sleeps can hide timing assumptions while making the suite slower and less reliable.

Launch isolated state → Perform action → Wait for condition → Assert outcome1
Launch isolated state
↓2
Perform action
↓3
Wait for condition
↓4
Assert outcome

Work through the example

Give test data a separate store and ensure repeated runs do not depend on a previous test's items.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

Pattern

Stabilize UI tests with accessibility identifiers at meaningful boundaries:

Button("Continue") {
    viewModel.continueTapped()
}
.accessibilityIdentifier("onboarding.continue")

Then test the user path, not the view hierarchy:

import XCTest

final class OnboardingUITests: XCTestCase {
    func testContinueOpensHome() {
        let app = XCUIApplication()
        app.launchArguments = ["--ui-testing", "--mock-state=first-run"]
        app.launch()

        app.buttons["onboarding.continue"].tap()

        XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: 2))
    }
}

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Launch isolated state

Confirm the input and environment

Preserve the failure and return to this step

Perform action

Inspect the intermediate artifact

Preserve the failure and return to this step

Wait for condition

Run the focused check

Preserve the failure and return to this step

Assert outcome

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

The Reading List suite provides concrete UI coverage; it does not prove the absence of all flakes.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.

Recorded sample check

On Xcode 26.6 with an iOS 26.5 simulator, the Reading List verification script passed 3 unit tests and 2 UI tests. It exported six screenshots. This image is one of those outputs, not an AI-generated mockup. The sample uses JSON persistence and does not establish all-client app generation or a complete accessibility audit.

What to do next
Next: Accessibility review of an agent-built app: the checks that matter

---

# Accessibility review of an agent-built app: the checks…
https://nagarjuna2997.github.io/ios-agent-skill/series/5.3.html

← Guided series
 · 
All articlesLesson 5.3 · Guided learningAccessibility review of an agent-built app: the checks that matterOn this page

Work through the example
Implementation reference

VoiceOver Labels, Hints, Values, and Traits

Acceptance and failure review
Evidence and limits
Recorded sample check

Accessibility review combines semantics, navigation, contrast and adaptable layout. A screenshot can expose clipping but cannot tell you the full spoken experience.

Semantic labels → Navigation order → Adaptive layout → Device review1
Semantic labels
↓2
Navigation order
↓3
Adaptive layout
↓4
Device review

Work through the example

Check an empty state and an error recovery action with accessibility tooling. Keep identifiers for automation distinct from user-facing labels.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

VoiceOver Labels, Hints, Values, and Traits

import SwiftUI

struct AccessibleCardView: View {
    let title: String
    let rating: Double
    let isFavorite: Bool

    var body: some View {
        VStack {
            Image("product")
                .resizable()
                .aspectRatio(contentMode: .fit)
                // Label: concise description of what the element IS (read first)
                .accessibilityLabel("Product photo of \(title)")

            Text(title)

            HStack {
                ForEach(1...5, id: \.self) { star in
                    Image(systemName: star <= Int(rating) ? "star.fill" : "star")
                }
            }
            // Combine child elements into a single accessible element
            .accessibilityElement(children: .ignore)
            .accessibilityLabel("\(Int(rating)) out of 5 stars")
            // Value: current state of a dynamic element
            .accessibilityValue("\(rating, specifier: "%.1f") rating")

            Button(action: { /* toggle favorite */ }) {
                Image(systemName: isFavorite ? "heart.fill" : "heart")
            }
            .accessibilityLabel(isFavorite ? "Remove from favorites" : "Add to favorites")
            // Hint: describes what happens when the element is activated (read after a pause)
            .accessibilityHint("Double tap to \(isFavorite ? "remove from" : "add to") favorites")
            // Traits: describe the behavior and purpose of the element
            .accessibilityAddTraits(.isButton)
            .accessibilityRemoveTraits(.isImage)
        }
    }
}

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Semantic labels

Confirm the input and environment

Preserve the failure and return to this step

Navigation order

Inspect the intermediate artifact

Preserve the failure and return to this step

Adaptive layout

Run the focused check

Preserve the failure and return to this step

Device review

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

A successful UI test is not a complete VoiceOver audit.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.

Recorded sample check

On Xcode 26.6 with an iOS 26.5 simulator, the Reading List verification script passed 3 unit tests and 2 UI tests. It exported six screenshots. This image is one of those outputs, not an AI-generated mockup. The sample uses JSON persistence and does not establish all-client app generation or a complete accessibility audit.

What to do next
Next: Performance: hangs, launch time and the patterns agents introduce

---

# Performance: hangs, launch time and the patterns agents…
https://nagarjuna2997.github.io/ios-agent-skill/series/5.4.html

← Guided series
 · 
All articlesLesson 5.4 · Guided learningPerformance: hangs, launch time and the patterns agents introduceOn this page

Work through the example
Implementation reference

Instruments Profiling
Time Profiler
Allocations
Leaks
Network (Instruments)
Animation Hitches (Core Animation / RenderServer)

Acceptance and failure review
Evidence and limits

Performance investigation starts with a reproducible symptom and a representative workload. Static patterns point to possible problems; runtime measurements establish whether the change helps.

Reproduce workload → Record baseline → Change one cause → Compare trace1
Reproduce workload
↓2
Record baseline
↓3
Change one cause
↓4
Compare trace

Work through the example

Separate cold launch, warm launch and interaction responsiveness. Record device conditions alongside measurements.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

Instruments Profiling

Time Profiler

[ ] Profile the app with Time Profiler to find CPU hot spots
[ ] Look for methods consuming >10% of total CPU time
[ ] Check for work on the main thread that should be on a background queue
[ ] Verify no synchronous I/O (file reads, database queries) on the main thread
[ ] Identify redundant or repeated calculations that can be cached

How to run: Xcode > Product > Profile > Time Profiler
Focus: Check "Hide System Libraries" to see only your code
Tip:  Use Call Tree > Invert Call Tree to find leaf functions consuming the most time

Allocations

[ ] Profile with Allocations to track memory growth over time
[ ] Check for unbounded memory growth during scrolling or navigation
[ ] Identify transient allocations that could be reduced (e.g., creating objects in tight loops)
[ ] Look for large allocations (images, data buffers) that are not released

How to run: Xcode > Product > Profile > Allocations
Focus: Mark Generation snapshots before and after user flows to isolate growth
Tip:  Filter by "Persistent" to find objects that never get deallocated

Leaks

[ ] Run Leaks instrument to detect retain cycles
[ ] Investigate any leaked objects (especially closures capturing 
self
)
[ ] Verify delegates are declared 
weak
[ ] Verify closures use 
[weak self]
 when capturing view models or coordinators
[ ] Check Combine subscriptions are stored and cancelled properly

Network (Instruments)

[ ] Profile network calls with the Network instrument
[ ] Identify redundant or duplicate API requests
[ ] Check for requests that could be batched or deduplicated
[ ] Verify responses are cached appropriately (HTTP cache headers)

Animation Hitches (Core Animation / RenderServer)

[ ] Profile with the Animation Hitches instrument
[ ] Target zero commit hitches (frame drops during layout/render)
[ ] Target zero render hitches (GPU cannot complete frame in time)
[ ] Investigate any hitch duration > 8ms on 120Hz devices

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Reproduce workload

Confirm the input and environment

Preserve the failure and return to this step

Record baseline

Inspect the intermediate artifact

Preserve the failure and return to this step

Change one cause

Run the focused check

Preserve the failure and return to this step

Compare trace

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

No invented launch-time or hang-rate numbers are supplied.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Memory: retain cycles agents create in closures and Combine

---

# Memory: retain cycles agents create in closures and Combine
https://nagarjuna2997.github.io/ios-agent-skill/series/5.5.html

← Guided series
 · 
All articlesLesson 5.5 · Guided learningMemory: retain cycles agents create in closures and CombineOn this page

Work through the example
Implementation reference

Common retain-cycle shapes

Acceptance and failure review
Evidence and limits

A retain cycle is an ownership graph that cannot release itself. Weak references are useful only when they match the desired lifetime; adding weak everywhere can hide missing ownership.

Owner → Stored closure or subscription → Captured owner → Break intended edge1
Owner
↓2
Stored closure or subscription
↓3
Captured owner
↓4
Break intended edge

Work through the example

Draw the ownership graph before changing a capture list. Confirm deallocation after the screen or task ends.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

Common retain-cycle shapes

Delegate cycles:

protocol PlayerCoordinatorDelegate: AnyObject {
    func playerDidFinish()
}

final class PlayerCoordinator {
    weak var delegate: PlayerCoordinatorDelegate?
}

Closure cycles:

final class Loader {
    private var onComplete: (() -> Void)?

    func start() {
        onComplete = { [weak self] in
            self?.finish()
        }
    }

    private func finish() {}
}

Timer cycles:

final class Poller {
    private var timer: Timer?

    func start() {
        timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
            self?.tick()
        }
    }

    func stop() {
        timer?.invalidate()
        timer = nil
    }

    deinit {
        timer?.invalidate()
    }

    private func tick() {}
}

Combine cycles:

final class SearchModel {
    private var cancellables = Set<AnyCancellable>()

    func bind(_ publisher: AnyPublisher<String, Never>) {
        publisher
            .sink { [weak self] query in
                self?.runSearch(query)
            }
            .store(in: &cancellables)
    }

    private func runSearch(_ query: String) {}
}

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Owner

Confirm the input and environment

Preserve the failure and return to this step

Stored closure or subscription

Inspect the intermediate artifact

Preserve the failure and return to this step

Captured owner

Run the focused check

Preserve the failure and return to this step

Break intended edge

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

Static review findings require runtime confirmation when lifetime depends on navigation or cancellation.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Security review of an agent-built app: keychain, ATS, secrets in source

---

# Security review of an agent-built app: keychain, ATS,…
https://nagarjuna2997.github.io/ios-agent-skill/series/5.6.html

← Guided series
 · 
All articlesLesson 5.6 · Guided learningSecurity review of an agent-built app: keychain, ATS, secrets in sourceOn this page

Work through the example
Implementation reference

Keychain for Sensitive Data

Acceptance and failure review
Evidence and limits

Security review follows data through storage, transport and diagnostics. A secret kept out of source can still leak through logs, screenshots or an overly broad tool connection.

Sensitive input → Storage boundary → Network boundary → Redacted diagnostics1
Sensitive input
↓2
Storage boundary
↓3
Network boundary
↓4
Redacted diagnostics

Work through the example

Use synthetic credentials when testing failures. Check what leaves the device and who can read stored values.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

Keychain for Sensitive Data

[ ] Store authentication tokens in Keychain, never in 
UserDefaults
 or files
[ ] Store API keys and secrets in Keychain (or better, fetch from server at runtime)
[ ] Set appropriate Keychain accessibility level for each item
[ ] Use 
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
 for tokens needed in background
[ ] Use 
kSecAttrAccessibleWhenUnlockedThisDeviceOnly
 for highly sensitive data
[ ] Never use 
kSecAttrAccessibleAlways
 (deprecated and insecure)
[ ] Set 
kSecAttrAccessControl
 with biometric requirement for high-value secrets
[ ] Delete Keychain items on user logout

import Security

enum KeychainHelper {

    static func save(
        key: String,
        data: Data,
        accessibility: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
    ) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecValueData as String: data,
            kSecAttrAccessible as String: accessibility
        ]

        // Delete any existing item first
        SecItemDelete(query as CFDictionary)

        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw KeychainError.saveFailed(status: status)
        }
    }

    static func load(key: String) throws -> Data? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]

        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)

        switch status {
        case errSecSuccess:
            return result as? Data
        case errSecItemNotFound:
            return nil
        default:
            throw KeychainError.loadFailed(status: status)
        }
    }

    static func delete(key: String) {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key
        ]
        SecItemDelete(query as CFDictionary)
    }
}

enum KeychainError: Error {
    case saveFailed(status: OSStatus)
    case loadFailed(status: OSStatus)
}

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Sensitive input

Confirm the input and environment

Preserve the failure and return to this step

Storage boundary

Inspect the intermediate artifact

Preserve the failure and return to this step

Network boundary

Run the focused check

Preserve the failure and return to this step

Redacted diagnostics

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

This checklist is not a penetration test or a guarantee of security.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Static review vs the compiler: what heuristics catch and what they miss

---

# Static review vs the compiler: what heuristics catch and…
https://nagarjuna2997.github.io/ios-agent-skill/series/5.7.html

← Guided series
 · 
All articlesLesson 5.7 · Guided learningStatic review vs the compiler: what heuristics catch and what they missOn this page

Work through the example
Implementation reference

2. Every claim carries a label

Acceptance and failure review
Evidence and limits

A heuristic can flag a suspicious pattern without proving a compiler error, and a clean compile can leave behavior wrong. Treat each result as evidence for a specific question.

Static finding → Inspect context → Compile → Exercise behavior1
Static finding
↓2
Inspect context
↓3
Compile
↓4
Exercise behavior

Work through the example

Keep a real finding and a false-positive fixture beside a rule. Explain which context the scanner cannot model.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

2. Every claim carries a label

Each factual claim in a report falls into exactly one bucket, and the report
says which:

Label

Means

Requires

VERIFIED

A command was run; this is its real output

The command and its output, verbatim

INSPECTED

The code was read and reasoned about

The file:line that was read

UNVERIFIED

Could not be checked

The reason (no scheme, no simulator, no network)

A report with zero VERIFIED claims and no explanation of why is a failed report,
regardless of how confident it sounds.

UNVERIFIED is a legitimate, useful result.
 "I could not build this — there is
no Xcode on this machine, so the isolation fix is INSPECTED only" is honest and
actionable. Quietly implying it built is not.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Static finding

Confirm the input and environment

Preserve the failure and return to this step

Inspect context

Inspect the intermediate artifact

Preserve the failure and return to this step

Compile

Run the focused check

Preserve the failure and return to this step

Exercise behavior

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

Do not count a suppressed warning as a repaired defect.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: App Store readiness: the checks before you upload an agent-built app

---

# App Store readiness: the checks before you upload an…
https://nagarjuna2997.github.io/ios-agent-skill/series/6.1.html

← Guided series
 · 
All articlesLesson 6.1 · Guided learningApp Store readiness: the checks before you upload an agent-built appOn this page

Work through the example
Implementation reference

Privacy

Acceptance and failure review
Evidence and limits

Release readiness joins product behavior, metadata, permissions and distribution configuration. A local review can organize checks, but only the real submission process establishes acceptance.

Working build → Privacy and metadata → Signed archive → Submission review1
Working build
↓2
Privacy and metadata
↓3
Signed archive
↓4
Submission review

Work through the example

Create an evidence checklist with owners and unresolved items. Keep unsupported or untested capabilities out of release claims.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

Privacy

[ ] Privacy policy URL provided (required for all apps)
[ ] Privacy policy is accessible and clearly written
[ ] App Privacy labels configured in App Store Connect (Data Types questionnaire)
[ ] Each data type categorized correctly (collected vs. tracked vs. linked)
[ ] App Tracking Transparency (ATT) prompt implemented if tracking users across apps
[ ] ATT prompt shown before any tracking begins
[ ] 
NSUserTrackingUsageDescription
 added to Info.plist if using ATT
[ ] Purpose strings (usage descriptions) provided for all permission requests:
[ ] 
NSCameraUsageDescription
[ ] 
NSPhotoLibraryUsageDescription
[ ] 
NSLocationWhenInUseUsageDescription
[ ] 
NSLocationAlwaysAndWhenInUseUsageDescription
 (if applicable)
[ ] 
NSMicrophoneUsageDescription
[ ] 
NSContactsUsageDescription
[ ] 
NSCalendarsUsageDescription
[ ] 
NSBluetoothAlwaysUsageDescription
[ ] 
NSFaceIDUsageDescription
[ ] 
NSHealthShareUsageDescription
 / 
NSHealthUpdateUsageDescription
[ ] Each purpose string clearly explains why the permission is needed (in user-friendly language)

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Working build

Confirm the input and environment

Preserve the failure and return to this step

Privacy and metadata

Inspect the intermediate artifact

Preserve the failure and return to this step

Signed archive

Run the focused check

Preserve the failure and return to this step

Submission review

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

No app was submitted to App Review for this series.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Privacy manifests, permissions strings and the things agents leave blank

---

# Privacy manifests, permissions strings and the things…
https://nagarjuna2997.github.io/ios-agent-skill/series/6.2.html

← Guided series
 · 
All articlesLesson 6.2 · Guided learningPrivacy manifests, permissions strings and the things agents leave blankOn this page

Work through the example
Implementation reference

Privacy

Acceptance and failure review
Evidence and limits

A privacy manifest and a permission-purpose string are different declarations. Derive them from actual behavior and dependencies rather than filling fields with generic text.

Inventory data use → Inspect SDK behavior → Declare reasons → Review release archive1
Inventory data use
↓2
Inspect SDK behavior
↓3
Declare reasons
↓4
Review release archive

Work through the example

Trace one permission to the feature that asks for it and the user-visible explanation. Recheck dependencies when they change.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

Privacy

[ ] Privacy policy URL provided (required for all apps)
[ ] Privacy policy is accessible and clearly written
[ ] App Privacy labels configured in App Store Connect (Data Types questionnaire)
[ ] Each data type categorized correctly (collected vs. tracked vs. linked)
[ ] App Tracking Transparency (ATT) prompt implemented if tracking users across apps
[ ] ATT prompt shown before any tracking begins
[ ] 
NSUserTrackingUsageDescription
 added to Info.plist if using ATT
[ ] Purpose strings (usage descriptions) provided for all permission requests:
[ ] 
NSCameraUsageDescription
[ ] 
NSPhotoLibraryUsageDescription
[ ] 
NSLocationWhenInUseUsageDescription
[ ] 
NSLocationAlwaysAndWhenInUseUsageDescription
 (if applicable)
[ ] 
NSMicrophoneUsageDescription
[ ] 
NSContactsUsageDescription
[ ] 
NSCalendarsUsageDescription
[ ] 
NSBluetoothAlwaysUsageDescription
[ ] 
NSFaceIDUsageDescription
[ ] 
NSHealthShareUsageDescription
 / 
NSHealthUpdateUsageDescription
[ ] Each purpose string clearly explains why the permission is needed (in user-friendly language)

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Inventory data use

Confirm the input and environment

Preserve the failure and return to this step

Inspect SDK behavior

Inspect the intermediate artifact

Preserve the failure and return to this step

Declare reasons

Run the focused check

Preserve the failure and return to this step

Review release archive

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

Current Apple requirements must be checked for the actual release; this is not legal advice or submission approval.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Preparing for TestFlight: signing, builds and account boundaries

---

# Preparing for TestFlight: signing, builds and account…
https://nagarjuna2997.github.io/ios-agent-skill/series/6.3.html

← Guided series
 · 
All articlesLesson 6.3 · Guided learningPreparing for TestFlight: signing, builds and account boundariesOn this page

Work through the example
Prepare the distribution handoff
Keep a release handoff record
Acceptance and failure review
Prepare without pretending to upload
Evidence and limits

TestFlight requires a real distribution workflow, account access and a signed build. An agent can help prepare artifacts, but a local simulator result does not establish signing or upload success.

Local acceptance → Distribution configuration → Upload build → Tester feedback1
Local acceptance
↓2
Distribution configuration
↓3
Upload build
↓4
Tester feedback

Work through the example

Separate account setup from code fixes. Record archive and upload outcomes without publishing signing material.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Prepare the distribution handoff

This is a preparation guide. It deliberately does not present an unperformed upload as an end-to-end TestFlight tutorial.

Record the intended app record, bundle identifier, version and build number. Make sure the identity belongs to the app you intend to distribute, rather than a sample identifier copied from a tutorial.
Confirm the account has the required program access and role for the distribution action. Keep sign-in in Apple's local or web interface; do not paste credentials into an agent transcript.
Run the app's acceptance suite before preparing a release artifact. Record unresolved failures separately from optional improvements.
In Xcode, inspect the intended target's signing and capability settings. A simulator build that disabled signing is useful development evidence, but it is not the distribution archive.
Use the distribution workflow supported by the installed Xcode and account. Inspect archive validation errors before retrying an upload; do not switch bundle identities simply to make an error disappear.
After an actual upload, confirm processing in App Store Connect, complete the required beta metadata, and select the intended testing audience. Internal and external testing have different review and access steps; consult the current Apple instructions for that audience.
Ask a tester to install the distributed build and repeat a small acceptance journey. A successful upload is not proof that the installed build starts correctly or uses the intended environment.

Keep a release handoff record

Field

What belongs here

Build identity

App identifier, version and build number

Source

Commit used for the archive

Validation

Commands and outcomes before distribution

Distribution

Actual processing or validation outcome, when performed

Tester check

Device/runtime, observed behavior and feedback

Unresolved items

Explicit blockers with an owner

Do not include signing private keys, provisioning files or account tokens in the public record. A private account operation cannot be reconstructed from a screenshot of a local simulator. The lack of distribution access is a legitimate blocker for the lab, while the preparation checklist remains useful.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Local acceptance

Confirm the input and environment

Preserve the failure and return to this step

Distribution configuration

Inspect the intermediate artifact

Preserve the failure and return to this step

Upload build

Run the focused check

Preserve the failure and return to this step

Tester feedback

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Prepare without pretending to upload

Apple’s 
TestFlight overview
 describes beta distribution. This lesson stops before account-specific distribution operations. Record the bundle identifier, intended audience and local acceptance results, then use the actual account’s distribution workflow. A simulator build with signing disabled cannot be uploaded as the release artifact.

Evidence and limits

No TestFlight upload is authorized or performed in this series; the end-to-end lab is blocked.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Investigating App Review feedback without guessing the cause

---

# Investigating App Review feedback without guessing the…
https://nagarjuna2997.github.io/ios-agent-skill/series/6.4.html

← Guided series
 · 
All articlesLesson 6.4 · Guided learningInvestigating App Review feedback without guessing the causeOn this page

Work through the example
Implementation reference

App Metadata

Acceptance and failure review
Evidence and limits

A useful rejection analysis quotes the actual reason and ties it to a reproducible app behavior. Avoid claiming that an app was rejected because it used AI unless the evidence says so.

Review message → Relevant behavior → Small correction → Resubmission evidence1
Review message
↓2
Relevant behavior
↓3
Small correction
↓4
Resubmission evidence

Work through the example

Use the current official guidelines to interpret a real case. Distinguish policy requirements from a maintainer's guess.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

App Metadata

[ ] App name finalized (30 character limit, no keyword stuffing)
[ ] Subtitle written (30 character limit, descriptive and compelling)
[ ] App description written (up to 4000 characters, most important info first)
[ ] Promotional text set (170 characters, can be updated without a new build)
[ ] Keywords optimized (100 character budget, comma-separated, no spaces after commas)
[ ] Primary and secondary categories selected
[ ] Support URL provided (must be a working webpage)
[ ] Marketing URL provided (optional but recommended)
[ ] Copyright field filled (e.g., "2026 Your Company Name")
[ ] Version number follows semantic versioning (e.g., 1.0.0)

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Review message

Confirm the input and environment

Preserve the failure and return to this step

Relevant behavior

Inspect the intermediate artifact

Preserve the failure and return to this step

Small correction

Run the focused check

Preserve the failure and return to this step

Resubmission evidence

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

No sourced rejection case collection has been assembled; no invented rejection stories are presented.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Versioning, changelogs and release notes the agent can draft

---

# Versioning, changelogs and release notes the agent can…
https://nagarjuna2997.github.io/ios-agent-skill/series/6.5.html

← Guided series
 · 
All articlesLesson 6.5 · Guided learningVersioning, changelogs and release notes the agent can draftOn this page

Work through the example
Implementation reference

[Unreleased]

Acceptance and failure review
Evidence and limits

Release notes should describe the behavior users receive, not every internal commit. Separate new capabilities, fixes and known limits, then verify that the package and source match the release.

Changes since tag → User-facing behavior → Version consistency → Release notes1
Changes since tag
↓2
User-facing behavior
↓3
Version consistency
↓4
Release notes

Work through the example

Compare the installed package version with the changelog before publishing. Batch compatible fixes rather than releasing every cosmetic edit.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

[Unreleased]

Source-only private feedback preview/approval tool and receiver for a private GitHub inbox. Explicit in-chat approval, fixed categories, private-destination checks and no public fallback. Hosting is not configured; no npm publication.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Changes since tag

Confirm the input and environment

Preserve the failure and return to this step

User-facing behavior

Inspect the intermediate artifact

Preserve the failure and return to this step

Version consistency

Run the focused check

Preserve the failure and return to this step

Release notes

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

This writing task does not authorize a new npm release.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: CI for an iOS repo with agent-written code: what to run on every PR

---

# CI for an iOS repo with agent-written code: what to run…
https://nagarjuna2997.github.io/ios-agent-skill/series/6.6.html

← Guided series
 · 
All articlesLesson 6.6 · Guided learningCI for an iOS repo with agent-written code: what to run on every PROn this page

Work through the example
Implementation reference

1. Hook vs. CI vs. reviewer

Acceptance and failure review
Evidence and limits

CI gives the repository an independent place to run checks. Local hooks improve feedback time, but they do not replace a clean runner using the committed files.

Checkout → Install pinned dependencies → Build and test → Report failures1
Checkout
↓2
Install pinned dependencies
↓3
Build and test
↓4
Report failures

Work through the example

Keep required checks reproducible and preserve nonzero exit codes through pipes. Run simulator jobs only where the runner supports them.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

1. Hook vs. CI vs. reviewer

Three enforcement layers. Use the cheapest one that can decide the question.

Layer

Decides

Cost

Feedback speed

Hook

Rules a script can evaluate

~free

Immediate — model self-corrects mid-turn

CI

Same, plus full build/test

minutes

After push

Reviewer subagent

Judgment: is this correct, does it match intent

tokens

End of task

The ordering matters. Spending a reviewer subagent on "did you use

DispatchQueue.main.async
" is waste — a grep answers that for free, instantly,
and cannot be argued with. Reserve model judgment for what rules cannot express.

Rule of thumb:
 if you can write the check as a grep or an exit code, it is a
hook. If it needs to understand intent, it is a reviewer.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Checkout

Confirm the input and environment

Preserve the failure and return to this step

Install pinned dependencies

Inspect the intermediate artifact

Preserve the failure and return to this step

Build and test

Run the focused check

Preserve the failure and return to this step

Report failures

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

A workflow file is not a successful run; inspect the actual job result.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Subagents for iOS: splitting review, build and design across agents

---

# Subagents for iOS: splitting review, build and design…
https://nagarjuna2997.github.io/ios-agent-skill/series/7.1.html

← Guided series
 · 
All articlesLesson 7.1 · Guided learningSubagents for iOS: splitting review, build and design across agentsOn this page

Work through the example
Implementation reference

4. Writing the delegation prompt

Acceptance and failure review
Evidence and limits

Delegation is useful when a task has an independent input and a reviewable output. More agents do not automatically mean more progress when they compete for the same files or simulator.

Bounded task → Independent execution → Evidence report → Main-agent integration1
Bounded task
↓2
Independent execution
↓3
Evidence report
↓4
Main-agent integration

Work through the example

Give the reviewer a diff and an acceptance question. Keep the main agent responsible for resolving disagreements.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

4. Writing the delegation prompt

A subagent starts cold. This is the failure mode that makes delegation look
useless: an under-specified prompt produces a confident, irrelevant report.

Every delegation prompt carries:

The goal
, as an outcome rather than an activity.
The context it cannot see
 — decisions already made, constraints, the
   relevant file paths you already found.
The boundary
 — what is explicitly out of scope.
The return format
 — what you need back, and in what shape.

### WEAK
"Look at the networking code."

### STRONG
"Find every place that constructs a URLRequest in Sources/, and report which
ones set an Authorization header and which do not.

Context: we are adding a shared auth interceptor and need to know what would
be duplicated. The APIClient at Sources/Data/APIClient.swift:44 is already
known — I need the ones outside it.

Out of scope: test targets, and anything under Vendor/.

Return: a table of file:line, the endpoint, and whether it sets auth."

The 
Out of scope
 line matters more than it looks. Without it, subagents
reliably expand the task.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Bounded task

Confirm the input and environment

Preserve the failure and return to this step

Independent execution

Inspect the intermediate artifact

Preserve the failure and return to this step

Evidence report

Run the focused check

Preserve the failure and return to this step

Main-agent integration

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

The repository's available roles are not proof that every role improves every task.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Coordinating parallel coding work with isolated checkouts

---

# Preparing to verify Xcode 27 agents with external skills
https://nagarjuna2997.github.io/ios-agent-skill/series/7.10.html

← Guided series
 · 
All articlesLesson 7.10 · Guided learningPreparing to verify Xcode 27 agents with external skillsOn this page

Work through the example
Implementation reference

3. The same verification contract applies

Acceptance and failure review
Evidence and limits

An editor-integrated agent still needs a reproducible build and a clear evidence boundary. Local MCP discovery in another terminal client does not establish integration inside Xcode.

Editor capability → Project access → Tool discovery → Real task check1
Editor capability
↓2
Project access
↓3
Tool discovery
↓4
Real task check

Work through the example

Document the selected Xcode version and the actual in-editor setup surface. Verify one small operation before claiming support.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

3. The same verification contract applies

An agent in Xcode is still an agent. Everything in

docs/orchestration/verification.md
 holds:

Never accept "done" without evidence.
 In Xcode the evidence is right
  there — the build result and the test navigator. Look at it.
The author does not grade the work.
 If the agent wrote the code 
and
 the
  test, both encode the same misunderstanding. Read the test yourself and ask
  whether it would fail against the old behavior.
A green build is not a passing feature.
 It compiles. That is one claim.

Agent says                          What you check
"Added tests, they pass."      →    Run them. Read them. Would they fail before?
"Fixed the layout issue."      →    Run it on the device size that broke.
"Localized to 8 languages."    →    Check pluralization and RTL, not just presence.
"Build succeeds."              →    True and insufficient.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Editor capability

Confirm the input and environment

Preserve the failure and return to this step

Project access

Inspect the intermediate artifact

Preserve the failure and return to this step

Tool discovery

Run the focused check

Preserve the failure and return to this step

Real task check

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

Xcode 27 is not installed on the test Mac; this integration lab is blocked.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Keeping up with Apple: refreshing references after each beta without breaking rules

---

# Keeping up with Apple: refreshing references after each…
https://nagarjuna2997.github.io/ios-agent-skill/series/7.11.html

← Guided series
 · 
All articlesLesson 7.11 · Guided learningKeeping up with Apple: refreshing references after each beta without breaking rulesOn this page

Work through the example
Implementation reference

Pattern
Where the complete code lives
Apple guidance and ownership

Acceptance and failure review
Evidence and limits

A reference refresh should preserve provenance and separate catalog coverage from compiled examples. New documentation can change guidance without proving that an implementation works on the new SDK.

Upstream change → Source comparison → Guide update → Compile affected sample1
Upstream change
↓2
Source comparison
↓3
Guide update
↓4
Compile affected sample

Work through the example

Track the source date and identify API claims that require the new toolchain. Keep old deployment targets in the verification matrix.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

Pattern

Use a clone for offline work after installation. Search runs locally with Node and does not call an AI or make network requests:

node scripts/query-library.mjs search "Persistence" source
node scripts/query-library.mjs outline docs/frameworks/swiftdata.md
node scripts/query-library.mjs read samples/SkillPatterns/Sources/SkillPatterns/Persistence.swift 0 6000

search
 returns up to eight file paths, titles and sizes, without file bodies. 
outline
 returns headings with character offsets. 
read
 returns exact content, a content hash and 
nextOffset
; continue from that offset until it is null when the complete source is needed. Offsets are JavaScript UTF-16 string indexes, not bytes. No excerpt is silently presented as a complete file.

The same library is bundled inside the knowledge MCP package:

Call 
search_local_references
 with a feature/API query and 
kind: "source"
 for reusable code, or 
kind: "guide"
 for explanations.
Use 
get_reference_outline
 to select a guide section.
Call 
read_local_reference
 with its exact returned path and offset. The default body budget is 6,000 characters; the maximum is 16,000 per call. Follow 
nextOffset
 only if necessary.
Read sibling package manifests, dependencies and tests before adapting a source file. Preserve error handling, actor isolation and availability requirements.
Build and test the resulting app with the installed SDK. A source file being indexed does not prove it builds independently.

get_apple_technology
 now defaults to an overview with local guide/source routes. Request 
view: "full"
 only when the entire guide and topic map are needed. The repository's Markdown examples also contain explicitly labelled wrong patterns; retain their surrounding explanation and do not automatically extract every fenced block as production Swift.

Where the complete code lives

samples/SkillPatterns/
: Swift package containing persistence, streams, routing, observation, composition and signal-processing implementations with tests.
samples/AppleRecipes/
: original Apple API implementations with their own manifest, tests and build evidence. See its README for exact platform coverage.
templates/ios-app/
: an app's screens, models, repositories and test starting points.
templates/common-patterns/
: editable networking, persistence, authentication, design and navigation source; adaptation and app-level testing are required.
cli/src/
: complete source of the app scaffolder.
mcp-server/src/
: complete source of the analysis and local knowledge servers.

See 
docs/apple/local-library.md
 for the generated file inventory. The index contains metadata only. Content has one canonical source file; the MCP bundle stores identical content once by SHA-256. Plugin archives carry those same canonical files so they work without fetching individual guides.

Apple guidance and ownership

Original code in this repository is covered by the repository's MIT license, subject to any file-specific notices. Apple documentation links in the guides are attribution and freshness references. Apple SDK binaries, internal source and proprietary manuals are not relicensed as part of this project. Use the installed SDK and the linked official guidance for availability or behavior that changed after the snapshot.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Upstream change

Confirm the input and environment

Preserve the failure and return to this step

Source comparison

Inspect the intermediate artifact

Preserve the failure and return to this step

Guide update

Run the focused check

Preserve the failure and return to this step

Compile affected sample

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

A successful index refresh is not a GA compatibility test.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Planning a safe Swift 6 and iOS 27 migration

---

# Planning a safe Swift 6 and iOS 27 migration
https://nagarjuna2997.github.io/ios-agent-skill/series/7.12.html

← Guided series
 · 
All articlesLesson 7.12 · Guided learningPlanning a safe Swift 6 and iOS 27 migrationOn this page

Work through the example
Implementation reference

Sendable Protocol

Acceptance and failure review
Evidence and limits

A migration is easier to diagnose when language-mode changes and feature changes are separate. Preserve behavior first, then fix isolated compiler failures with small checkpoints.

Baseline tests → Toolchain change → Scoped fixes → Same acceptance suite1
Baseline tests
↓2
Toolchain change
↓3
Scoped fixes
↓4
Same acceptance suite

Work through the example

Record the old and new build settings. Do not silence concurrency diagnostics by changing semantics without a test.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

Sendable Protocol

Sendable
 marks types that are safe to transfer across concurrency domains.

// Value types with Sendable fields are implicitly Sendable
struct UserDTO: Sendable {
    let id: String
    let name: String
    let email: String
}

// Classes must be final and have only immutable stored properties
final class Configuration: Sendable {
    let apiKey: String
    let baseURL: URL

    init(apiKey: String, baseURL: URL) {
        self.apiKey = apiKey
        self.baseURL = baseURL
    }
}

// @unchecked Sendable — when you manage thread safety manually
final class AtomicCounter: @unchecked Sendable {
    private let lock = NSLock()
    private var _value = 0

    var value: Int {
        lock.withLock { _value }
    }

    func increment() {
        lock.withLock { _value += 1 }
    }
}

// @Sendable closures
func performAsync(_ work: @Sendable @escaping () async -> Void) {
    Task {
        await work()
    }
}

// Common Sendable conformances
// - All value types with Sendable properties
// - Actors (always Sendable)
// - Enums with Sendable associated values
// - Tuples of Sendable types

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Baseline tests

Confirm the input and environment

Preserve the failure and return to this step

Toolchain change

Inspect the intermediate artifact

Preserve the failure and return to this step

Scoped fixes

Run the focused check

Preserve the failure and return to this step

Same acceptance suite

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

A complete old-project migration to iOS 27 has not been executed here.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Ask for the finding, not the fix: why review-first prompts produce better Swift

---

# Coordinating parallel coding work with isolated checkouts
https://nagarjuna2997.github.io/ios-agent-skill/series/7.2.html

← Guided series
 · 
All articlesLesson 7.2 · Guided learningCoordinating parallel coding work with isolated checkoutsOn this page

Work through the example
Implementation reference

4. Failure handling at scale

Acceptance and failure review
Evidence and limits

Parallel coding needs isolated checkouts and explicit ownership of shared resources. A worktree protects file edits, but not a shared database, simulator or external account.

Partition work → Isolate checkout → Coordinate shared resources → Integrate sequentially1
Partition work
↓2
Isolate checkout
↓3
Coordinate shared resources
↓4
Integrate sequentially

Work through the example

Assign one integration owner and require each task to report its starting commit and tests. Stop conflicting simulator operations.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

4. Failure handling at scale

At thirty units, some will fail. Decide the policy up front:

Policy

Behavior

Use when

Fail fast

Stop the whole run on first failure

Units share risk; a failure implies a bad plan

Isolate and continue

Mark it failed, keep going, report at the end

Units are genuinely independent

Retry once, then isolate

One retry with the failure fed back

Flaky infra, transient toolchain issues

Default to 
isolate and continue
 for independent units, with a summary at the
end that lists every failure. Twenty-eight successes and two clearly-reported
failures is a good run. Twenty-eight successes and two silently-skipped units is
a bad one that looks identical from the outside — which is exactly why the final
report must enumerate failures explicitly.

Never let a unit "succeed" by doing nothing.
 A worker that finds no work
should say "no change needed" — that is a distinct outcome from "changed and
verified", and collapsing the two hides coverage gaps.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Partition work

Confirm the input and environment

Preserve the failure and return to this step

Isolate checkout

Inspect the intermediate artifact

Preserve the failure and return to this step

Coordinate shared resources

Run the focused check

Preserve the failure and return to this step

Integrate sequentially

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

No four-client concurrency experiment was executed for this chapter.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Writing your own review rule for a Swift codebase

---

# Writing your own review rule for a Swift codebase
https://nagarjuna2997.github.io/ios-agent-skill/series/7.3.html

← Guided series
 · 
All articlesLesson 7.3 · Guided learningWriting your own review rule for a Swift codebaseOn this page

Work through the example
Implementation reference

review_swift_concurrency

Acceptance and failure review
Evidence and limits

A review rule should have a narrow predicate, a useful explanation and fixtures that define its limits. Adding a pattern without a false-positive example makes future maintenance harder.

Pattern candidate → Positive fixture → Negative fixture → Located finding1
Pattern candidate
↓2
Positive fixture
↓3
Negative fixture
↓4
Located finding

Work through the example

Inspect a neighboring analyzer and preserve its result shape. Add a test for a context that looks similar but must not trigger.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

review_swift_concurrency

Rule

Severity

observable-without-mainactor

🔴

type-named-task

🔴

task-detached

🟠

dispatchqueue-main-async

🟠

unchecked-sendable

🟠

task-in-onappear

🟠

empty-catch

🟠

redundant-mainactor-run

🟡

nonisolated-unsafe

🟡

observable-not-final

🟡

Background: 
../swift/swift-concurrency.md
, 
../../patterns/mvvm.md
.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Pattern candidate

Confirm the input and environment

Preserve the failure and return to this step

Positive fixture

Inspect the intermediate artifact

Preserve the failure and return to this step

Negative fixture

Run the focused check

Preserve the failure and return to this step

Located finding

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

An explanatory example is not a newly shipped analyzer; rule changes require their own tests.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Writing your own skill for a Swift project: SKILL.md structure that agents follow

---

# Writing your own skill for a Swift project: SKILL.md…
https://nagarjuna2997.github.io/ios-agent-skill/series/7.4.html

← Guided series
 · 
All articlesLesson 7.4 · Guided learningWriting your own skill for a Swift project: SKILL.md structure that agents followOn this page

Work through the example
Implementation reference

Apple app engineering

Acceptance and failure review
Evidence and limits

A skill should state when to use it, what inputs it needs and how success is checked. A giant list of technologies cannot substitute for a procedure an agent can follow.

Trigger → Relevant context → Bounded procedure → Evidence1
Trigger
↓2
Relevant context
↓3
Bounded procedure
↓4
Evidence

Work through the example

Keep the entry point short enough to route to focused references. Test discovery separately from whether the model follows the workflow.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

name: ios-agent-skill
description: Expert iOS/Swift developer behavior for AI coding agents. Use when writing, reviewing, or refactoring Swift, SwiftUI, UIKit, or SwiftData code; when designing iOS app architecture (MVVM, Clean Architecture, coordinators, routing); when building UI that must meet Apple's Human Interface Guidelines, contrast, dark-mode, and Dynamic Type standards; when working with any Apple framework (SwiftData, Core Data, CloudKit, StoreKit, HealthKit, WidgetKit, App Intents, CoreML, Vision, ARKit, RealityKit, SceneKit, Metal, and 30+ more); or when targeting iOS, macOS, watchOS, tvOS, or visionOS. Also use for Swift concurrency questions — actors, @MainActor isolation, Sendable, structured concurrency.
version: "3.5.0"
license: MIT
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
metadata:
  author: Nagarjuna Reddy
  homepage: https://nagarjuna2997.github.io/ios-agent-skill/
  languages: [swift]
  platforms: [ios, macos, watchos, tvos, visionos]
  entry: SKILL.md
  # Toolchain this skill is written against.
  swift-version: "6.4"
  xcode-version: "27"
  ios-sdk-version: "27"
  # Deployment floor the generated code must still support.
  minimum-swift: "5.9"
  minimum-ios: "17.0"
  supports:
    - Foundation Models
    - Apple Intelligence
    - Private Cloud Compute
    - Xcode Coding Agents
    - Device Hub
    - Liquid Glass
    - SwiftData
    - Swift 6 strict concurrency

Apple app engineering

Use the current project and the user's requested feature as the scope. Build working Swift implementations with explicit errors, injected dependencies, preview data and meaningful verification.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Trigger

Confirm the input and environment

Preserve the failure and return to this step

Relevant context

Inspect the intermediate artifact

Preserve the failure and return to this step

Bounded procedure

Run the focused check

Preserve the failure and return to this step

Evidence

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

Do not claim a skill improves quality without a controlled comparison.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Building a Claude Code plugin from a skill

---

# Building a Claude Code plugin from a skill
https://nagarjuna2997.github.io/ios-agent-skill/series/7.5.html

← Guided series
 · 
All articlesLesson 7.5 · Guided learningBuilding a Claude Code plugin from a skillOn this page

Work through the example
Package the skill separately from your app
Check the package boundary
Acceptance and failure review
Evidence and limits

Packaging a skill for Claude adds a distribution boundary. Validate the manifest, relative paths and installed tool connection rather than assuming a folder that works in a checkout will work after installation.

Plugin folder → Manifest validation → Clean install → Tool check1
Plugin folder
↓2
Manifest validation
↓3
Clean install
↓4
Tool check

Work through the example

Inspect the repository's Claude package layout and verify which files are included. Keep machine-specific paths out of the distributable.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Package the skill separately from your app

A Claude plugin exercise starts with a package root containing 
.claude-plugin/plugin.json
 and the skill directory. Keep application source outside that distribution folder unless it is an intentional example.

my-swift-review/
  .claude-plugin/
    plugin.json
  skills/
    swift-review/
      SKILL.md

A minimal manifest supplies an identity and description:

{
  "name": "my-swift-review",
  "version": "0.1.0",
  "description": "A focused Swift review procedure."
}

The skill should identify its trigger and required inputs, then tell the agent to inspect a bounded diff, locate findings and report evidence. Avoid filling the skill with installation commands for unrelated clients.

Check the package boundary

Parse the manifest with 
python3 -m json.tool .claude-plugin/plugin.json
, confirm the skill file exists, and inspect the package inventory before testing installation. Follow Claude's current plugin documentation for the installed version rather than assuming another client's ZIP layout or manifest schema applies.

After installation, run a synthetic review that actually triggers the skill. Confirm both discovery and the relevant behavior. If the plugin also configures MCP or hooks, test those surfaces independently: the skill loading does not prove the server started, and the server starting does not prove the hook dispatched.

Keep one installation route active during diagnosis. A direct MCP registration plus a plugin-provided registration can make duplicate tools look like a packaging defect. Record the client version, package version and the exact scope of the test before sharing the plugin.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Plugin folder

Confirm the input and environment

Preserve the failure and return to this step

Manifest validation

Inspect the intermediate artifact

Preserve the failure and return to this step

Clean install

Run the focused check

Preserve the failure and return to this step

Tool check

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

A marketplace submission is not acceptance or an installed-client test.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Building a Codex plugin from the same skill

---

# Building a Codex plugin from the same skill
https://nagarjuna2997.github.io/ios-agent-skill/series/7.6.html

← Guided series
 · 
All articlesLesson 7.6 · Guided learningBuilding a Codex plugin from the same skillOn this page

Work through the example
Build the package directory
Validate before installing
Acceptance and failure review
Check the current package contract
Evidence and limits

A Codex plugin needs a valid package layout and a clear relationship between skills and tool connections. Keep local execution requirements visible to users before they install.

Manifest → Bundled guidance → Tool configuration → Installed verification1
Manifest
↓2
Bundled guidance
↓3
Tool configuration
↓4
Installed verification

Work through the example

Validate the .codex-plugin manifest and resolve its paths from the package root. Check the official packaging documentation before release.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Build the package directory

Use a separate package root so the manifest's relative paths resolve consistently:

my-swift-plugin/
  .codex-plugin/
    plugin.json
  .mcp.json
  skills/
    swift-review/
      SKILL.md

The repository's existing package uses this same separation between plugin metadata, skill content and MCP configuration. A small manifest for the exercise is:

{
  "name": "my-swift-plugin",
  "version": "0.1.0",
  "description": "Focused Swift review guidance for this project.",
  "skills": "./skills/",
  "mcpServers": "./.mcp.json"
}

Keep the public name lowercase and stable. The version belongs to your plugin; it does not have to match the server version. The MCP file can pin the released package used in the existing setup:

{
  "mcpServers": {
    "ios-agent": {
      "command": "npx",
      "args": ["-y", "ios-agent-mcp@2.7.0"]
    }
  }
}

Create 
skills/swift-review/SKILL.md
 with a name, a task-specific description and a short review procedure. Start with a synthetic Swift file, request located findings and require an explicit distinction between static inspection and a build result.

Validate before installing

Run JSON parsing and path checks from the package root:

python3 -m json.tool .codex-plugin/plugin.json >/dev/null
python3 -m json.tool .mcp.json >/dev/null
test -f skills/swift-review/SKILL.md

These commands validate syntax and file presence, not the entire client schema. Then follow the current official plugin installation/development workflow for your client. Confirm the installed package exposes the skill and exactly one server connection. Remove or disable a duplicate direct MCP registration before judging duplicate tools as a server bug.

For a reproducible artifact, create an archive from the package contents rather than accidentally nesting the root twice:

zip -r ../my-swift-plugin.zip .codex-plugin .mcp.json skills
unzip -l ../my-swift-plugin.zip

Inspect the archive inventory for local paths, logs or account files. A distributable package should contain the declared inputs, not your whole development checkout. This example is a package construction exercise; installation and marketplace review remain separate validation steps.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Manifest

Confirm the input and environment

Preserve the failure and return to this step

Bundled guidance

Inspect the intermediate artifact

Preserve the failure and return to this step

Tool configuration

Run the focused check

Preserve the failure and return to this step

Installed verification

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Check the current package contract

Use the official 
plugin architecture documentation
 for the package contract. A local manifest check and an accepted directory listing are distinct results. Keep the plugin’s executable requirements visible, and test the installed package rather than only a source checkout.

Evidence and limits

No new marketplace acceptance is claimed by this chapter.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Cross-client packaging: verified Muse scope and an Antigravity test boundary

---

# Cross-client packaging: verified Muse scope and an…
https://nagarjuna2997.github.io/ios-agent-skill/series/7.7.html

← Guided series
 · 
All articlesLesson 7.7 · Guided learningCross-client packaging: verified Muse scope and an Antigravity test boundaryOn this page

Work through the example
Implementation reference
Acceptance and failure review
Evidence and limits

Compatibility should be demonstrated through the actual client, not inferred from a familiar filename. Muse's existing record has a narrow scope and cannot certify another client's plugin format.

Documented format → Client install → Discovery → Bounded task1
Documented format
↓2
Client install
↓3
Discovery
↓4
Bounded task

Work through the example

Preserve positive and negative fields in the verification record. Leave unsupported client capabilities explicitly unknown.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

{
  "checkedAt": "2026-09-16T23:07:13.850Z",
  "museVersion": "Muse Code 1.3.0 (1.3.0-R3233.1)",
  "serverPackage": "ios-agent-mcp",
  "serverVersion": "2.7.0",
  "toolCount": 36,
  "tools": [
    "review_app_intents",
    "review_swift_concurrency",
    "review_swift_architecture",
    "review_swiftui",
    "check_availability_guards",
    "audit_app_store_readiness",
    "analyze_swift_project",
    "lint_skill",
    "review_swift_memory",
    "review_swift_security",
    "review_swift_testing",
    "review_swift_performance",
    "search_apple_technologies",
    "get_apple_technology",
    "get_apple_updates",
    "plan_ios_app",
    "plan_app_icon",
    "search_local_references",
    "read_local_reference",
    "get_reference_outline",
    "simulator_list",
    "simulator_boot",
    "simulator_shutdown",
    "install_app",
    "launch_app",
    "terminate_app",
    "open_deep_link",
    "screenshot",
    "build_project",
    "run_tests",
    "simulator_environment",
    "simulator_show",
    "simulator_preview_start",
    "simulator_preview_stop",
    "create_app",
    "prepare_issue_report"
  ],
  "stopHookExecuted": true,
  "provider": "echo",
  "modelSessionVerified": false,
  "prePostHooksVerified": false,
  "observerVerified": false
}

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Documented format

Confirm the input and environment

Preserve the failure and return to this step

Client install

Inspect the intermediate artifact

Preserve the failure and return to this step

Discovery

Run the focused check

Preserve the failure and return to this step

Bounded task

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

Antigravity packaging and a Muse model-driven session are blocked pending real client tests.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Verifying a skill across clients: the client verification record method

---

# Verifying a skill across clients: the client verification…
https://nagarjuna2997.github.io/ios-agent-skill/series/7.8.html

← Guided series
 · 
All articlesLesson 7.8 · Guided learningVerifying a skill across clients: the client verification record methodOn this page

Work through the example
Implementation reference

5. The report format

Acceptance and failure review
Evidence and limits

A client record is most useful when it says what did not run. Keep discovery, hook execution, model behavior and app acceptance as separate fields.

Environment → Command → Observed result → Explicit limits1
Environment
↓2
Command
↓3
Observed result
↓4
Explicit limits

Work through the example

Record versions and artifact paths without credentials or personal source. A boolean should name a concrete check, not vague compatibility.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

5. The report format

Every agent in this repository returns this shape:

VERDICT: <done | blocked | partial>

EVIDENCE
$ <command>
<real output>

$ <command>
<real output>

WHAT CHANGED
- path/to/File.swift:88 — <what and why>

NOT VERIFIED
- <claim> — <why it could not be checked>

FOLLOW-UPS
- <anything deliberately left, so nobody assumes it is covered>

NOT VERIFIED
 is not optional. An empty section is fine; omitting the section
suggests everything was verified, which is rarely true.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Environment

Confirm the input and environment

Preserve the failure and return to this step

Command

Inspect the intermediate artifact

Preserve the failure and return to this step

Observed result

Run the focused check

Preserve the failure and return to this step

Explicit limits

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

Existing records are dated snapshots, not guarantees for later versions.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Measuring whether a skill helps: a paired benchmark protocol

---

# Measuring whether a skill helps: a paired benchmark…
https://nagarjuna2997.github.io/ios-agent-skill/series/7.9.html

← Guided series
 · 
All articlesLesson 7.9 · Guided learningMeasuring whether a skill helps: a paired benchmark protocolOn this page

Work through the example
Implementation reference

Protocol

Acceptance and failure review
Evidence and limits

A paired benchmark compares a defined treatment against a baseline on the same tasks. Hidden acceptance checks must be independent of the agent's output, and failed runs must remain in the results.

Fixed tasks → Alternating conditions → Independent scorer → Report all outcomes1
Fixed tasks
↓2
Alternating conditions
↓3
Independent scorer
↓4
Report all outcomes

Work through the example

Compare within a client before comparing providers. Keep missing token counts unknown and report sample size and retries.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

Protocol

20 fixed prompts; one trial per client × prompt × condition.
Clients: Claude Code and Codex, using each account's default model without a model override.
Baseline gets the task and source-file constraints. Treatment additionally gets
  the skill entry point plus local guides, templates, examples and retrieval scripts.
No MCP server or subagent bundle is supplied to the model. This tests the
  guidance treatment, not the entire product.
Each task gets an empty 
Sources/Solution.swift
, Swift 6 and Foundation.
  No secrets or personal app source is used.
Hidden assertions are introduced after the client exits, compiled separately
  with the generated file, and executed independently. The client cannot edit them.
Alternate baseline-first and skill-first by task. Each call has a timeout.
Claude uses safe-mode, no session persistence and file tools only; Codex uses
  ephemeral mode, ignores user config and disables automatic project docs.
  Client tools/default prompts still differ, so compare conditions within a client.
Report compilation, assertions, static review blockers and client-reported token
  usage. Cache/input token accounting differs by client; do not compare raw token
  totals across providers or convert them into savings/cost without pricing data.
Provider failures/timeouts are recorded separately from acceptance failures.
  A missing token field or failed scorer is unknown, not zero.

Acceptance and failure review

Checkpoint

What to inspect

If it does not match

Fixed tasks

Confirm the input and environment

Preserve the failure and return to this step

Alternating conditions

Inspect the intermediate artifact

Preserve the failure and return to this step

Independent scorer

Run the focused check

Preserve the failure and return to this step

Report all outcomes

Record the observed result

Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

No new full benchmark run is completed here; no quality or token-saving percentage is invented.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Preparing to verify Xcode 27 agents with external skills

---

# Ask for the finding, not the fix: why review-first…
https://nagarjuna2997.github.io/ios-agent-skill/series/8.1.html

← Guided series
 · 
All articlesLesson 8.1 · Guided learningAsk for the finding, not the fix: why review-first prompts produce better SwiftOn this page

Work through the example
One practical example
Apply it to your project
Evidence and limits

Ask for a located finding before asking for a rewrite. The first useful question is what source behavior violates the requirement, not how many lines the agent can change.

Requirement → Finding location → Context → Minimal patch1
Requirement
↓2
Finding location
↓3
Context
↓4
Minimal patch

Work through the example

Prompt: Show one finding, its file and line, and the smallest test that would distinguish a real bug from a false positive.

One practical example

Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.

In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.

Apply it to your project

Prompt: Show one finding, its file and line, and the smallest test that would distinguish a real bug from a false positive. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.

Evidence and limits

A review-first prompt is a technique, not a measured universal quality gain.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Ask about actor ownership before adding MainActor

---

# When the agent says "done", run the tests yourself once
https://nagarjuna2997.github.io/ios-agent-skill/series/8.10.html

← Guided series
 · 
All articlesLesson 8.10 · Guided learningWhen the agent says "done", run the tests yourself onceOn this page

Work through the example
One practical example
Apply it to your project
Evidence and limits

An independent rerun tests whether the completion claim survives outside the agent's narrative. Use the committed command and inspect the actual result.

Completion claim → Exact command → Fresh result → Accept or reopen1
Completion claim
↓2
Exact command
↓3
Fresh result
↓4
Accept or reopen

Work through the example

Prompt: List the commands you ran and their outcomes. I will rerun the relevant acceptance check before accepting the change.

One practical example

Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.

In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.

Apply it to your project

Prompt: List the commands you ran and their outcomes. I will rerun the relevant acceptance check before accepting the change. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.

Evidence and limits

Passing selected tests does not prove untested paths.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Small prompts, one screen at a time, commit between each

---

# Small prompts, one screen at a time, commit between each
https://nagarjuna2997.github.io/ios-agent-skill/series/8.11.html

← Guided series
 · 
All articlesLesson 8.11 · Guided learningSmall prompts, one screen at a time, commit between eachOn this page

Work through the example
One practical example
Apply it to your project
Evidence and limits

A small prompt with one observable goal makes the repair loop easier to control. A checkpoint preserves what worked before the next change.

One goal → Small diff → Acceptance check → Checkpoint1
One goal
↓2
Small diff
↓3
Acceptance check
↓4
Checkpoint

Work through the example

Prompt: Implement only the empty-state recovery action. Keep navigation and storage unchanged unless required and explained.

One practical example

Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.

In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.

Apply it to your project

Prompt: Implement only the empty-state recovery action. Keep navigation and storage unchanged unless required and explained. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.

Evidence and limits

Small work units can still require careful integration testing.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Keep signing and account changes separate from code repairs

---

# Keep signing and account changes separate from code repairs
https://nagarjuna2997.github.io/ios-agent-skill/series/8.12.html

← Guided series
 · 
All articlesLesson 8.12 · Guided learningKeep signing and account changes separate from code repairsOn this page

Work through the example
One practical example
Apply it to your project
Evidence and limits

Keep account authority separate from code editing. Signing changes should be deliberate, reviewed and performed through the appropriate local account flow.

Code task → Need account change? → Explicit review → Local configuration1
Code task
↓2
Need account change?
↓3
Explicit review
↓4
Local configuration

Work through the example

Prompt: Do not change signing or provisioning to hide this build error. Explain whether the selected simulator destination needs signing.

One practical example

Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.

In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.

Apply it to your project

Prompt: Do not change signing or provisioning to hide this build error. Explain whether the selected simulator destination needs signing. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.

Evidence and limits

An absolute ban is not necessary for every authorized workflow; secret values still stay out of chat and source.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Reading agent transcripts: the three phrases that mean it guessed

---

# Reading agent transcripts: the three phrases that mean it…
https://nagarjuna2997.github.io/ios-agent-skill/series/8.13.html

← Guided series
 · 
All articlesLesson 8.13 · Guided learningReading agent transcripts: the three phrases that mean it guessedOn this page

Work through the example
One practical example
Apply it to your project
Evidence and limits

Phrases such as “should work” and “likely fixed” express uncertainty, not execution. Ask what observation supports the conclusion rather than treating confidence as proof.

Claim → Supporting artifact → Missing check → Follow-up1
Claim
↓2
Supporting artifact
↓3
Missing check
↓4
Follow-up

Work through the example

Prompt: Separate what you inspected from what you executed, and list the unresolved checks.

One practical example

Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.

In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.

Apply it to your project

Prompt: Separate what you inspected from what you executed, and list the unresolved checks. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.

Evidence and limits

Wording alone cannot prove deception or correctness; inspect the evidence.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Turn every failure into a rule: how findings become reviews

---

# Turn every failure into a rule: how findings become reviews
https://nagarjuna2997.github.io/ios-agent-skill/series/8.14.html

← Guided series
 · 
All articlesLesson 8.14 · Guided learningTurn every failure into a rule: how findings become reviewsOn this page

Work through the example
One practical example
Apply it to your project
Evidence and limits

A repeated failure can become a regression fixture before it becomes a broad rule. Start with the smallest reproduction and a nearby example that should remain valid.

Failure → Minimal fixture → Negative case → Review rule1
Failure
↓2
Minimal fixture
↓3
Negative case
↓4
Review rule

Work through the example

Prompt: Reduce this finding to synthetic source and identify a similar pattern that must not trigger.

One practical example

Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.

In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.

Apply it to your project

Prompt: Reduce this finding to synthetic source and identify a similar pattern that must not trigger. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.

Evidence and limits

Do not upload private app code to build a public fixture.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Verify Muse observer behavior before recommending it

---

# Verify Muse observer behavior before recommending it
https://nagarjuna2997.github.io/ios-agent-skill/series/8.15.html

← Guided series
 · 
All articlesLesson 8.15 · Guided learningVerify Muse observer behavior before recommending itOn this page

Work through the example
One practical example
Apply it to your project
Evidence and limits

A verification observer is useful only when its actual behavior is known. The current Muse record explicitly does not establish observer behavior.

Feature claim → Official schema → Controlled failure → Observed response1
Feature claim
↓2
Official schema
↓3
Controlled failure
↓4
Observed response

Work through the example

Prompt: Identify the documented observer setting and demonstrate it on a failing synthetic task before recommending it.

One practical example

Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.

In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.

Apply it to your project

Prompt: Identify the documented observer setting and demonstrate it on a failing synthetic task before recommending it. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.

Evidence and limits

Observer enablement instructions are blocked until verified; no setting is guessed.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Codex worktrees for parallel screens

---

# Codex worktrees for parallel screens
https://nagarjuna2997.github.io/ios-agent-skill/series/8.16.html

← Guided series
 · 
All articlesLesson 8.16 · Guided learningCodex worktrees for parallel screensOn this page

Work through the example
One practical example
Apply it to your project
Use the documented client workflow
Evidence and limits

Worktrees let independent code tasks use separate checkouts. They do not merge the results or coordinate every external resource for you.

Shared baseline → Separate checkout → Independent task → Reviewed integration1
Shared baseline
↓2
Separate checkout
↓3
Independent task
↓4
Reviewed integration

Work through the example

Prompt: Keep this screen's changes in its own worktree and report the base commit, diff and tests.

One practical example

Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.

In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.

Apply it to your project

Prompt: Keep this screen's changes in its own worktree and report the base commit, diff and tests. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.

Use the documented client workflow

For the client-specific setup, consult 
OpenAI’s worktree documentation
. The important integration step is reviewing the finished changes back against the intended base. An isolated checkout does not automatically settle competing edits to shared design tokens.

Evidence and limits

Resolve shared simulator and data-store ownership separately.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Treat background builds as running work, not completed tasks

---

# Treat background builds as running work, not completed…
https://nagarjuna2997.github.io/ios-agent-skill/series/8.17.html

← Guided series
 · 
All articlesLesson 8.17 · Guided learningTreat background builds as running work, not completed tasksOn this page

Work through the example
One practical example
Apply it to your project
Evidence and limits

A background build needs clear ownership, logs and a completion signal. Do not equate generic process execution with a specific client's subagent feature.

Start build → Retain logs → Wait for completion → Inspect exit status1
Start build
↓2
Retain logs
↓3
Wait for completion
↓4
Inspect exit status

Work through the example

Prompt: Report the running command and final exit status; do not mark the task complete while the build is still running.

One practical example

Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.

In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.

Apply it to your project

Prompt: Report the running command and final exit status; do not mark the task complete while the build is still running. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.

Evidence and limits

Antigravity background-subagent behavior is unverified here, so no client-specific command is provided.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Claude Code plan mode before any multi-file Swift change

---

# Claude Code plan mode before any multi-file Swift change
https://nagarjuna2997.github.io/ios-agent-skill/series/8.18.html

← Guided series
 · 
All articlesLesson 8.18 · Guided learningClaude Code plan mode before any multi-file Swift changeOn this page

Work through the example
One practical example
Apply it to your project
Evidence and limits

Planning before a multi-file edit exposes dependencies and unresolved choices. A plan is valuable when it narrows the next action, not when it repeats the prompt in more words.

Inspect files → Identify dependencies → Agree criteria → Edit one slice1
Inspect files
↓2
Identify dependencies
↓3
Agree criteria
↓4
Edit one slice

Work through the example

Prompt: Before editing, list the files involved, the behavior to preserve and the checks that determine completion.

One practical example

Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.

In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.

Apply it to your project

Prompt: Before editing, list the files involved, the behavior to preserve and the checks that determine completion. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.

Evidence and limits

Planning does not itself validate implementation or guarantee lower cost.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Ask the agent to search local Apple references before the web

---

# Ask the agent to search local Apple references before the…
https://nagarjuna2997.github.io/ios-agent-skill/series/8.19.html

← Guided series
 · 
All articlesLesson 8.19 · Guided learningAsk the agent to search local Apple references before the webOn this page

Work through the example
One practical example
Apply it to your project
Evidence and limits

Local references provide project-specific context and reproducible examples. When they lack an answer, disclose the gap and distinguish external research from repository guidance.

Focused local search → Read source section → Identify gap → Check primary source1
Focused local search
↓2
Read source section
↓3
Identify gap
↓4
Check primary source

Work through the example

Prompt: Search the local library for this API first; cite the relevant file and say if the answer needs current Apple documentation.

One practical example

Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.

In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.

Apply it to your project

Prompt: Search the local library for this API first; cite the relevant file and say if the answer needs current Apple documentation. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.

Evidence and limits

A local catalog is dated and is not Apple's proprietary framework implementation.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: What to put in .gitignore for agent-built Xcode projects

---

# Ask about actor ownership before adding MainActor
https://nagarjuna2997.github.io/ios-agent-skill/series/8.2.html

← Guided series
 · 
All articlesLesson 8.2 · Guided learningAsk about actor ownership before adding MainActorOn this page

Work through the example
One practical example
Apply it to your project
Evidence and limits

MainActor is an isolation choice, not a universal concurrency repair. Inspect ownership and work placement before applying an annotation.

State owner → Mutation sites → Isolation decision → Compiler check1
State owner
↓2
Mutation sites
↓3
Isolation decision
↓4
Compiler check

Work through the example

Prompt: Identify who owns this UI-observed state and where it is mutated. Explain the actor boundary before changing annotations.

One practical example

Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.

In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.

Apply it to your project

Prompt: Identify who owns this UI-observed state and where it is mutated. Explain the actor boundary before changing annotations. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.

Evidence and limits

No evidence supports the original claim that one prompt fixes half of all concurrency errors.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: "Show me the screenshot" as a standing instruction

---

# What to put in .gitignore for agent-built Xcode projects
https://nagarjuna2997.github.io/ios-agent-skill/series/8.20.html

← Guided series
 · 
All articlesLesson 8.20 · Guided learningWhat to put in .gitignore for agent-built Xcode projectsOn this page

Work through the example
One practical example
Apply it to your project
Evidence and limits

A useful gitignore keeps generated artifacts and private local state out of commits without hiding reproducible inputs. Treat every new ignore pattern as a scope decision.

Classify file → Source or generated? → Ignore narrowly → Inspect git status1
Classify file
↓2
Source or generated?
↓3
Ignore narrowly
↓4
Inspect git status

Work through the example

Prompt: Propose ignore rules for build output and local evidence, but keep project specs, tests and shared configuration tracked.

One practical example

Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.

In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.

Apply it to your project

Prompt: Propose ignore rules for build output and local evidence, but keep project specs, tests and shared configuration tracked. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.

Evidence and limits

Ignoring a secret after committing it does not remove it from history.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: All levels

---

# "Show me the screenshot" as a standing instruction
https://nagarjuna2997.github.io/ios-agent-skill/series/8.3.html

← Guided series
 · 
All articlesLesson 8.3 · Guided learning"Show me the screenshot" as a standing instructionOn this page

Work through the example
One practical example
Apply it to your project
Evidence and limits

Ask for the screenshot of a named state and a named run destination. An unspecified screenshot can show a launch screen, stale build or unrelated simulator.

Named state → Reproduction steps → Capture → Compare1
Named state
↓2
Reproduction steps
↓3
Capture
↓4
Compare

Work through the example

Prompt: Capture the empty-search state after entering a query with no matches. State the runtime and build you launched.

One practical example

Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.

In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.

Apply it to your project

Prompt: Capture the empty-search state after entering a query with no matches. State the runtime and build you launched. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.

Evidence and limits

A screenshot is visual evidence, not proof of every behavior.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Pin your Xcode version in AGENTS.md and stop the agent guessing APIs

---

# Pin your Xcode version in AGENTS.md and stop the agent…
https://nagarjuna2997.github.io/ios-agent-skill/series/8.4.html

← Guided series
 · 
All articlesLesson 8.4 · Guided learningPin your Xcode version in AGENTS.md and stop the agent guessing APIsOn this page

Work through the example
One practical example
Apply it to your project
Evidence and limits

A toolchain version in project instructions gives the agent a concrete compatibility target. It does not prevent the model from guessing, so require compilation against that target.

Recorded toolchain → API choice → Build → Availability review1
Recorded toolchain
↓2
API choice
↓3
Build
↓4
Availability review

Work through the example

Prompt: Use the installed Xcode version recorded here. If an API needs a newer SDK, explain the fallback before editing.

One practical example

Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.

In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.

Apply it to your project

Prompt: Use the installed Xcode version recorded here. If an API needs a newer SDK, explain the fallback before editing. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.

Evidence and limits

Do not label an API available solely because it appears in a newer guide.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: One deep link per screen makes every screenshot automatable

---

# One deep link per screen makes every screenshot automatable
https://nagarjuna2997.github.io/ios-agent-skill/series/8.5.html

← Guided series
 · 
All articlesLesson 8.5 · Guided learningOne deep link per screen makes every screenshot automatableOn this page

Work through the example
One practical example
Apply it to your project
Evidence and limits

A deep link can shorten a screenshot workflow when it resolves to deterministic test state. It cannot create missing data or bypass legitimate access checks.

Validated URL → Seeded item → Destination → Screenshot1
Validated URL
↓2
Seeded item
↓3
Destination
↓4
Screenshot

Work through the example

Prompt: Add a test route to this screen using a synthetic identifier and an explicit not-found state.

One practical example

Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.

In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.

Apply it to your project

Prompt: Add a test route to this screen using a synthetic identifier and an explicit not-found state. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.

Evidence and limits

Not every screen should expose a public route; keep test hooks appropriately scoped.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Keep design tokens in one file and tell the agent it's the only source of color

---

# Keep design tokens in one file and tell the agent it's…
https://nagarjuna2997.github.io/ios-agent-skill/series/8.6.html

← Guided series
 · 
All articlesLesson 8.6 · Guided learningKeep design tokens in one file and tell the agent it's the only source of colorOn this page

Work through the example
One practical example
Apply it to your project
Evidence and limits

A single token source reduces accidental divergence between screens. Semantic roles let a design change update meaning consistently instead of replacing random hex values.

Token source → Named asset → View use → Appearance check1
Token source
↓2
Named asset
↓3
View use
↓4
Appearance check

Work through the example

Prompt: Use the existing semantic color roles. If a role is missing, propose it with all appearances before adding a literal.

One practical example

Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.

In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.

Apply it to your project

Prompt: Use the existing semantic color roles. If a role is missing, propose it with all appearances before adding a literal. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.

Evidence and limits

Centralized tokens do not guarantee contrast or visual quality.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Review the XcodeGen spec before regenerating the project

---

# Review the XcodeGen spec before regenerating the project
https://nagarjuna2997.github.io/ios-agent-skill/series/8.7.html

← Guided series
 · 
All articlesLesson 8.7 · Guided learningReview the XcodeGen spec before regenerating the projectOn this page

Work through the example
One practical example
Apply it to your project
Evidence and limits

A readable project specification makes generated changes easier to review. The project file still matters, but regeneration should be intentional and reproducible.

Spec change → Generator → Project diff → Build1
Spec change
↓2
Generator
↓3
Project diff
↓4
Build

Work through the example

Prompt: Explain the target change in project.yml, regenerate with the documented tool, then show the resulting project diff.

One practical example

Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.

In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.

Apply it to your project

Prompt: Explain the target change in project.yml, regenerate with the documented tool, then show the resulting project diff. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.

Evidence and limits

Do not regenerate with an unrecorded tool version or overwrite unrelated manual changes.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Use `#available` audits before every beta upgrade

---

# Use `#available` audits before every beta upgrade
https://nagarjuna2997.github.io/ios-agent-skill/series/8.8.html

← Guided series
 · 
All articlesLesson 8.8 · Guided learningUse `#available` audits before every beta upgradeOn this page

Work through the example
One practical example
Apply it to your project
Evidence and limits

Availability audits should distinguish SDK introduction, deployment target and runtime capability. A guard can be syntactically present and still protect the wrong boundary.

API introduction → Deployment target → Runtime readiness → Fallback1
API introduction
↓2
Deployment target
↓3
Runtime readiness
↓4
Fallback

Work through the example

Prompt: Show the availability assumption and the fallback behavior for each newly introduced API.

One practical example

Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.

In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.

Apply it to your project

Prompt: Show the availability assumption and the fallback behavior for each newly introduced API. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.

Evidence and limits

Text-based guard checks cannot model every control-flow path.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: Hooks beat reminders: enforce, don't request

---

# Hooks beat reminders: enforce, don't request
https://nagarjuna2997.github.io/ios-agent-skill/series/8.9.html

← Guided series
 · 
All articlesLesson 8.9 · Guided learningHooks beat reminders: enforce, don't requestOn this page

Work through the example
One practical example
Apply it to your project
Evidence and limits

Use a hook for a repeatable event check and instructions for the reason behind it. Keep the handler small enough that a failure can be diagnosed from its exit status.

Lifecycle event → Handler → Exit status → Explain result1
Lifecycle event
↓2
Handler
↓3
Exit status
↓4
Explain result

Work through the example

Prompt: Demonstrate one passing and one failing hook case using synthetic files.

One practical example

Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.

In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.

Apply it to your project

Prompt: Demonstrate one passing and one failing hook case using synthetic files. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.

Evidence and limits

Hook schemas and exit behavior are client-specific; copying JSON does not verify dispatch.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The 
series evidence record
 separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson
.
What to do next
Next: When the agent says "done", run the tests yourself once