Headless App Store submission, run end-to-end by a coding agent
At 00:01 UTC on July 11, 2026, a review submission for my iOS app — Run Fun: See Yourself, v2.0.0 — flipped to WAITING_FOR_REVIEW in App Store Connect. I didn't click Submit. A coding agent (Claude Code) did the whole final mile through the App Store Connect API: deleted stale screenshots and uploaded new ones, attached the processed build, created the review submission, submitted it, and then verified the state by reading it back. The app had existed as a concept for about 48 hours — a pivot cut from a much larger app — and it went from decision to "waiting for review" in those same 48 hours, with the agent doing the overwhelming majority of the work.
If you stop reading there, the takeaway is "AI agents can ship iOS apps now." That takeaway is wrong in an important way, and the wrongness is the useful part.
Here's the claim I actually believe, and the evidence for it: the agent executed, in one night, a recipe that my failures had spent months writing. Nothing about that night was improvised. Every step the agent took either followed a runbook that a previous failure had paid for, or hit a brand-new failure — and the new ones are now in the runbook too.
What the agent actually did that night
The condensed flow, for people who came for the recipe (all raw App Store Connect API — fastlane turned out to be a dead end on my machine, more on that below):
- Build & upload: bump
CURRENT_PROJECT_VERSION,xcodebuild archive→-exportArchivewith an export-only options plist (method=app-store-connect, nodestination) → upload the IPA withxcrun altool --upload-appusing an ASC API key. 163 MB, 12-second transfer. - Wait properly: poll
GET /v1/builds?filter[app]=…&sort=-uploadedDateuntilprocessingState=VALID. Not "wait a bit and hope" — poll for the actual state. - Screenshots via API: for each image — delete the stale
appScreenshotsin the display set,POST /v1/appScreenshotsto reserve upload operations, PUT the bytes,PATCH uploaded=truewith an md5sourceFileChecksum, then pollassetDeliveryStateuntilCOMPLETE. - Attach the build to the version record, patch reviewer notes, keep
releaseType=MANUAL. - Submit:
POST /v1/reviewSubmissions→POST /v1/reviewSubmissionItemslinking theappStoreVersionrelationship — notappStoreVersionForReview, which 409s — thenPATCH {"attributes":{"submitted":true}}. - Verify: read back the version and the review queue; both said
WAITING_FOR_REVIEW. Only then report done.
Two things went wrong that night, which is exactly the point. First: we'd deleted the Apple Watch screenshot set, reasoning that v2.0 has no user-facing Watch surface. ASC rejected the review submission item with SCREENSHOT_REQUIRED.APP_WATCH_SERIES_4 — because the binary still ships a dormant, flag-gated Watch app, and if the binary ships it, the listing needs its screenshots. The agent recovered the previous version's Watch images through each localization's old screenshot set (imageAsset.templateUrl, fill in {w}x{h}bb.{f}) and re-uploaded them. Second: one of the new iPhone screenshots came back black — a staged camera capture the agent couldn't retake, because a camera pointed at nothing is one of the few things an agent genuinely cannot fake. We shipped three good screenshots and deferred the hero shot to a metadata update.
Both failures are now permanent lines in the runbook. That's the loop this whole post is about.
The staircase this stood on
Here is what that one night actually consumed, with dates. I went back through the repo's own records to check my memory, because "I couldn't have done this cold" is exactly the kind of claim people flatter themselves with.
May 26 — the first submission took ~7 hours, by hand. Four build cycles, two screenshot redos, a privacy-declaration expansion. It produced a 12-item gotcha list that reads like a hazing ritual: screenshots must be exactly 1284×2778 (a native iPhone 17 Pro screenshot is 1206×2622 — "close" is a rejection); macOS screenshots carry an alpha channel that ASC rejects, and sips silently fails to strip it while PIL and ImageMagick work; the listing's "Requires iOS X" line is derived from the binary's deployment target, not your marketing copy; every data type you declare in App Privacy must appear, by Apple's exact name, in your privacy policy, because the reviewer reads both and matches them.
June 21 — credential archaeology. A headless upload needs an ASC API key. The .p8 had been created back in September 2025 and later removed from the repo; the working copy was recovered from a git blob in the repo's history, validated with openssl, and stored properly. The same session proved the "normal" path — uploading through the signed-in Xcode account — is flaky from a headless CLI context (Xcode 26's account layer can't reliably be reached from a spawned xcodebuild), which is why the API-key path matters. fastlane was also a dead end on this machine (system Ruby, no bundler). Every one of those dead ends cost real hours, once.
June 25–26 — the recipe gets validated on a real release. Issuer ID resolved, device registration via POST /v1/devices proven (the CLI's -allowProvisioningUpdates does not register unknown devices, another thing you learn exactly once), and then v1.5.7 shipped through the full headless path: archive → export → altool → poll → attach → submit. A pre-submission audit that same day caught a genuine rejection blocker — a feature that had drifted out of its internal-build gate and contradicted a note we'd previously given Apple's reviewers.
July 4 — the release-order trap. A marketing version closes for TestFlight beta review the moment it's released on the App Store (ENTITY_UNPROCESSABLE.CLOSED_VERSION). Learned on v1.5.8, encoded as "do not click Release until beta review clears." This is why the agent left releaseType=MANUAL and why the human keeps the Release click.
And under all of that: the app itself. The "48-hour" v2.0.0 was a carve-out from an app that took 18 months to build — the capture flow, the cloud GPU rendering pipeline it calls (already deployed, already canary-tested), the build system, the signing setup, the device-automation harness the agent used to screenshot its own UI changes on a physical iPhone. The velocity of the last 48 hours was almost entirely stored potential energy.
The part that isn't about Apple at all
There's a second dependency that's easy to miss because it doesn't live in any runbook: knowing when to believe the agent.
Agents report success with the same fluent confidence whether or not the thing succeeded. Early on, I got burned by this repeatedly — "done and verified" claims where the verification was the agent's own optimism. The discipline that fixed it is boring and absolute: verify the effect against an independent signal, never the self-report. It's baked into the submission runbook in small, specific ways: don't trust the upload script's exit code (a trailing tee masks altool's real status — look for UPLOAD SUCCEEDED and a Delivery UUID); don't trust "submitted" until the API reads back WAITING_FOR_REVIEW; don't trust a config change until the serving system proves it changed.
That discipline is why the agent could run for hours without me watching. Not because it doesn't fail — it failed twice that night — but because the process doesn't let a failure masquerade as a success. It took months of being burned to internalize that, and I don't think there's a shortcut. If you hand an agent a task you've never done yourself, you have no independent signal to check it against, and you've built an unsupervised liar with API access.
What the human still did
Worth being precise about, since "end-to-end" invites exaggeration. I decided what the app was and what got cut. I reviewed the store description and What's New before they went up. I confirmed the privacy labels in the ASC web UI — there is no official API path for privacy labels (the endpoints 404 with a valid key; the one tool that automates them rides a private Apple-ID session API). I kept the Release click. And I was the reason there was anything to submit: the judgment calls about what a two-day pivot should contain came from 18 months of watching real users hit the previous app.
The transferable version
If you want an agent to do some multi-step, high-stakes process for you headlessly, I now believe the path is:
- Do it by hand first, badly. The 7-hour version is not wasted time; it's the tuition.
- Write down every trap as it bites you, in a runbook the agent will literally read. Mine says things like "READ IT" at the top, and agents do.
- Make verification independent of the agent. Every step needs a check the agent can't wish into passing: a state read back from the API, a checksum, a serving system's own report.
- Keep the irreversible clicks human until the boring parts have earned trust.
releaseType=MANUALis a philosophy, not a config value. - Feed failures back into the runbook the same night. The Watch-screenshot 409 is already in mine, so the next agent won't pay for it twice.
The headline version of that night — AI ships app in 48 hours — is technically true and deeply misleading. The truthful version is better, I think: an agent turned 18 months of accumulated, written-down, independently-verifiable experience into one night of execution. The leverage is real. It's just not free, and it's not the model — it's the runbook, and the failures that wrote it.