Palbase
Sign inGet started

iOS SDK

Codegen

The typed Swift client for your backend is generated by the Palbase CLI and committed to your repo — there is no build-tool plugin, and there has not been one since 2026-07-27. Every command that refreshes the contract also regenerates the client from it in the same run, so the two never drift, and because a project can be built against more than one Environment, the CLI writes one contract and one client per environment, each in its own directory, plus a build-configuration pattern you own that picks between them. This page is what lands where, which command writes it, and how the per-environment layout is wired into Xcode.

Because the client is ordinary committed source, you see it in autocomplete before the first build, a backend change arrives as a reviewable git diff, and a fresh clone compiles with no CLI, no network and no Xcode trust prompt.

Quick example

cd ~/code/TodoApp
palbase link todoapp --platform ios
# wrote palbase/environments/main/ios-config.json
# wrote palbase/environments/staging/ios-config.json
#
# Add these to your own build configuration (xcconfig, build settings, Tuist —
# whichever you already use). PALBASE_ENV is the only line you change; the other
# two never do:
#
#     PALBASE_ENV = main
#     EXCLUDED_SOURCE_FILE_NAMES = */palbase/environments/*/*
#     INCLUDED_SOURCE_FILE_NAMES = */palbase/environments/$(PALBASE_ENV)/*
#
# Then add palbase/environments to your app target. …
# ✓ wrote /Users/you/code/TodoApp/palbase/environments/main/PalbaseGenerated.swift
# ✓ wrote /Users/you/code/TodoApp/palbase/environments/main/Palbase-Info.plist
# ✓ wrote /Users/you/code/TodoApp/palbase/environments/staging/PalbaseGenerated.swift
# ✓ wrote /Users/you/code/TodoApp/palbase/environments/staging/Palbase-Info.plist
#
# linked to todoapp (proj_01)
#   contract read from main; each verb resolves its own environment
# commit palbase/

After every deploy, one command brings the contract and the client forward together:

palbase spec

What lands where

palbase/
  project.json                   the linked project — committed, the binding
  client.ts                      web only: the barrel palbe-gen writes
  environments/
    <env>/                       everything that exists PER environment
      openapi.json                the contract, ONE PER ENVIRONMENT —
                                  carrying that environment's role
                                  definitions as `x-palbase-roles`
      ios-config.json             the iOS slot: {app_id, base_url, api_key, oauth?}
      macos-config.json           the macOS slot, if you linked macOS
      PalbaseGenerated.swift      one typed client PER ENVIRONMENT
      Palbase-Info.plist          swiftgen's output for THIS environment only — an {ios?, macos?} envelope

The environment directory is flat — the platform is in the file name, never a subdirectory. That is a measured constraint: Xcode's EXCLUDED_SOURCE_FILE_NAMES / INCLUDED_SOURCE_FILE_NAMES pattern (below) does not cross a directory boundary, so a platform subdirectory would silently make the pattern match nothing.

This replaces the entire layout people who linked before 2026-09 learned — not just the contract's path. A checkout still carrying the old hidden root (.palbase/) or the old visible one's markers (Palbase/Generated, Palbase/Config, a flat openapi.json/roles.json/palbase-config.json) is refused before link does anything else:

this checkout still carries the retired layout: %s.
  Delete it and commit that deletion, then run `palbase link` again — everything here is regenerated from the project.
  There is no migration: a tree holding both layouts has two contracts and two clients, and no way to tell which one a build read

There is no migration path by design: delete the old directory, commit the deletion, and re-run link — everything under palbase/ regenerates from the project. (Palbase — capital P — is deliberately not what the refusal looks for by name: on a case-insensitive filesystem, which is what macOS and Windows ship, palbase and Palbase are the same directory, so a check keyed on the name would refuse the very layout it is supposed to accept. The refusal looks for the old layout's contents instead.)

A left-behind environment directory is a different, smaller problem and link handles it itself: Xcode 16's synchronized groups compile everything under a folder reference, so a stale palbase/environments/staging/ after a rename is a second generated client in the same target and a build that fails with "Multiple commands produce PalbaseGenerated.stringsdata". Every Apple link removes the directory of any environment the project no longer has — when every file in it is one Palbase generates — before writing anything new.

Note: The header line inside every generated file still reads // Generated by palbase-swiftgen from .palbase/openapi.json — a stale string, byte-locked by a golden test, which is why it has not been corrected. The layout above is what the commands actually write; only that one comment line still names the pre-per-environment path.

The commands

CommandExistsWhat it does
palbase linkyesreads what the checkout is, binds it, fetches every environment's contract, and writes the iOS (and macOS) slot and every client — then, on an Apple platform, prints the build-configuration pattern once
palbase link --platform iosyesthe same, limited to one platform — useful only when a checkout carries more than one and you want less
palbase link <ref>yesbinds the project that environment belongs to — the same binding as its name — then does all of the above
palbase unlinkyesremoves the binding; the generated files stay where they are
palbase specyesrefreshes the contract for the environment this checkout points at, then regenerates every environment
palbase ios link, macos link, android link, <platform> usenoretired with no shim and no alias — one link does what all of them did

On first run it registers a new iOS app; later runs reuse the app id this checkout persisted, after validating its product and platform. It does not inspect or modify your .xcodeproj.

Note: There is no change-environment step for the app: every link fetches every environment of the project, and which one a build talks to is decided by its Xcode build configuration. Which one the CLI's own commands act on — palbase spec among them — is palbase env use <name>, or --env <name> for one call. See Linking a Checkout.

Note: palbase apps does not exist. It was removed in the v2 cutover with no shim and no alias, and there is no manual app-registration path beside palbase link.

Environments, one client each

A project has one or more environments — main, staging, … — and, when this machine's register holds a stack for the same project, the one running here (local). Each gets its own directory, its own contract, its own generated client and its own plist; which one enters a given build is a build-configuration setting, not a file the CLI writes for you:

PALBASE_ENV = main
EXCLUDED_SOURCE_FILE_NAMES = */palbase/environments/*/*
INCLUDED_SOURCE_FILE_NAMES = */palbase/environments/$(PALBASE_ENV)/*

Both static lines are load-bearing:

  • EXCLUDED_SOURCE_FILE_NAMES keeps every environment's files out of the compile by default. INCLUDED_SOURCE_FILE_NAMES then adds back only the one PALBASE_ENV names. Without both, every environment's client (and plist) compiles into the target at once and you get duplicate symbols — so "which endpoints exist" is decided by the same setting that decides which address they are called at.
  • The two-level glob is deliberate, not a typo: * does not cross a directory boundary in these settings (measured on a real Xcode 26.6 build), so a single-level */palbase/environments/* excludes nothing at all.

PALBASE_ENV is the only line you change per build configuration; leaving it unset expands to nothing in Xcode, so the include pattern matches nothing and the build ships with no plist at all — there is no default it falls back to.

An environment that has never had a contract fetched — a fresh local that has never been up — gets neither a client nor a plist: link writes only its ios-config.json input and moves on. Once an environment has been fetched at least once, its client and plist keep regenerating on every relink from whatever contract already sits in its directory, api_key and all — so a plist can carry an empty key on purpose, when the local stack happened to be down at link time. Selecting that environment's directory at build time then fails at first pb.* access with AppConfigError.missingAPIKey (the plist exists; its key does not), while selecting one that was never fetched at all fails with .missingConfig (there is no plist to read). See How configuration works.

Add the SDK package

1. Add the package

Add it in Xcode (File ▸ Add Package Dependencies…) with https://github.com/palgroup/palbackend-ios, or in Package.swift:

dependencies: [
    .package(url: "https://github.com/palgroup/palbackend-ios", from: "0.53.0"),
]

from: is a floor, not a pin: SwiftPM reads it as .upToNextMajor, so this resolves to the newest 0.x tag on the repository and keeps resolving forward as releases ship. Use exact: "0.53.0" when you want one fixed build. See Overview.

2. Add one product and the generated folder

Add the library for the highest capability your app needs — Palbe, PalbeMessaging, or PalbeCall — and only that one; each carries the layers beneath it. See Overview for what each costs an App Store submission.

.target(
    name: "MyApp",
    dependencies: [.product(name: "Palbe", package: "palbackend-ios")]
)

Then drag palbase/environments into your app target as a folder reference: each environment's PalbaseGenerated.swift compiles with your app, and its Palbase-Info.plist ships as a bundle resource — for whichever environment your build configuration selects. The folder reference only behaves once your own build configuration carries the pattern from Environments, one client each above — that is what excludes the other environments' files from the compile.

3. Commit the output

Commit palbase/ — all of it: project.json, and every environment's openapi.json, ios-config.json, PalbaseGenerated.swift and Palbase-Info.plist. That is the CLI's own closing line (commit palbase/), and it is what makes a fresh clone build without a login.

Headless agents and CI

Nothing extra to configure. There is no build-tool plugin, so xcodebuild needs no -skipPackagePluginValidation and Xcode never asks anyone to Trust & Enable anything. The generated client is committed, so CI compiles it like any other source file — no palbase binary on the build machine and no network during the build.

palbase spec

spec refreshes the contract after a deploy and regenerates from it. It fetches the OpenAPI document for the environment this checkout points at, writes palbase/environments/<env>/openapi.json, regenerates every environment's client and plist, and then names the environments it did not reach:

palbase spec
# ✓ wrote palbase/environments/main/PalbaseGenerated.swift
# ✓ wrote palbase/environments/main/Palbase-Info.plist
#
# only main was refreshed. The others still describe what they last served:
#   local (from 2026-08-24 11:07)

Which platforms it refreshes is read from the committed slot filespalbase/environments/<env>/ios-config.json here, palbase/environments/<env>/web-config.json for web — not from per-machine state, so a fresh clone behaves like the machine that linked it. If the same checkout also ran palbase link, spec refreshes each environment's openapi.json in the same run and both SDKs generate from one fetch.

spec does not write runtime config. The per-environment ios-config.json — base URL and publishable key — is written by palbase link --platform ios / palbase link, and rewriting it means running one of those again.

If SwiftPM has not resolved palbackend-ios yet, spec refuses before touching anything. The generator lives in the package checkout; with no checkout there is no regeneration. Rather than refresh the contract and leave the committed client stale beside it — it still compiles, so the drift would only surface as a runtime 404 — the preflight fails first and writes nothing: no environment's openapi.json or generated files change. Run one Xcode build, or xcodebuild -resolvePackageDependencies, then re-run. It does not guard palbase link, where having no client yet is the normal first-run state.

If the generator becomes unavailable after a fetch has already landed, the CLI deletes every palbase/environments/<env>/PalbaseGenerated.swift and its Palbase-Info.plist, and fails loudly, naming what it removed. That is deliberate: stale generated code compiles, so leaving it is the one outcome worse than having none.

A project that is still starting answers 502/503. The fetch retries those (and per-attempt timeouts) for up to 150 s, honoring Retry-After and printing backend waking (attempt N): … — retrying in 2s, then fails. Nothing else is retried: a refused connection or a 401 is an answer, and asking again would only make it slower. There is no idle pause to wait out — Environments do not sleep.

Linking by an environment's ref

palbase link <ref> binds the project that environment belongs to — exactly the binding palbase link todoapp makes — so it is not a way to reach one environment and not the others. Every link fetches every environment of the project, writes a contract and a client for each, and gives each its own Palbase-Info.plist:

palbase link p8vn2c4rm --platform ios
# wrote palbase/environments/main/ios-config.json
# wrote palbase/environments/staging/ios-config.json
# …
# linked to todoapp (proj_01)
#   contract read from main; each verb resolves its own environment

Which environment a build talks to is decided by which directory your build configuration includes — not by a command anyone ran earlier. Which one palbase spec refreshes is the one this checkout resolves to: palbase env use <name> remembers it on this machine, and --env <name> names one for a single call. To refresh the wiring after a change on the platform side, re-run palbase link with no target; to bind a different project, link that project.

What gets committed vs. generated

FileWritten byCommit it?
palbase/project.jsonpalbase linkYes — the binding
palbase/environments/<env>/openapi.jsonlink / specYes — codegen input
palbase/environments/<env>/ios-config.jsonlink --platform iosYes — codegen input
palbase/environments/<env>/macos-config.jsonlink --platform macosYes — codegen input
palbase/environments/<env>/PalbaseGenerated.swiftthe generator, on every contract refreshYes — ordinary source
palbase/environments/<env>/Palbase-Info.plistthe generator, on every contract refreshYes — ships as a bundle resource

palbase/ as a whole is not gitignored — nothing under it is. palbase link actively repairs a .gitignore that tries to narrow or ignore it, taking back any inherited rule so the contract, the slots and the binding all stay trackable. The state this checkout has that is not committed lives entirely outside it: the stack palbase start brought up here, and what palbase plan last measured, sit under ~/.palbase/checkouts/<hash>/ — per machine, never in git.

Note: A checkout still carrying the pre-2026-09 layout — the hidden .palbase/ root, Palbase/Generated, Palbase/Config, or a leftover .palbase/config.json from the backend's own now-retired project config — is refused by link until that layout is deleted and the deletion committed. See What lands where above; there is no migration path, only a clean re-link.

The generator

The generator is not part of the CLI. palbase-swiftgen is an executable target inside the SDK package, so the emitted code always matches the SDK version your app pins. The CLI:

  1. Locates it in the palbackend-ios checkout SwiftPM already resolved for your project — a local .package(path:) reference, <root>/.build/checkouts/, or the newest matching Xcode DerivedData SourcePackages/checkouts/. If it cannot find one: "the palbackend-ios checkout is not resolved for this project yet — build once in Xcode, or run xcodebuild -resolvePackageDependencies".
  2. Compiles it once per SDK version with xcrun swiftc, cached at ~/.palbase/tools/swiftgen-<sha256>/palbase-swiftgen. The cache key is a hash of the generator's source, which is why bumping the SDK silently recompiles it once.
  3. Runs it: one invocation per environment for the client, one more for the plist.
palbase-swiftgen --openapi palbase/environments/main/openapi.json \
                 --out-swift palbase/environments/main/PalbaseGenerated.swift

palbase-swiftgen --out-plist palbase/environments/main/Palbase-Info.plist \
                 --ios-config palbase/environments/main/ios-config.json \
                 --macos-config palbase/environments/main/macos-config.json

Only the platform slots that exist are passed — an iOS-only project gets no --macos-config. The generator has two independent halves, the client (--openapi + --out-swift) and the plist (--ios-config / --macos-config + --out-plist), and half a pair is a hard argument error, never silence. A third flag, --purchases-catalog, emits StoreKit catalog constants; no CLI command passes it, so those constants do not exist in a generated project.

What the generated Swift guarantees

PalbaseGenerated.swift is marked DO NOT EDIT — each run rewrites it wholesale, deterministically, so regenerating without a backend change produces an empty diff. Real emitted output:

public nonisolated struct RoomsCreateEndpoint: PBEndpoint {
    public typealias Response = RoomsCreateResponse
    public typealias Failure = RoomsCreateError
    let input: RoomsCreateRequest
    public var pbRequest: PBRequest { PBRequest(.post, ["rooms"], body: input) }
}

public nonisolated struct PBRoomsNamespace: Sendable {
    let _pb: PalBackendClient
    @discardableResult
    public func create(_ input: RoomsCreateRequest) async throws(RoomsCreateError) -> RoomsCreateResponse {
        return try await _pb.call(RoomsCreateEndpoint(input: input))
    }
}
  • Namespaced methods. The operation id is <controllerName>.<methodName>, and the controller name is your class name minus Controller, first letter lowercased: RoomsController.createpb.rooms.create(...). An operation id with no dot lands directly on pb.
  • Typed throws per endpoint. Each method throws its own failure enum, so catch is already concrete. Every enum carries .other(BackendError). See Error Handling.
  • Plain value types. Requests and responses are Codable, Sendable structs with public memberwise initializers; string unions become nested enums, with Swift keywords escaped.
  • Every method is a thin wrapper over the public pb.call(_ endpoint:). There is no private seam — you can build the same request by hand.
  • Endpoints with no request body get zero-argument methods; endpoints with no response body return Void.

Reserved namespaces

Nine namespaces on pb are SDK-owned: auth, analytics, flags, realtime, notifications, perf, messaging, purchases, debug. An endpoint whose operation id begins with one of them is skipped, and the generated file says so:

// codegen: skipped reserved namespace "auth" (SDK-owned)

messaging is in that set and is skipped like the rest — an older note that codegen "does not yet skip it" had it backwards.

Wire format: snake_case and the SDK's coders

Your backend speaks snake_case JSON. Generated structs are idiomatic camelCase and rely on the SDK's built-in coder pair (camelCase ↔ snake_case, ISO 8601 dates), so they need no CodingKeys. This matters when you hand-write Codable types for pb.call:

// ✅ Correct — camelCase properties, no CodingKeys. The SDK's coders
// map createdAt ↔ created_at automatically.
struct Todo: Codable, Sendable {
    let id: String
    let title: String
    let createdAt: Date
}

// ❌ Wrong — snake_case CodingKeys fight the SDK's key strategy.
// The decoder converts keys to camelCase BEFORE CodingKeys lookup,
// so "created_at" never matches and the field decodes as nil / fails.
struct BrokenTodo: Codable {
    let createdAt: Date
    enum CodingKeys: String, CodingKey {
        case createdAt = "created_at"   // never matches — don't do this
    }
}

Warning: Never add snake_case CodingKeys to a type you pass to pb.call, and never declare both createdAt and created_at on one type. Both produce silently-missing fields, not errors.

Why the output is committed

Generation runs entirely on committed local files, so it could live in the build — and it did, as an SPM build-tool plugin, until 2026-07-27. The problem was where the output landed: a plugin writes into its work directory under DerivedData, which put the typed surface out of reach of git diff, of your editor before a first build, and of any agent reading the repository to learn which calls exist. You could not review what a backend change did to your client; you could only build and find out.

Running the generator from the CLI puts the client in your repo, where it behaves like the source it is. It also removes the plugin trust prompt, the -skipPackagePluginValidation dance in CI, and the per-build codegen cost. Fetching the spec is the only step that needs the network — and it is the same step that regenerates, so the contract and the client move together.