A reusable template repository for iOS projects built to be worked on by an AI coding agent (Claude Code, Codex, etc.) — deterministic scaffolding, auto-loaded meta-files, enforced lint/style, and Skills for routine work.
This repo describes the template system itself, not any one app. There is no
Xcode project here, no architecture decision, no ios-skeleton.config.json —
those all belong to a real project generated from this template, via /start.
Clone it, run one command, answer the questions, build. You never create anything
in Xcode by hand — /start generates the project for you.
1. Install the tools (once per machine)
brew install xcodegen swiftlint jq # tuist instead of xcodegen if you prefer itYou also need Xcode 16 or newer, and Claude Code (or another agent that reads
.claude/skills/).
2. Get the template
git clone <template-repo-url> ios-ai-skeleton && cd ios-ai-skeleton3. Run /start, and give it a path
/start ~/Code/MyNewApp # folder missing or empty → fresh start
/start ~/Code/ExistingApp # an app that already exists → adoption, nothing of yours overwritten
/start ~/Code/Workspace # an .xcworkspace + several projects → adoption/start copies its own files into that folder and scaffolds there — you never
cp -r anything, and it works out which of the three situations it's in on its
own.
Pass the path unless you meant to convert this checkout. With no argument,
/start scaffolds into the current directory, so running it inside your clone of
the template turns the template checkout itself into the app and leaves your
project sharing the kit's git history. That's a real, supported way to start —
clone the template as the new project's root and it's exactly right — but it's
not what you want from a clone you keep around to start several projects from.
/start asks you to confirm the target before writing anything when it looks like
the second case.
4. Answer the eleven setup questions
/start always shows you the whole list — topology, UI framework, persistence,
architecture, navigation, networking, minimum iOS version, DI, testing framework,
tooling, app identity — with a recommended default beside each one. Reply "use
the recommended defaults" to accept all of them in a single answer; the only
thing you have to supply yourself is the app's display name and bundle ID. Your
answers go into ios-skeleton.config.json, which every other command reads from
then on, so you never repeat them.
/start then generates a real, compiling app via XcodeGen/Tuist — no manual
Xcode step, ever — with one starter feature and a test that passes.
5. Fill in Secrets.xcconfig
/start created it for you and it's gitignored. Set API_BASE_URL — the one key
name the template itself reads, in AppEnvironment, in the Info.plist entry and
in Scripts/check_secrets.sh — and write the URL as https:/$()/host, not
https://host, because // starts a comment in an xcconfig file and the plain
form silently truncates to https:. The staging URL already sitting there, and
the commented-out API_KEY line under it, are examples showing the shape a key
takes — not values or key names your project has to keep. Add your own with
/add-secret KEY=value. Then run Scripts/check_secrets.sh to confirm.
6. Open the project and hit Run
Then: /new-feature <Name> for each new screen, /status to see where the
project stands, /add-secret KEY=value for the next key.
Re-running /start later is safe — it shows what's already recorded and never
silently regenerates or deletes anything.
Full walkthrough: docs/ONBOARDING.md.
.claude/skills/ — /start plus 10 routine-work Skills (new-feature, add-module,
add-secret, translate, ...), each pinning its own model
tier in frontmatter (see Model tiers below)
Scripts/ — lint, string/color/secrets enforcement, codegen (incl. the
String Catalog toolchain in lib/xcstrings.py), four fully-authored
file-template sets (see Architecture below), plus the
persistence/, logging/ and config/ templates /start renders
into a real project
docs/ — this template's own docs + the .template sources /start renders
docs/product/ — the domain slot: your PRD/SRS/API contracts land here later
.swiftlint.yml — one root lint config, every tier
.githooks/pre-commit
.gitignore — the always-ignore list (incl. Secrets.xcconfig); copied in
by /start so the secrets rule has something enforcing it
CLAUDE.md.template — renders into a real project's CLAUDE.md
Every Skill declares its model in frontmatter, so the tier follows the work rather than whatever model the session happens to be on:
model: opus + effort: high |
model: inherit |
|
|---|---|---|
| Skills | /start, /new-feature, /add-module, /add-app |
/add-assets, /update-app-icon, /add-permission, /update-theme, /status, /translate, /add-secret |
| Why | Irreversible or cross-cutting: /start writes the config every other Skill reads and owns the adoption branch; /new-feature falls back to agent-assisted generation outside the four authored combos; the two module/app Skills rewire every consumer. Pinned rather than inherited — these must not silently run on a cheaper session model. |
Bounded, single-destination edits over an already-decided architecture, each backed by a script that refuses an ambiguous destination. Nothing to pin, so they follow your session. |
Overriding per run. A pinned model: replaces the session model while the
Skill runs — /model sonnet then /status gets you Sonnet, but /model sonnet
then /start still gets you Opus. That asymmetry is the point: the four pinned
Skills are the ones a cheap model shouldn't quietly handle. To change one
anyway, edit the key in your project's own .claude/skills/<name>/SKILL.md
(inherit hands it back to /model); there is no per-invocation flag.
Aliases, not pinned model IDs — these files get copied into projects that
outlive any one model generation. /start copies .claude/ into the target,
so the tiers travel with every project scaffolded from here. The copy is
merge-only, so a project that already has its own .claude/skills/ keeps it
untouched — pick the tiers up there by editing its frontmatter directly.
| T1 | T2 (default) | T3 | |
|---|---|---|---|
| Shape | single .xcodeproj |
project + local Swift packages | workspace + N projects |
| Good for | prototypes, <10 screens | one shipping app of any size | two apps sharing a spine, or a shipped framework |
Moving up a tier later is a bounded, scripted migration (spec edit + regenerate,
never pbxproj surgery) — see docs/ONBOARDING.md for the honest cost table,
including the one migration that reliably costs more than it looks: turning an
existing single-target app into something a second app can import.
docs/ai/architecture.md.template is a generic reference covering MVC, MVVM,
VIP (Clean Swift), VIPER, and MV (SwiftUI-native) — /start renders it into a
real project's docs/ai/architecture.md showing only the one pattern
actually chosen. Four combinations are fully template-backed today:
- MVVM + SwiftUI +
NavigationStack(mvvm-swiftui-navigationstack/) — the default. ViewModel depends on the Repository's protocol directly — noUseCaselayer (seedocs/CODING_STANDARDS.md's §8.2 rules for why). - VIP + SwiftUI +
NavigationStack(vip-swiftui-navigationstack/) — classic Clean Swift with a thin@ObservableViewModel bridge, since a SwiftUIViewcan't hold aweakPresenter reference the way aUIViewControllercan. - VIP + UIKit + Coordinator (
vip-uikit-coordinator/) — classic Clean Swift, ViewController conforms toDisplayLogicdirectly. - MVC + UIKit + Coordinator (
mvc-uikit-coordinator/) — deliberately one file per feature, making MVC's "Massive View Controller" risk honest rather than hidden.
VIPER is deliberately template-assisted only — it's close enough to VIP (drops the weak reference, gives Router full navigation ownership) that a dedicated template would be near-duplicate effort. Every other combination still gets deterministic folder/DI/navigation/test scaffolding from /new-feature, but the layer file bodies fall back to the agent writing them from the rendered architecture doc.
This repo describes how an app here is built. It knows nothing about what
any given app is for, and it never will — that's docs/product/, the one folder
nothing in this template generates, renders or overwrites. Drop the PRD, SRS, API
contracts and anything else domain-specific in there whenever they arrive, and
keep changing them; they're expected to be living, contradictory and incomplete.
CLAUDE.md carries a pointer to that folder and deliberately never a summary —
a digest of a living document is stale within weeks and reads as current.
/new-feature reads the relevant requirement at the start of each invocation, so
a spec'd field list beats an inferred one, and says which document it used.
/start's Q4 (SwiftData / Core Data / None) changes what every feature
generates. None gives remote-only screens — a complete answer, not a degraded
one. A real stack gives each feature a <Name>LocalStore alongside its remote
dependency (behind a protocol its consumer owns), a PersistenceController
injected from the composition root, and — on SwiftData — a @Model record in
Models/ registered with the container schema. The generated policy is
cache-on-success / read-on-failure; a feature that pages or syncs deltas
rewrites that one method.
Carried over honestly rather than hidden:
- The four fully-deterministic combos above are all T1/T2 shape; T3 placement for any of them is best-effort. Everything else (MVC+SwiftUI, MV, VIPER, or any architecture on its non-default navigation approach) is template-assisted.
/start's idempotent conflict-handling is agent judgment, not a byte-for-byte diff check.- No hardcoded-string enforcement script (colors have one; strings don't — a blanket regex would false-positive too heavily on legitimate literals).
- No CI pipeline, no image-caching library choice (
AsyncImageis the default — seedocs/ai/ui_rules.md), no pagination convention — confirmed as deliberate scope, not oversights. networking: none(an offline, local-only app) generates every part of a feature deterministically except the data layer's read method and its test — those areTODO(agent), the same posture as an off-default combo. Refused outright only when persistence is alsoNone, since then there's no data layer to generate at all.- Core Data's per-feature store is a wired, compiling seam with
TODO(agent)bodies — its entity lives in a.xcdatamodeldthat can't be text-templated, unlike SwiftData's@Model, which is generated end to end. - No auth/session layer (token refresh, Keychain, 401 retry), no deep-link →
Routemapping, and no UI/snapshot test tier — unaddressed so far rather than deliberately excluded. - Per-module localization and the base/app theme split are correctness
requirements with no compile-time guard —
check_strings.shcatches key parity, nothing catches a wrong-bundle lookup at runtime. check_secrets.shdoes not follow xcconfig#includedirectives, so a value inherited from an included file reads as absent to it. A missing key is therefore never an error on its own — only an empty one, or key drift against the committed.example. A multi-config project layering xcconfigs is where this gap shows up; the composition root's launch-time guard still catches it.- Generated
.xcodeproj/.xcworkspacefiles are committed, not gitignored (seedocs/ONBOARDING.md). - Shared views (
DesignSystem/Views/) are mandatory by convention with nothing enforcing them — nothing detects a feature that reimplements a component the shared folder already has, the waycheck_hardcoded_colors.shdetects a raw color. - Storyboards and XIBs are out of scope by decision — generated UIKit screens lay out in code (build spec §3.3 records why). A project can add them by hand; no Skill will generate or edit one.
- Agent permissions granted inside Xcode (Intelligence ▸ Agents ▸ Permissions) are global to the Mac and apply to every project. Nothing in this template can scope, version or audit them.
Resolved since the initial build (localization moved to String Catalogs, so
Xcode's localization tooling and this template's scripts now share one source of
truth instead of two — with a migration script and a check that fails a module
carrying both formats; persistence now actually generates a local
data layer instead of being recorded and discarded, Core/Logging/Log.swift now
exists so the no-print() rule has a referent, Objective-C support dropped entirely,
/translate Skill added, commit-message format deliberately left unenforced
(the commit-msg hook was added, then removed — message conventions are the
team's call, not the template's), DI-container
upgrade path documented, secrets handling now ships a real Secrets.xcconfig
seam and .gitignore rather than only a doc line about one, docs/PROJECT_MAP.md now seeded by /start instead of
first appearing when another Skill appends to it, every Skill now declares its own model tier in frontmatter,
/start now always presents the whole Setup Questionnaire on a first run — preferences stated in the
invocation pre-fill it rather than skipping it) — see the build spec's §10 for
the full history.
Full list: docs/ONBOARDING.md and the build spec this template was generated
from.