How I built a native Mac app with Claude Code: spec, spikes, budget
143 commits, 137 of them co-authored by Claude. Here is what I set up before the first line of Swift, what the agent was good at, and where it went wrong.
By Zernonia · November 10, 2026 · 9 min read

The build plan in my spec said six weeks for the core and about ten for full launch scope. Stat Space 1.0 shipped on day three. As I write this the repo has 143 commits, and 137 of them carry a Co-Authored-By: Claude trailer.
That ratio is the reason for this post. Stat Space is a native menu bar system monitor for Apple Silicon: AppKit, Core Graphics, a SwiftUI popover, private frameworks loaded at runtime, a CLI and an MCP server. It’s not a CRUD app, and Claude Code wrote most of it. What made that work wasn’t a clever prompt. It was a handful of boring artifacts I wrote before any code, and a few guardrails the agent couldn’t talk its way around.

The order that made it work: docs, handover, spikes, app, and a benchmark that fails the build.
Write the spec before the code
Before there was a single Swift file, the repo had three documents:
docs/SPEC.md: scope, pricing tiers, architecture, data sources for every metric, a week-by-week build plan, risks, and open questions.docs/DESIGN.md: the chosen popover design, a graphite side rail with a pinnable Home.docs/design/popover-reference.dc.html: an interactive HTML mock of the popover, explicitly marked “reference only; build natively”.
The mock was the most useful of the three for UI work. “Make it look like the reference” is unambiguous in a way that a paragraph about spacing never is.
The spec also has a running “Decided” section. Every time a scope or design question got settled, the decision went there with a date. That turned out to matter more than the plan itself, because it’s how a decision made on Tuesday survives into Friday’s session without me re-explaining it.
The handover file
The fourth document was AGENTS.md, and CLAUDE.md is a one-liner that points to it. I wrote it as if I were handing the project to a contractor who would never be able to ask me a question. It says what to read and in what order, then lists the non-negotiables:
- Lean first. Idle budget: < 0.3% CPU and < 25 MB on M1 at 1s sampling,
popover closed. A CI benchmark enforces it; never merge a regression.
- Samplers run only when something visible or an active rule needs them.
- No allocations in the sampling hot path; fixed-size ring buffers.
- SwiftUI only inside the popover, created on open, torn down on close.
- Private frameworks are dlopen'ed; the feature hides itself if loading fails.
- Dependencies: Sparkle and system SQLite only. Ask before adding anything else.
- Kill actions: only dev-tool processes parented to launchd, always confirm.
It also has naming rules (“Stat Space” in UI, StatSpace in identifiers, statspace for the CLI), Swift 6 strict concurrency, and “samplers are pull-based, no Combine”.
None of these are rules a model would follow by default. Left alone, it would reach for a third-party package, a Timer per metric, or SwiftUI in the menu bar, because those are the common patterns. Written down once, they get read every session, and I stopped repeating myself. When scope changed, the instruction was to update the spec in the same change, so the handover never drifted from reality.
Spikes before the app shell
The last section of AGENTS.md is “Start here (week 1 spikes)”: build these as small standalone targets before the app, and report results. Each one tested an assumption that, if wrong, would change the product:
| Spike | Risk it tested | How it reports |
|---|---|---|
spike-nstat | Per-app network via the private NetworkStatistics framework, the main Pro feature | Generates known traffic for about 20 s and checks how much is attributed to the right PIDs. Prints GATE: PASS or GATE: FAIL |
spike-grouping | Folding helpers, XPC services and Electron renderers into their parent app | Prints every app group with its members, counts of ungrouped and unreadable processes, and full vs incremental resolve cost |
spike-ports | Listening ports traced to a project folder and launcher, without root | Starts two python3 -m http.server listeners from a temp project, one on loopback and one on 0.0.0.0, and passes only if both are found with the right project and exposure |
spike-selfcost | Whether the sampling design fits the idle budget at all | Runs the real scheduler with CPU, memory and network at 1 s and measures its own CPU and memory against the budget |
Two choices made these work well with an agent. First, every spike prints markdown with an explicit pass or fail line, so “did it work?” is a fact in the output, not the agent’s opinion of its own code. Second, the spec had a written gate: if per-app network failed, Pro would be reshuffled around other features. That gave the spike real stakes and a clear decision for me, not the model, to make. It passed, so we built the shell on top.

Each spike tested one assumption that would have changed the product if it were wrong.
The spikes are still in the repo, and scripts/run-spikes.sh reruns all four. They paid off again later. When a user asked “which app made that last beep?”, I had Claude write a fifth spike, spike-sounds. It found that Core Audio’s kAudioProcessPropertyIsRunningOutput never fires its listener on macOS 26, only kAudioProcessPropertyIsRunning does, which also explained a bug where the Volume tab’s playing state didn’t update until the process list changed. That’s a finding I’d much rather get from a 150-line probe than from a week of confused UI debugging.
A budget the agent can’t argue with
The idle budget is the product’s main promise: a monitor that shows up in its own top-apps list has failed. So it’s enforced by code, not by asking nicely.
pulse-bench measures another process from the outside. It samples proc_pid_rusage every second, converts CPU time from Mach ticks with mach_timebase_info (on Apple Silicon those fields are not nanoseconds), prints a report and exits 1 when the mean CPU or peak memory is over budget. scripts/benchmark.sh launches the bundled app in a benchmark mode with no onboarding, on an isolated socket so it doesn’t fight my running copy, waits 15 seconds, and measures for 60. When the app gets SIGTERM it writes a per-sampler cost table, so a failure comes with “processes and observers are where the time goes”, not just a number.
This changed the dynamic more than anything else. When a change pushed the app over budget, the agent didn’t get to explain why it was probably fine. The benchmark failed, the cost table showed which sampler was responsible, and the next change fixed it. The biggest structural change for staying under budget is now a line in the spec: with the popover closed, process sampling runs in a lite mode that reads usage only for your own command-line processes, with a full pass every 30 seconds.
The budget also had to be honest. The first memory gate used raw RSS, which counts shared AppKit and SwiftUI framework pages, around 70 MB for any AppKit app. No amount of optimization would pass that, and an agent will happily burn hours on an impossible target. I changed the gate to phys_footprint, the number Activity Monitor shows as Memory, and recorded it under “Decided”. Changing a budget is a human call. The agent’s job is to meet it.
Tests, and where CI runs them
The test target has 282 test functions covering the resolvers (lineage, app grouping), process features like the orphan rules, IPC, the CLI and MCP server, alerts, history and more. The package also builds on Linux, and every push runs swift build and swift test in a swift:6.1 container on a Linux runner. Swift 6 strict concurrency catches a whole class of mistakes before tests even run.
The loop since 1.0 has been short: I hit something annoying, describe it, Claude fixes it and adds a test. The test is the part I insist on, because it’s what keeps the same bug from coming back three sessions later when the context is gone.
What I actually did
Claude wrote most of the code. My part was:
- Reading diffs. The ones that deserve the closest read are anything that can kill a process, touch the Keychain, or change the sampling path.
- Running it on my own Mac every day. Tests check what I thought to test. Daily use finds the rest.
- Deciding scope. What’s Free and what’s Pro, what gets cut, and judgment calls like “a finished agent session’s leftovers only count after 10 minutes idle”. That rule is marked as an owner call in a code comment, so the next session knows not to “improve” it.
- Keeping the docs true. User requests go into
docs/TODO.mdand only move into the spec once decided.
The agent was also better than me at some digging. Reading Claude Code’s sign-in from the Keychain for usage limits caused a macOS permission prompt. Claude worked out that Claude Code writes that item with /usr/bin/security, so the item trusts that tool, and reading it the same way doesn’t prompt. I wouldn’t have found that quickly.
What went wrong
Some honest lessons, in rough order of how much they cost me:
Noisy CI made the budget soft. GitHub’s macOS runners are shared VMs. The same commit measured 0.26% and 0.81% idle CPU on different runs. CI now allows 0.6% to catch large regressions, and the real 0.3% gate runs on M1 hardware. Then macOS runner minutes, which bill at ten times Linux, pushed the full macOS job to manual dispatch. So “CI enforces the budget on every push” is no longer true. I run the benchmark before every release instead. If you build a budget into CI, plan for the noise from day one.
The local and CI compilers disagreed. On day one, CI’s Swift 6.1 flagged strict-concurrency errors the local build didn’t, and Swift 6.1 also hit a compiler crash on a method-reference binding setter that needed a workaround. Pin the toolchain early, and have CI print unique compile errors so the agent can read them.
Optimizations interact. The lite process mode that saved CPU also skipped processes inside app bundles while the popover was closed. Claude Code run by the Claude desktop app lives inside one. The Agents tab read that as the session ending and offered to clean up its MCP servers while it was still running. Nothing was killed, because every kill requires a confirmation, which is exactly why that rule was in AGENTS.md. Liveness now comes from the full process table.
Day one was messy. The early history is a run of WIP: ... [skip ci] commits, each a large batch of modules landing before CI was green. It worked out, but smaller, buildable steps would have made review easier.
If you try this
- Write the spec, the design reference and a handover file before code. Put constraints in the handover, not in your head.
- Turn every risky assumption into a spike that prints pass or fail.
- Make your quality bar a program with an exit code. Agents respond to failing checks far better than to reminders.
- Keep a dated “Decided” log so decisions survive between sessions.
- You still own the judgment calls, and the diffs that can hurt someone.
The result is Stat Space, a menu bar monitor that groups processes into apps, traces ports to projects, watches your coding agents, and ships a CLI and an MCP server so Claude Code can ask what’s running. It’s free to try at statspace.app, with an optional one-time Pro upgrade.
Also on Medium, if you'd like to follow or comment there.
Questions
What should go in an AGENTS.md or CLAUDE.md for a new project?
The things you'd otherwise repeat every session: what to read first, the non-negotiable constraints (budgets, dependencies, safety rules), naming conventions and the first tasks. Keep the spec in a separate file and point to it.
Why build spikes before the app?
A spike is a small standalone program that proves one risky assumption, like a private framework working on current macOS. If it fails, you change the plan before any UI depends on it.
Know what your Mac is doing, without adding to it.
Free, no account. Pro is $9.99 once for the first 100 buyers.