Skip to content

Configuring Primitive Services ​

Everything Primitive does for your app — which sign-in methods are enabled, what server functions exist and who may call them, which external APIs they can reach, how your databases are shaped — is configuration of Primitive's services, stored on the Primitive servers. Your client code doesn't define that behavior; it invokes what configuration declares.

There are two equivalent ways to manage that configuration:

  1. The Admin Console — interactive and visual. Best for exploring, testing, and one-off changes.
  2. TOML files in your repo, synced via the CLI — configuration as code. Best for anything you want versioned, reviewed, reproduced across environments, or managed by an AI coding agent.

Both edit the same underlying configuration. Most teams explore in the console and commit the result as TOML.

The Sync Loop ​

primitive config round-trips your app's configuration between the server and a directory of TOML files:

bash
primitive config init  # create the directory structure
primitive config pull  # download current server config as TOML
# ... edit files locally ...
primitive config diff  # see what would change
primitive config push  # apply your changes to the server

The config directory is primitive/<env>/ — one isolated slot per environment, and the only directory any config command reads or writes. The lookup walks up from where you run the command, so in an app with several clients the configuration lives once at the repository root and every client directory resolves it.

The files live in your repo, so configuration changes ride the same workflow as code: branches, diffs, reviews, rollbacks. config push runs a local preflight over the tree before it applies anything — an error there aborts the push with no changes applied — and reports the file and entity behind any failure. See What a push guarantees for what that preflight checks and what it leaves to the server.

Preview before you push. primitive config diff shows which entities would be created, changed, or removed; primitive config push --dry-run walks the full push and reports the same outcome without writing anything. Both answer from the comparison push itself acts on, so a clean diff means push has nothing to apply; What diff compares covers how each difference is reported.

Every config pull snapshots the config directory before writing, so a pull that overwrites local edits is recoverable:

bash
primitive config revert --list           # list available snapshots
primitive config revert                  # restore the most recent
primitive config revert --snapshot <id>  # restore a specific one

Snapshots are local CLI state, kept out of version control and pruned after 28 days. revert replaces the config directory with the snapshot — run primitive config diff afterwards to see where the restored files stand relative to the server.

TOML Is the Only Write Path ​

Server-side configuration is authored in TOML and applied with primitive config push. That is the only way the CLI writes it: there are no primitive <thing> create / update / delete commands and no flags that set a configuration field. Whatever you want to change — a function's triggers, an integration's base URL, a prompt's model config, a cron schedule, an app setting, a config var — you edit the file that owns it and push.

Four commands cover authoring without leaving the terminal:

bash
primitive config fields function                    # the type's TOML keys, types, required, defaults
primitive config create function order-intake       # scaffold functions/order-intake.toml from defaults
primitive config set function/order-intake function.description="Order intake" # edit one scalar in place
primitive config push --only function/order-intake  # apply just that object
  • config fields <type> lists a configuration type's TOML keys with their types, whether they're required, and their defaults — the way to discover the field surface. --json prints it for tooling, and primitive config fields --help lists every type it accepts.
  • config create <type> <key> writes <key>.toml in that type's directory, filled in from the type's defaults. It is local only — nothing reaches the server until you push.
  • config set <object> <path>=<value>... sets one or more scalars in an existing file, preserving comments and key order. <object> is <type>/<key>, app, or vars. Also local only. Tables and arrays are edited in the file directly.
  • config push --only <selector>[,...] applies just the named objects instead of the whole tree. Selectors are <type>/<key>, app, or var/<key>. Everything else about push — validation, conflict detection, --dry-run, --force, --prune — behaves the same.

set plus push --only is the scripting path: two commands, no here-document, no temporary file.

Deleting a configuration object means deleting its file and running primitive config push --prune — every type, no exceptions. One caveat is specific to metadata categories: deleting the definition does not delete stored metadata values, which become unreachable, so delete the values first (primitive metadata delete) if you need them gone. The prune plan says so before you confirm.

Structured values are native TOML tables. Where a config surface takes a structured value — a function's or prompt's inputSchema and outputSchema, a prompt's providerConfig, a database type's autoPopulatedFields, a metadata category's schema — write it as nested TOML tables. The same fields also accept single-line JSON text, which the server treats identically, and config pull keeps whichever form a file already uses.

What is not configuration stays interactive. Secrets (primitive secrets set) never go in a file. Resources whose creation mints a server id — primitive apps create, databases create, collections create, groups create — are created with a command, then configured in TOML. Data operations (databases records, documents records, metadata set) and every read or test command (list, get, diff, test, execute) are unaffected. Availability and retirement are the two recurring cases of this, covered next.

Availability (status) Is Server-Owned ​

Three nouns carry an availability switch — primitive functions, integrations and prompts — and on every one of them status (active / inactive) is runtime state, not configuration. It is deliberately off the TOML surface entirely:

  • It has no TOML key. None of the three types' config tables declare status; the CLI does not accept it on any file the three nouns own.
  • disable/enable are its only writers — primitive functions disable <function-id>, primitive prompts enable <prompt-id>, and so on. Taking an object out of service is reversible and immediate; enable puts it back.
  • config pull never writes it. A file a pull produces states the object's configuration, not whether it's currently serving — pull the same file whether the object is active or disabled.
  • A file that still carries the key fails. A hand-authored or stale status line is an unrecognized key: config push refuses it, naming the enable/disable verbs instead, and config diff's identical pre-flight validation reports the identical rejection — so it is never silently applied or converged, and pushing a file pulled before an operator disabled the object can never re-enable it as a side effect.
  • config diff still surfaces it, distinctly from drift. When an object is out of service, diff appends "inactive; configuration unchanged" (or, for an archived row, "archived; config push --prune reclaims it") to its line instead of reporting a difference to reconcile — the file and the server agree on configuration; only the operational switch differs.

A prompt's per-config status (active | archived on a [[configs]] entry) is a different key answering a different question — which named config is retired — and it stays TOML-owned; see Prompt Configurations.

Retiring an Object: Archive vs Prune ​

The same three nouns — functions, integrations, prompts — carry a third verb, archive (primitive functions archive, primitive integrations archive, primitive prompts archive). It answers a different question than availability: not "is it serving right now" but "does this row still exist."

  • archive is a soft delete — the CLI spelling of the API's plain DELETE. The row is retired but kept, so anything that references it keeps resolving, and it goes on holding its key. enable refuses an archived object, and there is no un-archive.
  • config push --prune is the hard delete, and the only one in the CLI: delete the object's TOML file and run a confirmed prune. The row is destroyed outright, and its key comes back for reuse.
  • Recovering from an archive means the hard delete, not a second push. config push resolves an existing row by key and cannot clear a server-owned archived state, and pull never exports an archived row as a file, so undoing an archive is: primitive config pull (which removes the archived row's local file, if one is still sitting from before you archived it, while keeping it in sync state so a prune can still find it), a confirmed primitive config push --prune to reclaim the key, then add the file back and push it as new.
  • Every archive and prune confirms first. --yes/-y skips the prompt; on a pipe or in CI without it, the command exits 1 and asks you to re-run with --yes rather than acting unasked.

users and admins carry enable/disable but no archive — they are people, not configuration objects, and their model has two states by design.

What Lives in the Config Directory ​

One subdirectory per kind of configuration:

primitive/<env>/
  app.toml                        # App settings
  vars.toml                       # Config vars (non-secret key/value store)
  prompts/*.toml                  # Managed prompts
  prompts/{key}.tests/*.toml      # Prompt test cases
  integrations/*.toml             # External API integrations
  integrations/{key}.tests/*.toml # Integration test cases
  functions/*.toml                # Server function definitions
  functions/{key}/**              # Function sources (the entry its TOML names)
  functions/primitive-db-types.d.ts  # Generated: this app's database types
  functions/primitive-functions.d.ts # Generated: the function SDK's own types
  functions/tsconfig.json         # Scaffolded: wires both into your editor
  database-type-configs/*.toml    # Database types: schemas, rule set, triggers
  blob-buckets/*.toml             # Blob bucket definitions
  email-templates/*.toml          # Email template overrides
  rule-sets/*.toml                # Access rule sets
  group-type-configs/*.toml       # Group type configuration
  collection-type-configs/*.toml  # Collection type configuration
  metadata-category-configs/*.toml # Resource metadata category configs

Every feature page in these docs that shows a TOML block — Server Functions, Prompts, API Integrations, Working with Databases, Blobs and Files, Resource Metadata — is describing one of these files. Define the entity in TOML, primitive config push, and it's live.

vars.toml round-trips your app's config vars the same way app.toml round-trips app settings — a flat table of non-secret key/value pairs, written by config pull and applied by config push.

Credentials never go in these files: config that needs an API key references it as {{secrets.KEY}}, with the value stored server-side. See App Secrets.

App Settings (app.toml) ​

app.toml holds the app-level settings. primitive config push applies them along with everything else, and primitive config push --only app applies just them. primitive config pull --only app writes the current server settings into app.toml, primitive config diff --only app shows per-field differences, and primitive apps get renders the current server-effective settings without touching any file. All of them read and write the same app.toml. The TOML-syncable settings are:

  • [app] — name, mode, baseUrl, waitlistEnabled, waitlistNotifyAdmins, allowedDomains, testAccountBaseEmails, largeDocumentWindowDays (how many days this app's large documents may be written offline before a client goes read-only — 1–14; omit it to take the deployment's window, 7 days unless the deployment says otherwise)
  • [auth] — googleOAuthEnabled, emailSignInEnabled, passkeyEnabled, passkeyUserVerification, appleSignInEnabled, appleAudiences, emailRedirectUris, [auth.google.clients.<type>] Google client entries, and [auth.passkeys] relying-party config
  • [cors] — mode, allowedOrigins, allowCredentials, allowedMethods, allowedHeaders, exposedHeaders, maxAge
  • [invitations] — enabled, limit (whether members may send invitations and the per-member cap)

app.toml is the whole truth. config push sends a value for every setting listed above, not just the keys the file happens to carry, so the file states the app's configuration rather than patching it: delete a line and the next push clears that setting, or resets it to its declared default where the server has one (emailSignInEnabled and waitlistNotifyAdmins go back to true — so a file that never stated emailSignInEnabled turns email sign-in back on, and an app that wants it off keeps the line emailSignInEnabled = false in the file; [cors] mode to "universal"; the invitation limit to 5; waitlistEnabled, passkeyEnabled, [invitations] enabled and allowCredentials to false; everything else is cleared). This is the same source-of-truth rule every other configuration file follows (see Deletions, Cleared Fields, and Out-of-Band Edits).

[app].name and [app].mode are required: they have no default to reset to, and a deleted mode line silently returning an invite-only app to public is not a failure worth allowing, so their absence is a validation error from both config push and config diff. A key config pull omitted is a setting the server does not hold, so a pull followed by a push is a no-op. An unrecognized key (or a mistyped table header) is rejected by name before anything is applied, the same as for every other configuration file.

The [auth] fields — the Google client map, the email sign-in allow-list, and passkeys — follow the same TOML rules as everything above; see Authentication for what each field does and its error codes.

Change these settings by editing app.toml and pushing it — there is no command that sets an app setting directly (see TOML Is the Only Write Path):

bash
primitive config set app app.mode=invite-only
primitive config push --only app

app.toml accepts only the keys above. googleClientId, googleClientSecret, redirectUris, passkeyRpId, passkeyRpName, magicLinkEnabled and otpEnabled are rejected by name, and the error names the key that holds the setting: [auth.google.clients.<type>] for the Google id, secret and callbacks; emailRedirectUris for the email sign-in allow-list (a redirectUris list may mix Google callbacks with sign-in-link targets — split it); [auth.passkeys] for the passkey relying-party pair; and emailSignInEnabled, the one switch for email sign-in.

The Functions Directory ​

functions/ is the one directory whose contents are not all yours. A function is authored as functions/<key>.toml plus the TypeScript entry the TOML's entry key names, and those you write. The other files are put there by primitive config push so your editor and a bare tsc type your functions against your own schemas with nothing to wire up:

  • functions/primitive-db-types.d.ts — this app's database types, model by model, rendered from the database-type-configs/*.toml schemas in this same tree. It is what gives ctx.db(databaseId, "<type>").model("<Model>") its model names and field types.
  • functions/primitive-functions.d.ts — the types of the primitive-functions module every function imports.
  • functions/primitive-function-types.d.ts — <Key>Input and <Key>Output for every function in the tree, from its inputSchema and outputSchema, and the FunctionSchemas augmentation that types the keyed defineFunction("<key>", handler).
  • functions/primitive-document-types.d.ts — the project's document models from models/models.toml, and the DocumentSchemas augmentation that types ctx.doc(documentId).model("<Model>"); open when the project has no schema file.
  • functions/primitive-prompt-types.d.ts — <Key>PromptOutput for every prompts/*.toml in this tree that declares a [prompt.outputSchema], and the PromptSchemas augmentation that types ctx.prompts.run("<key>"). A prompt that declares no output schema is listed with an empty entry, so its parsed stays unknown; a tree with no prompts/ directory gets the open form.
  • functions/primitive-document-schema.generated.toml — the project's models/models.toml, copied into the tree. config push carries the document schema inside each function's version so a function's first write to a declared model can seed that model's metadata in the document; config pull writes the active version's copy here, and removes a stale one when the active version carries none. Push and diff resolve the project file first and this copy only as the fallback, so a tree pulled into a project that has no models/models.toml still rebuilds the same version — and a push from a project that HAS one regenerates the copy from it.
  • functions/generated/<key>.generated.ts — one typed client invoker per function, for the app's client code (it imports js-bao-wss-client): both verbs, because the caller picks the runtime at each call; primitive functions codegen -o <dir> writes these somewhere else.
  • functions/tsconfig.json — the wiring that puts the sources and the declarations in one program, points primitive-functions at the declaration beside it, and excludes generated/**.

All of it is generated on every push and none of it is sent to the server; see Server Functions — Typed from the declaration for the rewrite rule, how tsconfig.json is scaffolded once and repaired, and primitive functions codegen --check.

The [function] table's own keys are covered with the functions themselves. It says nothing about how a function runs — the caller picks the runtime at each call, and code that must not run under one says so with assertRuntime (see Runtimes). Its capabilities declare integrations (integration:<integrationKey>), secrets (secret:<NAME>) and a few high-blast operations (see Capabilities); its trigger blocks add a webhook, whose deliveries use the request runtime, and cron schedules, where every cron fire starts a task run (see Triggers). Integrations are applied before functions, so a tree that adds an integration and a function that declares it lands in one push.

Test Case Identity ​

A test case's file name is its identity. The basename of a <key>.tests/<case>.toml file is the case's key on the server — unique per block, case-insensitively. That is what makes the checked-in tree self-sufficient: a case's .sync-state.json entry exists only once it has been pushed or pulled at least once, so without a key derived from the tree itself, a newly authored case would create a duplicate of every case whose filename differed from its [test] name the first time anyone pushed it. config pull writes a case back under its own name rather than renaming it, and renaming the file renames the case on the next push — the same test case, with its history, not a second one. Keep the name path-safe (no / or \, no leading or trailing spaces, no trailing dot, at most 200 characters); config push names any file that is not and refuses it — before it sends anything for that case, so it is never created without its identity — while the rest of the push proceeds. Cases created outside the CLI carry no key until a push stamps one, and where push cannot tell which server case an unkeyed local file belongs to it says so and creates nothing — config pull first, or push after pulling the commit that recorded its .sync-state.json entry.

Writing the file is not enough to run it. A .tests/<case>.toml file is local until a config push registers it — tests run-all and tests list (on prompts and integrations alike) run the registered set on the server, not whatever is in the directory. A newly authored case is silently absent from run-all until you push, and editing an already-registered case's file doesn't change what runs until the next push sends it. config diff is where this becomes visible, with four counters per case:

  • Test Cases (local only) — a file with no registered case yet; run-all won't run it until you push.
  • Test Cases (remote only) — a registered case whose file is gone; it keeps running until config push --prune removes it — deleting the file alone does not stop it.
  • Test Cases (modified) — both exist but differ; run-all still runs the last-pushed version, not the file on disk.
  • Test Cases (synced) — file and registered case match; the only state where what you wrote is what runs.
  • Test Cases (push would refuse) — a case file that fails validation, shown only when there is one. It is a separate status, not a subset of "local only": a malformed new case is counted here and nowhere else, so a "local only" count of zero doesn't mean every file reached the server.

Email Templates ​

Primitive sends transactional email on your app's behalf — the sign-in email, document and collection share notices, waitlist and invitation messages. Each kind has a built-in default, and you can override any of them with your own subject and body. Your app can also register custom types to send app-specific mail such as order confirmations or receipts.

The built-in types are email-sign-in, document-share, document-share-deferred, collection-share, collection-share-deferred, waitlist-invite, waitlist-signup-notification, admin-invite, app-invite, access-request-created, and access-request-resolved. A server function's ctx.api.email.send can send any of these, or a custom kebab-case type you register.

Each type exposes template variables — {{code}}, {{expiryMinutes}} and the optional {{magicLink}} for email-sign-in, and so on — that Primitive substitutes at send time. A custom subject and/or HTML and text body overrides the built-in default; deleting the override restores it. Overrides live in the config directory as email-templates/<emailType>.toml, a [template] table naming the type it overrides (emailType and subject required, htmlBody and textBody optional):

toml
# primitive/dev/email-templates/email-sign-in.toml
[template]
emailType = "email-sign-in"
subject = "Sign in to MyApp"
htmlBody = "<p>Your code: <strong>{{code}}</strong> (expires in {{expiryMinutes}} minutes){{#if magicLink}}<br>Or tap to sign in: {{magicLink}}{{/if}}</p>"
textBody = "Your code: {{code}}{{#if magicLink}} — or sign in: {{magicLink}}{{/if}}"

It's authored and applied like any other configuration:

bash
primitive config create email-template email-sign-in   # scaffold email-templates/email-sign-in.toml
# ... edit subject, html, text ...
primitive config push --only email-template/email-sign-in

The sign-in email is the one type email-sign-in: its code block carries {{code}} and {{expiryMinutes}}, and the optional link sits inside {{#if magicLink}}…{{/if}} as {{{magicLink}}}. magic-link and otp are not sign-in types: an override stored under either name is listed as retired and never rendered, and every write to it except deletion is refused with a message naming the block of email-sign-in its content belongs in.

See Email Sign-In for which parts of the email-sign-in override are removable (the link block) and which aren't ({{code}}).

Delete the file and run primitive config push --prune to drop the override and go back to the built-in default. See Email Templates for the read commands — listing types and override status, viewing a type's current template and variables, and sending a test email.

Environments ​

A project usually targets more than one app — a development app for day-to-day coding, a production app for your customers. The CLI's named environments bind a server and app together so every command (including config pull and config push) knows which app it's talking to, and each environment gets its own isolated config directory. See Project Configuration and Environments.

In a web app, which Primitive environment a build resolves is independent of its Vite mode (the .env.<mode> file of app-behavior keys). When a mode's keys are only correct against one backend, pin the pair with VITE_EXPECTED_PRIMITIVE_ENV=<name> in that mode's .env file: a run that resolves a different environment then fails at startup instead of using the wrong app. See Pinning a mode to a Primitive environment.

Console or Code? ​

TaskConsoleTOML + CLI
Exploring and testingPreferred — visual, interactiveWorks but less convenient
Version-controlled config—Preferred
Quick one-off changesConvenientConvenient
AI agent automation—Preferred — agents speak CLI
CI/CD pipelines—Preferred — scriptable

Push and Diff in Detail ​

The sync loop above is all most changes need. This section is the precise contract behind it — what diff compares, which failures stop a push, and how deletions and out-of-band edits reconcile — for when a push surprises you.

What diff compares ​

config diff and config push --dry-run answer "has this changed?" from one source. diff compares every resource type config push handles — every per-entity type, its test-case sidecars, and the app settings in app.toml, which are compared per field — for CONTENT against the state the server currently holds, and push decides what to apply from that same comparison. There is no per-type exception list to memorize: a clean diff means push has nothing to apply, and a comment-only or formatting-only edit is a change to neither command. A Modified row names the field it differs in — for a database type, the model field inside the [models.*] schema — ahead of the config pull remedy, so a difference you cannot see in the file is diagnosable from the row itself. A difference diff reports is one push acts on: it applies the edit, or it names what it will not overwrite — DRIFT when the server moved and your file did not (run config pull, or --force to overwrite), a CONFLICT when both moved or the direction cannot be established, or an immutable-field difference with the remedy (a blob bucket's ttlTier cannot be changed by an update, so push says so instead of reporting a success that changed nothing). A file push would REJECT is not one diff calls in sync: an unrecognized or retired key, or a value whose spelling is not its declared type, is reported by both commands with the identical message, under Invalid in the diff. If it can't fetch a type's current state from the server, that type is named explicitly under a not compared line instead of being silently left out, and the fallback fails closed — with no last-sync content hash to gate on, the resource is not written and the run tells you to config pull or --force rather than overwriting server state it never managed to read.

A local file with no matching server entity falls into one of two groups, reported separately: a file you authored and never pulled is new — push will create it — while a file a prior pull wrote whose entity is now absent from the server was deleted server-side, so push would re-create it unless you remove the file.

Both run the same local preflight a real push runs — the identical checks, reported in the identical words — and both add the comparisons against live server state. What a preview cannot reach is what only applying can: a server function's build, and a server refusal that depends on your app's state. Those surface when the push runs. What a push guarantees draws the line. And a blocked entity records no sync state, so it stays visible as pending on the next diff instead of quietly reading as "in sync."

What a push guarantees ​

A push has two stages, and only the first is all-or-nothing.

Preflight — the local tree, and nothing else. Before the first mutating call, config push checks the files, and any error among them aborts the whole run with nothing applied. The two stages are a fixed classification, not a loose description: every cross-field rule and every field's own validation is declared as either preflight or apply. preflight means decidable from your files alone and enforced before the first mutating call — the CLI runs the server's own check rather than a local imitation of it, so a preflight refusal is the sentence the server would have given you. apply means the rule needs your app's state, or has path semantics only the server can decide. Unrecognized keys and declared types are preflight on every config type, and so is almost everything about a server function's file (the function type): its entry, its capability grammar, the trigger block shapes, a webhook trigger's signing-secret requirement and reference shape, and a cron entry's expression and timezone. A constraint that spans two fields of one object is decidable here because the state a push writes comes from the file: a function's version carries the whole authored TOML.

Apply — incremental, convergent, and without rollback. Once the preflight passes, entities are applied one after another. A rejection may stop the remaining work, depending on the type — a rule set's or an integration's server error ends the run — while a refused function is recorded and the push continues, so the rest of the tree still lands. Nothing already applied is undone. The exit status is non-zero, and re-running converges: every applied change is idempotent.

What the preflight cannot answer. Whether a {{secrets.KEY}} reference names a secret that exists, whether a webhook scheme's config is valid under this environment's JWKS policy, per-app caps, key collisions, archived entities — each of those is a question about the app's state, not about your file, so each is apply, surfacing during apply rather than preflight. Nothing you could work out from the file alone is on that list; guessing at these locally would mean a preflight that gives a false all-clear. Which stage a refusal came from tells you whether re-running could ever help without touching the file: an apply refusal may depend on your app's state, so fixing that state — creating the missing secret, freeing the cap — and re-running can turn it into a success; a preflight refusal cannot, because it is decided by the tree in front of you and nothing server-side changes the answer. A function whose webhook trigger references a secret you have not created yet is the plain example: the file is well formed, the push starts, that one function fails naming the secret, and its siblings land.

An unrecognized key is an error, not a shrug. config push rejects any key the CLI does not recognize — in a server function's [function] table, a prompt's [prompt] or [[configs]] blocks, or an integration, blob bucket, email template, database type, rule set, group- or collection-type config or metadata-category config's table, in app.toml's [app] / [auth] / [cors] / [invitations] sections, and in a <key>.tests/ test-case file's [test] table — naming the file and the key, so a typo fails locally instead of being silently dropped on the way to the server. The same goes for a whole table: a mistyped header ([integraton]) or a repeated table written with single brackets ([configs] where [[configs]] is meant) is named too, because the file would otherwise push as though the table it meant to write were empty — clearing the fields inside it. The check runs in pre-flight, so a bad key or table aborts before anything is applied, and config diff runs the identical check and reports the identical message — a file push will refuse is never one diff calls in sync. The declared type is part of the contract: a value whose spelling is not its field's type — a quoted number for timeoutMs, a table where JSON text is expected — is a validation error both commands name by field, not something either converts for you. (A handful of fields genuinely accept two spellings and are recorded as such: a prompt's temperature and topP are stored as strings and returned parsed, so "0.2" and 0.2 are one value, and a JSON field may be a native table or JSON text.) The mirror case is a CLI older than the server: config pull names every key the server returned that this CLI version does not know and leaves it out of the file rather than writing a key the next push would reject. Nothing is dropped without a message, and the round trip stays safe — push only sends the fields it knows, so the server keeps its stored values for the rest. Upgrade the CLI (npm i -g primitive-admin@latest) to manage the new keys.

Deletions, Cleared Fields, and Out-of-Band Edits ​

Removing a managed field clears it. For the fields the CLI owns on a synced entity, the local file is the source of truth: delete a line and the next push clears that field on the server rather than leaving the old value in place. Remove a database type's ruleSetName, or an integration's or prompt's description, and push sets it back to empty — for database types it reports each one as Cleared <field> on <type>. Server functions are the strongest form of this rule: a push that changes a function creates a new version from the whole authored file, so a removed line — a trigger, a capability, a limit — is simply absent from the version that runs. status is never on the list: availability is server-owned. This is field-level and separate from removing a whole file: a default push never deletes an entity, so a file you delete simply isn't pushed and its server entity stays put (tree-level deletions are handled by the pull-side reconciliation and push --prune below). App settings follow this rule too, in its strongest form: config push sends the whole of app.toml, so a deleted line clears the setting or resets it to its declared default (see What Lives in the Config Directory).

config pull mirrors deletions. A pull doesn't only write files — after downloading the current configuration it also removes local files whose entity a prior pull wrote but the server no longer returns (an entity you deleted in the console or with a delete command). This keeps the config directory a faithful mirror: because a default config push treats every local TOML as something to create or update, a stale file left behind would re-create the entity you just deleted. Files you authored by hand and never pulled are left untouched — a diff lists them as new — and a file with uncommitted git changes is kept rather than removed, so an in-progress edit is never lost. Pass --no-prune to keep every local file regardless; and because the snapshot below is taken first, config revert restores anything a pull removed unexpectedly.

config push --prune mirrors deletions the other way. By default a push only creates and updates — delete a local file and its server entity lives on. Pass --prune and push also deletes: after the create/update pass, it removes managed entities — the ones a prior pull recorded in sync state — whose local TOML you've since deleted. Entities authored in the console and never pulled are never touched. config diff shows every server entity that has no local file under Remote only, split into managed (which --prune deletes) and unmanaged (left alone). Each deletion is confirmed by a fresh read of the entity and skipped if it changed on the server since your last sync — --force overrides that check — and an entity still referenced by another is reported and kept while the rest proceed. Push confirms once before deleting; --yes skips the prompt for CI, and --dry-run reports the plan without deleting anything.

Make the sync tree the single source of truth. Once you manage configuration as TOML, create entities and land changes by editing files and pushing — including throwaway test or debugging functions — rather than with console edits. The CLI records each pushed entity's server identity in .sync-state.json, the committed baseline at the root of the environment's config directory, and uses it to decide whether a push creates or updates each entity; changes made outside the loop leave that state out of date, and the two directions behave differently. For an entity created outside the loop (a console edit), push adopts by key: a create that hits a conflict matches the existing entity and updates it in place instead of failing, so re-running a push that hit a conflict succeeds. An entity deleted outside the loop leaves a stale entry behind: the next push of an edited version of its TOML updates an entity that no longer exists and fails with a "not found" error. When a push fails that way, remove that entity's entry from .sync-state.json and push again — the entity is created fresh.

Next Steps ​

  • Primitive CLI — Full CLI reference, environments, and sync details
  • Admin Console — The interactive view of the same configuration
  • Server Functions — Server-side logic, authored and pushed from the same tree
Documentation validated against js-bao-wss-client 3.4.0 · js-bao 0.11.0 · primitive-admin 1.0.62 · primitive-app 3.1.0 — 2026-09-30