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
| Command | Exists | What it does |
|---|---|---|
palbase link | yes | reads 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 ios | yes | the same, limited to one platform — useful only when a checkout carries more than one and you want less |
palbase link <ref> | yes | binds the project that environment belongs to — the same binding as its name — then does all of the above |
palbase unlink | yes | removes the binding; the generated files stay where they are |
palbase spec | yes | refreshes the contract for the environment this checkout points at, then regenerates every environment |
palbase ios link, macos link, android link, <platform> use | no | retired 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 specamong them — ispalbase env use <name>, or--env <name>for one call. See Linking a Checkout.
Note:
palbase appsdoes not exist. It was removed in the v2 cutover with no shim and no alias, and there is no manual app-registration path besidepalbase 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_NAMESkeeps every environment's files out of the compile by default.INCLUDED_SOURCE_FILE_NAMESthen adds back only the onePALBASE_ENVnames. 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 files — palbase/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-iosyet,specrefuses 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 runtime404— the preflight fails first and writes nothing: no environment'sopenapi.jsonor generated files change. Run one Xcode build, orxcodebuild -resolvePackageDependencies, then re-run. It does not guardpalbase 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
| File | Written by | Commit it? |
|---|---|---|
palbase/project.json | palbase link | Yes — the binding |
palbase/environments/<env>/openapi.json | link / spec | Yes — codegen input |
palbase/environments/<env>/ios-config.json | link --platform ios | Yes — codegen input |
palbase/environments/<env>/macos-config.json | link --platform macos | Yes — codegen input |
palbase/environments/<env>/PalbaseGenerated.swift | the generator, on every contract refresh | Yes — ordinary source |
palbase/environments/<env>/Palbase-Info.plist | the generator, on every contract refresh | Yes — 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.jsonfrom the backend's own now-retired project config — is refused bylinkuntil 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:
- Locates it in the
palbackend-ioscheckout SwiftPM already resolved for your project — a local.package(path:)reference,<root>/.build/checkouts/, or the newest matching Xcode DerivedDataSourcePackages/checkouts/. If it cannot find one: "the palbackend-ios checkout is not resolved for this project yet — build once in Xcode, or runxcodebuild -resolvePackageDependencies". - 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. - 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 minusController, first letter lowercased:RoomsController.create→pb.rooms.create(...). An operation id with no dot lands directly onpb. - Typed throws per endpoint. Each method throws its own failure enum, so
catchis already concrete. Every enum carries.other(BackendError). See Error Handling. - Plain value types. Requests and responses are
Codable,Sendablestructs 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
CodingKeysto a type you pass topb.call, and never declare bothcreatedAtandcreated_aton 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.
Related
- Overview — how the SDK boots from
Palbase-Info.plist - Calling Your Backend — using the generated methods
- Linking a Checkout —
palbase link, and what decides a directory's target - CLI: Codegen — the platform
link/usecommands andpalbase spec, alongside the web generator - Introduction — refs, addresses, and what one Environment is