Skip to content

Apps, tabs & navigation

An app is a named bundle of tabs: a label, an icon, a strip of destinations, and a surface to start on. It is the unit a customer ships — “Sales”, “Field Service”, “Setup” — and the unit an administrator hands to a group of people. What a user actually sees when they land — a header, a navigation strip, the landing surface — is drawn by the shell package around that metadata; the app itself is only the list.

It is also the smallest construct on the platform. An app owns no objects, no fields, no records, no page layouts, and no permissions. It is an ordered list of references to things that already exist, plus a routing prefix and a few presentation properties. Deleting an app deletes nothing but the list.

The load-bearing decision on this page: an app is a lens, not a container. Nothing is “inside” an app. An invoice is not a Sales-app record; it is an invoice, and Sales happens to expose a tab that resolves to it. Everything downstream follows from that — routes are derived rather than registered, visibility is decided by the permission planes and not by app membership, an app a user cannot reach is simply absent, and the same record renders identically in whichever app reaches it.

The app model on this page is pure metadata — a list of references. The frame that presents it is not part of the model, and it is not part of the engine. As described in How the platform works, the kernel is a pure engine: it ships only a sign-in screen and an empty-workspace landing, and every other surface arrives as metadata delivered by an installable package. The shell is a capability one package masters, and it owns the surrounding chrome — the global header, the app-navigation strip, the record-tabs strip, global search, and the app switcher (App Launcher). The full contract those surfaces satisfy lives on the architecture site; packaging covers how a package claims the capability.

So two things cooperate on every navigation:

  • The kernel resolves the route — app → tab → object → page → template → components — enforces access on every read along the way, and renders the resolved page inside the shell’s content region.
  • The shell package supplies everything around that region: the header carrying the active app’s brand, the strip presenting its tabs, the record tabs of a console, the App Launcher that pivots between apps.

Nothing in this page’s app or tab metadata is “the header” or “the launcher.” An app declares its brand accent and its tab list; the shell’s header reads that brand and its strip renders that list. Swap the shell package and the same app metadata is framed differently, with no change to the app, the routing, or the engine. This is why the constructs below never name a component: they name references, and the shell decides how those references look.

Construct What it is Owns Where it lives
App Identity (label, icon, brand), an ordered tab list, a landing tab, a navigation shape, utilities Nothing but the list Component, type: "app"
Tab A named, addressable destination — an object, a page, or an external URL Its target reference and its display identity Implicit for objects; component, type: "tab", for page and web tabs
Utility item A component instance docked to the app frame, available from every surface in it Nothing Inline on the app’s utilities[]
Navigation state One user’s strip order, added and removed tabs, open records Per user, per app Record data, never metadata

Three properties hold across all of it.

A tab is navigation, not access. Granting a tab grants the ability to get there. It grants no object permission, no field permission, and no record. A user with the Invoice tab and no Read on invoice sees an empty list, not a privileged one — and, because that is a useless surface, the platform removes it (see visibility resolution).

Membership is not containment. A tab may appear in any number of apps. An object may be exposed by ten apps or none. Removing a tab from an app removes a route from that app’s strip and nothing else.

Navigation state is data. A user’s personalized strip and their open records are rows, versioned by nothing and deployed by nothing. Metadata declares the default; the user’s deviation from it is theirs.

An app declares seven things and no more:

  • Identity — label, icon, and a brand (accent color and optional wordmark). The shell’s global header reads this brand while the app is active; the app supplies the value, the shell renders it.
  • Tabs — an ordered list of tab keys. Order is the strip order and the default order of a user’s personalized strip.
  • Landing tab — the tab the app opens on. It must be a member of tabs.
  • Navigation shape — standard or console. Standard opens one record at a time in place; console keeps open records as tabs. This is mutable after creation, because it is a rendering choice and the tab list is identical either way.
  • Form factors — which of desktop and mobile the app is offered on. Both is the default.
  • Utilities — component instances docked to the frame, persistent across navigation within the app.
  • Personalization — whether users may reorder and extend their own strip. Open by default.

Nothing else belongs on the app component. In particular an app does not declare object permissions, page layouts, list views, or record types — all of those are org-level and shared, and an app that could redefine them would make the same object behave differently depending on how a user arrived at it.

A tab is a destination with a stable key, a label, and an icon. There are three kinds, and one of them is free.

Object tabs are implicit. Every object is addressable the moment it exists, under the key tab.<object>, resolving to the object’s list-view surface. There is no artifact to create and nothing to forget. An object with no tab component is still reachable by URL, by search, and from the App Launcher, subject only to the user holding Read on it.

That is a deliberate departure. In the incumbent, an object and its tab are separate metadata, and a custom object created without a tab is invisible in a way that has its own Salesforce Help troubleshooting article (Custom Object Tab Not Visible For Users). The failure is not exotic — it is what happens when someone creates an object and does not tick the wizard box. Deriving the tab from the object removes the failure rather than documenting it.

Appearing in an app is still by choice. Implicit addressability is not implicit exposure: a new object does not appear in anyone’s tab strip. Adding it to an app’s tabs[] is an explicit, ordered, reviewable edit. The split is the point — reachability is automatic so nothing is stranded, prominence is curated so a strip does not accumulate every object anyone ever modeled.

Page tabs bind a tab key to a page — a dashboard, a workbench, a home surface. The page decides its own template and components; the tab only decides that the page has a name and a place in a strip.

Web tabs point at an external address (CAOS-503) — one property, an absolute http/https URL. It always opens in a new browser tab; there is no frame (in-place) option to declare, and that is not an oversight to fill in later. The operative directive is default-src 'none' in the platform’s own content-security policy: nothing declares a frame-src, there is no mechanism to grant one, and frame-src falls back to default-src when absent — so an iframe onto any origin is refused today. (frame-ancestors 'none', which that same policy carries, is the opposite direction — whether other pages may frame us — and is not the mechanism at issue for a web tab.) So embedding an external address in place is not a capability the platform can offer today, and a per-tab toggle for it would be a knob with one working position. A blocked pop-up is shown as a real, clickable link on screen, never as nothing.

An earlier version of this page described a frame/newTab choice and context-parameter injection for web tabs. Neither was ever built; corrected here in place, 2026-08-22, alongside the kind itself landing in the schema.

A tab component exists only for page and web tabs, because those are the ones with something to declare. Both may be reused by any number of apps, and an app may override the label and icon for its own strip without forking the tab.

A utility is an ordinary component instance whose manifest declares utilityBar among its eligible surfaces. It is docked to the app frame, survives navigation within the app, and may declare startAutomatically and a panel size.

Utilities are not desktop-only. On a phone the utility row collapses into a single sheet handle and the items become entries in it. A capability that exists on one form factor and silently vanishes on another is a worse contract than a smaller one that holds everywhere.

Everything here is a canonical component under retrieve → diff → validate → apply. Per-user navigation state is not.

An app:

{
"key": "app_sales",
"label": "Sales",
"type": "app",
"body": {
"icon": "briefcase",
"brand": { "accent": "#7A1F1F" },
"navigation": "standard",
"formFactors": ["desktop", "mobile"],
"landingTab": "tab.home_sales",
"tabs": [
"tab.home_sales",
"tab.account",
"tab.contact",
"tab.project",
"tab.invoice",
"tab.order",
"tab.vendor_portal"
],
"utilities": [
{ "component": "platform.notes", "label": "Notes", "icon": "note" },
{ "component": "dragon.price_lookup", "label": "Price lookup",
"icon": "search", "startAutomatically": false, "panel": "md" }
],
"personalization": "open"
}
}

A page tab and a web tab:

{
"key": "tab.home_sales",
"label": "Home",
"type": "tab",
"body": { "kind": "page", "page": "page_sales_home", "icon": "home" }
}
{
"key": "tab.vendor_portal",
"label": "Vendor portal",
"type": "tab",
"body": {
"kind": "web",
"url": "https://vendors.example.com/dashboard",
"openIn": "frame",
"passContext": ["user", "org"],
"icon": "link"
}
}

tab.account, tab.invoice, and the rest are never authored — they exist because the objects do.

Both are grants, and both live on a permission set, in the same additive union as everything else in that plane:

{
"key": "ps_inside_sales",
"label": "Inside Sales",
"type": "permission_set",
"body": {
"apps": ["app_sales"],
"tabSettings": [
{ "tab": "tab.invoice", "access": "visible" },
{ "tab": "tab.order", "access": "visible" },
{ "tab": "tab.project", "access": "available" }
// tab.vendor_portal absent → hidden
]
// objectPermissions / fieldPermissions omitted for brevity
}
}

Three access values, one vocabulary everywhere — Setup, API, and CLI use these three words and no others:

Value Meaning
hidden Not reachable. Absent from the strip, the App Launcher, search, and the API’s navigation payload. The default when unmentioned.
available Reachable from the App Launcher, search, and by URL. Not in the strip unless the user adds it.
visible All of the above, and in the strip by default.

Union is most-permissive across every assigned set, matching the rest of the plane. There is no deny, for the same reason there is no deny anywhere else: a model where one grant can be silently cancelled elsewhere cannot be audited by reading the grants.

A tab grant that cannot produce a working surface is caught before it ships:

  • Permission set — granting tab.invoice without Read on invoice in the same set is a warning, not an error, because the object grant legitimately arrives from a different set.
  • Permission set group — the same condition against the group’s resolved union is an error. At that point the union is knowable and a user holding the group would land on a dead tab.
  • App — a landingTab not present in tabs, a tab key that resolves to nothing, or a mobile form factor on an app whose page tab uses a component with no mobile support: all errors.

Resolution runs once per (user, app) and produces the strip the shell will render. Every step is a filter; nothing in it can widen access.

  1. App exposure. The union of apps across the user’s permission sets. An app not in the union does not exist for this user.
  2. Tab access. Most-permissive union of tabSettings, defaulting to hidden.
  3. Target reachability. A tab whose target the user cannot use is dropped, whatever its tab setting says. An object tab requires Read on the object; a page tab requires the page to resolve for that user and form factor; a web tab requires nothing. This is the step that makes the incumbent’s “tab is on but the user gets an error” class of ticket impossible.
  4. Form factor. Tabs whose target cannot render on the current form factor are dropped.
  5. Personalization overlay. The user’s stored order, additions, and removals apply on top.
  6. Landing tab fallback. If the declared landing tab did not survive, the first surviving tab is used. If none survived, the app itself is dropped — an app with nothing reachable in it is not a broken app, it is not an app for this user.

Step 3 is worth stating plainly: a tab grant is not an access grant, and an access gap is not an error. The three planes — object/field, record, system — are unchanged by any of this. The field plane is enforced in the query on every read; the object-Read and record planes are enforced on the read path once record access ships. Navigation shapes what a user sees offered; it is never the thing that stops a read — a destination hidden from a strip is still reachable by direct URL, and the read-path planes are what refuse (or filter to not-found) a request the user may not make.

Every route is derived, and the kernel resolves it — there is no route table, no registration step, and no build artifact to regenerate when an object or a tab is added. The URL grammar is fixed, the metadata supplies the segments, and the resolved page renders in the shell’s content region:

URL Resolves to
/app/<app> The app’s landing tab
/app/<app>/o/<object> The object’s list surface; ?view=<listView> selects one
/app/<app>/o/<object>/<recordId> A record; /edit and /clone are actions on it
/app/<app>/t/<tab> A page or web tab
/setup/<node> The Setup app, addressed by its own tree

The app segment is always present. The incumbent’s record URLs do not carry it — /lightning/r/Account/001…/view renders inside whichever app the recipient happened to have open, so the same link produces a different frame for two people. Including the app makes a deep link fully determined: the same URL yields the same tabs, the same utilities, and the same page assignment for everyone who can open it.

How /o/<object> resolves its structure. Verified 2026-08-21 — in the kernel’s own resolver, not yet reachable from a live route. The kernel exposes resolveListRoute, which resolves an object’s list surface through the SAME most-specific-wins assignment resolver a record page’s page assignment uses, over a ListView rather than a page — a page can never serve a list-kind template (see Pages & components), so the list surface is assigned a list view, the metatype built for exactly this. resolveListRoute takes no ?view= or other query input: it resolves purely from the object in context against the org’s listViewAssignments, and a more specific assignment overrides a less specific one — the same “assign a surface to an object as its override” move a record page’s assignment already supports. This is distinct from a tab’s defaultListView field (above): a tab’s field names one fixed view for that tab’s own landing; the object’s list-surface assignment is the general resolver, keyed on the object itself, that a tab with no defaultListView falls through to. The ?view=<listView> query parameter shown in the routing table below is served today by the shell’s own hand-rolled list screen, a separate code path not yet wired to this resolver — that integration, and any future ?view= support inside resolveListRoute itself, is follow-on work, not part of what shipped here.

The app segment is a rendering context, not a gate. If a user can read the record but cannot reach the named app, they are redirected to the same record in an app they can reach — because the app decided the frame, and the frame is not the thing they asked for. Only when nothing reachable can render it does the request fail.

Not-found beats forbidden, consistent with the error model:

Situation Result
Record not visible under the record plane not_found.record
App exists, user has no grant, record is reachable elsewhere Redirect to a reachable app
App exists, user has no grant, nothing else reaches the target not_found.app
Tab hidden for this user, target still readable Resolves; the tab is simply not in the strip
Tab key does not exist not_found.tab

Returning forbidden for an app or a record the user cannot see would leak that it exists, which for a private object is exactly the fact the org-wide default was set to hide.

Keys are the URL, labels are not. A tab key and an object key are immutable once deployed, so a saved link never rots. label is free to change at any time and changes nothing about addressing — the same split the platform draws everywhere between identity and presentation.

The shell’s app-navigation strip renders as many tabs as fit and moves the remainder into an overflow menu, measured at render. There is no cap on the number of tabs and no cap on personalization, because overflow is computed rather than budgeted: a strip of sixty tabs is a long overflow menu, not a broken app or a silently disabled feature.

Within an app, a user may reorder tabs, add any tab they can reach, and remove any tab, including ones the administrator declared. Removal affects that user’s strip and nothing else — the tab stays reachable from the App Launcher, from search, and by URL, because access was never the strip’s job. The incumbent forbids removing admin-placed items precisely because in its model the strip is the access path; once reachability is decided by permission sets, the prohibition has nothing left to protect.

An app may set personalization: "locked" where a fixed strip is a requirement rather than a preference — a regulated workflow, a kiosk, a training environment. It is a per-app property, not an org switch.

Personalization is stored per (user, app). Resetting it restores the declared order exactly.

Standard apps open one record at a time in place. Opening a record whose object is not in the strip creates a transient tab, marked as transient, dropped when it is closed or the session ends. It never becomes a permanent personalization without the user saying so.

Console apps keep open records as tabs beneath the strip — the shell’s record-tabs strip — with related records opening as subtabs of the record that produced them. Whether that set survives a session is a per-app property. Because the shape is a rendering decision over the same tab list, switching an app between standard and console is an ordinary metadata edit; nothing in either shape is stored per-app beyond the flag and the user’s open-tab state.

The open-record set is bounded at 25, per user per app. Two different bounds operate on that strip and they must not be confused. Rendering overflow is measured at draw time — as many tabs as fit are drawn and the rest collapse into an overflow menu, exactly as the app strip above it behaves — and it has no fixed number, because it depends on the viewport. Capacity is the count of records the app holds open at once, and that is fixed at 25. Every open record counts: a workspace tab and a subtab are both one record on screen, and a bound that ignored subtabs would be a bound on nothing.

It is per app rather than per session because that is where the state already lives. The open set is stored per (user, app) and restored at the next sign-in, so a user working two consoles carries two independent sets of 25 and closing the browser costs neither of them. Twenty-five is not a rendering ceiling — overflow already absorbs any number — it is the point past which the strip stops being a way to find anything, and past which restoring the set at sign-in becomes a noticeable cost paid on every login for tabs the user abandoned weeks ago.

At the bound, opening a twenty-sixth record closes the least-recently-active clean tab and opens the new record in the active position. Least-recently-active means last touched, not last opened, so the record someone parked on Monday and returned to on Friday outlives the one they glanced at an hour ago. The close is stated rather than silent: the user is told which record was closed and can reopen it from their recent items, because a set that quietly thins itself is a set nobody trusts to hold their place.

A tab holding uncommitted edits is never the one evicted. Eviction skips every dirty tab and takes the oldest clean one instead. When all 25 are dirty, the open is refused rather than resolved by discarding someone’s work: the platform states that the app is holding 25 records with unsaved changes, names the least-recently-touched of them, and offers to go there. This is the same rule the strip follows everywhere — overflow before closing, confirm before discarding — applied to the one case where the platform, not the user, is choosing what closes.

The App Launcher — the shell’s app switcher — searches apps, tabs, objects, and Setup destinations in one field, over exactly the set that survived resolution. There is no “all tabs” page listing things a user cannot open, and no ordering of tiles that includes apps they will be refused.

Tile order comes from an org-level app order, overridden by the user’s own arrangement once they change it.

Which app a user lands in on login resolves in order: their last-used app, then the org’s declared default app, then the first app they can reach in launcher order. It is deliberately not a permission-set property. Tying the landing surface to a security artifact — as the incumbent does, allowing exactly one profile to mark one app as default — forces a choice between “which app should this person start in” and “what may this person do”, which are unrelated questions that then have to be answered by the same object.

An app contributes exactly one thing to rendering: it is one axis of page assignment. A page is routed by app + record type + permission-set group + form factor, and the app supplies the first axis and nothing else (pages & components). Which template a page uses, which regions exist, and which components sit in them are decided by the page and its template — never by the app.

The practical consequence: two apps exposing the same object show the same fields, the same required behavior, and the same actions, unless a page assignment deliberately says otherwise. Surface parity is the default state, not an achievement.

The boundary is ownership of shared state.

Belongs to the app admin Belongs to org Setup
Label, icon, brand accent Objects, fields, relationships
Tab list and order; landing tab Permission sets, groups, assignments
Navigation shape; form factors Roles, sharing rules, org-wide defaults
Utility items and their props List views, record types, page layouts
Personalization lock Lifecycles, automation, validation
Page assignments scoped to this app Everything else

The rule that produces the table: an app admin may change how their app presents shared things; they may not change the shared things. Renaming the Invoice tab to “Bills” inside one app is an app-level edit. Renaming the Invoice object is not, because seven other apps and every integration would see it.

Authority is granted through the system-permission plane naming the specific app component, so an app administrator can deploy their own app without holding org-wide metadata authority. Every such edit lands in the metadata audit stream with the same envelope as any other deploy.

The platform ships two apps in the base-org template, and they are ordinary app components — not privileged chrome:

  • Setup — navigation: "console", its tabs bound to the Setup pages, its tree rendered by the shell’s console rail on the setup templates. It is reached from the App Launcher like anything else and addressed at /setup/…. Setup is itself a package that masters the setup capability; it edits metadata the engine interprets and holds no privilege the engine does not expose to any app.
  • Profile — the signed-in user’s own record, preferences, sessions, and personalization reset. One object, a small tab list, nothing special.

Both are platform-owned: present in every tenant, upgraded with the platform, and refused by safe-delete. A tenant may reorder their tabs, add its own tabs to them, and rebrand them; it may not delete them or remove the tabs the platform declares.

They exist as ordinary apps for a structural reason, not an aesthetic one. A Setup surface built from privileged, non-metadata machinery is a Setup surface that can do things customer apps cannot — and every one of those things is a capability the platform failed to expose. Building Setup from the same components is the check that keeps the model honest: if Setup needs something the metadata cannot express, the metadata is wrong.

Concern CAOS Salesforce Why their limit exists
Tabs per app No cap; overflow is computed No cap on the list, but personalization stops above 50 items The strip is the access path, so its length is a security-adjacent property
User personalization Always available; user may remove admin-declared tabs Unavailable above 50 items; admin items cannot be removed or renamed Same cause: removing a nav item would remove the practical means of reaching the object
Custom tabs per org None — object tabs are implicit, so most orgs author very few 1,225 custom tabs (Enterprise Edition allocation) Every tab is a metadata artifact, including one per custom object
Apps per org No cap 260 custom apps (Enterprise Edition allocation) —
Navigation shape Mutable per app navType is “Not updateable” Console and standard diverge deeply enough in its implementation to make the flag structural
Utility items No cap; utilities reflow into a sheet on mobile Utility bar is “supported in Lightning Experience for desktop only” The utility bar is a desktop-frame feature, not a component contract
Tab visibility vocabulary Three values, one vocabulary Three values under three different vocabularies Profiles, permission sets, and the Metadata API each named the same enum separately

A Lightning app is the CustomApplication metadata type, authored in the App Manager in Setup. Its navType field “indicates the type of navigation the app uses. The value Standard is for a Lightning app with standard navigation. The value Console is for a Lightning app with console navigation,” and it is marked “Not updateable.” formFactors chooses among Small (mobile), Medium (reserved), and Large (desktop). tabs is “the list of tabs included in this application,” where “built-in tabs are prefixed with standard-” (CustomApplication).

Navigation behavior is controlled by three booleans on the same component: isNavPersonalizationDisabled (“indicates whether navigation personalization is disabled”), isNavAutoTempTabsDisabled (“indicates whether the navigation automatically creates temporary tabs settings. Applies only to Lightning apps with standard navigation”), and isNavTabPersistenceDisabled (“indicates whether workspace tabs are cleared for each new console session”).

Standard versus console. “Apps with standard navigation let you open a single record at a time,” while “apps with console navigation let you open multiple records at a time, and related records open in subtabs under the original record” (How Are Console Apps Different?). The utility bar is “a specialized type of Lightning page that gives your users quick access to common productivity tools,” and “utility bars are supported in Lightning Experience for desktop only” (Console Developer Guide).

Tabs. CustomTab covers custom object tabs, web tabs, Visualforce page tabs, Lightning page tabs, and Lightning web component tabs — one content field populated, mutually exclusive. For a custom object tab the tab’s fullName must match the object name exactly and customObject must be true; web tabs additionally carry url, urlEncodingKey, frameHeight, and hasSidebar (CustomTab). An object and its tab are separate artifacts, which is why “custom object tab not visible” has a dedicated troubleshooting article whose checklist includes the object’s deployment status, the profile tab setting, and whether the object was created “without selecting the option to Launch New Custom Tab Wizard” (Salesforce Help).

Visibility. Apps are exposed by profile or permission set: “assigned app settings specify the apps that users can select in the Lightning Platform app menu,” and “unlike profiles, you can’t assign a default app in permission sets. You can only specify whether apps are visible” (Assigned Apps). Tab settings “specify a tab’s availability in App Launcher or the All Tabs page and its default visibility in apps,” and “you can view and edit tab settings in permission sets only if the permission set has a license type specified” (Tab Settings in Permission Sets or Profiles).

The same three states carry three different vocabularies, and Salesforce documents the divergence rather than resolving it — “tab settings labels in permission sets differ from the labels in profiles”:

State Profile UI Permission set UI Metadata API
In the strip by default Default On Available and Visible DefaultOn (Profile) / Visible (PermissionSet)
Reachable, not in the strip Default Off Available DefaultOff / Available
Not reachable Tab Hidden None Hidden / None

Sources: Tab Settings reference, Profile, PermissionSet. The None case is documented as removing the tab from “the App Launcher or the All Tabs page,” from “any app navigation,” and from API responses, and “the most permissive setting applies” when a user holds several assignments.

The App Launcher “displays tiles that link to a user’s available Salesforce, connected (third-party), and on-premises apps,” and “users see only the apps that they are authorized to see according to their profile or permission sets”; administrators “determine which apps are available to which users and the order in which the apps appear” (App Launcher).

Personalization is real but capped and asymmetric. A navigation bar “can have up to 50 items, including the default items,” users “can’t remove the items you include in the navigation bar,” and they “can’t personalize the navigation bar when it contains more than 50 items” — an admin who places 32 items leaves users 18. A temporary tab “opens when you open an item that doesn’t have a parent object already in the navigation bar,” is marked with an asterisk, and disappears on close, logout, or app switch; temporary tabs also “aren’t available in the new browser tab” if the user opens one. When an admin removes an item from an app, “that item remains in your users’ personalized navigation bars,” and admins “can’t access or modify the personal items users add” (Personalized Navigation Considerations). In console apps the same asymmetry holds: users may add, rename, and remove their own items, but “you can’t rename or remove items that your admin has specified for the app” (Personalize the Navigation Menu for Lightning Console Apps).

Routing is a fixed grammar over metadata, and it is the part Salesforce got right: standard__app → /lightning/app/{appTarget}, standard__objectPage → /lightning/o/{objectApiName}/{actionName}, standard__recordPage → /lightning/r/{objectApiName}/{recordId}/view, standard__navItemPage → /lightning/n/{apiName} for “the content mapped to a custom tab”, and standard__webPage for an external URL (PageReference Types). Notably, only the app route names an app; record and object routes do not.

Actions are not the App Builder’s. On a record page the actions in the highlights panel come from the page layout — the Salesforce Mobile and Lightning Experience Actions section — not from the page the App Builder assembled, unless Dynamic Actions is enabled for that page. Dynamic Actions reached general availability for desktop on a limited set of standard objects (Account, Case, Contact, Lead, Opportunity) alongside custom objects, so for the remaining standard objects the layout still owns the action list. That split — one artifact owning fields and another owning actions on the same surface — is the same split the page model collapses into a single manifest.

Where CAOS is genuinely better:

  • Object tabs are derived from the object. There is no second artifact to create, so the “object exists, tab does not” failure — and the troubleshooting article written for it — has nothing to describe.
  • One visibility vocabulary. Three words, used identically in Setup, the API, and the CLI. The incumbent ships three vocabularies for one enum and documents the mismatch instead of removing it.
  • A tab whose target is unusable is dropped, not shown. Reachability is resolved against the permission planes at render, so a granted tab always leads somewhere. Salesforce evaluates tab settings and object permissions independently and lets the user find the gap.
  • The app segment is in every URL. A deep link determines its own frame. A shared record link renders the same tabs and utilities for the sender and the recipient.
  • Navigation shape is mutable. Standard ⇄ console is a metadata edit, not a rebuild-the-app migration, because the tab list does not differ between them.
  • Personalization has no cliff and no asymmetry. No 50-item threshold past which the feature silently turns off, and no admin-placed item a user is forbidden to remove from their own strip — since removing it from the strip never removes access.
  • Utilities survive the form factor. They reflow into a sheet on mobile rather than being a desktop-only feature.
  • Landing app is decoupled from security. Last-used, then an org default, then first reachable — instead of one profile marking one default app, which is a permission artifact answering a preference question.
  • The chrome is replaceable. The header, strip, record tabs, and App Launcher are the shell package’s surfaces, not compiled-in UI — swap the shell and the same apps are framed differently with no change to the app metadata or the engine.

Parity: the app as a named, assignable, ordered list of tabs; tabs that resolve to an object, a platform page, or an external URL; an App Launcher that shows only what the user may reach; per-user navigation personalization; standard versus console shapes with workspace tabs and subtabs; a persistent utility area; transient tabs for records opened outside the strip; visibility driven by the permission layer; and a fixed URL grammar derived from metadata. The model is right, and CAOS ports it.

Costs and risks:

  • Derived routes make keys permanent. With no route table there is nothing to add a redirect to, so an object or tab key is immutable once deployed. Renaming is a create-plus-migrate, and the platform must make that obvious at authoring time rather than at the first broken bookmark.
  • Resolution is per request. Six filter steps run on every navigation render. They are cheap and cacheable per (user, app, metadata generation), but the cache must be invalidated by the generation flip or a user will keep a stale strip after a deploy.
  • Absence is hard to debug. A dropped tab looks identical to a tab that was never granted. The resolution steps must be explainable — reporting, per tab, which step removed it for a given user — and that explain surface is part of the feature, not a later addition. Verified 2026-09-10 — there is still no app noun in the CLI, so this explain surface has no command-line form; the intended caos app explain sales --user jane shape does not exist. Tracked as CAOS-1140, whose first slice gave the jobs noun its read surface and left this one untouched: unlike the job engine, whose primitives were already built and merely unreachable, per-tab resolution is not expressed anywhere as the discrete steps this explain surface would have to report, so building it is authoring the resolver rather than exposing one.
  • Implicit object tabs make the App Launcher noisy. Every readable object is reachable, so an org with 300 objects has 300 launcher entries. Search relevance and per-object launcher suppression carry weight they would not carry if tabs were opt-in artifacts.
  • Letting users remove admin-declared tabs will generate complaints. An administrator who places a tab and finds users removed it has lost a lever they had in the incumbent. The personalization: "locked" escape hatch is the answer, and it needs to be discoverable or it will be reinvented as a support process.
  • Refusing to open a record is a hard stop a user did not ask for. Never evicting a dirty tab means a console holding 25 records with unsaved changes cannot open a twenty-sixth, and the person who hits that is mid-task. It is the right trade against discarding someone’s edits, and it depends on the refusal naming the tabs that caused it and offering a way to them — a bare “too many tabs open” would be the worst version of this rule.
  • Mutable navigation shape multiplies test surface. Every app must work in both shapes, including its utilities and its open-record behavior, and a shape flip must degrade console-only user state without losing it silently.
  • Framed web tabs depend on someone else’s headers. The deploy-time check catches the target’s current policy, not the policy it will have next quarter; a runtime failure state for a frame that starts refusing to load is required.
Component type Body
App app icon, brand, navigation, formFactors, landingTab, tabs[], utilities[], personalization
Tab (page or web) tab kind, plus page or (url, openIn, passContext), icon
App exposure on permission_set apps[]
Tab exposure on permission_set tabSettings[] — { tab, access }
Org app order app_settings order[], defaultApp

Object tabs have no representation, because they have no independent existence: retrieving an org’s metadata returns the objects and the apps that reference them, and tab.invoice appears only as a string inside a tab list or a tab setting.

Per-user navigation state — strip order, added and removed tabs, open console tabs, last-used app — is record data, never a component. It does not move between environments, it is not diffed, and it is not deployed. The same split the permission model draws between a set and its assignments.

A deploy that reorders tabs, adds one, or flips an app to console navigation is metadata-only and takes effect at the next render under the standard generation flip. Removing a tab from an app is a component edit; deleting the tab component routes through safe-delete, which refuses while any app still references it.

Salesforce Metadata API analogs, for migration mapping: CustomApplication (navType, uiType, formFactors, tabs, utilityBar, brand, isNavPersonalizationDisabled, isNavAutoTempTabsDisabled, isNavTabPersistenceDisabled), CustomTab (customObject, url, page, flexiPage, lwcComponent, motif), Profile.tabVisibilities and Profile.applicationVisibilities, PermissionSet.tabSettings and PermissionSet.applicationVisibilities, and AppMenu for launcher ordering.

  • How the platform works — the engine/UI separation this page rests on: the kernel renders metadata, and UI arrives as packages.
  • The developer surface — the full map of what you author, and where apps and navigation sit within it.
  • Pages & components — what a tab opens: templates, regions, and the components a page places in them.
  • Packaging — how a package claims the shell capability that renders this navigation, and how one master per capability is enforced.
  • Design system — the tokens and brand seam the shell’s header reads, so an app’s accent and a tenant’s wordmark re-skin the frame without touching it.
  • Architecture site — the shell contract (required surfaces, named regions, injection points) and the object model (App and Tab metadata types, the five lanes).