Hooks For Apple Development: Patterns That Save The Project
A Claude Code session pointed at an iOS project has reach that a generic Python or web project does not. The agent can run xcodebuild and xcrun through its Bash tool. It can read and edit .pbxproj files (an old-style ASCII property list by default, sometimes XML or JSON after tooling conversion, and equally fatal to corrupt in any of those formats). It holds the developer’s signing identities by virtue of running on the developer’s machine. It can erase a simulator. It can rebuild a project with the wrong scheme. It can commit and push. The protocol does not gate any of this: the developer’s filesystem is the agent’s filesystem, and Claude Code’s --dangerously-skip-permissions flag is one keystroke away from full automation.
The mitigation is not “trust the agent.” The mitigation is hooks: deterministic shell scripts the host runs at lifecycle boundaries (PreToolUse, PostToolUse, UserPromptSubmit, SessionStart, Stop).1 Hooks slow the agent down on dangerous inputs, validate destructive outputs, and gate completion on a green build. They’re the load-bearing safety primitive every iOS developer running an agent should configure before the agent runs anything.
Four hook patterns earn their place on iOS projects. They’re framework-level, not project-specific; the cluster’s apps (Return, Get Bananas, Reps, Water, Ace Citizenship) all run variants of these. Each pattern names a real failure mode, a concrete script, and the lifecycle event that bounds the blast radius.
TL;DR
- Four hook patterns that matter on iOS:
.pbxprojvalidation (PostToolUse, feeds errors back to the agent), dangerous bash gating (PreToolUse, blocks before run), green-build Stop gate, simulator-state hygiene (Stop). - Hook exit codes matter, and they behave differently per event.
exit 2blocks the proposed action onPreToolUse(the tool never runs); onPostToolUseit cannot block (the tool already ran) but it surfaces stderr back to the agent so it can repair or revert; onStopit prevents the agent from concluding the session.exit 0allows.exit 1generally logs but does not block.1 - Hook scripts live in
.claude/hooks/*.shin the repo, referenced by relative path from.claude/settings.json. Code review applies. - The agent’s authority is the developer’s authority. Hooks are how the developer carves that authority back into a deliberate set of approved actions.
Pattern One: .pbxproj Validation On Every Edit
The Xcode project file is the one file an agent regularly mutates that has the highest blast-radius-per-line ratio. One wrong bracket in project.pbxproj silently breaks the build for every developer on the team. The build error appears at the next xcodebuild invocation, not at the moment of the edit, so the agent typically claims the change worked before the breakage surfaces.
The hook runs plutil -lint against any .pbxproj write. PostToolUse cannot block the write itself (the file is already on disk by the time the hook fires), but exit 2 surfaces the validation error back to the agent immediately as a tool-call failure: the agent reads the failure, knows the file is broken, and can revert or repair before the session continues:
#!/bin/bash
# .claude/hooks/post-write-pbxproj.sh
# Runs after every Edit or Write tool call. Exits 2 to surface the
# validation failure to the agent so it can revert/repair the broken
# .pbxproj before the session moves on. (PostToolUse cannot prevent
# the write itself; it can only feed the error back.)
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ "$FILE" != *.pbxproj ]]; then
exit 0
fi
if ! plutil -lint "$FILE" >/dev/null 2>&1; then
echo "ERROR: $FILE failed plutil -lint after write" >&2
echo "The Xcode project file is structurally broken. Revert and try again." >&2
exit 2
fi
exit 0
plutil -lint catches structural breakage: unbalanced braces or parentheses, missing semicolons, invalid plist tokens, broken XML nesting.2 It does not catch Xcode-semantic errors like a malformed UUID that happens to be syntactically valid plist text, or a build phase referencing a missing file. Those produce ordinary build errors the agent can debug normally. The plutil gate catches the catastrophic parse-failure class; semantic errors fall through to the build itself.
The hook configuration in .claude/settings.json (note the quoted $CLAUDE_PROJECT_DIR for paths with spaces):
{
"hooks": {
"PostToolUse": [{
"matcher": "Write|Edit",
"hooks": [{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-write-pbxproj.sh"
}]
}]
}
}
The matcher fires the hook on Write and Edit tool calls only; the script’s first action is to short-circuit on non-.pbxproj paths. The cost of running on every Edit is negligible because the path filter is the first check.
Pattern Two: Gating Destructive Bash Commands Before They Run
xcrun simctl erase wipes a simulator’s data. xcodebuild archive invokes signing and can produce signed artifacts the developer didn’t intend. git push --force rewrites history. The agent has access to all of them through its Bash tool. A PreToolUse hook on Bash matches the proposed command pattern and decides whether to proceed.
The shape:
#!/bin/bash
# .claude/hooks/pre-bash-xcode.sh
# Runs before every Bash tool call. Exits 2 (blocking) on irreversible
# Xcode/signing/git operations the developer hasn't explicitly approved.
# PreToolUse hooks CAN block: exit 2 prevents the tool from running.
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
case "$COMMAND" in
*"simctl erase"*)
echo "ERROR: simulator erase requires explicit human approval" >&2
echo "Tell the developer what you wanted to erase and why; let them run it." >&2
exit 2
;;
*"xcodebuild archive"*|*"xcodebuild -exportArchive"*)
echo "ERROR: xcodebuild archive/export invokes signing; requires human approval" >&2
exit 2
;;
*"git push --force"*|*"git push -f"*)
echo "ERROR: force-push rewrites history; requires human approval" >&2
exit 2
;;
*"rm -rf"*)
echo "ERROR: rm -rf requires explicit approval" >&2
exit 2
;;
esac
exit 0
The hook is a switch on command-pattern substrings. The blocking class is the irreversible-action class: erase, sign, force-push, recursive delete. Reversible operations (regular builds, tests, git commits without force) pass through.
A common refinement is to allow some commands when the developer has explicitly opted in via an env var or flag in the conversation. For example: xcodebuild archive could be allowed if CLAUDE_ALLOW_ARCHIVE=1 is in the environment, set by the developer before the session for a specific archive task. The hook reads the env and bypasses the block:
*"xcodebuild archive"*)
if [[ "${CLAUDE_ALLOW_ARCHIVE:-0}" == "1" ]]; then
exit 0
fi
echo "ERROR: xcodebuild archive requires CLAUDE_ALLOW_ARCHIVE=1" >&2
exit 2
;;
The pattern: default-deny on the irreversible class, opt-in escape valve for the cases the developer wants the agent to handle.
Pattern Three: Stop Hook That Gates Completion On A Green Build
The agent likes to declare a task done when the conversation looks resolved. Without a gate, “done” can mean “I edited the files and the chat is in a coherent state” rather than “the build still compiles.” The Stop hook is the place to enforce the right meaning.
#!/bin/bash
# .claude/hooks/stop-build-check.sh
# Runs when the agent tries to stop. Exits 2 if the build is broken,
# which prevents the session from concluding until the agent fixes it.
cd "$CLAUDE_PROJECT_DIR" || exit 0
# Hard-code project, scheme, and destination per repo. Do not rely on
# auto-discovery: workspaces, multiple projects, or shared-vs-user
# schemes all break naive heuristics.
PROJECT="MyApp.xcodeproj"
SCHEME="MyApp"
DESTINATION="platform=iOS Simulator,name=iPhone 17 Pro"
LOG=/tmp/claude-stop-build.log
if ! xcodebuild -project "$PROJECT" -scheme "$SCHEME" \
-configuration Debug \
-destination "$DESTINATION" \
build > "$LOG" 2>&1; then
echo "ERROR: build failed; cannot stop with a broken build" >&2
echo "See $LOG for the full xcodebuild output." >&2
tail -50 "$LOG" >&2
exit 2
fi
exit 0
Hard-coding PROJECT, SCHEME, and DESTINATION is the right shape for repo-committed hooks: the values never drift, workspaces and multi-project repos work without per-machine tweaks, and a CI build system using the same hook can swap the destination via env var. Auto-discovery (ls *.xcodeproj, xcodebuild -list | awk) works for the simplest solo case but fails on .xcworkspace-rooted projects, on repos with multiple .xcodeproj files, and on the shared-vs-user scheme distinction. The destination string follows xcodebuild’s documented platform=...,name=... syntax;3 it has to be a simulator the developer’s machine actually has, otherwise the hook fails for environment reasons rather than code reasons.
Two product decisions the hook makes:
Stop blocks the agent’s “I’m done” signal, not the human’s. The developer can always Ctrl+C, close the terminal, or override. The hook is a guardrail against the agent’s optimism, not a lock-in on the human.
The hook runs a real build, not a syntax check. swift build against an iOS-specific project skips the iOS-specific compilation steps; only xcodebuild proves the iOS target compiles. The cost is the build time itself (10-60 seconds on most projects); the value is catching the broken-build-marked-done case every time.
Pattern Four: Simulator-State Hygiene
After a long agent session, simulators can pile up: booted simulators the agent forgot to shut down, old app installations that cache stale state, runtime data that survives across sessions and produces irreproducible bugs. A Stop hook can clean up.
#!/bin/bash
# .claude/hooks/stop-simulator-cleanup.sh
# Soft cleanup: shuts down booted simulators we don't need anymore.
# Does NOT erase data; does NOT block. Logs only.
BOOTED=$(xcrun simctl list devices booted 2>/dev/null | grep -E "Booted" | wc -l | xargs)
if (( BOOTED > 0 )); then
echo "[hook] $BOOTED booted simulators at session end; consider shutdown" >&2
# Uncomment to auto-shutdown:
# xcrun simctl shutdown all 2>/dev/null
fi
exit 0
The shape is non-blocking by design: the hook reports state but does not act unless the developer uncomments the shutdown line. The reason is that the agent’s next session might want the booted simulator preserved (faster cold-start when the simulator is already running). The decision is per-developer: if simulators pile up across multiple sessions and the cost is real, uncomment the shutdown; otherwise leave it as a log signal.
A more aggressive variant erases simulators between sessions, but that crosses into Pattern Two’s destructive operation territory. Erase belongs in PreToolUse blocking, not Stop automation.
What Hooks Do Not Solve
The four patterns above are the working set, not the whole picture. Three classes of failure that hooks cannot catch:
Logic bugs in the agent’s code. A hook validates structure, not semantics. The agent can write a @Model class that compiles, passes the project-file lint, builds green, and is still semantically wrong (a missing migration, a broken unique constraint, a SwiftData relationship without an inverse). Logic correctness lives in tests, code review, and the developer’s eyes; hooks are for structural and lifecycle concerns.
Slow drift in agent quality. Each individual hook stops a class of failure on first encounter, but cumulative drift over many sessions (gradually messier code, gradually weaker tests, gradually outdated CLAUDE.md instructions) is not what hooks measure. That’s a session-review problem, not a hook problem.
Trust boundary violations outside the agent’s tool surface. A hook on Bash and Edit covers the common path. A hook on every MCP tool the agent might call requires per-tool matchers; some MCP servers expose dozens or hundreds of tools (XcodeBuildMCP advertises around 80) and writing a hook per tool is impractical. The right pattern there is to scope MCP server access (project-level .mcp.json, approval flow on first use) rather than hook every individual tool, and to accept that the agent operating its MCP servers is part of its sanctioned authority.
The relationship between hooks and the broader trust posture is covered in The Repo Shouldn’t Get to Vote on Its Own Trust: trust is a load-order invariant, not a downstream check. Hooks are downstream guards on actions an already-trusted agent takes; they do not replace the upstream decision about whether the agent should be trusted at all.
What I Would Build Differently
Three patterns the cluster’s apps either ship or wish they shipped.
Hook scripts in version control with the rest of the project. The hook scripts live at .claude/hooks/*.sh in the repo. The .claude/settings.json references them by relative path. The team gets the same safety nets across machines, code review applies to hook changes, and onboarding a new developer is a git clone instead of a copy-paste exercise. User-scoped hooks at ~/.claude/settings.json are the wrong granularity for project-specific gating.
A SessionStart hook that prints the active hook configuration. Hooks are silent until they fire. A SessionStart hook that runs at the start of every Claude Code session and prints “Active hooks: pbxproj-validation, dangerous-bash-gate, build-check-on-stop” reminds the developer (and the agent) what guards are running. The cost is one line of stderr per session; the value is that nobody develops without knowing the safety net is there.
A repo-level audit log of agent tool calls. A PostToolUse hook that appends every tool call (with timestamp, tool name, arguments) to a JSONL file in .claude/logs/ (gitignored). The log answers “what did the agent do this session?” with a jq query instead of a chat-history scroll. The hook adds a few milliseconds per tool call and produces durable audit data the developer can grep when something goes sideways.
When Hooks Are The Wrong Answer
Two cases where the hook layer is the wrong place to solve the problem.
The agent’s MCP servers themselves. A bad MCP server doing the wrong thing is not a hook problem; it’s an MCP server problem. The fix is to scope what MCP servers the project trusts (.mcp.json review, project-scoped first-use approval) and to read the server’s source code if it’s open. A hook on every MCP tool call adds overhead without addressing the trust question.
Agents running unattended. The full hook posture assumes a developer is near the session and can interpret a failed hook. An agent running in CI without a human in the loop needs a different posture: stricter MCP scoping, narrower tool sets, a different model of trust. Hooks alone do not bridge attended development to unattended automation; that gap is intentional.
What The Pattern Means For iOS Apps Shipping On iOS 26+
Three takeaways.
-
Default-deny on irreversible operations, validate the structural blast-radius files, gate completion on green builds. Three hook lifecycle events (
PreToolUse,PostToolUse,Stop), four patterns, covering the common iOS failure modes. The combined set is small enough to write in an afternoon and durable enough to outlive any specific agent or model. -
The exit code matters, and it differs by event.
exit 2blocks the action onPreToolUse(the tool never runs); onPostToolUseit cannot block (the tool already ran) but it surfaces stderr back to the agent so the agent can repair or revert; onStopit prevents the agent from concluding the session.exit 1does not block on most events. Test every hook with a deliberately-failing case before relying on it. -
Hooks bound authority. They do not grant it. The agent’s reach into the developer’s machine is whatever the OS permits the developer’s terminal session to do. Hooks let the developer carve specific actions out of that authority and require explicit approval. The default is whatever the OS grants; the goal of hooks is to make the default smaller, not larger.
The full Apple Ecosystem cluster: typed App Intents for the Apple Intelligence surface; MCP servers for the agent surface; the routing question between them; Foundation Models for in-app on-device LLM features; the runtime vs tooling LLM distinction; the three surfaces synthesis; the single source of truth pattern; Two MCP Servers for the Xcode integration that pairs with these hooks; Live Activities for the iOS Lock Screen state machine; the watchOS runtime contract on Apple Watch; SwiftUI internals for the framework substrate; RealityKit’s spatial mental model for visionOS scenes; SwiftData schema discipline for persistence; Liquid Glass patterns for the visual layer; multi-platform shipping for cross-device reach. The hub is at the Apple Ecosystem Series. For broader iOS-with-AI-agents context, see the iOS Agent Development guide.
FAQ
What are Claude Code hooks and why do they matter for iOS development?
Claude Code hooks are deterministic shell scripts that run at lifecycle events (PreToolUse, PostToolUse, UserPromptSubmit, SessionStart, Stop). For iOS development, they bound the agent’s authority over destructive operations: simulator erasure, code signing, project file mutations, force-pushes. Without hooks, the agent has the developer’s full machine authority; with hooks, specific dangerous actions require explicit approval.
Which hook events should an iOS developer prioritize?
PreToolUse on Bash for blocking destructive commands (simctl erase, xcodebuild archive, git push --force). PostToolUse on Edit/Write for validating .pbxproj integrity. Stop for gating on a green build. SessionStart for logging the active hook configuration. The four together catch the most common iOS-specific agent failures.
What’s the difference between exit codes 0, 1, and 2?
Exit 0 allows the action and proceeds. Exit 2 behaves differently per event: on PreToolUse it blocks the proposed action (the tool never runs); on PostToolUse it cannot block because the tool already executed, but it surfaces stderr back to the agent so the agent can repair or revert; on Stop it prevents the agent from concluding the session. Exit 1 logs an error but does not block on most hook events. For safety patterns that need to actually prevent action before it runs, use exit 2 on PreToolUse. For validation after a destructive write, use exit 2 on PostToolUse to feed the failure back to the agent. Test every hook with a deliberately-failing input to confirm it behaves as expected for the specific event.
Where should hook scripts live?
In .claude/hooks/*.sh at the project root, with .claude/settings.json referencing them by relative path. Version-controlled and code-reviewed alongside the rest of the project. User-scoped hooks at ~/.claude/settings.json work too but are the wrong granularity for project-specific iOS gating.
Do hooks replace the need for code review?
No. Hooks catch structural errors (broken project files, dangerous bash, broken builds) before they ship. Code review catches semantic errors (logic bugs, missing migrations, weak tests). The two layers complement each other: hooks make the agent safer to deploy on the inner loop, code review keeps the agent’s output honest at the boundary.
References
-
Anthropic, “Claude Code reference: Hooks”. Lifecycle events (
PreToolUse,PostToolUse,UserPromptSubmit,SessionStart,Stop), matcher syntax, command shape, and the role of exit codes. Exit 2 behaves differently per event: onPreToolUseandStopit blocks the action; onPostToolUseit cannot block (the tool already ran) but it surfaces stderr back to the agent. Exit 0 allows; exit 1 generally logs but does not block. Author’s analysis in When the LLM Lives in Your App vs in Your Tooling covers the runtime-vs-tooling LLM trust posture that hooks operationalize. ↩↩ -
Apple,
plutil(1)man page. The-lintflag validates property list syntax across old-style ASCII, XML, and binary formats. It detects parse-level breakage but does not check Xcode-specific semantics like build phase references or UUID validity within the project graph. ↩ -
Apple Developer, “xcodebuild Destination Specifier” and the
xcodebuildman page. The-destination 'platform=...,name=...'syntax is the canonical way to pin a build target; CI environments override the simulator name via env vars or scripted device-availability detection. ↩