On this page
On this page
macOS companion app
macOS dev setup
macOS developer setup
Build and run the OpenClaw macOS application from source.
The packaged app requires macOS 26.2 or later. The build host must also meet the Xcode requirements below.
Prerequisites
- Xcode 26.4+ (Swift 6.3 toolchain), on the latest macOS available in Software Update.
- Node.js 24.16+ or 26.1+ & pnpm for the gateway, CLI, and packaging scripts.
SwiftPM and Xcode generate the Gateway protocol models with the first
supported Node on
PATH, then/opt/homebrew/bin/nodeor/usr/local/bin/node. Version-manager shims such as mise, asdf, or Volta resolve to the Node binary they launch.
macOS shell tooling uses the system /bin/bash (3.2); Homebrew Bash is not
required. Run scripts directly or with /bin/bash. Bash 5.3+ can stall on a
heredoc before its reader starts, leaving packaging or signing logs empty under
pipe-buffer pressure. Portable script entrypoints switch to /bin/bash on
macOS, and the streamed installers (curl ... | bash) do the same by capturing
the rest of their input into a private temporary file before re-executing, so
the documented install commands need no change.
1. Install dependencies
2. Build and package the app
Outputs dist/OpenClaw.app. By default this is a debug-configuration build
with the development bundle identifier ai.openclaw.mac.debug, meant to run
beside an installed release. To replace a release install, see
Replace an installed release app.
Packaging requires a real signing identity by default and fails if none is
available. Ad-hoc signing is an explicit opt-in; it does not preserve TCC
permissions. See macOS signing.
Packaging builds the JavaScript runtime and Control UI, then stages the full
standard package with production dependencies under
Contents/Resources/runtime/lib/node_modules/openclaw. It retains the published
package's files filter, including its CLI, Gateway, Control UI, npm, and
optional sqlite-vec; on-demand plugins excluded from that package remain
excluded. The root tarball uses the repository-pinned pnpm packer; Corepack-only
setups are supported. scripts/stage-mac-runtime.sh installs the package with
build-time Node and npm, then stages Bun and SQLite. No Node executable or
npm/corepack/npx shims ship in the app.
The OpenClaw Bun fork has one shared pin in scripts/lib/openclaw-bun.json,
consumed by macOS packaging, the Tauri app, and CI's setup-test-bun action.
scripts/stage-openclaw-bun.sh <runtime> <darwin|linux> <arm64|x64> [...]
downloads it; Darwin accepts both architectures for a universal binary.
Archives are cached under .cache/openclaw-bun/<tag>/. Staging verifies the
release manifest against SHA256SUMS, its identity and artifact fields against
the pin, each archive against both sources, then the executable checksum,
native architecture, and runnable fork revision. The fork's package auto-install
default stays off; OpenClaw's runtime admission still applies at launch.
scripts/build-mac-sqlite.sh builds
the pinned amalgamation in scripts/lib/sqlite-macos.json, cached under
apps/macos/.build/sqlite/<version>/. The resulting signed library supports
SQLite extensions without relying on Apple's system SQLite or Homebrew.
Packaging verifies the Bun revision, CLI version, Gateway help, SQLite and
sqlite-vec loading, native capabilities, and worker readiness in temporary
state before and after signing, then replaces the previous app. It also rejects
any Node executable in the app. scripts/restart-mac.sh uses the same path;
SKIP_TSC=1 does not bypass the runtime build. Existing content-checked build
caches still avoid unnecessary declaration work.
The private worker preserves the app's saved desktop-sharing preference and profile selection. Packaging checks startup with sharing enabled, disabled, and unspecified, including named-profile launches, before and after signing.
Set OPENCLAW_NODE_VERSION=<version> when packaging to select a supported Node
version for the temporary package installation. If unset or empty, the CLI
installer's default applies. This build-time override does not change the
bundled Bun pin.
The runtime shares one JavaScript package across requested BUILD_ARCHS
architectures. Universal builds combine Bun and SQLite into universal binaries
and retain the separate arm64 and x64 Darwin native packages. Staging installs
each CPU's optional dependencies separately, merges matching shared files, and
combines distinct Mach-O slices at shared paths. Other shared-file conflicts
fail packaging. Single-architecture
builds retain only their compatible native payloads. Linux and Windows prebuilds
are removed; compatible JavaScript, WASM, and other resources remain intact.
Universal staging on Apple silicon requires Rosetta for x64 dependency install
hooks, which execute native code using a temporary x64 Node driver.
Packaging executes verification for each runnable architecture. Verifying
x86_64 on Apple silicon requires Rosetta; without it, packaging reports that
architecture's execution checks as skipped. Missing binary architectures or
nonportable native dependencies fail packaging. Downloads and package
installation need network access on the build host. A packaged app needs no
runtime download during onboarding: it seeds that payload into the profile's
state directory and hosts the Gateway with Bun. The stage script writes the
package's openclaw-install-owner.json marker so Gateway updates remain owned
by the app.
Packaging builds the MLX voice helper with Swift Build (--build-system swiftbuild)
and copies its SwiftPM resource bundles into Contents/Resources. The native
SwiftPM backend does not compile MLX's Metal shaders. Packaging fails if the
helper's mlx-swift_Cmlx.bundle/Contents/Resources/default.metallib is missing,
rather than shipping a helper that fails on its first speech request.
The private worker uses read-only core config bootstrap rather than Gateway-wide Doctor preflight. Node plugin validation, MCP lifecycle, and node-owned identity and exec-approval startup migrations remain enabled. See Gateway ownership for the boundary.
Set OPENCLAW_SKIP_MLX_TTS=1 to package a dev/proof build without the local
MLX voice helper. This skips the openclaw-mlx-tts binary and its large
mlx-swift Metal shader stack, which some beta Xcode toolchains cannot compile.
The resulting app has no on-device MLX voice; it is rejected for release
builds, which must ship the helper.
For dev run modes, signing flags, and Team ID troubleshooting, see
apps/macos/README.md.
Fast dev loop from repo root: scripts/restart-mac.sh (add --no-sign for
ad-hoc signing; TCC permissions do not stick with --no-sign).
Replace an installed release app
scripts/package-mac-app.sh defaults to BUILD_CONFIG=debug and
BUNDLE_ID=ai.openclaw.mac.debug. Copied over /Applications/OpenClaw.app,
that build is still a different app to macOS and to OpenClaw:
- The debug bundle ID has its own TCC grants and, for the default profile, its
own
ai.openclaw.mac.debugdefaults domain. Packaging clears its Sparkle feed, so it never updates. - Keychain items the release app created, such as
ai.openclaw.tls-pinning, trust only the release app's code signature. Reading them raises login-keychain password prompts. - The debug Swift configuration reads saved Gateway profiles from the separate
ai.openclaw.gateway-profiles.debugKeychain service, so the release app's saved Gateways are missing.
To replace a release install, package the release identity from a clean checkout:
Release configuration runs scripts/apple-release-source-check.sh, which fails
unless the checkout is clean at the commit being built, and it requires the MLX
voice helper. It builds a universal app unless you set BUILD_ARCHS (for
example BUILD_ARCHS=arm64). Before installing, check the designated
requirement:
The output must include identifier "ai.openclaw.mac". Keychain and TCC access
follow the whole requirement, not just the identifier: sign with a Developer ID
Application identity from the same team as the installed app, or macOS still
treats the build as a different app. Sparkle stays enabled in this build, so a
newer published release can replace it.
Keep the default debug identity for development builds that run beside an installed release.
Shared Bun pin and repin gate
The JSON schema has top-level tag, commit, revision, and artifacts.
artifacts is keyed by darwin-arm64, darwin-x64, linux-arm64, linux-x64,
windows-arm64, and windows-x64; each entry contains asset, sha256,
executable, and executableSha256. Windows entries also carry
authenticodeSigned: true and testOnly: false. These are a projection of the
published fork release's manifest.json, not independently maintained app or CI
pins. Unsigned Windows dry-run artifacts are not shippable.
Every repin requires both gates on the same published tag: CI's paired Bun-lane replay and Bun-only smoke, plus the macOS runtime checks and two-binary test set. Neither app nor CI advances when either gate fails. A Linux-only regression also stops the shared repin. Preserve the last tag admitted by both gates while investigating; a published prerelease alone is not admission. Record the exact tag and gate evidence in the PR. See CI runtime selection.
After the gates pass, download manifest.json and SHA256SUMS from that exact
release and verify the manifest and asset checksums. Regenerate all six entries together:
Repin one shared owner in one PR and run the shared stager for all four Darwin/Linux targets; execute native proofs on matching hosts (Rosetta can verify Darwin x64). The signed Windows entries are available for Windows packaging integration. Do not advance an individual artifact or copy the pin into an app or workflow.
3. Install the CLI and Gateway
On a fresh profile in a packaged app, choose This Mac during onboarding.
The app prepares its bundled Bun runtime, starts the Gateway as its child,
and creates the profile's terminal CLI shim. It does not invoke
scripts/install-cli.sh, install Node, or ask for an install channel.
Unbundled DEBUG builds retain the installer and channel chooser.
Runtime copies live at <state>/runtime/<runtimeBuildId>/. App-hosted children
and app-managed Bun LaunchAgents use concrete build paths; only the terminal
shim uses runtime/current. Post-update setup reseeds the payload and refreshes
app-owned Bun service pins before verifying health and collecting old builds.
See Gateway on macOS.
For manual development recovery, install the matching CLI yourself. Read the
version from the app: choose About OpenClaw in the menu bar, or run
openclaw-mac status --json, which reports the app version and build.
The npm command below is for npm 12 or npm 11.16+. On npm 11.15 and earlier,
omit --allow-scripts=openclaw.
pnpm add -g --allow-build=openclaw openclaw@<version> and
bun add -g --trust openclaw@<version> also work. Bun's --trust allows the
OpenClaw lifecycle scripts for that install. Node remains the recommended
runtime for the Gateway itself.
Run native tests safely
Run the full macOS app test suite in a disposable macOS VM or CI worker with
no operator credentials, config, or running Gateway. AppKit tests can show
windows, and WebKit starts helper processes. A temporary HOME, TMPDIR, or
named app profile alone is not a sandbox: fixed preferences domains and
Keychain access can still reach macOS services outside those directories.
The native test bundle links OpenClawWebKitTestSupport, which suppresses WebKit
Screen Time observation for every WKWebView in the test process. WebKit removes
its KVO observer on the main thread during deallocation while ScreenTime delivers
configuration on a private queue. Tearing down a windowed HTTP(S) web view right
after its first commit can hit this race and abort the process with
NSInternalInconsistencyException. Product builds keep Screen Time.
The macos-swift GitHub CI job builds the tests with the runner's normal
SwiftPM caches, then runs the built suite through scripts/test-macos-native.mts.
Each invocation selects private HOME and CFFIXED_USER_HOME,
OPENCLAW_CONFIG_PATH, OPENCLAW_STATE_DIR, and short TMPDIR before any test
bundle loads. Tools honoring TMPDIR use that launcher-owned directory;
Foundation uses Darwin's per-user temp directory, owned and discarded by the
disposable OS worker. The full suite explicitly selects the default profile, preserving
its local Gateway lifecycle contracts. AppState lifecycle tests and the interactive
chat fixture run separately with a unique named profile; no test is run twice. The child environment excludes
inherited app settings and credentials while retaining toolchain and runtime
loader paths. Before Swift starts, the launcher creates an empty-password test
Keychain under its private HOME/Library/Keychains, unlocks it, disables automatic
locking for that resource, and sets the user-domain default and search list to
that file. This lets real catalog migration save without an interactive
login-Keychain creation prompt. Its Security preferences live in the private
HOME/Library/Preferences; common and dynamic Keychain domains remain unchanged.
Resources remain available for process-lifetime singletons and retained windows;
the launcher deletes its Keychain and files after the managed process group and
output pipes close. The disposable runner owns default preferences and system-service
state; named-profile preferences use a fresh domain and are also discarded with
the runner.
If process cleanup cannot be verified or Keychain deletion fails, the launcher
fails and retains its files for inspection.
CI environment markers only catch accidental invocation; they do not enforce
isolation or make an operator desktop safe.
For a focused rerun inside that disposable macOS CI runner, after the normal test build:
The ordinary CI invocation bounds Swift Testing parallelism to the runner's logical
CPU count, capped at 12. It runs three disjoint partitions sequentially with coverage
instrumentation: the default-profile suite, rendered Quick Chat in a fresh default-profile
process, and named-profile fixtures. The rendered partition preserves catalog, disclosure,
and shortcut order without sharing process-wide executor changes from other tests.
Both interactive fixtures use Swift Testing and start an AppKit-owned run loop before
exercising native menus, so XCTest does not have to regain its outer wait loop afterward. Historical targets
with the launcher keep their original default- and named-profile partitions.
Local scripts/prepush-ci.sh runs Swift lint/format checks and a release
build, but does not run native tests. For native changes it exits nonzero with a
requirement to obtain the exact commit's macos-swift CI result; local build
success is not native test success.
On an operator desktop, run only an audited subset inside an OS sandbox that blocks operator files, preferences and Keychain services, unwanted network access, and desktop/helper processes. A Swift test filter is not itself an isolation boundary. If that boundary cannot be established, use the disposable macOS environment instead.
Tests should own their resources: unique defaults suites with cleanup,
nonpersistent WebKit data stores, ephemeral loopback fixture endpoints, and
temporary files rooted in FileManager.temporaryDirectory. Unix-domain socket
fixtures require a short test-owned path there; an overlong path fails rather
than silently writing outside that directory. The cooperative TestIsolation helper
serializes and restores participating tests' environment and selected defaults
mutations. Config-only scopes also own a temporary state directory for config
health/audit writes and remove it when the async body finishes, including errors.
Callers still own their config fixture files and must join any async work before
leaving the scope. These unique fixture directories are cleaned by their owners;
remaining Foundation temporary files are discarded with the worker, not the
launcher's root. Never change OPENCLAW_PROFILE inside a test: AppProfile
and AppDefaults freeze their identity for the process. Tests needing another
singleton identity require a fresh process. The cooperative helper does not
isolate unrelated tests or the process from the host.
Swift Testing runs suites concurrently in one process, so never replace the
global executor (for example withMainSerialExecutor or
uncheckedUseMainSerialExecutor from ConcurrencyExtras). Its hook moves every
other suite's global jobs, actor work, and timer wakeups onto the main thread;
one non-yielding loop there starves the timeout that would cancel it, and the
job hangs until CI cancels it. To assert that a late reply changed nothing,
await the task that settles that reply, as provider wizard and manual-key
actions return it.
Work a test starts must end with the test, because the process outlives it.
Shut down Gateway channels and connections a test opens; their watchdog
otherwise keeps reconnecting through the test's fixture. Fake transports and
sockets must stop waiting when their read is cancelled, as the production stream
transports do: AsyncTimeout cancels and abandons the operation that loses its
race instead of joining it, so a fake that ignores cancellation keeps polling for
the rest of the run.
Troubleshooting
Build fails while freezing Peekaboo sources
If packaging stops at Freezing authenticated Peekaboo sources in a read-only snapshot,
check the hdiutil error on stderr. Routine image creation and attachment output stays
quiet, but failures such as hdiutil: attach failed - Permission denied are preserved.
Snapshot images and build outputs stay in the checkout. Their read-only mount directories
use macOS's per-user temporary location (getconf DARWIN_USER_TEMP_DIR), independently of
TMPDIR, so an external checkout does not need to support nested mounts. If attachment
still fails, check that location's mount permissions. Unverified cleanup retains the
mount directories and build locks at the paths printed in the error. This step runs before
signing; source verification and the read-only snapshot remain required.
Build fails: toolchain or SDK mismatch
The macOS app build expects the latest macOS SDK and the Swift 6.3 toolchain (Xcode 26.4+).
If versions don't match, update macOS/Xcode and re-run the build.
Build fails: MLX voice helper Metal shaders
On a beta-only Xcode toolchain (for example Xcode 27 with the macOS 27 SDK),
only the openclaw-mlx-tts helper may fail while the main app builds fine. The
mlx-swift Metal compilation fails unpredictably (a different .metal
file each run, Could not read serialized diagnostics file then a nonzero
metal exit), because the beta metal compiler and its separately downloaded
Metal Toolchain are still unstable. This is an upstream toolchain issue, not an
OpenClaw one.
If you do not need on-device MLX voice, skip the helper:
Otherwise, install the Metal Toolchain
(xcodebuild -downloadComponent MetalToolchain) and build from a stable Xcode
release.
App crashes on permission grant
If the app crashes when you try to allow Speech Recognition or Microphone access, it may be a corrupted TCC cache or signature mismatch.
-
Reset TCC permissions for the debug bundle id:
bash -
If that fails, temporarily change
BUNDLE_IDinscripts/package-mac-app.shto force a clean slate from macOS.
Gateway "Starting..." indefinitely
Check whether a zombie process holds the port:
If a manual run holds the port, stop it (Ctrl+C), or kill the PID found above as a last resort.