[KongchangAI]
· 4 min read· 2,070 words

Building a Multi-Agent Simulator with SwiftUI: Zero-API-Key AI Social Simulation

Building a Multi-Agent Simulator with SwiftUI: Zero-API-Key AI Social Simulation

Sorcerer uses SwiftUI to call the user's logged-in CLIs, enabling multi-agent social simulation with zero keys.

Developer gbrasil720's open-source macOS app Sorcerer implements multi-agent social simulation purely in SwiftUI: users pose a hypothetical question and summon a cast of characters to watch them post, reply, take sides, and change stances. The core trick is driving the user's already-logged-in claude, codex, or Ollama CLIs instead of embedding a model or requiring API keys, shifting inference costs to existing subscriptions. Simulation rules are pure functions with apply validation, facts are split into public/insider/hidden tiers, and a hand-written scanner parses streaming JSON. The author notes simulation cannot replace real-world evidence.

Building a Multi-Agent Simulator with SwiftUI: Zero-API-Key AI Social Simulation

Sorcerer app interface

What if a group of AI characters discussed, argued, and changed their stances in a virtual community? Developer gbrasil720 built a macOS app called Sorcerer for this purpose—written purely in SwiftUI, open-sourced under the MIT license. Users simply input a "what if…?" question, summon a cast of characters, and watch them post, reply, take sides, and even "defect" round by round.

The most interesting aspect of this project isn't the "multi-agent" concept itself, but how it sidesteps the common engineering quagmires of LLM applications in an extremely lightweight way: no API keys, no self-hosted inference backend, no third-party dependencies. The author breaks down the complete implementation in a technical blog post, and this article distills the design decisions worth learning from.

No API Keys: Driving Already-Logged-In CLIs Directly

The core trick of Sorcerer is that it doesn't embed any model or require users to fill in an API Key. Instead, it calls command-line tools already installed and logged in on the user's computer—claude (Claude Code), codex (ChatGPT), or local Ollama. This means inference costs are shifted to the user's existing subscription, and the app itself incurs zero inference overhead.

The first engineering obstacle is typical: a macOS app launched from Finder cannot access the PATH environment variable from the terminal, causing the claude command to report "command not found." The author's solution is to launch an interactive zsh (zsh -ilc) once to query $PATH and cache it, with a 5-second timeout added to prevent the shell from hanging due to plugins or prompts. This detail may seem trivial, but it's a pitfall every "GUI app calling local CLIs" will hit.

For different providers, each backend is just a switch branch. Claude runs with no tools, no session persistence, and no user configuration, using the stream-json output format—because this format carries both the subscription's 5-hour and weekly usage windows, allowing the app to proactively pause before the user hits the limit. Codex uses codex exec --ephemeral -s read-only, while Ollama sends a streaming POST request to localhost:11434.

In zsh -ilc, -i means interactive mode, -l means login mode, and -c tells the shell to execute the command string that follows. A login shell reads configuration files like /etc/zprofile, ~/.zprofile, and ~/.zshrc in sequence, and the PATH additions from user-installed Node, Homebrew, or npm global packages take effect in these files. GUI apps launched from Finder or Dock on macOS inherit a minimal environment provided by launchd and don't go through any shell initialization, so claude that works in the terminal can't be found in the GUI. Running echo $PATH once with a login interactive shell is equivalent to "asking the shell to tell me the path according to the user's real configuration," and then caching the result for the app to use—this is the common approach for GUI tools that wrap CLIs. The timeout is added because .zshrc often contains time-consuming logic like version managers and prompt frameworks, or even commands waiting for input, which could hang indefinitely without a limit.

Characters as Data Structures, Rules as Pure Functions

A character in Sorcerer is a plain Codable struct Persona, containing handle, role, bio, traits dictionary, interests, goals, mood (attitude toward the hypothesis, -1 to 1), memories (one line written by the model each round), ties (a mapping from handle to relationship type, such as "colleague" or "competitor"), and an insider flag. The relationship network determines how influence propagates, while memories provide continuity across rounds.

What truly makes this project testable is the design philosophy of Engine.swift—the file header comment states outright: "Prompts and parsing. Pure functions, so simulation rules can be tested without the model." Each round, roundPrompt hands the entire world state to the model: the cast (including role, mood, relationships, the last 4 memories, and the previous action), historical events, content injected this round, the latest 24 posts (each with a stable ID like A3 meaning the third post of branch A), and what each character "can know."

The model plays all characters and must return only JSON, containing posts (author, reply target, text, mood), reactions (agree/disagree counts for a post), and memories. The prompt guides realism: not everyone speaks every round, opinions drift gradually, replies more often come from people with relationships, and quiet characters still express themselves through agree/disagree reactions.

The key is that the apply function doesn't trust the model output but validates it: discarding unknown authors, clamping mood values, requiring actions to be within the experiment's allowlist, allowing only those who left to "return," and permitting at most one invitation per round. Since it's pure code, swift test can cover all rules without calling the model.

A pure function is one whose output depends only on its inputs, doesn't modify external state, and produces no side effects—the same world state necessarily yields the same validation result. In Sorcerer, apply receives the JSON returned by the model and the current world state, and returns a brand-new, sanitized state object rather than modifying the old object in place, which allows each simulation step to be replayed and compared. Combined with Swift's Codable protocol, the model's JSON output can be decoded directly into strongly-typed structs, and field type mismatches surface at the decoding stage rather than silently corrupting state. swift test uses the XCTest framework to construct several hand-written model output samples and assert that illegal authors are discarded, moods are clamped between -1 and 1, and actions are restricted to the allowlist. This layering of "rules in code, model only provides raw material" means that when swapping models or adjusting prompts, the legality baseline of the simulation doesn't drift—a common paradigm for incorporating unreliable generative components into testable systems.

Who Knows What: Three-Tier Knowledge Boundaries

Sorcerer lets users point to a GitHub repository (via their own gh login), folder, website, or file to generate a brief, where each fact is tagged as public, insider, or hidden. The knowledge() function uses this to build a knowledge section for each prompt: insider facts are delivered only to characters marked as insiders, hidden facts are visible to no one, and it ends with "characters never reference facts they couldn't possibly know."

Privacy boundaries are equally clear: when reading source files, only documentation is read—.env and key files are never opened, and characters see the brief rather than the user's code. This design is especially important for enterprise internal simulations or product feedback scenarios—it turns "information isolation" into a structural constraint beyond prompt engineering.

Streaming Parsing of Half a JSON

To display "@wren is typing…" in the UI and have text emerge character by character, Sorcerer parses while the JSON is still streaming. The partialRound function is a hand-written mini-scanner: it locates "posts", tracks brace depth and string escapes, decodes each closed {…} as a completed post, and extracts author and partial text from the currently unclosed object. The comment reads "never throws: half a JSON is the norm here."

This is the most thought-provoking technical point in the article—the experience bottleneck of streaming UI is often not model speed, but whether you dare to render when data is incomplete. Claude (with --include-partial-messages) and Ollama support streaming; Codex does not yet, and this has been publicly opened as issue #21 seeking contributions.

Streaming output from LLM APIs typically returns text token by token or in small chunks, and the caller receives a growing byte stream rather than a complete response body. If you wait until generation is complete to parse, users face a blank screen for several seconds; parsing as you receive enables typewriter effects and progress indicators. The difficulty is that JSON is a structured format, and any truncation point may fall in the middle of a string, after an escape character, or inside a nested object, causing standard parsers to error out. Hence a hand-written incremental scanner is needed: maintaining a brace depth stack, recognizing escape states for " and \\, recording the current field name, and only attempting to decode a complete object when a matching closing brace is encountered. Claude's --include-partial-messages and Ollama's streaming interface both provide this incremental output, while Codex currently returns only the final result—which is why this feature is still pending.

Branching, Rewinding, and "Counting Rather Than Guessing"

The simulation world supports forking: fork() copies the current branch (same cast, same memories), assigns a new letter name, and records a parent marker like "from B at r3." To branch from a past round, rewind(_:to:start:) filters posts, events, actions, and votes, removes people who joined later, removes memories tagged for subsequent rounds, and reconstructs each person's mood from the posts. The author acknowledges a trade-off: agree/disagree counts are totals, so reactions from later rounds linger on old posts.

The report system deliberately distinguishes "model judgments" from "world counts." If six people answer a poll, the report shows "would you pay $14 · no · 3 / 6" from real answers; action statistics count only each person's latest action, so "escalate" followed by "cancel" counts only as one cancel. This distinction gives the simulation output a degree of auditability.

Tech Stack and How to Contribute

The app side is pure Swift + SwiftUI + Swift Package, with zero third-party dependencies—swift run to run, swift test to test. The landing page uses a Bun + Turbo monorepo (React, TanStack Router, Tailwind, Hono, tRPC), deployed on Cloudflare Pages. The release process is to push a vX.Y.Z tag, and CI handles testing, building a universal DMG, and publishing.

The author has opened several well-scoped issues, including non-default Ollama hosts, VoiceOver accessibility labels, new experiment templates, unit tests for Ollama stream parsing, CI integration of Biome and landing page builds, removing leftover scaffold modules, and the most-watched Codex streaming reply support. He also suggests testing with Ollama to avoid consuming Claude or ChatGPT quota.

Worth noting is the author's own restrained statement: simulations reveal possible reactions and cannot replace real-world evidence. This sentence should become the default disclaimer for all multi-agent social simulation products—their value lies in exploring the reaction space, not predicting truth.

Swift Package Manager is Apple's official dependency management and build tool, declaring targets and dependencies via Package.swift. swift run directly compiles and runs executable targets, and swift test runs test targets—daily development and CI builds can be done without opening an Xcode project. Zero third-party dependencies means a minimal supply chain surface and avoids network and version conflicts when SPM resolves remote packages. On the landing page side, Bun doubles as runtime, package manager, and bundler; Turbo handles task orchestration and caching in the monorepo; TanStack Router provides type-safe routing; Hono is a lightweight web framework; tRPC lets frontend and backend share type definitions without hand-writing interface contracts; and Cloudflare Pages handles static and edge deployment. The shared orientation of both tech stacks is "fewer dependencies, strong typing, fast builds," consistent with the desktop side's engineering philosophy.

Key Takeaways

  • Achieves zero-API-key, zero-self-hosted-backend multi-agent simulation by calling the user's already-logged-in CLIs (claude, codex, Ollama)
  • All simulation rules live in pure functions, and model output must pass apply validation, making them unit-testable without the model
  • Three-tier knowledge boundaries (public/insider/hidden) isolate information structurally while avoiding reading sensitive files
  • Streaming parsing of "half a JSON" supports the character-by-character typing UI experience—a practical paradigm for streaming rendering
  • Reports distinguish model judgments from world counts, acknowledging the limits of simulation rather than packaging it as a prediction tool

One-line Summary

Sorcerer uses SwiftUI to call the user's already-logged-in CLIs, achieving multi-agent social simulation with zero keys.

Paragraph Summary

Developer gbrasil720's open-source macOS app Sorcerer implements multi-agent social simulation purely in SwiftUI: users pose a hypothetical question and summon a cast of characters, then watch them post, reply, take sides, and even change stances round by round. The core trick is not embedding a model or requiring an API key, but driving the user's already-logged-in claude, codex, or Ollama command-line tools, shifting inference costs to existing subscriptions. Engineering-wise, all simulation rules are pure functions, and model output must pass apply validation, making them unit-testable without the model; facts in the brief are split into public, insider, and hidden tiers for structural information isolation; a hand-written scanner parses unclosed objects during JSON streaming to support character-by-character display. Reports distinguish model judgments from world counts, and the author explicitly states that simulation cannot replace real-world evidence.

#SwiftUI#multi-agent simulation#zero API key#Claude Code#Ollama#streaming JSON parsing
Share:

Related articles