One App, Several Clients
A product with a web app and a native app is one Primitive app: one app ID, one set of environments, one server configuration, one model schema. The two clients are two front ends onto the same backend, and the things they share are the things that are expensive to get wrong — a second copy of the model schema silently orphans records, a second app ID splits your users in half.
primitive init — the command the Quick Start's npx create-primitive-app runs on your behalf — scaffolds that repository for you, and the layout it produces is the one this page describes.
Scaffolding both clients
primitive init my-app --platform web,ios--platform takes one platform or a comma-separated list; run it interactively and the prompt lets you select both. One run creates the app, downloads both templates, and produces:
my-app/
primitive/config.json # the environments: backend URL + app ID per environment
primitive/<env>/ # your app's server configuration, as TOML
models/models.toml # the model schema — one copy, shared
AGENTS.md # what this repo is and what is shared
web/ # the Vue client: its own package.json, .env, AGENTS.md
ios/ # the SwiftUI client: its own project, AGENTS.mdThere is one git repository, at the root, with one initial commit. There is no root package.json and no workspace file: the clients are independent projects sitting side by side, each with its own dependencies, build, tests and deploy. What they share sits above them.
A single-platform primitive init my-app still produces the flat single-client project it always has — the client is the repository.
Adding a client later
You do not have to decide up front. Run init from inside the repository, pointing at the new client's directory:
cd my-app
primitive init ios --platform ios # or just `primitive init --platform ios`Name the directory or leave it out — one platform goes in a directory named after itself. Init walks up to your project's primitive/config.json and adds the client to that app: the app ID and backend URL come from the selected environment, and the new client is wired to the repository's model schema. It creates no second app, no second .primitive/, and no second git repository — and it makes no commit, so you can review the new files with git status and commit them yourself.
If your repository is a single client at the root, adding a second one moves the model schema up to my-app/models/models.toml and rewires the existing client to read it there, after asking you. Nothing else about that client moves: its package.json, sources and build config stay exactly where they are.
For CI and scripted setup, .primitive-init.toml:
action = "add-client"
platform = "ios"
promote_schema = true # consent to move a single-client repo's schema to the rootWhat is shared and what is not
| Per app — at the repository root | Per client — in its own directory |
|---|---|
Environments and the app ID (primitive/config.json) | Generated code (models, function invokers) |
Server configuration (primitive/<env>/) | Runtime connection settings |
The model schema (models/models.toml) | Dependencies, build and test configuration |
| App secrets and config vars | Deploy configuration |
The app-wide AGENTS.md | The client's own AGENTS.md |
Server functions, database types and the rest of your server configuration are defined once for the app. The typed client code generated from them — models and function invokers — is per client: each client runs its own codegen, in its own language, into its own source tree. The database-type declarations your functions compile against are per app: config push writes them once, into the config tree's functions/ directory.
The model schema
models/models.toml is the app's one schema, and it is never copied. The TOML keys are the field names on the wire, so two copies that drift apart write records the other cannot read.
Each client points at that one file:
- The web client's codegen reads it and its generated model barrel imports it, so the schema it generates from and the schema it loads at runtime are the same file.
- The Swift client names it in
bao-codegen.jsonbeside its sources, which is what its codegen — SwiftPM and Xcode alike — reads.
A client's template ships code generated from its own starter models, so adding a client adds any of those models the schema does not already declare, and says which ones. Models you already have keep their definitions exactly as they are.
To change a model: edit models/models.toml, then run each client's codegen (pnpm codegen in the web client, bash scripts/codegen.sh in the Swift one). See Choosing Your Data Model for what belongs in the schema in the first place.
Running commands
Every primitive command walks up from where you run it to the nearest primitive/config.json, the way git finds its repository. Run primitive config diff in web/, in ios/, or at the root and it resolves the same project, the same environment and the same config directory. No path flag, no per-client setup.
Environments
An environment is a backend and an app together, and it belongs to the app rather than to one client:
primitive env list
primitive env use staging # every command in this repository now targets staging
primitive -e prod config diff # or name one per commandprimitive env use writes this machine's selection, not a tracked file, so it is yours and not your teammates'. Each client resolves its own runtime connection settings from that same project config, so pointing the repository at an environment points both clients at the same backend and app.
One sign-in link for both clients
An app with both clients emails ONE https:// sign-in link: the web client's /oauth/callback. Tapped on a device with the iOS app installed, iOS opens the app (that is a universal link); anywhere else it is the web app's normal sign-in page. Nothing about the request is per-platform — where the link opens is decided by where the mail is read.
The environment carries the web origin, alongside the backend URL and app ID it already holds:
"environments": {
"dev": { "apiUrl": "http://localhost:8787", "appId": "app_…", "webUrl": "http://localhost:5173" },
"prod": { "apiUrl": "https://primitiveapi.com", "appId": "app_…", "webUrl": "https://app.example.com" }
}See Authentication for the webUrl format, what primitive init seeds automatically, and the deploy-time rollout (association file, then the applinks: entitlement).
AGENTS.md
The root AGENTS.md describes the app: the layout, what is shared, where the schema is. Each client keeps its own, describing its stack and conventions, and it ships with that client's template — so app-wide facts belong in the root file, which upgrades never touch.