Building a Native macOS AI Agent App: SwiftUI, MCP and Rich Output

From a debugging window to a product: Parto, the Mac app built on iAssist

The Inspector That Became the Product

Part 6 ended with a SwiftUI window that showed the agent tree live. It started as a debugging tool, a way to see what the agents were doing. Over a few weeks, it gained a terminal-style input, permission dialogs, a model picker and a context viewer. At some point it was clearly no longer a debugger. It was the app.

In July 2026 it got a name: Parto (پرتو), Persian for “ray of light”. Before choosing it, we checked the App Store for collisions. No AI or developer tool used the name, only unrelated apps sharing the bare word.

This article covers what it took to turn a terminal agent into a native Mac app.

One Engine, Many Front-Ends

The most important architectural decision paid off here. Because the loop, tools, prompts and turn strategies live in iAssistCore with zero UI dependencies, the Mac app reuses all of it. A shared composition root builds providers and prompts the same way for the terminal and the GUI:

feat(core): shared composition root for provider + prompt wiring
refactor(console): build providers and prompt env via the shared root

When the terminal and the GUI briefly built providers differently, they behaved differently. A single source of truth ended that class of bug.

Windows Are Contexts

A terminal has one conversation. A Mac app can have many. The refactor that made this work split application state in two:

refactor(inspector): split App state into AppServices (shared) + ContextSession (per-context)
feat(inspector): multi-window — WindowGroup(for:) vends a ContextSession per window
feat(inspector): shared context — multiple windows on ONE context (⌘⇧N)

Each window gets its own context, which means one window can talk to Claude while another runs a local model. Utility windows (context viewer, metrics) follow the frontmost context using SwiftUI’s @FocusedValue. One bug here is a good lesson in multi-window apps: utility windows first followed the primary window instead of the last active one, and permission prompts from MCP tools went to a fixed window instead of the focused one.

MCP: Any Tool, Without Writing Code

The Model Context Protocol lets an agent use tools provided by external servers: databases, browsers, issue trackers, design tools. iAssist’s iAssistMCP package wraps every MCP tool as a normal AgentTool, so the loop doesn’t know or care where a tool comes from.

The work happened in a few steps:

feat(mcp): scaffold iAssistMCP — MCP tools as AgentTools (TDD seam)
feat(mcp): MCP server support — window, install, stdio, policy, reconnect, timeout
refactor(mcp): generalize transport behind MCPConnection; add HTTP connection
feat(mcp): auto-refresh tools on tools/list_changed — no manual reconnect

Three details were harder than they look:

Async connect, sync tools. Connecting to a server is slow (spawn a process, handshake). But a turn asks for its tool list synchronously. The MCPHost connects ahead of time and hands out a snapshot of ready tools. A turn that starts mid-connect simply doesn’t have that server’s tools yet, and gets them on the next turn.

Cancelled connects. If you disable a server while it’s still connecting, the slow connect must not publish itself afterwards. Each server has a generation counter, bumped on every connect and disconnect, so a superseded connect knows to back off.

The GUI app PATH problem. Many MCP servers are launched via npx or uvx. A Mac app started from the Dock doesn’t inherit your shell’s PATH, so those commands aren’t found. The host copies the login shell’s PATH into the environment of spawned servers. The add-server dialog also explains the difference between a wrong path and a command that isn’t on PATH.

Every Token Counted

The metering seam from Part 3 feeds a SwiftData store in iAssistMetrics. Every model call records model, tokens and duration. The app shows:

Answers as Documents, Not Just Text

Model answers are markdown. Rendering them well turned into its own pair of packages:

The most ambitious step was Rich HTML rendering: an HTML “shaper” turns an answer into a magazine-style page with CSS and SVG diagrams, shown in a WebView beside the transcript and exportable as PDF.

Sandboxing the WebView

Rendering model-generated HTML is a security problem. The model’s output may contain content from files it read, including secrets. The render pane disables JavaScript and applies a strict Content Security Policy, so the page can’t fetch anything.

An adversarial review still found a hole, and the commit message describes it precisely:

HIGH: gate WKWebView navigation to the initial in-memory (about:blank) load only,
cancelling link clicks, client redirects, and <meta http-equiv=refresh>.
Without it the model's HTML could exfiltrate context data (file contents,
secrets) by refreshing to https://attacker/?d=<payload>, no JS required.

A <meta http-equiv="refresh"> tag needs no JavaScript. The fix allows only the initial in-memory load and also strips meta refresh tags as defense in depth.

Lesson: Whatever the model produces is untrusted input, including HTML you asked it to write.

Small Things That Make It Feel Native

A lot of commits are about feel, and they matter for a tool you use all day:

Packaging a SwiftPM App

The app is built with Swift Package Manager, not an Xcode project. A small script turns it into a real .app:

Packaging/make-app.sh:
  1. swift build -c release
  2. assemble Parto.app (Info.plist, Parto.icns, the binary)
  3. auto-detect an Apple Development signing identity (ad-hoc fallback)
  4. codesign, then codesign --verify

The icon is generated from an SVG by a Swift script into all Retina sizes. It uses the Congruent Tech congruence mark in cyan. TestFlight and notarization need an Apple Distribution certificate and an archivable Xcode target, so those are the next distribution steps.

Key Takeaways

In the final part we cover syncing history across devices with CloudKit and hybrid logical clocks, the strategy behind Parto, and what we learned overall.