Category: iOS

  • Claude Code Memory: Context, CLAUDE.md, and When to Clear

    Claude Code Memory: Context, CLAUDE.md, and When to Clear

    Part 3 of a series on what I learned shipping BaseballScorer. Part 1 was the arc; Part 2 was the release workflow and the skills that mechanize it. This one is about memory — and more broadly, about the question every Claude Code user ends up arguing about: what goes in the context window, what goes in CLAUDE.md, what goes in persistent memory, and when to clear the whole thing and start fresh.


    In late June, Apple’s upload API lied to me. I ran my TestFlight release lane, the build uploaded, and then fastlane reported a failure — a 500 “internal server error” from something called ASSET_SPI. The natural move is to retry the upload. I did, through Apple’s Transporter app, and Apple rejected the retry as a duplicate: the build was already there. The 500 hadn’t been the upload failing. It was Apple’s status check failing after the upload had already succeeded. The error was, not to put too fine a point on it, a lie — and figuring that out cost me a chunk of an evening.

    Here’s the part that matters for this post. Two weeks and three releases later, that lesson was still operative. Every subsequent release, the assistant flagged it unprompted: if the lane reports an ASSET_SPI 500, don’t re-upload — verify whether the build actually landed first. I never re-explained it. I never re-derived it. A war story from June was still standing guard in July, across dozens of fresh sessions, each of which started — as every Claude session starts — knowing absolutely nothing about me or my project.

    That’s what a memory system buys you. But “use memory” is not actually the interesting advice, because every AI-assisted developer I talk to is wrestling with a more tangled set of questions: How often should I clear my context? Does compaction make the model dumber? Should CLAUDE.md be lean or loaded? What’s the actual difference between telling Claude something in the prompt, putting it in CLAUDE.md, and saving it to memory? People have strong opinions about all of these, usually derived from one bad experience and generalized into doctrine.

    So this post is my attempt at a working mental model — the one that’s held up across three-plus months and five TestFlight-and-App-Store releases of BaseballScorer. It’s not doctrine. But it’s been load-tested.

    The memory hierarchy

    If you’ve been an engineer for more than about a week, you already know this shape: registers, cache, RAM, disk. Small-fast-expensive at the top, big-slow-cheap at the bottom, and the whole game is putting each piece of data in the right tier.

    Working with Claude Code has exactly this structure. Four tiers:

    Tier 1: The conversation context. This is working memory — everything said and done in the current session, including the contents of every file Claude has read. It’s the most powerful tier, because everything in it directly shapes what the model does next. It’s also finite, expensive, and it decays (more on compaction in a minute). Crucially, influence cuts both ways: stale or wrong material in context doesn’t sit there neutrally. It competes with the truth.

    Tier 2: CLAUDE.md. Standing orders. This file is loaded at the start of every session, which makes it the most expensive durable real estate you own — every line you put here is a line Claude reads before every single task, forever. It’s also checked into the repo, which turns out to matter more than it first appears.

    Tier 3: Persistent memory. The judgment journal. In my setup this is a directory of small markdown files plus an index — the index is loaded every session (like CLAUDE.md, but for accumulated lessons rather than standing orders), and the detail files are pulled in only when relevant. This is where the ASSET_SPI story lives.

    Tier 4: The repo itself. Ground truth. The code, the docs/ directory, the git history, the test suite. Effectively unlimited, fully durable, shared with every collaborator — and, critically, verifiable. Claude can read it fresh anytime and trust what it finds, because it’s not a note about the code; it is the code.

    And the rule that organizes everything — if you take one sentence from this post, take this one:

    Push every fact down to the cheapest tier that preserves it, and treat the conversation as disposable.

    If losing your context window right now would hurt, something important is living in the wrong tier. The conversation is where work happens, not where knowledge lives. The moment something in a session turns out to be durably true — a gotcha, a decision, a preference — it should flow downward: into memory, into CLAUDE.md, into a doc in the repo, wherever it belongs. What remains in the conversation should be only the work in progress.

    Baseball version, since this is nominally a baseball-app series: the conversation is what’s in the scorer’s head during the play. CLAUDE.md is the ground-rules card taped inside the scorebook. Memory is the scorebook itself. The repo is the rulebook and the league’s official records. Nobody tries to keep the whole season in their head, and nobody should have to reread the rulebook to remember that the ballpark has a short porch in right.

    With the model in place, let’s take the contested questions one at a time.

    When should you clear the context?

    Liberally, and specifically: between unrelated tasks.

    The instinct to preserve a long-running conversation comes from a reasonable place — the model feels smarter mid-session, because it has all that context. And it genuinely is, while the context is relevant. The problem is what happens when you pivot. Finish a gnarly print-layout investigation, then start a networking feature in the same session, and all that layout reasoning is still sitting in working memory. It isn’t neutral filler. It’s noise with authority — hundreds of lines of intermediate hypotheses, half of which were wrong (that’s what investigation is), all still whispering to the model while it tries to think about something else.

    Old context doesn’t just waste space. Wrong-but-confident material in context is precisely the raw ingredient hallucinations are made of. The model has no typographic marker distinguishing “conclusion we verified” from “hypothesis we abandoned twenty minutes ago”; both are just tokens it once said.

    The discipline that makes clearing cheap is the push-down rule. When the print investigation concluded, the conclusion — “the scorecard print is height-bound, not width-bound; future size complaints should target row heights, not column widths” — went into memory. Two sentences. The eight hundred lines of measurement and dead ends that produced those two sentences got thrown away with the session, unmourned. Next time print size comes up, the two sentences come back and the dead ends don’t. That’s not lost information; that’s distilled information.

    If clearing your context feels scary, that fear is diagnostic. It means knowledge is trapped in tier 1 that belongs in tier 3 or 4. Fix the filing, and the fear goes away.

    Does compaction hurt accuracy?

    Some. Here’s the mechanism, because knowing why tells you what to do about it.

    When a session runs long, the harness compacts it: older conversation gets replaced by a summary. Summarization is lossy in a very particular way — it preserves narrative and drops precision. “We fixed the auto-advance bug and merged to main” survives compaction beautifully. The exact tag name, the specific line number, the precise flag that carried the fix — those are exactly the details a summary rounds off.

    So the practical rule: after a compaction, trust the story, re-verify the specifics. If post-compaction work depends on an exact value — a version number, a build setting, a function signature — the move is to look it up fresh from the repo (tier 4), not to trust the summary’s recollection of it. Ground truth is one file-read away, and unlike the summary, it can’t have rounded anything off.

    I can offer this series itself as evidence. These posts have been written across many sessions with the same assistant, through multiple compactions, spanning weeks of feature work in between. The continuity you’re reading — callbacks to Part 2’s war stories, the running motifs — survived not because the context window is heroic but because everything load-bearing lives in files: the draft posts themselves, a memory note tracking the series plan, the repo’s docs. The conversations were disposable, so losing detail from them cost nothing. The hierarchy is what makes compaction survivable, the same way it makes clearing safe. They’re the same insurance policy.

    How much belongs in CLAUDE.md?

    Less than you’re putting there, probably — but the reason matters more than the rule.

    Every line of CLAUDE.md is read at the start of every session, before every task, for the life of the project. That’s its superpower and its cost. The budget question for any candidate line is: does this change Claude’s behavior often enough to justify being read every single time?

    Things that clear that bar, from my actual file: the exact build and test commands (with the environment-variable gotcha that makes them work on my machine); the instruction to read the workflow doc before non-trivial work; the warning to never edit the Xcode project file directly while Xcode is open, because that way lies corruption; a note that a particular category of tooling is unreliable for builds so use the command line instead. Every one of those redirects behavior on a large fraction of tasks. They’ve each paid their rent many times over.

    Things that don’t clear the bar: architecture narratives, feature history, aspirational coding standards nobody consults, and anything that reads like documentation. The tell is exactly that — if a section reads like documentation, it is documentation, and it belongs in docs/ with a pointer. My CLAUDE.md doesn’t contain my branching and release policy; it contains one line saying “read docs/workflow.md before starting non-trivial work.” The policy lives in tier 4, where it’s versioned, diffable, and readable by humans too. CLAUDE.md just makes sure Claude knows the pointer exists.

    So in the great “edit down vs. fill up” debate: edit down, but not out of minimalist aesthetics — out of budget discipline. It’s the most expensive real estate you own. Spend it on behavior, link to everything else.

    Memory vs. CLAUDE.md vs. the prompt

    This one has the cleanest answer of the bunch, and it comes down to scope and authorship.

    CLAUDE.md is checked into the repo. That makes it true-for-anyone: any collaborator, any future contributor, any other agent that clones the project gets the same standing orders. It describes how to work in this codebase. It’s also curated deliberately — you edit it the way you edit code, on purpose, in commits.

    Memory is specific to a collaboration. Mine holds things that would be presumptuous or meaningless in a checked-in file: my preferences (I want a high bar for what earns a point release; I’m skeptical of elaborate persona prompts), corrections I’ve issued and why, the current state of in-flight work (“build 31 is on TestFlight awaiting tester assignment”), lessons that encode judgment rather than procedure. It accrues conversationally — “remember this” mid-session — rather than being edited like a source file. If CLAUDE.md is the ground-rules card, memory is the relationship.

    The prompt is for this task only. Anything you find yourself typing into prompts repeatedly is a filing error — it’s a durable fact living in the most ephemeral tier, at the cost of your typing it forever. Promote it: repo-truths into CLAUDE.md, collaboration-truths into memory.

    The taxonomy of what earns a memory slot, from three months of practice — three categories carry nearly all the value:

    1. Corrections, saved with the why. Not “don’t edit project.pbxproj directly” but “don’t edit it while Xcode is open, because external edits can corrupt Xcode’s in-memory state — ask me to make the change in the Xcode UI instead.” The why is what lets the lesson generalize instead of becoming a cargo-cult rule.
    2. Validated approaches. When something works and we confirm it worked, that’s as valuable as a correction. The best example from this project: Siri integration silently failed with one Apple API pattern and worked with another (Part 2 readers will remember the AppEnum saga). The memory doesn’t just say which one won; it says what the failure looked like, so the next occurrence gets recognized in minutes instead of hours.
    3. Project state that isn’t in the code. What shipped in which build, what’s awaiting whose decision, what the tester feedback said. Git knows what changed; it doesn’t know what we’re waiting on.

    And one anti-category: never save what the repo already records. A memory that duplicates the code is a stale copy waiting to mislead. If Claude can look it up in tier 4, it should — which brings us to the sharpest knife in the drawer.

    Memory is not live state

    Here’s the discipline that separates a memory system that compounds from one that slowly poisons you: a memory is a point-in-time observation, not a fact about the present.

    Code moves. Files get renamed, functions get refactored, flags get removed. A memory that says “the fix is the flag on line 600 of such-and-such service” was true the day it was written and gets falser every week. The rule we run: when a memory names a file, a function, a line, a setting — verify it against the current repo before acting on it. The memory’s job is to point; the repo’s job is to be true.

    This is the same principle as the compaction rule, and it’s worth saying why: stale information is worse than no information, because it arrives wearing the costume of authority. A model with no memory of your build system will go read the config and get it right. A model with a confident eight-week-old memory of your build system may not think to check. The failure mode of memory isn’t forgetting — it’s remembering wrong, fluently.

    Two small hygiene habits fall out of this. First, absolute dates: a memory that says “last Tuesday” is gibberish in a month, so relative time gets converted to real dates at save time. Second, aggressive pruning: when a memory turns out to be wrong or obsolete, it gets deleted, not annotated. Memory is a working set, not an archive — the archive is git.

    The payoff: judgment that compounds

    Part 2 argued that skills turn workflows into things that happen the same way every time. Memory does the same thing one level up: it makes judgment repeatable. Every war story costs you once and then pays dividends forever — but only if the distillation is good. Three examples from just the past two weeks of BaseballScorer work, because recency is the point:

    The ASSET_SPI lie you already know. One bad evening in June; every release since has carried the antidote in its pocket.

    The print investigation ended with a two-sentence memory — “height-bound, not width-bound; target row heights” — that converts every future “can the print be bigger?” request from an afternoon of measurement into a thirty-second answer.

    Best of all, the batting-around bug. A live game exposed a display bug: when a team bats around, a player can reach base twice in one inning, and any code that matched events to players without also checking sequence mixed the two trips together. We fixed the two places it bit us. But the memory doesn’t record the fix — the commit records the fix. The memory records the pattern: any player-keyed scan over inning events breaks under batting-around unless it’s sequence-bounded. That’s a lesson about a whole class of latent bugs, some of which probably exist in code we haven’t stressed yet. When one surfaces next April, the diagnosis is pre-loaded.

    That’s the compounding: fixes accumulate in the repo, but pattern recognition accumulates in memory. One is what happened; the other is what to watch for.

    Tutorial mode, briefly

    The prescriptions, in the spirit of the previous posts:

    • Treat the conversation as disposable, and act accordingly. Distill conclusions downward the moment they’re conclusions. Then clear without fear, especially between unrelated tasks.
    • After compaction, trust the narrative and re-verify the numbers. Exact values should come from the repo, not from a summary’s memory of them.
    • Budget CLAUDE.md like the expensive real estate it is. Behavior-changing lines only; anything that reads like documentation moves to docs/ and leaves a pointer.
    • Scope decides the tier. True for anyone in the repo → CLAUDE.md. True for this collaboration → memory. True for this task → the prompt. Typing it repeatedly → you’ve filed it wrong.
    • Save the why with every correction, and save validations, not just failures. Both halves of the feedback signal matter.
    • Verify memories before acting on them. Point-in-time observations, not live state. Stale-but-confident is the failure mode.
    • Prune as aggressively as you save. Wrong memories don’t age into harmlessness; they age into ambushes.

    What’s next

    The final post in this series is the one I most wish had existed when I started: moving from standalone Claude Code in a terminal to the Xcode-integrated version — what’s different, what’s missing, what to do instead. If this post was about where knowledge should live, that one is about where the assistant lives, and it turns out the answer changes more than you’d expect.

    That’s where we’ll leave things for today.


    Part of an ongoing series at Nodes and Edges. The app is on the App Store, and the companion scoring guide lives at scoring.theyawns.com.

  • Claude Code Custom Skills & fastlane for iOS Releases

    Claude Code Custom Skills & fastlane for iOS Releases

    Part 2 of a 4-post series on what I learned shipping BaseballScorer. Part 1 was the arc — first commit to App Store in eighteen days. This one is the machinery underneath: the release workflow, and the handful of custom Claude Code skills I actually use.


    Here’s a confession to start with, because it sets up everything else in this post: on my pre-retirement Java projects, I had eight specialized Claude agents. I had config-manager and debugging-helper and documentation-writer and framework-developer and performance-optimizer and pipeline-specialist and service-developer and test-writer. Each had its own persona prompt. Each was going to be the expert in its lane. I built a little org chart of robots and felt very clever about it.

    In hindsight: overkill. Almost all of it.

    On BaseballScorer I have five skills — bug-fix, release, commit, testflight-upload, and security-review — and I’d argue four of them earn their keep and one is borderline. That’s the whole roster. No personas. No “you are a senior iOS architect with twenty years of experience” preamble. The main agent is already a senior iOS architect with twenty years of experience, or near enough; telling it to pretend to be one is theater.

    So if you came here for “here are the twelve agents you need to ship an app,” I’m going to disappoint you on purpose. The thesis of this post is that the highest-leverage Claude Code artifacts on a real project aren’t clever — they’re boring. They encode the multi-step, error-prone, do-it-the-same-way-every-time workflows that you’d otherwise wing each Friday and get subtly wrong. A good skill isn’t a personality. It’s a checklist with teeth.

    Let me show you what I mean.

    What earns a skill

    Here’s the test I landed on, after the Java over-engineering taught me what not to do: a workflow earns a skill when it’s multi-step, painful to do by hand, and — this is the one people skip — dangerous to do inconsistently.

    That third criterion is where the value actually lives. A one-step task doesn’t need a skill; you just ask. A multi-step task you do once a year doesn’t need a skill; you look it up. But a multi-step task where doing the steps in the wrong order, or skipping one, quietly corrupts something — that’s where you want the steps welded together so neither you nor Claude can freelance them at 11pm.

    Releasing a build is the canonical example. So let’s start there.

    fastlane: one place that talks to Apple, and only one

    Quick detour for anyone who hasn’t met it — and if you’re new to iOS, you probably haven’t: fastlane is an open-source toolkit that automates the tedious parts of shipping an app. Building the archive, signing it, uploading to TestFlight, pushing screenshots and the App Store description, submitting for review — all the steps you’d otherwise do by hand-clicking through Xcode and the App Store Connect website. You write down what you want once, in a file called a Fastfile, as a named recipe (fastlane calls these “lanes”), and then fastlane ios beta runs the whole recipe the same way every time. Think of it as the difference between following a checklist taped to the wall and pressing a single button that does the checklist for you. Until I started this project I didn’t know it existed either; now I’d no sooner ship without it than score a game without a pencil.

    With that out of the way: the single most important rule in my release process is this: exactly one thing is allowed to talk to App Store Connect, and that thing is fastlane, driven from a config file in my repo. I do not log into the App Store Connect website and edit the description. I do not tweak the “What’s New” text in the browser because it’s faster. Everything goes through docs/app-store-metadata.md → fastlane → Apple.

    I learned this the way you learn most worthwhile rules — by getting burned. Early on, before fastlane owned the metadata, I added a line to my App Store description in the web UI: “no ads, no paywall.” Felt good. Forgot about it. A few weeks later a routine fastlane push regenerated the listing from a doc in my repo — a doc that didn’t have that line — and silently overwrote my edit. No warning, no diff, no “are you sure.” The web edit and the repo doc were two sources of truth, and when two sources of truth disagree, one of them loses, usually the one you forgot you had.

    The fix isn’t “remember not to edit the website.” The fix is to make the repo the only source of truth and let the automation be the only writer. Now if I want to change the description, I change the markdown, and fastlane is the courier. There’s exactly one path, so there’s nothing to get out of sync with.

    This is a theme, so I’ll name it now and you’ll see it three more times before we’re done: when something bites you because two things can both do the job, the fix is usually to make sure only one thing can.

    The beta lane, and the lesson hiding in its control flow

    The skill I lean on most is testflight-upload, which runs my fastlane beta lane. On the surface it’s mundane — it bumps the build number, archives, uploads to TestFlight, and tags the release in git. But there’s a design decision baked into the order of those steps that I want to pull out, because it’s the kind of thing that’s invisible when it works and infuriating when it’s done the other way.

    My workflow doc has a rule: tag after the upload succeeds, never before. A failed upload should not burn a version tag. That’s easy to say in a doc and easy to violate in practice — you tag, then upload, then the upload dies, and now you’ve got a tag v1.4-b28 pointing at a build that never made it to Apple. Next time you’ll either reuse the tag (don’t) or skip it (now your tags lie about what shipped).

    The trick is that in the beta lane, that rule isn’t a comment reminding me to be careful. It’s control flow. The archive and upload_to_testflight calls come first; the commit_version_bump, add_git_tag, and push_git_tags calls come after. If the upload throws, the lane halts — and execution never reaches the tagging code. You cannot burn a tag on a failed upload because the code that creates the tag is downstream of the code that can fail. The “be careful” rule got promoted from a human responsibility to a structural guarantee.

    That’s the move I keep coming back to with skills. Anywhere you find yourself writing “remember to X,” ask whether you can instead arrange things so that not doing X is impossible. A reminder is a liability you carry forever. A structural guarantee you build once.

    The lane has a couple of other guards in the same spirit. Before it does anything, it checks that you’re on main with a clean working tree (ensure_git_branch, ensure_git_status_clean) — because releasing from a feature branch with uncommitted experiments is a great way to ship something you didn’t mean to. And it auto-generates the TestFlight changelog from git commit messages since the last v* tag, excluding merge commits. That last bit is small but it means my changelog can’t drift from my actual history, because it is my actual history. One source of truth again. You’ll keep seeing it.

    The locale crash, or: how an em-dash took down my release

    Now for a war story, because abstract principles are easy to nod at and forget.

    The first time I ran the beta lane on this machine, it crashed. Not with a useful error — with this:

    [!] invalid byte sequence in US-ASCII (ArgumentError)
    

    followed, a few lines later, by fastlane helpfully informing me that it “requires your locale to be set to UTF-8.” The proximate cause: macOS shells default to a US-ASCII locale, and fastlane’s build step parses xcodebuild‘s output as it streams by. The first non-ASCII byte in that stream — and there’s always one eventually — and the parser falls over.

    And here’s the part that’s almost too on the nose: the non-ASCII byte that took down my release was, as often as not, an em-dash. In my own App Store metadata. Which I write full of em-dashes, because — well, you’ve read this far, you’ve noticed. My prose style was crashing my deployment pipeline. There’s a metaphor in there about the cost of having a voice, but I’ll leave it alone.

    The first fix was the obvious one: set the locale on the command line every time.

    LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 /opt/homebrew/bin/fastlane ios beta
    

    That works. But look at what it is — it’s a “remember to X.” Every release, forever, I’d have to remember to prefix the command with the magic words, or watch it die on the first smart quote. That’s exactly the kind of carried liability I just spent a section telling you to eliminate.

    So the real fix went into the top of the Fastfile itself:

    ENV["LANG"] = "en_US.UTF-8" unless ENV["LANG"]&.include?("UTF-8")
    ENV["LC_ALL"] = "en_US.UTF-8" unless ENV["LC_ALL"]&.include?("UTF-8")
    

    Now the trap is disarmed permanently. The lane sets its own locale before it does anything else, so it doesn’t matter what shell I run it from or whether I remembered the incantation. The gotcha can’t recur because the tool defends itself. Same pattern as the tag-on-success thing: take a rule that lived in my head and move it into a place where it’s enforced by code.

    If there’s one transferable habit from this whole post, it’s that one. When you hit an environment gotcha, the fix is not to remember it. The fix is to make it impossible to hit again, in the most permanent place you can put the fix.

    The bug-fix skill: branch off the buggy tag

    The other skill that genuinely changed how I work is bug-fix, and it’s worth explaining because it encodes a habit that I’m told is less common than I’d assumed from my pre-Claude career.

    When a bug ships in, say, build v1.3-b26, the fix does not start from current main. It starts from the taggit checkout -b bugfix/short-name v1.3-b26. You branch from the code that actually shipped the bug.

    Why bother? Two reasons, both about honesty. First, the skill makes you write a failing reproducer test before the fix — a test named test_bugfix_<shortDescription> that demonstrates the bug. And a reproducer test is only trustworthy if it reproduces the bug on the code that shipped it. If you write your test against current main, where the symptom may have already shifted or been accidentally masked by other changes, you might write a test that passes for the wrong reason and convince yourself you’ve fixed something you haven’t. Branching from the tag guarantees the test fails for the real reason before it passes for the real reason.

    Second, it gives you a clean merge path forward. The fix and its test travel together from the tag up to main, and the reproducer stays in the suite forever as a tripwire against regressions. I’ve got a couple of recent ones from the 1.4 cycle — an error that was getting credited to the wrong team in the box score, and a runner-advancement display that rendered a hanging “advanced to ” with no destination — and in both cases the value wasn’t just the fix. It was that the test which proves the fix is now a permanent member of a 365-test suite that runs before every release. The bug can come back, but it can’t come back quietly.

    The discipline of test-first matters even when — especially when — Claude is the one writing the test. It keeps both of us honest about whether we’re fixing the actual bug or just papering over the symptom that happened to be visible. It’s very easy to make a symptom disappear. It’s harder, and more valuable, to prove you understood it.

    When the automation breaks (and it will)

    I want to close the practical part with the least glamorous lesson, because it’s the one nobody puts in their “ship with Claude!” thread: every piece of automation needs a documented recovery procedure, and that procedure belongs right next to the automation, written while you’re calm.

    Two examples from this project, both real, both having cost me an evening.

    The beta lane bumps the build number across every target — app, tests, screenshots — before it archives. If it crashes mid-lane (say, on a locale issue before I’d pinned the fix), it’s already dirtied the project file, and the next run’s clean-tree guard refuses to proceed. The first time this happened I flailed. Now there’s a known dance: revert the app target’s build number in Xcode’s UI (not by hand-editing the project file while Xcode’s open — that way lies corruption), commit the leftover diff with a “cleanup from failed run” note, and re-run. The lane re-bumps everything to the next number. Skipping a build number is fine, by the way — Apple only requires that build numbers go up, not that they’re contiguous. That fact alone would’ve saved me twenty minutes of panic if I’d known it.

    The second one is sneakier and I love it as a cautionary tale. On one upload, fastlane reported a flat-out failure: an ASSET_SPI 500, “internal server error,” during the post-upload status check. So I did the natural thing and retried the upload through Transporter — which Apple promptly rejected, because the build was already there. The 500 wasn’t the upload failing. It was Apple’s status-check endpoint failing after the upload had already succeeded. The error message was, not to put too fine a point on it, a lie. The only reason I figured it out is that the duplicate-rejection error (bundle version already used) told the truth that the 500 had obscured.

    The lesson there isn’t about fastlane specifically. It’s: don’t trust an error message about a remote system’s state — verify the actual state. Apple told me the upload failed. Apple was wrong. The build was sitting in App Store Connect the whole time. When a distributed system reports a failure, it’s reporting that one call failed, which is not the same as the operation having failed, and the gap between those two things is where you lose evenings if you take the error at face value.

    (If that distinction sounds familiar, it’s the same reason “the network is unreliable” is the first hard lesson in distributed systems. A failed acknowledgment doesn’t tell you the work didn’t happen. It tells you that you didn’t hear that it happened. Apple’s 500 was a lost ack, nothing more.)

    All of this — the recovery dances, the “skip a build number, it’s fine,” the “the 500 is a liar” — lives in a doc in my repo and a couple of memory notes Claude carries between sessions. Which is the natural segue to where we’re headed next.

    The actual point

    Strip away the war stories and here’s what the five skills and the one config file have in common: none of them make Claude smarter. The model was already plenty smart. What they do is make the process repeatable and the hard-won lessons durable. The locale fix, the tag-on-success ordering, the branch-off-the-tag habit, the single source of truth for metadata — every one of those is a place where a mistake I made once got promoted into something I can’t easily make again.

    That’s the unsexy truth about being productive with an AI coding assistant on a real, shipping project. The leverage isn’t in elaborate prompts or a cast of specialized agents with backstories. It’s in noticing which boring workflows are error-prone, encoding them so they happen the same way every time, and turning each war story into a guardrail before you have to fight the same war twice. A skill is just where a hard-won lesson goes to become a habit.

    Which raises an obvious question: how does any of that survive across months of work, when each Claude session starts fresh and remembers nothing? How does the lesson from June still be there in September? That’s the persistent-memory system, and it’s the subject of the next post — the same idea as this one, lifted up one level, from “make this workflow repeatable” to “make this project’s accumulated judgment repeatable.” That’s where we’ll leave things for today.


    Part of an ongoing series at Nodes and Edges. If you’re curious about the app itself, it’s on the App Store, and the companion scoring guide lives at scoring.theyawns.com.

  • Claude Code for iOS: Shipping a Real App in 18 Days

    Claude Code for iOS: Shipping a Real App in 18 Days

    Part 1 of a 4-post series on what I learned shipping BaseballScorer — from first commit to a usable App Store release in under three weeks, plus everything that’s come after.


    I have files on my laptop dated January 7, 2009. They’re the start of an iOS baseball scoring app, written in Objective-C, abandoned partway through the lineup management screens after several other apps beat me to the App Store. I shipped 1.0 of BaseballScorer on April 15, 2026 — about seventeen years and three months later. The gap between those dates isn’t a story about Swift vs. Objective-C. It’s a story about productivity floors.

    The 2009 version stalled because building a real iOS app — even one whose design I’d been sketching since the Apple Newton — was a part-time hobbyist’s nightmare. The 2026 version shipped because Claude Code took “build the version of this app I actually want, even though the market is crowded with perfectly good alternatives” from a fantasy into a practical project. The first commit landed on March 28, 2026 — the regular season was about to start. The 1.0 release went live on the App Store eighteen days later, and 1.0 wasn’t a hollow milestone-for-the-sake-of-shipping. It was a genuinely usable scoring app — one you could take to a ballgame and actually score a game with. The two months between 1.0 and 1.3 have been a steady cadence of upgrades, increasingly guided by feedback from real users — both App Store downloaders and folks on the public TestFlight link — rather than by my own backlog. There’s still plenty more in the pipeline.

    This is the first of four posts where I try to be honest about what that looked like. Not “Claude wrote my app for me” — that’s not what happened — but a frank account of what I brought, what Claude brought, what went well, what I regret, and what I’d do differently. The next three posts will go deep on (2) the release workflow and the custom Claude Code skills I actually use, (3) the persistent memory system that lets a single Claude conversation feel coherent across months, and (4) the specific differences between running standalone Claude Code in a terminal and the Xcode-integrated version — which is the post I most wish I’d had when I started. This one is the arc.

    Why ship into a crowded market?

    If you search “baseball scoring app” in the App Store right now you’ll find plenty of decent options. I know, because I checked, repeatedly, every time I asked myself whether this was a sensible use of my time. The honest answer is: no, not by any normal definition of “sensible.” I’m not going to dethrone anyone. Most baseball-scoring app users are loyal to whatever they learned first, and they should be — the existing options work fine.

    The reason I built it anyway is the same reason you might build your own task tracker even though Todoist exists. I had a specific mental model of how scoring an iPad baseball game should feel, and none of the existing apps matched it. Some were too “this is a database, please fill it in.” Others tried to be too clever about inferring plays and left me fighting them when I wanted to record something unusual. My design philosophy — which I’ll come back to in a minute — is “the app trusts you.” Everything is optional. Nothing blocks you from moving forward. You can be sloppy and still end up with a usable scorecard, because in the bleachers, sometimes you have to be sloppy.

    The other “why now” factor: I’d recently transitioned mostly into retirement, but I was a computer nerd before anyone paid me to be one, so “stop doing tech because no one’s paying me” was never going to be the deal. A project I genuinely wanted to use was the right shape for that phase of life. Side projects without bosses tend to either die fast or finish well, and this one was going to do one of the two.

    Here’s the part that’s relevant to the Claude Code angle: that specificity is exactly the kind of thing that used to make “build it yourself” infeasible. Not because the design was hard — most of the design was twenty-plus years old, sitting in my head since the Newton days. It was infeasible because the cost of translating a clear design into working SwiftUI + SwiftData code, with reasonable test coverage and a clean release process, exceeded what I could spend on a side project. Claude Code dropped that cost enough that “build my own version of an app that already exists” went from “fun fantasy” to “actually happening on weekends.”

    If you have a personal-version-of-an-existing-app project that you’ve been sitting on, this is the part of the post where I tell you to just start it. You don’t need a market opportunity. You need a productivity floor low enough that doing it for yourself is a reasonable trade for your time.

    What came from where

    Almost every Claude Code post I’ve read leaves the credit question vague. Mine won’t. Here’s the honest division on BaseballScorer:

    From me:

    The first two bullets below trace back to a Newton-era bitmap mockup I made decades ago. The rest emerged during this project, mostly from the iPad form factor making certain choices obvious.

    • The basic layout — a line score across the top, and then the rest of the screen is the main scoring area with tap targets for the fielding sequence, inning summary down the left, previous at-bats across the top
    • The idea of tapping bases to drive baserunner actions
    • The “the app trusts you” philosophy — every field optional, an incomplete at-bat never blocks progress, casual scoring is the default. Getting distracted or interrupted and missing a play shouldn’t make it impossible to continue.
    • The decision to make portrait orientation the scoring view and landscape the scorecard grid (an iPad-driven call — the Newton mockup had no equivalent)
    • The K vs. Kc distinction (swinging strikeout vs. called/looking)
    • iPad-primary with iPhone as an adaptive secondary

    From Claude, almost entirely:

    • The color system for ball / strike / foul / hit-by-pitch. I’d envisioned the buttons monochrome with inapplicable ones dimmed. Claude proposed a color encoding and I liked it immediately. It’s now one of my favorite things about the app.
    • Flipping the button set between “pitch results” and “in-play outcomes” depending on the moment in the at-bat. My original design had every button visible all the time with the inapplicable ones grayed out. The flip is better. I didn’t see it.
    • Most of the SwiftUI idiom. My only prior iOS App Store release was a collectible-card-game companion app, written in Objective-C years ago — nothing to do with baseball, nothing to do with Swift. BaseballScorer is my first Swift project and my first SwiftUI project. Claude carried me through the language and framework. I had strong opinions about what the UI should do. Claude knew how to make SwiftUI actually do it.

    Heavily collaborative:

    • The data model. I had an event-sourcing mental model from a separate project, and Claude knew SwiftData’s quirks. We arrived at the current Game → Inning → AtBat → PlayEvent structure together. (We also made an architectural decision there I now regret — more on that below.)
    • The release workflow and the custom skills. I brought the discipline; Claude wrote most of the actual fastlane glue and the skill definitions.
    • The test discipline. 365 unit tests, zero failing, as of v1.3-b26. I insisted on the failing-reproducer-test-first habit for bug fixes; Claude wrote most of the tests.

    This is, I think, the actually-honest shape of a productive human/AI collaboration on a real codebase. It’s not “Claude built it.” It’s not “I built it with Claude as a fancy autocomplete.” It’s a real division of labor where one side brings vision and judgment and the other side brings language fluency and willingness to write the boring parts, and they meet in the middle on the interesting parts.

    The structural mistake (and the screenshot that proved it was real)

    In early April 2026, between TestFlight builds 6 and 7, a tester I’d never met sent me a screenshot via the public TestFlight link. He was trying to catch up to a live NYY-at-TB game using my MLB-feed catch-up path. The screenshot showed four distinct symptoms in one frame:

    1. Three outs filled in on the indicator, but the half-inning hadn’t ended and the active-batter card was still up
    2. The active batter card showed Goldschmidt (a Yankees player) while TB was supposed to be batting
    3. The runner-action prompt offered “Stay on 3rd” — but the diamond showed no runner on 3B
    4. The at-bat history rendered out of chronological order (1st → 5th → 3rd instead of 1st → 3rd → 5th)

    Each of those symptoms looked like a different bug. They were not. They were four faces of the same structural problem.

    A few months earlier I had written, mostly for my own future reference, a document called docs/architecture-retrospective.md — the kind of “what would I do differently” file you write after a long debugging session, more for catharsis than for action. It listed five “structural pain points” — places where the data model wasn’t wrong exactly, but was generating recurring bug classes rather than one-off bugs. The five pain points it called out:

    1. AtBat is doing too much (it’s a historical record and a container for events and a lookup point for rendering)
    2. Player identity in events is fragile (SwiftData persistent identifiers can be temporary until the next save — found this out the hard way)
    3. State has two implementations (live view-model state vs. reconstructed-from-history state) that drift
    4. Catch-up from MLB feed and manual scoring are parallel implementations that diverge subtly
    5. Substitution semantics (pinch hitters, pinch runners, defensive substitutions) are tangled across three different storage locations

    The retrospective predicted that these pain points would generate exactly the bug classes that the tester’s screenshot demonstrated. Reading the report, I could point at each symptom and say which structural pain it came from. That’s a useful diagnostic moment and a horrible feeling at the same time. The doc had explicitly listed “the same bug class keeps recurring” as a triggering criterion for pulling refactor work forward. The screenshot tripped it.

    I pulled three refactors that were scheduled for 1.1 and 1.2 into the 1.0 release, shipped them across builds 10–12, and the entire class of “catch-up shows impossible state” bugs disappeared as a side effect of the refactors rather than as a targeted patch.

    The lesson — and this is one of the few times I’m going to be tutorial-mode prescriptive in this post — is write the retrospective doc before you need it. Not as planning. Not as a refactor commitment. As a catch-basin for “this keeps biting me” intuitions, with explicit triggering criteria for when intuition becomes action. Mine sits in the repo at docs/architecture-retrospective.md. When the trigger fires, you don’t have to re-derive the analysis under pressure. You just open the doc and execute the plan you wrote when your head was clear.

    I would not have written that doc without Claude. Not because it required AI to write — it didn’t — but because the conversational format of working with Claude generates these documents as a natural side effect of bug-fix sessions. “Tell me what we’re actually fighting here” turns into a doc that I can keep, not a Slack thread that scrolls into oblivion.

    The honest regret: two paths that should have been one

    Here’s the architectural decision I’d take back if I could.

    BaseballScorer can score a game two ways. You can score it by hand, pitch by pitch — the original use case, the one I designed for. Or you can let the app pull from the MLB Stats API and “catch up” to a live game, populating the scorecard from the feed so you can join in mid-game without having to manually backfill the first three innings.

    These two paths share almost no code. Manual scoring goes through ScoringViewModel.recordResult / recordPitch / placeRunner and friends. Catch-up goes through MLBAutoFillService.populateFromFeed, which directly mutates the SwiftData models. By the time I noticed this was a problem, both paths had grown enough complexity that unifying them wasn’t a quick refactor.

    The cost shows up most clearly in runner advancement. On the manual path, the user has full control — they can move every runner exactly where they need to be. On the catch-up path, if the MLB feed doesn’t surface a runner movement (or we miss one during ingestion), it’s just gone, with no equivalent corrective UI. Two paths, two test surfaces, two places to fix every bug, and a class of “catch-up does X but manual does Y” inconsistencies that I’ve patched at least a dozen times.

    If I were starting over, I’d build a typed event log first, and force both paths to produce events that feed a single applier. Both the manual UI and the feed parser would emit the same runnerMovement events; one code path would consume them. The retrospective doc lays this out as a future refactor — possibly worth doing if 1.4’s “Live Game Assistance” theme makes the divergence painful enough — but it would have been trivial to design in on day one and is genuinely hard to refactor in now.

    The general lesson, if you want one: when you have two code paths that produce “the same kind of state” through different mechanisms, ask very hard whether they can share a layer. The answer is almost always yes, and almost always you’ll only see how to do it once you’ve already built both.

    A few things I would tell you to do

    Tutorial mode, briefly, because abstract advice gets nodded at and forgotten:

    • Keep custom skills minimal. On my prior Java projects I had eight specialized agents — config-manager, debugging-helper, documentation-writer, framework-developer, performance-optimizer, pipeline-specialist, service-developer, test-writer — each with its own persona prompt. In hindsight: overkill. On BaseballScorer I have five skills (bug-fix, release, commit, testflight-upload, security-review), each tied to a specific recurring multi-step workflow that’s actually painful to do by hand. That’s the right number. If you find yourself writing a skill for “the documentation persona,” that’s a sign your main agent is fine and you’re inventing problems.
    • Write the failing test first for bug fixes. Even when Claude is going to write the test for you. The discipline keeps you honest about whether you’re actually fixing the bug or just papering over a symptom. My bug-fix skill enforces this by convention — it won’t write a fix until there’s a test file with test_bugfix_<shortDescription> in it.
    • Branch off the buggy build’s tag, not main. When a bug ships in v1.2-b23, the fix branch starts from that tag, not from current main. This guarantees the reproducer test actually reproduces the bug in question, and gives you a clean cherry-pick path back to main once the fix is verified. I thought this was standard practice from my pre-Claude career; I’m told it’s less common than I assumed.
    • Make App Store metadata source-of-truth in your repo, not in App Store Connect. I learned this one the hard way. I added some marketing copy (“no ads, no paywall”) directly in App Store Connect and forgot about it. A subsequent fastlane push regenerated the metadata from a doc in my repo and overwrote my edits with no warning. Now docs/app-store-metadata.md is the only thing I touch, and fastlane is the only thing that talks to App Store Connect.
    • Write the retrospective doc before you need it. I already preached this one above. I’ll say it again because it’s the highest-ROI habit I’ve adopted on this project.

    What’s next

    If you’re a baseball scorer — or curious enough about scoring to want to learn — the app is on the App Store, and the companion scoring guide lives at scoring.theyawns.com. The guide is about 20,000 words of “here’s how baseball scoring actually works,” from “what is a 6-4-3?” to the Manager Challenge notation we added in 1.3. If you’re wondering whether to bother learning to score: the app makes it about as low-stakes as it can be, and the guide tries to do the same.

    The next post in this series gets into the release workflow — the actual fastlane glue, the custom skills, the gotchas I hit, the time fastlane silently crashed on a non-ASCII byte and I had to learn more about shell locales than I wanted to. The post after that is on the persistent memory system that lets Claude keep coherent context across months of work without me re-explaining the codebase every session. And the final post is the one that’s most specifically for iOS developers: a side-by-side guide to moving from standalone (terminal) Claude Code to the Xcode-integrated version, including the commands and modes that aren’t there and what to do instead. All three will be more concrete and more tutorial-shaped than this one.

    That’s where we’ll leave things for today.


    Part of an ongoing series at Nodes and Edges. Earlier post in a related vein: Baseball Invented Event Sourcing 150 Years Ago.