| name | objectstack-platform | ||||||||
|---|---|---|---|---|---|---|---|---|---|
| description | Bootstrap, configure, extend, and operate ObjectStack runtimes. Covers project setup (`defineStack`, drivers, scaffolding), declaring platform capabilities (`requires:` — which service plugins boot), plugin and service development (PluginContext, DI, kernel hooks like `kernel:ready`), and operations (CLI commands, migrations, deployment, test harnesses via LiteKernel). Use when the user is writing `objectstack.config.ts`, building a plugin or driver, turning a platform capability on, mounting the Hono HTTP layer, running `os` CLI commands, or planning deployment. Do not use for data schema design (see objectstack-data) or query patterns (see objectstack-query); data lifecycle hooks (beforeInsert / afterUpdate) belong in objectstack-data — only kernel / service-level events live here. | ||||||||
| license | Apache-2.0 | ||||||||
| compatibility | Requires @objectstack/spec 18.x and @objectstack/core 18.x (Zod v4 schemas), Node 22+ | ||||||||
| metadata |
|
→ See references/bootstrap.md § Minimal Example.
defineStack() accepts an ObjectStackDefinitionInput whose top-level keys
are manifest, packages, objects, objectExtensions, picklists,
picklistExtensions, views, apps, pages, dashboards, reports,
datasets, actions, flows, jobs,
emailTemplates, docs, books, positions, permissions,
capabilities, sharingRules, queues, assignmentRules, apis, webhooks, api, server,
agents, tools, skills, hooks, functions, mappings,
analyticsCubes, connectors, data (seed), datasources,
datasourceMapping, translations, i18n, plugins, devPlugins,
devHint, devLogins, demoStaff, requires, tiers, onEnable, appName, email, sms, push.
There is deliberately no top-level workflows or approvals collection:
an approval is a flow with Approval nodes, and record
state machines are a state_machine validation rule on each object.
A phantom key like roles: or policies: is not a silent
no-op — the top level refuses it and the stack fails to load.
queues declares ownership queues (defineQueue, @objectstack/spec/security;
objectstack-data): named sets of people a new record of an ownership: 'user'
object sits in until a member claims it. assignmentRules (defineAssignmentRule,
@objectstack/spec/automation; objectstack-automation) routes each inserted
ownerless row into a queue — parked, round_robin or least_loaded — or to one
user. Both need the ownership-queue service @objectstack/plugin-sharing registers:
a stack declaring either without it is refused at boot.
Undeclared keys are handled per surface, in two postures:
- Refused —
defineStack()'s top level, eachobjects[]entry (ObjectSchema), and each field (FieldSchema). The parse throws, naming the surface and the offending key. TypeScript rejects the literal earlier still, withTS2353: Object literal may only specify known properties. - Warned, then dropped — the authoring surfaces whose shapes have not
been closed yet (
connectorsis one).defineStack()warns before the parse:defineStack: connectors.stripe.bogusKey: 'bogusKey' is not a declared connector key, so its value is dropped at load.Treat these as errors-in-waiting: a key that only warns today is expected to be refused later.
→ See references/bootstrap.md § Map Format (Key → Name) · § Barrel Import Pattern.
defineStack() validates by default (strict: true):
- Zod schemas — field names, types, enums
- Cross-references — views/actions/flows reference defined objects
- Seed data — dataset objects exist in the definition
Disable it with defineStack(config, { strict: false }) (objects another plugin provides).
ObjectStack runtime metadata must come from source files during local development or from a compiled artifact. Do not configure an environment runtime to read or write metadata through its business database.
# the CLI ships an `os` binary; `objectstack` is an alias for it
objectstack compile
# -> dist/objectstack.json
OS_ARTIFACT_PATH=./dist/objectstack.json objectstack devEvery stack needs a manifest:
import { defineStack } from '@objectstack/spec';
export default defineStack({ manifest: {
id: 'com.example.crm', // Reverse domain unique ID
version: '1.0.0', // Semver
type: 'app', // app | plugin | driver | module | ...
name: 'Acme CRM', // Human-readable display name
description: 'CRM system', // Optional
engines: { protocol: '^18' },
} });manifest.engines.protocol: the metadata-protocol major the app is authored
against (stamped by create-objectstack). The runtime
checks it before it loads anything and refuses an out-of-range app at the
boundary with the exact migration command. Change it when you deliberately move
to a new protocol major — never to silence a mismatch.
Object naming: The object name is the canonical identifier and equals the physical table name. Embed any domain prefix directly in the name (e.g. name: 'crm_account'); the object-level namespace field is retired (ADR-0129 D3) and refused at load. Never set tableName, and never add a sys_metadata object to a runtime manifest.
manifest.namespace (ADR-0048): Optional, but enforced once set. When a package declares manifest.namespace: 'crm', every object.name must start with crm_ or defineStack errors (validateNamespacePrefix in @objectstack/spec); the legacy <ns>__<short> double-underscore form is rejected, and sys_-prefixed names are platform-reserved and exempt. The namespace is also a package-ownership key — installing two packages that both claim crm fails with NamespaceConflictError (downgrade to a warning with OS_METADATA_COLLISION=warn). Never actions, connectors, global, hooks, oauth, trigger (route literals; os validate and build refuse).
Which names carry the namespace (ADR-0150): object and doc names, capabilities (crm.export_data) and positions (one slot per environment). Every other name stays short (send_email): flows, actions, hooks, apps, pages, dashboards, reports, datasets, permission sets, tools, skills, agents are identified by package and name. A prefixed name stays valid; no name starts with _, which the router reserves. os lint's naming/namespace-prefix warns on a bare capability or position name, and when one package declares an app, page, dashboard, flow, report or dataset name twice, or an action name twice on one object.
Customizing an installed package (ADR-0146): Customize an installed base hotcrm in its paired writable hotcrm.custom, which depends on it: a same-named item there with overrides: { package: 'hotcrm', basedOn: '3.2.0' } (its manifest.id/manifest.version) replaces the base's document whole. Reads honour it only from the pair (the row's package_id), for a view, dashboard, report, email_template, translation, flow, action, page, app, dataset, doc, book or mapping, never judging basedOn. An email template is one item per (name, locale): an override names a locale the base ships; one it does not ship is added without overrides. Then no ?package, ?package=hotcrm, the layered read and both lists serve it, and removing the pair's item (DELETE …?package=hotcrm.custom) serves the base's again; the env-wide list holds one item per name one code package ships (a dependent's stays under its own ?package=); a pre-D11 unbound Setup row wins over the base's until os migrate packageless-overlays moves it, and once movable as a declared override, its package-less save, publish, reset and rollback are refused (D11); an email-template row at a locale the base does not ship moves as the pair's own member and keeps that write. The base's address serves the pair's added locale too (both lists, the layered read with code null and effective the pair's, the execution list, a send in that locale); one a dependent adds under the base's name is served only under its own ?package=. An undeclared pair row or a dependent's row at a base-shipped locale is an orphan in the layered read's overlay, effective = the base's. An override may narrow a public form's anonymous intake, never re-open one any honoured body withdrew. GET /packages/hotcrm.custom/export carries it with overrides.
The pair comes from POST /api/v1/packages/hotcrm/ensure-customization: namespace = the base's + _custom, dependencies: { hotcrm: '^<major>' }; GET /packages/hotcrm/customizations lists its overrides, extensions and switched-off items, and uninstalling the base is refused (409) while the pair exists. Only the pair declares: the save, publish and install doors, os validate and os build refuse overrides from any other package, and a dependent's item under a base item's name — its one fix is a new name. A permission set, position or capability is never overridden: copy it under a new name ("new similar", landing in the pair). An object is extended (__c fields, objectstack-data); a flow is overridden or copied (objectstack-automation). An action override keeps the base's objectName (keyed OBJECT:NAME; another object is refused declared_package_ships_no_such_item) and replaces its behaviour (type, body, label, visible, …) whole, for whoever could already run the base: dropping a requiredPermissions entry, turning ai.exposed on or ai.requiresConfirmation off is refused declared_action_widens_access, both 403 NOT_OVERRIDABLE.
That read's contributions lists what other packages add to the base, one entry per item with its packageId: a field or flow names the base's object it is on, a nav item the base's app, its group if any, and its id as name; counts.contributions counts items, not packages.
An ObjectStack app is a simplified implementation of business features:
author metadata under the platform's spec and check it
with the os commands (Verify your work). Never rebuild
what the platform owns.
- Business features belong in the app; capability belongs in the platform. A missing default, a wrong diagnostic, a shape the spec refuses — the fix is upstream.
- Could this be written by something that has only the metadata, and no knowledge of this company? No — it encodes this company's own judgement (a discount ceiling, who a case is assigned to, how won/lost is booked) ⇒ the app. Yes — it only asks whether the metadata is self-consistent (reference integrity, translation coverage, view rosters, sharing-rule coverage, CRUD round-trips, RLS probes per declared position) ⇒ the platform.
- A platform defect means waiting for the platform fix. No defensive coding, no shape tolerance, no hand-written predicate re-implementing a platform rule, and never "land the half we can" — that spends the contract-first option and leaves a decision half-executed. Record the block against the platform issue so it is machine-visible; before resuming, confirm the version you pin carries the fix (merged upstream ≠ present on your pin) and re-run the defect's own reproduction.
blank is the only template create-objectstack offers, and it is the default:
- Bundled with
create-objectstack— no network fetch - One example object;
requires: ['automation', 'triggers']plus the three generic connector executors inplugins:; and the eight generator barrels (objects,views,actions,flows,dashboards,apps,skills,picklists), eachsrc/*/index.tshanded to the stack key of its name throughexportsOf— the scaffoldedAGENTS.mdsays how to add to them.
The five retired remote templates (todo, compliance, content,
contracts, procurement) are refused by name.
→ See references/bootstrap.md § Scaffolding Command.
objectstack.config.ts at the root is THE entry point (defineStack()), beside
package.json and tsconfig.json. Metadata lives under src/<collection>/ —
objects/ (one task.object.ts per object), then the optional views/, apps/,
flows/, actions/, dashboards/, reports/, datasets/, i18n/ and
handlers/ (runtime hook handlers) — each with a barrel index.ts
(export * from './task.object') handed to the stack key of its name.
| Concept | Convention | Example |
|---|---|---|
| File names | {name}.{type}.ts |
task.object.ts, main.app.ts |
| Machine names | snake_case |
project_task, first_name |
| Config keys | camelCase |
maxLength, defaultValue |
Drivers are the storage layer:
| Driver | Package | Best For | Notes |
|---|---|---|---|
| Memory | @objectstack/driver-memory |
Dev, testing, prototyping | InMemoryDriver — data lost on restart |
| SQL | @objectstack/driver-sql |
Production (PostgreSQL, MySQL, SQLite) | SqlDriver — Knex.js under the hood (pg / mysql / better-sqlite3 clients) |
| MongoDB | @objectstack/driver-mongodb |
Production (document store) | MongoDBDriver |
| SQLite WASM | @objectstack/driver-sqlite-wasm |
Browser / WebContainer | SqliteWasmDriver — in-process, no server |
| Turso | @objectstack/driver-turso |
Edge, serverless, multi-tenant | Cloud / EE only. The open-core CLI recognizes libsql:// URLs but fails loudly (UnsupportedDriverError) |
Under os dev / os serve / os start the CLI resolves the driver itself
from the database URL and registers DriverPlugin for you. Do not put a
driver in your config's plugins: array: no example app does, and the
plugins: key is for plugins the CLI cannot infer (connector executors, your
own plugins).
Construct DriverPlugin yourself only when you own the runtime — embedding
via Runtime / ObjectKernel, or a test that boots a kernel directly:
new DriverPlugin(new SqlDriver({ client: 'pg', connection: process.env.DATABASE_URL }))
(@objectstack/runtime / @objectstack/driver-sql; a full boot:
Programmatic Bootstrap).
Two packages exist:
| Package | Export | Use When |
|---|---|---|
@objectstack/hono |
createHonoApp({ kernel, prefix }) |
You own the server: embed ObjectStack routes in your own Hono app / deploy target. |
@objectstack/plugin-hono-server |
HonoServerPlugin |
ObjectStack owns the server: a kernel plugin that hosts the Hono app and opens the listening socket (this is what os dev / os serve register). |
There are no @objectstack/adapter-* packages. To integrate another
framework, mount the Hono app (a web-standard fetch handler) or call the
dispatcher yourself.
import { createHonoApp } from '@objectstack/hono';
// prefix defaults to '/api'.
export default createHonoApp({ kernel });prefix does not move auth. The /auth/* mount follows the auth
service's basePath (AuthPlugin default /api/v1/auth), not prefix —
createHonoApp({ kernel }) reaches better-auth at /api/v1/auth/*. A prefix
that basePath is not inside refuses at boot, naming both values.
createHonoApp creates an HttpDispatcher, mounts explicit
routes for auth and discovery, and delegates everything else to it — so new
routes added to HttpDispatcher work automatically.
objectstack.config.ts
└── defineStack({ manifest, objects, views, ... })
│
▼
CLI: `os serve` / `os dev`
1. Load .env files (NODE_ENV-based)
2. Dynamic import of config file
3. Create Runtime + ObjectKernel
4. Auto-detect and register plugins (in this order):
├── ObjectQLPlugin (if objects defined)
├── DriverPlugin (memory in dev, SQL in prod)
├── AppPlugin (loads the defineStack bundle)
├── I18nServicePlugin (if translations/i18n defined)
├── HonoServerPlugin (registered BEFORE AuthPlugin — the server must
│ exist for plugins that mount routes during init/start)
├── AuthPlugin
├── Split platform-app plugins (ADR-0048, optional/best-effort, after AuthPlugin):
│ @objectstack/setup → createSetupAppPlugin (first-run wizard)
│ @objectstack/account → createAccountAppPlugin
│ (@objectstack/studio is intentionally NOT default-loaded — the
│ Console, mounted at /_console/ by `--ui`, ships its own Studio
│ surface at /_console/studio/…)
├── RESTPlugin (auto-generated API)
├── DispatcherPlugin
└── AIServicePlugin (cloud / EE only — reverse-mounted by a cloud host)
5. Runtime.start() → init + start all plugins
6. Server listens on the resolved port
Port resolution and conflict behaviour: Ports & networking.
Step 4's list is the fixed core. Tokens marked ° are always-on: every boot
mounts them, listed or not, and --preset minimal drops them; email, approvals or auth keeps job and queue. Every
other service plugin is opt-in (requires: [...] on the stack root turns it on) but the host defaults, mounted under every preset: mcp unless OS_MCP_SERVER_ENABLED=false, and pinyin-search when i18n names a zh-* locale (OS_SEARCH_PINYIN_ENABLED overrides). The CLI expands each
token through the CAPABILITY_PROVIDERS registry in
@objectstack/core:
| Token | Provider package |
|---|---|
automation |
@objectstack/service-automation — flows, and any declarative connectors: entry |
analytics° cache° storage° queue° job° messaging° realtime settings° sms° push |
@objectstack/service- + the token (the push block) |
mailbox |
@objectstack/service-mailbox — inbound mail |
marketplace package-registry° |
@objectstack/service-package |
audit email° sharing° approvals webhooks |
@objectstack/plugin- + the token |
pinyin-search |
@objectstack/plugin-pinyin-search |
mcp |
@objectstack/mcp |
triggers |
@objectstack/trigger-record-change, plus trigger-schedule and trigger-api |
The tokens in PLATFORM_CAPABILITY_TOKENS not in that map do not resolve
through it:
- Tier-gated —
ai,ai-studio,i18n,ui,auth: dedicated blocks inserve.tsrun()open their tier instead (ai/ai-studiothrough the intent-driven AI block). - Enterprise / cloud —
hierarchy-securityships in@objectstack/security-enterprise, loaded throughplugins[];ai-seat,governance,audit-complianceandaccess-policy-consoleare resolved only by cloud's objectos-runtime.
The authoritative list is PLATFORM_CAPABILITY_TOKENS
(@objectstack/spec, kernel/platform-capabilities.ts) — an unknown token is
rejected by defineStack at authoring time, not at boot.
Five rules that change what you write:
- Precedence:
requires›tiers›--preset› built-in default. An explicit instance inplugins:always shadows capability resolution. - Declaring is a demand. A capability YOU declared whose provider package is absent is a hard boot error; one the platform auto-injects for you stays best-effort (warn and continue).
authimpliesemail, even under--preset minimal: auth callbacks need the mail service.- Keep
automationwheneverplugins:lists a connector — connector executors register their provider factories with it, and without it boot fails. - Under
--preset minimal, pairtriggerswithjob:triggersalone arms record-change triggers; schedule and time-relative ones run on the job service, so scheduled flows stay silent.
objectstack.config.ts may export onEnable beside its default stack.
AppPlugin invokes it during boot and hands the app live runtime handles: this
is the one seam where declarative metadata reaches imperative code (registering
action handlers, giving a job its data handle, provisioning a fixture
datasource).
export const onEnable = async (ctx: { ql: { registerAction: (...a: unknown[]) => void } }) => {
registerTaskActionHandlers(ctx.ql);
};→ See references/bootstrap.md § Plugin Loading Order Matters · § Programmatic Bootstrap (Without CLI) · § Multi-App Composition · § Complete Working Example.
The stack's data: collection is authored with defineSeed(), which
objectstack-data owns — go there for externalId matching, env: scoping,
and which keys are derived.
mode (ignore default · upsert · insert · update · replace
A seed deals records to people (owner_id: 'Legal Counsel 1') but cannot
create one: a sys_user row written through data lands as a directory row
with no account, so nobody can sign in as it. Declare the people in
demoStaff; os dev creates each account after the development admin,
signing in with the seed password (--admin-password, default admin123) —
an entry carries no password:
demoStaff: [
{ name: 'Legal Lead', email: 'lead@example.com' },
{ name: 'Legal Counsel 1', email: 'counsel1@example.com', manager: 'lead@example.com',
position: 'crm_legal_counsel', permissionSets: ['crm_contracts_editor'] }],name is what a seed reference resolves by; position / permissionSets
name only what this stack declares (a platform set such as
admin_full_access is refused); manager is another entry's email — the
boot writes manager_id itself once every account exists and keeps one set
by hand, so no seed hook writes it; a display name, an address no entry
has, the entry itself or a loop is refused at load; os build drops the
key and a non-dev boot creates nothing. devLogins creates and grants nothing — it only prints an
existing account on the boot banner.
→ Part 2 — Plugin Development & Kernel Extension: references/plugin-development.md; its detailed rules stay at rules/plugin-lifecycle.md · rules/service-registry.md · references/plugin-hooks.md.
→ Part 3 — Operations: CLI, Testing, Deployment (os deploy to see an edit on your Cloud environment): references/operations.md.
See references/_index.md for the full list of Zod
schemas (with one-line descriptions) — pointers into
node_modules/@objectstack/spec/src/. Always Read the source for exact field
shapes; do not rely on memory of property names.