# Connecting Claude Code to Your Second Brain How a simple skill turns Obsidian into a live dashboard for your AI agents, skills, and plans. category: Building date: 2026-03-05 reading-time: 6 min read url: https://conor.fyi/writing/connecting-claude-code-to-your-second-brain --- ## Obsidian as a second brain If you haven't come across [Obsidian](https://obsidian.md), it's a markdown-based knowledge base that runs entirely on local files. No cloud lock-in, no proprietary format — just folders of markdown files that you own. What makes it powerful isn't just the markdown editor. It's the *connections*. Obsidian gives you backlinks, graph view, tags, and a plugin ecosystem that turns a pile of notes into a navigable knowledge graph. You can see how ideas relate to each other and search across everything instantly. And because it's all just files on disk, it plays nicely with other tools. People use it for everything — engineering docs, product specs, meeting notes, personal journals, project planning. You don't need to be a developer to get value from it. If you write things down, Obsidian makes those things findable and connected. Claude and myself have been building something called [Zodsidian](https://github.com/conorluddy/Zodsidian) that takes this further — an Obsidian plugin that enforces structure on note frontmatter using Zod schemas, then exposes the vault as a typed data graph that AI agents can query. Think of it as giving your second brain an API. But that's a story for another post. ## The problem Claude Code is brilliant at generating useful artefacts. Custom agents that handle specific workflows. Skills that encode repeatable processes, and Plan files that break down complex projects into steps. But where do these things live? Agents sit in `~/.claude/agents/` or `.claude/agents/` inside project repos. Skills are in `~/.claude/skills/`. Plans end up in `~/.claude/plans/`. They're scattered across hidden directories, and unless you know exactly where to look, they're invisible. You can't search across them. You can't see which projects share patterns. You can't browse your growing library of AI tooling without dropping into a terminal and running `ls`. If you're using Claude Code for product planning, project management, or team coordination, these artefacts should be *visible*. They should be searchable, cross-referenced, and easy to browse — especially for people who aren't living in a terminal all day. I was originally only symlinking Claude's plans into it – until today when I went looking for a specific agent for something and twigged that there's no reason not to have your agents and skills in there too. For ease of finding, reviewing and editing them, and remembering what they actually do. ## Symlinks as portals The solution uses symlinks, which are worth a quick explanation. A symlink is a pointer — think of it as a portal from one location to another. When you create a symlink, a file appears to exist in two places, but there's only ever one real copy. Open the portal, you get the original file. Edit through the portal, you're editing the original. No duplication, no syncing, no drift. This is how `obsidian-link` bridges Claude Code and Obsidian. It creates portals between where your AI artefacts actually live and your Obsidian vault, so Obsidian can see and index them without anything being copied or moved. ## What obsidian-link does [obsidian-link](https://github.com/conorluddy/ObsidianSkills) is a Claude Code skill with four modes: **Link** — The default. Run `/obsidian-link` inside any project and it connects that project's agents and skills to your vault. It creates a `ClaudeCode/` directory in your vault with organised subdirectories for agents, skills, and plans. Everything is symlinked, so files stay where they belong. **Status** — A health check. Run `/obsidian-link status` to see every linked project, how many agents and skills each has, and whether any symlinks are broken (maybe you deleted a file or moved a repo). **Unlink** — Cleanly disconnects a project from your vault. Removes the symlinks, leaves all your actual files untouched. **Init** — Sets up plan file templates with Obsidian-friendly frontmatter. This means every plan Claude creates automatically gets tags, status fields, and metadata that make it browsable with Obsidian's Dataview plugin and visible in graph view. ### Directionality There's a deliberate design choice in how the linking works: - **Global agents and skills** (ones you use across all projects) live in your Obsidian vault. The vault is the source of truth, and symlinks point *into* `~/.claude/`. - **Per-project agents and skills** live in their project repos (where they should be, version controlled). Symlinks point *into* Obsidian. Both directions use the same portal concept. Files always have one true home — the symlinks just make them visible from both sides. ## What you get Once you've linked a few projects, your Obsidian vault becomes a live dashboard for your Claude Code setup.
![A plan file browsable in Obsidian, with structured frontmatter, project agents and skills visible in the sidebar](/images/obsidian-link/obsidian-vault-plan.jpg)
**Graph view lights up.** Agents, skills, and plans appear as nodes. The auto-generated index note uses wikilinks, so Obsidian's graph view shows how everything connects — which projects share skills, which plans reference which agents. **Search across everything.** Want to find every agent that mentions "deployment"? Every plan with status "draft"? Obsidian's search covers it all in one place, across every linked project. **Non-technical access.** Anyone on your team who uses Obsidian can browse project architecture, read agent instructions, and review plans without touching a terminal. Product managers can see what skills exist. Designers can read the agents that handle their workflows. Everyone gets visibility. **Plans become first-class notes.** With the init mode configured, every plan Claude creates includes frontmatter that Obsidian understands — status tracking, tags, project attribution, dates. You can build Dataview dashboards that show all active plans across all projects. ## Getting started Install the skill from [the GitHub repo](https://github.com/conorluddy/ObsidianSkills), then run `/obsidian-link` inside any project. The skill handles the rest — it'll ask where your vault is, create the directory structure, and set up the symlinks. It's fully idempotent, so running it multiple times is safe. It reports what it did — how many new links, how many were already in place, whether anything is broken. And it never auto-deletes anything. If a symlink is broken, it tells you and lets you decide what to do. If you're already using Obsidian as your second brain, this just gives it eyes into one more part of your workflow. And if you're not using Obsidian yet — this might be a good reason to start. --- # FoundationModels: Quick Reference Cheatsheet The scannable companion to the full iOS 26 FoundationModels reference — key types, patterns, and things not to do category: Reference date: 2026-03-01 reading-time: 8 min read url: https://conor.fyi/writing/foundation-models-cheatsheet --- > This is the fast version. For full detail on any section, follow the deep-dive links to the [complete reference](/writing/foundation-models-reference). --- ## The 15-Part Index | Part | Covers | One thing to know | Deep dive | |------|--------|-------------------|-----------| | 1 | Availability & Setup | `.modelNotReady` is transient — model is downloading, not missing | [→](/writing/foundation-models-reference#part-1-availability--setup) | | 2 | Sessions & Basic Prompting | `respond()` returns `Response` — always unwrap `.content` | [→](/writing/foundation-models-reference#part-2-sessions--basic-prompting) | | 3 | Prompt Engineering | Short beats long. `<200 words`. Explicit rules beat prose descriptions | [→](/writing/foundation-models-reference#part-3-prompt-engineering-for-on-device-models) | | 4 | `@Generable` | Macro generates a structured output schema; `@Guide` adds constraints | [→](/writing/foundation-models-reference#part-4-guided-generation-generable) | | 5 | Streaming | `streamResponse()` → `AsyncSequence`; use `.collect()` to finalise | [→](/writing/foundation-models-reference#part-5-streaming) | | 6 | Generation Options | `temperature: nil` or `0.0–0.2` for correction tasks; higher for creative | [→](/writing/foundation-models-reference#part-6-generation-options) | | 7 | Tool Calling | Pre-fetch if always needed; define as `Tool` only for conditional data | [→](/writing/foundation-models-reference#part-7-tool-calling) | | 8 | Token Budget | All inputs + output share one fixed window (~4,096 tokens) | [→](/writing/foundation-models-reference#part-8-token-budget) | | 9 | The Transcript | New session per call for stateless tasks — don't accumulate history | [→](/writing/foundation-models-reference#part-9-the-transcript) | | 10 | Failure Modes | `normalise()` should never throw — return raw input on any failure | [→](/writing/foundation-models-reference#part-10-failure-modes--graceful-degradation) | | 11 | Testing | `@Generable` types are unit-testable without the model (memberwise init) | [→](/writing/foundation-models-reference#part-11-testing) | | 12 | Use Cases | 10 concrete patterns: BJJ, recipes, journaling, commits, triage... | [→](/writing/foundation-models-reference#part-12-example-use-cases) | | 13 | Quick Reference | Full type table + anti-patterns (see below) | [→](/writing/foundation-models-reference#part-13-quick-reference--anti-patterns) | | 14 | Context Engineering | 4,096 tokens = ~3,000 words shared. Select, don't dump | [→](/writing/foundation-models-reference#part-14-context-engineering-for-on-device-ai) | | 15 | Advanced Patterns | `call()` runs `@concurrent` — hop to `@MainActor` for state access | [→](/writing/foundation-models-reference#part-15-advanced-patterns) | --- ## Key Types | Type | Purpose | |------|---------| | `SystemLanguageModel` | Singleton entry point — `.default`, `.availability`, `.isAvailable` | | `SystemLanguageModel.Availability` | `.available` / `.unavailable(reason)` — always handle `@unknown default` | | `LanguageModelSession` | One conversation thread. Stateful — holds `Transcript` | | `Instructions` | System prompt — set once at session creation, not per-turn | | `Prompt` | Per-turn user input to the model | | `Response` | Wrapper around typed output — **always** access `.content`, not `response` | | `ResponseStream` | `AsyncSequence` of `Snapshot` for streaming | | `GenerationOptions` | `temperature`, `maximumResponseTokens`, `SamplingMode` | | `@Generable` | Macro — synthesises guided generation schema for a struct or enum | | `@Guide` | Property wrapper on `@Generable` fields — description + constraints | | `GenerationGuide` | Constraint type: `.range()`, `.count()`, `.pattern()` | | `Transcript` | Linear history: `.instructions`, `.prompt`, `.response`, `.toolCalls`, `.toolOutput` | | `Tool` | Protocol — `Arguments` (Generable), `Output` (PromptRepresentable), `call()` | | `SystemLanguageModel.TokenUsage` | `.tokenCount` — measure cost before injection | --- ## Session Init Variants ```swift // Minimal — no tools, inline instructions LanguageModelSession { "Correct BJJ terminology. kimora→Kimura, half card→Half Guard." } // Explicit model LanguageModelSession(model: SystemLanguageModel.default) { "..." } // With tools LanguageModelSession(tools: [PositionLookupTool()]) { "..." } // Resume from saved transcript LanguageModelSession(model: .default, tools: [], transcript: savedTranscript) ``` --- ## `respond()` vs `streamResponse()` | | `respond()` | `streamResponse()` | |--|------------|-------------------| | Returns | `Response` | `ResponseStream` | | Best for | Background processing, pipelines | Live UI with typing effect | | Partial results | No | Yes — `snapshot.content` returns `PartiallyGenerated` | | Finalise stream | N/A | `.collect()` → `Response` | **Rule:** if the output is going directly into a pipeline or SwiftData model, use `respond()`. If the user sees it appear on screen as it generates, use `streamResponse()`. --- ## Token Budget Formula ``` Total window ≈ 4,096 tokens ≈ 3,000 words instructions + tool definitions + transcript history + prompt + response ``` All five compete for the same pool. Response tokens are consumed from the same window as input tokens — a 500-token response leaves 3,596 tokens for everything else. **Measure before injecting:** ```swift let cost = try await model.tokenUsage(for: instructions).tokenCount let window = await model.contextSize // back-deployed via @backDeployed ``` --- ## `@Generable` vs Raw `String` **Use `@Generable` when:** - You need multiple structured fields - Output must be parsed or processed programmatically - You want compile-time guarantees on shape - You need constraints (`@Guide`) on values **Use raw `String` when:** - Output is prose for display to the user - You're summarising or generating a paragraph - Streaming a typing effect --- ## The `AnyObject?` Pattern (Availability Without `@available` Spread) The problem: adding a `@State private var session: LanguageModelSession?` forces `@available(iOS 26, *)` onto the whole view. The fix: use `AnyObject?` as the declared type and cast inside `#available` guards. ```swift // In your view — no @available annotation needed on the view itself @State private var normalisationService: AnyObject? // In .onAppear or .task if #available(iOS 26, *) { normalisationService = TranscriptNormalisationService() } // At call site if #available(iOS 26, *), let service = normalisationService as? TranscriptNormalisationService { let result = await service.normalise(rawText) } ``` --- ## Context Engineering — 4 Patterns When app data is too large to inject directly: | Pattern | When to use | How | |---------|-------------|-----| | **Select, Don't Dump** | Data is queryable | SwiftData predicate — fetch only relevant rows | | **Layered Injection** | Hierarchical data | Inject summaries; load detail on demand via tools | | **Two-Step Compression** | Large corpus, summary needed | Call 1 summarises → Call 2 reasons with summary | | **Pre-Summarise at Write Time** | Rich entities with stable detail | Generate + store AI summary when entity is saved; reuse forever | --- ## The 10 Anti-Patterns **1. Accessing `response` instead of `response.content`** `respond()` returns `Response`, not `T`. Always unwrap `.content`. **2. Storing `LanguageModelSession` persistently when you don't need history** For stateless tasks (normalisation, extraction, classification), create a new session per call. History accumulates and eventually overflows the context window. **3. Too many tools** Each tool definition consumes ~50–100 tokens whether called or not. Keep to 3–5 per session. Split into multiple focused sessions if you have more. **4. Calling `isAvailable` / `checkAvailability()` in the hot path** Availability doesn't change mid-session. Check once at service init; cache the result. **5. High temperature for structured / correction tasks** `@Generable` correction types need `nil` or `temperature: 0.0–0.2`. High temperature produces creatively varied — and wrong — output. **6. Long, elaborate instructions modelled on frontier model prompts** The on-device model is ~3B parameters. Instructions over ~200 words dilute signal. Short, explicit rules outperform discursive prose every time. **7. Not testing the fallback path** On most devices today, Apple Intelligence is unavailable. Your non-AI path is the primary experience for most users. Test it as thoroughly as the AI path. **8. Using FoundationModels for regex-solvable tasks** If the task is a known, fixed pattern (extract a UUID, validate an email, format a date), use a deterministic function. LLM overhead — latency, availability, complexity — is waste. **9. Propagating `@available(iOS 26, *)` to SwiftUI views** Adding `@available` to a `@State` property forces the whole view to require iOS 26. Use the `AnyObject?` pattern instead. **10. Treating `.modelNotReady` as permanent** `.modelNotReady` means the model is downloading. It is transient. Show "not available right now" and retry on next app launch. Do not display a permanent "unsupported" message. --- ## Minimum Viable Service Pattern The production-safe wrapper — never throws, falls back silently: ```swift @available(iOS 26, *) @MainActor final class TranscriptNormalisationService { private func makeSession() -> LanguageModelSession { LanguageModelSession { """ You are a BJJ transcript corrector. Fix misrecognised terms only. Common corrections: kimora→Kimura, half card→Half Guard, arm bar→armbar. Vocabulary: Kimura, Triangle, Armbar, Half Guard, Full Guard, Mount, Back Control. Return the corrected transcript and the BJJ terms found. """ } } /// Never throws. Returns raw input unchanged on any failure. func normalise(_ rawTranscript: String) async -> NormalisedTranscript { guard !rawTranscript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return NormalisedTranscript(normalisedText: rawTranscript, extractedTerms: []) } guard SystemLanguageModel.default.isAvailable else { return NormalisedTranscript(normalisedText: rawTranscript, extractedTerms: []) } do { let session = makeSession() let result = try await session.respond( to: Prompt { rawTranscript }, generating: NormalisedTranscript.self ) return result.content } catch { return NormalisedTranscript(normalisedText: rawTranscript, extractedTerms: []) } } } ``` --- ## Availability Cases at a Glance | Case | Meaning | What to do | |------|---------|------------| | `.available` | Ready to use | Create session, proceed | | `.unavailable(.deviceNotEligible)` | Hardware doesn't support Apple Intelligence | Show permanent alternative UI; remove AI option | | `.unavailable(.appleIntelligenceNotEnabled)` | User hasn't enabled it in Settings | Optionally prompt user; respect their choice | | `.unavailable(.modelNotReady)` | Model weights downloading | Show "not available right now"; retry on next launch | --- *Full 15-part reference: [iOS 26 FoundationModels: Comprehensive Swift/SwiftUI Reference](/writing/foundation-models-reference)* --- # iOS 26 FoundationModels: Comprehensive Swift/SwiftUI Reference A complete guide to Apple's on-device language model framework — from availability checking through guided generation, tool calling, and production patterns category: Reference date: 2026-02-28 reading-time: 75 min read url: https://conor.fyi/writing/foundation-models-reference --- ## Overview FoundationModels is Apple's framework for accessing the on-device large language model that powers Apple Intelligence. Introduced at WWDC 2025, it gives apps direct access to the same model behind Writing Tools, Smart Replies, and Mail Summaries — running entirely on-device, with no network requests and no data leaving the device. **Key characteristics:** - **On-device only** — no cloud fallback, no API key, no latency from network round-trips - **Privacy-first** — all inference happens locally; Apple never sees your prompts or responses - **Availability-gated** — requires Apple Intelligence to be enabled; not all devices qualify - **iOS 26+ only** — requires iPhone 15 Pro / iPhone 15 Pro Max or later (or equivalent iPad) - **Shared resource** — the model serves all apps; system may rate-limit under load **What it excels at:** - Text correction, normalisation, and reformatting - Entity extraction and classification - Summarisation of short-to-medium content - Structured output generation (via guided generation) - Context-aware suggestions and completions **What it is not:** - A replacement for frontier models (GPT-4, Claude, Gemini) for complex reasoning - A cloud API — if the model is unavailable, there is no fallback infrastructure - A general-purpose search or retrieval system **Minimum requirements:** - iOS 26.0+, iPadOS 26.0+, macOS Tahoe 26.0+ - Xcode 26.0+ - Device must support Apple Intelligence (iPhone 15 Pro or later) - Apple Intelligence must be enabled in Settings --- > **Want the fast version?** The [FoundationModels cheatsheet](/writing/foundation-models-cheatsheet) covers every part in one scannable page — key types, patterns, token budget formula, and anti-patterns. ## Contents 1. [Availability & Setup](#part-1-availability--setup)
`SystemLanguageModel`, availability cases, `AnyObject?` pattern 2. [Sessions & Basic Prompting](#part-2-sessions--basic-prompting)
`LanguageModelSession`, `Instructions`, `Prompt`, `.content` gotcha 3. [Prompt Engineering for On-Device Models](#part-3-prompt-engineering-for-on-device-models)
What works, what doesn't, `#Playground` 4. [Guided Generation (`@Generable`)](#part-4-guided-generation-generable)
`@Guide`, constraints, `PartiallyGenerated` 5. [Streaming](#part-5-streaming)
`streamResponse()`, `ResponseStream`, `.collect()` 6. [Generation Options](#part-6-generation-options)
`temperature`, `SamplingMode`, `maximumResponseTokens` 7. [Tool Calling](#part-7-tool-calling)
`Tool` protocol, pre-fetch vs inject, context cost 8. [Token Budget](#part-8-token-budget)
`tokenUsage(for:)`, `contextSize`, overflow strategies 9. [The Transcript](#part-9-the-transcript)
`Transcript.Entry`, saving/resuming sessions 10. [Failure Modes & Graceful Degradation](#part-10-failure-modes--graceful-degradation)
`GenerationError`, never-throws pattern 11. [Testing](#part-11-testing)
Four test categories, `.disabled()` on-device tests 12. [Example Use Cases](#part-12-example-use-cases)
10 concrete patterns across app domains 13. [Quick Reference & Anti-Patterns](#part-13-quick-reference--anti-patterns)
Cheatsheet + 10 things not to do 14. [Context Engineering](#part-14-context-engineering-for-on-device-ai)
Select/inject/compress/pre-summarise 15. [Advanced Patterns](#part-15-advanced-patterns)
Actor isolation, `@Generable` enums with associated values, `Observable` monitoring, `PromptRepresentable` chaining, bounded domain injection --- ## Part 1: Availability & Setup ### `SystemLanguageModel.default` `SystemLanguageModel.default` is the singleton entry point for the on-device language model. You do not initialise it — it is a static property you reference directly. Everything in FoundationModels starts here. ```swift let model = SystemLanguageModel.default switch model.availability { case .available: // model is ready — create a session and run prompts case .unavailable(let reason): // handle the specific reason @unknown default: break } ``` `SystemLanguageModel` is an `Observable` final class, so you can observe `.availability` changes in SwiftUI via `@State` or inside `.task {}` blocks without any special wiring. --- ### Availability Cases `SystemLanguageModel.Availability` is a `@frozen` enum with two top-level cases: `.available` and `.unavailable(UnavailableReason)`. Always handle `@unknown default` — Apple will add cases in future OS versions. **`.available`** The model is downloaded, Apple Intelligence is enabled, and the device is eligible. Create a `LanguageModelSession` and proceed. **`.unavailable(.deviceNotEligible)`** The hardware does not support Apple Intelligence. This applies to iPhone 14 and earlier, and equivalent iPad/Mac models. This is **permanent for the lifetime of the device** — no amount of waiting or retrying will change it. When you see this case, remove the AI code path from your UI entirely and show a permanent alternative experience. **`.unavailable(.appleIntelligenceNotEnabled)`** The device is eligible but the user has not turned on Apple Intelligence in Settings > Apple Intelligence & Siri. This is a **user choice**, not a hardware limitation. You can optionally prompt the user to enable it: ```swift // Optionally deep-link to Settings if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } ``` Respect the user's decision. If they choose not to enable it, show the non-AI path without nagging. **`.unavailable(.modelNotReady)`** This is the most misunderstood case. It does **not** mean the model is permanently unavailable — it means the model weights are currently **downloading**. There is **no programmatic download API**. You cannot trigger the download, request it, or track its progress. The OS manages download timing based on network conditions, battery level, device temperature, and system load. Download can take minutes to hours. Treat `.modelNotReady` as a transient state. Do not show a permanent "not supported" message. Instead, show a softer "not available right now — check back later" state and retry on the next app launch or session. ```swift func checkAvailability() -> String { switch SystemLanguageModel.default.availability { case .available: return "Ready" case .unavailable(let reason): switch reason { case .deviceNotEligible: return "Device not supported" case .appleIntelligenceNotEnabled: return "Enable Apple Intelligence in Settings" case .modelNotReady: return "Downloading... check back soon" @unknown default: return "Unavailable" } @unknown default: return "Unknown" } } ``` --- ### `isAvailable` Convenience Property `SystemLanguageModel.default.isAvailable` is a `Bool` shorthand. Use it when you only need to gate a code path and don't need to distinguish between unavailability reasons: ```swift guard SystemLanguageModel.default.isAvailable else { return } // proceed with AI code path ``` If you need to communicate *why* the model is unavailable to the user, use the full `.availability` switch instead. --- ### `UseCase` — `.general` vs `.contentTagging` `SystemLanguageModel.UseCase` selects a specialised version of the model. There are two options: **`SystemLanguageModel.UseCase.general`** (the default for `SystemLanguageModel.default`) — a general-purpose model for writing assistance, analysis, correction, extraction, and summarisation. This is what you get when you use `SystemLanguageModel.default`. **`SystemLanguageModel.UseCase.contentTagging`** — specialised for classification and extraction tasks. When you use this model, it **always responds with tags** — it is tuned to identify topics, emotions, actions, and objects. Use this when you want to categorise or label content rather than transform or generate it. ```swift // General model (default — used for most tasks) let model = SystemLanguageModel.default // Content tagging model — for classification/extraction let taggingModel = SystemLanguageModel(useCase: .contentTagging) let session = LanguageModelSession(model: taggingModel) ``` Do not use `.contentTagging` for text correction or generation tasks. The model will produce tags rather than prose, regardless of your instructions. --- ### `Guardrails` `SystemLanguageModel.Guardrails` controls content safety filtering on model inputs and outputs. There are two presets: **`SystemLanguageModel.Guardrails.default`** — the standard setting. Blocks unsafe content in both prompts and responses. When triggered, throws `LanguageModelSession.GenerationError.guardrailViolation(_:)`. **`SystemLanguageModel.Guardrails.permissiveContentTransformations`** — allows potentially sensitive source material to pass through for string generation tasks. Use this when your app legitimately processes user-generated content that might incidentally contain sensitive words (e.g., a chat moderation tool, a study app covering difficult topics). This mode **only applies to string output** — guided generation (`@Generable`) always uses default guardrails. ```swift // Default guardrails (most apps) let model = SystemLanguageModel.default // Permissive mode — for apps that must process sensitive source text let model = SystemLanguageModel(guardrails: .permissiveContentTransformations) ``` Even in permissive mode, the model may still refuse certain content — it retains its own layer of safety separate from the guardrail system. --- ### The `AnyObject?` Pattern for SwiftUI iOS 26 types require `@available(iOS 26, *)` annotations. Annotating a `@State` property with `@available` propagates that constraint to the entire containing view struct — meaning the whole view requires iOS 26, which is likely not what you want. The solution is to store iOS 26-only service instances as `AnyObject?` and cast them back inside `#available` guards: ```swift // DON'T do this — @available propagates to the whole view @available(iOS 26, *) @State private var service: MyAI26Service? // ❌ forces view to require iOS 26 // DO this instead — no @available constraint on the view struct @State private var service: AnyObject? // ✅ clean — AnyObject has no availability // Store the service (inside a #available guard) if #available(iOS 26, *) { self.service = MyAI26Service() } // Use the service (inside a #available guard) if #available(iOS 26, *), let s = self.service as? MyAI26Service { let result = try await s.process(text) } ``` This pattern lets you write a single view struct that gracefully degrades on older OS versions without any `@available` annotation on the view itself. --- ## Part 2: Sessions & Basic Prompting ### `LanguageModelSession` — Init Variants `LanguageModelSession` is the object you interact with to send prompts and receive responses. The two most common init patterns are: **Fresh session (most common):** ```swift // With builder-style instructions let session = LanguageModelSession { "You are a BJJ terminology corrector." "Fix misrecognised terms to their canonical spellings." } // With a specific model let session = LanguageModelSession(model: SystemLanguageModel.default) { "You are a motivational coach." } // With string instructions — also valid let session = LanguageModelSession( instructions: "You are a code review assistant." ) ``` **Resume from transcript (multi-turn):** ```swift // Rehydrate a session from a saved transcript to continue a conversation let session = LanguageModelSession( model: SystemLanguageModel.default, tools: [], transcript: savedTranscript ) ``` The session is an `Observable` final class. It is also `Sendable`, so you can safely hold a reference from a `@MainActor` context and call its methods from async tasks. --- ### `Instructions` `Instructions` defines the model's persona, rules, and domain — what the model *is* and how it *behaves*. Set it once at session creation. Instructions apply to every prompt in that session. Use `@InstructionsBuilder` (result builder syntax) to compose instructions from multiple strings: ```swift let instructions = Instructions { "You are a BJJ terminology corrector." "Fix misrecognised BJJ terms to their canonical spellings." "Common corrections: kimora→Kimura, half card→Half Guard, darce→D'Arce" } ``` Or pass a plain `String` directly: ```swift let session = LanguageModelSession( instructions: "You are a concise summariser. Respond in three sentences maximum." ) ``` Instructions are not the user's question — that is the `Prompt`. Instructions define the container; the prompt fills it. The framework injects instructions as the system-level context for the model. The model follows instructions at higher priority than prompt content, so put your constraints and rules in instructions, not in the prompt itself. --- ### `Prompt` `Prompt` is the user's input — the actual question, text, or content you want the model to process. Use `@PromptBuilder` for dynamic construction: ```swift // Builder style — for dynamic prompts let prompt = Prompt { "Correct this transcript: \(rawText)" } // String literal — also valid let response = try await session.respond(to: "Summarise the following: \(article)") ``` Prompt strings accept string interpolation. Keep prompts concise — every token in a prompt consumes context budget that competes with the response. --- ### The Critical `.content` Gotcha `respond(to:options:)` returns `LanguageModelSession.Response`, **not** `T` directly. `Response` is a wrapper struct. The actual generated value is at `.content`. This is the single most common mistake when first using the framework: ```swift // WRONG — response is Response, not String let text = try await session.respond(to: prompt) print(text.uppercased()) // compile error: Response has no uppercased() // RIGHT let response = try await session.respond(to: prompt) let text = response.content // String print(text.uppercased()) // With typed guided generation let response = try await session.respond( to: prompt, generating: MyOutputType.self ) let value = response.content // MyOutputType ``` Internalise this: `respond()` always returns `Response`. Always unwrap `.content` before using the value. --- ### `Response.rawContent` `response.rawContent` gives you the unprocessed `GeneratedContent` before guided generation parsing. This is the raw structured output the model produced, before it was decoded into your `@Generable` type. Use it for debugging when a response fails to parse or produces unexpected values — it shows you exactly what the model generated. --- ### Session-Per-Call vs Persistent Sessions This is a key architectural decision. Get it right at design time. **Session-per-call** — create a new `LanguageModelSession` for each request. No conversation history accumulates. This is the correct pattern for the vast majority of use cases: text correction, extraction, summarisation, classification, entity detection. Each request is independent. ```swift // Session-per-call — correct for stateless tasks func normalise(_ text: String) async throws -> String { let session = LanguageModelSession { "Fix speech-to-text errors in BJJ transcripts." "Corrections: kimora→Kimura, half card→Half Guard, darce→D'Arce" } let response = try await session.respond(to: Prompt { text }) return response.content } ``` **Persistent session** — keep the `LanguageModelSession` alive across multiple `respond()` calls. The session accumulates its `Transcript` as you go, so the model remembers previous exchanges. Use this *only* when the model needs that history to answer correctly — for example, a coaching chatbot where the user refers to something they said three turns ago. ```swift // Persistent session — for multi-turn conversation @Observable class ChatAssistant { private let session = LanguageModelSession { "You are a BJJ coach assistant." "Help the user analyse and improve their game based on their training logs." } func chat(_ message: String) async throws -> String { let response = try await session.respond(to: Prompt { message }) return response.content // transcript accumulates automatically } } ``` The risk with persistent sessions: the transcript grows with each exchange and eventually hits the context window limit, throwing `LanguageModelSession.GenerationError.exceededContextWindowSize`. For long-running conversations, you need a strategy for trimming or summarising history. Session-per-call has no such risk. **Default to session-per-call.** Only reach for persistent sessions when you have a concrete requirement for cross-turn memory. --- ## Part 3: Prompt Engineering for On-Device Models ### The On-Device Model is Smaller — This Changes Everything The model powering FoundationModels is Apple’s private on-device LLM — not GPT-4, not Claude, not Gemini. It is significantly smaller (estimated ~3B parameters) than frontier cloud models. This is a feature, not a bug — it runs entirely on your device with sub-second latency — but it fundamentally changes how you should write prompts. Techniques that work reliably on frontier models can **actively degrade** performance on the on-device model. Treat every prompt engineering heuristic you have learned from cloud models as a starting point to validate, not a rule to apply. --- ### Principle 1: Short, Direct Instructions Keep instructions under approximately 200 words total. Longer instructions dilute the signal — the model struggles to prioritise which parts matter most and may partially ignore sections buried deep in a long system prompt. Every sentence in your instructions should earn its place. If you can remove a sentence without changing the model’s behaviour, remove it. ```swift // WEAK — verbose, repetitive let session = LanguageModelSession { "You are a helpful assistant specialising in Brazilian Jiu-Jitsu." "Your primary purpose is to help users with BJJ-related queries." "When you see text from speech recognition, carefully examine it." "Your goal is to correct any speech recognition errors in the text." "Please make sure to handle common BJJ terminology correctly." } // STRONG — dense, direct let session = LanguageModelSession { "Fix speech-to-text errors in BJJ transcripts." "Correct misrecognised terms. Return only the corrected text." } ``` --- ### Principle 2: Explicit Corrections Beat Implied Inference If you have known domain-specific misrecognitions or corrections, list them explicitly. Do not rely on the model inferring what “fix BJJ terms” means — it may not know the canonical spellings for niche vocabulary. ```swift // WEAK — relies on the model knowing BJJ terminology "Fix any incorrectly transcribed Brazilian Jiu-Jitsu terminology." // STRONG — explicit correction table let session = LanguageModelSession { "Fix speech-to-text errors in BJJ transcripts." "Common misrecognitions: kimora/kimura -> Kimura, half card/half god -> Half Guard," "darce/dart -> D'Arce, rnc/arnc -> Rear Naked Choke, omoa plata -> Omoplata." } ``` The on-device model does not have the deep BJJ domain knowledge that a frontier model trained on vast internet corpora might have. Make your domain knowledge explicit in the prompt rather than hoping the model already knows it. --- ### Principle 3: Include a Domain Vocabulary in Instructions For niche domains — BJJ, medicine, legal, finance, specialised engineering — include a vocabulary list or canonical term glossary in your instructions. This gives the model the reference it needs to make correct corrections or use correct terminology in its output. ```swift let session = LanguageModelSession { "You are a BJJ transcript corrector." "Canonical terms: Guard, Half Guard, Mount, Back Mount, Side Control," "North-South, Turtle, Closed Guard, Open Guard, De La Riva, X-Guard," "Kimura, Armbar, Triangle, Rear Naked Choke, D'Arce, Anaconda," "Omoplata, Heel Hook, Kneebar, Toe Hold." "Correct misrecognised terms to their canonical forms." } ``` This is more token-efficient than hoping for inference, and significantly more reliable. --- ### Principle 4: One Task Per Session Do not ask the model to perform multiple distinct tasks in one session. Correction AND summarisation AND extraction in a single prompt will produce worse results on the on-device model than running them as separate sessions. ```swift // WEAK — three tasks in one call let response = try await session.respond(to: Prompt { "Correct BJJ terms, summarise the session, and extract techniques used." rawText }) // STRONG — one focused task per session let corrected = try await correctSession.respond(to: Prompt { rawText }) let summary = try await summarySession.respond(to: Prompt { corrected.content }) let techniques = try await extractSession.respond(to: Prompt { corrected.content }) ``` The overhead of running multiple sessions is minimal compared to the reliability gain from focused, single-task prompts. --- ### Principle 5: Avoid Chain-of-Thought Prompting "Think step by step", "Let’s reason through this", and similar chain-of-thought prompts improve performance on large models but **add noise on smaller on-device models**. The model produces reasoning tokens that consume context budget without materially improving the final answer — and can sometimes cause the model to talk itself into a worse answer. Do not use CoT prompting for on-device tasks. Give direct instructions and ask for direct output. ```swift // WEAK — chain-of-thought on a small model "Think step by step about what BJJ terms might have been misrecognised, then correct them." // STRONG — direct instruction "Correct misrecognised BJJ terms. Return only the corrected text." ``` --- ### Frontier Model vs On-Device: Comparison | Technique | Frontier Model | On-Device Model | |-----------|----------------|-----------------| | Chain-of-thought prompting | Works well ✅ | Degrades performance ❌ | | Long, elaborate instructions | Fine ✅ | Unreliable ⚠️ | | Implicit domain inference | Often works ✅ | Unreliable for niche domains ⚠️ | | Explicit correction lists | Helpful ✅ | Critical ✅✅ | | Multi-task instructions | Usually works ✅ | Fails ❌ | | Short, direct instructions | Works ✅ | Works best ✅✅ | | CoT / "think step by step" | Major boost ✅ | Noise and overhead ❌ | | Few-shot examples in prompt | Works ✅ | Works, watch token budget ⚠️ | --- ### The `#Playground` Macro — Fast Prompt Iteration Available from iOS 26.4+ (February 2026 Foundation Models update), the `#Playground` macro lets you iterate on prompts directly in Xcode without building and running the full app. Write a `#Playground` block in a Swift file, run it from the Xcode canvas, and see the response inline. When you run the canvas, the output shows **Input Token Count** and **Response Token Count** separately — useful for understanding your prompt’s cost against the ~4,096 token context window estimate shown in canvas. ```swift import FoundationModels #Playground { let session = LanguageModelSession { "Fix BJJ transcript errors." "kimora -> Kimura, half card -> Half Guard, darce -> D'Arce" } let response = try await session.respond( to: "worked kimora from half card today, finished with darce" ) response.content // displayed in Xcode canvas } ``` This is the fastest feedback loop for prompt engineering. Iterate on your instructions in the playground before wiring them into the app. Test with the exact on-device model, not a frontier proxy — behaviour differs significantly, and a prompt that works on GPT-4 may not work well on the Apple on-device model. --- ## Part 4: Guided Generation (`@Generable`) ### What `@Generable` Does `@Generable` is an attached macro that synthesises `Generable` protocol conformance on a struct or enum. At compile time it does three things: 1. Generates a `PartiallyGenerated` associated type — a mirror of the struct where every stored property is `Optional`. This is the type you receive when iterating a stream mid-generation. 2. Infers a JSON schema from the struct's property types and any `@Guide` annotations. That schema drives constrained sampling, which guarantees the output is always structurally valid — no parsing, no runtime crashes from malformed responses. 3. Synthesises `ConvertibleFromGeneratedContent` and `ConvertibleToGeneratedContent` conformances, which handle encoding and decoding between the model's internal representation and your Swift type. The model generates properties in the order they are declared, so put properties that should influence later ones first. ### Basic Usage ```swift @Generable struct BookReview { var title: String var rating: Int var summary: String } let session = LanguageModelSession() let response = try await session.respond( to: "Review this book: \(bookTitle)", generating: BookReview.self ) let review = response.content // BookReview — fully populated, no parsing needed ``` ### `@Guide` — Descriptions `@Guide(description:)` tells the model what a property means. Include descriptions for any property where the name alone is ambiguous. Keep them concise — long descriptions consume context and add latency. ```swift @Generable struct NormalisedTranscript { @Guide(description: "The full transcript with BJJ terms corrected and properly cased") var normalisedText: String @Guide(description: "BJJ terms found in the transcript, each in canonical form e.g. 'Kimura', 'Half Guard'") var extractedTerms: [String] } ``` You can also annotate the struct itself via `@Generable(description:)`: ```swift @Generable(description: "A classified support ticket with priority and routing metadata") struct TicketClassification { @Guide(description: "Urgency level for routing decisions") var priority: Int } ``` ### `@Guide` — Constraints with `GenerationGuide` `@Guide` also accepts one or more `GenerationGuide` values to enforce numeric bounds and array sizes. All bounds are inclusive. ```swift @Generable struct ProductReview { @Guide(description: "Star rating", .range(1...5)) var rating: Int @Guide(description: "Key selling points, at most three", .maximumCount(3)) var keyPoints: [String] @Guide(description: "Topics addressed, at least one", .minimumCount(1)) var topics: [String] @Guide(description: "Quality score", .minimum(0), .maximum(100)) var qualityScore: Double } ``` Available `GenerationGuide` constraints: | Constraint | Applies To | Behaviour | |-----------|-----------|-----------| | `.range(n...m)` | Numeric types | Value must fall within the closed range (inclusive both ends) | | `.minimum(n)` | Numeric types | Value must be ≥ n | | `.maximum(n)` | Numeric types | Value must be ≤ n | | `.minimumCount(n)` | `[T]` arrays | Array must contain ≥ n elements | | `.maximumCount(n)` | `[T]` arrays | Array must contain ≤ n elements | Multiple guides can be combined on a single property as variadic arguments — `.minimum(0), .maximum(100)` is valid. ### Enums as `@Generable` Types Mark enums with `@Generable` to use them as property types inside other `@Generable` structs. The constrained sampler restricts output to valid case names only: ```swift @Generable enum Sentiment { case positive case neutral case negative } @Generable struct MessageClassification { @Guide(description: "Overall tone of the message") var sentiment: Sentiment @Guide(description: "Urgency, 1 = routine, 5 = escalate immediately", .range(1...5)) var urgency: Int } ``` Enums with associated values are also supported — the `@Generable` macro ensures all associated and nested values are themselves generable. ### `PartiallyGenerated` — Streaming Snapshots Every `@Generable` type gets a synthesised `PartiallyGenerated` associated type. It is a version of the struct where all stored properties are `Optional`, representing work-in-progress output during streaming: ```swift for try await snapshot in session.streamResponse( to: "Review: \(bookTitle)", generating: BookReview.self ) { let partial = snapshot.content // BookReview.PartiallyGenerated // partial.title might be "The G..." while still generating // partial.rating is nil until the model has written that property if let title = partial.title { titleLabel.text = title } } // After the loop completes, collect() gives a Response with all properties set ``` `PartiallyGenerated` is a streaming-only concern. When you call `respond()` (non-streaming), you receive the completed `Content` type directly — no optionals, no partial states to handle. ### `GeneratedContent` — Untyped Escape Hatch `GeneratedContent` is the framework's internal structured representation of model output. You normally never interact with it — `@Generable` handles encoding and decoding automatically. When you need raw access, every `Response` exposes: ```swift let response = try await session.respond(to: prompt, generating: BookReview.self) response.content // BookReview — your typed result response.rawContent // GeneratedContent — the underlying parsed value ``` `rawContent` is useful for debugging when model output does not match your type. You can inspect it to see exactly what the model produced before your `ConvertibleFromGeneratedContent` init ran. For fully dynamic schemas (where the type is not known at compile time), use `respond(schema:)` with a `GenerationSchema` built from `DynamicGenerationSchema`. The response will have `Content == GeneratedContent`, and you decode manually via `value(_:forProperty:)`: ```swift let response = try await session.respond(to: prompt, schema: schema) let soup: String = try response.content.value(forProperty: "dailySoup") ``` ### Independent Constructability — Critical for Testing `@Generable` types must be constructable via their memberwise initialiser without running the model. This is the property that makes them unit-testable: ```swift // Your output type @Generable struct NormalisedTranscript { @Guide(description: "Corrected transcript text") var normalisedText: String @Guide(description: "Extracted BJJ terms in canonical form") var extractedTerms: [String] } // Tests run on any machine — no Apple Intelligence required func testNormalisationOutputType() { let result = NormalisedTranscript( normalisedText: "Worked Kimura from Half Guard", extractedTerms: ["Kimura", "Half Guard"] ) #expect(result.normalisedText.contains("Kimura")) #expect(result.extractedTerms.count == 2) } ``` If your `@Generable` type has custom initialisers that depend on model output, or computed properties with side effects, you have broken this contract. Keep output types as plain data containers — structs with stored properties and no embedded behaviour. ### Protocol Hierarchy You rarely interact with these directly — `@Generable` wires everything up — but understanding the hierarchy helps when debugging conformance errors or writing manual implementations: | Protocol | Role | |---------|------| | `Generable` | Synthesised by `@Generable`. Requires `PartiallyGenerated` associated type, `generationSchema`, and `ConvertibleFromGeneratedContent` init. Inherits from both `Convertible*` protocols. | | `ConvertibleFromGeneratedContent` | Types constructable from model output. `Int`, `String`, `Bool`, `Float`, `Double`, `Decimal`, `Array`, enums, and `@Generable` structs all conform automatically. | | `ConvertibleToGeneratedContent` | Types that can be serialised back to `GeneratedContent`. Used for tool output and prompt injection. Inherits from `PromptRepresentable`. | | `PromptRepresentable` | Types that can appear inside a `@PromptBuilder` closure. `@Generable` types conform, so you can pass model output directly back as prompt input in a subsequent call. | --- ## Part 5: Streaming ### The Core Decision: Stream or Not? | Use Case | Method | Reason | |----------|--------|--------| | Live text appearing for the user (typing effect) | `streamResponse()` | User sees progress, engagement increases | | Processing output programmatically | `respond()` | Simpler — no partial state handling | | Background pipeline (normalisation, extraction) | `respond()` | No UI benefit; streaming increases rate-limit risk in background | | Long-form generation the user is watching | `streamResponse()` | Progress feedback reduces perceived latency | | Structured `@Generable` output | `respond()` preferred | Partial structs with all-Optional properties add complexity for no gain | Apple's own docs note that background tasks should use the non-streaming `respond()` to reduce the likelihood of encountering `GenerationError.rateLimited` errors. ### String Streaming ```swift let stream = session.streamResponse(to: "Summarise: \(text)") for try await snapshot in stream { let partial: String = snapshot.content // String grows with each chunk await MainActor.run { self.displayText = partial } } // Or skip the loop entirely and just collect the final result let fullResponse = try await stream.collect() let finalText = fullResponse.content // String — complete ``` ### Typed (`@Generable`) Streaming ```swift let stream = session.streamResponse( to: "Review: \(text)", generating: BookReview.self ) for try await snapshot in stream { let partial = snapshot.content // BookReview.PartiallyGenerated // All properties are Optional — may be nil while the model generates earlier properties if let title = partial.title { titleLabel.text = title } if let rating = partial.rating { updateStars(rating) } } // Collect to receive the complete, fully-typed result let response = try await stream.collect() let review = response.content // BookReview — all properties non-nil ``` ### `ResponseStream` `streamResponse()` returns a `ResponseStream`, which is an `AsyncSequence` of `ResponseStream.Snapshot` values. The type parameter matches what you would get from the equivalent `respond()` call. ```swift // Type relationships session.streamResponse(to: prompt) // → ResponseStream session.streamResponse(to: prompt, generating: BookReview.self) // → ResponseStream // Each snapshot during the stream: snapshot.content // → String (for string stream) // → BookReview.PartiallyGenerated (for typed stream — all properties Optional) // After .collect(): response.content // → String (complete) // → BookReview (complete, all properties set) ``` `ResponseStream` conforms to `AsyncSequence`, so you get the full suite of async sequence operators — `map`, `filter`, `prefix`, etc. ### Progressive UI Update Pattern The natural pattern for SwiftUI is to assign each snapshot directly to a `@State` property: ```swift @Observable final class SummaryViewModel { var generatedText = "" func generate(prompt: Prompt) async throws { let stream = session.streamResponse(to: prompt) for try await snapshot in stream { generatedText = snapshot.content // @Observable triggers view update per chunk } } } // In the view: Text(viewModel.generatedText) .animation(.default, value: viewModel.generatedText) ``` For `@Generable` types, update individual UI elements as their backing properties become available: ```swift for try await snapshot in stream { let partial = snapshot.content // BookReview.PartiallyGenerated titleLabel.text = partial.title ?? titleLabel.text // retain last known value summaryLabel.text = partial.summary ?? summaryLabel.text } ``` ### `collect()` — Streams to Full Response `collect()` is an async method on `ResponseStream` that waits for the stream to finish and returns a complete `Response`: ```swift let stream = session.streamResponse(to: prompt) // Option A: observe snapshots AND get the final result for try await snapshot in stream { updateProgressUI(snapshot.content) } // Stream is exhausted — collect() returns immediately since the stream is done let finalResponse = try await stream.collect() // Option B: skip observation, just get the final result let finalResponse = try await stream.collect() ``` If the stream finished with an error before `collect()` is called, `collect()` propagates that error. If the stream completed successfully, `collect()` returns immediately with the cached result. ### Error Handling in Streams Errors are thrown during iteration, not at stream creation (the stream object itself is always returned, even if the model will fail): ```swift do { for try await snapshot in stream { // process snapshot } } catch LanguageModelSession.GenerationError.rateLimited(let retryAfter) { // system under load — retry after the given delay } catch LanguageModelSession.GenerationError.exceededContextWindowSize { // prompt + history too long — trim the input } catch LanguageModelSession.GenerationError.guardrailViolation { // content flagged — show alternative UX } catch { // unexpected error } ``` The same error types apply to `streamResponse()` as to `respond()` — the difference is only in when they surface during your `async` call. --- ## Part 6: Generation Options `GenerationOptions` is a struct you pass to `respond()` or `streamResponse()` to control how the model generates output. All properties are optional — omitting them leaves the model at its defaults, which are usually correct. ```swift let options = GenerationOptions( temperature: 0.1, maximumResponseTokens: 200 ) let response = try await session.respond(to: prompt, options: options) ``` ### `temperature` Temperature controls how "creative" or "random" the model's output is, on a scale from 0.0 to 1.0. `nil` (the default) lets the model use its own calibrated default, which is appropriate for most tasks. | Temperature | Behaviour | Best For | |-------------|-----------|----------| | `nil` | Model default (typically ~0.7) | General use — let the model decide | | `0.0–0.2` | Near-deterministic, consistent | Corrections, extraction, classification | | `0.3–0.6` | Balanced | Summarisation, analysis | | `0.7–1.0` | Creative, varied | Brainstorming, dialogue, story generation | **The most common mistake**: setting a high temperature for a correction or extraction task. If you are normalising speech-to-text errors using `@Generable`, you want the model to produce the *same correct answer* every time — not a creatively varied interpretation. Use `nil` or set it low. ```swift // ❌ High temperature for a structured correction task — produces inconsistent output let options = GenerationOptions(temperature: 0.9) let response = try await session.respond( to: Prompt { rawTranscript }, generating: NormalisedTranscript.self, options: options ) // ✅ Low temperature — deterministic, reliable corrections let options = GenerationOptions(temperature: 0.1) // or just omit options entirely — the schema constraints already reduce variance ``` For `@Generable` output, the constrained sampler that enforces your schema also reduces variance regardless of temperature. But setting temperature low is still good practice to signal your intent and produce maximally consistent output. ### `GenerationOptions.SamplingMode` `SamplingMode` gives you control over the underlying sampling algorithm. The two modes are: **`.greedy`** — always selects the single most probable token at each step. Maximally deterministic. Best for tasks with one correct answer (grammar correction, structured extraction). **`.random(temperature:)`** — samples from the probability distribution, with temperature scaling how broadly. This is the mode behind the `temperature` parameter. ```swift // Explicit greedy sampling — maximum determinism let options = GenerationOptions( sampling: .greedy ) // Random sampling at a specific temperature let options = GenerationOptions( sampling: .random(temperature: 0.7) ) ``` The `temperature` property on `GenerationOptions` is a convenience shorthand for `.random(temperature:)`. Setting `temperature: 0.0` is equivalent to `.greedy`. ### `maximumResponseTokens` Sets an upper bound on how many tokens the model can generate in its response. Useful for: - **Capping costs** (on-device, this is latency rather than money) when you know responses should be short - **Preventing runaway generation** in summary tasks where you want concise output - **Enforcing length constraints** the instructions alone can't reliably enforce ```swift // Limit to a short summary (~100 tokens ≈ ~75 words) let options = GenerationOptions(maximumResponseTokens: 100) let response = try await session.respond( to: "Summarise this training session in one paragraph: \(notes)", options: options ) ``` Be careful not to set `maximumResponseTokens` too low for `@Generable` types — if the model runs out of tokens before completing your struct, it will throw `GenerationError.exceededContextWindowSize`. --- ## Part 7: Tool Calling Tools let the model call back into your Swift code to fetch data or perform actions during generation. The model autonomously decides whether and when to call a tool — you provide the definitions; it decides whether they are relevant to the current prompt. ### The `Tool` Protocol Conform to `Tool` to define a callable function the model can invoke: ```swift @available(iOS 26, *) struct CurrentDateTool: Tool { let name = "getCurrentDate" let description = "Returns today's date in ISO 8601 format (YYYY-MM-DD)." // Arguments the model will pass — a @Generable struct @Generable struct Arguments { @Guide(description: "Optional timezone identifier, e.g. 'Europe/Dublin'") var timezone: String? } // Return type — any PromptRepresentable (String is simplest) func call(arguments: Arguments) async -> String { let formatter = ISO8601DateFormatter() if let tz = arguments.timezone, let zone = TimeZone(identifier: tz) { formatter.timeZone = zone } return formatter.string(from: Date()) } } ``` Key constraints: - `Arguments` must conform to `ConvertibleFromGeneratedContent`. A `@Generable` struct is the standard approach — the macro handles conformance automatically. - `Output` (the return type) must conform to `PromptRepresentable`. `String` always works. `@Generable` types also work. - `call(arguments:)` is implicitly `@concurrent` — it runs off the main actor. Make it `async` if you need to do async work. ### Registering Tools With a Session Pass tools in the `tools` parameter when creating a session: ```swift @available(iOS 26, *) let session = LanguageModelSession( tools: [CurrentDateTool(), UserProfileTool()] ) { "You are a task scheduling assistant." "Use getCurrentDate to determine today's date before scheduling." } let response = try await session.respond( to: "Schedule a reminder for two weeks from today" ) let text = response.content // model called getCurrentDate internally ``` The model receives each tool's `name`, `description`, and the JSON schema derived from `Arguments`. It uses the name and description to decide whether calling the tool is relevant to the prompt. **Name and description are the primary signals** — write them as short, specific phrases. ### How the Model Decides to Call Tools You cannot force the model to call a specific tool. It decides autonomously based on: 1. Whether the tool's **name and description** match the intent of the prompt 2. Whether it already has the information it needs without a tool call 3. Whether the prompt semantically requires external data The model may call zero tools (if it can answer from its knowledge), call one tool, or call multiple tools before producing its final response. ### Critical Performance Insight: Pre-Fetch vs Tool This is Apple's own guidance from the documentation, and it matters for performance: **If you ALWAYS need data from a source**, inject it directly into instructions rather than defining a tool. ```swift // ❌ Tool for data you always need — adds latency on every call struct UserPreferencesTool: Tool { ... } // ✅ Pre-fetch and inject — one fetch, zero tool overhead let preferences = await loadUserPreferences() let session = LanguageModelSession { "User preferences: \(preferences.serialised)" "Use these preferences when making recommendations." } ``` Tools have two costs: 1. **Token cost** — each tool definition (name + description + arguments schema) consumes context budget. A tool with a complex `Arguments` struct can cost 50–100 tokens just for its definition. 2. **Latency cost** — each tool call is a model inference round-trip: the model generates a call, your code runs, the result is injected back, the model continues. This adds meaningful latency. **Reserve tools for data that is conditionally needed** — data you might need depending on what the user asks. ### Context Window Cost Define tools concisely. The model sees `name + description + arguments schema` for every tool, every call, whether it uses them or not. ```swift // ❌ Verbose tool definition — each call consumes more context struct FetchUserTrainingHistoryForTheLastSixMonthsTool: Tool { let name = "fetchUserTrainingHistoryForTheLastSixMonths" let description = "This tool fetches the complete training history of the current user for the past six calendar months, including all session notes, techniques practised, and time spent..." // ... } // ✅ Concise — same capability, fraction of the tokens struct TrainingHistoryTool: Tool { let name = "getTrainingHistory" let description = "Returns recent training sessions with notes and techniques." // ... } ``` A practical limit is **3–5 tools per session**. Beyond that, the definitions alone consume a significant portion of context, leaving less room for the actual conversation. ### Tool Calls in the Transcript When the model calls a tool, it appears in the session's `Transcript` as two entries: - `Transcript.Entry.toolCalls` — the model's request(s) to call tools - `Transcript.Entry.toolOutput` — the results that were injected back This is useful when debugging why the model produced a particular response — you can inspect the transcript to see exactly what tool calls were made and what data the model received. See Part 9 (The Transcript) for full `Transcript` coverage. --- ## Part 8: Token Budget The on-device model has a **fixed context window** shared by all inputs and outputs for a session. Understanding how that budget is consumed is essential for building reliable features — especially multi-turn conversations and tool-using sessions. ### The Budget Breakdown Every token in a session competes for the same fixed window: ``` Total Context Window ├── Instructions (system prompt) ├── Tool definitions (name + description + args schema × number of tools) ├── Transcript history (all previous turns) ├── Current prompt └── Response (tokens generated) ``` Response tokens are not free — they come out of the same pool as input. A long system prompt and a long conversation history leave less room for both the current prompt and its response. ### Measuring Token Usage `SystemLanguageModel` exposes three `tokenUsage(for:)` overloads (added February 2026): ```swift let model = SystemLanguageModel.default // 1. Cost of Instructions + tool definitions let instrUsage = try await model.tokenUsage( for: instructions, tools: [MyTool()] ) print(instrUsage.tokenCount) // e.g. 180 // 2. Cost of a single Prompt let promptUsage = try await model.tokenUsage(for: prompt) print(promptUsage.tokenCount) // e.g. 45 // 3. Cost of a saved Transcript (conversation history) let historyUsage = try await model.tokenUsage(for: transcript.entries) print(historyUsage.tokenCount) // e.g. 620 ``` All three return `SystemLanguageModel.TokenUsage`, with a single `tokenCount: Int` property. Use these to profile your sessions during development rather than guessing. ### The `contextSize` Property `SystemLanguageModel.contextSize` returns the total context window size in tokens as an async `Int`. It is back-deployed to earlier OS versions via `@backDeployed`: ```swift let totalWindow = await SystemLanguageModel.default.contextSize // e.g. 4096 let available = totalWindow - instrUsage.tokenCount - historyUsage.tokenCount print("Available for prompt + response: \(available) tokens") ``` Use `contextSize` to compute headroom before sending a prompt, particularly in multi-turn sessions where history accumulates. ### `GenerationError.exceededContextWindowSize` This error is thrown when the combined input (instructions + tools + history + prompt) exceeds the context window. Handle it gracefully: ```swift do { let response = try await session.respond(to: prompt) } catch LanguageModelSession.GenerationError.exceededContextWindowSize { // Strategies: // 1. Summarise the conversation history and start a new session // 2. Trim the oldest transcript entries // 3. Remove tool definitions you don't strictly need // 4. Shorten the prompt } ``` For multi-turn sessions, the most robust strategy is to detect when history is growing long and summarise it before continuing: ```swift // When history exceeds a threshold, compress it if historyTokenCount > contextSize / 2 { let summary = try await summariseHistory(session.transcript) // Start fresh session with summary in instructions session = LanguageModelSession { "Previous conversation summary: \(summary)" } } ``` ### The `#Playground` Macro for Budget Profiling The `#Playground` macro in Xcode (26.4+) shows **Input Token Count** and **Response Token Count** separately in the canvas after each run. This is the fastest way to profile token usage during development — no logging, no instrumentation, just iterate on the prompt and watch the counts update in real time. ### Rules of Thumb | Content | Approximate Token Cost | |---------|----------------------| | 1 word | ~1.3 tokens | | 100 words | ~130 tokens | | 1 page (250 words) | ~325 tokens | | Simple `@Generable` struct (2 props) | ~50 tokens overhead | | Tool definition (name + description + args) | ~50–100 tokens | | Default context window | ~4,096 tokens | A 4k window sounds large but fills up quickly in multi-turn sessions with tool-heavy prompts. --- ## Part 9: The Transcript `Transcript` is the linear record of everything that has happened in a `LanguageModelSession`. Every turn adds entries. The transcript is how the model "remembers" previous exchanges in a multi-turn conversation. ### `Transcript.Entry` The transcript is an array of `Transcript.Entry` values. Each entry is one of five cases: | Entry | When It Appears | |-------|----------------| | `.instructions(Transcript.Instructions)` | Session creation — the system prompt | | `.prompt(Transcript.Prompt)` | Each time you call `respond()` or `streamResponse()` | | `.response(Transcript.Response)` | Each model reply | | `.toolCalls(Transcript.ToolCalls)` | When the model decides to invoke one or more tools | | `.toolOutput(Transcript.ToolOutput)` | The result(s) returned from your tool's `call()` | A simple two-turn conversation produces this entry sequence: ``` .instructions ← session setup .prompt ← "What's the best sweep from Half Guard?" .response ← "The Hip Bump Sweep is..." .prompt ← "How do I set it up?" .response ← "Start by flattening your opponent..." ``` A tool-calling exchange adds two extra entries per tool call: ``` .prompt ← "What techniques did I drill last Tuesday?" .toolCalls ← [getTrainingHistory(date: "2026-02-24")] .toolOutput ← [{ sessions: [...] }] .response ← "Last Tuesday you drilled..." ``` ### Reading the Transcript Access the current session transcript via `session.transcript`: ```swift let session = LanguageModelSession { "You are a BJJ coach." } _ = try await session.respond(to: "What is the Kimura?") _ = try await session.respond(to: "How do I finish it from Guard?") // Inspect the transcript for entry in session.transcript.entries { switch entry { case .prompt(let p): print("User: \(p.segments.map(\.description).joined())") case .response(let r): print("Model: \(r.segments.map(\.description).joined())") default: break } } ``` ### Saving and Resuming Sessions Save the transcript to persist a conversation and resume it later — useful for a coaching assistant where the user expects the model to remember what they discussed in previous sessions: ```swift // Save let savedTranscript = session.transcript // Persist to SwiftData, UserDefaults, or disk... // Resume — new session with full history let resumedSession = LanguageModelSession( model: SystemLanguageModel.default, tools: [], transcript: savedTranscript ) // Model now has full context of the previous conversation let response = try await resumedSession.respond(to: "Where were we?") ``` The resumed session is identical in behaviour to a session that never stopped — the model sees the full entry history. ### When to Use the Transcript **Use transcript accumulation when:** - The model needs to refer back to something the user said earlier ("as I mentioned before...") - You are building a multi-turn chatbot or coaching assistant - Continuity across app sessions is a user-facing feature **Do NOT accumulate transcripts when:** - Each call is independent (normalisation, extraction, summarisation, classification) - You are using session-per-call — there is no transcript to worry about - The task is stateless — the model does not need to "remember" anything Unnecessary transcript accumulation wastes context budget and eventually causes `GenerationError.exceededContextWindowSize`. Most FoundationModels use cases do not need cross-turn memory — use session-per-call by default (see Part 2). --- ## Part 10: Failure Modes & Graceful Degradation FoundationModels can fail in ways that are different from a typical network API. Most failures are environmental (device eligibility, model state, system load) rather than logic errors. The right response in almost every case is graceful degradation, not throwing errors up to the UI. ### `GenerationError` Cases `LanguageModelSession.GenerationError` is thrown from `respond()` and `streamResponse()`: **`.exceededContextWindowSize`** The combined input (instructions + tools + history + prompt) exceeded the context window. Solutions in order of preference: 1. Reduce the prompt — summarise or truncate the input text 2. Trim the oldest transcript entries in a multi-turn session 3. Remove tool definitions that aren't needed for this call 4. Split into multiple sessions **`.rateLimited`** The system is under load. The on-device model is a shared resource — all apps use the same model, and the OS rate-limits when demand is high. Handle with simple exponential backoff: ```swift func generateWithRetry(session: LanguageModelSession, prompt: Prompt) async throws -> String { var delay: UInt64 = 1_000_000_000 // 1 second for attempt in 1...3 { do { return try await session.respond(to: prompt).content } catch LanguageModelSession.GenerationError.rateLimited { if attempt < 3 { try await Task.sleep(nanoseconds: delay) delay *= 2 } } } throw LanguageModelSession.GenerationError.rateLimited // re-throw after 3 attempts } ``` **`.guardrailViolation`** The content triggered safety filtering. This can happen on the prompt (the input was flagged) or on the response (the model started generating something that triggered the filter). The error contains context on what was flagged. **`.unsupportedGuide`** A `@Guide` constraint on a `@Generable` type is not supported for the current model or OS version. This should not occur in production if your deployment target is correct, but handle it defensively. **`LanguageModelSession.GenerationError.Refusal`** When the model declines to answer a prompt, it throws a `Refusal` error. `Refusal` is special because it includes an explanation: ```swift do { let response = try await session.respond(to: prompt) } catch let refusal as LanguageModelSession.GenerationError.Refusal { // Get the explanation as a complete Response let explanation = try await refusal.explanation print(explanation.content) // "I can't help with that because..." // Or stream it for try await snapshot in refusal.explanationStream { print(snapshot.content) } } ``` ### Production Pattern: The Never-Throws Service The cleanest production pattern is a service method that **never throws** — it returns the raw input unchanged on any failure. Callers have zero error handling burden, and worst case equals current pre-AI behaviour: ```swift @available(iOS 26, *) final class TranscriptNormalisationService { func normalise(_ rawTranscript: String) async -> String { guard !rawTranscript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return rawTranscript } do { let session = LanguageModelSession { "Fix speech-to-text errors in BJJ transcripts." "Return only the corrected text." } let response = try await session.respond( to: Prompt { rawTranscript }, generating: NormalisedTranscript.self ) return response.content.normalisedText } catch { // Log the error, return the raw transcript unchanged GraplaLogger.data.error("Normalisation failed: \(error)") return rawTranscript } } } ``` This pattern means: - The caller always gets a `String` back — no try/catch required - If AI is unavailable, the app works exactly as before - Errors are logged for debugging without surfacing to the user ### Additional Production Patterns **Cache availability at setup, not per-call.** `SystemLanguageModel.default.availability` has non-trivial overhead. Check it once when the view or service initialises and store the result. Availability doesn't change mid-session. ```swift // ❌ Checking availability on every call func normalise(_ text: String) async -> String { guard SystemLanguageModel.default.isAvailable else { return text } // overhead each time ... } // ✅ Check once, cache final class NormalisationService { private let isAvailable = SystemLanguageModel.default.isAvailable func normalise(_ text: String) async -> String { guard isAvailable else { return text } ... } } ``` **The fallback path is production code.** On the majority of devices in 2026, Apple Intelligence will not be available (older hardware, non-supported regions, disabled in settings). Your non-AI code path is not a fallback — it is the primary path for most users. Test it as thoroughly as the AI path. **Use `AnyObject?` for iOS 26 services in SwiftUI views.** Covered in Part 1, but worth repeating: avoid `@available(iOS 26, *)` on `@State` properties. Use `AnyObject?` and cast inside `#available` guards to prevent the constraint propagating to the whole view. --- ## Part 11: Testing The most important insight for testing FoundationModels code: **most of your test suite should never touch the model**. Well-structured FoundationModels code is testable at every layer without Apple Intelligence. ### The Four Test Categories #### 1. Output Type Tests (No Model Required) `@Generable` structs are plain data containers with memberwise initialisers. You can construct them directly in tests, verify `Equatable` conformance, and test edge cases without the model ever running: ```swift @Suite("NormalisedTranscript") struct NormalisedTranscriptTests { @Test func construction() { let result = NormalisedTranscript( normalisedText: "Worked Kimura from Half Guard", extractedTerms: ["Kimura", "Half Guard"] ) #expect(result.normalisedText == "Worked Kimura from Half Guard") #expect(result.extractedTerms.count == 2) #expect(result.extractedTerms.contains("Kimura")) } @Test func equatable() { let a = NormalisedTranscript(normalisedText: "Test", extractedTerms: []) let b = NormalisedTranscript(normalisedText: "Test", extractedTerms: []) #expect(a == b) } @Test func emptyTerms() { let result = NormalisedTranscript(normalisedText: "Some text", extractedTerms: []) #expect(result.extractedTerms.isEmpty) } } ``` These tests run in CI on any machine. No simulator required. #### 2. Service Fallback Tests (Works on All Simulators) Test that your service returns the raw input unchanged when the model is unavailable. The simulator never has Apple Intelligence, so this path is always exercised: ```swift @MainActor @Suite("TranscriptNormalisationService") struct TranscriptNormalisationServiceTests { @Test func emptyTranscriptReturnsEmpty() async { guard #available(iOS 26, *) else { return } let service = TranscriptNormalisationService() let result = await service.normalise("") #expect(result.isEmpty) } @Test func whitespaceOnlyReturnsUnchanged() async { guard #available(iOS 26, *) else { return } let service = TranscriptNormalisationService() let result = await service.normalise(" \n ") #expect(result.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } @Test func unavailableModelReturnsFallback() async { guard #available(iOS 26, *) else { return } // On simulator, model is unavailable — service must return raw transcript let service = TranscriptNormalisationService() let raw = "worked on kimora from half card today" let result = await service.normalise(raw) // On device: could be corrected. On simulator: must equal raw input. #expect(!result.isEmpty) // just verify it doesn't crash } } ``` #### 3. Availability Tests (Works Everywhere) Verify your availability checking code runs without crashing. Do **not** assert the specific availability state — it varies by machine, OS, and whether Apple Intelligence is enabled: ```swift @Test func availabilityCheckDoesNotCrash() { guard #available(iOS 26, *) else { return } let availability = SystemLanguageModel.default.availability // Just verify we get a valid state — don't assert which state switch availability { case .available: break // fine case .unavailable: break // also fine — expected on simulator @unknown default: break } } ``` #### 4. On-Device Tests (Manual, `.disabled()` by Default) Mark tests that require a real device with Apple Intelligence as `.disabled()`. They are skipped in CI but can be run manually on a real device: ```swift @Test("Normalises BJJ terms on-device", .disabled("Requires device with Apple Intelligence")) func normalisesTermsOnDevice() async throws { guard #available(iOS 26, *) else { return } let service = TranscriptNormalisationService() let raw = "rolled today, worked on my kimora from half card" let result = await service.normalise(raw) // On a real device with AI, these should be corrected #expect(result.contains("Kimura") || result.contains("kimura")) #expect(!result.contains("kimora")) } ``` To run these locally: open the test plan in Xcode, filter by the test name, and run on a connected iPhone 15 Pro or later with Apple Intelligence enabled. ### Testing Checklist | Test | Runs in CI | Requires Apple Intelligence | |------|-----------|---------------------------| | `@Generable` type construction | ✅ | ❌ | | `@Generable` equatable | ✅ | ❌ | | Service empty input handling | ✅ | ❌ | | Service fallback (model unavailable) | ✅ | ❌ | | Availability check no-crash | ✅ | ❌ | | End-to-end normalisation | ❌ (manual) | ✅ | Aim for 100% automated coverage of everything above the model boundary. The on-device generation itself is integration-tested manually. --- ## Part 12: Example Use Cases These examples cover the range of tasks FoundationModels handles well. Each follows the same pattern: on-device, private, structured output, graceful fallback. --- ### 1. Sports / BJJ App — Domain-Specific Transcript Normalisation **Use case**: Correct speech-to-text misrecognitions of BJJ terms before feeding into entity extraction. **Why FoundationModels**: A regex can't handle "kimora" → "Kimura" contextually; a cloud API sends private training notes offsite. On-device gets both right. ```swift @Generable struct NormalisedTranscript { @Guide(description: "The transcript with BJJ terms corrected") var normalisedText: String @Guide(description: "Canonical BJJ terms extracted, e.g. ['Kimura', 'Half Guard']") var extractedTerms: [String] } ``` **Tools needed**: None — pure text transformation. Session-per-call. --- ### 2. Recipe App — Ingredient Extraction From Voice **Use case**: "I need some eggs, any kind of cheese, and that Italian herb" → structured shopping list. **Why FoundationModels**: The model handles colloquial descriptions ("that Italian herb" → "basil"), vague quantities ("some"), and variety descriptions ("any kind of cheese") — none of which a regex can parse. ```swift @Generable struct Ingredient { @Guide(description: "Canonical ingredient name, e.g. 'basil', 'eggs'") var name: String @Guide(description: "Quantity as spoken, e.g. '2', 'some', 'a handful'") var quantity: String } @Generable struct IngredientList { @Guide(description: "All ingredients mentioned", .minimumCount(1)) var ingredients: [Ingredient] } ``` **Tools needed**: None. Session-per-call. --- ### 3. Journaling App — Private Mood Tagging **Use case**: Classify a journal entry's emotional tone without sending text to a cloud service. **Why FoundationModels**: Journal entries are deeply personal. On-device is the only acceptable processing option — not a preference, a product requirement. ```swift @Generable enum PrimaryMood { case joyful, content, neutral, anxious, sad, angry, reflective } @Generable struct MoodAnalysis { @Guide(description: "The dominant emotion in the entry") var primaryMood: PrimaryMood @Guide(description: "Intensity, 1 = mild, 5 = intense", .range(1...5)) var intensity: Int @Guide(description: "Key themes, up to three", .maximumCount(3)) var themes: [String] } ``` **Tools needed**: None. Session-per-call. --- ### 4. Task Manager — Natural Language Task Parsing **Use case**: "Remind me to call Mum next Tuesday afternoon" → structured task with date components and priority. **Why FoundationModels**: Natural language date parsing ("next Tuesday"), intent extraction, and priority inference in a single call. ```swift @Generable struct ParsedTask { @Guide(description: "Clean task title, e.g. 'Call Mum'") var title: String @Guide(description: "Relative date reference as spoken, e.g. 'next Tuesday afternoon'") var dateReference: String @Guide(description: "Priority 1 (low) to 3 (high)", .range(1...3)) var priority: Int } ``` **Tools needed**: `CurrentDateTool` to anchor relative dates ("next Tuesday" needs to know what today is). --- ### 5. Fitness App — Workout Log Summarisation **Use case**: After a training session, summarise a structured workout log into a human-readable weekly review. **Why FoundationModels**: Summary generation from structured data into natural prose. Streaming makes it feel responsive. ```swift // No @Generable needed — plain text output, streamed let stream = session.streamResponse( to: "Summarise this week's training in 2 paragraphs: \(workoutLogJSON)" ) for try await snapshot in stream { summaryView.text = snapshot.content // live update as text generates } ``` **Tools needed**: None. Session-per-call. Use `streamResponse()` for the typing effect. --- ### 6. Developer Tool — Conventional Commit Message Generation **Use case**: Given a summary of changed files and diff, generate a conventional commit message. **Why FoundationModels**: Requires understanding intent from code changes — beyond simple pattern matching, but doesn't need frontier reasoning. On-device keeps source code private. ```swift @Generable enum CommitType { case feat, fix, chore, docs, refactor, test, perf } @Generable struct CommitMessage { @Guide(description: "Conventional commit type") var type: CommitType @Guide(description: "Affected scope, e.g. 'auth', 'ui', nil if unclear") var scope: String? @Guide(description: "Imperative subject line, 72 chars max") var subject: String @Guide(description: "Optional body with context on why this change was made") var body: String? } ``` **Tools needed**: None. Session-per-call. --- ### 7. Language Learning App — Sentence Correction **Use case**: Correct a learner's written sentence while preserving their intended meaning. **Why FoundationModels**: Grammar correction requires semantic understanding — the model must know what the learner was *trying* to say. On-device matters here too: learners write embarrassing mistakes they would prefer not to send to a cloud API. ```swift @Generable struct CorrectedSentence { @Guide(description: "The corrected sentence with natural grammar") var correctedText: String @Guide(description: "Explanations of corrections made, e.g. ['Changed tense from past to present perfect']") var explanations: [String] @Guide(description: "Confidence the original meaning was preserved, 1-5", .range(1...5)) var meaningPreservedConfidence: Int } ``` **Tools needed**: None. Session-per-call. --- ### 8. E-Commerce — Product Attribute Extraction **Use case**: Extract structured attributes (colour, size, material, style) from free-text product descriptions for catalogue indexing. **Why FoundationModels**: Product descriptions are unstructured prose. Structured extraction via `@Generable` is more robust than regex for the variety of descriptions sellers write. ```swift @Generable struct ProductAttributes { @Guide(description: "Primary colour(s), e.g. ['navy', 'white']") var colours: [String] @Guide(description: "Material, e.g. 'cotton', 'polyester blend'") var material: String? @Guide(description: "Style keywords, e.g. ['casual', 'slim-fit']", .maximumCount(5)) var styleKeywords: [String] } ``` **Tools needed**: Optional `ProductCatalogTool` to canonicalise values against your taxonomy. --- ### 9. Health App — Symptom Log Structuring **Use case**: User dictates how they're feeling → structured symptom entry for a health log. **Why FoundationModels**: Privacy is non-negotiable. Health data is the most sensitive category — on-device is not a preference, it's a product and ethical requirement. ```swift @Generable enum BodyArea { case head, chest, abdomen, back, leftArm, rightArm, leftLeg, rightLeg, general } @Generable struct SymptomEntry { @Guide(description: "Primary affected body area") var bodyArea: BodyArea @Guide(description: "Symptom description in normalised clinical language") var description: String @Guide(description: "Severity 1 (mild) to 10 (severe)", .range(1...10)) var severity: Int @Guide(description: "Duration as spoken, e.g. 'since this morning', 'two days'") var duration: String } ``` **Tools needed**: None. Session-per-call. --- ### 10. Customer Support — Ticket Triage **Use case**: Classify incoming support tickets by category, urgency, and sentiment to route them to the right team. **Why FoundationModels**: Classification with semantic understanding. A keyword-based classifier misroutes tickets with indirect language; the model understands context. ```swift @Generable enum TicketCategory { case billing, technicalSupport, accountAccess, featureRequest, complaint, other } @Generable enum CustomerSentiment { case positive, neutral, frustrated, angry } @Generable struct TicketClassification { @Guide(description: "Primary support category") var category: TicketCategory @Guide(description: "Urgency 1 (low) to 5 (escalate immediately)", .range(1...5)) var urgency: Int @Guide(description: "Customer emotional tone") var sentiment: CustomerSentiment @Guide(description: "One-sentence routing note for the support agent") var routingNote: String } ``` **Tools needed**: Optional `KnowledgeBaseTool` to check if similar issues have documented resolutions before routing. --- ## Part 13: Quick Reference & Anti-Patterns ### Quick Reference #### Key Types | Type | One-liner | |------|-----------| | `SystemLanguageModel` | Entry point — `SystemLanguageModel.default` | | `SystemLanguageModel.Availability` | `.available` / `.unavailable(reason)` | | `LanguageModelSession` | Manages one conversation thread; stateful | | `Instructions` | System prompt — set once at session creation | | `Prompt` | User input for a single turn | | `Response` | Wrapper — always access `.content` | | `ResponseStream` | `AsyncSequence` of `Snapshot` | | `GenerationOptions` | `temperature`, `maximumResponseTokens`, `sampling` | | `GenerationGuide` | Constraints on `@Guide` properties | | `Transcript` | Linear history of all session entries | | `Tool` | Protocol for functions the model can call | | `SystemLanguageModel.TokenUsage` | `.tokenCount` — cost of instructions/prompt/history | #### Session Init Cheatsheet ```swift // Fresh session, no tools LanguageModelSession { "Instructions here" } // With specific model LanguageModelSession(model: SystemLanguageModel.default) { "..." } // With tools LanguageModelSession(tools: [MyTool()]) { "..." } // Resume from saved transcript LanguageModelSession(model: .default, tools: [], transcript: savedTranscript) ``` #### `respond()` vs `streamResponse()` | | `respond()` | `streamResponse()` | |--|------------|-------------------| | Returns | `Response` | `ResponseStream` | | Best for | Background processing, pipelines | Live UI with typing effect | | Partial results | No | Yes (via `Snapshot`) | | Rate limit risk | Lower | Higher in background tasks | | Collect to full response | N/A | `.collect()` | #### `@Generable` vs Raw `String` Use `@Generable` when: - You need structured, typed output (multiple fields) - You want compile-time guarantees on output shape - The response must be parsed/processed programmatically - You need constraints (`@Guide`) on values Use raw `String` when: - Output is prose for display to the user - You're summarising or generating a paragraph - Streaming the output for a typing effect #### Token Budget Formula ``` Total = instructions + tool definitions + transcript history + prompt + response ``` All compete for the same fixed window (~4,096 tokens). Response tokens come out of the same pool as input. #### Tool vs Pre-Fetch vs Inject | If you... | Do this | |-----------|---------| | Always need the data | Pre-fetch, inject into instructions | | Sometimes need the data | Define as `Tool` | | Need data only when asked about it | Define as `Tool` | | Have more than 5 tools | Split into multiple focused sessions | --- ### Anti-Patterns **1. Accessing `response` instead of `response.content`** `respond()` returns `Response`, not `T`. Always unwrap `.content`. ```swift let text = try await session.respond(to: prompt) // Response, not String text.uppercased() // ❌ compile error let text = try await session.respond(to: prompt).content // ✅ String ``` **2. Storing `LanguageModelSession` persistently when you don't need history** For stateless tasks (normalisation, extraction, classification), create a new session per call. Persistent sessions accumulate transcript and eventually hit the context limit. **3. Defining too many tools** Each tool definition consumes ~50–100 tokens of context budget, whether used or not. Keep it to 3–5 tools per session. If you have 10 tools, split them across multiple focused sessions. **4. Calling `isAvailable` or `checkAvailability()` per-call** Availability checking has overhead and doesn't change mid-session. Check once at service/view init and cache the result. **5. High temperature for structured/correction tasks** For `@Generable` types that correct or extract, use `nil` or `temperature: 0.0–0.2`. High temperature produces creatively varied — but wrong — corrections. **6. Long, elaborate instructions modelled on frontier model prompts** On a ~3B parameter model, shorter is better. Instructions over ~200 words dilute signal. Explicit rules outperform discursive descriptions. **7. Not testing the fallback path** On most devices today, Apple Intelligence is unavailable. Your non-AI code path is the primary experience for the majority of users. Test it as thoroughly as the AI path. **8. Using FoundationModels where a regex or simple function would do** If the task is a known, fixed pattern (extract a UUID, validate an email, format a date), use a deterministic function. LLM overhead — latency, availability, complexity — is waste for these cases. **9. Propagating `@available(iOS 26, *)` to SwiftUI views** Adding `@available` to a `@State` property forces the whole view to require iOS 26. Use the `AnyObject?` pattern instead and cast inside `#available` guards. **10. Treating `.modelNotReady` as permanent** `.modelNotReady` means the model is downloading. It's transient. Show "not available right now" UI and retry later. Do not show a permanent "unsupported" state for this case. --- ## Part 14: Context Engineering for On-Device AI The context window is the most important constraint in FoundationModels. Everything else — prompt engineering, temperature, tool design — happens within it. Understanding how to engineer what goes into that window is the difference between a feature that works reliably and one that fails silently on complex inputs. ### The Fundamental Constraint The on-device model has a fixed context window of approximately 4,096 tokens shared across: ``` instructions + tool definitions + transcript history + current prompt + response ``` This is roughly **3,000 words** (about 12 pages) of total input and output. That sounds like a lot until you try to inject meaningful app data. A BJJ training app with 116 positions, each with a 200-word description: **~30,000 tokens** — 7x the entire context window. Injecting "all your app data" into instructions is not a strategy; it's a crash waiting to happen. ### What Breaks First When you over-fill the context window you get `GenerationError.exceededContextWindowSize`. But the model also silently degrades *before* it throws — a model given 3,500 tokens of input in a 4,096 window has only 596 tokens for its response. For most tasks that's enough. For others it's not — and the failure mode is truncation, not an error. **Common over-injection mistakes:** | Data | Tokens (approx) | Problem | |------|----------------|---------| | All SwiftData records (100+ items) | 10,000–50,000 | Massively exceeds window | | Full JSON blob of one complex entity | 500–2,000 | May leave little room for response | | Entire app configuration/preferences | 200–800 | Unnecessary; most not relevant | | Complete conversation history (100 turns) | 2,000–5,000 | Pushes out current prompt | ### Pattern 1: Select, Don't Dump The simplest and most impactful change: **fetch only what's relevant to the current request**. ```swift // ❌ Dumps all 116 positions into context — will throw let allPositions = try await queryService.fetchAllPositions() let session = LanguageModelSession { "Here are all BJJ positions: \(allPositions.map(\.description).joined(separator: "\n"))" } // ✅ Fetches only positions relevant to the current question let relevantPositions = try await queryService.fetchPositions( matching: userQuery, limit: 5 // 5 positions × ~200 tokens = ~1,000 tokens — fits comfortably ) let session = LanguageModelSession { "Relevant positions: \(relevantPositions.map(\.summary).joined(separator: "\n"))" } ``` Use SwiftData predicates and `fetchLimit` to constrain what you load before it reaches the context. ### Pattern 2: Layered Injection Inject summaries at the top level, with detail available on-demand via tools. The model sees the overview by default and only loads detail when it actually needs it: ```swift // Layer 1 — always injected: position names only (~50 tokens for 116 positions) let positionNames = positions.map(\.name).joined(separator: ", ") // Layer 2 — injected only when needed via tool struct PositionDetailTool: Tool { let name = "getPositionDetail" let description = "Returns full description and transitions for a named BJJ position." @Generable struct Arguments { var positionName: String } func call(arguments: Arguments) async -> String { // Fetch the full detail only when the model asks for it return await loadPositionDetail(arguments.positionName) } } let session = LanguageModelSession(tools: [PositionDetailTool()]) { "Available positions: \(positionNames)" "Use getPositionDetail to look up full information about any position." } ``` This keeps the base context lean (~50 tokens for names vs 30,000 for all descriptions) while still giving the model access to full detail on demand. ### Pattern 3: The Two-Step Compression Pipeline For tasks that require reasoning over large datasets, compress first, then reason. This only makes sense on-device — with a cloud API you pay per token on both calls and gain nothing. On-device both calls are free and private: ```swift // Step 1: Summarise the large dataset (fresh session, large input is fine) func summariseTrainingHistory(_ sessions: [TrainingSession]) async throws -> String { let session = LanguageModelSession { "Summarise this training history in 150 words, highlighting patterns and progress." } let fullHistory = sessions.map(\.description).joined(separator: "\n\n") return try await session.respond(to: fullHistory).content // fullHistory might be 5,000 tokens — fills most of the window, but that's fine // The output is ~150 tokens } // Step 2: Reason with the summary (fresh context, compact input) func answerWithHistory(question: String, summary: String) async throws -> String { let session = LanguageModelSession { "Training history summary: \(summary)" // ~150 tokens "Answer questions about training progress based on this summary." } return try await session.respond(to: question).content // Plenty of context headroom for question + answer } // Usage let summary = try await summariseTrainingHistory(recentSessions) let answer = try await answerWithHistory(question: userQuestion, summary: summary) ``` The summary call uses most of its window for the raw data and produces a compact output. The reasoning call has clean context with just the summary. Each call is focused on a single task. ### Pattern 4: Pre-Summarise at Write Time For persistent app data (SwiftData entities), generate summaries when the data is *saved* and store them alongside the entity. The summary is computed once and reused for every future AI interaction: ```swift @Model final class TrainingSession { var rawNotes: String = "" var date: Date = Date() var techniques: [String] = [] // Pre-generated — computed at save time, reused in every AI call var aiSummary: String = "" } // When saving a session func saveSession(_ session: TrainingSession) async { // Generate summary once at write time if #available(iOS 26, *) { let model = LanguageModelSession { "Summarise this BJJ training session in 50 words." } let summary = try? await model.respond( to: session.rawNotes + "\nTechniques: \(session.techniques.joined(separator: ", "))" ).content session.aiSummary = summary ?? "" } modelContext.insert(session) try? modelContext.save() } // At query time — inject pre-built summaries, not raw notes func buildSessionContext(recentSessions: [TrainingSession]) -> String { recentSessions .map { "[\($0.date.formatted())]: \($0.aiSummary)" } .joined(separator: "\n") // Each summary: ~50 tokens × 10 sessions = 500 tokens — fits comfortably } ``` Pre-summarisation at write time means: - Zero AI cost at query time — the summary is already there - The context load is predictable and bounded by summary length - The summary can be updated when the entity changes ### Dataset Size Reference | Content | Volume | Approx. Tokens | Fits in Context? | |---------|--------|----------------|-----------------| | Single entity description | 1 | 200–500 | ✅ Yes | | Entity names list | 100 | ~150 | ✅ Yes | | Short entity summaries | 10 | ~500 | ✅ Yes | | Short entity summaries | 50 | ~2,500 | ⚠️ Tight | | Full entity descriptions | 10 | ~2,000 | ⚠️ Tight | | Full entity descriptions | 50+ | 10,000+ | ❌ No | | Full entity descriptions | 100+ | 20,000+ | ❌ No | | Conversation (10 turns) | — | ~1,000 | ✅ Yes | | Conversation (50 turns) | — | ~5,000 | ❌ No | ### Decision Tree ``` Do you need to inject app data into context? │ ├── Yes → How much data? │ │ │ ├── 1–5 entities, full detail │ │ └── Inject directly into instructions │ │ │ ├── 5–20 entities │ │ ├── Always need all of them? → Inject summaries (pre-generated at write time) │ │ └── Only need some? → Names in instructions + detail via Tool │ │ │ └── 20+ entities │ ├── Need to reason across all of them? → Two-step: summarise first, then reason │ └── Need specific ones? → Select with predicate, inject summaries for matched │ └── No → Standard session-per-call, no data injection needed ``` ### On-Device vs Cloud: Why This Pattern Is Different With cloud APIs (OpenAI, Anthropic, Google), the two-step pattern is often not worth it: you pay per token on both calls, and the total cost may be similar to one call with the full data — especially if the summarisation model is also expensive. On-device, the economics flip: - **No per-token cost** — both calls are free - **No network latency** — both calls run locally, typically in under a second each - **No privacy concern** — data never leaves the device regardless of call count - **Shared resource** — each call consumes system resources and may be rate-limited, so compact contexts are still preferred This makes on-device AI uniquely suited to multi-step pipelines where cloud would be prohibitively expensive or slow. --- ## Part 15: Advanced Patterns This section covers patterns that don't fit neatly into any earlier part — actor isolation details, the non-obvious syntax for `@Generable` enums with associated values, reactive availability monitoring in SwiftUI, chaining model output back as prompt input via `PromptRepresentable`, and the bounded domain injection pattern for apps with curated entity datasets. --- ### Actor Isolation and `call(arguments:)` — What Actor Does Your Code Run On? Understanding actor isolation in FoundationModels matters when your tool or service touches `@MainActor`-bound state. #### `Tool.call(arguments:)` Is `@concurrent` The `call(arguments:)` method on the `Tool` protocol is implicitly `@concurrent`, which means it runs **off the main actor** — in a generic concurrent executor, not `@MainActor`. This is deliberate: the model calls your tool during inference, which itself is off the main actor. Calling back to the main actor mid-inference would require a hop, adding latency. ```swift @available(iOS 26, *) struct TrainingHistoryTool: Tool { let name = "getTrainingHistory" let description = "Returns recent training sessions." @Generable struct Arguments { var limit: Int } // This runs @concurrent — NOT on @MainActor func call(arguments: Arguments) async -> String { // ✅ Pure computation or actor-independent async work is fine here let sessions = await fetchSessions(limit: arguments.limit) return sessions.map(\.summary).joined(separator: "\n") // ❌ Accessing @MainActor-bound state directly will cause a data race warning // return self.someMainActorProperty // won't compile } } ``` If your tool genuinely needs main-actor state (e.g., reading from a `@MainActor` service), hop explicitly: ```swift func call(arguments: Arguments) async -> String { // Hop to MainActor to read the value, then hop back let data = await MainActor.run { myMainActorService.currentData } return process(data) } ``` #### What Actor Does `respond()` Run On? `LanguageModelSession.respond()` is `async` but has **no actor isolation requirement** — it is safe to call from any actor context, including `@MainActor`. Internally, the framework dispatches inference to a background executor automatically. ```swift // ✅ Calling respond() from @MainActor is fine — the framework handles the dispatch @MainActor final class NormalisationService { func normalise(_ text: String) async -> String { let session = LanguageModelSession { "Fix BJJ terms." } // respond() is async but not @MainActor — call is fine from here let response = try? await session.respond(to: Prompt { text }) return response?.content ?? text } } ``` You do not need to manually `Task.detach` or use `Task { @concurrent in ... }` before calling `respond()`. The framework does the right thing automatically. #### `@MainActor` Services Calling Tools — The Safe Pattern When a `@MainActor` service needs tools that access non-`@MainActor` data, the cleanest pattern is to make the tool capture any main-actor dependencies at session creation time (before inference begins), rather than accessing them from within `call()`: ```swift @MainActor final class CoachingService { private let userProfile: UserProfile // @MainActor bound func answer(_ question: String) async -> String { // Capture the profile value NOW, on MainActor, before the session runs let profileSummary = userProfile.summary // safe — we're on MainActor // The tool closes over the already-captured value — no actor hop needed in call() struct ProfileContextTool: Tool { let name = "getUserProfile" let description = "Returns the user's training profile." @Generable struct Arguments {} let summary: String // captured at creation time func call(arguments: Arguments) async -> String { summary } } let session = LanguageModelSession(tools: [ProfileContextTool(summary: profileSummary)]) { "Answer BJJ coaching questions using the user's profile." } return (try? await session.respond(to: question).content) ?? "" } } ``` This is simpler than hopping to `MainActor` inside `call()` and avoids any potential race conditions. --- ### `@Generable` Enums With Associated Values The earlier enum examples in Part 4 showed simple case enums (`.positive`, `.neutral`, `.negative`). `@Generable` also supports enums with associated values — but the syntax has a specific constraint: **all associated values must themselves conform to `Generable`** (or be types that `@Generable` already knows how to handle: `String`, `Int`, `Double`, `Bool`, arrays of generable types). #### Basic Associated Value Enum ```swift @available(iOS 26, *) @Generable enum TranscriptCorrection { case termCorrection(original: String, corrected: String) case spellingFix(original: String, corrected: String) case noChange } @Generable struct AnnotatedTranscript { @Guide(description: "The corrected transcript text") var correctedText: String @Guide(description: "Each correction made, with original and corrected forms") var corrections: [TranscriptCorrection] } ``` The model generates each `corrections` element as a tagged union — it chooses the case name and then generates the associated values. This is significantly richer than a flat string array for corrections, because the output is fully typed. #### Nested `@Generable` Structs as Associated Values Associated values can also be `@Generable` structs: ```swift @available(iOS 26, *) @Generable struct DateRange { @Guide(description: "Start date in YYYY-MM-DD format") var start: String @Guide(description: "End date in YYYY-MM-DD format") var end: String } @Generable enum ScheduleIntent { case singleDay(date: String) case dateRange(range: DateRange) case recurring(dayOfWeek: String, startTime: String) case unspecified } @Generable struct ParsedScheduleRequest { @Guide(description: "What the user wants to schedule") var activity: String @Guide(description: "When the user wants to schedule it") var timing: ScheduleIntent } ``` #### When to Use Associated Value Enums vs Flat Structs Use associated value enums when the output shape is fundamentally **discriminated** — the presence of one field makes others meaningless. In the `ScheduleIntent` example above, if the user said "every Monday at 9am", the `.recurring` case makes `date` and `range` meaningless, and a flat struct would leave those fields awkwardly nil. Use flat `@Generable` structs with optional properties when most combinations of values are valid. The associated value enum excels when the cases are truly mutually exclusive and each has distinct associated data. #### The Constraint: All Associated Values Must Be Generable If you include a type that is not `Generable`-conformant as an associated value, the `@Generable` macro will emit a compile-time error. The fix is always one of: 1. Add `@Generable` to the associated type 2. Change the associated type to a primitive (`String`, `Int`, etc.) 3. Represent it as a separate `@Generable` struct with its own properties --- ### `Observable` Availability Monitoring — Reactive SwiftUI Pattern `SystemLanguageModel` is an `Observable` final class. This means SwiftUI views can react to `.availability` changes without any additional wiring — the view re-renders automatically when availability changes. This is useful when you want to show/hide AI features reactively, for example when the model finishes downloading (`.modelNotReady` → `.available`) while the user is already in the app. #### Basic Reactive Availability View ```swift @available(iOS 26, *) struct AIFeatureBadge: View { var body: some View { // SwiftUI observes SystemLanguageModel.default automatically // because it's @Observable — no @StateObject, no manual subscription let model = SystemLanguageModel.default switch model.availability { case .available: Label("AI Ready", systemImage: "sparkles") .foregroundStyle(.green) case .unavailable(.modelNotReady): Label("AI Downloading...", systemImage: "arrow.down.circle") .foregroundStyle(.yellow) case .unavailable(.appleIntelligenceNotEnabled): Label("Enable Apple Intelligence", systemImage: "exclamationmark.circle") .foregroundStyle(.secondary) case .unavailable(.deviceNotEligible): EmptyView() // Don't surface this — it's permanent @unknown default: EmptyView() } } } ``` Because `SystemLanguageModel` is `@Observable`, SwiftUI tracks which properties the `body` reads and re-renders when they change. No `.onReceive`, no `Combine`, no explicit observation setup. #### Watching for the Model Becoming Ready The `.task {}` modifier is the right tool for reacting to an availability change and triggering a one-time action — for example, kicking off an initial data enrichment pass once the model becomes available: ```swift @available(iOS 26, *) struct TrainingDashboardView: View { @State private var hasRunInitialEnrichment = false var body: some View { // ... view content ... .task { // This task runs when the view appears and re-runs if availability changes for await _ in SystemLanguageModel.default.availabilityUpdates { guard !hasRunInitialEnrichment else { break } if SystemLanguageModel.default.isAvailable { await runInitialEnrichment() hasRunInitialEnrichment = true } } } } private func runInitialEnrichment() async { // Generate AI summaries for any entities that don't have them yet } } ``` > **Note**: If `availabilityUpdates` is not available on your OS target, use `.task(id: SystemLanguageModel.default.availability)` as an alternative — the task re-runs when `availability` changes since `Availability` is `Equatable`: ```swift .task(id: SystemLanguageModel.default.availability) { guard SystemLanguageModel.default.isAvailable else { return } guard !hasRunInitialEnrichment else { return } await runInitialEnrichment() hasRunInitialEnrichment = true } ``` #### Avoiding the Per-View `@available` Constraint The reactive pattern works cleanly with the `AnyObject?` wrapping approach from Part 1. Keep the `Observable` observation inside a `#available` check, or confine it to a view that is itself conditionally shown: ```swift // In the parent view (no iOS 26 requirement): var body: some View { VStack { mainContent if #available(iOS 26, *) { AIStatusBadge() // only this view requires iOS 26 } } } ``` This way the availability-reactive logic is isolated to a specific subview, and the containing view has no version constraint. --- ### `PromptRepresentable` — Chaining Model Output Back as Input One of the cleaner architectural patterns enabled by the protocol hierarchy is **output-as-input chaining**: taking a `@Generable` type from one call and passing it directly as prompt input to the next call, without any serialisation step. This works because `@Generable` types conform to `PromptRepresentable` (via `ConvertibleToGeneratedContent`), which means they can appear directly in a `@PromptBuilder` closure. #### Basic Chaining Example ```swift @available(iOS 26, *) @Generable struct NormalisedTranscript { @Guide(description: "Corrected transcript text") var normalisedText: String @Guide(description: "BJJ terms found, in canonical form") var extractedTerms: [String] } @Generable struct SessionSummary { @Guide(description: "One-paragraph summary of the training session") var summary: String @Guide(description: "Techniques practiced, from the corrected terms") var techniquesWorked: [String] } // Two-step pipeline: correct → summarise func processTranscript(_ raw: String) async throws -> SessionSummary { // Step 1: Correct BJJ terminology let correctionSession = LanguageModelSession { "Fix speech-to-text errors in BJJ transcripts. Return corrected text and term list." } let corrected = try await correctionSession.respond( to: Prompt { raw }, generating: NormalisedTranscript.self ) // Step 2: Summarise — pass the @Generable output directly as prompt input // No JSON encoding, no manual string building needed let summarySession = LanguageModelSession { "Summarise a BJJ training session given a corrected transcript." } let summary = try await summarySession.respond( to: Prompt { "Transcript: \(corrected.content)" // NormalisedTranscript directly in @PromptBuilder }, generating: SessionSummary.self ) return summary.content } ``` The `\(corrected.content)` interpolation works because `NormalisedTranscript` (a `@Generable` struct) conforms to `PromptRepresentable`. The framework serialises it appropriately for the model — you never touch the intermediate representation. #### When Chaining Is Worth It The chain pattern is most valuable when: - **Output type 1 contains richer structure than a plain string** — passing the full `NormalisedTranscript` (with both `normalisedText` and `extractedTerms`) to the next session gives the model more signal than a plain corrected string - **Each step is a focused, single-task session** — staying true to the "one task per session" principle (Part 3) while getting compound results - **You want typed output at every step** — rather than a single sprawling `@Generable` struct trying to do everything, each step produces its own clean type Avoid chaining when the first step's output is a plain `String` — in that case, just use string interpolation normally. The `PromptRepresentable` chaining is most valuable for multi-property structured output. --- ### Bounded Domain Injection — The Names-Only Pattern This is a specialised context engineering pattern for apps that have a **fixed, curated, known domain** — a set of entities whose names are meaningful and bounded. The insight is that entity names alone are remarkably compact while still giving the model strong domain grounding. #### The Core Insight In Grapla, there are 116 BJJ positions, 150 techniques, 118 submissions, and 141 movements — 525 total entities. Injecting *all the descriptions* for all 525 entities would require tens of thousands of tokens and overflow the context window many times over. But injecting *just the names* is cheap: ``` Mount, Half Guard, Side Control, Back Mount, Turtle, North-South, Closed Guard, Open Guard, De La Riva, X-Guard, Butterfly Guard, Single Leg X, ... Kimura, Armbar, Triangle, Rear Naked Choke, D'Arce, Anaconda, Omoplata, ... Hip Bump Sweep, Flower Sweep, Scissor Sweep, Pendulum Sweep, ... ``` A full list of ~525 entity names in CSV format uses approximately **700–900 tokens** — well within a 4,096-token window, leaving ample room for instructions, prompt, and response. #### Why Names Alone Are Sufficient for Correction Tasks For a transcript correction service, the model's job is: 1. Recognise that "kimora" is a garbled version of a known entity 2. Replace it with the canonical form "Kimura" The model doesn't need the *description* of a Kimura to know that "kimora" should be "Kimura". The name list acts as a **canonical term index** — the model can fuzzy-match against it and apply corrections. ```swift @available(iOS 26, *) struct BJJEntityNames { // Pre-built at app startup from the SwiftData store — reused for every normalisation call static let positions = [ "Mount", "Half Guard", "Side Control", "Back Mount", "Turtle", "North-South", "Closed Guard", "Open Guard", "De La Riva", "X-Guard", "Butterfly Guard", "Single Leg X", "Full Guard", "Rubber Guard", // ... all 116 positions ] static let techniques = [ /* all 150 */ ] static let submissions = [ /* all 118 */ ] static let movements = [ /* all 141 */ ] static var allAsCSV: String { (positions + techniques + submissions + movements).joined(separator: ", ") } } @available(iOS 26, *) final class TranscriptNormalisationService { func normalise(_ rawTranscript: String) async -> String { let entityNames = BJJEntityNames.allAsCSV // ~700 tokens let session = LanguageModelSession { "Fix speech-to-text errors in BJJ training transcripts." "Canonical entity names: \(entityNames)" "Correct misrecognised terms to their canonical forms. Return only the corrected text." } // Total instructions: ~750 tokens — leaves ~3,300 tokens for prompt + response let response = try? await session.respond(to: Prompt { rawTranscript }) return response?.content ?? rawTranscript } } ``` #### Generalising the Pattern The bounded domain pattern works whenever your app has a finite, knowable set of canonical terms. Some examples: | App | Bounded Domain | Names-Only Size | |-----|----------------|-----------------| | BJJ app | 525 positions/techniques/submissions/movements | ~700 tokens | | Recipe app | 500 common ingredients | ~600 tokens | | Medical notes | 300 ICD-10 conditions (common subset) | ~400 tokens | | Developer tool | 200 API method names | ~250 tokens | | Music app | 400 instruments + musical terms | ~500 tokens | The test for whether this pattern applies: **Can you enumerate all the canonical terms your app cares about?** If yes, inject the names list. The model will use it as a correction index without needing any descriptions. #### Names-Only vs Names + Detail Combine with the Layered Injection pattern (Part 14) when you sometimes need both correction *and* reasoning about entities: ```swift let session = LanguageModelSession(tools: [PositionDetailTool()]) { // Layer 1: names always present (~700 tokens) — enables correction "Canonical BJJ entities: \(BJJEntityNames.allAsCSV)" // Layer 2: detail available on demand via tool — enables reasoning "Use getPositionDetail to look up descriptions, transitions, and techniques for any position." } ``` This gives the model correction capability (names) plus on-demand depth (tool) while keeping the base context compact. --- ### Experimental Directions These patterns are worth exploring but untested at scale. They use only FoundationModels — no additional frameworks required. **Sharded parallel sessions.** When your vocabulary corpus is too large for a single context but you need full coverage, split it across multiple sessions running concurrently. Each session holds a different shard of the names list. After all sessions return, merge results — prefer any correction over "unchanged", break ties by confidence or frequency. The on-device model's free-per-call economics make this viable in a way that would be expensive with a cloud API. ```swift async let positions = normalise(rawText, vocabulary: BJJEntityNames.positions) async let techniques = normalise(rawText, vocabulary: BJJEntityNames.techniques) async let submissions = normalise(rawText, vocabulary: BJJEntityNames.submissions) let (p, t, s) = try await (positions, techniques, submissions) let merged = merge(p, t, s) // your logic for combining corrections ``` **Adaptive context budgeting.** Before injecting data, measure how much headroom you have with `tokenUsage(for:)`, then fill to a target percentage (e.g. 60% of the window, reserving 40% for prompt + response). Rank your entities by relevance and inject greedily until you hit the budget. This turns context injection from a static decision into a runtime one. ```swift let instrTokens = try await model.tokenUsage(for: instructions).tokenCount let window = await model.contextSize let budget = Int(Double(window) * 0.6) - instrTokens // 60% target, minus instructions var injected: [String] = [] var used = 0 for entity in rankedEntities { let cost = estimateTokens(entity.name) // ~1.3 tokens per word guard used + cost <= budget else { break } injected.append(entity.name) used += cost } ``` **Transcript as structured cache.** Rather than rehydrating a conversation, use a saved `Transcript` as a compressed knowledge cache — pre-generate a transcript that contains a curated Q&A exchange about your domain (e.g. "what is a Kimura?" → model's answer), then resume from that transcript for every live session. The model starts with pre-baked domain knowledge already in its context, without spending live call tokens to establish it. All three patterns are speculative — they depend on how the model handles parallel resource contention, whether adaptive sizing materially improves output quality, and whether transcript rehydration preserves semantic coherence. The `#Playground` macro is the fastest way to validate any of them before committing to an implementation. --- ## Resources ### Official Apple Documentation **WWDC 2025 Sessions:** - Session 286: Meet the Foundation Models framework - Session 301: Deep dive into the Foundation Models framework - Session 259: Code-along: Bring on-device AI to your app using the Foundation Models framework **Framework Updates:** - February 2026: Improved instruction-following, `tokenUsage(for:)`, `contextSize`, `#Playground` macro ### Key Types at a Glance | Type | Purpose | |------|---------| | `SystemLanguageModel` | Entry point — access the model, check availability | | `LanguageModelSession` | Manages a single conversation thread with the model | | `Instructions` | System-level behaviour definition for a session | | `Prompt` | User input to the model | | `Response` | Wrapper around typed model output — use `.content` | | `ResponseStream` | Async sequence of partial responses for streaming | | `GenerationOptions` | Controls temperature, sampling, max tokens | | `GenerationGuide` | Constraint on `@Guide` properties (min/max/regex) | | `GeneratedContent` | Untyped structured output — escape hatch | | `Transcript` | Linear history of a multi-turn session | | `Tool` | Protocol for functions the model can call during generation | | `SystemLanguageModel.TokenUsage` | Token count for a prompt, instructions, or transcript | --- # Anatomy of a CSS Phone Mockup Building depth, glass, and physics in layers category: Frontend date: 2026-02-27 reading-time: 13 min read url: https://conor.fyi/writing/anatomy-of-a-css-phone-mockup --- A phone mockup looks like one effect, but it's actually six composited layers — each doing a specific job. Collapse them all and you get a flat coloured rectangle. Stack them in the right order and the eye reads it as glass, metal, and depth. This is a walkthrough of how I built the `PhoneMockup` component above, from a blank `div` to a mouse-reactive 3D frame. We'll add one layer at a time so it's clear what each one contributes. Here's the skeleton we're building toward: ```jsx
{/* Layer 1: metallic stroke */}
{/* Layer 1: dark matte casing */}
{/* Layer 2: clipped content */} {/* Layer 3: notch + camera */}
{/* Layer 2: depth ring */}
{/* Layer 4: glass glint */}
{/* Layer 4: ambient wash */}
``` Layers 5 and 6 are JavaScript: tilt physics and reactive shadows. Let's build up to them. --- ## Layer 1: The Bevel and the Stroke The outermost `div` is just `padding: 1` and a gradient — that single pixel of visible padding is the metallic bevel: ```jsx
``` The gradient runs from `#555` to `#000` at 135° — top-left lighter than bottom-right. This simulates a brushed aluminium edge catching light from above. It's one CSS property doing optical work that would otherwise take a texture. The `borderRadius` is calculated as `width * 0.12`. The iPhone's corner radius scales proportionally to the device width, so we keep it as a ratio rather than a fixed pixel value. Resize the component and the corners stay iPhone-shaped. Nested inside: the dark matte casing: ```jsx
``` `5px` of padding is the visible dark frame around the screen. Notice `borderRadius` here is `outerRadius`, not `outerRadius + 1` — the outer wrapper gets `+1` to avoid a visual gap between the bevel ring and the body where the gradient might bleed through. `innerRadius` is `outerRadius - 5` — matching the 5px padding offset. This keeps the inner corners visually concentric with the outer ones. Without this, the screen corners appear too sharp or too round relative to the frame. ### Ambient shadows A single `box-shadow` looks artificial — the falloff is a crisp edge rather than a natural penumbra. We stack nine layers, each progressively lighter and more diffuse: ```js const ambientShadows = [ "0 1px 2px rgba(0,0,0,0.15)", "0 2px 4px rgba(0,0,0,0.12)", "0 4px 8px rgba(0,0,0,0.10)", "0 6px 12px 2px rgba(0,0,0,0.08)", "0 8px 16px 4px rgba(0,0,0,0.06)", "0 12px 24px 6px rgba(0,0,0,0.05)", "0 16px 32px 10px rgba(0,0,0,0.04)", "0 24px 48px 16px rgba(0,0,0,0.03)", "0 32px 64px 24px rgba(0,0,0,0.03)", ] ``` Each step doubles the blur, adds more spread, and halves the opacity. The result is a soft, real-looking shadow that falls off the way light actually does. This is passed as the `box-shadow` property alongside the edge shadows we'll add in Layer 6. --- ## Layer 2: The Screen The screen wrapper does two things: clips the image to the rounded shape, and adds a depth ring: ```jsx
{/* Screen inset shadow */}
``` `overflow: hidden` does the border-radius clipping on the image — the image itself doesn't need a `borderRadius` because the container clips it. Without an image yet, we can see the screen container clipped to the frame: `lineHeight: 0` is non-obvious but essential. Images are inline elements by default, which means the browser reserves space below them for text descenders — about 4px of empty space at the bottom of the container. Setting `lineHeight: 0` on the wrapper collapses that. Without it, the image floats slightly above the bottom of the frame. ### Height calculation We need to maintain the real iPhone aspect ratio. The iPhone 17 Pro is 2622 × 1206 pixels — a ratio of roughly 2.175:1. We derive height from width: ```js const height = Math.round(width * (2622 / 1206)) ``` Pass a `width` prop and height follows automatically. Hardcoding a height would break the proportions the moment someone changes the width. The inset shadow overlay sits at `zIndex: 2` above the image. It's a dark ring that recesses the screen into the bezel — the difference between a screen that looks flush and one that looks embedded. --- ## Layer 3: The Dynamic Island Three concentric circles faking lens depth: ```jsx // notchW = width * 0.33, notchH = width * 0.09
{/* Camera lens — outer housing */}
{/* Lens inner ring */}
{/* Specular highlight — covered in Layer 6 */}
``` `borderRadius: 999` on the island container is intentional. A value large enough — larger than half the element's height — always produces a perfect pill shape regardless of the element's actual dimensions. No need to recalculate as width changes. The radial gradients are positioned off-center (`35% 35%`, `40% 40%`) to simulate a fixed light source above and to the left. Moving the gradient center away from `50% 50%` shifts the bright spot, making the glass appear to have a real angle relative to the light. The `zIndex: 1` on the island positions it above the image but below the shine overlays (`zIndex: 2`). The Dynamic Island is part of the device, not a reflection. Building it up in three steps — pill, lens housing, inner ring with specular:
--- ## Layer 4: The Glass Layers Two overlays simulate screen glass. Both sit at `zIndex: 2`. ### Sharp diagonal glint ```jsx
``` The trick here is the negative positioning. Setting `top/left/right/bottom` all to `-25%` makes this div 50% larger than its parent in every direction. The parent has `overflow: hidden`, so only the portion that falls within the screen bounds is visible. Why bother making it oversized? So we can rotate and translate it without revealing transparent corners. When the tilt physics move this overlay (Layer 6), it needs room to slide without the edge becoming visible. The gradient has two colour stops at the same percentage: `rgba(255,255,255,0.08) 35%` and `rgba(255,255,255,0) 35%`. Same position, different values — that's how you get a hard edge in a CSS gradient. No transition, just a sharp cut. ### Soft ambient wash ```jsx
``` Gentler falloff, slightly different angle. This adds the ambient diffusion that sits behind the glint — making the top-left corner of the screen look softly lit rather than just adding one bright stripe. --- ## Layer 5: Making it Move State and ref: ```js const containerRef = useRef(null) const [tilt, setTilt] = useState({ rotateX: 0, rotateY: 0 }) ``` The core function normalises pointer position to −1..1: ```js function tiltFromPoint(clientX, clientY) { const rect = containerRef.current.getBoundingClientRect() // Convert absolute viewport coords to element-local, then normalise to -1..1 const x = ((clientX - rect.left) / rect.width) * 2 - 1 const y = ((clientY - rect.top) / rect.height) * 2 - 1 setTilt({ rotateX: -y * MAX_TILT, // pointer at top → tilt top toward viewer rotateY: x * MAX_TILT, // pointer at right → tilt right }) } ``` `getBoundingClientRect()` returns the element's position in the viewport. Subtracting `rect.left` converts from viewport-absolute to element-local coordinates. Dividing by `rect.width` normalises to 0–1. Multiplying by 2 and subtracting 1 shifts to −1..1. `rotateX: -y` is the axis inversion. When the pointer is near the *top* of the element, the top of the phone should tilt *toward* you — that's a positive `rotateX` in CSS 3D space. But `y` is negative at the top (above centre), so we negate it. The transform: ```js transform: `perspective(800px) rotateX(${tilt.rotateX}deg) rotateY(${tilt.rotateY}deg)` ``` `perspective(800px)` is the simulated camera distance. Smaller values increase the foreshortening effect — 400px looks dramatic, 1200px looks nearly flat. 800px hits the sweet spot for a phone-sized element. `transformStyle: "preserve-3d"` propagates the 3D context to child elements. Without it, all the children collapse to the same plane and depth ordering breaks. `willChange: "transform"` is a GPU hint — promotes the element to its own compositing layer before any animation starts, avoiding a repaint on first move. Touch support uses the same function: ```js function handleTouchMove(e) { const touch = e.touches[0] if (touch) tiltFromPoint(touch.clientX, touch.clientY) } ``` `e.touches[0]` is the first touch point. `clientX` and `clientY` are the same coordinate space as mouse events, so `tiltFromPoint` handles both. --- ## Layer 6: Edge Extrusion and Shine Physics When you tilt a physical phone, you see its edges. We fake this with offset `box-shadow`: ```js const edgeX = -(tilt.rotateY / MAX_TILT) * EDGE_DEPTH const edgeY = (tilt.rotateX / MAX_TILT) * EDGE_DEPTH const edgeShadows = Array.from({ length: EDGE_LAYERS }, (_, i) => { const t = (i + 1) / EDGE_LAYERS return `${edgeX * t}px ${edgeY * t}px 0 0 #222` }) ``` When `rotateY > 0` (tilting right), `edgeX` is negative, shifting the shadow left — which makes the left edge appear to protrude. Two stacked layers (`EDGE_LAYERS = 2`) with progressive offsets give soft depth rather than one sharp step. The blur radius is `0` — these are solid-colour shadows, not diffuse. They're edge geometry, not light scatter. The glass shine overlays also react to tilt: ```js const shineX = -(tilt.rotateY / MAX_TILT) * SHINE_MAX_OFFSET const shineY = (tilt.rotateX / MAX_TILT) * SHINE_MAX_OFFSET const lightAlign = (-tilt.rotateY + tilt.rotateX) / (2 * MAX_TILT) // -1 to 1 const shineOpacity = Math.max(0, Math.min(1, 0.5 + lightAlign * 0.5)) const shineAngle = 155 + (tilt.rotateY / MAX_TILT) * 5 // ±5° rotation ``` `lightAlign` measures how much the screen's normal vector aligns with the fixed top-left light source. Tilt the top-left corner toward the viewer and `lightAlign` increases, brightening the shine. Tilt it away and the shine fades. `shineX/Y` slide the oversized shine overlay in the direction opposite to tilt — the reflection appears to hold still relative to the light source while the glass moves beneath it. The specular highlight inside the camera lens follows the same values: ```jsx
``` At rest it's at `top: 20%, left: 25%` — off-center, simulating a fixed light above-left. As the device tilts, it moves, maintaining the illusion of a consistent light source. --- ## Accessibility One `useEffect` respects `prefers-reduced-motion`: ```js useEffect(() => { const mq = window.matchMedia("(prefers-reduced-motion: reduce)") setReducedMotion(mq.matches) const handler = (e) => setReducedMotion(e.matches) mq.addEventListener("change", handler) return () => mq.removeEventListener("change", handler) }, []) ``` We listen for runtime changes, not just the initial value — users can toggle system preferences while the page is open. When `reducedMotion` is true, `tiltFromPoint` returns early. The transition CSS is still present but never fires. --- ## All Together Six layers, each handling one physical phenomenon:
| Layer | What it simulates | |-------|-------------------| | Bevel gradient | Metallic edge catching light | | Dark casing + ambient shadow | Matte body with natural depth | | Screen clip + inset shadow | Glass recessed into frame | | Dynamic Island | Camera hardware | | Sharp + soft shine overlays | Specular and ambient glass reflection | | JS tilt + edge shadows + shine physics | Device orientation and light interaction |
The key insight: physical objects fool the eye by accumulating subtle cues. A single gradient isn't convincing. A single shadow isn't convincing. But six layers, each doing their specific job — bevel, shadow falloff, depth ring, glass glint, edge geometry, reactive shine — combine into something the brain reads as solid. You can see the full component on the [apps page](/apps). --- ## Update: Side Buttons *3 March 2026* The mockup was missing one detail that real phones have — hardware buttons on the sides. Three on the left (action button, volume up, volume down) and a power button on the right. The buttons are absolutely positioned inside the perspective container, sitting just outside the bezel edge. Each one is a thin rectangle with a metallic gradient, rounded on its outward-facing corners: ```jsx const btnW = Math.max(2, Math.round(width * 0.013)) const buttons = [ { side: "left", top: 0.155, h: 0.032 }, // Action button { side: "left", top: 0.215, h: 0.06 }, // Volume up { side: "left", top: 0.29, h: 0.06 }, // Volume down { side: "right", top: 0.22, h: 0.08 }, // Power ] ``` Positions and heights are expressed as fractions of the total phone height, so they scale proportionally with the `width` prop. At the default 218px width, each button is about 3px wide — subtle but visible. The interesting part: the buttons react to tilt. On a real phone, you only see the side buttons when the edge faces you. Here, each button's opacity is tied to the tilt direction that reveals its side: ```jsx // Left buttons brighten when tilting right (left edge faces viewer) const leftOpacity = 0.3 + Math.max(0, tilt.rotateY / MAX_TILT) * 0.4 // Right button brightens when tilting left (right edge faces viewer) const rightOpacity = 0.3 + Math.max(0, -tilt.rotateY / MAX_TILT) * 0.4 ``` Base opacity is 0.3 so they're always faintly visible, rising to 0.7 at full tilt. The transition follows the same 300ms ease-out as the rest of the tilt system, so it feels physically connected. --- # Three years of AI My first record of coding with AI was in March 2023 when I was poking around with ChatGPT. category: Reflection date: 2026-02-22 reading-time: 17 min read url: https://conor.fyi/writing/three-years-of-ai --- *I wanted to do a write up on this stuff to mark one year of using ClaudeCode. This document is just me logging, mostly for myself, an intense few years of working with AI tools as a software maker. If you're reading this - thanks for having a look and I hope it's somewhat interesting. I'm not using any AI to write it, review it, or suggest any ideas for it.* My first record of coding with AI was in March 2023 when I was poking around with ChatGPT and wanted to experiment with their API, where you could send a payload and get a response. I was interested in learning Rust at the time too so I killed two birds with one stone and "paired" with Chippity to hack up [a little web-app](https://github.com/conorluddy/weather-haiku). It took the weather forecast from the Yr.no API and sent it to the GPT API to get converted into a haiku with a little serverless function. I had no idea I'd be heavily using Anthropic's Haiku model a year or two later. I'm glad I have an actual record in a public repo of my first crack at it because it was technically my first time paying for LLM tokens. After that I started yet another portfolio website that never got completed, but I was using ChatGPT to help me build a static web framework with Rust, with the goal being that it would work with web components and do some magic with CSS and bundling. It never really had a clear goal and never got completed. The next big timeline event for me was in April 2024 when I was hit by tech layoffs. At the time when I was laid off I had been in this career for 18 years. It's never fun being laid off, and this was my second time, but that fresh start mentality had me considering career changes. Wedding and real estate photography were up there as serious ventures and I already have more than enough serious photo gear to go down that path without needing to spend more, and I was ready to get away from desk work. At the time I was on a digital detox buzz after reading Digital Minimalism by Cal Newport, and had been using my phone in black and white for months to kill the habit of unconsciously unlocking it. It's a very effective way to do it.
![Black and white phone](/images/3-years-with-ai/black-and-white-phone.jpg)
Luckily, I opted to by myself a new Macbook and start taking side projects a bit more seriously instead. In hindsight that layoff was probably the best thing that happened to me during this AI revolution - the timing was perfect, because I started building things just as OpenAI, [Cursor](https://www.cursor.com) and Anthropic were getting really good. Around the time I was laid off I started following YouTubers like Pieter Levels and Marc Lou, and Marc's [shipfa.st](https://www.shipfa.st) product got me thinking that I should build a boilerplate repo that I'd use as a jump-off for all of these other products I was going to start building. Youtube Premium is one of the things I kept even when unemployed - it's very underrated. I had never built a backend setup with user auth from scratch before, and I suddenly had a lot of free time, so I started building [Residents](https://github.com/conorluddy/Residents). The motive being that most apps and products will need to manage users, so lets build the lowest-common-denominator backend API that I can reuse like a cookie cutter. ![Residents GitHub repo](/images/3-years-with-ai/residents-github.png) I learned a bunch about auth, Express, middleware, JWTs, refresh tokens, security, role based access and API testing. The satisfying part about building this was that I was the only person working on it, so had the freedom to make it fully Typescript typed, and well covered by tests. At that point I would have been discussing the codebase with Chippity (I just searched in my ChatGPT dashboard and I had set up one of those custom GPTs and called it "ResidentOne"). I was zipping up code repositories and uploading them as an asset for the GPT, using it to get feedback on the code and advice on how to do auth securely, and then iterating that way. I'm still maintaining Residents, and it's a special one for me because it has been through every phase of my AI journey so far. Starting with Chippity, then Cursor and Continue, and now being occasionally updated by the latest Claude models. It's likely the last ever repository I'll ever have coded the majority of "by hand", because it's no longer efficient to do that. Despite planning to take a few months off and decide what to do next, I ended up starting a new job with [Nory.ai](https://nory.ai) in May '24. I got busy in there very quickly and the personal side products slowed down to a crawl, but I was gradually building a wedding portal website for a friend and designing and building a NextJS-Wordpress website for a Dental clinic.
![Deansgrange Dental Clinic website](/images/3-years-with-ai/deansgrange-dental.jpg)
Actually while looking at that site again to get this screenshot, I remembered that AI helped me to find some unusual ways to do scroll tracking and responsive visibility with native CSS variables. In trying to piece this timeline together, I dipped into my emails and found a "Making the most of Cursor" email from 4th September 2024, followed by a "Receipt from Anthropic" 14 days later. Cursor is a great product, but it was the Claude/Sonnet API that was doing the AI work, so I never ended up subscribing to Cursor - I went straight to the source and I've been an Anthropic customer since. There was (and still is) a VSCode plugin called [Continue](https://continue.dev), that you could use to get a similar setup to Cursor, with a chat panel in the IDE that you use to specify files or lines of code to pass to Claude, and this became my daily driver at work and at home. Rather than paying a fixed subscription to Cursor I was just paying directly for token usage from Anthropic. Nory.ai are obviously a product company who lean into AI and using these tools in there was encouraged. That's where I'd start to really notice the rift between engineers who were leaning into it and those who had no interest in using it. I used to share things on Slack in work when I found useful or interesting ways of working with AI while coding. I remember writing up at the time about how pairing with AI gets you into the flow-channel quicker. The flow channel is a theoretical graph that you can Google, with Challenge on the Y axis and Skill on the X. When you're working "in the zone" in the sweet spot of skill and challenge, anxiety and boredom are minimised and you get very focused. AI tooling can augment your skills and reduce most challenges, so I always found it got me into that flow super quick. At this point I was still in chatbot territory, with Continue making code changes to files from within VSCode. Agentic coding with ClaudeCode hadn't arrived yet, but there was (and still is) a tool called [Aider](https://aider.chat) that gave AI command line access in a similar way. I'm still on that same Macbook, so I can just ask Claude to check the file system and figure out when I installed that... *"Aider was first installed on October 19, 2024"* -- ClaudeCode At the time when I was trying it out we had a little bit of tech debt in the frontend code in Nory. I recorded a Loom video of me using Aider to decompose a React component into 12 smaller React components. Not complicated work, but definitely repetitive and time consuming, creating prop types for them all and extracting shared code etc. I shared the video thinking it was awesome what could be done with Aider. I think I only had a couple of replies, one being "Command line access for AI - what could go wrong...". At the same time our head designer in there was loving Cursor and giving us a dig out with updating our design system tokens in the codebase. It was interesting to see how some engineers shun away from AI tooling while some designers jump in head-first. Back over in side-project territory I had started building a gym app I was calling [Afterset](https://apps.apple.com/us/app/afterset/id6756236020) (named that because that's when you log your set.). I realised I could also use AI to generate data for muscles and exercises - not just write code. So now it was trivial to create hundreds of exercises and get AI to define what proportion of each exercise would hit each muscle. A recurring theme with working with AI is that the more you use it the more "oh you can do THAT with it too" ideas naturally occur. I started learning about graph data structures and graph databases through building Afterset. Without AI I'd never have had the patience or time. Using Residents as a boilerplate, Claude and I built an Arango database that we'd use as a back-end for a weight training app, where each set of an exercise could be roughly attributed to each muscle involved, so that as you build up training logs you'd be able to see what muscles are being used too much or too little. I got stuck into Miro and started wire-framing. The app/front-end of Afterset had no plan yet and I'd never tried any iOS/Swift development at this point. I started trying out React Native but ended up parking the app for the time being.
![Afterset Miro wireframes](/images/3-years-with-ai/afterset-miro.jpg)
The next thing on this timeline was ClaudeCode. I was still using Claude via Continue a lot at this stage, at work and at home, still using the pay-as-you-go model via the API. ClaudeCode came out as a developer preview in late February 2025 - I'm writing all of this on its first birthday. I started using it from day one, but at the time it was also paying by the token/API, and it absolutely burned through money. It was way more expensive than using the API, but even then it felt like a completely different beast to Cursor and Continue. I still kept Continue as my primary tool due to the cost of using ClaudeCode, but if I was stuck on something I'd reach for ClaudeCode and it would nearly always nail it. I'd definitely have been using ClaudeCode full-time back then if I'd had a room full of cash to burn. In spring 2025 I left Nory and went back to Toast to work on a React Native product. I had a lot of React experience by then but no significant iOS or Android experience, making it a good move for learning some new tech. It aligned well with me wanting to build my own products and iOS apps. By now most big tech companies had started to push these new AI workflows too - so we had pilot programs for Cursor, followed soon after by ClaudeCode. ClaudeCode has been my main tool in my day-job for about 9 months now too and writes most of my code. I'll write a separate more technical post about how I work with it on the day to day. By now ClaudeCode had the new subscription model, and I was using the basic €20/month subscription on my personal account, but was hitting the usage limits too quickly. Soon I bumped up to a Claude Max account at around €100/month. I think if there's ever an important time to pay this much for a subscription, it's during these formative years of AI. I was hitting the usage limits because I had started building a new iOS app called [Grapla](https://grapla.app) and the flow state with ClaudeCode was getting highly addictive. I ended up committing code to my personal Github account on 360 days of 2025. Grapla turned into a massive rabbit-hole that I'm still building, but it's teaching me a lot.
"The Grapla repo began on April 30, 2025" -- ClaudeCode I won't get into the details of that app here because it'll end up doubling the length of this essay. It'll be a good subject for a series later. But it taught me a massive amount of iOS development, and triple that again in ways of building with Claude. One recurring theme in both work and side projects is the speed that you can build tooling and scripts to support the main project, and these often grow into their own side projects. For iOS projects I ended up building [XC-MCP](https://github.com/conorluddy/xc-mcp) (an MCP that wraps XCodebuild so that Claude can build your app without having to eat 40,000 tokens in output logs), [ios-simulator-skill](https://github.com/conorluddy/ios-simulator-skill) (now with 500+ stars on Github - (but it's just a Claude-Skill port of XCMCP)), and another tool called [Persuader](https://github.com/conorluddy/Persuader) - intended to "persuade" Claude to consistently generate data to match a schema.
Back in the day job I was still sharing ways I was finding useful to work with Claude, and sharing tips and shortcuts and insights. I gave a couple of talks on MCP servers, one virtually and one in a room full of real people in our Boston office - explaining what MCPs are for and why they can be valuable and/or save tokens and money. This boils down to "context engineering" and optimising how context and tokens are used - although since promoting MCPs back then, it's now better to use Skills and not to have any MCPs or plugins active unless you're actively using them. They add unnecessary tokens to every session, costing you money even if you're not using them. At the time though, I was all about the MCPs, and thought I'd try to build an MCP that would spawn other MCPs, like a boilerplate/manager type thing. I called it [ContextPods](https://github.com/conorluddy/ContextPods) because the pods were supposed to be little modular MCPs that would handle context for the agents. Never ended up using it for anything in the end, but all of these were practice and taught me different things. The Grapla app was taking a long time but had also produced a lot of reusable patterns and tooling. One Saturday I had been thinking about the Afterset app that hadn't been touched since I started Grapla. The JSON data that had been generated for it was ripe for revival, and since I'd last looked at the project I'd gone and learned a bunch about iOS development. Now I knew it didn't even really need a back-end - everything could just live on the phone and work offline. I decided to see if we could start an iOS project for Afterset on a weekend and have it submitted to the AppStore by the Sunday evening. Apple reviewed it within 24 hours, and it live and downloadable by Monday evening. It was far from perfect, but it was proof that all the work on my other app had paved the way for getting apps built fast. The problem now was that Afterset was live as a weekend project, but I couldn't just leave it there like that, it needed a bunch of work to make it actually be any good. The POC of getting an app live in a weekend had turned into a time trap. So Grapla got parked for a while and I tried to get Afterset up to a state where it could be used in the gym without having too many missing features. It needs a lot more work, but it's another app that I can do a complete write-up on soon.
I finally parked Afterset (it still needs more work) and got back to Grapla, and while Claude was building me out a separate NextJs marketing website for it we got chatting about ideas. All of the data for the app is JSON, so it can be plucked into other projects too. With the marketing website done we started discussing how we could generate other marketing assets. I often tell Claude to create experiments using prompts like "build us ten variations of X" and we'll cross pollinate ideas and polish up something that comes out of it. In this case it randomly came up with a periodic table of Jiu Jitsu that turned into a super feature for SEO on the website - but that also evolved into something that could render social media images for hundreds of the positions in our app data.
[![Grapla periodic table of Jiu Jitsu positions](/images/3-years-with-ai/grapla-periodic-table.png)](https://grapla.app)
So we had an endpoint that rendered images in social media format, and a script that would hit it for each entity in our data. Hundreds of images generated in under a minute, using nice colour palette generated by ChromaJs. Claude then went a step further and created flow videos with ffmpeg. Grapla is a Jiu Jitsu app, so these videos are all just flow sequences ending in submissions, perfect for the marketing Tiktok/Insta accounts.
![Grapla social media images generated by Claude](/images/3-years-with-ai/grapla-social-images.png)
If anything can be done on a command line, Claude can do it and automate it. The latest thing I've been exploring is augmenting [Obsidian](https://obsidian.md) with Claude. I'm seeing if I can build a Zod and data graph layer over it that Claude can plug into. Then I'll be using it as a central knowledge base that contains documentation, specs and idea/discovery content for all of my projects. Claude will be able to extract a strongly typed JSON representation of it all and hopefully able to cross-pollinate ideas and resources between everything. Before I go too deep down that rabbit hole though I need to get Grapla launched.
![Grapla marketing website — Kimura submission](/images/3-years-with-ai/grapla-website.png)
Somehow between starting this write-up and finishing it, I ended up building another minimal app called [FrictionList](https://apps.apple.com/us/app/frictionlist/id6759489834) that's ~~currently being reviewed by Apple~~.
These little CSS phone mockups and the timelines at the edges (only on big screen) were also built by Claude, purely for this article - they were fun. I'll never be dogmatic about ways to work with AI - it's a very open-ended and subjective experience and everyone has their own approach. But I'll be sharing what works for me, and what feels like useful details of all of the different things I worked with it on over the past few years. More ramblings coming soon... --- # Code Style Guide Write for understanding, optimize for limited attention. I use some version of this guide in most of my projects, to help keep them LLM and human friendly. category: Reference date: 2026-02-20 reading-time: 20 min read url: https://conor.fyi/writing/codestyle --- > **Core Principle**: Context is finite. Every token — code, comment, structure — competes for limited attention. Maximize signal, minimize noise. Write for two audiences: humans with limited working memory and AI agents with bounded context windows. ## Philosophy The optimal code is the minimum necessary to solve the problem correctly. Every additional line is debt. **Progressive Disclosure**: Structure code layer-by-layer. Readers grasp high-level flow immediately, drilling into details only when needed. File names indicate purpose. Directory structures mirror conceptual hierarchies. Function names describe behavior without reading implementation. **Self-Documenting**: Names eliminate need for comments. Comments explain "why," never "what." If you chose algorithm A over B for subtle reasons, state that. If you're working around a library bug, explain it. **Aggressive Minimalism**: Before adding code, ask: "Is this the simplest solution?" Before adding a comment: "Does this clarify something non-obvious?" Before introducing an abstraction: "Does this reduce complexity, or merely relocate it?" **AHA Over DRY**: Avoid Hasty Abstractions. Wait for the 3rd duplication before extracting. The wrong abstraction is worse than duplication. Three similar lines of code is better than a premature abstraction. ## Progressive Disclosure Structure every layer of your system so readers — human or agent — get the right level of detail at the right time. No one should need to read 2000 lines to understand what a module does. ### The Zoom Principle Code should work like a map: zoom out for the big picture, zoom in for street-level detail. Each zoom level should be self-sufficient. ``` // Level 0: Directory structure tells you what exists src/ ├── authentication/ # "There's an auth system" ├── orders/ # "There's an order system" ├── payments/ # "There's a payment system" └── README.md # How they connect // Level 1: Index file tells you what it can do // authentication/index.ts export { authenticateUser } from './authenticate'; export { refreshSession } from './sessions'; export { revokeAccess } from './revoke'; // No implementation visible — just capabilities // Level 2: Function signature tells you the contract async function authenticateUser( credentials: UserCredentials, db: Database, clock: Clock ): Promise> // Level 3: Implementation tells you how // Only read this when you need to change the behaviour ``` ### File-Level Disclosure Every file should answer "what is this?" in its first 10 lines. Implementation details belong below. ```typescript // ✅ Top of file reveals purpose, contract, and shape /** * Order Processing Pipeline * * Validates → enriches → prices → submits orders. * Entry point: processOrder() * Error strategy: Result types, no throws */ // Types first — the contract type ProcessOrderInput = { /* ... */ }; type ProcessOrderResult = Result; // Public API second export async function processOrder(input: ProcessOrderInput): Promise { const validated = validateOrder(input); if (!validated.ok) return validated; const enriched = await enrichWithInventory(validated.value); if (!enriched.ok) return enriched; return submitOrder(enriched.value); } // Private helpers last — only read if you need to understand a specific step function validateOrder(input: ProcessOrderInput): Result { // ... } ``` ```typescript // ❌ Implementation soup — must read everything to understand anything import { db } from '../globals'; const RETRY_COUNT = 3; const BACKOFF_MS = 100; function helper1() { /* ... */ } function helper2() { /* ... */ } // 200 lines later... export function processOrder() { /* ... */ } ``` ### Documentation Disclosure Match documentation depth to the reader's likely intent. Most readers want "what does this do?" — very few want "why did you choose bcrypt over argon2?" ``` Level 1 — CLAUDE.md (5 seconds) "This is an order processing API. Entry: src/api/server.ts" Level 2 — Module README (30 seconds) "Orders go through validate → enrich → price → submit. Uses Result types. Retries on transient failures." Level 3 — Section comments (2 minutes) // ======================================== // PRICING ENGINE // ======================================== // Applies tiered discounts, tax rules, and currency conversion. // See: docs/pricing-model.md for business rules. Level 4 — Inline "why" comments (as needed) // Using ceiling division here because partial units // must be billed as full units per the SLA. ``` ### API & Type Disclosure Public interfaces should be scannable summaries. Implementation types stay internal. ```typescript // ✅ Public types: minimal, focused, scannable // orders/types.ts — what consumers need to know export type OrderSummary = { id: OrderId; status: OrderStatus; total: Money; itemCount: number; createdAt: DateTime; }; // orders/internal-types.ts — implementation detail // Not exported. Contains pricing breakdowns, audit trails, // intermediate computation states, retry metadata, etc. type OrderPricingContext = { /* ... */ }; type OrderAuditEntry = { /* ... */ }; ``` ### Disclosure Anti-Patterns - **Premature depth**: Putting implementation details in README files - **Flat disclosure**: 500-line files with no visual hierarchy or grouping - **Inverted disclosure**: Helpers at top, public API buried at bottom - **Missing levels**: Jumping from directory listing straight to inline comments with nothing in between ## Naming The #1 impact on readability. Good names eliminate mental translation overhead. ``` // ✅ Descriptive, unambiguous async function validateJsonAgainstSchema( schema: ZodSchema, input: string ): Promise function calculateExponentialBackoff( attemptNumber: number, baseDelayMs: number ): number // ❌ Vague, abbreviated async function valJson(s: any, i: string): Promise function calcBackoff(n: number, d: number): number ``` **Rules**: 1. **Be specific**: `activeUsers` not `users`, `httpTimeoutMs` not `timeout` 2. **Include units**: `delayMs` not `delay`, `maxRetries` not `max` 3. **Avoid abbreviations**: `customer` not `cust`, `configuration` not `cfg` 4. **Use domain language**: Names from business domain, not technical abstractions 5. **Boolean prefixes**: `isValid`, `hasPermission`, `canEdit`, `shouldRetry` 6. **Verbs for functions**: `validateEmailFormat()` not `checkEmail()`, `fetchActiveUsers()` not `getUsers()` ## Function Design ### Single Responsibility with Explicit Contracts ``` // ✅ Self-contained, explicit dependencies, typed contract async function authenticateUser( credentials: UserCredentials, database: Database, currentTime: DateTime ): Promise> { // All dependencies visible in signature // Return type reveals all possible outcomes } // ❌ Hidden dependencies, unclear contract async function auth(data: any): Promise { // Uses global config, modifies global state } ``` ### Guard Clauses Over Nesting Handle edge cases first, keep the happy path unindented and visible. ``` // ✅ Guard clauses — happy path clear function processOrder(order: Order): Result { if (!order) return err('missing_order'); if (order.items.length === 0) return err('empty_order'); if (order.total <= 0) return err('invalid_total'); if (!order.paymentMethod) return err('missing_payment'); return ok(completePayment(order)); } // ❌ Nested conditions — happy path buried function processOrder(order: Order) { if (order) { if (order.items.length > 0) { if (order.total > 0) { // Happy path buried 4 levels deep } } } } ``` ### Design Rules 1. **Single responsibility** — describable in one sentence 2. **Explicit dependencies** — all inputs as parameters, no hidden global state 3. **Type everything** — TypeScript strict mode, Python type hints 4. **Self-contained context units** — comprehensible without reading other files 5. **50-line guideline** — not a hard limit, but a refactoring trigger ## Error Handling ### Result Types — Make Errors Explicit Errors belong in function signatures, not hidden behind `throw`. ``` type Result = | { ok: true; value: T } | { ok: false; error: E }; type UserError = 'not_found' | 'unauthorized' | 'network_failure'; async function fetchUser(id: string): Promise> { // Errors are part of the contract } // Usage forces error handling — compiler catches missing cases const result = await fetchUser(userId); if (!result.ok) { switch (result.error) { case 'not_found': return show404(); case 'unauthorized': return redirectLogin(); case 'network_failure': return showRetry(); } } ``` **When to use Result types**: API calls, file I/O, validation, any complex error path. **When to use exceptions**: Truly exceptional/unrecoverable situations (out of memory, corrupted state). ### Branded Types — Validate at Boundaries ``` type ValidatedEmail = string & { readonly __brand: 'ValidatedEmail' }; type UserId = string & { readonly __brand: 'UserId' }; function validateEmail(input: string): ValidatedEmail | null { return isValidEmail(input) ? (input as ValidatedEmail) : null; } // Type system prevents using unvalidated data function sendEmail(to: ValidatedEmail, subject: string) { // No need to re-validate — type guarantees validity } ``` Once you have a `ValidatedEmail`, downstream functions carry zero validation overhead. The type system encodes the knowledge that validation occurred. ### Error Principles 1. **Never silently swallow errors** — log or propagate, never ignore 2. **Fail fast at boundaries** — validate inputs immediately, not deep in call stack 3. **Provide actionable messages** — what failed, expected vs actual, how to fix ``` // ✅ Actionable error with context throw new ValidationError( `Email validation failed for "user_email": ` + `Expected "name@domain.com", received "${input}". ` + `Use validateEmailFormat() to check before calling.` ); // ❌ Opaque throw new Error("Validation failed"); ``` ## File & Module Organization ### Structure with Clear Boundaries ``` // ======================================== // PUBLIC API // ======================================== export class UserService { constructor(private readonly db: Database) {} async createUser(data: CreateUserData): Promise> { // Public interface } } // ======================================== // VALIDATION // ======================================== function validateUserData(data: unknown): Result { // Grouped validation logic } // ======================================== // PRIVATE HELPERS // ======================================== function hashPassword(password: string): Promise { // Internal implementation } ``` ### Organization Rules 1. **Group by feature/domain**, not file type — `authentication/`, `orders/`, `payments/` 2. **Public API first** — exported functions at top, helpers at bottom 3. **One major export per file** — `UserService.ts` exports `UserService` 4. **Co-locate tests** — `UserService.test.ts` next to `UserService.ts` 5. **300-line guideline** — not a hard limit, but a refactoring trigger 6. **Minimal cross-module dependencies** — each module is a clean context boundary ``` project/ ├── authentication/ # Self-contained context │ ├── index.ts # Public API only │ ├── credentials.ts │ ├── sessions.ts │ └── README.md # Module architecture ├── orders/ # Independent context └── storage/ # Independent context ``` ## Testing ### Testing Trophy — Mostly Integration "Write tests. Not too many. Mostly integration." — Kent C. Dodds 1. **Static Analysis** (foundation): TypeScript strict mode, ESLint 2. **Unit Tests** (narrow): Pure functions, complex algorithms 3. **Integration Tests** (widest — most tests here): How pieces work together, where bugs actually live 4. **E2E Tests** (top): Critical user journeys only ### Tests as Documentation Test names describe scenarios. Docstrings explain "why." Tests demonstrate usage. ``` test('should reject invalid credentials without revealing if username exists', async () => { // Prevents username enumeration attacks const auth = new Authenticator(database); const result = await auth.authenticate({ email: 'nonexistent@example.com', password: 'any-password' }); expect(result.ok).toBe(false); expect(result.error.code).toBe('INVALID_CREDENTIALS'); expect(result.error.message).not.toContain('user not found'); }); ``` ### Testing Rules 1. **Test behavior, not implementation** — focus on inputs/outputs, not internal state 2. **One concept per test** — don't test multiple unrelated things 3. **Integration over unit** — test pieces working together (more confidence per test, more resilient to refactoring) 4. **Clear test names** — describe the scenario: `test('user can add items to cart')` 5. **80% coverage minimum** — focus on critical paths ## Observability ### Structured Logging ``` // ✅ Structured — queryable, correlated logger.info('Request processed', { request_id: requestId, user_id: userId, endpoint: req.path, method: req.method, duration_ms: duration, status_code: res.statusCode, cache_hit: cacheHit }); // ❌ Unstructured — hard to query logger.info(`User ${userId} accessed ${req.path}`); ``` ### What to Log **Always include**: request\_id, user\_id, trace\_id, entity IDs, operation type, duration\_ms, error details. **Log at critical boundaries**: * External API calls (request/response) * Database operations (query, duration) * Authentication/authorization decisions * Error occurrences with full context **One structured event per operation** — derive metrics, logs, or traces from the same data. Don't instrument separately for each observability pillar. ## Agentic Coding Patterns These patterns address the unique demands of code that will be read, modified, and executed by AI agents alongside humans. ### Idempotent Operations Agents retry. Network calls fail. Tasks get re-run. Design every mutation to be safely repeatable. ``` // ✅ Idempotent — safe to retry async function ensureUserExists( email: ValidatedEmail, db: Database ): Promise { const existing = await db.users.findByEmail(email); if (existing) return existing; return db.users.create({ email }); } // ❌ Non-idempotent — duplicates on retry async function createUser(email: string, db: Database): Promise { return db.users.create({ email }); } ``` ### Explicit State Machines Over Implicit Flows When operations have distinct phases, model them explicitly. Agents reason about state machines far better than implicit status flags scattered across objects. ``` type OrderState = | { status: 'draft'; items: Item[] } | { status: 'submitted'; items: Item[]; submittedAt: DateTime } | { status: 'paid'; items: Item[]; submittedAt: DateTime; paymentId: string } | { status: 'shipped'; items: Item[]; trackingNumber: string }; // Each transition is a pure function with clear preconditions function submitOrder(order: OrderState & { status: 'draft' }): OrderState & { status: 'submitted' } { return { ...order, status: 'submitted', submittedAt: DateTime.now() }; } ``` ### Machine-Parseable Errors Agents need structured errors alongside human-readable ones. Return error codes that can be programmatically matched, with messages that explain context. ``` type AppError = { code: 'VALIDATION_FAILED' | 'NOT_FOUND' | 'CONFLICT' | 'UPSTREAM_TIMEOUT'; message: string; // Human-readable explanation field?: string; // Which input caused it retryable: boolean; // Can the caller retry? }; ``` ### Atomic, Independently-Verifiable Changes Structure work so each change can be validated in isolation. This applies to commits, PRs, and function design. An agent (or reviewer) should be able to verify correctness without understanding the entire system. ``` // ✅ Each function is independently testable and verifiable function parseConfig(raw: string): Result { /* ... */ } function validateConfig(config: Config): Result { /* ... */ } function applyConfig(config: ValidConfig, system: System): Result { /* ... */ } // ❌ Monolithic — must understand everything to verify anything function loadAndApplyConfig(path: string): void { /* 200 lines */ } ``` ### Convention Over Configuration Reduce the search space for agents (and humans). Consistent patterns mean less context needed per decision. * Consistent file naming: `UserService.ts`, `UserService.test.ts`, `UserService.types.ts` * Predictable directory structure across features * Standard patterns for CRUD operations, API endpoints, error handling * If your project has a pattern, follow it. If it doesn't, establish one and document it ### Contract-First Design Define types before implementation. Types are the cheapest, most scannable form of documentation. An agent reading your types understands your system's data flow without reading a single function body. ``` // Define the contract first interface OrderService { create(data: CreateOrderInput): Promise>; cancel(id: OrderId, reason: CancelReason): Promise>; findByUser(userId: UserId, pagination: Pagination): Promise>; } // Then implement — the types guide everything ``` ### Observable Side Effects Every mutation should produce structured output describing what changed. This enables agents to verify their actions and enables humans to audit. ``` type MutationResult = { data: T; changes: Change[]; // What was modified warnings: string[]; // Non-fatal issues encountered }; async function updateUserProfile( id: UserId, updates: ProfileUpdates ): Promise, UpdateError>> { // Returns both the result AND a description of what changed } ``` ### Context Optimisation & Token Economics > Every token an agent reads is a token it can't use for reasoning. Treat context like memory in an embedded system — budget it, measure it, and refuse to waste it. #### The Context Budget AI agents operate within fixed context windows. Your code, documentation, error messages, and tool outputs all compete for the same finite space. Code that is token-efficient isn't just neat — it directly improves agent reasoning quality. ``` Context Window (finite) ├── System prompt & instructions ~2-5k tokens (fixed cost) ├── Conversation history ~variable ├── Tool definitions ~1-10k tokens (per tool schema) ├── Retrieved code / docs ~variable ← YOU CONTROL THIS ├── Agent reasoning ~variable ← THIS GETS SQUEEZED └── Output generation ~variable ← AND SO DOES THIS The more tokens your code consumes, the less room the agent has to think. Optimise ruthlessly. ``` #### Semantic Compression Collapse granular interfaces into high-level semantic operations. Instead of exposing every low-level action, expose intent-based APIs. ```typescript // ❌ 15 granular tools = ~15k tokens of schema // An agent must read and reason about ALL of them tools: [ createFile, readFile, deleteFile, moveFile, copyFile, listDirectory, createDirectory, deleteDirectory, getFileMetadata, setFilePermissions, watchFile, compressFile, decompressFile, hashFile, diffFiles ] // ✅ 1 semantic dispatcher = ~1k tokens of schema // Agent reasons about intent, not mechanics tools: [{ name: "filesystem", description: "Manage files and directories", parameters: { operation: "create | read | delete | move | copy | list | ...", path: "string", options: "object (operation-specific)" } }] ``` This is the dispatcher pattern: consolidate related tools behind a single entry point that routes by intent. Token cost drops dramatically while functionality stays the same. #### Layered Context Loading Don't front-load everything. Provide summaries first, with drill-down paths for when the agent actually needs more detail. ```typescript // ✅ Layered: summary first, details on demand function getProjectOverview(): ProjectSummary { return { name: "DataPipeline", modules: ["ingestion", "transform", "export"], entryPoint: "src/main.ts", recentChanges: getRecentChangeSummary(5), // Drill-down references — agent only loads what it needs getModuleDetail: (name: string) => loadModuleContext(name), getFileContent: (path: string) => loadFileContext(path), }; } // ❌ Eager: dumps everything into context upfront function getProjectContext(): FullProjectDump { return { allFiles: readAllFiles(), // 50k tokens allTests: readAllTests(), // 30k tokens allDocs: readAllDocs(), // 20k tokens // Agent's context window is now full before it starts thinking }; } ``` #### Token-Aware Documentation Write documentation that serves both human readers and token budgets. Every word should earn its place. ```markdown # ❌ Token-heavy: narrative style, repetitive, verbose ## Overview of the Authentication Module The authentication module is responsible for handling all aspects of user authentication within our application. This module was designed with security best practices in mind and implements industry-standard protocols. The module handles user login, token generation, session management, and token refresh functionality. It is important to note that this module uses JWT tokens for authentication purposes. (~80 tokens to say what could be said in 15) # ✅ Token-efficient: dense, scannable, no filler ## Authentication JWT-based auth with refresh token rotation. - Entry: `authenticate()` → `Result` - Tokens: 15min access, 7d refresh (HTTP-only cookie) - Storage: PostgreSQL users, Redis token blacklist (~40 tokens, more information conveyed) ``` #### Structured Output for Agent Consumption When building tools or functions that agents will consume, prefer structured, parseable output over human-readable prose. ```typescript // ✅ Agent-friendly: structured, parseable, minimal type BuildResult = { success: boolean; errors: { file: string; line: number; code: string; message: string }[]; warnings: { file: string; line: number; code: string; message: string }[]; stats: { duration_ms: number; filesProcessed: number }; }; // ❌ Human-only: requires parsing natural language function getBuildOutput(): string { return `Build completed with 2 errors and 1 warning. Error in src/auth.ts line 42: Type 'string' is not assignable... Error in src/orders.ts line 18: Property 'id' does not exist... Warning in src/utils.ts line 7: Unused variable 'temp'... Build took 3.2 seconds, processed 47 files.`; } ``` #### Context Boundaries as Architecture Design modules so an agent can work within one module without loading others. Each module should be a self-contained context unit. ```typescript // ✅ Clean context boundary — agent only needs this module // payments/index.ts export interface PaymentService { charge(input: ChargeInput): Promise>; refund(id: PaymentId, reason: RefundReason): Promise>; } // payments/types.ts — all types co-located, no external dependencies export type ChargeInput = { amount: Money; method: PaymentMethod; idempotencyKey: string; // Agent-friendly: built-in retry safety }; // payments/errors.ts — exhaustive, machine-readable export type PaymentError = | { code: 'INSUFFICIENT_FUNDS'; available: Money } | { code: 'CARD_DECLINED'; reason: string; retryable: false } | { code: 'GATEWAY_TIMEOUT'; retryable: true }; ``` ``` // ❌ Leaky context boundary — agent must load 4 modules to understand 1 // payments/index.ts import { User } from '../users/types'; import { Order } from '../orders/types'; import { AuditLogger } from '../audit/logger'; import { ConfigManager } from '../config/manager'; // Agent now needs context from users/, orders/, audit/, config/ ``` #### Compression Strategies Reference | Strategy | Before | After | Savings | |----------|--------|-------|---------| | Semantic dispatchers | N tool schemas (~N × 1k tokens) | 1 dispatcher (~1k tokens) | ~(N-1)k tokens | | Layered loading | Full dump (50k tokens) | Summary + drill-down (2k + on-demand) | ~48k idle tokens | | Dense docs | Narrative prose (~80 tokens/concept) | Structured bullets (~40 tokens/concept) | ~50% | | Co-located types | Scattered across modules | Single `types.ts` per module | Fewer file loads | | Summary-first returns | Full object graphs | Summary + reference IDs | 60-90% per call | | Discriminated unions | Generic error + message string | Typed union with `code` field | Eliminates parsing | ## Project Navigation ### CLAUDE.md at Project Root Every project needs a navigation file. List entry points, patterns, and common tasks. ``` # Project: Data Processing Pipeline ## Entry Points - `src/main.ts`: CLI interface - `src/api/server.ts`: REST API - `src/processors/pipeline.ts`: Core processing ## Key Patterns - All processors implement `Processor` interface (src/processors/base.ts) - Config uses Zod schemas (src/config/schemas.ts) - External APIs via `APIClient` (src/external/client.ts) ## Common Tasks - Add data source → implement `DataSource` in `src/api/sources/` - Add transformation → implement `Transformer` in `src/processors/transformers/` ``` Keep under 200 lines. Update when architecture changes. ### Module-Level READMEs Every major directory gets a README answering: What is this? How does it work? What are the gotchas? ``` # Module: User Authentication ## Purpose JWT-based authentication with refresh token rotation ## Key Decisions - bcrypt cost factor 12 for password hashing - Access tokens expire after 15 minutes - Refresh tokens stored in HTTP-only cookies ## Dependencies - jose library for JWT (not jsonwebtoken — more secure) - PostgreSQL for user storage - Redis for token blacklist ``` ### Progressive Context Hierarchy 1. **CLAUDE.md / README.md at root** — system overview, entry points, setup 2. **README.md per major module** — module purpose, key decisions, patterns 3. **Section comments in files** — group related code with clear headers 4. **Function/class docs** — purpose, examples for non-obvious APIs 5. **Inline comments** — only for "why" decisions ## Anti-Patterns * **Premature optimization** — Measure first, optimize second * **Hasty abstractions** — Wait for 3rd duplication before extracting * **Clever code** — Simple and obvious beats clever and compact * **Silent failures** — Log and propagate, never swallow * **Vague interfaces** — `process(data: any): any` provides zero guidance * **Hidden dependencies** — Global state, singletons, ambient imports * **Nested conditionals** — Use guard clauses instead * **Comments describing "what"** — If you need a comment to explain what code does, rename things * **Premature generalization** — Build for today's requirements, not hypothetical futures * **Token bloat** — Functions returning everything when callers need summaries * **Inverted disclosure** — Helpers at top, public API buried at bottom * **Flat files** — 500-line files with no visual hierarchy, grouping, or section comments * **Leaky context boundaries** — Modules that import heavily from siblings, forcing agents to load the entire codebase * **Eager context loading** — Dumping full project state into agent context when a summary would suffice ## Checklist Before submitting code: * Solves the stated problem with minimal code? * A new developer can understand it without extensive context? * Errors handled with actionable messages? * Names clear, specific, and unambiguous? * Functions have single, clear responsibilities? * Dependencies explicit (no hidden global state)? * Tests cover critical paths? * Operations idempotent where applicable? * Types define contracts before implementation? * Would this work well with ~200 lines of surrounding context? * Can an agent understand this module without loading adjacent modules? * Are public APIs scannable in under 50 lines? * Do tool/function outputs use structured types, not prose? * Is documentation token-dense (no filler words, no repetition)? * Does the file follow progressive disclosure (types → public API → helpers)? --- *"Any fool can write code that a computer can understand. Good programmers write code that humans can understand." — Martin Fowler* --- # Building a Design System on Mathematical Harmony The algebra of beautiful design category: Frontend date: 2026-01-16 reading-time: 8 min read url: https://conor.fyi/writing/golden-ratio-grid --- Most design systems feel arbitrary—a collection of numbers chosen because they seemed reasonable. This one is different. Every spacing value, every font size, every breakpoint follows mathematical sequences that naturally create visual harmony. ## The Philosophy Design systems should be predictable. When you know that spacing follows the Fibonacci sequence and font sizes follow the same pattern, you don't need to guess. You don't need a design spec with 47 different margin values. You need one simple rule: multiply by φ (the golden ratio, ≈1.618). The system is built on two foundational ideas: 1. **The Golden Ratio** - A mathematical constant that appears throughout nature and has been used in art and architecture for centuries 2. **The Fibonacci Sequence** - A series where each number is the sum of the two before it (0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89...) These aren't decorative. They're the entire foundation. ## The Golden Ratio Grid The layout starts with a single column: **34em** wide. For a reading experience optimized around 65–75 characters per line (the sweet spot for comprehension), this is perfect. But a single column is boring. On larger screens, we need a secondary column. We get it by dividing by φ: ```text 34em ÷ 1.618 ≈ 21em ``` The gutter between them is φ itself: ```text 1.618em ≈ 26px at a 16px base font size ``` Total width: ```text 34em + 1.618em + 21em = 56.618em ``` On mobile, the two-column layout collapses to a single column. The grid reactivates at 900px. ### In CSS: ```css :root { --phi: 1.618; --grid-primary: 34em; --grid-secondary: 21em; --grid-gutter: calc(var(--phi) * 1em); /* 1.618em */ --grid-total: 56.618em; } .grid-golden { display: grid; grid-template-columns: 1fr; gap: var(--space-8); } @media (min-width: 900px) { .grid-golden { grid-template-columns: var(--grid-primary) var(--grid-secondary); gap: var(--grid-gutter); } } ``` ## Typography: Three Fonts, One Purpose The typography system uses **three fonts**, each with a distinct role: ### 1. **Monda** (Body text) - Weights: 400, 500, 600, 700 - Role: Primary reading font for paragraphs, UI labels, lists - Why: Geometric and readable; weights provide hierarchy without changing fonts ### 2. **Kanit 100** (Display) - Weight: 100 (ultra-thin) - Role: h1 only—the page title - Why: Dramatic contrast. Ultra-thin at large size creates visual impact without noise ### 3. **Space Mono** (Structure + Code) - Weights: 400, 700 - Role: Headings (h2–h6), code blocks, labels - Why: Monospace creates a structured, technical feel. Separates hierarchy visually ### In CSS: ```css h1 { font-family: var(--font-display); /* Kanit 100 */ font-size: var(--font-size-2xl); font-weight: 100; } h2, h3, h4, h5, h6 { font-family: var(--font-secondary); /* Space Mono */ font-weight: 700; } body { font-family: var(--font-base); /* Monda */ } code, pre { font-family: var(--font-secondary); /* Space Mono */ } ``` ## Fibonacci Font Sizes Font sizes don't increment by fixed amounts (no +2px, +4px, +6px). Instead, they follow the Fibonacci sequence: ```text 8, 13, 21, 34, 55, 89 (px equivalents at 16px base) ``` Mapped to variables: ```css --font-size-xs: 0.5em /* 8px */ --font-size-sm: 0.8125em /* 13px */ --font-size-base: 1em /* 16px */ --font-size-lg: 1.3125em /* 21px */ --font-size-xl: 2.125em /* 34px */ --font-size-2xl: 3.4375em /* 55px */ --font-size-3xl: 5.5625em /* 89px */ ``` Why Fibonacci? The ratio between consecutive Fibonacci numbers approaches φ. This creates a consistent visual growth where jumping from one size to the next feels natural, not jarring. ### In CSS: ```css p { font-size: var(--font-size-base); } h3 { font-size: var(--font-size-lg); } h2 { font-size: var(--font-size-xl); } small { font-size: var(--font-size-sm); } ``` ## Fibonacci Spacing Spacing is everything in design. Too tight and text feels suffocating. Too loose and it feels disconnected. The solution: **consistent, mathematically justified spacing**. The base unit is `0.25em` (4px at 16px root). Multiples of the Fibonacci sequence: ```css --space-0: 0 --space-1: 0.25em /* 4px */ --space-2: 0.5em /* 8px */ --space-3: 0.75em /* 12px */ --space-5: 1.25em /* 20px */ --space-8: 2em /* 32px */ --space-13: 3.25em /* 52px */ --space-21: 5.25em /* 84px */ --space-34: 8.5em /* 136px */ --space-55: 13.75em /* 220px */ ``` Every spacing decision uses these values. No custom margins. No "15px because it looked right." This means the spacing rhythm propagates throughout the entire interface. ### In CSS: ```css h2 { margin-top: var(--space-21); /* 84px */ margin-bottom: var(--space-5); /* 20px */ } p { margin-bottom: var(--space-5); /* 20px */ } button { padding: var(--space-3) var(--space-5); /* 12px 20px */ } ``` ## Mobile-First Breakpoints Most design systems define breakpoints based on desktop first, then squeeze mobile. This system inverts that: **mobile is the default, desktop is the enhancement**. Six breakpoints, heavily skewed toward mobile: ```css --breakpoint-xs: 320px /* Tiny phones */ --breakpoint-sm: 375px /* Small phones (iPhone SE) */ --breakpoint-md: 428px /* Standard phones (iPhone 14/15) */ --breakpoint-lg: 520px /* Large phones (Pro Max) */ --breakpoint-xl: 680px /* Small tablets */ --breakpoint-2xl: 900px /* Desktop → 2-column grid */ ``` Why 6 breakpoints? Because mobile needs granularity. A 320px phone screen and a 680px tablet screen require different typography and spacing adjustments. But there's only one desktop breakpoint because once you reach 900px, the full two-column layout takes over. ### In CSS: ```css .container { padding-inline: var(--gutter-xs); /* 12px on tiny screens */ } @media (min-width: 375px) { .container { padding-inline: var(--gutter-sm); /* 16px on small phones */ } } @media (min-width: 900px) { .container { padding-inline: 0; /* Grid handles spacing */ } } ``` ## Desktop Offset: Quirky Centering On desktop, instead of centering the layout dead center, we offset it slightly to the right using—you guessed it—a Fibonacci value: ```text var(--space-13) = 52px offset ``` This creates visual breathing room on the left while keeping the layout slightly off-center. It feels intentional and quirky rather than perfectly balanced. The offset is applied using `transform: translateX()` on the desktop breakpoint, which means it doesn't affect the layout's centering calculations—the container still measures and aligns as centered, but appears shifted. ### In CSS: ```css @media (min-width: 900px) { .container { padding-inline: 0; transform: translateX(var(--space-13)); /* 52px right */ } } ``` Why `transform` instead of `margin`? Transform is applied after layout is calculated, so it doesn't break centering logic. Pure elegance. ## Responsive Gutters Side padding isn't fixed—it scales with the breakpoints: ```css --gutter-xs: 0.75em /* 12px */ --gutter-sm: 1em /* 16px */ --gutter-md: 1.25em /* 20px */ --gutter-lg: 1.5em /* 24px */ ``` On desktop, the gutter vanishes because the grid's gap handles spacing. This keeps the system predictable: you're not fighting the container for space. ## Colors & Dark Mode The color system is built on HSL. Instead of having 50 arbitrary hex colors, we define: 1. **Hue** - The color (0-360°) 2. **Saturation** - How colorful (0-100%) 3. **Lightness** - How bright (0-100%) This makes dark mode trivial. A light mode uses lightness 90%. Dark mode uses lightness 10%. No manual color tweaks. ```css :root { --hue-brand: 200; /* Sky blue */ --sat-brand: 100%; } :root { --color-brand-50: hsl(var(--hue-brand), var(--sat-brand), 97%); --color-brand-500: hsl(var(--hue-brand), var(--sat-brand), 50%); --color-brand-900: hsl(var(--hue-brand), var(--sat-brand), 19%); } @media (prefers-color-scheme: dark) { :root { --color-text-primary: var(--color-gray-50); --color-bg-primary: var(--color-gray-900); } } ``` Semantic tokens map to actual use: ```css :root { --color-text-primary: var(--color-gray-900); --color-bg-primary: var(--color-white); --color-border-primary: var(--color-gray-200); } ``` Now you use `var(--color-text-primary)` instead of `#1a1a1a`, and dark mode works automatically. ## Other Primitives The system extends beyond spacing and typography: - **Border radius**: `0.125em` (2px) to `1em` (16px)—all em-based so they scale - **Shadows**: Layered for depth without heaviness - **Transitions**: `150ms`, `200ms`, `300ms`, `500ms`—all Fibonacci-adjacent - **Z-index**: Named layers (`--z-dropdown: 1000`, `--z-modal: 1200`) - **Line heights**: `1.25` (tight) through `2` (loose) ## Putting It Together These primitives don't exist in isolation. A button uses typography (font-size), spacing (padding), colors (background), transitions (hover state), and sizing. The magic is that every element cascades from the same foundation. A simple component: ```css button { font-size: var(--font-size-sm); padding: var(--space-3) var(--space-5); background-color: var(--color-bg-brand); color: var(--color-text-inverse); border-radius: var(--radius-base); transition: background-color var(--duration-fast) var(--ease-out); } button:hover { background-color: var(--color-bg-brand-hover); } ``` Every value traces back to a mathematical sequence or semantic choice. There's no guesswork. ## The Result A design system built on mathematical principles isn't just aesthetic—it's practical: - **Predictability**: You know how spacing works. You know how sizes scale. - **Maintainability**: Change one variable (`--phi`) and the entire system follows. - **Scalability**: Add a new feature, use the existing tokens, and it automatically feels cohesive. - **Dark mode**: Automatic, no manual tweaking. - **Accessibility**: Proper line heights, contrast ratios, and motion preferences built in. The site you're reading now is built entirely on this system. Every margin, every font size, every color has a mathematical justification. That's not decoration—that's discipline. And discipline is what makes design systems work. --- # iOS 26 Liquid Glass: Comprehensive Swift/SwiftUI Reference A complete guide to Apple's most significant design evolution since iOS 7 category: Reference date: 2025-11-16 reading-time: 17 min read url: https://conor.fyi/writing/liquid-glass-reference --- ## Overview iOS 26 Liquid Glass represents Apple's most significant design evolution since iOS 7, introduced at WWDC 2025 (June 9, 2025). **Liquid Glass is a translucent, dynamic material that reflects and refracts surrounding content while transforming to bring focus to user tasks**. This unified design language spans iOS 26, iPadOS 26, macOS Tahoe 26, watchOS 26, tvOS 26, and visionOS 26. Liquid Glass features real-time light bending (lensing), specular highlights responding to device motion, adaptive shadows, and interactive behaviors. The material continuously adapts to background content, light conditions, and user interactions, creating depth and hierarchy between foreground controls and background content. **Key Characteristics:** - **Lensing**: Bends and concentrates light in real-time (vs. traditional blur that scatters light) - **Materialization**: Elements appear by gradually modulating light bending - **Fluidity**: Gel-like flexibility with instant touch responsiveness - **Morphing**: Dynamic transformation between control states - **Adaptivity**: Multi-layer composition adjusting to content, color scheme, and size --- ## Part 1: Foundation & Basics ### 1.1 Core Concepts **Design Philosophy** Liquid Glass is exclusively for the **navigation layer** that floats above app content. Never apply to content itself (lists, tables, media). This maintains clear visual hierarchy: content remains primary while controls provide functional overlay. **Material Variants** | Variant | Use Case | Transparency | Adaptivity | |---------|----------|--------------|------------| | `.regular` | Default for most UI | Medium | Full - adapts to any content | | `.clear` | Media-rich backgrounds | High | Limited - requires dimming layer | | `.identity` | Conditional disable | None | N/A - no effect applied | **When to Use Each Variant:** - **Regular**: Toolbars, buttons, navigation bars, tab bars, standard controls - **Clear**: Small floating controls over photos/maps with bold foreground content - **Identity**: Conditional toggling (e.g., `glassEffect(isEnabled ? .regular : .identity)`) **Design Requirements for Clear Variant** (all must be met): 1. Element sits over media-rich content 2. Content won't be negatively affected by dimming layer 3. Content above glass is bold and bright ### 1.2 Basic Implementation **Simple Glass Effect** ```swift import SwiftUI struct BasicGlassView: View { var body: some View { Text("Hello, Liquid Glass!") .padding() .glassEffect() // Default: .regular variant, .capsule shape } } ``` **With Explicit Parameters** ```swift Text("Custom Glass") .padding() .glassEffect(.regular, in: .capsule, isEnabled: true) ``` **API Signature** ```swift func glassEffect( _ glass: Glass = .regular, in shape: S = DefaultGlassEffectShape, isEnabled: Bool = true ) -> some View ``` ### 1.3 Glass Type Modifiers **Core Structure** ```swift struct Glass { static var regular: Glass static var clear: Glass static var identity: Glass func tint(_ color: Color) -> Glass func interactive() -> Glass } ``` **Tinting** ```swift // Basic tint Text("Tinted") .padding() .glassEffect(.regular.tint(.blue)) // With opacity Text("Subtle Tint") .padding() .glassEffect(.regular.tint(.purple.opacity(0.6))) ``` **Purpose**: Convey semantic meaning (primary action, state), NOT decoration. Use selectively for call-to-action only. **Interactive Modifier** (iOS only) ```swift Button("Tap Me") { // action } .glassEffect(.regular.interactive()) ``` **Behaviors Enabled:** - Scaling on press - Bouncing animation - Shimmering effect - Touch-point illumination that radiates to nearby glass - Response to tap and drag gestures **Method Chaining** ```swift .glassEffect(.regular.tint(.orange).interactive()) .glassEffect(.clear.interactive().tint(.blue)) // Order doesn't matter ``` ### 1.4 Custom Shapes **Available Shapes** ```swift // Capsule (default) .glassEffect(.regular, in: .capsule) // Circle .glassEffect(.regular, in: .circle) // Rounded Rectangle .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 16)) // Container-concentric (aligns with container corners) .glassEffect(.regular, in: .rect(cornerRadius: .containerConcentric)) // Ellipse .glassEffect(.regular, in: .ellipse) // Custom shape conforming to Shape protocol struct CustomShape: Shape { func path(in rect: CGRect) -> Path { // Custom path logic } } .glassEffect(.regular, in: CustomShape()) ``` **Corner Concentricity** Maintains perfect alignment between elements and containers across devices: ```swift // Automatically matches container/window corners RoundedRectangle(cornerRadius: .containerConcentric, style: .continuous) ``` ### 1.5 Text & Icons with Glass **Text Rendering** ```swift Text("Glass Text") .font(.title) .bold() .foregroundStyle(.white) // High contrast for legibility .padding() .glassEffect() ``` Text on glass automatically receives vibrant treatment - adjusts color, brightness, saturation based on background. **Icon Rendering** ```swift Image(systemName: "heart.fill") .font(.largeTitle) .foregroundStyle(.white) .frame(width: 60, height: 60) .glassEffect(.regular.interactive()) ``` **Labels** ```swift Label("Settings", systemImage: "gear") .labelStyle(.iconOnly) .padding() .glassEffect() ``` ### 1.6 Accessibility Support **Automatic Adaptation** - No code changes required: - **Reduced Transparency**: Increases frosting for clarity - **Increased Contrast**: Stark colors and borders - **Reduced Motion**: Tones down animations and elastic effects - **iOS 26.1+ Tinted Mode**: User-controlled opacity increase (Settings → Display & Brightness → Liquid Glass) **Environment Values** ```swift @Environment(\.accessibilityReduceTransparency) var reduceTransparency @Environment(\.accessibilityReduceMotion) var reduceMotion var body: some View { Text("Accessible") .padding() .glassEffect(reduceTransparency ? .identity : .regular) } ``` **Best Practice**: Let system handle accessibility automatically. Don't override unless absolutely necessary. --- ## Part 2: Intermediate Techniques ### 2.1 GlassEffectContainer **Purpose** - Combines multiple Liquid Glass shapes into unified composition - Improves rendering performance by sharing sampling region - Enables morphing transitions between glass elements - **Critical Rule**: Glass cannot sample other glass; container provides shared sampling region **Basic Usage** ```swift GlassEffectContainer { HStack(spacing: 20) { Image(systemName: "pencil") .frame(width: 44, height: 44) .glassEffect(.regular.interactive()) Image(systemName: "eraser") .frame(width: 44, height: 44) .glassEffect(.regular.interactive()) } } ``` **With Spacing Control** ```swift GlassEffectContainer(spacing: 40.0) { // Glass elements within 40 points will morph together ForEach(icons) { icon in IconView(icon) .glassEffect() } } ``` **Spacing Parameter**: Controls morphing threshold - elements within this distance visually blend and morph together during transitions. **API Signature** ```swift struct GlassEffectContainer: View { init(spacing: CGFloat? = nil, @ViewBuilder content: () -> Content) init(@ViewBuilder content: () -> Content) } ``` ### 2.2 Morphing Transitions with glassEffectID **Requirements for Morphing:** 1. Elements in same `GlassEffectContainer` 2. Each view has `glassEffectID` with shared namespace 3. Views conditionally shown/hidden trigger morphing 4. Animation applied to state changes **Basic Morphing Setup** ```swift struct MorphingExample: View { @State private var isExpanded = false @Namespace private var namespace var body: some View { GlassEffectContainer(spacing: 30) { Button(isExpanded ? "Collapse" : "Expand") { withAnimation(.bouncy) { isExpanded.toggle() } } .glassEffect() .glassEffectID("toggle", in: namespace) if isExpanded { Button("Action 1") { } .glassEffect() .glassEffectID("action1", in: namespace) Button("Action 2") { } .glassEffect() .glassEffectID("action2", in: namespace) } } } } ``` **API Signature** ```swift func glassEffectID( _ id: ID, in namespace: Namespace.ID ) -> some View ``` **Advanced Morphing Pattern - Expandable Action Menu** ```swift struct ActionButtonsView: View { @State private var showActions = false @Namespace private var namespace var body: some View { ZStack { Image("background") .resizable() .ignoresSafeArea() GlassEffectContainer(spacing: 30) { VStack(spacing: 30) { if showActions { actionButton("rotate.right") .glassEffectID("rotate", in: namespace) } HStack(spacing: 30) { if showActions { actionButton("circle.lefthalf.filled") .glassEffectID("contrast", in: namespace) } actionButton(showActions ? "xmark" : "slider.horizontal.3") { withAnimation(.bouncy) { showActions.toggle() } } .glassEffectID("toggle", in: namespace) if showActions { actionButton("flip.horizontal") .glassEffectID("flip", in: namespace) } } if showActions { actionButton("crop") .glassEffectID("crop", in: namespace) } } } } } @ViewBuilder func actionButton(_ systemImage: String, action: (() -> Void)? = nil) -> some View { Button { action?() } label: { Image(systemName: systemImage) .frame(width: 44, height: 44) } .buttonStyle(.glass) .buttonBorderShape(.circle) } } ``` ### 2.3 Glass Button Styles **Button Style Types** | Style | Appearance | Use Case | |-------|------------|----------| | `.glass` | Translucent, see-through | Secondary actions | | `.glassProminent` | Opaque, no background show-through | Primary actions | **Basic Implementation** ```swift // Secondary action Button("Cancel") { } .buttonStyle(.glass) // Primary action Button("Save") { } .buttonStyle(.glassProminent) .tint(.blue) ``` **With Customization** ```swift Button("Action") { } .buttonStyle(.glass) .tint(.purple) .controlSize(.large) .buttonBorderShape(.circle) ``` **Available Control Sizes** ```swift .controlSize(.mini) .controlSize(.small) .controlSize(.regular) // Default .controlSize(.large) .controlSize(.extraLarge) // New in iOS 26 ``` **Border Shapes** ```swift .buttonBorderShape(.capsule) // Default .buttonBorderShape(.roundedRectangle(radius: 8)) .buttonBorderShape(.circle) ``` ### 2.4 Toolbar Integration **Automatic Glass Styling** Toolbars automatically receive Liquid Glass treatment in iOS 26: ```swift NavigationStack { ContentView() .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel", systemImage: "xmark") { } } ToolbarItem(placement: .confirmationAction) { Button("Done", systemImage: "checkmark") { } } } } ``` **Automatic Behaviors:** - Prioritizes symbols over text - `.confirmationAction` automatically gets `.glassProminent` style - Floating glass appearance - Grouped layouts with visual separation **Toolbar Grouping with Spacing** ```swift .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { Button("Draw", systemImage: "pencil") { } Button("Erase", systemImage: "eraser") { } } ToolbarSpacer(.fixed, spacing: 20) // New in iOS 26 ToolbarItem(placement: .topBarTrailing) { Button("Save", systemImage: "checkmark") { } .buttonStyle(.glassProminent) } } ``` **ToolbarSpacer Types** ```swift ToolbarSpacer(.fixed, spacing: 20) // Fixed space ToolbarSpacer(.flexible) // Flexible space (pushes items apart) ``` ### 2.5 TabView with Liquid Glass **Basic TabView** Automatically adopts Liquid Glass when compiled with Xcode 26: ```swift TabView { Tab("Home", systemImage: "house") { HomeView() } Tab("Settings", systemImage: "gear") { SettingsView() } } ``` **Search Tab Role** Creates floating search button at bottom-right (reachability optimization): ```swift struct ContentView: View { @State private var searchText = "" var body: some View { TabView { Tab("Home", systemImage: "house") { HomeView() } Tab("Search", systemImage: "magnifyingglass", role: .search) { NavigationStack { SearchView() } } } .searchable(text: $searchText) } } ``` **Tab Bar Minimize Behavior** ```swift TabView { // tabs... } .tabBarMinimizeBehavior(.onScrollDown) // Collapses during scroll ``` **Options:** - `.automatic` - System determines - `.onScrollDown` - Minimizes when scrolling - `.never` - Always full size **Tab View Bottom Accessory** Adds persistent glass view above tab bar: ```swift TabView { // tabs... } .tabViewBottomAccessory { HStack { Image(systemName: "play.fill") Text("Now Playing") Spacer() } .padding() } ``` ### 2.6 Sheet Presentations **Automatic Glass Background** Sheets in iOS 26 automatically receive inset Liquid Glass background: ```swift .sheet(isPresented: $showSheet) { SheetContent() .presentationDetents([.medium, .large]) } ``` **Sheet Morphing from Toolbar** ```swift struct ContentView: View { @Namespace private var transition @State private var showInfo = false var body: some View { NavigationStack { ContentView() .toolbar { ToolbarItem(placement: .bottomBar) { Button("Info", systemImage: "info") { showInfo = true } .matchedTransitionSource(id: "info", in: transition) } } .sheet(isPresented: $showInfo) { InfoSheet() .navigationTransition(.zoom(sourceID: "info", in: transition)) } } } } ``` ### 2.7 NavigationSplitView Integration **Automatic Floating Sidebar** ```swift NavigationSplitView { List(items) { item in NavigationLink(item.name, value: item) } .navigationTitle("Items") } detail: { DetailView() } ``` Sidebar automatically receives floating Liquid Glass with ambient reflection. --- ## Part 3: Advanced Implementation ### 3.1 glassEffectUnion **Purpose**: Manually combine glass effects that are too distant to merge via spacing alone. **API Signature** ```swift func glassEffectUnion( id: ID, namespace: Namespace.ID ) -> some View ``` **Example** ```swift struct UnionExample: View { @Namespace var controls var body: some View { GlassEffectContainer { VStack(spacing: 0) { Button("Edit") { } .buttonStyle(.glass) .glassEffectUnion(id: "tools", namespace: controls) Spacer().frame(height: 100) // Large gap Button("Delete") { } .buttonStyle(.glass) .glassEffectUnion(id: "tools", namespace: controls) } } } } ``` ### 3.2 glassEffectTransition **API Signature** ```swift func glassEffectTransition( _ transition: GlassEffectTransition, isEnabled: Bool = true ) -> some View enum GlassEffectTransition { case identity // No changes case matchedGeometry // Matched geometry transition (default) case materialize // Material appearance transition } ``` ### 3.3 Complex Multi-Element Compositions **Floating Action Cluster** ```swift struct FloatingActionCluster: View { @State private var isExpanded = false @Namespace private var namespace let actions = [ ("home", Color.purple), ("pencil", Color.blue), ("message", Color.green), ("envelope", Color.orange) ] var body: some View { ZStack { ContentView() VStack { Spacer() HStack { Spacer() cluster .padding() } } } } var cluster: some View { GlassEffectContainer(spacing: 20) { VStack(spacing: 12) { if isExpanded { ForEach(actions, id: \.0) { action in actionButton(action.0, color: action.1) .glassEffectID(action.0, in: namespace) } } Button { withAnimation(.bouncy(duration: 0.4)) { isExpanded.toggle() } } label: { Image(systemName: isExpanded ? "xmark" : "plus") .font(.title2.bold()) .frame(width: 56, height: 56) } .buttonStyle(.glassProminent) .buttonBorderShape(.circle) .tint(.blue) .glassEffectID("toggle", in: namespace) } } } func actionButton(_ icon: String, color: Color) -> some View { Button { // action } label: { Image(systemName: icon) .font(.title3) .frame(width: 48, height: 48) } .buttonStyle(.glass) .buttonBorderShape(.circle) .tint(color) } } ``` ### 3.4 Symbol Effects Integration **Smooth Icon Transitions** ```swift struct SymbolGlassButton: View { @State private var isLiked = false var body: some View { Button { isLiked.toggle() } label: { Image(systemName: isLiked ? "heart.fill" : "heart") .font(.title) .frame(width: 60, height: 60) } .glassEffect(.regular.interactive()) .contentTransition(.symbolEffect(.replace)) .tint(isLiked ? .red : .primary) } } ``` ### 3.5 Performance Optimization **Best Practices:** 1. **Always Use GlassEffectContainer for Multiple Elements** ```swift // ✅ GOOD - Efficient rendering GlassEffectContainer { HStack { Button("Edit") { }.glassEffect() Button("Delete") { }.glassEffect() } } // ❌ BAD - Inefficient, inconsistent sampling HStack { Button("Edit") { }.glassEffect() Button("Delete") { }.glassEffect() } ``` 2. **Conditional Glass with .identity** ```swift .glassEffect(shouldShowGlass ? .regular : .identity) ``` No layout recalculation when toggling. 3. **Limit Continuous Animations** Let glass rest in steady states. 4. **Test on Older Devices** - iPhone 11-13: May show lag - Profile with Instruments for GPU usage - Monitor thermal performance ### 3.6 Dynamic Glass Adaptation **Automatic Color Scheme Switching** Glass automatically adapts between light/dark based on background: ```swift ScrollView { Color.black.frame(height: 400) // Glass becomes light Color.white.frame(height: 400) // Glass becomes dark } .safeAreaInset(edge: .bottom) { ControlPanel() .glassEffect() // Automatically adapts } ``` **Adaptive Behaviors:** - **Small elements** (nav bars, tab bars): Flip between light/dark - **Large elements** (sidebars, menus): Adapt but don't flip (would be jarring) - **Shadows**: Opacity increases over text, decreases over white backgrounds - **Tint**: Adjusts hue, brightness, saturation for legibility ### 3.7 Gesture Integration **Drag Gesture with Glass** ```swift struct DraggableGlassButton: View { @State private var offset = CGSize.zero @State private var isDragging = false var body: some View { Button("Drag Me") { } .glassEffect(.regular.interactive()) .offset(offset) .scaleEffect(isDragging ? 1.1 : 1.0) .gesture( DragGesture() .onChanged { value in isDragging = true offset = value.translation } .onEnded { _ in withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { isDragging = false offset = .zero } } ) } } ``` --- ## Part 4: Edge Cases & Advanced Topics ### 4.1 Handling Complex Background Content **The Readability Problem** Liquid Glass over busy, colorful, or animated content causes readability issues. **Solution 1: Gradient Fade** ```swift struct TabBarFadeModifier: ViewModifier { let fadeLocation: CGFloat = 0.4 let opacity: CGFloat = 0.85 let backgroundColor: Color = Color(.systemBackground) func body(content: Content) -> some View { GeometryReader { geometry in ZStack { content if geometry.safeAreaInsets.bottom > 10 { let dynamicHeight = geometry.safeAreaInsets.bottom VStack { Spacer() LinearGradient( gradient: Gradient(stops: [ .init(color: .clear, location: 0.0), .init(color: backgroundColor.opacity(opacity), location: fadeLocation) ]), startPoint: .top, endPoint: .bottom ) .frame(height: dynamicHeight) .allowsHitTesting(false) .offset(y: geometry.safeAreaInsets.bottom) } } } } } } extension View { func deliquify() -> some View { self.modifier(TabBarFadeModifier()) } } ``` **Solution 2: Strategic Tinting** ```swift .glassEffect(.regular.tint(.purple.opacity(0.8))) ``` **Solution 3: Background Dimming** ```swift ZStack { BackgroundImage() .overlay(Color.black.opacity(0.3)) // Subtle dimming GlassControls() .glassEffect(.clear) } ``` ### 4.2 Glass Layering Guidelines **Avoid Glass-on-Glass** ```swift // ❌ BAD - Confusing visual hierarchy VStack { HeaderView().glassEffect() ContentView().glassEffect() FooterView().glassEffect() } // ✅ GOOD - Clear separation ZStack { ContentView() // No glass HeaderView().glassEffect() // Single floating layer } ``` **Proper Layering Philosophy:** 1. **Content layer** (bottom) - No glass 2. **Navigation layer** (middle) - Liquid Glass 3. **Overlay layer** (top) - Vibrancy and fills on glass ### 4.3 Platform Differences | Platform | Adaptations | |----------|-------------| | **iOS** | Floating tab bars, bottom search placement | | **iPadOS** | Floating sidebars, ambient reflection, larger shadows | | **macOS** | Concentric window corners, adaptive search bars, taller controls | | **watchOS** | Location-aware widgets, fluid navigation | | **tvOS** | Focused glass effects, directional highlights | **Minimum Requirements:** - iOS 26.0+, iPadOS 26.0+, macOS Tahoe (26.0)+, watchOS 26.0+, tvOS 26.0+, visionOS 26.0+ - Xcode 26.0+ **Device Support:** - iOS 26: iPhone 11 or iPhone SE (2nd gen) or later - Older devices: Receive frosted glass fallback with reduced effects ### 4.4 Backward Compatibility **Automatic Adoption** Simply recompile with Xcode 26 - no code changes required for basic adoption. **Temporary Opt-Out** (expires iOS 27) ```xml UIDesignRequiresCompatibility ``` **Custom Compatibility Extension** ```swift extension View { @ViewBuilder func glassedEffect( in shape: some Shape = Capsule(), interactive: Bool = false ) -> some View { if #available(iOS 26.0, *) { let glass = interactive ? Glass.regular.interactive() : .regular self.glassEffect(glass, in: shape) } else { self .background( shape .fill(.ultraThinMaterial) .overlay( LinearGradient( colors: [.white.opacity(0.3), .clear], startPoint: .topLeading, endPoint: .bottomTrailing ) ) .overlay(shape.stroke(.white.opacity(0.2), lineWidth: 1)) ) } } } ``` ### 4.5 UIKit Integration **UIGlassEffect** ```swift import UIKit let glassEffect = UIGlassEffect( glass: .regular, isInteractive: true ) let effectView = UIVisualEffectView(effect: glassEffect) effectView.frame = CGRect(x: 0, y: 0, width: 200, height: 50) view.addSubview(effectView) ``` ### 4.6 Known Issues & Workarounds **Issue 1: Interactive Shape Mismatch** **Problem**: `.glassEffect(.regular.interactive(), in: RoundedRectangle())` responds with Capsule shape **Workaround**: Use `.buttonStyle(.glass)` for buttons instead **Issue 2: glassProminent Circle Artifacts** **Workaround**: ```swift Button("Action") { } .buttonStyle(.glassProminent) .buttonBorderShape(.circle) .clipShape(Circle()) // Fixes artifacts ``` ### 4.7 Performance Implications **Battery Impact** - iOS 26: 13% battery drain vs. 1% in iOS 18 (iPhone 16 Pro Max testing) - Increased heat generation - Higher CPU/GPU load on older devices **Optimization Strategies:** 1. Use `GlassEffectContainer` for multiple elements 2. Limit continuous animations 3. Let glass rest in steady states 4. Test on 3-year-old devices 5. Profile with Instruments --- ## Part 5: Best Practices & Design Patterns ### 5.1 When to Use Glass vs Traditional UI **Use Liquid Glass for:** - Navigation bars and toolbars - Tab bars and bottom accessories - Floating action buttons - Sheets, popovers, and menus - Context-sensitive controls - System-level alerts **Avoid Liquid Glass for:** - Content layer (lists, tables, media) - Full-screen backgrounds - Scrollable content - Stacked glass layers - Every UI element **Apple's Guidance**: "Liquid Glass is best reserved for the navigation layer that floats above the content of your app." ### 5.2 Design Principles **Hierarchy** - Content = Primary - Glass controls = Secondary functional layer - Overlay fills/vibrancy = Tertiary **Contrast Management** - Maintain 4.5:1 minimum contrast ratio - Test legibility across backgrounds - Use vibrant text on glass - Add subtle borders for definition **Tinting Philosophy** - Use selectively for primary actions - Avoid tinting everything - Tint conveys meaning, not decoration ### 5.3 Anti-Patterns **Visual Anti-Patterns:** 1. Overuse - glass everywhere 2. Glass-on-glass stacking 3. Content layer glass 4. Tinting everything 5. Breaking concentricity **Technical Anti-Patterns:** 1. Custom opacity bypassing accessibility 2. Ignoring safe areas 3. Hard-coded color schemes 4. Mixing Regular and Clear variants 5. Multiple separate glass effects without container **Usability Anti-Patterns:** 1. Busy backgrounds without dimming 2. Insufficient contrast 3. Excessive animations 4. Breaking iOS conventions 5. Prioritizing aesthetics over usability --- ## Part 6: API Quick Reference ### Core Modifiers ```swift // Basic glass effect .glassEffect() -> some View .glassEffect(_ glass: Glass, in shape: some Shape, isEnabled: Bool) -> some View // Glass effect ID for morphing .glassEffectID(_ id: ID, in namespace: Namespace.ID) -> some View // Glass effect union .glassEffectUnion(id: ID, namespace: Namespace.ID) -> some View // Glass effect transition .glassEffectTransition(_ transition: GlassEffectTransition, isEnabled: Bool) -> some View ``` ### Glass Types ```swift Glass.regular // Default adaptive variant Glass.clear // High transparency variant Glass.identity // No effect // Modifiers .tint(_ color: Color) // Add color tint .interactive() // Enable interactive behaviors (iOS only) ``` ### Button Styles ```swift .buttonStyle(.glass) // Translucent glass button .buttonStyle(.glassProminent) // Opaque prominent button ``` ### Container ```swift GlassEffectContainer { // Content with .glassEffect() views } GlassEffectContainer(spacing: CGFloat) { // Content with controlled morphing distance } ``` ### Toolbar & Navigation ```swift .toolbar { } // Automatic glass styling ToolbarSpacer(.fixed, spacing: CGFloat) ToolbarSpacer(.flexible) .badge(Int) // Badge count .sharedBackgroundVisibility(.hidden) // Hide glass background ``` ### TabView ```swift .tabBarMinimizeBehavior(.onScrollDown) .tabBarMinimizeBehavior(.automatic) .tabBarMinimizeBehavior(.never) .tabViewBottomAccessory { } ``` --- ## Resources ### Official Apple Documentation **WWDC 2025 Sessions:** - Session 219: Meet Liquid Glass - Session 323: Build a SwiftUI app with the new design - Session 356: Get to know the new design system **Sample Code:** - Landmarks: Building an app with Liquid Glass - Refining toolbar glass effects ### Community Resources **GitHub Repositories:** - mertozseven/LiquidGlassSwiftUI - GonzaloFuentes28/LiquidGlassCheatsheet - GetStream/awesome-liquid-glass - artemnovichkov/iOS-26-by-Examples **Developer Blogs:** - Donny Wals: "Designing custom UI with Liquid Glass on iOS 26" - Swift with Majid: Glassifying custom views series - Create with Swift: Design principles guide --- **Key Takeaways:** 1. Reserve Liquid Glass for navigation layer only 2. Always use GlassEffectContainer for multiple glass elements 3. Test extensively with accessibility settings enabled 4. Monitor performance on older devices 5. Respect user preferences and system settings 6. Prioritize content legibility over visual effects 7. Use morphing transitions for smooth state changes 8. Follow Apple's design guidelines and HIG ---