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
| Property | Type | Required | Description |
|---|---|---|---|
name | string | ✅ | Machine name (snake_case) |
label | string | ✅ | Display name |
description | string | optional | App description |
icon | string | optional | App icon (Lucide) |
active | boolean | optional | Is app active (default: true) |
isDefault | boolean | optional | Is default app |
navigation | NavigationItem[] | optional | Navigation tree |
areas | NavigationArea[] | optional | Partition navigation by business domain — see Areas |
contextSelectors | AppContextSelector[] | optional | Sidebar scope dropdowns whose value is injected into navigation items — see Context Selectors |
branding | AppBranding | optional | Visual customization |
requiredPermissions | string[] | optional | Required permissions to access |
defaultAgent | string | optional | Platform 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. Usemanifest.versionand 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/mobileincluded, ever read it, so themodepicker 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.
Navigation Items
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 removablefilter[<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 —defineStackand lint reject a reference to nothing — and it cannot be combined withrecordId(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' }Page Navigation
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.)
| Property | Type | Description |
|---|---|---|
id | string | Unique identifier (snake_case, required) |
label | string | Display label |
icon | string | Icon name (Lucide) |
order | number | Sort order within the same level (lower = first) |
badge | string | number | Badge text or count displayed on the item |
visible | Expression | Visibility predicate (CEL expression) |
requiredPermissions | string[] | Permissions required to see/access this item |
requiresObject | string | Hide/disable unless the named object is registered |
requiresService | string | Hide/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
groupitem 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
| Property | Type | Required | Description |
|---|---|---|---|
id | string | ✅ | Unique area identifier (snake_case) — the switcher's identity and active-area state key |
label | string | ✅ | Area display label |
icon | string | optional | Area icon (Lucide) |
description | string | optional | Authoring annotation only — no surface renders it today |
navigation | NavigationItem[] | ✅ | 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
navigationis what the sidebar renders; the top-levelnavigationis the fallback, rendered when no area is visible to this user. - Declaration order is display order. Both the sidebar and
AppSchemaRendereriterate theareasarray 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 property | Enforced where | What it actually does |
|---|---|---|
requiredPermissions, requiresService | Server, in both trees — then re-checked in the shell | The entry is never served: it is absent from the /meta body |
visible (CEL), requiresObject | Client only, at every level | Hides 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 setordersaw nothing move. Delete the key and reorder theareasarray. Note the neighbour that behaves differently: a navigation item'sorderis genuinely sorted, and this removal does not touch it.visibleandrequiredPermissions(#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'svisibletakes the same CEL expression, and itsrequiredPermissionsis the server-enforced one — or gate the whole app with the app-levelrequiredPermissions, 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 itsfiltersmap; - a
pageorcomponentitem's stringparamsvalues.
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
| Property | Type | Required | Description |
|---|---|---|---|
id | string | ✅ | Selector id (snake_case) — also the template-variable name: id: 'active_package' is referenced as {active_package} |
label | string | ✅ | Dropdown label |
icon | string | optional | Icon name (Lucide) |
optionsSource | object | ✅ | Where the dropdown's options come from — see below |
allValue | string | optional | Sentinel meaning "nothing concrete is selected yet" (default: '') |
persist | 'query' | 'session' | 'none' | optional | How 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.
| Property | Type | Required | Description |
|---|---|---|---|
endpoint | string | ✅ | REST endpoint returning the option rows (e.g. /api/v1/packages) |
valueKey | string | optional | Row property used as the option value; dotted paths allowed (default: id) |
labelKey | string | optional | Row property used as the option label; dotted paths allowed (default: name) |
filter | { key, op, value }[] | optional | Predicates (AND) a row must satisfy before it becomes an option |
Each filter entry compares the dotted path key against value using op —
eq (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, soincludeAll: falsehardened nothing andincludeAll: trueunlocked nothing. Delete the key; widenoptionsSource.filterto 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.
askis 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
}| Property | Type | Description |
|---|---|---|
primaryColor | string | Primary brand color (hex) |
logo | string | Logo image URL |
favicon | string | Favicon 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.
});Related
- Page Metadata — Custom pages referenced from navigation
- Dashboard Metadata — Dashboards referenced from navigation
- View Metadata — Views displayed within navigation items
- Permission Metadata — Access control for apps
- AI Agents — The two platform agents
defaultAgentbinds