Swift and SwiftUI rules for AI coding agents

SwiftUI state is where agents get confused fastest, because the right property wrapper depends on who owns the data and on which observation system the project uses. The Swift rules cover Swift 6 and SwiftUI apps, and with the Observation framework they describe the pattern plainly: own a model with @State, pass it as a plain property, use @Bindable when a child needs bindings.

They also keep body cheap and free of side effects, gate newer APIs with if #available instead of quietly raising the deployment target, ban force unwraps, try! and as! in app code, and send tokens to the Keychain rather than UserDefaults. Concurrency rules cover @MainActor, Sendable and task cancellation for the Swift 6 language mode.

Folder: rules/swift-swiftui/ · swift-swiftui.mdc, CLAUDE.md, AGENTS.md

What the SwiftUI rules tell your agent

First rule of each section in swift-swiftui.mdc, quoted as shipped.

Mistakes these rules are written to prevent

Source: “Concurrency”.

Checks the agent runs before it says done

Example commands from the SwiftUI file (swap in your repo's own):

Build for the simulator (xcodebuild -scheme <App> -destination 'platform=iOS Simulator,name=<device>' build test) or swift build && swift test for packages, and report warnings as well as errors.

Before and after: SwiftUI: state ownership review

Prompt card 26 · Stack: Swift, SwiftUI (iOS 17+ Observation or ObservableObject) · Type: Review and refactor · Best model: a frontier reasoning model

What the card is for

Find view models recreated on every parent update, duplicated sources of truth, @State initialized from props that never update, and async work tied to the wrong lifecycle. These are the usual causes of "my screen resets" and "data loads twice" bugs.

Worked example from the card

Before

struct OrdersScreen: View {
    let customerID: Customer.ID
    @ObservedObject var model = OrdersModel()
    @State private var filter = ""
    var body: some View {
        List(model.orders.filter { filter.isEmpty || $0.title.contains(filter) }) { order in
            Text(order.title)
            Text(DateFormatter.localizedString(from: order.date, dateStyle: .medium, timeStyle: .none))
        }
        .searchable(text: $filter)
        .onAppear { Task { await model.load(customerID) } }
    }
}

Expected AI output (abridged)

| model    | nobody (recreated) | body | load()   | @ObservedObject with an initial value:
|          |                    |      |          | recreated whenever the parent re-renders;
|          |                    |      |          | orders vanish and load again.
| filter   | OrdersScreen       | body | search   | fine
| load     | -                  | -    | -        | Task in onAppear is not canceled, and does
|          |                    |      |          | not rerun when customerID changes.
Fix: own the model with @State (Observable, iOS 17+) or @StateObject; use
.task(id: customerID); use Text(date, format:) instead of building strings in body.

After

@Observable @MainActor
final class OrdersModel {
    private(set) var orders: [Order] = []
    var error: Error?
    func load(_ id: Customer.ID) async {
        do { orders = try await api.orders(for: id) } catch is CancellationError { } catch { self.error = error }
    }
}

struct OrdersScreen: View {
    let customerID: Customer.ID
    @State private var model = OrdersModel()
    @State private var filter = ""

    private var visible: [Order] {
        filter.isEmpty ? model.orders : model.orders.filter { $0.title.localizedStandardContains(filter) }
    }

    var body: some View {
        List(visible) { order in
            Text(order.title)
            Text(order.date, format: .dateTime.day().month().year())
        }
        .searchable(text: $filter)
        .task(id: customerID) { await model.load(customerID) }
    }
}

Excerpt from card 26; the prompt itself is in the pack.

More SwiftUI prompt cards in the pack

Using these rules in Cursor, Claude Code and AGENTS.md tools

Cursor

Put _base.mdc and swift-swiftui.mdc in .cursor/rules/. Globs: Package.swift, **/*.swift.

Claude Code

Root CLAUDE.md, base first. Path-scoped variant .claude/rules/swift-swiftui.md for **/*.swift; PostToolUse hook: swift format --in-place <file> (or swiftformat, if configured).

AGENTS.md tools (Codex, Gemini CLI, Aider)

Base then stack in one AGENTS.md. Gemini CLI needs context.fileName; Aider needs --read AGENTS.md. Put this file in the folder with the .xcodeproj or Package.swift. Agents use the AGENTS.md nearest to the file being edited, so a nested copy overrides a root one for that subtree.

Related stacks

← All stacks