ObjectStackObjectStack

App Metadata

Define application containers with navigation, branding, and access control

App Metadata

An App is a logical container that bundles objects, views, pages, and dashboards into a cohesive application experience. It defines the navigation structure, branding, and access permissions.

Basic Structure

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

const crmApp = defineApp({
  name: 'crm',
  label: 'CRM',
  description: 'Customer Relationship Management',
  icon: 'briefcase',
  active: true,

  branding: {
    primaryColor: '#1a73e8',
    logo: '/assets/crm-logo.svg',
    favicon: '/assets/favicon.ico',
  },

  navigation: [
    { id: 'nav_accounts', type: 'object', label: 'Accounts', objectName: 'account', icon: 'building' },
    { id: 'nav_contacts', type: 'object', label: 'Contacts', objectName: 'contact', icon: 'users' },
    { id: 'nav_opportunities', type: 'object', label: 'Opportunities', objectName: 'opportunity', icon: 'trending-up' },
    { id: 'nav_sales_dashboard', type: 'dashboard', label: 'Sales Dashboard', dashboardName: 'sales_overview', icon: 'bar-chart' },
  ],

  requiredPermissions: ['crm_access'],
});

App Properties

PropertyTypeRequiredDescription
namestringMachine name (snake_case)
labelstringDisplay name
descriptionstringoptionalApp description
iconstringoptionalApp icon (Lucide)
activebooleanoptionalIs app active (default: true)
isDefaultbooleanoptionalIs default app
navigationNavigationItem[]optionalNavigation tree
areasNavigationArea[]optionalPartition navigation by business domain — see Areas
contextSelectorsAppContextSelector[]optionalSidebar scope dropdowns whose value is injected into navigation items — see Context Selectors
brandingAppBrandingoptionalVisual customization
requiredPermissionsstring[]optionalRequired permissions to access
defaultAgentstringoptionalPlatform agent bound to this app's ambient AI chat — see Default Agent

Older app samples no longer parse — check yours before copying it forward. @objectstack/spec 17.0.0 (2026-06 liveness audit, ADR-0049 enforce-or-remove) removed version and mobileNavigation, and both are now refused at parse time rather than ignored, so one leftover key fails the whole save.

  • version — an app is versioned by its owning package. Use manifest.version and delete the key; nothing in framework or objectui ever read the per-app number, which could silently disagree with the package's.
  • mobileNavigation — fully unimplemented: no renderer, packages/mobile included, ever read it, so the mode picker changed nothing. Delete the key; the block returns if and when a real mobile navigation ships.

homePageId (removed in the same major, #4667/#4709) is covered under Common Navigation Properties. Every rejection carries its own replacement instruction, so paste the old app and read what the error tells you.

The navigation tree supports nine item types, combined to create rich menu structures: object, dashboard, page, url, report, action, component, group and separator. Each is documented below.

type is the discriminator: a value outside that list is rejected with the full set of valid ones, and every other key is checked against the branch you picked rather than against all nine.

Object Navigation

Links to an object's list view:

{ id: 'nav_accounts', type: 'object', label: 'Accounts', objectName: 'account', icon: 'building', viewName: 'all_accounts' }

Three optional target fields refine where the entry lands. They are mutually exclusive — combining filters with recordId or viewName is rejected at validation, and the only tolerated pairing is the legacy recordId + viewName (where recordId wins and viewName is ignored):

  • viewName — anchor the entry to a named list view.
  • recordId — deep-link straight to one record ("My Profile"); supports {current_user_id} / {current_org_id} template variables.
  • filters — a one-off parameterized slice: the entry lands on the bare data surface (/:objectName/data) with each condition serialized as a removable filter[<field>]=<value> URL chip, not anchored to any saved view. Use it for drill-throughs and "assigned to me"-style links instead of authoring a view; values support the same template variables. The surface shows what row-level permissions allow — it is not a security feature.
{ id: 'nav_my_open', type: 'object', label: 'My Open Deals', objectName: 'opportunity',
  filters: { owner_id: '{current_user_id}', status: 'open' }, icon: 'user-check' }

A fourth optional field composes with any list landing (viewName, filters, or the default view) instead of choosing one:

  • runAction — deep-link auto-run ("navigate = run action"): after the entry lands on the object's list surface, the shell runs this declared action once (e.g. the create dialog is already open on arrival). The name must resolve to an action defined in the stack — defineStack and lint reject a reference to nothing — and it cannot be combined with recordId (a record detail has no list toolbar to auto-run). The slot is declared contract-first: until the shell consumes it, lint's liveness advisory tells you the auto-run does not fire from this declaration yet.
{ id: 'nav_envs', type: 'object', label: 'Environments', objectName: 'sys_environment',
  runAction: 'create_environment', icon: 'server' }

Dashboard Navigation

Links to a dashboard:

{ id: 'nav_analytics', type: 'dashboard', label: 'Analytics', dashboardName: 'sales_overview', icon: 'bar-chart' }

Links to a custom page:

{ id: 'nav_settings', type: 'page', label: 'Settings', pageName: 'app_settings', icon: 'settings', params: { tab: 'general' } }

URL Navigation

Links to an external URL:

{ id: 'nav_help', type: 'url', label: 'Help Center', url: 'https://help.example.com', icon: 'help-circle', target: '_blank' }

Group Navigation

Groups items into collapsible sections with children:

{
  id: 'grp_sales',
  type: 'group',
  label: 'Sales',
  icon: 'dollar-sign',
  expanded: true,
  children: [
    { id: 'nav_accounts', type: 'object', label: 'Accounts', objectName: 'account', icon: 'building' },
    { id: 'nav_contacts', type: 'object', label: 'Contacts', objectName: 'contact', icon: 'users' },
    { id: 'nav_opportunities', type: 'object', label: 'Opportunities', objectName: 'opportunity', icon: 'trending-up' },
  ],
}

Report Navigation

Links to a saved report:

{ id: 'nav_pipeline', type: 'report', label: 'Pipeline Report', reportName: 'sales_pipeline', icon: 'file-bar-chart' }

Action Navigation

Runs an action instead of navigating to a surface. The reference lives in a nested actionDef block — actionName is not a top-level key on the item:

{
  id: 'nav_import',
  type: 'action',
  label: 'Import Records',
  icon: 'upload',
  actionDef: {
    actionName: 'bulk_import',
    params: { objectName: 'account', mode: 'upsert' },
  },
}

actionDef accepts only actionName and params. params itself is open by design — the action owns its own parameter contract, so the app schema does not validate what goes inside it.

Component Navigation

Renders a registered component. componentRef is a component-registry key, not a file path:

{ id: 'nav_directory', type: 'component', label: 'Directory', componentRef: 'metadata:directory', icon: 'contact' }

params is handed to the component as props and is open by design — the props are the component's own contract.

Separator

A visual divider in the navigation list. It renders no target and carries no label — the only keys it accepts are type, an optional id, and an optional order:

{ type: 'separator', order: 30 }

Common Navigation Properties

All navigation items except separator share these base properties. Every item must declare a unique id (lowercase snake_case) — it is required by the schema, and it is what order sorts and what the landing page resolves to (the first item, since homePageId was removed in 17.0.0 — see below). (On a separator, id is optional and the remaining properties below are rejected — a divider has nothing to label, gate, or badge.)

PropertyTypeDescription
idstringUnique identifier (snake_case, required)
labelstringDisplay label
iconstringIcon name (Lucide)
ordernumberSort order within the same level (lower = first)
badgestring | numberBadge text or count displayed on the item
visibleExpressionVisibility predicate (CEL expression)
requiredPermissionsstring[]Permissions required to see/access this item
requiresObjectstringHide/disable unless the named object is registered
requiresServicestringHide/disable unless the named kernel service is registered

Areas

An area partitions one app's navigation by business domain — Sales, Service, Settings — and each area carries its own independent navigation tree. The active area's tree is what the sidebar renders; the shell offers a switcher above it once more than one area is visible.

When to use an area instead of a top-level group

Both split a long sidebar, but they split it differently, and the choice is about whether the user needs to see the partitions at the same time:

  • A group item keeps everything on screen — a collapsible section inside one tree, with every sibling group still visible beside it. Reach for this first; most apps never need anything else.
  • An area replaces the sidebar. Only the active area's items are listed; the rest are reached by switching. That is the right shape when a Service rep and a Sales rep share one app but never work in the other's tree, and the wrong shape when the two sets are browsed together.

So: simple apps use navigation alone. areas[] is for apps large enough that a single tree would be unusable, and where the domains are contexts a user switches between rather than sections they scan.

Area properties

PropertyTypeRequiredDescription
idstringUnique area identifier (snake_case) — the switcher's identity and active-area state key
labelstringArea display label
iconstringoptionalArea icon (Lucide)
descriptionstringoptionalAuthoring annotation only — no surface renders it today
navigationNavigationItem[]The area's own navigation tree

That is the whole key set: NavigationAreaSchema is strict, so any other key fails the parse — see Retired area-level keys for the three that used to be accepted here.

How areas[].navigation relates to the top-level tree

Both trees hold the same NavigationItem shape, and every item property means the same thing in either one. What differs is which tree the shell renders:

  • Areas take precedence. With areas declared, the active area's navigation is what the sidebar renders; the top-level navigation is the fallback, rendered when no area is visible to this user.
  • Declaration order is display order. Both the sidebar and AppSchemaRenderer iterate the areas array exactly as authored — nothing sorts areas. To rearrange them, reorder the array itself.
  • The first visible area is the initial one, and the switcher appears only when more than one area is visible. A single-area app renders as an ordinary sidebar with no switcher chrome.
  • Area visibility is derived, never authored. An area is offered if and only if at least one item inside it survives the item-level gates below. An area whose every item is gated away disappears from the switcher instead of stranding the user on an empty tree, and it is never auto-activated.

Server-side and client-side gates are not symmetric

Anything that must never reach the browser goes in requiredPermissions, never in visible.

An area carries no gate of its own — gating lives on the items inside it, and the two item-level mechanisms are enforced in different places:

Item propertyEnforced whereWhat it actually does
requiredPermissions, requiresServiceServer, in both trees — then re-checked in the shellThe entry is never served: it is absent from the /meta body
visible (CEL), requiresObjectClient only, at every levelHides an entry the browser has already received

Since #4722 the authoritative server-side filter (filterAppForUser) runs the same item filter over the app's top-level navigation and over every areas[].navigation, so an item's requiredPermissions / requiresService is enforced identically in both trees: a gated entry — with its objectName / pageName / componentRef target — never leaves the server. An area emptied by the gate is dropped from the response, mirroring how an emptied group collapses; an area authored empty is passed through untouched, because filtering reports what the caller may not see rather than tidying the metadata.

visible did not move server-side with them, and that asymmetry is deliberate: CEL is evaluated in the browser because server-side evaluation needs a bound user context the read layer does not have. So visible is a decluttering affordance — it hides an entry the response already contains, and reading the JSON defeats it. Use it to reduce noise, never to keep a secret. The same holds for requiresObject.

Retired area-level keys

areas[].order, areas[].visible and areas[].requiredPermissions were removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) and are now refused at parse time rather than ignored, so one leftover key fails the whole save. Run os migrate meta --from 16 to rewrite existing sources automatically.

  • order (#4667) — no renderer ever sorted areas, so declaration order already was display order and an author who set order saw nothing move. Delete the key and reorder the areas array. Note the neighbour that behaves differently: a navigation item's order is genuinely sorted, and this removal does not touch it.
  • visible and requiredPermissions (#4651) — these were not merely unread keys, they were fail-open gates. No layer read them, so an area "hidden" by one, or restricted to ['sales.admin'], was served to and rendered for every user: the author got a clean parse, a stored value, and no gate at all. Removing a gate that never gated is strictly safer than shipping one that looks like it works. Gate the items inside the area instead — an item's visible takes the same CEL expression, and its requiredPermissions is the server-enforced one — or gate the whole app with the app-level requiredPermissions, which is checked server-side.

The removals do not read back onto the items: item-level gating inside an area is fully supported, and since #4722 it is server-enforced.

Complete Example

Two areas for one field-service app, showing both gate kinds side by side:

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

const fieldServiceApp = defineApp({
  name: 'field_service',
  label: 'Field Service',
  description: 'Dispatch, work orders, and service analytics',
  icon: 'wrench',
  active: true,

  branding: {
    primaryColor: '#0f766e',
    logo: '/assets/fs-logo.svg',
  },

  // Declaration order IS display order — areas carry no `order` key.
  areas: [
    {
      id: 'area_dispatch',
      label: 'Dispatch',
      icon: 'calendar-clock',
      navigation: [
        { id: 'nav_board', type: 'page', label: 'Dispatch Board', pageName: 'dispatch_board', icon: 'layout-dashboard' },
        { id: 'nav_work_orders', type: 'object', label: 'Work Orders', objectName: 'work_order', icon: 'clipboard-list', viewName: 'open_work_orders' },
        {
          id: 'nav_my_jobs',
          type: 'object',
          label: 'My Jobs',
          objectName: 'work_order',
          icon: 'user-check',
          filters: { technician_id: '{current_user_id}', status: 'scheduled' },
        },
      ],
    },
    {
      id: 'area_analytics',
      label: 'Analytics',
      icon: 'bar-chart',
      navigation: [
        { id: 'nav_sla', type: 'dashboard', label: 'SLA Overview', dashboardName: 'service_sla', icon: 'gauge' },
        {
          id: 'nav_job_margin',
          type: 'report',
          label: 'Job Margin',
          reportName: 'job_margin',
          icon: 'file-bar-chart',
          // SERVER-enforced in both trees: a caller without this permission
          // never receives this entry, so its `reportName` is not readable
          // from the /meta body either.
          requiredPermissions: ['service.finance'],
        },
        {
          id: 'nav_forecast_beta',
          type: 'dashboard',
          label: 'Forecast (beta)',
          dashboardName: 'service_forecast',
          icon: 'trending-up',
          // CLIENT-only: this entry IS sent and then hidden. Decluttering,
          // not access control — never put a secret behind `visible`.
          visible: "'service_beta' in current_user.positions",
        },
      ],
    },
  ],

  // The fallback tree: rendered only when NO area is visible to the caller
  // (every area's items gated away, or the areas list filtered empty).
  navigation: [
    { id: 'nav_handbook', type: 'url', label: 'Service Handbook', url: 'https://help.example.com/service', icon: 'book-open', target: '_blank' },
  ],

  requiredPermissions: ['service_access'],
});

Context Selectors

A context selector is an app-level scope dropdown — a Package filter, an Environment switcher, a Locale picker — rendered at the top of the sidebar, above the navigation tree. Its current value is published under the selector's own id and substituted into navigation items as {<id>}, so picking an option re-scopes every item below it without the value being wired into each item by hand.

The substitution is the one already used by {current_user_id} / {current_org_id} (see Object Navigation). The active value is injected into:

  • an object item's recordId, and each value in its filters map;
  • a page or component item's string params values.

A variable with no active value resolves to nothing, and the entry it appears in is dropped from the resolved URL rather than emitted empty — an unscoped shell still produces well-formed links.

Selector properties

PropertyTypeRequiredDescription
idstringSelector id (snake_case) — also the template-variable name: id: 'active_package' is referenced as {active_package}
labelstringDropdown label
iconstringoptionalIcon name (Lucide)
optionsSourceobjectWhere the dropdown's options come from — see below
allValuestringoptionalSentinel meaning "nothing concrete is selected yet" (default: '')
persist'query' | 'session' | 'none'optionalHow the selection survives navigation (default: 'query')

optionsSource re-uses an existing REST surface instead of requiring a bespoke option API: the shell fetches endpoint and maps each returned row to one option.

PropertyTypeRequiredDescription
endpointstringREST endpoint returning the option rows (e.g. /api/v1/packages)
valueKeystringoptionalRow property used as the option value; dotted paths allowed (default: id)
labelKeystringoptionalRow property used as the option label; dotted paths allowed (default: name)
filter{ key, op, value }[]optionalPredicates (AND) a row must satisfy before it becomes an option

Each filter entry compares the dotted path key against value using opeq (default), ne, in or nin. That is what keeps a shared endpoint generic while an individual selector narrows what it offers.

A selector is a mandatory scope

There is no "All" row, and no key asks for one. A selector exists to scope a surface, so an "All" choice would clear the very thing it declares — on Studio's package scope that would mean listing the platform's own system / cloud kernel metadata to a developer who scoped to their own package. The shell instead auto-selects the first option as soon as the list resolves and nothing concrete is selected yet.

That is what allValue names: the value the scope variable holds before a concrete pick — not an "All" option. Empty string is almost always right; set it only if a real option value would collide with ''. To widen what a selector offers, widen optionsSource.filter.

One scope per app today. contextSelectors is an array, but the shipped shell tracks a single active scope: the selection is reflected onto the URL under one fixed query key that every declared selector reads back, so a second selector mirrors the first instead of scoping independently. Declare one selector per app.

Retired selector keys

contextSelectors[].includeAll and contextSelectors[].placement were removed in @objectstack/spec 17.0.0 (#4509, ADR-0049 enforce-or-remove) and are now refused at parse time rather than ignored, so one leftover key fails the whole save. Run os migrate meta --from 16 to rewrite existing sources automatically.

  • includeAll — not merely unread, deliberately disobeyed, for the reason above: the renderer never offered an "All" row regardless of the flag, so includeAll: false hardened nothing and includeAll: true unlocked nothing. Delete the key; widen optionsSource.filter to widen the choices.
  • placement — no renderer ever read it. Selectors always render in the sidebar header block, and 'topbar' placed nothing in the topbar. Delete the key.

Both carried schema defaults, which is why removal was the only channel that could reach an author: a default materialises at parse time, so no lint could tell an authored value from one the schema supplied.

Example — a package scope

The platform's own Studio app is the shipped example: one selector scopes every metadata surface in its sidebar to the selected package.

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

const studioApp = defineApp({
  name: 'studio',
  label: 'Studio',
  icon: 'hammer',

  contextSelectors: [
    {
      // Referenced below as `{active_package}`.
      id: 'active_package',
      label: 'Package',
      icon: 'package',
      optionsSource: {
        endpoint: '/api/v1/packages',
        valueKey: 'manifest.id',
        labelKey: 'manifest.name',
        // Keep the platform's own kernel packages out of a developer-facing
        // scope: only project-scoped packages are selectable.
        filter: [{ key: 'manifest.scope', op: 'nin', value: ['system', 'cloud'] }],
      },
      allValue: '',
      persist: 'query',
    },
  ],

  navigation: [
    // One entry, scoped to whichever package the dropdown has active.
    {
      id: 'nav_objects',
      type: 'component',
      label: 'Objects',
      icon: 'database',
      componentRef: 'metadata:resource',
      params: { type: 'object', package: '{active_package}' },
    },
  ],

  requiredPermissions: ['studio.access'],
});

Default Agent

defaultAgent binds this app's ambient AI chat — the assistant the shell opens inside the app — to one platform agent, so the user never picks from a roster. It is a surface-binding knob, not a custom-agent slot:

  • Omit it on a data app. ask is the implicit default for every app that does not pin one, which is what an ordinary data surface wants.
  • Set 'build' on an authoring surface (Studio is the built-in example), so the app opens the metadata-authoring assistant instead.

Those two platform agents are the whole resolvable set. Tenant and app-package custom agents were withdrawn in ADR-0063, so a name outside it — sales_copilot, say — parses and then binds nothing: the chat surface falls back to the platform default at resolution time. Give an app deeper AI capability by authoring skills, which attach to the platform agents by surface affinity — see AI Agents.

The in-product chat runtime this key binds ships in ObjectOS, not in the open-source framework, which reaches your metadata over MCP (BYO-AI) instead. The key is authorable either way — in the open edition there is simply no in-product chat surface for it to bind.

Branding

Customize the visual appearance of the app:

branding: {
  primaryColor: '#1a73e8',    // Hex color code
  logo: '/assets/logo.svg',   // Logo URL
  favicon: '/assets/icon.ico', // Favicon URL
}
PropertyTypeDescription
primaryColorstringPrimary brand color (hex)
logostringLogo image URL
faviconstringFavicon URL

Complete Example

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

const projectApp = defineApp({
  name: 'project_management',
  label: 'Project Management',
  description: 'Track projects, tasks, and team workload',
  icon: 'folder-kanban',
  active: true,

  branding: {
    primaryColor: '#6366f1',
    logo: '/assets/pm-logo.svg',
  },

  navigation: [
    {
      id: 'nav_home',
      type: 'page',
      label: 'Home',
      pageName: 'pm_home',
      icon: 'home',
    },
    {
      id: 'grp_projects',
      type: 'group',
      label: 'Projects',
      icon: 'folder',
      expanded: true,
      children: [
        { id: 'nav_projects', type: 'object', label: 'Projects', objectName: 'project', icon: 'folder' },
        { id: 'nav_tasks', type: 'object', label: 'Tasks', objectName: 'project_task', icon: 'check-square', viewName: 'task_board' },
        { id: 'nav_milestones', type: 'object', label: 'Milestones', objectName: 'milestone', icon: 'flag' },
      ],
    },
    {
      id: 'grp_reports',
      type: 'group',
      label: 'Reports',
      icon: 'bar-chart',
      children: [
        { id: 'nav_overview', type: 'dashboard', label: 'Overview', dashboardName: 'project_overview', icon: 'layout-dashboard' },
        { id: 'nav_team_workload', type: 'dashboard', label: 'Team Workload', dashboardName: 'team_workload', icon: 'users' },
      ],
    },
    {
      id: 'nav_docs',
      type: 'url',
      label: 'Documentation',
      url: 'https://docs.example.com/pm',
      icon: 'book-open',
      target: '_blank',
    },
  ],

  requiredPermissions: ['pm_access'],
  // No `homePageId`: the landing page IS the first navigation item (by `order`),
  // and the ROOT landing follows `isDefault`. The key was removed in 17.0.0
  // (#4667, #4709) — it did have a consumer, but it pointed at a navigation item
  // by id and fell back silently when that id dangled.
});

On this page