Give Your Agents a Makefile
The GUI Problem
If you’ve worked on an iOS codebase for any length of time, the tooling becomes second nature. You know which scheme to pick, which simulator to target, which flags to pass. You probably don’t even think about it anymore; you just hit Cmd+B.
An AI coding agent can’t hit Cmd+B. It operates through a terminal: shell commands, stdout, exit codes. All that implicit knowledge you carry around is invisible to it.
Drop Claude Code into a fresh iOS project and ask it to run the tests. It’ll try xcodebuild test with the wrong scheme, the wrong destination, and missing flags. It’ll get a wall of errors, adjust one flag, get a different wall of errors, and loop until it stumbles into something that works or gives up. The agent isn’t bad at shell commands; it just doesn’t have the project-specific knowledge that you built up over months.
A Makefile paired with a well-structured AGENTS.md changes this. The Makefile encodes each operation as a single command with the right flags baked in; the AGENTS.md points agents to those commands and away from the raw tools. Instead of guessing, the agent follows a known path.
What a Task Runner Gives You
A Makefile (or Justfile, or a scripts/ directory, or whatever your team prefers) serves three roles:
-
Encoding institutional knowledge. The correct
xcodebuildinvocation for your project isn’t obvious. It might need a half-dozen flags, a specific simulator, and custom build settings. The Makefile remembers these so nobody else has to. -
Providing a discovery mechanism. An agent (or a new team member) can run
make helpand get a categorized list of every available operation with examples. This beats searching through documentation or guessing. -
Abstracting environment differences. The same
make testcommand should work in the main repo, in a git worktree, and in CI. The Makefile handles the differences (which simulator to target, where to put build artifacts, which tool versions to use) so the caller doesn’t care.
What to Wrap
Start with the operations that happen on every task:
build:
tuist build MyApp-Prod \
-- \
-derivedDataPath $(DERIVED_DATA) \
-destination 'platform=iOS Simulator,name=$(SIMULATOR),OS=latest' \
-skipMacroValidation \
-skipPackagePluginValidation \
RUN_SWIFTLINT=1
An agent runs make build. The full invocation with all its flags is invisible. If a flag changes, you update the Makefile, not every script and prompt that references it.
Testing is where the Makefile really earns its keep:
test:
@rm -rf .build/tests/results.xcresult
tuist test \
--no-selective-testing \
$(if $(FILTER),,--skip-test-targets SlowSnapshotTests) \
--result-bundle-path .build/tests/results.xcresult \
-- \
-derivedDataPath $(DERIVED_DATA) \
-destination 'platform=iOS Simulator,name=$(SIMULATOR),OS=latest' \
-skipMacroValidation \
-skipPackagePluginValidation \
RUN_SWIFTLINT=1 \
$(if $(FILTER),-only-testing:$(FILTER))
The interface is make test or make test FILTER=AppFeatureTests/LoginViewSnapshotTests. Everything else is hidden.
Beyond build and test, wrap anything an agent might need:
make setup # First-time setup (installs tools, fetches deps, generates project)
make install # Fetch/update dependencies
make generate # Regenerate Xcode project from Tuist manifests
make graphql # Regenerate GraphQL types from schema
make format # Run SwiftFormat
make lint # Run SwiftLint
make modules # List all available modules
make module-test MODULE=Networking # Test a specific module
make clean # Clean build artifacts
Each of these encodes something non-obvious. make setup chains Tuist installation, dependency fetching, project generation, and git hook installation in the right order. make graphql knows where the Apollo CLI lives and which config file to use. make format downloads a pinned SwiftFormat version on first run so agents and CI always use the same formatter.
Blocking Direct Tool Access
Wrapping isn’t enough. Agents know about xcodebuild. They’ll reach for it unless you explicitly tell them not to.
We learned this the hard way. Even with a Makefile sitting right there, agents would shell out to xcodebuild directly, burn several minutes re-deriving flags, and fail anyway. The fix was embarrassingly simple: tell them not to.
In your AGENTS.md:
## ⚠️ IMPORTANT: Always Use Makefile Commands
**Do NOT call these tools directly:**
- `xcodebuild` — use `make build` or `make test` instead
- `tuist build` — use `make build` instead
- `tuist test` — use `make test` instead
- `tuist install` — use `make install` instead
- `tuist generate` — use `make generate` instead
The Makefile commands include required flags that are easy
to forget when calling tools directly.
The blocklist makes the Makefile the path of least resistance.
Worktree-Aware Commands
If you run multiple agents in parallel using git worktrees, the Makefile is where you absorb the environment differences. The key challenge is simulators: they’re a shared global resource, and two agents targeting the same simulator will fight.
Our Makefile resolves the simulator dynamically at make-time:
BASE_SIMULATOR := iPhone 17 Pro
SIMULATOR = $(shell ./Tools/simulator-clone.sh get 2>/dev/null \
|| echo "$(BASE_SIMULATOR)")
The shell script detects whether you’re in a git linked worktree (by comparing git rev-parse --git-dir against --git-common-dir) and returns the appropriate simulator: a dedicated clone for worktrees, the base simulator for the main repo. Every command uses $(SIMULATOR) in its -destination flag. The agent runs the same make build regardless of which worktree it’s in.
DerivedData follows the same principle. Instead of the default ~/Library/Developer/Xcode/DerivedData/, we use a project-local directory:
DERIVED_DATA := DerivedData
Each worktree gets its own build artifacts. No cross-contamination, no index store corruption from parallel builds.
Pinned Tool Versions
Agents and CI should use the same tool versions. The Makefile handles this:
TUIST_VERSION := 4.152.0
SWIFTFORMAT_VERSION := 0.59.1
_install-tuist:
@if [ "$$(tuist version)" != "$(TUIST_VERSION)" ]; then \
brew install --formula tuist@$(TUIST_VERSION); \
brew link --force --overwrite tuist@$(TUIST_VERSION); \
fi
install-swiftformat:
@mkdir -p $(LOCAL_BIN)
@CURRENT=$$($(LOCAL_BIN)/swiftformat --version 2>/dev/null || echo "none"); \
if [ "$$CURRENT" != "$(SWIFTFORMAT_VERSION)" ]; then \
curl -sL "https://github.com/.../$(SWIFTFORMAT_VERSION)/swiftformat.zip" \
-o /tmp/swiftformat.zip; \
unzip -o /tmp/swiftformat.zip -d /tmp/swiftformat >/dev/null; \
mv /tmp/swiftformat/swiftformat $(LOCAL_BIN)/swiftformat; \
fi
make format depends on install-swiftformat, which downloads the pinned version on first run. No global install required. An agent in a fresh worktree gets the right version automatically.
Module Discovery
Large modular iOS projects have dozens of targets. An agent needs to know which modules exist and which have tests before it can run make module-test MODULE=.... Rather than maintaining a list in documentation (which goes stale), the Makefile generates it from the source of truth:
modules:
@swift Tuist/Scripts/list-modules.swift 2>/dev/null
make modules lists every module and flags which ones are testable. This replaced a static module list in our AGENTS.md that was constantly out of date.
make help as Documentation
Every Makefile should have a help target. Ours is categorized and includes examples:
◆ Setup
──────────────────────────────────────────────
setup Complete first-time setup
install Install/update Tuist and fetch dependencies
generate Generate Xcode project from Tuist manifests
◆ Development
──────────────────────────────────────────────
build Build the Prod variant
clean Clean build artifacts
◆ Testing
──────────────────────────────────────────────
test Run tests (skips slow snapshots)
test FILTER=... Filter tests (see examples)
test-record FILTER=... Record specific snapshot tests
Unlike a wiki page or a README section, the help target lives next to the commands it describes, so it’s much harder for the two to drift apart.
Where to Start
If this all feels like a lot, start with two targets: build and test. Those cover 80% of what an agent needs. Add the blocklist to your AGENTS.md the same day. You can layer on module discovery, tool pinning, and worktree support as the gaps reveal themselves.
I’d also recommend make over a scripts/ directory. It’s universal, has no dependencies, and agents already know how to invoke it. A collection of shell scripts works, but make gives you dependency tracking and a standard help convention for free. The Makefile can enforce tool versions automatically. Documentation can only ask nicely.
The Makefile is the unglamorous foundation that everything else builds on. Skills, AGENTS.md files, and sophisticated verification workflows all assume the agent can build and test reliably. Get that right first.