ObjectStackObjectStack

Command Line Interface

Complete guide for using the ObjectStack CLI to build metadata-driven applications

@objectstack/cli

Command Line Interface for building metadata-driven applications with the ObjectStack Protocol.

Installation

pnpm add -D @objectstack/cli

The CLI is available as objectstack or the shorter alias os. Installed as a dev dependency, the bins are project-local — invoke them as npx os … / pnpm exec os … or via your package scripts.

Your First App in 2 Minutes

Create a project

npm create objectstack@latest my-app
cd my-app

This scaffolds a working project with objectstack.config.ts, a sample object, and all dependencies installed — plus the AI skills bundle and an AGENTS.md for coding agents. (os init is the CLI's own scaffolder for plugin skeletons and bare configs — see below.)

Add more metadata

os generate object customer    # Add a Customer object
os generate action approve     # Add an action
os generate flow onboarding    # Add an automation flow

Launch the dev server

os dev --ui

Open http://localhost:3000/_console/ — you'll see the Console UI with a data browser, metadata explorer, and API documentation. Sign in with the seeded dev admin (admin@objectos.ai / admin123) — os dev provisions it automatically on an empty database. The boot banner also prints the app's MCP endpoint (/api/v1/mcp) so a coding agent can connect to the running app.

Validate & Build

os validate          # Check schema + CEL predicates + widget bindings (no artifact)
os compile           # Build production artifact → dist/objectstack.json

These commands are the AI build loop. In day-to-day work, Claude Code writes the metadata and runs two of them for you: os validate is the gate (it rejects predicate/schema/binding mistakes that fail silently at runtime), and os dev --ui is the human verify surface (the Console, where you confirm the app matches intent). See Build with Claude Code for the full loop.

os dev --ui starts a dev server with the bundled Console UI, auto-loads ObjectQL, an in-memory driver when appropriate, and the Hono HTTP server.

Commands

Development

CommandAliasDescription
os init [name]Initialize a new ObjectStack project in the current directory
os dev [package]Start development mode with hot reload
os serve [config]Start the ObjectStack server with plugin auto-detection
os db cleanReclaim SQLite free space with a one-time VACUUM (ADR-0057)

os init

Scaffolds a new ObjectStack project with configuration, TypeScript setup, and initial metadata files.

Which scaffolder? For a new app, prefer npm create objectstack@latest — it also derives your namespace, pins the framework packages to the current release, and installs the AI skills bundle + AGENTS.md. Reach for os init when you want a plugin skeleton or a bare config in an existing directory.

os init my-app                    # Create with default "app" template
os init my-plugin -t plugin       # Create a plugin project
os init blank -t empty            # Minimal config only
os init my-app --no-install       # Skip dependency installation

Options:

  • -t, --template <template> — Template: app (default), plugin, empty
  • --no-install — Skip automatic dependency installation
  • -p, --package-manager <npm|pnpm|yarn|bun> — Package manager to use (auto-detected from the environment)

Templates:

TemplateWhat it creates
appFull application with objects, barrel imports
pluginReusable plugin package with objects
emptyMinimal project with just objectstack.config.ts

os dev

Starts development mode. Three usage shapes:

  1. With local source (objectstack.config.ts in cwd) — auto-compiles to dist/objectstack.json if missing, then delegates to os serve --dev.
  2. With a pre-built artifact (--artifact <path|url>) — skips auto-compile and boots the artifact directly. No objectstack.config.ts needed in cwd. Useful for trying out a published app in dev mode without cloning its source.
  3. Monorepo root (cwd has pnpm-workspace.yaml) — orchestrates pnpm -r dev across packages.
os dev                     # Auto-compile cwd config, then start
os dev my-package          # Workspace package (monorepo orchestration mode)
os dev --ui -v             # Dev server with Console UI + verbose

# Boot a remote artifact in dev mode (no local config needed)
os dev --artifact https://raw.githubusercontent.com/<org>/<repo>/main/dist/objectstack.json

# Override storage / auth on the fly
os dev --database file:./data/test.db --auth-secret $(openssl rand -hex 32)

Options (the runtime overrides mirror os start — each flag overrides the matching env var):

FlagEnv equivalentPurpose
-a, --artifact <path|url>OS_ARTIFACT_PATHBoot a pre-built artifact directly; skips auto-compile
-d, --database <url>OS_DATABASE_URLfile:… / libsql:// / postgres:// / mongodb:// / memory://
--database-driver <kind>OS_DATABASE_DRIVERForce sqlite | sqlite-wasm | turso | postgres | mysql | mongodb | memory
--database-auth-token <t>OS_DATABASE_AUTH_TOKENlibsql/Turso token
--auth-secret <s>OS_AUTH_SECRETOverride the dev-fallback secret
--environment-id <id>OS_ENVIRONMENT_IDEnvironment identifier (default env_local)
-p, --port <n>OS_PORT / PORTListen port (default 3000). In dev a busy port auto-hops to the next free one; the banner shows the actual port.
--uiForce Console UI on (already on by default in dev)
--compileForce compiling objectstack.config.tsdist/objectstack.json before starting (auto when the artifact is missing; ignored with --artifact)
--freshEphemeral OS_HOME in the OS tempdir (clean DB, uploads root, and other OS_HOME-keyed state), auto-deleted on exit; implies --seed-admin. See the scope note below
--seed-admin / --no-seed-adminSeed a dev admin (admin@objectos.ai / admin123) on an empty DB — default on; override with --admin-email / --admin-password
-v, --verboseVerbose output

By default os dev keeps your data between restarts in a project-local SQLite file at .objectstack/data/dev.db (created on first run). Pass --database, set OS_DATABASE_URL, or use --fresh for a throwaway run.

What `--fresh` covers

--fresh isolates the state the CLI places for the run: everything keyed off the ephemeral OS_HOME (the dev SQLite DB, the uploads root, plugin state under OS_HOME) plus the env channels os dev publishes for it — OS_DATABASE_URL and OS_STORAGE_LOCAL_ROOT. That tempdir is deleted on exit.

It does not relocate state your app reaches by a relative path it declares itself — for example a datasource with config: { filename: '.objectstack/data/my.db' }. Such a path is resolved by its own consumer against the process working directory, which --fresh does not change, so the file is written into your project tree and is still there after the run ends. Declare an absolute path (or one derived from OS_HOME) when you want a datasource to follow --fresh.

With a file-backed SQLite database, dev also provisions a sibling <db>.telemetry.<ext> file registered as the telemetry datasource — lifecycle-classed system data (activity streams, job runs, notifications, audit) lands there instead of the business DB (ADR-0057). Opt out with OS_TELEMETRY_DB=0, or point it elsewhere (any mode, including serve) with OS_TELEMETRY_DB=<path>.

os serve

Starts the ObjectStack server with automatic plugin discovery:

  • Auto-loads ObjectQL Engine when objects are defined
  • Auto-loads InMemory Driver in dev mode
  • Auto-loads App Plugin for metadata
  • Auto-loads Hono HTTP Server for REST APIs
  • Auto-loads the auth tier plugins (@objectstack/plugin-auth, @objectstack/plugin-security, @objectstack/plugin-audit) when the preset includes the auth tier and the user did not pin them in objectstack.config.ts
os serve                   # Default: port 3000
os serve -p 4000           # Custom port
os serve --dev             # Development mode (pretty logs, devPlugins)
os serve --dev --ui        # Dev mode with Console UI
os serve --no-server       # Skip HTTP server (kernel only)
os serve --preset minimal  # Skip auto-loaded auth/i18n/ui plugins

Options:

  • -p, --port <port> — Server port (default: env OS_PORT or 3000)
  • --dev — Development mode (loads devPlugins, pretty logging)
  • --ui / --no-ui — Toggle Console UI at /_console/ (default on)
  • --server / --no-server — Toggle HTTP server plugin
  • --prebuilt — Skip esbuild / bundle-require and load the config as native ESM (use this in production builds where the config is already pre-compiled)
  • --preset minimal | default | full — Override the auto-registration tier (see below)

Tier presets

os serve decides which optional plugins to auto-register from a tier list. Any plugin already present in config.plugins always wins; tiers only gate the automatic registration of optional plugins.

PresetTiersAuto-loaded optional plugins
minimalcorenone
default (default)core, i18n, ui, ai, authi18n service, Console UI, AI service, Auth + Security + Audit
fullcore, i18n, ui, ai, authcurrently an alias of default — same tiers, no additional plugins

The auth tier requires OS_AUTH_SECRET to be set; otherwise AuthPlugin is skipped with a yellow warning and the /api/v1/auth/* endpoints will return 404. (In --dev mode the CLI falls back to an insecure local secret so login works out of the box.) To take full control, set tiers on the stack config:

import { defineStack } from '@objectstack/spec';

export default defineStack({
  manifest: { /* ... */ },
  tiers: ['core'],            // disable all optional auto-registration
  plugins: [
    // ... only what you explicitly want
  ],
});

os db clean

Reclaims SQLite free space with a one-time VACUUM (ADR-0057 §3.4). The platform reclaims space incrementally (auto_vacuum=INCREMENTAL), but that setting only takes effect on a fresh database — files created before it stay pinned at their high-water mark until one full VACUUM rebuilds them. Non-destructive: every row survives; free pages return to the OS. Cleans the telemetry sibling too when one exists.

os db clean                                      # default: the per-project dev DB
os db clean --database file:./data/app.db        # explicit target

Options:

  • -d, --database <url> — SQLite database URL/path (defaults to $OS_DATABASE_URL, then the per-project dev DB)

Console UI

Launch the development server with the Console UI:

os dev --ui                # Default: port 3000
os serve --dev --ui -p 4000

The Console UI is a metadata-driven admin interface that provides object exploration, package management, and runtime metadata diagnostics.

Architecture:

┌─────────────────────────────────────────┐
│          os dev --ui (:3000)            │
├─────────────────────────────────────────┤
│  Hono Server                            │
│  ├─ /api/v1/*     → ObjectStack API     │
│  ├─ /_console/*   → Console SPA         │
│  └─ /*            → custom routes       │
└─────────────────────────────────────────┘

The prebuilt Console bundle ships with the framework packages and is served at /_console/ — no separate frontend install or build step is needed.

Production

os start

Boots a production server directly from a compiled objectstack.json artifact — no objectstack.config.ts required. This is the canonical "deploy a built ObjectStack app" command: hand a server one JSON file (or a URL pointing at one) and it runs. When the cwd does contain an objectstack.config.ts and no artifact exists yet, os start auto-compiles it first; with no config and no artifact at all it boots an empty kernel with the Console + marketplace, so you can install apps interactively.

# Quick start — load ./dist/objectstack.json with sqlite at file:<home>/data/objectstack.db
os start

# Pick everything via flags (no env vars needed)
os start \
  --artifact ./build/myapp.json \
  --database file:./data/prod.db \
  --auth-secret $(openssl rand -hex 32) \
  --port 8080

# Remote artifact + Turso/libSQL backing store
# (needs the optional driver package: npm install @objectstack/driver-turso)
os start \
  --artifact https://cdn.example.com/app.json \
  --database libsql://my-db.turso.io \
  --database-auth-token $TURSO_TOKEN

# Postgres
os start --database "postgres://user:pass@host:5432/mydb"

# Pure env-var style still works (Docker / Fly / k8s friendly)
OS_ARTIFACT_PATH=./build/myapp.json \
OS_DATABASE_URL=file:./data/prod.db \
OS_AUTH_SECRET=… \
os start

Options (all override the matching env var):

FlagEnv equivalentPurpose
-a, --artifact <path|url>OS_ARTIFACT_PATHFile path or http(s):// URL to the compiled artifact
OS_ARTIFACT_URLBoot a published artifact by reference, optionally content-hash pinned via a #sha256= fragment. See Artifact-pinned boot
-d, --database <url>OS_DATABASE_URLfile:… / libsql:// / postgres:// / mongodb:// / memory://
--database-driver <kind>OS_DATABASE_DRIVERForce sqlite | sqlite-wasm | turso | postgres | mysql | mongodb | memory when the URL is ambiguous
--database-auth-token <token>OS_DATABASE_AUTH_TOKENAuth token for libsql/Turso
--auth-secret <secret>OS_AUTH_SECRET / AUTH_SECRETSecret for @objectstack/plugin-auth. If neither the flag nor the env var is set, os start auto-generates one and persists it at <home>/auth-secret
--home <dir>OS_HOMEHome directory for persistent state (default <cwd>/.objectstack when an objectstack.config.ts is present, otherwise ~/.objectstack)
--environment-id <id>OS_ENVIRONMENT_IDEnvironment identifier (default env_local)
-p, --port <port>OS_PORT / PORTListen port (default 3000). Production fails loudly if the port is busy — see note below.
--ui / --no-uiMount the Console portal at /_console/. Enabled by default (so you can install marketplace apps); pass --no-ui to disable it.
-v, --verboseVerbose output

Port conflicts: production never auto-shifts. Unlike os dev (which hops to the next free port for local convenience), os start exits with an error if its resolved port is in use. A silently drifted port would break your reverse-proxy upstream, OS_AUTH_URL callbacks, and OS_TRUSTED_ORIGINS (CORS). Pin the port explicitly (OS_PORT=8080 os start) and keep OS_AUTH_URL / OS_TRUSTED_ORIGINS in sync when you change it.

Resolution priority (artifact): --artifact > OS_ARTIFACT_URL > OS_ARTIFACT_PATH > <cwd>/dist/objectstack.json > <home>/dist/objectstack.json > auto-compile from objectstack.config.ts (when present) > empty kernel. Resolution priority (database): --database > OS_DATABASE_URL > DATABASE_URL (legacy) > file:<home>/data/objectstack.db.

A named artifact (--artifact or OS_ARTIFACT_PATH) does not participate in that fall-through: it is used as given, and a local path that does not exist fails the boot — naming the path and which of the two named it — instead of quietly continuing down the list. You asked for a specific artifact, so booting something else (or an empty kernel) would hide the typo behind a running server. The fall-through applies to the conventional locations only. Remote (http(s)://) sources cannot be checked up front and are validated when fetched.

What it boots:

  • Reads the artifact's manifest, objects, views, flows, …
  • Auto-registers the platform services declared in requires: [...] (e.g. ai, automation, analytics, auth, ui). Declaring a service capability (automation, analytics, ai, audit, …) is a requirement: if its provider package isn't installed, boot fails fast with a clear error instead of silently starting without a capability you asked for. (auth and ui are tier-gated with their own opt-in rules — auth's secret-gated skip is described below.)
  • Auto-detects the driver from the database URL scheme (memory:// → in-memory, libsql:///https://*.turso.* → Turso — via the optional @objectstack/driver-turso package, and a loud failure with the install command when it is missing rather than a fallback to sqlite —, postgres[ql]:///pg:// → pg, mongodb[+srv]:// → MongoDB, otherwise sqlite)
  • Runs standalone boot mode with one active environment.

Authentication: os start always resolves an auth secret — --auth-secret > OS_AUTH_SECRET / AUTH_SECRET env > a secret auto-generated and persisted at <home>/auth-secret on first run — so /api/v1/auth/* (login/register) and the Console's login flow work out of the box, without any manual secret provisioning. Set the env var (or flag) explicitly when you deploy across multiple nodes or want to rotate the secret.

os start vs os serve: os serve boots from objectstack.config.ts (TypeScript source). os start boots from objectstack.json (compiled artifact) and falls back to the same default-host path if you happen to run it without a config but with an artifact present. The two commands ultimately go through the same kernel — they just differ in which input shape they accept. See Source vs Artifact below.

Build & Validate

CommandDescription
os compile [config]Compile configuration to a JSON artifact (dist/objectstack.json)
os build [config]Alias for os compile (scaffolded projects wire it as npm run build)
os validate [config]Validate schema, CEL predicates, and widget bindings — the same gates as os compile/os build, no artifact emitted
os info [config]Display metadata summary (objects, fields, apps, agents, etc.)

os compile

Bundles and validates your objectstack.config.ts against the ObjectStackDefinitionSchema, then outputs a deployable JSON artifact.

os compile                           # Default output: dist/objectstack.json
os compile -o build/stack.json       # Custom output path
os compile --json                    # JSON output for CI pipelines

Options:

  • -o, --output <path> — Output path (default: dist/objectstack.json)
  • --json — Output compile result as JSON (for CI)

Output example:

◆ Compile
────────────────────────────────────────
  → Loading configuration...
  Config: objectstack.config.ts
  Load time: 57ms
  → Normalizing stack definition...
  → Lowering inline handlers...
  → Validating protocol compliance...
  → Running author-time rules (41)...
  → Checking capability providers (#3366)...
  → Collecting package docs (ADR-0046)...
  → Writing artifact...

  ✓ Build complete (74ms)

  Data: 2 Objects  6 Fields
  UI: 1 Views  1 Actions
  Runtime: 3 plugins

  Artifact: dist/objectstack.json (7.6 KB)

Those counts are from a sample project, named in full under os info below — the same fixture backs both output examples.

The resulting dist/objectstack.json is a portable, self-describing deployment unit — you can hand it to os start (locally or on a server), publish it to a CDN, or fetch it over HTTP from another runtime. See os start and Source vs Artifact for details.

os validate

The fast, artifact-free verification gate. It runs the same structural and semantic checks as os compile/os build but writes no dist/, so it is the command to run after every metadata edit. Use it before reporting a change done.

os validate                  # Validate current directory
os validate --strict         # Warnings as errors
os validate --json           # JSON output for CI
os validate path/to/config   # Validate specific file

Gates run (each exits non-zero with a located, corrective message):

  1. Protocol schema — the stack conforms to ObjectStackDefinitionSchema (@objectstack/spec).
  2. CEL / predicate validation (ADR-0032) — every visible / disabled / requiredWhen / validation rule / flow condition / sharing rule is parsed for CEL syntax and checked that each record.<field> reference exists on the target object. This catches a bare field ref (done instead of record.done) that would otherwise evaluate to null and silently hide an action on every record (#2183/#2185).
  3. Widget-binding integrity (ADR-0021) — every dashboard widget's dataset / dimensions / values resolves to a declared dataset/field, so a dangling binding fails here instead of rendering an empty chart.

…and every other author-time rule the three commands share — view shape, name/action/filter references, page sources, approval approvers, security posture, the autonumber and view-reference lints. All of them come from one registry, so the list is the same on os build and os lint; see The one gate, four doors for the full matrix. Every failing rule is reported in a single run rather than stopping at the first, so one pass shows the whole hole.

Options:

  • --strict — Treat warnings as errors (exit code 1)
  • --json — Output results as JSON

Warnings checked (advisory, non-blocking unless --strict):

  • Missing manifest.id (required for deployment)
  • Missing manifest.namespace (required for multi-app hosting)
  • No objects defined
  • No apps or plugins defined
  • Every advisory the rule registry raised (dangling stageField / highlightFields pointers, replay-unsafe seeds, ambiguous flow status, …)

os validate, os build and os lint share one rule registry, so a config that passes any of them will not fail another on schema/predicate/binding grounds — a CLI test fails the build if a rule that can gate runs on fewer than all three (#4409). In a scaffolded project these are wired as npm run validate and npm run build; your AGENTS.md tells coding agents to run npm run validate after editing metadata. See Validating metadata.

os info

Displays a summary of your metadata without compilation or validation:

os info                # Show metadata summary
os info --json         # JSON output for tooling

Output example:

◆ Info
────────────────────────────────────────

  My App v0.1.0
  my-app
    Namespace: my_app
    Type: app

  Data: 2 Objects  6 Fields
  UI: 1 Views  1 Actions
  Runtime: 3 plugins

  Objects:
    my_app_note (2 fields, user) — Note
    my_app_ticket (4 fields, user) — Ticket

  Loaded in 59ms

The project behind these counts. The os compile and os info examples above were run against one fixture, so the numbers can be rebuilt and checked rather than taken on trust: the project this page scaffolds in Your First App in 2 Minutesnpm create objectstack@latest my-app, whose blank starter supplies the two-field my_app_note object and the three connector plugins — plus the four-field ticket object, the view and the action listed under Build with Claude Code, renamed out of that page's support_desk_ namespace into my_app_. That page's support app is not added, and a zero count is never printed, which is why the UI: row reads 1 Views 1 Actions with no Apps. The walkthrough's os generate commands are not part of the fixture — run those as well and the summary gains my_app_customer and a Logic: row. Timings are machine identity, and the rule count and artifact size track the CLI version; everything else is fixture identity and reproduces.

Schema migrations

The metadata→database sync is additive-only: on boot it creates missing tables, adds new columns and creates missing indexes, but never alters or drops existing ones. So a non-additive change to an object already backed by a database — relaxing required (drop NOT NULL), changing a field's type/length, removing a field, or re-scoping a unique constraint — silently diverges from the live schema, and the physical column wins at write time. os migrate reconciles the database to the metadata (the source of truth).

CommandDescription
os migrate planDry-run: show how the database has drifted from metadata, categorised safe / needs-confirm / destructive (no changes applied)
os migrate applyReconcile the database to metadata. Applies loosening changes; destructive ones require --allow-destructive
os migrate plan                              # Preview drift (no changes)
os migrate apply                             # Apply safe (loosening) changes, with a confirm prompt
os migrate apply --yes                       # Skip the prompt (CI / scripts)
os migrate apply --allow-destructive --yes   # Also drop orphaned columns, tighten NOT NULL, narrow types
os migrate apply --force                     # Migrate even though another process is using the database
os migrate plan --json                       # Machine-readable output

Nothing is written before you confirm

Both commands boot your app to read its metadata. That boot no longer touches the target database: the additive schema sync (create missing tables, add missing columns) and the artifact's inline seed data are deferred, not performed. So plan really is a dry run, and everything apply is about to do — additive work included — is on screen before the [y/N] prompt:

  New (additive — created when you apply)
    + crm_quote [create_table, 9 column(s)]
    + crm_contact [add_columns: nickname, region]

  In place (existing rows converged when you apply)
    ~ crm_contact [normalize_datetime_storage: signed_at — 1,240 row update(s)]

  Safe (loosening — applied without --allow-destructive)
    ✓ crm_contact.email [relax_not_null]

Answering n leaves the database exactly as it was.

The two upper sections differ in a way worth reading carefully. New is purely additive — it creates tables and columns and never touches a row. In place rewrites existing data: the storage-form convergence a Field.datetime column needs when the database predates the canonical UTC storage (ADR-0053 addendum D-B1..D-B4). It carries a row count because that is the number deciding whether to run it now; on MySQL it reads widen_datetime_columns and is an ALTER … MODIFY table rebuild that holds a metadata lock for its duration.

Both are safe to apply — the convergence preserves every stored instant and is idempotent — but only the second takes time proportional to your data.

Occupancy check (SQLite)

A running os dev / os serve holding the same SQLite file open is the usual way a migration goes wrong: the migration itself is transactional and swaps tables inside the file, but the live server keeps prepared statements and a schema cookie that the migration invalidates, and its writes can collide as SQLITE_BUSY. Before booting, os migrate checks two things:

  1. Which processes hold the file open (/proc on Linux, lsof on macOS). The signal that works in every journal mode, and the only one that names the process to go and stop. It is also the only one that sees an idle server on a rollback-journal database, where a lock lasts no longer than the transaction that took it.
  2. A SQL lock probe (PRAGMA locking_mode = EXCLUSIVE under busy_timeout = 0). ObjectStack keeps file-backed SQLite in WAL mode, where this catches any attached connection — idle or not — including the cases step 1 cannot reach: a platform without process inspection, or a database held by another user's process.

Either one firing counts as busy. Both are non-destructive: no row is read or written.

✗ .objectstack/data/standalone.db is in use — it is open in pid 12367 (node).
⚠ Stop the process using it (a running "os dev"/"os serve" is the usual one)
  and re-run, or pass --force to migrate anyway.
CommandIf the database is in use
os migrate planWarns and continues — a plan writes nothing either way
os migrate applyRefuses (exit 1, error: database_busy under --json). Stop the other process, or pass --force
os migrate files-to-references --applyRefuses likewise — it rewrites rows, so a concurrent writer is at least as dangerous
os migrate meta --stored --applyRefuses likewise — it rewrites sys_metadata rows, and a live process saving metadata is exactly the collision

The check applies to SQLite only: Postgres and MySQL take their own server-side locks. Only same-user processes are visible without elevated privileges, and a -wal/-shm left behind by a crashed process is deliberately never treated as occupancy on its own.

CategoryExamplesApplied by
saferelax NOT NULL → nullable, widen a varchar, create a declared index, replace a legacy installation-wide unique with its per-organization compositeos migrate apply (and dev auto-reconcile)
needs_confirmnon-narrowing type change, rebuild a non-unique index whose columns changedos migrate apply
destructivedrop an orphaned column or index, tighten NOT NULL, narrow a type, rebuild an index as UNIQUEos migrate apply --allow-destructive

Index drift

plan covers indexes as well as columns:

OpWhat it means
create_indexMetadata declares an index the database does not have
replace_unique_indexA field's unique used to be enforced installation-wide, but metadata now scopes it per organization — the legacy single-column index is swapped for the NULL-safe (COALESCE(organization_id, '__global__'), field) composite. A pure relaxation: it creates before it drops, and cannot fail
recreate_indexAn index exists under the declared name but with different columns/uniqueness. The additive sync skips it by name, so it must be dropped and rebuilt. This is also how a per-organization unique becomes NULL-safe: a tightening, so it runs a duplicate pre-flight probe first — rows the old NULL-distinct index wrongly admitted block the op with a report instead of failing a boot, and the old index stays in place until they are resolved
drop_indexAn index carrying ObjectStack's generated naming (uniq_… / idx_…) that metadata no longer declares

Orphan detection is deliberately limited to indexes ObjectStack itself generated. A hand-rolled covering index you added in psql is never reported as drift, and --allow-destructive will not delete it.

Dev self-heal. os dev runs the SQL driver with autoMigrate: 'safe', so safe changes (you just made a field optional; a unique field became organization-scoped) are applied to your existing dev database automatically on restart — no os migrate needed, no data loss. Auto-reconcile is dev-only and never destructive; it is force-disabled under NODE_ENV=production, where every change is shown by os migrate plan before you apply it deliberately.

os migrate only sees objects in your compiled artifact — run os build first. It never drops a table that is absent from your metadata, and on SQLite it reconciles via a table rebuild (copy → swap) that preserves your data.

Data migrations

The commands above reconcile schema. A data migration rewrites rows, and whether it is done is a fact about your database, not about the platform version you installed — so each deployment runs it, and its result is recorded where the data lives.

CommandDescription
os migrate files-to-referencesConvert legacy file-field values to sys_file references, verify the ownership ledger, and record the deployment's migration flag
os migrate value-shapesScan stored reference and structured-JSON field values against the platform's value contract, and record the deployment's migration flag when clean
os migrate summary-nullsBackfill roll-up count / sum columns still stored as NULL on parent rows created before the insert-time seed. Repairs values; no flag, nothing depends on it having run
os migrate meta --storedReplay the metadata conversion chain over this deployment's sys_metadata rows and rewrite the ones still carrying a pre-protocol shape. Hygiene, not a gate — nothing depends on it having run
os migrate duplicatesReport business identifiers already minted twice across the organization partitions, and the rows blocking the boot-time NULL-safe index tightenings — a read-only inventory as JSON on stdout. Renumbers nothing and writes nothing at all; run it before the boot-time tenancy repair, which overwrites part of the evidence
os migrate files-to-references            # Dry run: full report, writes nothing
os migrate files-to-references --apply    # Convert, verify, record the flag (prompts)
os migrate files-to-references --apply --yes --json   # CI / scripts
os migrate files-to-references --object product       # Restrict to one object (repeatable)

A file / image / avatar / video / audio field value is an opaque sys_file id that the platform owns. Values written before that (an inline {url, name, …} blob, or a URL naming this platform's own …/storage/files/:id resolver) are converted in place; external URLs are reported, never re-hosted — re-hosting third-party content is a licensing and privacy decision, and the right fix is usually to model the field as a url field instead.

The run then reconciles what records actually hold against what sys_file records as each file's owner. Zero blocking discrepancies is what records the flag — and that flag, not the version number, is what enables behaviour that depends on the data actually being migrated. Never running it is safe: files are simply retained forever, and media values keep warning instead of failing.

Exit status is 0 only when the self-check passes, so CI can gate on it.

What the flag turns on

Once verifiedEffect
Media value shapesA malformed file / image / avatar / video / audio value is rejected (400 invalid_type) instead of warned about. Set OS_ALLOW_LAX_MEDIA_VALUES=1 to re-open leniency while diagnosing.
Released-file collectionA field file whose one owning record lets go (the field is cleared or the record deleted) is tombstoned into the declared 30-day grace window; re-referencing the id within the window revives it, and after it the platform sweep reclaims the row and its bytes. Unverified deployments keep every released file forever.

Other value classes are unaffected: a lookup or location value keeps its own warn-first rollout until os migrate value-shapes (below) supplies their evidence, because this migration is evidence about file values and says nothing about theirs.

A dry run writes nothing — not the conversions, and not the flag either, even when the self-check would pass. --apply is the only writing mode. A later run that fails its self-check clears the flag's verified state, so a database that has drifted closes its own gate.

A running server reads the flag once; after migrating, restart it for enforcement (and release-time tombstoning) to take effect. The sweep's final delete check re-reads the flag fresh, so a later failing run stops collection without a restart.

os migrate value-shapes

The same gate for the non-media value classes — references (lookup, master_detail, user, tree) and structured JSON (location, address, composite, repeater, record, vector).

os migrate value-shapes                    # Scan: full report, writes nothing
os migrate value-shapes --apply            # Scan, then record the flag if clean (prompts)
os migrate value-shapes --apply --yes --json   # CI / scripts
os migrate value-shapes --object contact       # Restrict to one object (repeatable)

This one converts nothing. Its sibling rewrites legacy file values because the platform narrowed that storage form and therefore owes the conversion; a location stored as {latitude, longitude} instead of {lat, lng} is application data whose correct value only its author knows. So the run reports — object, field, type, how many records, sample record ids, and the parse issue — and you fix the values (or the code writing them) and re-run until it is green. Because there is nothing to convert, --apply's only write is the flag row itself.

A scan that is truncated (by --max-records) or that cannot read an object fails the gate even with zero violations found: "none in the part we read" is not the claim the flag makes.

Once verifiedEffect
Reference + structured-JSON value shapesA malformed value of those classes is rejected (400 invalid_type) instead of warned about. Set OS_ALLOW_LAX_VALUE_SHAPES=1 to re-open leniency while diagnosing.

This flag is deliberately separate from the file migration's. That one attests that file values were migrated and their ownership reconciled — it says nothing about whether a lookup id or a location payload is well formed, so it may not vouch for these classes. A deployment can legitimately have passed either without the other.

OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1 turns on every value class at once, regardless of which migrations this deployment has run. It is the "I already know my data" lever, not the route to strictness — the route is running the migration that produces the evidence.

Same writing rules as its sibling: a dry run writes nothing, --apply is the only writing mode, a later failing run clears the verified state, and a running server reads the flag once — restart it after a successful apply.

os migrate summary-nulls

A roll-up summary field of function count or sum is 0 over an empty child collection — zero children is zero, not "unknown" — and since #5749 a parent row is created holding that value. Rows created before that are the exception: nothing seeded them, and the recompute that maintains a roll-up runs only when one of the parent's children is written, so a parent that has never had a child keeps its NULL indefinitely. filter ["task_count", "=", 0] then silently omits it, and so do sorting, GROUP BY and any formula reading the column (null propagation).

os migrate summary-nulls                    # Dry run: full report, writes nothing
os migrate summary-nulls --apply            # Recompute and write (prompts)
os migrate summary-nulls --apply --yes --json   # CI / scripts
os migrate summary-nulls --object project       # Restrict to one object (repeatable)

Each affected row is recomputed, not set to 0. A pre-upgrade parent that does have children is NULL too, and its correct value is the aggregate over them — writing 0 there would replace a missing value with a wrong one, and the next child write would change it back. The report separates the two: N NULL row(s), M with real child data.

min / max / avg are never touched. They are undefined on an empty set, so a null there is the correct reading of "no child rows"; the report lists them as deliberately skipped.

Idempotent — every write turns a NULL into a number, so a second run finds nothing and writes nothing. Re-running until the report says zero is the verification, which is why this command records no flag: it repairs values and changes no behaviour, so there is no posture for a flag to attest.

A deployment whose database was seeded fresh on this version has nothing to do here: its parents were created with the value already in place, and the run reports zero.

A database created by this version needs no migration

A deployment whose database the platform creates from empty records these flags at that moment, so it is enforcing from its first boot and never enters the warn regime at all. Nothing to run: the fact a migration would establish — no legacy value is stored here — is already settled by the store having no history.

The platform attests this only for a store it watched itself create: every table made by that first boot, none found already present. A database that existed before — an upgrade, a restore, a store shared with anything else — attests nothing and produces its evidence by running the command, because "found empty" and "created empty" are not the same claim.

Importing legacy values into such a deployment is rejected at the write path rather than silently accepted. That is the intended outcome; if you must admit them temporarily, OS_ALLOW_LAX_MEDIA_VALUES=1 (media) and OS_ALLOW_LAX_VALUE_SHAPES=1 (references and structured JSON) re-open leniency per class, and re-running the corresponding migration re-establishes its flag from the data itself.

How you find out a gate is open

You are told, in the two places an upgrade actually looks.

os migrate meta --from 16 — the metadata half of the upgrade — ends by naming the data migrations that remain, scoped to the field classes your metadata declares. It reads no database, so it reports what is left to do, never what this deployment has already done. The machine-readable output carries the same list under dataMigrations.

The server, once per boot, logs one line per gate that is still open here and the command that closes it. Only the lax posture announces itself: a closed gate logs that it is enforcing, and an app that declares neither class of field says nothing at all. So a running deployment always tells you the state of its own data — which is the question os migrate meta cannot answer.

os migrate meta --stored

The two commands above are about application data. This one is about the metadata itself, at rest: the sys_metadata rows Studio and the runtime authoring APIs write.

Those rows already read correctly whatever protocol they were written under — every rehydration seam replays the full conversion chain, so a body from an older major is served in today's canonical shape and always will be. What the rows do not do is change: they keep their original bytes, the chain re-lowers them on every load, and each one logs a conversion notice once per boot. This command ends that for the deployment that runs it.

os migrate meta --stored                    # Preview: per-row report, writes nothing
os migrate meta --stored --apply            # Rewrite the rows (prompts)
os migrate meta --stored --apply --yes --json   # CI / scripts
os migrate meta --stored --type view --type object   # Restrict to a type (repeatable)

It walks active and draft rows across every organization (archived rows are a record of what was and are never read), replays the same chain the read path does, and re-saves each changed body through the normal write path — so a rewritten row gets a sys_metadata_history entry, a fresh checksum, and the mutation projectors, exactly like an author's save. The history entry's source is migrate-stored, so a later diff shows which changes were an upgrade and which were somebody's edit.

What it deliberately declines, and names in the report rather than counting as done. This table is the operator-observable surface — what a run can actually report, from the command above or the route below. The function's own JSDoc in packages/metadata-protocol/src/protocol.ts documents its full internal surface instead, and so lists one decline more: flow rows skipped for want of a reachable automation engine, which neither operator door can produce, because both supply a live one.

Not rewrittenWhy
A row stored under a non-canonical metadata type (a plural or alias spelling, e.g. fields)Canonicalizing bodies is an edit; rewriting a stored type spelling is an identity move — a new (org, type, name, package_id) key — which this pass is not ruled to make. Re-author the item under its canonical type and drop the old row
Types with no repository write path (agent)Their write path records no history and would force a draft live — a half-write is worse than leaving the row to the read path
Rows that still fail the current schema after conversionThat is a genuine contract violation, not chain-owned history. The write path's rejection is correct; fix the row in Studio
A flow whose rename the conflict guard refusedThe old node-type token is a live name something else owns here. Rewriting would clobber that owner, so the row fails loudly naming the token — never a silent skip

Flows are covered, and cost one extra plugin. Flow-node conversions carry an open-namespace conflict guard that has to consult the live executor registry to tell a rename from a clobber, so this run boots the automation engine — in an inert mode that installs the node registry and then arms nothing: no flow registered, no record trigger or scheduled job bound, no connector materialized, no suspended run resumed. A migration process must not become a second server. What gets written back for a flow is the conversion result plus the condition envelopes the schema derives, and deliberately not the schema's defaults (version, runAs, per-edge type) — persisting a default the author never wrote would pin that row to today's value while untouched rows follow tomorrow's, which is the drift this command exists to remove.

--apply is the only writing mode, and it rewrites metadata — each affected row's checksum moves and each gets a history entry. Preview first. Like the other row-rewriting migration, an apply run refuses to start while another process holds the SQLite database (--force overrides).

Nothing gates on this having run. The read path is the guarantee, for every deployment, whether or not anyone runs this — an operator-run migration is not something the platform can depend on. What running it buys is hygiene (cleaner diffs, exports and history from here on, and the recurring boot notices go quiet) plus one thing that was previously unobtainable: you can assert it. A run with nothing left to do exits 0; a deployment with rows still carrying an old dialect exits 1. So "my metadata is on protocol N" becomes a check rather than a belief.

Note the division of labour with the default mode: os migrate meta --from N lists the edits an author's source needs and reads no database; --stored rewrites one deployment's rows and reads no config. Same chain, opposite ends of the contract — which is why the two modes are mutually exclusive.

Without shell access, use the route. This command needs to reach the deployment's database directly, which a hosted operator cannot do. The same pass is exposed over HTTP:

POST /api/v1/meta/_migrate-stored
Content-Type: application/json

{ "apply": true, "types": ["flow"] }

or from the SDK:

const preview = await client.meta.migrateStored();              // writes nothing
const result  = await client.meta.migrateStored({ apply: true });

It returns the same report the CLI renders, and takes the same posture: preview unless apply is literally true, types optional. It requires the manage_metadata capability — it rewrites every eligible row in the deployment, not one item — and answers 403 otherwise. Flows need no extra setup on this path: the server already holds a live automation engine, so the run resolves the executor registry the conflict guard needs from the process it is running in.

os migrate duplicates

The one command in this family that is not a migration: it writes nothing under any flag, and there is nothing to apply. It inventories business identifiers the platform already handed out twice — one value held by rows in more than one of the organization partitions a unique: 'organization' index separates.

The gap it reports is a real one and predates the repair for it. A seeded row written before any organization existed carries organization_id = NULL, an API row carries the signed-in organization, and the partitioned unique index (COALESCE(organization_id, '__global__'), field) does not bite across the two — so each side allocated from its own autonumber counter and both could mint CASE-00001.

os migrate duplicates                                  # The report — JSON on stdout
os migrate duplicates > duplicates-2026-08-18.json     # Archive it; the file is the deliverable
os migrate duplicates --object crm_case                # Restrict the scan to one object
os migrate duplicates --database-url postgres://…      # Inspect a database directly

Output is always the JSON document. There is no --json flag and no human-rendered mode — the report is the deliverable, you archive it, and a second renderer would be a second contract to keep true. The boot behind it is read-only: no DDL, no seed, and a missing SQLite file is not brought into existence. A full run leaves the rows and the counters byte-identical, so pointing it at production changes nothing about production.

Run it before the repair reaches this deployment. On its first boot after the upgrade, a single-organization deployment adopts those untenanted seed rows into its organization and merges the two counters — which is the same state this report reads. Half of what it can tell you does not survive that:

ReportedSurvives the repair?
The duplicates themselves — every value held across two partitions, with the id, organization and creation time of each holderYes. The repair deliberately refuses to adopt a row whose identifier is already taken in the destination partition, so those rows keep organization_id = NULL and stay visible
The live condition — an object still running a global counter beside an organization-scoped one, and therefore about to mint more duplicatesNo. The repair merges the two counters and deletes the global one. Once that has happened, this line can never be produced again

Running it afterwards is still worth doing — the inventory is what you act on, and it is complete either way. What you cannot recover is the forward-looking half.

A deployment holding more than one organization is skipped by that repair rather than guessed at: there is no derivable answer to which organization owns an untenanted row, so it logs the condition and the remedy and changes nothing. Its evidence therefore stays intact, and this report stays reproducible until somebody stamps those rows by hand.

Nothing is renumbered, here or by the repair. A business identifier that has already left the building — on an invoice, in a notification, in another system's idempotence key — is not the platform's to rewrite, so both sides report and stop. Deciding what a duplicate should become is yours.

The scan covers every organization-scoped object and, on it, every field that is an identifier: type: 'autonumber', or carrying any unique spelling. Platform objects are not filtered out — that filter is right for a repair and wrong for a report, which must not silently omit a real duplicate. Anything that could not be probed is listed under skipped with its reason, because "found nothing" and "never looked" must not read the same. For the same reason a driver with no raw SQL seam (memory, MongoDB) fails the whole run with error: "no_sql_seam" rather than returning an empty inventory.

The kernel:ready index pre-flight

The report carries a second section, runtimeIndexPreflight, answering a different question: will the next server start be able to finish tightening the platform's own unique indexes?

Three migrations run at kernel:ready on a serving boot (os dev, os serve, os start) and replace a declared UNIQUE index with the NULL-safe — and sometimes active-rows-only — form it was always meant to have:

TableIndexWhat the tightening adds
sys_metadataoverlay active and draftpackage-less overlays stop being NULL-distinct
sys_view_definitionidx_sys_view_def_activeshared and environment-level views stop being NULL-distinct, and only active rows are constrained
sys_settingthe declared row identitytenant- and global-scope rows stop being NULL-distinct on user_id

Each is a tightening, so rows an installation already holds can block it. When that happens the migration refuses — the previous index stays in place, no row is touched, and the server keeps running — and reports it at error in the boot log. Until this section existed that log line was the only channel: these indexes are invisible to os migrate plan by construction, because the drift reconciler deliberately excludes runtime-managed indexes (otherwise the next boot would propose rebuilding away the guarantee it just created), and because each migration reuses the declared index's name, so the reconciler's slot for it reads as correctly filled whichever form is physically there.

So the pre-flight lives here instead, on the command that already boots read-only and repairs nothing. It runs the migrations' own duplicate-listing queries — the exact statements the boot log prints — and reports one entry per index:

statusMeaning
blockedRows collide under the tightened key. groups lists each colliding key and how many rows hold it. The next serving boot will refuse this index
clearThe probe ran and nothing collides
table-absentThe table is not installed here. sys_setting, for instance, arrives with the optional settings service
unreadableThe probe could not run; detail carries the driver's message

summary.runtimeIndexesBlocked and summary.runtimeIndexBlockingRows are the same finding counted at the head of the document.

Read blocked as work to do before the restart, not damage: nothing is lost while an index stays untightened, but the guarantee it carries is not in force until the listed rows are resolved — and only an operator can decide which of two colliding rows survives, which is why the platform refuses rather than picking one.

--object does not narrow this section. It is a fixed set of platform indexes rather than a slice of your registry, and filter describes the object scan only.

Scaffolding

CommandAliasDescription
os generate <type> <name>os gGenerate metadata files
os create <type> [name]Create a new package from template

os generate (alias: os g)

Generates properly typed metadata files with barrel index management.

os g object customer        # Generate a Customer object
os g view customer          # Generate a Customer list view
os g action approve         # Generate an action
os g flow customer          # Generate an automation flow
os g dashboard sales        # Generate a dashboard
os g app crm                # Generate an app definition
os g skill lead-qual        # Generate an AI skill

os g object task -d lib/    # Override target directory
os g object task --dry-run  # Preview without writing

Available types:

TypeDefault DirectoryWritten asDescription
objectsrc/objects/NAME.tsBusiness data object with fields
viewsrc/views/NAME.tsList or form view definition
actionsrc/actions/NAME.tsButton or batch action
flowsrc/flows/NAME.tsAutomation flow
dashboardsrc/dashboards/NAME.tsAnalytics dashboard
appsrc/apps/NAME.tsApplication navigation
skillsrc/skills/NAME.skill.tsAI skill — the ADR-0063 extension primitive

Why `skill` alone gets a filename suffix

skill is the one type whose scaffold is written as NAME.skill.ts rather than NAME.ts. The metadata type registry declares that type's file convention as *.skill.ts / *.skill.yml, and skill is discoverable metadata — a file matching neither pattern still type-checks, still validates and still publishes, with nothing anywhere reporting that it was skipped.

The other six generators keep NAME.ts. Aligning the whole scaffolder with the registry's NAME.TYPE.ts convention — the shape the example apps already author in — would change every generator's output and is a separate decision.

`os g agent` is retired

There is no agent type. Running os g agent <name> fails with a message naming ADR-0063 and pointing at skills, rather than the generic "unknown type" listing.

Agents are platform-internal: the kernel ships exactly two (ask and build), and the runtime catalog filters out every other agent record. A scaffolded src/agents/*.ts therefore passed os validate, published without complaint, and never appeared — silently. Skills (plus tools / MCP) are the third-party extension primitive, authored as src/skills/<name>.skill.ts with defineSkill; see AI Agents. Scaffold one with os g skill <name>, which writes exactly that path.

Options:

  • -d, --dir <directory> — Override target directory
  • --dry-run — Preview without writing files

What it does:

  1. Creates a typed TypeScript file using Data.Object, UI.View, Automation.Flow, etc.
  2. Creates or updates the barrel index.ts in the target directory
  3. Shows a hint to run objectstack validate

os create

Creates new packages from built-in templates (for monorepo-level scaffolding):

os create plugin analytics    # Create packages/plugins/plugin-analytics
os create example my-app      # Create examples/my-app

Quality

CommandDescription
os lint [config]Every author-time gate validate/build run, plus style and convention checks
os test [files]Run Quality Protocol test scenarios against a running server
os doctorCheck development environment health

os lint

The cheapest of the three author-time commands. It runs the same rule registry os validate and os build run — so anything that can fail a build fails here too — and adds its own style rubric: naming, labels, namespace prefixes, data-model conventions, translation coverage, with a 0-100 quality score.

os lint                # Author-time rules + style / convention checks
os lint --score        # Append a 0-100 metadata quality score (letter-graded)
os lint --fix          # Show what would be fixed (dry-run)
os lint --json         # JSON output for CI

It does not replace os validate: os lint never parses the stack against the Zod schema (a schema error is os validate's verdict to give), and it emits no artifact. What it does guarantee is the direction that matters for a pre-flight — a green os lint is not followed by a red os build. That was not true before #4409: os lint ran one gating rule neither other command ran and missed six that both of them ran, so it disagreed with the build in both directions.

os test

Runs Quality Protocol test scenarios (JSON-based BDD) against a running ObjectStack server.

os test                               # Default: qa/*.test.json
os test qa/my-test.json               # Specific test file
os test --url http://localhost:4000    # Custom server URL
os test --token my-api-key            # With authentication
os test 'qa/**/*.test.json'           # Recursive — quote it, or the shell expands it first
os test --fail-on-empty               # Matching no suite is a failure, not a pass

The pattern accepts * (one path segment) and ** (any number of segments); every other character is matched literally. A wildcard never descends into node_modules, .git, dist or build: a wildcard is a search of your own sources, and a suite found in a dependency or in build output is one os test would otherwise load and run against your server. Naming such a directory still reaches it — packages/*/dist/*.test.json walks dist because you asked for dist. Matches run in sorted order, so a suite runs in the same position on every machine.

Each file is validated against TestSuiteSchema before it runs. A suite that does not match is refused at load time, naming the file and every offending path, and counts as one failed suite — the rest of the glob still runs. This is what stops a malformed suite from reporting success: a misspelled steps key used to produce a scenario that passed having executed nothing.

An assertion the runner cannot evaluate fails, it does not pass. contains is defined over an array (membership) and a string (substring); point it at anything else — most often a field path the response does not carry, because it was misspelled or the shape moved — and it fails, naming the field, the operator and the runtime type it actually found. Until #7256 that case fell out of the switch and reported ✅, so a contains against a missing path was a test that silently deleted itself. Assert absence with is_null; compare a scalar with equals.

A pattern that matches no suite is not a failure by default. The run prints Found 0 test suites. — the same machine-readable line a full run prints, so a caller can tell "every suite passed" from "there were no suites" — and exits 0, because a project that legitimately ships no suites should not fail its build. That is a posture, not an oversight, and it has the cost you would expect: a CI step whose glob stops matching (a renamed directory, a moved suite) reports success forever. Pass --fail-on-empty to opt into the strict reading, where an empty match exits 1 (#7848).

The record-shaped action types — create_record, read_record, update_record, delete_record, query_recordsask the server where the Data Protocol is mounted instead of assuming it. Once per run, os test fetches {apiBase}/discovery and addresses whatever routes.data advertises, so a deployment that moves the mount with crud.dataPrefix is reached without you telling it anything. When the probe cannot answer, the run falls back to the convention {apiPath}{crud.dataPrefix} (/api/v1/data) and says so: a warning naming the mount it will address and the probe that failed, and the same statement appended to every 404 a record step gets — so a wrong mount reads as a wrong mount, not as your own URL mistake.

One case survives that fallback by construction: setting api.apiPath moves the discovery document itself out from under the probe, and no fixed-path document reports the REST mount (/.well-known/objectstack advertises the dispatcher's own prefix, not this one). Against such a host, write those steps as api_call, which takes the path you give it. run_script has no adapter branch at all and fails by name.

os doctor

Checks your development environment and reports issues:

os doctor           # Check health
os doctor -v        # Show fix suggestions for warnings

Checks performed:

  • Node.js version (≥18 required)
  • pnpm installation
  • TypeScript availability
  • Dependencies installed
  • @objectstack/spec build status
  • Git availability

Authentication

CommandDescription
os registerCreate an account and store local credentials
os loginSign in and store credentials in ~/.objectstack/credentials.json
os whoamiShow the current authenticated user
os logoutRevoke the server session and clear local credentials
os cloud loginSign in to ObjectStack Cloud (the hosted package registry) and store credentials in ~/.objectstack/cloud.json

os register

Creates a user account and stores the returned token locally.

os register
os register --email user@example.com --name "Jane Doe" --password secret
os register --url https://api.example.com

os login

In an interactive terminal, login uses a browser-based device flow by default: the CLI prints a one-time verification URL, opens the browser, and polls until you approve access in your browser.

os login
os login --url https://api.example.com
os login --no-browser

If a valid token already exists, os login exits successfully with "Already logged in as <email>". Use os logout to switch users, or pass --force to re-authenticate.

For CI and other non-interactive contexts, pass email/password directly:

os login --email user@example.com --password secret
os login --json is NDJSON — the one exception

Every other ObjectStack command writes exactly one JSON document to stdout under --json, so JSON.parse(<entire stdout>) is the way to read it. os login is the single declared exception: its --json output is NDJSON, one compact JSON document per line. Parse it line by line.

The reason is the device flow: it is two events at two points in time, and the verification URL is only useful to a script before the user authorizes. So the CLI emits it as its own record immediately, then a second record when the poll resolves:

$ os login --json --no-browser
{"device_code":"…","user_code":"WXYZ-1234","verification_uri":"https://…/activate","verification_uri_complete":"https://…/activate?user_code=WXYZ-1234","expires_in":600}
{"success":true,"email":"user@example.com","userId":"usr_01H…"}

Read the first record, show the user the URL, then block on the next line:

os login --json --no-browser | while IFS= read -r line; do
  echo "$line" | jq -r 'if .verification_uri_complete then "Approve at: \(.verification_uri_complete)" else "Signed in as \(.email)" end'
done

Every record is one line, on every path — the --email/--password result and the failure payload ({"success":false,"error":"…"}) included, since a failure can arrive after the verification-URL record has already been written. Records that report failure also set exit code 1.

Before this was declared, os login --json wrote a compact record followed by a pretty-printed one, which parsed as neither a single document nor as NDJSON.

--json is non-interactive: it refuses rather than prompting

--json has one audience, a program, so it never asks a question. If a --json run has no --email and no --password to work from and cannot use the device flow, it does not fall back to a prompt — it emits one record and exits 1:

$ os login --json --url https://api.example.com < /dev/null
{"success":false,"error":"email and password are required in a non-interactive shell"}
$ echo $?
1

The same applies when only one of the two is supplied, which is the usual shape of the mistake: a CI step whose --password secret interpolated and whose --email did not gets that record, not a Password: prompt.

Without --json, os login still prompts on a pipe as before. What changed for that path is the ending: if stdin reaches end of input before a prompt is answered, the command reports it and exits 1, rather than being torn down by Node with an exit code the CLI does not define.

os logout

Logout calls POST /api/v1/auth/sign-out before deleting local credentials, so the server-side session is revoked as well.

os logout

os cloud login

Signs you in to ObjectStack Cloud — the hosted package registry — rather than to a runtime instance. It is the credential os package publish and the marketplace commands use, and it lands in its own file (~/.objectstack/cloud.json), separate from os login's ~/.objectstack/credentials.json.

os cloud login
os cloud login --no-browser
os cloud login --url https://cloud.example.com   # self-hosted control plane
os cloud login --email me@acme.com --password secret   # CI

Like os login, in an interactive terminal it uses the browser-based device flow: it prints a one-time verification URL and polls until you approve. If cloud credentials already exist it exits successfully with "Already logged in"; pass --force to re-authenticate.

os cloud login --json is NDJSON — the same exception as os login

Every other ObjectStack command writes exactly one JSON document to stdout under --json, so JSON.parse(<entire stdout>) is the way to read it. The two device-flow login commands — os login and os cloud login — are the declared exceptions, and they are the same exception: --json output is NDJSON, one compact JSON document per line. Parse it line by line.

The reason is the device flow: it is two events at two points in time, and the verification URL is only useful to a script before the user authorizes. So the CLI emits it as its own record immediately, then a second record when the poll resolves:

$ os cloud login --json --no-browser
{"device_code":"…","user_code":"WXYZ-1234","verification_uri":"https://…/activate","verification_uri_complete":"https://…/activate?user_code=WXYZ-1234","expires_in":600}
{"success":true,"email":"user@example.com","userId":"usr_01H…","url":"https://cloud.objectos.ai"}

Read the first record, show the user the URL, then block on the next line:

os cloud login --json --no-browser | while IFS= read -r line; do
  echo "$line" | jq -r 'if .verification_uri_complete then "Approve at: \(.verification_uri_complete)" else "Signed in as \(.email)" end'
done

Every record is one line, on every path — the --email/--password result, the "already logged in" notice, and the failure payload ({"success":false,"error":"…"}) included, since a failure can arrive after the verification-URL record has already been written. Records that report a login failure also set exit code 1.

Before this was declared, os cloud login --json emitted a single document and never handed the verification URL to a consumer at all — formally valid JSON that withheld the one thing device flow exists to give a script. The device-authorization record's fields are spelled exactly as os login --json spells them, so one consumer reads both commands.

Cloud Environments

CommandDescription
os environments listList environments visible to the current session
os environments show <id>Show one environment
os environments createProvision a new environment
os environments switch <id>Set the active environment for later CLI calls
os environments bind <id>Bind a compiled local artifact to an existing environment

Create an environment from a local artifact

Compile first, then create an environment and bind the generated dist/objectstack.json in one call:

os compile
os environments create --org <org-id> --name CRM --artifact ./dist/objectstack.json

The server stores the absolute artifact path in environment metadata. On environment-kernel boot, ObjectStack loads the JSON bundle, registers schemas, and seeds records from the bundle's data arrays.

Bind an existing environment

os environments bind <environment-id> --artifact ./dist/objectstack.json
os environments bind <environment-id> --artifact ./dist/objectstack.json --build

--build runs objectstack compile before updating the project. --reseed is reserved for the server-side reseed endpoint; use it only when that endpoint is available in your deployment.

Packages

The two commands that move a compiled app onto a platform. They target different systems and authenticate as different identities: publish uploads to ObjectStack Cloud (the catalog), install registers an app into a running runtime.

CommandTalks toDescription
os package publish [artifact]ObjectStack CloudUpload a compiled artifact as a versioned package in your organization
os package install <package>A running runtimeInstall a package into a live kernel, from that runtime's catalog or from a local artifact

For which one to reach for and the preview patterns around them, see Publish & preview. This section is the flag-level reference.

os package publish

Uploads a compiled artifact as a versioned package in your organization's catalog. It ensures a sys_package row keyed by the manifest id, then snapshots the artifact into a new sys_package_version. Publishing changes nothing that is already running.

os compile
os package publish                                        # dist/objectstack.json → your org
os package publish --manifest-id com.acme.crm --version 1.2.0
os package publish dist/objectstack.json --visibility org --note "first cut"
os package publish --env env_abc123 --install             # publish, then install into an environment
OS_CLOUD_URL=http://localhost:4000 os package publish     # against a local control plane

The credential is the cloud identity. Resolution order: --token, then $OS_TOKEN, then ~/.objectstack/cloud.json (written by os cloud login). It deliberately does not fall back to ~/.objectstack/credentials.json — that is the runtime identity os login writes, and the two are different accounts. With no token at all the command exits 1 and tells you to run os cloud login.

Options:

FlagEnv equivalentPurpose
artifact (positional)Path to the compiled artifact (default dist/objectstack.json)
-s, --server <url>OS_CLOUD_URLControl-plane URL. Default https://cloud.objectos.ai, or the URL recorded by os cloud login
-t, --token <key>OS_CLOUD_API_KEYBearer token; $OS_TOKEN and ~/.objectstack/cloud.json are the fallbacks
--manifest-id <id>OS_PACKAGE_MANIFEST_IDReverse-domain package id. Default: artifact.manifest.id, else local. + a slug of the artifact name
-v, --version <semver>Version to publish. Default: artifact.manifest.version, else 0.0.0-dev. + a timestamp
--display-name <name>Name shown in the Marketplace (default artifact.manifest.name)
--description <text>Short package description
--category <slug>Marketplace category slug (crm, hr, devtools, …)
--visibility <level>org (default, installable across your organization) · private (explicit grants only) · marketplace (public after review)
--org <id>OS_ORG_IDowner_org_id. Required with a bearer key in service mode; ignored in user mode
--env <id>OS_ENVIRONMENT_IDEnvironment to install the new version into
--installAuto-install into --env after publishing. Passed without --env it reports the mistake and publishes without installing
--seed-sample-dataInclude sample data in that auto-install
--pre-releaseMark the version as a pre-release (also inferred — see below)
--submitSubmit the new version for marketplace review. Needs --visibility marketplace and a complete listing
--auto-approvePlatform admin only: skip the review queue and publish straight to the public catalog
--readme <markdown>Inline marketplace README. Mutually exclusive with --readme-file
--readme-file <path>README file, read at publish time. Mutually exclusive with --readme
--icon-url <url>Public http(s) icon URL. Mutually exclusive with --icon-file
--icon-file <path>Local PNG/JPEG/WebP/SVG (≤256 KB) uploaded to the icon CDN, which returns a stable URL and rewrites icon_url for you. Mutually exclusive with --icon-url
--homepage-url <url>Public project / docs URL, surfaced in the catalog
--license <spdx>SPDX identifier (Apache-2.0, MIT, …)
-n, --note <markdown>Release notes
--timeout <ms>OS_CLOUD_TIMEOUT_MSHTTP timeout in milliseconds, default 120000. 0 disables it

objectstack.manifest.json supplies the listing fields. When that file is present in the working directory, publish reads manifestId, displayName, description, category, tagline, iconUrl, homepageUrl, license, readmePath and a translations map from it, so a listing need not be retyped as flags on every publish. CLI flags always win. A per-locale readme entry may be inlined markdown or a path resolved against the manifest's own directory (README.zh-CN.md). Publishing without the file is fully supported — it stays flag-driven.

The namespace travels with the artifact and no flag overrides it. manifest.namespace is read off the compiled artifact and sent with the publish payload, because the publish-time exclusivity gate (ADR-0048 addendum §A.2) must check the object-name prefix the package actually ships — a reservation naming a different string than the artifact installs would be worse than none. A malformed value is refused before any network call; to change it, edit manifest.namespace in objectstack.config.ts and rebuild. An artifact that declares no namespace publishes fine.

Pre-release is inferred as well as flagged. A version containing -alpha, -beta, -rc, -dev, -preview, -staging or -pr is marked a pre-release whether or not you pass --pre-release — so the generated 0.0.0-dev. + timestamp default never lands as a stable version.

A 422 on version publish is marketplace policy rejecting the listing. The command prints each violation and names the flags that fix them, rather than leaving them in the server log.

os package install

Installs a package into a running runtime through its local install endpoint (ADR-0008 Phase 3): the app is registered into the live kernel, and the manifest is cached on the runtime host so the install re-registers on every boot and survives restarts. This is the other half of publish — publish uploads to the cloud, install puts an app into a runtime.

Two modes, chosen by the shape of the argument:

# catalog mode — the TARGET runtime resolves the version from its own catalog
os package install com.acme.crm --version 1.2.0 --runtime https://app.example.com

# air-gapped mode — the artifact is read locally and sent inline; no catalog, works offline
os package install ./dist/objectstack.json

The argument is read as a file path when it ends in .json, starts with ./, ../ or /, or names something that exists in the working directory. Anything else is a catalog id. The last clause is the one to know: a bare catalog id that happens to match a file in the working directory is installed from that file instead.

The credential is the runtime identity, not your cloud login. The target runtime authenticates the call with its own session, so --email / --password (or OS_RUNTIME_EMAIL / OS_RUNTIME_PASSWORD) name an account on that runtime. A 401 means exactly that, and the command says so; os cloud login credentials do not apply here.

Options:

FlagEnv equivalentPurpose
package (positional, required)Package manifest id (com.acme.crm) or a path to a compiled artifact JSON
-r, --runtime <url>OS_RUNTIME_URLBase URL of the runtime to install into (default http://localhost:3000)
-v, --version <semver>Version to install in catalog mode (default latest). Air-gapped mode takes the version from the artifact
--email <email>OS_RUNTIME_EMAILAccount email on the target runtime
--password <password>OS_RUNTIME_PASSWORDAccount password on the target runtime
--confirm-global-uniquesAffirm this app's installation-wide unique constraints are genuinely platform-wide — see below
--timeout <ms>OS_CLOUD_TIMEOUT_MSHTTP timeout in milliseconds, default 120000. 0 disables it

--confirm-global-uniques answers a stop; it does not force one past. Installing an app that declares installation-wide (unique: 'global') constraints into a runtime whose tenancy posture is isolated stops with UNIQUE_SCOPE_CONFIRMATION_REQUIRED, and the command prints the offending constraints so you can decide per entry (ADR-0120 D5e). Passing the flag records an affirmative fact — these constraints really are platform-wide — into the install manifest, alongside the posture it was given under, a timestamp and the confirming identity when the seam knows one; os doctor then stops re-reporting the affirmed constraints, so the advisory does not become a recurring nag. It is deliberately not called --force, and deliberately not default-on. The other answer is to edit the app's metadata to unique: 'organization' and rebuild.

A 404 means the target runtime does not mount MarketplaceInstallLocalPlugin (from @objectstack/cloud-connection). The endpoint is opt-in, so a runtime composed without it will not accept installs.

Configuration

The CLI looks for objectstack.config.ts (or .js, .mjs) in the current directory:

import { defineStack } from '@objectstack/spec';
import * as objects from './src/objects';
import * as actions from './src/actions';

export default defineStack({
  manifest: {
    id: 'com.example.my-app',
    namespace: 'my_app',
    version: '1.0.0',
    type: 'app',
    name: 'My App',
    description: 'My ObjectStack application',
    // Protocol major this app is authored against (ADR-0087 load-time check).
    engines: { protocol: '^17' },
  },

  objects: Object.values(objects),
  actions: Object.values(actions),
});

Config File Auto-Detection

The CLI searches for configuration files in this order:

  1. objectstack.config.ts
  2. objectstack.config.js
  3. objectstack.config.mjs

You can also specify a path explicitly:

os compile path/to/my-config.ts

Typical Workflow

# 1. Create project
os init my-crm && cd my-crm

# 2. Define your data model
os g object account
os g object contact
os g object opportunity

# 3. Add business logic
os g flow lead-qualification

# 4. Validate everything
os validate

# 5. Start development with Console UI
os dev --ui

# 6. Build for production
os compile

# 7. Deploy: ship just the artifact
os start                                              # locally
OS_ARTIFACT_PATH=https://cdn.you.com/app.json os start  # remote artifact

Source vs Artifact

ObjectStack treats objectstack.config.ts and objectstack.json as two forms of the same schema — authoring source vs compiled artifact:

Aspectobjectstack.config.tsobjectstack.json
RoleAuthoring sourceDeployable artifact
FormatTypeScript (defineStack({...}))Pure JSON
May contain codeYes (handler: async (ctx) => {...})No — handlers are lowered to a sibling objectstack-runtime.<hash>.mjs
Loaded byos serve (via bundle-require)os start (via loadArtifactBundle — file or http(s)://)
SchemaObjectStackDefinitionSchemaSame schema, plus runtimeModule reference
Produced byYou (or os generate)os compile / os build

The artifact is fully self-describing: its requires: [...] field declares which platform services (ai, automation, analytics, …) the runtime should auto-register, so os start needs nothing other than the JSON itself to bring up a working server.

This is why an artifact is the canonical "portable deployment unit" — you can host it on S3 / GitHub raw / a CDN, and any ObjectStack runtime can fetch and execute it with no additional source code on the server.

Next Steps

CI/CD Integration

All commands that produce output support --json for machine-readable output:

# In CI pipeline
os validate --json --strict
os compile --json -o dist/objectstack.json
os info --json

Example GitHub Actions step:

- name: Validate ObjectStack Config
  run: npx objectstack validate --strict --json

- name: Build ObjectStack Artifact
  run: npx objectstack compile --json

On this page