| name | chorus-ios |
| description | Build, preview, sign, install, and publish iOS apps from Codex using the Vibecode/Chorus signing CLI. Use when the user wants to build an iOS app from source, create a simulator preview, sign an iOS app, install on their iPhone, register a device, set up Apple Developer auth, publish to the App Store or TestFlight, submit for review, or work with the iOS build/sign/distribute/publish pipeline. Also triggers for "test on device", "put this on my phone", "ship it to my iPhone", "publish to App Store", "submit my app", "push to TestFlight", "send for review", or any request to get a Swift/iOS app running on a real device or shipped to App Store Connect. |
iOS App Build, Sign, Install & Publish
Build Swift apps, sign them with Apple credentials, install on iPhones via OTA, and publish to App Store Connect (production review or TestFlight).
Codex operating model
Codex usually starts in the user's workspace, not this skill directory. Before running any command, define the skill-local CLI path:
export CHORUS_IOS_SKILL_DIR="${CODEX_HOME:-$HOME/.codex}/skills/chorus-ios"
export IOS_CLI="$CHORUS_IOS_SKILL_DIR/ios-cli"
test -x "$IOS_CLI" || chmod +x "$IOS_CLI"
If a new shell command does not preserve environment variables, rerun those exports or include them in the same command. Do not run a bare ios-cli or project-local $IOS_CLI; use the skill-local $IOS_CLI.
Do not assume this Codex environment has the same long-lived state as a persistent machine. Before auth-sensitive work, verify what is available:
$IOS_CLI config path
$IOS_CLI config get
Prefer the iOS-service key saved in ~/.vibecode/ios/config.json. If login is required, ask for the key only when needed, run $IOS_CLI login <api-key>, and do not echo secrets back to the user. The login command validates the key against the iOS service before saving it; if the service rejects the key, do not keep retrying builds with that key.
In this macOS Codex environment, $IOS_CLI is a wrapper. It uses the native Python CLI for skill, config, login, auth, build, sim-preview, sign, sign --from-sim, devices, register-apple, status, logs, bootstrap, screenshots, and publish. The downloaded Linux CLI is preserved as ios-cli-linux-x64 for Linux runners, but Codex should use the wrapper path above.
When a build or sim-preview command returns previewUrl, open that URL in the Codex in-app Browser so the user can see the app without leaving Codex. Also include the URL in the final reply.
CRITICAL RULES
- NEVER use Xcode, xcodebuild, or xcrun. You do not have Xcode access. ALL building is done through the cloud build pipeline via
$IOS_CLI.
- NEVER make raw HTTP/curl calls to the signing service. Always use
$IOS_CLI. It handles authentication, polling, error handling, and output formatting.
- Every iOS build request MUST go through this skill. When a user says "build an app", "test on my phone", "put this on my iPhone", "ship it", or anything about getting an iOS app running — use this skill exclusively.
- Before writing any app that uses a capability or extension, READ references/capabilities.md. It has per-capability rules for entitlement files, Info.plist keys, runtime code, and manual portal steps. Skipping it causes silent install/runtime failures that waste build cycles. Also read references/gotchas.md for Xcode 26 compile fixes.
Environment
The CLI reads these environment variables when present, but Codex should verify them instead of assuming runner injection:
VIBECODE_API_KEY — Authentication. Required. When unset (e.g. running locally), persist it once with $IOS_CLI login <api-key> and the CLI reads it from ~/.vibecode/ios/config.json on subsequent invocations.
SIGNING_SERVICE_URL — Service URL (auto-detected, defaults to https://ios-service.vibecodeapp.com)
VIBECODE_PROJECT_ID — Project / agent identifier. Optional for build — when unset, the CLI mints a UUID once and persists it in the config file so subsequent local builds share the same project namespace. Required for sim-preview.
VIBECODE_USER_ID — Optional. Defaults to the user resolved from the active API key. Set this (or pass --user <id>) to target a non-default signing user, or to tie a build to a specific publishing user (see references/publishing.md).
Flow at a glance
Default: Build, then create/open a simulator preview. If build returns previewUrl, use it directly. If it only returns buildJobId, run $IOS_CLI sim-preview <buildJobId> and open the resulting previewUrl in the Codex browser. No Apple auth, device registration, or signing unless the user asks for device install.
On-demand device install: If the user explicitly asks to install on device, or provides a message like Install this build on my device. (build: <simBuildId>), run the Install-on-Device callback below. That flow walks through any missing setup (auth, register, sign) and prints the install URL.
This install message is the only signal that should make the agent run auth/register/sign. Don't run those steps preemptively after a build — defer until the user explicitly asks to install.
CLI
The $IOS_CLI binary is in this skill directory. Run $IOS_CLI --help for full usage, or $IOS_CLI skill to print this document.
Output Modes
Controlled by --output global flag:
--output text (default) — logfmt key="value" pairs, one line per result. Designed for grep and cut.
--output json — Single JSON object per invocation. Designed for jq and programmatic parsing.
--quiet — Print only the primary identifier (ID or URL). For scripting and piping.
Long-running commands print bracketed event markers to stderr:
[building], [signing], [done], [error]
Errors: ERROR: message on stderr (text mode) or {"error":"message","code":"ERROR_CODE"} on stdout (JSON mode). Exit code 1.
Commands
| Command | Description |
|---|
$IOS_CLI login <api-key> | Persist a vibecode API key locally so subsequent commands work without env vars (no-op inside Chorus, where the env var is already injected) |
$IOS_CLI auth start --username <email> --password <pass> | Start Apple ID auth (returns sessionId) |
$IOS_CLI auth apikey [--user <id>] --issuer-id <id> --key-id <id> --p8-key <path> --team-id <id> | Auth with App Store Connect API key. --user is optional; defaults to the API-key owner. |
$IOS_CLI auth status <sessionId> | Poll auth session state |
$IOS_CLI auth respond --session <id> --value <code> | Submit 2FA code or team selection |
$IOS_CLI build <zip-path> | Upload source zip, build on cloud macOS, wait until done |
$IOS_CLI sim-preview <buildJobId> | Create/re-mint a browser simulator preview for an existing build job. |
$IOS_CLI sign <buildJobId> | Sign a built app, wait until done, returns install URL |
$IOS_CLI sign --from-sim <simBuildId> | Resolve a sim-preview's underlying build job server-side and sign it for device install. |
$IOS_CLI devices [userId] | List registered devices |
$IOS_CLI register-apple [userId] | Sync pending devices with Apple |
$IOS_CLI status build <jobId> | Check build job status |
$IOS_CLI status sign <buildId> | Check signing status |
$IOS_CLI logs <buildJobId> | Fetch build logs — mid-build for status, after failure for errors |
$IOS_CLI bootstrap <app-name> <bundle-id> <output-dir> | Create new SwiftUI project from template |
$IOS_CLI config get | Print current config |
$IOS_CLI config set <key> <value> | Set a config value (supports dot notation) |
$IOS_CLI config path | Print config file path |
$IOS_CLI skill | Print this skill reference |
[userId] is optional — when omitted the CLI uses the user resolved from the active API key.
Error Codes
If a command fails (exit code 1), check the error code:
| Error Code | Meaning | What To Do |
|---|
MISSING_API_KEY | No API key in env or config | Run $IOS_CLI login <api-key> or set VIBECODE_API_KEY. |
MISSING_ENV | Required env var not set | Ensure VIBECODE_PROJECT_ID is available. |
MISSING_ARG | Required command argument missing | Check command usage with --help. |
MISSING_FLAG | Required --flag not provided | Check command usage with --help. |
UNKNOWN_COMMAND | Unrecognized command or subcommand | Run $IOS_CLI --help to see available commands. |
CONNECTION_FAILED | Cannot reach the signing service | Check SIGNING_SERVICE_URL. Service may be down. Retry after a few seconds. |
UNAUTHORIZED | Invalid or expired API key (401) | Check VIBECODE_API_KEY. The key may have been revoked or rotated. |
FORBIDDEN | Access denied (403) | The API key doesn't have permission for this operation. |
NOT_FOUND | Resource not found (404) | The userId, buildJobId, sessionId, or buildId doesn't exist. Verify the ID. |
CLIENT_ERROR | Other client error (4xx) | Check the error message for details. |
SERVER_ERROR | Server error (5xx) | The signing service had an internal error. Retry. If persistent, report the issue. |
BUILD_FAILED | Cloud build failed | Fetch Xcode errors with $IOS_CLI logs <jobId>. Common causes: missing scheme, Swift compiler errors. |
BUILD_NOT_READY | Build hasn't finished yet | Wait for build to complete. Check with $IOS_CLI status build <jobId>. |
SIGN_FAILED | Code signing failed | Usually means no registered devices or expired Apple credentials. Re-authenticate and register devices. |
NO_APPLE_AUTH | Server says user has no usable Apple credentials | Run the auth flow (Step 1 of First-Time Setup). |
SIGN_IN_PROGRESS | A sign for the same build is already in flight | Wait 10–30s and retry; don't start another sign. |
UNEXPECTED_ERROR | Unknown/unhandled error | Check the error message. May be a bug — retry or report. |
Auth-specific errors:
auth start returns CONNECTION_FAILED → signing service may be down
auth status returns state="auth_failed" → wrong credentials or Apple blocked the login. Try API key auth instead.
- 2FA code rejected → ask user for a fresh code, they expire quickly
Output Examples
$IOS_CLI devices c906084e-...
$IOS_CLI --output json devices c906084e-...
$IOS_CLI --quiet devices c906084e-...
$IOS_CLI build /tmp/source.zip
Chaining Commands
The full build → sign → install flow:
$IOS_CLI build /tmp/source.zip
$IOS_CLI sign abc123
With quiet mode for scripting:
BUILD_JOB_ID=$($IOS_CLI --quiet build /tmp/source.zip)
INSTALL_URL=$($IOS_CLI --quiet sign "$BUILD_JOB_ID")
echo "Install your app: $INSTALL_URL"
State
All state lives at ~/.vibecode/ios/config.json. Schema: config-schema.json.
!cat ~/.vibecode/ios/config.json 2>/dev/null || echo "No config found — run first-time setup."
Routing
Default path (no auth required): Always follow Normal Flow below for any build request. Build, print URL. Done.
Install-on-Device callback: When chat receives a message matching Install this build on my device. (build: <uuid>), follow the Install-on-Device callback section. That's the only path that invokes auth/register/sign — and it's only invoked by the user clicking "Install on device" on the preview UI.
Do NOT preemptively run the First-Time Setup flow after a build. The user might never want to install on device — only previewing in the simulator. First-Time Setup is a sub-routine called from the Install-on-Device callback when prereqs are missing.
First-Time Setup
Walk the user through each step. Update ~/.vibecode/ios/config.json after each one. Only run these steps when the Install-on-Device callback says a prerequisite is missing.
Step 0: Pre-requisites
The user must be enrolled in the Apple Developer Program ($99/year). If they are not, they cannot use this skill. Begin by asking them if they are enrolled. If not, guide them on how to enroll.
Step 1: Authenticate with Apple
Ask for the user's Apple ID email and password. Explain:
"Your email and password are sent once to Apple to authenticate. They are not stored — only a session token is saved on the signing service."
Use the password auth flow:
$IOS_CLI --output json auth start --username "user@example.com" --password "their-password"
$IOS_CLI --output json auth status <sessionId>
$IOS_CLI auth respond --session <sessionId> --value "123456"
$IOS_CLI --output json auth status <sessionId>
$IOS_CLI auth respond --session <sessionId> --value "1"
If the password flow fails, fall back to API key auth:
"The password login didn't work. You can use an App Store Connect API key instead. Go to App Store Connect > Users and Access > Integrations > Keys to create one."
$IOS_CLI auth apikey \
--issuer-id <issuerID> \
--key-id <keyID> \
--p8-key /path/to/AuthKey_XXXX.p8 \
--team-id <teamId>
After auth succeeds, save to config:
mkdir -p ~/.vibecode/ios
Write config.json with activeUser, users.{userId} containing appleId, teamId, teamName.
Step 2: Register Device
Check if any devices exist:
$IOS_CLI --output json devices <userId>
If no devices (empty devices array in JSON output):
- Give the user the
registrationUrl from the JSON output
- Tell them to open it on their iPhone and tap "Register Device"
- They'll download a profile — guide them: Settings > General > VPN & Device Management > install the profile
- After success page appears, sync with Apple:
$IOS_CLI register-apple <userId>
Also remind them to enable Developer Mode:
Settings > Privacy & Security > Developer Mode > toggle ON > restart when prompted.
Step 3: Sign
Run $IOS_CLI sign <buildJobId>. If the user came from a simulator preview and only provided a simBuildId, run $IOS_CLI sign --from-sim <simBuildId>.
Normal Flow
The default path. No auth, no register, no sign. Build first and return the identifiers the current CLI actually produced.
1. Generate App Icon
Before building, generate an app icon and overwrite the bootstrap template's placeholder at Assets.xcassets/AppIcon.appiconset/AppIcon.png. Read the source code you just wrote and identify what makes this app's value unique — not its category. Pick the one visual element that would make someone understand what the app does at a glance.
Use Gemini CLI's nanobanana /icon command:
/icon "App icon design for [app description]. [Visual element] with subtle 3D depth. Premium quality, sophisticated, single focal point, subtle lighting" --sizes="1024" --type="app-icon" --style="modern" --corners="sharp"
Always include: Premium quality, sophisticated, single focal point, subtle lighting. You can add the app's color scheme from the source code if it has one.
Examples:
/icon "App icon design for streak habit tracker. Minimalist progress rings with subtle 3D depth. Premium quality, sophisticated, single focal point, subtle lighting" --sizes="1024" --type="app-icon" --style="modern" --corners="sharp"
/icon "App icon design for surf forecast app. Ocean wave curling with subtle 3D depth. Premium quality, sophisticated, single focal point, subtle lighting" --sizes="1024" --type="app-icon" --style="modern" --corners="sharp"
/icon "App icon design for split expense tracker. Two overlapping coins with subtle 3D depth. Premium quality, sophisticated, single focal point, subtle lighting" --sizes="1024" --type="app-icon" --style="modern" --corners="sharp"
Copy the generated PNG into the asset catalog:
mkdir -p "{project}/Assets.xcassets/AppIcon.appiconset"
cp [generated-icon-path] "{project}/Assets.xcassets/AppIcon.appiconset/AppIcon.png"
cat > "{project}/Assets.xcassets/AppIcon.appiconset/Contents.json" << 'EOF'
{"images":[{"filename":"AppIcon.png","idiom":"universal","platform":"ios","size":"1024x1024"}],"info":{"author":"xcode","version":1}}
EOF
2. Build
cd /path/to/project
zip -r /tmp/source.zip . -x ".git/*" -x "xcuserdata/*" -x "*.xcuserstate"
$IOS_CLI build /tmp/source.zip
The CLI uploads, builds on cloud macOS, and waits until complete. Outputs buildJobId, state, and appUrl.
If the build fails, fetch the Xcode compilation errors and fix them:
- Run
$IOS_CLI logs <buildJobId> to get the error lines from the build
- Fix the errors in your source code based on what the compiler says
- Re-zip and rebuild:
zip -r /tmp/source.zip . -x ".git/*" -x "xcuserdata/*" -x "*.xcuserstate" && $IOS_CLI build /tmp/source.zip
Do NOT give up after a failed build. Read the errors, fix the code, rebuild. Most build failures are missing imports, type mismatches, or project configuration issues that are straightforward to fix from the compiler output.
Build environment: The build server uses Xcode 26.0.1 on macOS. Builds run with -sdk iphoneos and CODE_SIGNING_ALLOWED=NO. The scheme is auto-detected from the .xcodeproj — for multi-target projects (app + widget extension), ensure the main app scheme is listed first.
Save buildJobId to config under the active project.
$IOS_CLI build emits buildJobId, state, and appUrl; it may also emit simBuildId and previewUrl.
3. Create and Open Preview
If the build output includes previewUrl, open it in the Codex browser immediately. If it does not, run:
$IOS_CLI sim-preview <buildJobId>
Then open the resulting previewUrl in the Codex browser and include the URL in your reply. Keep buildJobId too, because device install uses $IOS_CLI sign <buildJobId> and preview install uses $IOS_CLI sign --from-sim <simBuildId>.
Do not preemptively sign. Wait until the user asks to install on iPhone.
Install-on-Device callback
Trigger: the user asks to install the build. Prefer a buildJobId from the latest build output or local config. If the user gives a simBuildId, use sign --from-sim.
Steps
- Run
$IOS_CLI sign <buildJobId> or $IOS_CLI sign --from-sim <simBuildId>.
- Handle the exit code:
- Success: prints
installUrl. Print it verbatim in your reply so the user can open it on their iPhone.
NO_APPLE_AUTH: server says the user has no usable Apple credentials. Run First-Time Setup Step 1 (auth flow). Then retry from step 1 above.
SIGN_FAILED (with hint about devices in error message): the user has no registered iPhone yet, or registered devices haven't been Apple-synced. Run First-Time Setup Step 2 (registration). Then retry from step 1.
SIGN_IN_PROGRESS: a sign is already running for this build. Wait 15s and retry.
BUILD_NOT_READY: the underlying build job isn't built yet. Surface a clear error to the user; usually means the build hasn't completed or has expired.
Re-Sign After New Device
When register-apple adds a new UDID to an Apple team, the previous IPA's provisioning profile is stale. Re-run the callback steps to sign again.
Re-Sign After New Device
When a new device is registered, the current build needs re-signing (the provisioning profile must include the new device UDID).
Check config for the active project's buildJobId. If it exists:
"New device registered. Re-sign your current build to include it?"
Then re-trigger: $IOS_CLI sign <buildJobId>. No rebuild needed.
Creating a New Project
$IOS_CLI bootstrap "My App" "com.example.myapp" /path/to/project
Creates a SwiftUI project with SwiftData, tests, asset catalogs from the built-in template. Replaces all placeholders with your app name and bundle ID. Initializes a git repo.
Add new .swift files directly into the {App Name}/ directory — Xcode picks them up automatically.
After bootstrapping, save to config:
$IOS_CLI config set activeUser <userId>
$IOS_CLI config set users.<userId>.teamId <teamId>
Adding SPM packages
When you need a remote Git Swift Package Manager dependency, edit .xcodeproj/project.pbxproj directly. Five entries per product; the pipeline handles linkage and embedding. Local path packages (XCLocalSwiftPackageReference / package(path:)) are out of scope here — use a different shape.
Five required pbxproj entries
For a product Foo from https://github.com/owner/foo.git.
Object IDs: every pbxproj object needs a unique 24-character uppercase hex ID. The angle-bracketed names below (<PKG_REF_ID>, <PKG_PROD_ID>, <BUILD_FILE_ID>) are placeholders — replace each with a freshly generated unique ID, e.g. A1B2C3D4E5F60718293A4B5C. Never reuse one ID for two objects, never copy IDs verbatim from this doc.
-
XCRemoteSwiftPackageReference — under /* Begin XCRemoteSwiftPackageReference section */:
<PKG_REF_ID> /* XCRemoteSwiftPackageReference "Foo" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/owner/foo.git";
requirement = { kind = upToNextMajorVersion; minimumVersion = 1.0.0; };
};
Other valid requirement shapes — match the kind the package's docs recommend, don't blindly use upToNextMajorVersion:
{ kind = upToNextMinorVersion; minimumVersion = 1.2.0; }
{ kind = exactVersion; version = 1.2.3; }
{ kind = versionRange; minimumVersion = 1.0.0; maximumVersion = 2.0.0; }
{ kind = branch; branch = main; }
{ kind = revision; revision = "<commit-sha>"; }
-
XCSwiftPackageProductDependency — under /* Begin XCSwiftPackageProductDependency section */:
<PKG_PROD_ID> /* Foo */ = {
isa = XCSwiftPackageProductDependency;
package = <PKG_REF_ID> /* XCRemoteSwiftPackageReference "Foo" */;
productName = Foo;
};
-
PBXBuildFile under /* Begin PBXBuildFile section */, plus an entry in the main target's PBXFrameworksBuildPhase.files:
<BUILD_FILE_ID> /* Foo in Frameworks */ = {isa = PBXBuildFile; productRef = <PKG_PROD_ID> /* Foo */; };
-
packageProductDependencies on the main app's PBXNativeTarget — append <PKG_PROD_ID> /* Foo */ (create the array if absent).
-
packageReferences on the PBXProject object — append <PKG_REF_ID> /* XCRemoteSwiftPackageReference "Foo" */ (create the array if absent).
Then import <ModuleName> in your Swift source. The module name is not always the product name — check the package's README or Package.swift. Examples: product Realm exposes module Realm, but product RealmSwift exposes module RealmSwift; product FirebaseFirestore exposes module FirebaseFirestore; the gRPC-Swift package's product GRPCCore exposes module GRPCCore. Most match 1:1 but verify before importing.
Multiple products from one package (e.g., FirebaseAuth + FirebaseFirestore from firebase-ios-sdk): one XCRemoteSwiftPackageReference + one packageReferences entry, but a separate XCSwiftPackageProductDependency + PBXBuildFile + packageProductDependencies entry per product.
What the pipeline does for you
- Embeds dynamic frameworks. The pipeline scans
@rpath/<X>.framework references in the main binary and <App>.debug.dylib, copies matching frameworks from PackageFrameworks/ into App.app/Frameworks/. Works for forced-dynamic packages (Realm, RealmSwift, Sentry@dynamic).
- Skips macro & plugin trust prompts.
xcodebuild runs with -skipMacroValidation -skipPackagePluginValidation. Packages with macros (TCA, swift-syntax) or buildToolPlugins (SwiftLint) build without intervention.
- Resigns embedded frameworks recursively inside extensions, app clips, and watch targets.
Do NOT add an Embed Frameworks (PBXCopyFilesBuildPhase) phase yourself. The pipeline handles it. For static-default packages an explicit embed phase fails the build with lstat: No such file because no .framework is produced.
Do not strip the standard runpath. App targets must keep LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks" (the Xcode default). Without it, dyld cannot find the auto-embedded frameworks at launch even though the pipeline copied them in.
Multi-target apps (extensions, app clips, watch apps)
packageProductDependencies is a per-target field on each PBXNativeTarget. Any non-host target that imports an SPM module needs:
- an entry in that target's
packageProductDependencies array
- a
PBXBuildFile referencing the product
- an entry in that target's
PBXFrameworksBuildPhase.files
The XCRemoteSwiftPackageReference and project-level packageReferences are added once at the project level and shared.
This applies to widget extensions, app clips, watch apps, watch extensions, share / notification service / notification content / intents / file provider / network / keyboard / iMessage / audio unit / spotlight / today extensions — same rule, no special case per type.
Dynamic SPM framework consumed by an extension. Auto-embed scans @rpath references in the host's main binary and <App>.debug.dylib. The host target must carry the package as one of its dependencies for the framework to be embedded — even if the host doesn't directly use the API. So when an extension imports a dynamic SPM product, also add the same XCSwiftPackageProductDependency to the host's packageProductDependencies and a matching PBXBuildFile/Frameworks entry. If you genuinely don't want to call the API from host code, a no-op reference (_ = ModuleName.self in the App's init) keeps the symbol from being dead-stripped at link time. The host-target dependency is the load-bearing fix; the symbol reference is a belt-and-suspenders safety check.
Heavy packages — first build is slow
Cold resolves of Realm, swift-syntax-based packages (TCA, swift-macro-toolkit), Firebase, or large multi-package combos can take 15–40 minutes on Azure. The signing service has a 45-minute deadline — wait rather than cancel.
If the user asks for status mid-build, run $IOS_CLI logs <buildJobId> to surface live Azure progress (resolving packages, compiling targets, etc.). Most "stuck" builds are just slow, not broken.
Publishing
For App Store / TestFlight publishing, see references/publishing.md.
Routing apps (transit, ride-share, navigation)
If the user is building a routing app, the project's pbxproj must declare which transit modes the app supports. Add this build setting:
INFOPLIST_KEY_MKDirectionsApplicationSupportedModes = "MKDirectionsModeCar MKDirectionsModeTransit MKDirectionsModeWalking";
Pick the subset that matches the app from: MKDirectionsModeCar, MKDirectionsModeTransit, MKDirectionsModeWalking, MKDirectionsModeBus, MKDirectionsModeFerry, MKDirectionsModeStreetCar, MKDirectionsModePedestrian, MKDirectionsModeRideShare, MKDirectionsModeBike, MKDirectionsModeOther.
References