Skip to content

Shell Page reference

This page is the complete reference for Shell Pages and Shell Components: every field, default, policy, composable and event. For an overview of the concepts, see Shell pages and Shell components.

A Shell Page is a Content Server Smart Page (subtype 43300) flagged with pExtendedData.pageRenderer = "shell". Its Velocity view is a static host rather than a classic am_page.js round-trip page. A client-side Shell runtime, booted by the tile_content_shell Smart View tile, executes its declarative, region-based composition.

This guide is the practical, option-by-option reference for Shell Pages. It walks every supported use case — native Vue components, embedded AI View forms, explicit bundles, custom adapters, shared state, events, dirty-state protection, runtime region swaps and multi-tile composition — and documents every field you can set on the page definition and on a region or component.

Mental model

Components request, the Shell decides, the Region Manager executes, adapters mount, and events notify. The Shell is an execution runtime, not a design-time editor, and it never contains business logic.

How the pieces fit together

A generated Shell Page artifact contains these files:

File Becomes
view.vm The page content (a static Velocity layout).
controller.groovy The child Content Script named Controller.
page.json Renderer, dependencies and the region contract of the page.
generation.json, chat.json, stats.json, validation.json Generation provenance, transcript, statistics and validation results.

Once materialized, the Smart Page node (43300, pageRenderer = "shell") has a child Content Script Controller (43200) that produces the raw definition in model.data.shellDefinition. The page content is the generated Velocity view, which may host inert region containers (data-am-region).

flowchart TD
    ctrl["Controller (child Content Script)"] -->|"model.data.shellDefinition (raw)"| render["Smart Page render path"]
    render -->|"amsuishell.resolvePageDefinition: ACL filter, registry resolution, per-user cache"| inlinejson["Inline JSON: script#am-shell-definition"]
    inlinejson --> tile["tile_content_shell"]
    tile -->|"runtime.css, import map, import engine.js"| engine["createShell({ definition, root: document, host, hostLocale, debug })"]
    engine -->|"discovers [data-am-region], mounts initialComponentKey"| adapter["Adapter imports bundleUrl and mounts the component"]

Step by step:

  1. The render path runs the Controller, then passes the raw definition to amsuishell.resolvePageDefinition(<page node id>, raw, true). Resolution drops registry entries the user cannot see and completes each remaining entry (bundleUrl, stylesUrl, exportName, inputSchema, capabilities, requiredPermissions, sharedState, events, dataSources, designProfile) from the component's stored component.json, or resolves an AI View entry through the ai-form adapter.
  2. The render path wraps the view in <div class="am-shell-page">…</div> and serializes the resolved definition into <script type="application/json" id="am-shell-definition">.
  3. tile_content_shell injects the page HTML, reads the inline definition, injects the kit stylesheet runtime.css and an import map for vue and @amshell/runtime, imports engine.js, then calls createShell({ definition, root: document, host, hostLocale, debug }) and start().
  4. The engine discovers [data-am-region] hosts across the whole document and mounts each region's initialComponentKey. The adapter imports the component's bundleUrl and mounts it.

Exactly one Shell runtime runs per perspective. When another Shell tile on the same perspective renders, its regions are merged into the running Shell as a contributor fragment instead of booting a second runtime (see Multi-tile pages).

Four kinds of object are involved. They are easy to confuse, so keep them straight:

Concept What it is Where it lives
Shell Page The 43300 Smart Page the user opens, flagged pageRenderer = "shell". Anywhere in the tree.
Controller The child Content Script (43200) named exactly Controller. It emits the raw definition. controller.groovy in the artifact; child of the Shell Page.
Shell Component A reusable component (compiled bundle.js + component.json + locales.json, optional styles.css), stored as the versions of a 43300 Smart Page flagged pageRenderer = "shell-component". Any folder. The registry references it by node id.
AI View An existing AI View form (subtype 43100) mounted as a region. Any folder. The registry references it by node id.

The registry in the definition maps a logical componentKey (for example customer.detail) to one of those nodes, or to an explicit bundle URL. The resolver turns each registry entry into a full ComponentDefinition (with a real bundleUrl, or the resolved ai* URLs for a form) before the engine sees it.

There are three cooperating layers:

Layer Technology Role
Host Smart View tile_content_shell Load the rendered layout, boot exactly one Shell runtime, own auth, URLs and session, provide native dialogs and notifications.
Shell runtime TypeScript engine + @amshell/runtime (Vue) Discover regions, mount and replace components, enforce policy, permissions and dirty state, coordinate shared state and events.
Server Content Script Controller + Smart Page render path Run the Controller, resolve and ACL-filter the definition, emit the static layout and the inline JSON.

Prerequisites

The page cannot boot unless the engine and runtime assets are served from the Content Server support path. <support> below is the support path configured in the Smart View connector (connection.supportPath, for example /cssupport). If the connector has no support path, the tile falls back to /support/anscontentsmartui/amshell/engine/v1/engine.js.

Confirm these URLs return 200 in the browser before authoring anything:

Asset Served URL
Shell engine <support>/anscontentsmartui/amshell/engine/v1/engine.js
Vue (shared) <support>/anscontentsmartui/amshell/runtime/v1/vue.esm-browser.prod.js
Shell runtime <support>/anscontentsmartui/amshell/runtime/v1/runtime.js
Kit stylesheet <support>/anscontentsmartui/amshell/runtime/v1/runtime.css
Smart UI tile the anscontentsmartui Smart UI bundle, which provides tile_content_shell

engine.js and runtime.js change in place within v1, so the tile requests them with a ?v=<Module Suite version> query (for example ?v=4.1.0) to bypass stale browser caches.

The engine, the shared runtime and every compiled component bundle import the bare module specifiers vue and @amshell/runtime. Before it imports the engine, the tile injects one page-level import map (marked data-amshell-importmap) that maps them to the files under the same support path the engine is loaded from. The tile also injects runtime.css once (marked data-amshell-runtime-style); no JavaScript module imports it. As a result the engine, the runtime and every component share one Vue instance.

Warning

Never bundle Vue or @amshell/runtime into a component. Keep both as external bare imports so the import map can resolve them.

If you also embed AI View forms, the AI View runtime must be deployed too. The ai-form adapter loads it from <support>/ansbwebform/runtime/eve/<version>/runtime.js (the entry's aiRuntimeBase) and injects a separate import map, scoped to that runtime, for vue, @aifb/runtime and ansbwebform-runtime.

The Shell Page node

shell-page is generated as a specialization of the first-class smart-page type. One manifest component produces one artifact:

File Purpose
view.vm Velocity page content. It may host none, some, or all declared regions. Do not emit the <am:page /> placeholder.
controller.groovy Generated source persisted as the child Content Script named exactly Controller.
page.json pageRenderer ("shell"), depJs, depCss, optional templateRef, and regions (the region contract from the page specification).
generation.json Artifact identity and provenance (run, recipe, model, runtime version, target host, Design Profile, digests).

Materialization:

  • creates or versions the subtype 43300 page in the project's pages folder with view.vm;
  • sets the page extended data: pageRenderer, advanced mode, and the depJs / depCss lists from page.json;
  • creates or versions its subtype 43200 child Controller with controller.groovy;
  • rejects the artifact when view.vm hosts a data-am-region id that page.json does not declare (view.vm hosts undefined page regions: …), or when page.json pageRenderer does not match the component type.

The page publication records the child Controller node id, and drift detection covers both generated source files (view.vm and controller.groovy). The raw page definition is not stored in extended data; the Controller produces it on every request.

Note

A Shell Page always renders inside the Shell wrapper (<div class="am-shell-page"> plus the inline definition). A Smart Page template frame is not applied to it.

pageRenderer is authoritative: its absence selects the classic Smart Page path and "shell" selects the Shell path. The Controller output cannot change the page kind.

pExtendedData.pageRenderer Runtime
(absent) Classic Smart Page (Velocity + am_page.js)
"shell" Shell Page (this guide)
"shell-component" Storage node of a Shell Component. It cannot be rendered as a page; rendering it fails with an error.

There is no new subtype — classic pages, tiles and the ampagenotify bus keep working unchanged. The Shell coexists with them and exposes only an explicit, namespaced bridge to ampagenotify (see Events and classic interop).

The Controller and the raw page definition

The generated controller.groovy becomes an ordinary Content Script child named Controller. It computes the raw definition into model.data.shellDefinition. It may also put other values in model.data for the Velocity view. It does not call resolution and does not touch extended data. The render path always resolves the raw definition centrally through amsuishell.resolvePageDefinition (ACL filter and per-user cache), so a Controller cannot bypass security.

After the Controller runs, the render path:

  • fails the render with Shell page <id> Controller did not publish model.data.shellDefinition. when the value is missing;
  • treats a value that is not a map as an empty definition;
  • falls back to the raw, unresolved definition if resolution throws (the error is logged);
  • removes shellDefinition from model.data and exposes only the serialized JSON, so the view cannot read $model.data.shellDefinition.

The raw definition is a single map. This is its full shape:

model.data.shellDefinition = [
    pageId  : "customer-workspace",   // logical id, used by the engine and diagnostics
    version : "1",                    // definition version, surfaced in the shell state

    // Bootstrap state seeded into the engine (see Security and permissions, Shared state).
    initialState: [
        // `locale` is optional: omit it to use the user's Smart View language.
        user    : [ id: "...", displayName: "...", permissions: [...], roles: [...], attributes: [:], locale: "it" ],
        metadata: [:]
    ],

    // Cross-region shared store (see Shared state). Optional.
    sharedState: [
        "selectedCustomer": [ initial: null ],
        "appConfig"       : [ initial: [ theme: "light" ], readOnly: true ]
    ],

    // Optional runtime user-context refresh (see Security and permissions).
    userContextEndpoint : "/api/v2/members/current",

    // The region map (keyed by regionId).
    regions : [ /* regionId: RegionDefinition */ ],

    // The component registry (keyed by componentKey).
    registry: [ /* componentKey: registry entry */ ]
]

Top-level fields:

Field Type Required Meaning
pageId string yes Logical identifier surfaced to the engine (shell state pageId) and diagnostics.
version string yes Definition version, surfaced as the shell state definitionVersion. Any change to the raw definition, including this value, produces a new resolved-definition cache entry.
regions map yes regionId → RegionDefinition. See Regions and the layout View and Region policy.
registry map yes componentKey → entry. See The registry.
initialState object no Bootstrap user context (id, displayName, permissions, roles, attributes, locale) and arbitrary metadata. See Security and permissions.
sharedState map no key → { schema?, initial?, readOnly? } cross-region store. See Shared state.
userContextEndpoint string no URL the engine GETs, through its REST client, before mounting. A successful JSON response is merged over the bootstrap user; a failure or non-2xx status keeps the bootstrap user.
userContextScriptId number no Accepted and logged, but the engine does not call it: without userContextEndpoint the bootstrap user context is used. The tile copies the tile option source.userContextScriptId here when the definition does not set it.

Note

regions and registry are maps, keyed by regionId / componentKey. They are not arrays.

Generated Controller output

The Controller examples in this guide show the expected generated controller.groovy output. They are not separate manifest components. A minimal Controller is:

// 'model' is bound by the render path. Resolve component keys to node ids however you like.
def components = docman.getNodeByPath(docman.getContentScriptVolume(), "CSComponents")

model.data.shellDefinition = [
    pageId : "customer-workspace",
    version: "1",
    regions: [
        sidebar: [ initialComponentKey: "customer.sidebar", policy: [ mutable: false ] ],
        main   : [ initialComponentKey: "customer.detail" ]
    ],
    registry: [
        "customer.sidebar": [ componentNodeId: docman.getNodeByName(components, "customer-sidebar").ID ],
        "customer.detail" : [ componentNodeId: docman.getNodeByName(components, "customer-detail").ID ]
    ]
]

Per-request variation

The resolved definition is cached per user, page node and page version, plus a fingerprint of the whole raw definition. A Controller whose output varies per request (for example with params.objId in initialProps) gets a separate cache entry for each distinct output, so it needs no extra handling.

Regions and the layout View

A region is a named mount point where the Shell mounts exactly one component at a time. The engine accepts any non-empty region id (sidebar, main, right-panel, region-42, …); the Shell editor widgets limit ids to letters, numbers, dots, underscores and hyphens. The Controller's RegionDefinition is authoritative and must cover every region declared in the page specification. A DOM container carrying data-am-region="<regionId>" is only a host. The page view may provide none, some, or all hosts, because hosts can also come from the surrounding perspective, sibling tiles, or late-arriving markup.

RegionDefinition fields

Field Type Default Meaning
initialComponentKey string (none) Component mounted when the region's host is discovered. Authoritative — the engine uses this, not the DOM attribute. It must exist in registry (otherwise the initial mount fails with component-not-found). Without it the region stays empty until a component requests a change. Generation and the Shell widgets require it.
initialProps object {} Props passed to the initial component (for example [ customerId: params.objId ]). They are validated against the component's inputSchema; invalid props reject the mount with invalid-props.
policy object (none) A RegionPolicy (see Region policy). Omit it for an unconstrained region.

The region id comes from the map key; you do not set a regionId field inside the entry.

regions: [
    sidebar: [
        initialComponentKey: "customer.sidebar",
        initialProps       : [ customerId: params.objId ],
        policy             : [ mutable: false ]
    ],
    main: [
        initialComponentKey: "customer.detail",
        initialProps       : [ customerId: params.objId, mode: "view" ],
        policy             : [ mutable: true, allowedComponents: [ "customer.detail", "customer.orders" ] ]
    ]
]

The initial mount is an ordinary region change whose source and target are the region itself (reason initialization, dirty check off). It goes through the region policy and the component's requiredPermissions like any other change. A rejected initial mount is logged as Initial region mount rejected and the region stays empty.

The layout View

The generated view.vm is a static Velocity layout. It may include inert region containers, but does not have to reproduce the complete Controller region map. Any region id it does emit must match a Controller regions key.

<div class="row">
  <div class="col-xs-3 am-shell-region" data-am-region="sidebar"></div>
  <div class="col-xs-9 am-shell-region" data-am-region="main" data-am-initial-component="customer.detail"></div>
</div>
Attribute Purpose
data-am-region="<regionId>" What the engine discovers ([data-am-region]). Must match a key in the regions map.
data-am-initial-component Authoring aid only. The engine never reads it; it mounts the Controller's initialComponentKey from the resolved definition.

In the page editor, the Shell widget group provides three widgets. Row and Column widgets no longer exist; use Layout instead.

Widget What it does
Shell/Controller/Shell Controller Writes the page definition into the Controller script. Fields: Page ID (default shell-page), Version (default 1), Region ID, Initial component key, Initial props, Policy, Columns, Width, Height, additional CSS classes, inline style, Widget Style, and the Lookups, Initial state, Regions and Registry scripts. When Region ID is set, it also draws that one region host on the page, so a one-region page is a single Shell Controller widget; Initial component key is then required. Page save updates the view; Inject updates the Controller script, so click Inject after changing the region. A Regions script entry that repeats the widget's Region ID replaces that region entry completely, including its initial props and policy.
Shell/Layout/Layout Grid container that holds Region widgets. Fields: Columns (default 12), Box type (squared, rounded, clean), Color, Text Color, additional CSS classes, inline style, Fixed size.
Shell/Layout/Region An extra region host. Fields: Region ID, Initial component key, Columns, Width, Height, additional CSS classes, inline style, Widget Style. Declare the matching region in the Shell Controller Regions script. Do not reuse the id the Shell Controller is already drawing: the engine ignores a duplicate data-am-region host.

How region host styling works (Shell Controller region and Region widget):

  • The widget's style block is a sibling of the region element, scoped under .am-shell-page.
  • The component mounts in an open shadow root on the host element.
  • Border, padding, margin, background, width and height apply to the host.
  • Font and color settings apply to the host and reach the component only through inheritance, so the mounted component may not pick them up.

Note

Pages published before Row and Column were removed keep their saved HTML until the next editor save. On that save, a missing Row or Column becomes an HTML comment. Those widgets were not containers, so a Region placed beside them is kept.

Pull discovery

Because Smart View tiles render asynchronously, the engine discovers regions by pull, not push. The tile passes root: document, so discovery is document-wide (region markup may come from other tiles):

  • On start it runs querySelectorAll('[data-am-region]') to catch every region already in the DOM.
  • A MutationObserver on the document body catches hosts that arrive later (slow tiles) and detects removals. When a host is removed (tile teardown), the region's component is unmounted and its dirty flag cleared. If the host appears again, the initial component is mounted again.
  • Mounting is gated on the security context being ready (after the optional userContextEndpoint call). Region-change requests issued before then return status: "deferred", errorCode: "security-not-ready".
  • A change request for a region whose host is not in the DOM yet returns status: "deferred", errorCode: "region-element-missing".

Region ids are a flat global namespace and must be unique. A duplicate data-am-region host is logged (Region ID collision detected; ignoring duplicate region) and ignored. A DOM host whose id is not in regions is never mounted.

Generation enforces this asymmetric contract:

  • every specification region must be defined by the Controller;
  • the view may host none, some, or all Controller regions;
  • every region hosted by the view must be Controller-defined;
  • every Controller region's initialComponentKey must resolve in registry;
  • a Controller region absent from the specification is reported as a diagnostic only.

Region policy

A policy constrains what may happen to a region. Every field is optional; omit policy entirely for a fully open region. The engine enforces the policy on every region change, including the initial mount (see Runtime region changes). A violation returns a rejected result (accepted: false, status: "rejected") with a specific code and errorCode. Changes to the same region run one at a time, in request order.

Field Type Default Behavior Rejection (code / errorCode)
mutable boolean true When false, the region cannot be changed to a different component once one is mounted. The initial mount still happens, a same-key props update is still allowed, and clear is still allowed. policy-rejected / region-immutable
allowSelfReplacement boolean true When false, any change whose source region is the target region is rejected. This includes the initial mount, whose source is the region itself, and a component's requestSelfChange and clear on its own region. policy-rejected / self-replacement-disabled
allowedComponents string[] (any) Allow-list of componentKeys for this region. Empty or omitted means any registered key. Applies to the initial component too. policy-rejected / component-not-allowed
allowedSourceRegions string[] (any) Which other regions may request a change to this one. Self-changes (source equals target) are governed by allowSelfReplacement, not this list. Requests without a source region are not restricted. policy-rejected / source-region-not-allowed
requireDirtyCheck boolean true When the region is dirty and its component is about to be replaced by a different key or cleared, the user is asked to confirm first (see Dirty state and unsaved-changes protection). Set false to replace silently. A request can also skip the check with options.checkDirtyState: false. Same-key updates are never gated. denied / dirty-cancelled, status: "cancelled"
requiredPermissionToChange string (none) The user must hold this permission (in the current user context permissions) to change the region, including the initial mount. options.requirePermission: false does not bypass it: the engine checks it a second time and rejects the request. denied / missing-region-permission, or denied / security-policy-denied when requirePermission is false
maxChangesPerInterval { count, intervalMs } (off) Loop protection. Accepted changes (mounted, updated, cleared) are counted per region. When count changes already happened in the last intervalMs, further changes are rejected, REGION_CHANGE_LOOP_DETECTED is logged, and a region:loop-detected event is published with targetRegionId, componentKey, count, intervalMs, correlationId. Both values must be greater than 0. policy-rejected / REGION_CHANGE_LOOP_DETECTED

The engine checks the policy in this order: mutable, allowSelfReplacement, allowedComponents, allowedSourceRegions, requiredPermissionToChange, maxChangesPerInterval. The dirty check runs last, just before the replacement.

Example covering every option:

main: [
    initialComponentKey: "customer.detail",
    policy: [
        mutable                   : true,
        allowSelfReplacement      : true,
        allowedComponents         : [ "customer.detail", "customer.orders", "task.detail" ],
        allowedSourceRegions      : [ "sidebar", "main" ],
        requireDirtyCheck         : true,
        requiredPermissionToChange: "customer.edit",
        maxChangesPerInterval     : [ count: 10, intervalMs: 5000 ]
    ]
]

Common recipes

  • Fixed chrome (a sidebar or header that never changes): policy: [ mutable: false ].
  • Detail pane driven only by a list: policy: [ allowedSourceRegions: [ "sidebar" ] ].
  • Edit-gated: policy: [ requiredPermissionToChange: "customer.edit" ] plus a seeded user context.
  • Loop-safe auto-refreshers: add maxChangesPerInterval.

Warning

allowSelfReplacement: false also blocks the initial mount. Use it only on a region without initialComponentKey, which stays empty until another region fills it. Likewise, with requiredPermissionToChange a user who lacks the permission sees the region empty, because the initial mount is rejected too.

The registry

The registry maps each componentKey to an entry the resolver turns into a full ComponentDefinition. There are three ways to author an entry, all resolved server-side by amsuishell.resolvePageDefinition.

By component node id (the normal case)

Point the entry at a node id. The resolver reads the id from the first present of these keys: componentNodeId, componentId, nodeId, id, dataId.

registry: [
    "customer.sidebar": [ componentNodeId: docman.getNodeByPath(docman.getContentScriptVolume(), "CSComponents:customer-sidebar").ID ],
    "customer.detail" : [ componentNodeId: detailNodeId, requiredPermissions: [ "customer.view" ] ]
]

The resolver loads the node, checks the user's See Contents permission (ACL filter), and branches on the node:

  • A Shell Component — a Smart Page (43300) flagged pageRenderer = "shell-component" — is resolved from the component.json of the selected version (see Use case A).
  • An AI View (subtype 43100) is resolved through the ai-form adapter (see Use case B).
  • Any other node is dropped from the registry.

By explicit bundleUrl (no node)

For local or minimal tests you can skip the node entirely and pin the URL (see Use case C). An entry without a node id is passed through unchanged: no ACL filter, no component.json values, no defaults. Set at least bundleUrl, and exportName and capabilities when the component needs them — unset capabilities are denied. If the entry also has a node id, the server-derived bundleUrl replaces yours.

Version pinning

By default the node's latest version (the highest version number) is used.

  • Shell Component: pin a version with versionId (or its alias componentVersion). The resolver walks the versions from newest to oldest and picks the first whose version number equals the value, or whose component.json versionId matches it (case-insensitive). If no version matches, the entry is dropped.
  • AI View: pin with versionId, componentVersion or aiViewVersion. The value must be a version number; anything else selects the node's current version. The resolved entry reports the version in aiViewVersion.
"customer.detail": [ componentNodeId: detailNodeId, versionId: "3" ]

What resolution produces

For a node-backed Shell Component, artifact URLs are always server-derived. Other metadata takes the first non-empty value from the registry entry, then the stored component.json, then the default. Values are not merged: an entry capabilities map replaces the one in component.json.

Resolved field Source
bundleUrl <CGI>/am/sc/<nodeId>/<versionNum>/bundle.js
stylesUrl <CGI>/am/sc/<nodeId>/<versionNum>/styles.css when the stored version has a root styles.css; otherwise removed
exportName entry → component.json → the componentKey
inputSchema entry → component.json → {}
capabilities entry → component.json → {} (each unset capability is denied)
requiredPermissions entry → component.json → []
sharedState entry → component.json → {}
events entry → component.json → { publishes: [], subscribes: [] }
dataSources entry → component.json → []
designProfile component.json only; a value set in the entry is removed, so a page cannot restyle a reviewed component

<CGI> is the Content Server CGI URL of the current request (for example /otcs/cs). When the bundle module has no export named exportName, the adapter uses its default export. AI View entries follow their own rules (see Use case B).

ACL filter and caching

  • ACL filter. Entries whose node the current user cannot load or cannot See Contents are silently dropped. If the dropped key was a region's initialComponentKey, the initial mount is rejected (component-not-found) and that region stays empty.
  • Pass-through. An entry with no node id reaches the engine unchanged. If it has no bundleUrl, the mount fails with Component "<key>" has no bundleUrl. The page-definition registry entry was not resolved … and the region shows Component unavailable (see Troubleshooting). Always give every entry a node id or an explicit bundleUrl.
  • Per-user cache. The resolved definition is cached without the CGI prefix, under the key shelldef:v3:<userId>:<pageNodeId>:<pageVersionNum>:<fingerprint>. The fingerprint is a SHA-256 over the raw definition and the current version number of every referenced node. The CGI prefix of the current request is applied when the definition is returned. A new page version, any change in the Controller's output, or a new version of a referenced component or AI View invalidates the cached resolution automatically.

Note

Permission changes on a component node are not part of the cache key. A user's cached resolution changes only when one of the values above changes.

Use case A: native Vue Shell Components

A native Shell Component is a Vue single-file component (SFC) compiled to a JavaScript bundle. On Content Server it is a Smart Page node (subtype 43300) marked as a Shell Component. Each content version of the node is one build of the component, and the server resolves the component from the component.json in that version.

Producing one with the Component AI Builder

  1. Open the Component AI Builder and generate a Shell Component. Review, edit and authorize its key, input schema, capabilities and required permissions. The AI suggests capabilities, you authorize them, and the Shell enforces the authorized set.
  2. Store it. Each store adds one version to the Component node. The version file (extension .amver) is a ZIP that must contain bundle.js and component.json. It can also contain styles.css, and it carries the source and builder files (for example ShellView.vue, locales.json and the builder conversation). You can also upload an exported .amver as a new version; the upload is refused when its Shell runtime version is not installed on the server.
  3. The stored component.json carries the authorized metadata: key, inputSchema, capabilities, requiredPermissions, sharedState, events, dataSources, the pinned designProfile, the delivery platform targetHost (content-server or script-console), a versionId, and authorizationStatus (authorized unless the builder sent another value). bundleUrl and stylesUrl are removed when a version is stored.

At run time the server resolves a node-backed component to versioned URLs:

Host Bundle URL Styles URL
Content Server <CGI>/am/sc/<nodeId>/<versionNum>/bundle.js <CGI>/am/sc/<nodeId>/<versionNum>/styles.css, only when the version has styles.css
Module Suite Runtime /static/amshell/components/<componentKey>/<hash>/bundle.js (content-addressed) same folder, styles.css, only when the version has one

On Content Server, the request handler streams the files from that front-end's render cache. On a cache miss it checks the component node and version, then rebuilds the cache from the stored version.

Restoring a previous version in the builder adds a new version with the old content, so version numbers are never reused.

Tip

Keep Component nodes in a CSComponents folder of the Content Script Volume. The Shell Controller snippet looks them up there by path, for example docman.getNodeByPath(docman.getContentScriptVolume(), "CSComponents:customer-detail").ID.

The component.json contract

Whatever produces the component, its component.json must look like this. The server resolver reads these fields, and the engine enforces capabilities and requiredPermissions:

{
  "key": "customer.detail",
  "exportName": "ShellView",
  "runtimeVersion": "v1",
  "designProfile": "jato",
  "inputSchema": { "type": "object", "properties": { "customerId": { "type": "string" } } },
  "capabilities": {
    "region": true, "shellState": true, "security": false, "dirtyState": true,
    "restClient": true, "navigation": true, "events": true,
    "notifications": true, "dialogs": true, "store": false, "logger": true,
    "hostWidget": false
  },
  "requiredPermissions": [],
  "authorizationStatus": "authorized"
}
Field Meaning
key The component's logical key. By convention it matches the registry key.
exportName The named export of the bundle to mount. The resolver takes the registry entry's exportName, then component.json, then the registry key. When the bundle has no export of that name, the Vue adapter mounts the default export. Builder bundles export ShellView and a default export.
runtimeVersion The @amshell/runtime version the bundle was built against. v1 is the only installed version.
designProfile The Design Profile pinned when the component was generated or compiled (jato, studio or portal). See the Design Profile note below.
bundleUrl / stylesUrl Not stored for node-backed components. The resolver derives the versioned URLs shown above.
inputSchema JSON schema for the input props. The engine validates the props of every mount and region change against it and rejects a change whose props do not match (see Props and input schema).
capabilities The authorized capability set (see Capabilities: the full reference). A capability that is not set is denied.
requiredPermissions Permissions the user must all hold (from the Shell user context permissions) to mount the component.
sharedState, events, dataSources The designer-authored contracts. They are copied into the registry entry. events is documentation only: it does not subscribe or publish anything.
authorizationStatus Set to authorized when the builder stores authorized metadata. The engine does not read it.

Warning

On Content Server, the Shell Controller's registry entry wins over component.json for exportName, inputSchema, capabilities, requiredPermissions, sharedState, events and dataSources. A Controller that sets capabilities on an entry replaces the authorized set. Only set these fields in a Controller when you mean to override the component. On the Module Suite Runtime, entries are built from the stored version only.

Design Profiles

A Design Profile is the visual language of a Shell Component or an AI form. The shipped profiles are jato (the Smart View look; follows Content Server, light and dark), portal (follows the Module Suite Runtime portal Appearance and, on Content Server, Smart View; light and dark) and studio (a neutral look; light only). The profile id is pinned in the stored artifact when it is generated or compiled. The server copies the pinned id into the registry entry and removes any designProfile that a page puts in its own entry, so a page cannot restyle a reviewed artifact. The runtime loads the profile when the component opens: an empty id gives jato, and an unknown id gives jato with a console warning. The administrator's default profile per host only applies at generation, to artifacts that name no profile.

The compiled bundle.js exports the component (ShellView and a default export) and createShellComponentApp, which mounts it in a shadow root. Builder bundles also export bundledLocales and bundledDefaultLocale, compiled from locales.json. The bundle imports vue and @amshell/runtime from the shared support files; never bundle Vue into a component. A bundle without createShellComponentApp is mounted in the light DOM, with its stylesUrl added to the document head.

Writing the component

Components are Vue SFCs that use host services through @amshell/runtime composables (see The component runtime context). A component only gets the services of the capabilities it was authorized for. Calling the composable of a capability that is not granted throws.

For complete example components, covering initialProps, events, shared state, region navigation and dirty-state protection, each with its component.json, see Worked example components.

Embedding Smart View widgets and pickers

A native component can reuse Smart View UI through @amshell/runtime (capability hostWidget):

  • the widgets CsuiNodeTable, CsuiDocumentOverview, CsuiMetadata, CsuiRelatedWorkspaces, CsuiWorkspace and CsuiWorkspaceHeader;
  • the pickers useCsuiNodePicker() and useCsuiMemberPicker().

Widgets keep their normal Smart View actions, and the component receives Vue events. Pickers resolve plain JSON, or null when the user cancels.

Host Widgets and pickers
Shell Tile on Content Server Available. Widgets use the live perspective context.
Module Suite Runtime Portal Shell Page Available when the page has a Content Server connection with a support path. Smart View is loaded from that Content Server with the user's token. Widgets that rely on a perspective node need it as an option (containerId for CsuiNodeTable, workspaceNodeId for CsuiRelatedWorkspaces and CsuiWorkspaceHeader).
Preview Not available. The widget shows "Smart View widgets require Content Server Smart View", and the pickers reject with that message.

Props and events are listed under Host Widget Wrappers.

Use case B: AI View forms as regions

A region does not have to mount a hand-built component. An existing AI View can be mounted directly through the ai-form adapter. You do not author any ai* fields: the registry entry names the AI View, and the server fills the rest. The form runs on the V16 form runtime (@aifb/runtime), in the runtime version the view was generated for.

On Content Server

The registry entry points at the AI View node (a Beautiful WebForms view, subtype 43100). The resolver detects the subtype and builds an ai-form entry. It fills adapter: "ai-form", exportName: "FormView", aiBundleURL, aiStylesURL, aiRuntimeBase, aiSubmitURL, aiLoadURL, aiDefaultAction, aiViewID, aiViewVersion, aiLibVersion and aiSubmitMetadata, and copies the view's pinned designProfile.

The bundle and styles are served by the AI View file route, <CGI>/amwf/aiv/<viewId>/<versionNum>/bundle.js and .../styles.css. Any front-end can serve them: when the files are not on disk, the route checks, as the requesting user, that the node is an AI View and that the version exists, then rebuilds the files from the stored version. A version never changes, so the files are cached as immutable. The route is used unless the entry sets aiBundleURL, bundleUrl or aiStylesURL itself.

regions: [
    form: [
        initialComponentKey: "intake.form",
        initialProps       : [ "af-form-data": [ customer_id: params.objId ] ],
        policy             : [ mutable: false ]
    ]
],
registry: [
    // AI View node (subtype 43100): resolved to an ai-form entry, ai* fields filled by the server.
    "intake.form": [ componentNodeId: docman.getNodeByPath(docman.getContentScriptVolume(), "CSForms:Customer Intake").ID ]
]

The entry is dropped when the user lacks See Contents permission on the AI View node, or when the view version cannot be resolved.

Pin a form version with versionId (or componentVersion, or aiViewVersion) on the registry entry; otherwise the node's current version is used.

On Content Server the entry keeps every other field you put in the registry entry, so registry defaults such as aiReturnComponentKey, aiNotifyOnSubmit or aiSubmitStateKey work there. An aiLoadURL override is only accepted as a runcs/ path under the Content Server CGI prefix.

On Module Suite Runtime (Portal Shell Pages)

On the Module Suite Runtime the form is an AI View of a remote form, addressed as {formId}:{viewId} (the id the Forms administration shows for each AI View). The page Controller's registry entry names it with aiView, optionally pinning a version number with version, under any key that does not start with portal. (a portal. key with aiView stops the page from rendering):

model.data.shellDefinition = [
    regions : [
        form: [
            initialComponentKey: "intake",
            initialProps       : [ "af-form-data": [ customer_id: params.customerId ] ]
        ]
    ],
    registry: [
        "intake": [ aiView: "3f2c9b7e-1d4a-4c55-9a0e-6b1f2e8d4c10:1", version: 3 ]  // version is optional; must be a number
    ]
]

The portal resolves the entry through the forms module. With {form} standing for the form's URL, {ctx}/forms/{folder}/{formId} (no folder segment for a form in the root folder):

Field Value
aiSubmitURL {form}
aiLoadURL {form}/aifb/runtime-state. It runs the view's onLoad script, keeps the form state server-side and returns a token for it.
aiFileUploadURL {form}/uploads?scope=..., so file fields work in a portal region
aiDefaultAction am_submit_reload (validate, store a submission, stay on the form)
aiFormId the form id
aiBundleURL, aiStylesURL the published files of the view version under /static/
aiRuntimeBase {ctx}/modules/forms/aifb-runtime/..., for the view's runtime version
capabilities the fixed form defaults (see Default capabilities granted to a form)

Without version, the view's active version is used. The entry is built only from the AI View: other fields in the page's registry entry are ignored, so set return targets and other options through initialProps. The entry is never cached, because access depends on the form and the session.

The entry is dropped, together with the initialComponentKey and initialProps of any region that names it, when:

  • the form is not ACTIVE, has expired, or is PRIVATE and the user is neither assigned to it nor a form manager;
  • the form is PIN-protected and the PIN has not been verified in the current session. A region never prompts for a PIN; publish PIN forms as standalone pages;
  • the aiView id is malformed, the AI View or the pinned version does not exist, the version has never been published, or the forms module is not installed.

Options you can set through initialProps

The adapter merges these. Mount props win over registry values, which win over the values bundled with the form.

Prop Purpose Default
af-form-data (or initialData) Seed field data: a flat map of field names to values, applied server-side before the form's OnLoad script runs (see Behavior). On Content Server nested objects set nothing; on the Module Suite Runtime a seed that does not fit the form is ignored. The browser posts it, so it is no more secret than a standalone form's query string. none
params An object handed to the OnLoad script as params. On Content Server, formNodeId (or nodeID) names the form node the submission is stored against, and nextUrl sets the next URL. On the Module Suite Runtime, its string values are also set as view parameters of the form. {}
aiLocales / aiDefaultLocale (or af-i18n: { locales, defaultLocale }) Locale catalog overrides. The form renders the catalog locale that best matches the user's language. the form's bundled locales; default locale en
aiSubmitURL Where the form posts. Content Server: <CGI>?func=ambwf.aifbsubmit. Module Suite Runtime: the form URL.
aiDefaultAction Action of a completion submit. The server decides the action in the mount-time load, after OnLoad ran, and that value wins; this prop is only used when the entry has no load URL. am_submit_reload
aiSubmitMetadata (or submitMetadata, submitTransportMetadata) Extra submit transport metadata. The props ll_func_values (or llFuncValues), nextUrl, viewId, viewVersion, templateId, templateVersion and libVersion are merged into it. A com.ansermodules.FormState (or formState) supplied here is ignored: the state built by the mount-time load always wins. set by the server
aiFileUploadURL File-upload endpoint. Content Server: none. Module Suite Runtime: the form's upload URL.
aiFormId Form instance id, used to address the form with chrome events. Content Server: aiform:<regionId>. Module Suite Runtime: the form id.
aiBundleURL / aiStylesURL / aiRuntimeBase Override the resolved URLs. resolved by the server
rules / dataSources Override the bundled conditional-logic rules and remote data sources. the form's bundled values
aiNotifyOnSubmit Show a Shell notification on submit success or error. Needs the notifications capability. false
aiSubmitStateKey Shared-state key the last submit outcome is written to. Needs store. aiform:<regionId>:lastSubmit
returnComponentKey / aiReturnComponentKey Component key to mount in this region after a completion submit or a discard. The registry field is aiReturnComponentKey (Content Server only). Unprefixed props win, then ai* props, then the registry. none
returnProps / aiReturnProps Props for that return mount. They must satisfy the target component's inputSchema. none

designProfile is not a prop. The form uses the Design Profile pinned on the AI View when its artifact was stored; the adapter does not read it from props, and the server removes a designProfile that a page puts in its registry entry. An empty or unknown id shows jato.

Tip

On Content Server a region mounts a view, not a form node. Without formNodeId in params the submit still reloads the form and runs its scripts, but has no storage target.

Behavior

flowchart TD
    resolved["Server resolves the ai-form entry"] --> load["Adapter posts the seed to aiLoadURL"]
    load --> onload["Server builds the form, applies the seed, runs OnLoad"]
    onload -->|"redirect"| nav["Navigation service navigates; nothing mounts"]
    onload -->|"error, file or JSON result, or view switch"| failed["Region shows the Component unavailable fallback"]
    onload -->|"ok"| download["Adapter loads the form runtime and the bundle"]
    download --> mounted["Form mounts in an open shadow root"]
  • Before anything is downloaded, the adapter asks the server to build the form. It posts op=aifbPrepareRuntimeState to aiLoadURL with the view id and version, the af-form-data seed and the params prop. The server builds the form from the view's form template, applies the seed, runs the view's OnLoad script (on Content Server an OnLoad child node or the view's inline OnLoad script), and returns the field values plus the serialized form state that the submit requires. This is what makes a region form initialize and submit like a standalone one.
  • The call happens for every AI View region, not only those with an OnLoad script, because the serialized state is needed to submit either way.
  • On Content Server the server refuses the call when the user cannot read the view.
  • It is mount-only. Changing props on an unchanged component key does not run OnLoad again; mount a different component key, or request the change with options.forceRemount: true, when the seed must be applied again.
  • If the script redirects, the adapter navigates and mounts nothing.
  • If the script fails, or returns a file or a JSON stream instead of initializing the form, the form does not mount and the region shows the "Component unavailable" fallback. The script's message is in the error field of the component:error event and in the Shell log.
  • A script that switches to another view during load is refused: the standalone page renders the new view, which a region cannot do. Mount that view directly instead.
  • Known difference on Content Server: the runAs and csvars settings of the standalone page are not applied, so the script runs as the calling user.
  • The adapter loads @aifb/runtime and createAfApp, adds an import map for the form's bare module names (vue, @aifb/runtime), and mounts the form in an open shadow root (isolate: true), so it renders like the standalone form page. The runtime adopts its own styles, the Design Profile and the view's styles into that shadow root.
  • It registers the form as the region's dirty-state source: typing in the form marks the region dirty, a successful submit clears it, and a failed submit marks it again.
  • It routes the submit outcome: redirect goes to the Navigation Service (navigation.navigate), and reload is handled in the region.
  • It forwards the submit outcome to the Shell. Every channel needs its capability; channels without it are skipped:
  • Event bus (events): publishes form:submit with { status, regionId, componentKey, formId, outcome } on success or { status, regionId, componentKey, formId, errors } on error.
  • Shared state (store): writes the same record to aiSubmitStateKey.
  • Notification (notifications, opt-in with aiNotifyOnSubmit): a success or error toast.

events and store are not granted to a form by default (Default capabilities granted to a form); grant them to receive those channels. - It accepts action requests on the form:action and form:return topics, so page chrome outside the region can drive the form (see Letting the user save or discard from page chrome). This needs events. - When a return key is set (returnComponentKey), a completion submit (the form's own submit button, using the default action) changes this region back to that key after success. These do not return automatically: am_save and other named actions run with triggerAction (for example lookup or calculate), any action sent with form:action, and a submit whose redirect was followed. A failed submit stays on the form. Return and discard skip the dirty-state confirmation (checkDirtyState: false). When the region change is rejected, the adapter logs it (with logger) and shows "Unable to leave this form." (with notifications). - The adapter makes those region changes through a host Region API, even when the form's region capability is false. The form view never receives the ComponentContext or the page event bus. It receives a command facade instead (see Same-region list, form, list), which accepts only form:return and form:requestChange for this mount. - In-form discard (am_discard or cancel) does not post: the form asks the adapter to return. Without a return key, nothing happens. - A props update on the same key reads returnComponentKey and returnProps again, and applies new locales, rules and dataSources. Other options are read at mount only. - Picker fields in the form (node and member pickers) open the Smart View pickers through the region's hostWidget service. - The form bundle exports FormView (not ShellView).

Default capabilities granted to a form

The server gives an AI View entry these capabilities:

Capability Default for a form
dirtyState true (always on)
navigation true (always on)
notifications true
logger true
hostWidget true (picker fields; it also allows embedding Smart View widgets)
region, shellState, security, restClient, events, dialogs, store false

On Content Server you can change the defaults with capabilities on the registry entry, for example to grant events or store, or to turn hostWidget off. dirtyState and navigation stay on whatever you set. On the Module Suite Runtime the set is fixed.

Return and form:requestChange are executed by the adapter through a host Region API, so a form does not need region. Chrome topics need events on the form so the adapter can subscribe. The command facade the form view receives is not useEvents().

Letting the user save or discard from page chrome

A standalone AI View has "Save and exit" in its own chrome; a region mounts the form without it. Grant the form events (Content Server) and publish from another component:

events.publish('form:action', { action: 'am_save', formId: 'requestForm' })
events.publish('form:action', { action: 'am_discard', regionId: 'main' })
events.publish('form:return', { regionId: 'main' })

A request can address a form by formId, regionId or componentKey; every field you set must match. Always name a target for form:return: an untargeted form:return is ignored, so two forms on a page are not both unmounted. An untargeted form:action reaches every form on the page that has events.

am_save persists a partial form without required-field and PreSubmit enforcement and does not return to returnComponentKey. Type, format and range checks and Content Server validation still apply. When the user resumes, the form loads its data through the mount-time load (see Behavior).

am_discard and cancel do not post. The adapter treats them as a return. A redirect from an action sent with form:action does not navigate unless the request sets followRedirect: true.

In-form Cancel, from a hand-authored form view: inject('af-trigger-action')('am_discard').

The form's ordinary submit stays strict. It is the completion submit that can return automatically.

Same-region list, form, list

A list component (main.list) opens a form in its own region. Pass the return key as data, because the list unmounts:

await region.requestSelfChange('record.form', {
  returnComponentKey: 'main.list',
  returnProps: { /* keys valid for main.list inputSchema */ }
})

The region's policy must allow it: not mutable: false, not allowSelfReplacement: false, and if allowedComponents is set it must include both the list and the form keys. The sample above with policy: [ mutable: false ] cannot host this flow.

A hand-authored form view can change another region through the command facade (not needed for the automatic return). Get it with useShellEvents() from @aifb/runtime, or inject AF_SHELL_EVENTS_KEY:

import { inject } from 'vue'
import { AF_SHELL_EVENTS_KEY } from '@aifb/runtime'

inject(AF_SHELL_EVENTS_KEY)?.publish('form:requestChange', {
  targetRegionId: 'aside',
  componentKey: 'record.preview',
  props: { id }
})

form:requestChange takes targetRegionId, componentKey (or null with mode: 'clear'), props, and optionally reason and correlationId. publish('form:return') returns this region to its return key. Any other topic is refused with a console warning.

Region policies still apply. The events metadata (publishes and subscribes) of a component definition is documentation only; it does not wire these topics.

Use case C: an explicit bundle URL

For a quick local test on Content Server, skip the component node and set the URL directly. A registry entry without a node id is passed to the engine unchanged:

registry: [
    "customer.detail": [
        bundleUrl     : "/custom-shell-components/customer-detail.js",
        exportName    : "ShellView",
        runtimeVersion: "v1",
        capabilities  : [ region: true, restClient: true, logger: true ],
        inputSchema   : [ type: "object", properties: [ customerId: [ type: "string" ] ] ]
    ]
]

Because there is no node, there is no ACL filter and no component.json to read. Supply capabilities, inputSchema and requiredPermissions inline if you want them; capabilities you leave out are denied. Use this for prototyping. Production components should be node-backed so the registry stays ACL-governed.

Warning

The resolver treats componentNodeId, componentId, nodeId, id and dataId as a node id. Do not use any of these keys in an explicit-URL entry.

Portal Shell Pages on the Module Suite Runtime do not accept explicit bundle URLs: their registry entries name stored components.

You can also choose the adapter explicitly with adapter: "vue" or adapter: "ai-form". Otherwise the engine uses ai-form when the entry has aiBundleURL, aiRuntimeBase or aiSubmitURL, and vue in every other case.

Use case D: custom adapters

The engine ships two adapters (vue and ai-form) and selects one per component. A host can register more adapters (React, Lit, Web Components, …) without changing the engine, either by passing them to createShell or by calling registerAdapter:

const shell = createShell({
  definition,
  root,
  host,
  adapters: { 'web-component': myWebComponentAdapter }
})
// or, later:
shell.registerAdapter('react', myReactAdapter)

A registry entry then selects it with adapter: "web-component". An entry that names an adapter that is not registered is mounted with the vue adapter. An adapter implements one mount(input) that returns a handle with updateProps, setContext and unmount:

interface ShellAdapter {
  mount(input: {
    element: HTMLElement
    component: ComponentDefinition
    bundleUrl: string
    stylesUrl?: string
    exportName?: string
    props?: Record<string, unknown>
    context: ComponentContext
    // A Region API for this region that works even when the component's `region` capability is false.
    hostRegion?: ScopedRegionApi
    // The user's UI language, available even when `security` is denied.
    userLocale?: string
  }): Promise<{
    updateProps(nextProps: Record<string, unknown>): void
    setContext(nextContext: ComponentContext): void
    unmount(): void
  }>
}

When mount rejects, the region shows the "Component unavailable" fallback and the engine publishes component:error.

The Content Server Shell Tile (tile_content_shell) and the Module Suite Runtime Shell Host only use the built-in adapters, so custom adapters are a host-integration extension point, not something a page Controller configures.

Capabilities: the full reference

Capabilities are the Shell services a component may use. They are designer-authorized (in component.json) and Shell-enforced: only granted services are injected, and the others are denied stubs that throw when called. Every capability is false when it is not set.

Capability Grants Composable
region The scoped Region API: requestChange, requestSelfChange, clear, getCurrentComponent. useRegion()
shellState Read and subscribe to the Shell state snapshot (ready, pageId, definitionVersion, metadata, user). useShellState()
security User and permission checks: getCurrentUser, hasRole, can, canAny, canAll, hasPermission, hasAnyPermission, hasAllPermissions, canMountComponent, canChangeRegion. useSecurity()
dirtyState Register the component as a dirty source and run the replace confirmation. useDirtyState()
restClient The Shell REST client (request, get, post, json, upload, url), authenticated by the host. useRestClient()
navigation Host-delegated navigation: navigate, reload, openExternal, and the host page helpers pageUrl, toPage, currentPageKey (page keys exist on Module Suite Runtime portal pages). useNavigation()
events The Shell event bus (publish and subscribe), including the bridge to the classic ampagenotify channel. useEvents()
notifications Toast notifications (info, success, warn, error). On Content Server they use Smart View's global messages; the portal has its own. useNotifications()
dialogs alert, confirm and prompt. On Content Server they use Smart View's modal alerts; the portal has its own. useDialogs()
store The cross-region shared store. useSharedState(key)
logger Structured logging. useLogger()
hostWidget Embed Smart View widgets and open the Smart View pickers (Content Server Shell Tile and Module Suite Runtime portal pages with a Content Server connection). <CsuiNodeTable>, <CsuiDocumentOverview>, <CsuiMetadata>, <CsuiRelatedWorkspaces>, <CsuiWorkspace>, <CsuiWorkspaceHeader>, useCsuiNodePicker(), useCsuiMemberPicker()

The host Appearance (branding) and the file previewers are available to every component and need no capability.

Grant the minimum a component needs. A composable called for a capability that is not granted throws [amshell/runtime] Capability "<name>" is not granted for "<key>". at the call, so a missing grant shows up as an error in the browser console, never as a silent no-op. A denied service method called directly throws [amshell] Capability "<name>" is denied.

The component runtime context

When the Shell mounts a component in a region, it injects a capability-scoped ComponentContext and the component's resolved props. Read them through the @amshell/runtime composables; never reach for window globals.

interface ComponentContext {
  regionId: string
  componentKey: string
  inputSchema?: Record<string, unknown>
  capabilities: ComponentCapabilities
  region: ScopedRegionApi
  shellState: ShellStateService
  security: SecurityService
  dirtyState: DirtyStateService
  restClient: RestClientService
  navigation: NavigationService
  events: EventBusService
  notifications: NotificationService
  dialogs: DialogService
  store: SharedStateService
  logger: LoggerService
  hostWidget: HostWidgetService
  branding?: BrandingService     // read-only host Appearance; no capability needed
  previewers?: PreviewerService  // file previewers of the host; used by FilePreview
}

A service whose capability is not granted is replaced by a stub. The matching composable throws [amshell/runtime] Capability "<name>" is not granted for "<componentKey>". as soon as it is called.

Composables

All composables are imported from @amshell/runtime.

Composable Returns Requires capability
useInputProps() Readonly Ref of the resolved props (schema defaults applied, values coerced). —
useShellContext() Readonly Ref of the whole ComponentContext (for example useShellContext().value.regionId). —
useRegion() ScopedRegionApi (see below). region
useShellState() getSnapshot(), subscribe(listener) → unsubscribe. shellState
useSecurity() can(permission), canAny(permissions), canAll(permissions), hasRole(role), getCurrentUser(), canMountComponent(componentKey), canChangeRegion(request), and the aliases hasPermission, hasAnyPermission, hasAllPermissions. security
useDirtyState() register(registration) → unregister, registerSource(sourceId) → unregister, setDirty(sourceId, dirty), isDirty(regionId?), hasDirty(sourceId?), listDirtySources(regionId?), confirmReplaceRegion(regionId, message?), confirmReplace(sourceId, message?). dirtyState
useRestClient() request(req), get(url, query?), post(url, body?), json(urlOrRequest), upload(url, formData, options?) → { promise, abort }, url(url). restClient
useNavigation() navigate(url), reload(), openExternal(url, target?), pageUrl(pageKey, query?), toPage(pageKey, query?), currentPageKey(). navigation
useEvents() publish(topic, payload?), subscribe(topic, listener) → unsubscribe. events
useNotifications() info / success / warn / error(message, { title?, detail?, timeoutMs? }?). notifications
useDialogs() alert(message, title?), confirm(message, title?) → Promise<boolean>, prompt(message, defaultValue?, title?) → Promise<string \| null>. dialogs
useLogger() debug / info / warn / error(message, details?). logger
useSharedState(key) A writable Ref bound to the shared-state key. store
useI18n() { t, has, formatDate, formatNumber, locale } (see Localization with useI18n). —
useTheme() { theme, set(theme), toggle() }: the page light/dark theme. —
useBranding() { branding, logoUrl(fallback?), colors }: the administrator-defined Appearance, or null on hosts without one (Content Server, preview). —
useBreakpoint() { isMobile, isTablet, isDesktop } (below 640px, 640px to 1023px, 1024px and wider). —
useShellPortalTarget() The in-shadow teleport target for overlays. The UI kit uses it; components do not need it. —
useCsuiNodePicker() A function (request?) → Promise<[{ id, name, type }] \| null>. request is { title?, selectableTypes?, multiple?, containerId? }; null means cancelled. hostWidget
useCsuiMemberPicker() A function (request?) → Promise<[{ id, name, displayName, type, email }] \| null>. request is { title?, startLocation? }; type is 0 for a user, 1 for a group. hostWidget
useHostWidget() Internal to the Host Widget Wrappers and pickers. Use the wrappers instead. hostWidget
useAgentChat({ endpoint }) Chat state and actions for an AI agent, used with AgentMessageList, AgentComposer and AgentAttachmentTray. restClient

RestClientService details:

  • request, get and post resolve for every HTTP status and return RestResponse { status, headers, data }. Check res.status yourself.
  • json(url | request) returns the body of a 2xx response and throws a RestError (status, data, message) for any other status. Use it with try/catch when a failure must reach your error handling.
  • request takes { url, method?, headers?, query?, body?, credentials?, responseType? }; responseType is 'json' (default), 'text' or 'blob'. query is a flat record of string | number | boolean.
  • Address Content Server REST with the cs: prefix (cs:/api/v2/nodes/2000). The host resolves it to the current server and attaches the ticket. On the Module Suite Runtime portal, api:/... addresses the portal API. A plain path such as /api/v2/... is resolved against the page origin.
  • url(url) returns the absolute URL for an <a href> or <img src>, resolving cs: and api:.

useNavigation(): pageUrl and currentPageKey return null on hosts without page keys (Content Server), and toPage throws there.

Host Widget Wrappers

Host Widget Wrappers embed Smart View widgets in a component. They need the hostWidget capability. Changing a prop remounts the widget. Each widget fills its parent, so give the region an explicit height.

Wrapper Props Events
CsuiNodeTable containerId? (omit to use the perspective node; required on hosts without one), enableDragAndDrop? (default true) select → { nodes: [{ id, name, type }] }, error
CsuiDocumentOverview nodeId (required) error
CsuiMetadata nodeId (required) error
CsuiRelatedWorkspaces title?, workspaceTypeId?, relationType?, workspaceNodeId? (required on hosts without a business workspace perspective) error
CsuiWorkspace workspaceNodeId?, or the business object busObjectType?, busObjectId?, extSystemId?; viewMode? ('folderBrowse' or 'fullPage') error
CsuiWorkspaceHeader workspaceNodeId (required) error

There is no open event: folder, document and workspace clicks keep their normal Smart View action. Nothing is published on the Shell EventBus; republish a selection with useEvents() if other regions need it.

import { ref } from 'vue'
import { useRestClient, useRegion, useEvents, useLogger } from '@amshell/runtime'

const rest = useRestClient()
const region = useRegion()
const events = useEvents()
const logger = useLogger()
const users = ref([])

async function loadUsers() {
  const res = await rest.get('cs:/api/v2/members', { where_type: 'user' })  // RestResponse { status, headers, data }
  if (res.status !== 200) return logger.warn('members request failed', res.status)
  users.value = Array.isArray(res.data) ? res.data : []
}

function openDetail(id) {
  events.publish('user:selected', { id })
  return region.requestChange({ targetRegionId: 'detail', componentKey: 'user-detail', props: { id } })
}

Reading the shell state

To read the global shell state (the seeded initialState.user, metadata, and the ready flag) rather than the component's own props, use useShellState(). getSnapshot() returns the current snapshot; subscribe(listener) calls the listener with the current snapshot and then on every change.

import { computed, onScopeDispose, ref } from 'vue'
import { useShellState } from '@amshell/runtime'

const shell = useShellState()                 // requires capability "shellState"
const snapshot = ref(shell.getSnapshot())     // { ready, user, metadata?, pageId?, definitionVersion? }

const displayName = computed(() => snapshot.value.user.displayName)

const off = shell.subscribe((next) => { snapshot.value = next })
onScopeDispose(off)

The user context is final before the first component mounts: the Shell fetches the optional userContextEndpoint first (see Security and permissions). During the initial mount ready is false; it becomes true once every initial region has been mounted, and that is the change a subscriber sees.

snapshot.user is { id?, displayName?, permissions, roles, attributes, locale? }. permissions, roles and attributes are always present (empty when not seeded).

Localization with useI18n

Every Shell Component owns a Component Locale Catalog, locales.json, stored in its artifact next to ShellView.vue and compiled into bundle.js. It uses the same envelope as AI Views:

{ "defaultLocale": "en", "locales": { "en": { "users.title": "Users", "users.count": "{count} users" }, "it": { "users.title": "Utenti", "users.count": "{count} utenti" } } }

The component reads its strings with useI18n() (no capability required):

<script setup>
import { useI18n } from '@amshell/runtime'
const { t, has, formatDate, formatNumber, locale } = useI18n()
</script>

<template>
  <h2>{{ t('users.title') }}</h2>
  <p>{{ t('users.count', { count: 12 }) }}</p>
  <p>{{ formatDate(new Date(), 'long') }} · {{ formatNumber(0.25, 'percent') }}</p>
</template>
Member Behavior
t(key, params?) Looks up the Active Locale, then the default locale, then returns the key. {name} placeholders are replaced from params; unknown placeholders stay as written. There are no plural forms.
has(key) true when the active or default catalog has the key.
formatDate(value, format?) Formats a Date, ISO string or epoch milliseconds in the Active Locale. format is 'short' (default), 'long', 'monthDay' or 'time'. Invalid or empty input returns ''.
formatNumber(value, format?) Formats a number in the Active Locale. format is 'decimal' (default) or 'percent' (expects a ratio: 0.25 gives 25%). A non-number returns ''.
locale Readonly Ref with the Shell-resolved Active Locale. Components never change it.
  • The Shell picks the Active Locale once, at mount. It takes the user's language from, lowest priority first: the Smart View host (csui i18n), initialState.user.locale, a non-blank locale in the userContextEndpoint response. It then matches it against the catalog, ignoring case and treating _ as -: exact code (it-IT), base language (it), another locale with the same base language (en → en-US), and finally defaultLocale.
  • AI Form Regions use the same rule: the AI form switches to the matched locale on mount and on prop updates.
  • Author translations in the Component AI Builder Locales tab. A literal t('key') without default-language text fails compilation and blocks Deploy; catalog keys that ShellView.vue never uses are warnings.
  • Do not put a language in initialState.metadata; the Shell only reads user.locale.

The scoped Region API

The Region API a component receives is scoped to its own region. The Shell always sets sourceRegionId to the component's region, so a component cannot spoof its source. requestSelfChange and clear always target the component's own region.

interface ScopedRegionApi {
  readonly regionId: string
  requestChange(request: {
    targetRegionId: string
    componentKey: string | null
    props?: Record<string, unknown>
    mode?: 'replace' | 'clear'
    reason?: string
    options?: { checkDirtyState?: boolean; requirePermission?: boolean; forceRemount?: boolean }
    correlationId?: string
  }): Promise<RegionChangeResult>
  requestSelfChange(componentKey: string | null, props?: Record<string, unknown>): Promise<RegionChangeResult>
  clear(reason?: string): Promise<RegionChangeResult>
  getCurrentComponent(): { regionId: string; componentKey: string; props?: Record<string, unknown> } | null
}

interface RegionChangeResult {
  ok: boolean
  code: 'ok' | 'denied' | 'region-not-found' | 'component-not-found' | 'invalid-request' | 'mount-failed' | 'policy-rejected'
  status?: 'mounted' | 'updated' | 'cleared' | 'rejected' | 'cancelled' | 'deferred' | 'not-found' | 'error'
  errorCode?: string   // for example 'invalid-props', 'component-permission-denied', 'dirty-cancelled'
  reason?: string
  message?: string
  accepted?: boolean
  targetRegionId?: string
  componentKey?: string | null
  correlationId?: string
}

See the Validation pipeline of runtime region changes for the order of the checks and the full list of error codes.

Worked example components

The examples below are complete native Shell Components. Each is a single Vue SFC, ShellView.vue, compiled to bundle.js and exported as ShellView, paired with the component.json contract that authorizes its capabilities and declares its inputs, shared-state keys and events. Rules the compiler and the runtime enforce:

  • Import only from vue and @amshell/runtime; any other import fails compilation. The shadcn-vue kit (Card, Button, Table, …) and every lucide icon are re-exported from @amshell/runtime.
  • component.json needs a key, an inputSchema with a properties object (use { "type": "object", "properties": {} } for no inputs), and capabilities as a boolean map. Only the capabilities set to true are granted.
  • Do not author authorizationStatus: it is set when the artifact is published, and writing it fails compilation. Artifact URLs (bundle.js, styles.css) are derived when the page is resolved.
  • Read inputs with useInputProps() (a readonly Ref) and shared atoms with useSharedState(key) (a writable Ref). Use .value in <script>; in <template> use the bare name, never props.value.x or key.value.
  • Do not use defineProps; read inputs through useInputProps().
  • Calling a composable for an ungranted capability throws.
  • When a region change keeps the same component key, the component is not remounted: its props are updated in place. React to input changes with watch, not only onMounted.
  • The examples use literal text and placeholder endpoints (cs:/api/v2/customers) for brevity. Replace the endpoints with your own REST services and put visible text in locales.json.

Example A: input props, input schema and shell state

A read-only summary. Its customerId and mode arrive as initialProps from the region (or from a later requestChange), validated against inputSchema and then shaped with defaults (see Props and input schema). It also reads the seeded user context through useShellState().

{
  "key": "customer.summary",
  "exportName": "ShellView",
  "runtimeVersion": "v1",
  "inputSchema": {
    "type": "object",
    "required": ["customerId"],
    "properties": {
      "customerId": { "type": "string" },
      "mode": { "type": "string", "enum": ["view", "edit"], "default": "view" }
    },
    "additionalProperties": false
  },
  "capabilities": { "shellState": true, "logger": true },
  "requiredPermissions": []
}
<script setup>
import { computed, onMounted } from 'vue'
import { Card, CardHeader, CardTitle, CardContent, Badge } from '@amshell/runtime'
import { useInputProps, useShellState, useLogger } from '@amshell/runtime'

const props = useInputProps()       // readonly Ref: use .value in <script>
const shell = useShellState()       // capability "shellState"
const logger = useLogger()

const customerId = computed(() => props.value.customerId)
const mode = computed(() => props.value.mode ?? 'view')
const viewer = shell.getSnapshot().user.displayName || 'unknown'

onMounted(() => logger.info('summary mounted', { customerId: customerId.value, mode: mode.value }))
</script>

<template>
  <Card>
    <CardHeader>
      <CardTitle class="flex items-center gap-2">
        Customer {{ props.customerId }}      <!-- prop in template: NO .value -->
        <Badge :variant="mode === 'edit' ? 'default' : 'secondary'">{{ mode }}</Badge>
      </CardTitle>
    </CardHeader>
    <CardContent class="text-sm text-muted-foreground">Opened by {{ viewer }}.</CardContent>
  </Card>
</template>

Mount it from a region with initialProps: [ customerId: params.objId as String, mode: "view" ]. customerId must be a string: the Shell rejects a number with errorCode: "invalid-props".

Example B: REST, events, region navigation and shared state (a master list)

A list that loads rows over the REST client. On click it writes the selection into the shared store, publishes a customer:selected event, and navigates by composition: it swaps the main region to the detail component.

{
  "key": "customer.list",
  "exportName": "ShellView",
  "runtimeVersion": "v1",
  "inputSchema": { "type": "object", "properties": {} },
  "capabilities": {
    "restClient": true, "region": true, "events": true, "store": true, "logger": true
  },
  "requiredPermissions": ["customer.view"],
  "sharedState": { "selectedCustomer": { "initial": null } },
  "events": {
    "publishes": [ { "topic": "customer:selected", "description": "A customer row was chosen." } ]
  }
}
<script setup>
import { onMounted, ref } from 'vue'
import { Card, CardHeader, CardTitle, CardContent, Table, TableBody, TableRow, TableCell, Button, Skeleton } from '@amshell/runtime'
import { useRestClient, useRegion, useEvents, useSharedState, useLogger } from '@amshell/runtime'

const rest = useRestClient()                          // capability "restClient"
const region = useRegion()                            // capability "region"
const events = useEvents()                            // capability "events"
const selected = useSharedState('selectedCustomer')   // capability "store"; key declared in the page sharedState
const logger = useLogger()

const customers = ref([])
const loading = ref(true)
const error = ref('')

async function load() {
  loading.value = true
  error.value = ''
  try {
    const data = await rest.json({ url: 'cs:/api/v2/customers', query: { limit: 50 } })   // throws RestError on non-2xx
    customers.value = Array.isArray(data) ? data : []
  } catch (e) {
    error.value = e?.message ?? 'Failed to load customers'
    logger.error('customer load failed', e)
  } finally {
    loading.value = false
  }
}

async function open(customer) {
  selected.value = customer
  events.publish('customer:selected', { id: String(customer.id) })
  const result = await region.requestChange({
    targetRegionId: 'main',
    componentKey: 'customer.detail',
    props: { customerId: String(customer.id) }   // must match the target inputSchema (type string)
  })
  if (!result.ok) logger.warn('region change rejected', result.errorCode)
}

onMounted(load)
</script>

<template>
  <Card>
    <CardHeader><CardTitle>Customers</CardTitle></CardHeader>
    <CardContent>
      <div v-if="loading" class="space-y-2">
        <Skeleton v-for="n in 5" :key="n" class="h-8 w-full" />
      </div>
      <p v-else-if="error" class="text-sm text-destructive">{{ error }}</p>
      <Table v-else>
        <TableBody>
          <TableRow v-for="c in customers" :key="c.id">
            <TableCell>{{ c.name }}</TableCell>
            <TableCell class="text-right">
              <Button size="sm" variant="ghost" @click="open(c)">Open</Button>
            </TableCell>
          </TableRow>
        </TableBody>
      </Table>
    </CardContent>
  </Card>
</template>

Note

The sharedState block in component.json documents which keys the component uses. The value a key starts with comes only from the page definition's sharedState (see Shared state).

Example C: shared-state read and event subscription (a detail pane)

The peer of Example B. It reacts to its customerId prop, to the customer:selected event, and to the selectedCustomer shared atom. When Example B requests customer.detail in a region that already shows it, the Shell updates the props in place, so the component watches the prop instead of loading only on mount. Note the explicit onScopeDispose to unsubscribe, and the null-guards: a shared value can be undefined or null until a peer sets it.

{
  "key": "customer.detail",
  "exportName": "ShellView",
  "runtimeVersion": "v1",
  "inputSchema": {
    "type": "object",
    "required": ["customerId"],
    "properties": { "customerId": { "type": "string" } }
  },
  "capabilities": { "store": true, "events": true, "restClient": true, "logger": true },
  "requiredPermissions": ["customer.view"],
  "sharedState": { "selectedCustomer": { "initial": null } },
  "events": { "subscribes": [ { "topic": "customer:selected" } ] }
}
<script setup>
import { computed, onScopeDispose, ref, watch } from 'vue'
import { Card, CardHeader, CardTitle, CardContent, Separator } from '@amshell/runtime'
import { useInputProps, useSharedState, useEvents, useRestClient, useLogger } from '@amshell/runtime'

const props = useInputProps()
const selected = useSharedState('selectedCustomer')   // shared with customer.list
const events = useEvents()
const rest = useRestClient()
const logger = useLogger()

const record = ref(null)
let loadedId = ''
// shared values can be undefined or null until a peer sets them: always null-guard nested reads.
const name = computed(() => record.value?.name ?? selected.value?.name ?? '…')

async function loadDetail(id) {
  if (!id || String(id) === loadedId) return
  loadedId = String(id)
  try {
    record.value = await rest.json(`cs:/api/v2/customers/${loadedId}`)
  } catch (e) {
    loadedId = ''
    logger.error('detail load failed', e)
  }
}

const off = events.subscribe('customer:selected', (payload) => loadDetail(payload?.id))
onScopeDispose(off)
watch(() => selected.value?.id, (id) => loadDetail(id))
watch(() => props.value.customerId, (id) => loadDetail(id), { immediate: true })
</script>

<template>
  <Card>
    <CardHeader><CardTitle>{{ name }}</CardTitle></CardHeader>
    <CardContent class="text-sm">
      <p>ID: {{ props.customerId }}</p>
      <Separator class="my-4" />
      <p v-if="record">{{ record.email }}</p>
      <p v-else class="text-muted-foreground">Select a customer to see details.</p>
    </CardContent>
  </Card>
</template>

Example D: dirty-state protection, security, dialogs and notifications (an editor)

An editor that registers itself as a dirty source of its region, gates editing on a permission, confirms a discard with a dialog, and reports outcomes with notifications.

When another component asks to put a different component in this region (or to clear it) while the editor is dirty, the Shell asks the user to discard. If the user declines and the registration has a save hook, the Shell offers to save. A save that returns false (or throws) cancels the change with errorCode: "dirty-cancelled".

The registration must name the region (regionId). Without it the region's replace guard never sees the source.

{
  "key": "customer.editor",
  "exportName": "ShellView",
  "runtimeVersion": "v1",
  "inputSchema": {
    "type": "object",
    "required": ["customerId"],
    "properties": { "customerId": { "type": "string" } }
  },
  "capabilities": {
    "restClient": true, "dirtyState": true, "security": true,
    "notifications": true, "dialogs": true, "logger": true
  },
  "requiredPermissions": ["customer.edit"]
}
<script setup>
import { computed, onScopeDispose, reactive, watch } from 'vue'
import { Card, CardHeader, CardTitle, CardContent, CardFooter, Input, Label, Button, Alert, AlertDescription } from '@amshell/runtime'
import { Save, Lock } from '@amshell/runtime'
import { useInputProps, useShellContext, useRestClient, useDirtyState, useSecurity, useNotifications, useDialogs, useLogger } from '@amshell/runtime'

const props = useInputProps()
const { regionId } = useShellContext().value
const rest = useRestClient()
const dirty = useDirtyState()       // capability "dirtyState"
const security = useSecurity()      // capability "security"
const notify = useNotifications()
const dialogs = useDialogs()
const logger = useLogger()

const canEdit = computed(() => security.can('customer.edit'))
const form = reactive({ name: '', email: '' })
let pristine = { name: '', email: '' }
const isDirty = () => form.name !== pristine.name || form.email !== pristine.email

async function load(id) {
  if (!id) return
  try {
    const data = await rest.json(`cs:/api/v2/customers/${id}`)
    Object.assign(form, { name: data?.name ?? '', email: data?.email ?? '' })
    pristine = { ...form }
  } catch (e) {
    logger.error('load failed', e)
    notify.error('Could not load the customer')
  }
}

async function save() {
  if (!canEdit.value) { notify.warn('You have read-only access.'); return false }
  try {
    await rest.json({
      url: `cs:/api/v2/customers/${props.value.customerId}`,
      method: 'POST',
      body: { name: form.name, email: form.email }
    })
    pristine = { ...form }
    notify.success('Customer saved')
    return true
  } catch (e) {
    logger.error('save failed', e)
    notify.error('Save failed')
    return false                    // false cancels a pending region change (dirty-cancelled)
  }
}

async function reset() {
  if (!isDirty()) return
  if (await dialogs.confirm('Discard your changes?', 'Unsaved changes')) {
    Object.assign(form, pristine)
  }
}

const unregister = dirty.register({
  sourceId: 'customer-editor',
  regionId,                         // required: without it the region's replace guard never sees this source
  isDirty,
  save,
  discard: () => { Object.assign(form, pristine) }
})
onScopeDispose(unregister)

watch(() => props.value.customerId, (id) => load(id), { immediate: true })
</script>

<template>
  <Card>
    <CardHeader><CardTitle>Edit customer</CardTitle></CardHeader>
    <CardContent class="space-y-4">
      <Alert v-if="!canEdit">
        <Lock class="size-4" />
        <AlertDescription>You have read-only access.</AlertDescription>
      </Alert>
      <div class="space-y-2">
        <Label for="cust-name">Name</Label>
        <Input id="cust-name" v-model="form.name" :disabled="!canEdit" />
      </div>
      <div class="space-y-2">
        <Label for="cust-email">Email</Label>
        <Input id="cust-email" v-model="form.email" :disabled="!canEdit" />
      </div>
    </CardContent>
    <CardFooter class="flex justify-end gap-2">
      <Button variant="ghost" :disabled="!canEdit" @click="reset">Reset</Button>
      <Button :disabled="!canEdit" @click="save"><Save class="size-4" /> Save</Button>
    </CardFooter>
  </Card>
</template>

Wiring the examples into a page

The Controller hosts the list and detail flow in two regions, seeds the user context that the permission checks read, declares the shared atom both components bind to, and registers all three component keys. The main policy lets customer.list (and main itself) swap in customer.detail or customer.editor.

def components = docman.getNodeByPath(docman.getContentScriptVolume(), "CSComponents")

model.data.shellDefinition = [
    pageId : "customer-workspace",
    version: "1",

    // Read by requiredPermissions checks; an empty list denies every gated component.
    initialState: [
        user: [
            id         : users.current.ID as String,
            displayName: users.current.displayName,
            permissions: [ "customer.view", "customer.edit" ]
        ]
    ],

    sharedState: [ "selectedCustomer": [ initial: null ] ],

    regions: [
        list: [ initialComponentKey: "customer.list", policy: [ mutable: false ] ],
        main: [
            initialComponentKey: "customer.detail",
            initialProps       : [ customerId: params.objId as String ],   // useInputProps().value.customerId
            policy: [
                allowedComponents   : [ "customer.detail", "customer.editor" ],
                allowedSourceRegions: [ "list", "main" ]
            ]
        ]
    ],

    registry: [
        "customer.list"  : [ componentNodeId: docman.getNodeByName(components, "customer-list").ID ],
        "customer.detail": [ componentNodeId: docman.getNodeByName(components, "customer-detail").ID ],
        "customer.editor": [ componentNodeId: docman.getNodeByName(components, "customer-editor").ID,
                             requiredPermissions: [ "customer.edit" ] ]
    ]
]

Warning

If the page opens without a value for customerId, the initial mount of main fails schema validation (customerId is required) and the region stays empty. Seed a valid value or make the property optional.

Props and input schema

Props reach a component from two places: a region's initialProps (initial mount) and the props of any later region change. The component's inputSchema (a JSON Schema subset) controls them in two stages.

flowchart LR
    req["Region change with props"] --> validate["Shell validates raw props against inputSchema"]
    validate -->|"fails"| reject["Rejected: errorCode invalid-props, region unchanged"]
    validate -->|"passes"| shape["Runtime applies defaults and coerces values"]
    shape --> comp["useInputProps()"]

Shell-side validation

Before a mount or a props update, the Shell validates the props exactly as passed (no defaults applied yet). A missing props counts as {}.

Keyword Rule
type object (not an array or null), array, string, number (finite), integer, boolean, null. A wrong type stops checking that value.
enum The value must equal one of the entries.
const The value must equal the constant.
required Each listed key must be present. Checked only where the schema node has type: "object".
properties Each present key with a sub-schema is validated recursively. Checked only where the node has type: "object".
additionalProperties: false Keys without a sub-schema are rejected. Checked only where the node has type: "object".
items Every element is validated against the sub-schema. Checked only where the node has type: "array".

Other keywords (minLength, pattern, minimum, format, …) are ignored. A failure returns ok: false, code: "invalid-request", errorCode: "invalid-props", and a reason listing the errors, for example props.customerId is required; props.mode must be one of [view, edit]. The region is not changed.

Warning

Always give the root schema "type": "object". Without it, required, properties and additionalProperties are not checked. Because validation runs before defaults are applied, a required property must be passed even when it declares a default.

Defaults and coercion in the component

The component runtime then shapes the validated props for useInputProps():

  • A declared property that is missing or undefined gets its default, if it has one.
  • A present value is coerced to its declared type: number and integer with Number(...) (kept as is when the result is not a valid number or integer), boolean from "true" / "false", string with String(...).

Because the Shell validates first, a value of the wrong type is rejected rather than coerced (the string "42" for a number property fails with invalid-props). Pass values of the declared type.

{
  "type": "object",
  "required": ["customerId"],
  "properties": {
    "customerId": { "type": "string" },
    "mode": { "type": "string", "enum": ["view", "edit"], "default": "view" }
  },
  "additionalProperties": false
}

Props updates and remounts

When the region already shows the requested component key, a region change performs an in-place props update: no remount, the component's useInputProps() value and context are replaced, and the result has status: "updated". Pass options: { forceRemount: true } to unmount and mount again. When the key changes, the old component is unmounted (after the dirty-state check) and the new one mounted (status: "mounted").

Security and permissions

The Shell enforces two permission concepts. Both check against the user context in the shell state. They are for UX and composition; the backend remains the real authority on every REST call.

  • requiredPermissions gates whether a component may be mounted. It comes from the page's registry entry, or from the component's component.json when the registry entry does not set it. The user needs all listed permissions. Checked on the initial mount and on every change. A failure returns code: "denied", errorCode: "component-permission-denied".
  • requiredPermissionToChange on a region policy gates whether the region may be changed. A failure returns code: "denied", errorCode: "missing-region-permission" or "security-policy-denied". Passing options: { requirePermission: false } does not bypass it.

Separately, the server-side resolver drops every registry entry whose component node the user cannot See Contents, or that is not a Shell Component or AI View, or that has no matching version. A Controller cannot bypass this filter. The resolved definition is cached per user and per definition content.

Components can query the same context with useSecurity(): can, canAny, canAll and hasRole match exact strings. An empty permission or role, or an empty list, returns true.

Seeding the user context

Permission and role checks read initialState.user. The Shell does not fill it in; seed it from the Controller:

initialState: [
    user: [
        id         : users.current.ID as String,
        displayName: users.current.displayName,
        permissions: [ "customer.view", "customer.edit" ],   // matched by requiredPermissions / requiredPermissionToChange
        roles      : [ "customer-manager" ],                 // matched by hasRole()
        attributes : [ department: "sales" ],
        locale     : "it"                                    // optional; overrides the Smart View language
    ],
    metadata: [:]
]
  • An empty or omitted permissions list denies every permission-gated entry and region change.
  • Permissions are opaque application strings, not Content Server ACL bits.
  • Non-string entries in permissions and roles are dropped; a missing attributes becomes {}.
  • metadata is passed through unchanged to useShellState().getSnapshot().metadata.

Refreshing the user context

You can enrich the bootstrap user context once, when the Shell starts:

Field Behavior
userContextEndpoint A URL the Shell GETs through its REST client (so cs: URLs work) before any initial component mounts. A 2xx JSON object is merged over the bootstrap user (top-level fields replace), then permissions:changed ({ userId, permissionsCount }) is published on the EventBus. A blank locale in the response keeps the earlier one. On a non-2xx status or an error the Shell logs a warning and keeps the bootstrap user.
userContextScriptId The id of a Content Script, set on the shell tile (userContextScriptId in the tile's source configuration, or userContextSource(scriptId) / userContextSource(script) on the shell tile widget builder) and copied onto the definition when the definition does not set one. The Shell does not run it: without userContextEndpoint it logs the id and uses the bootstrap user. Use userContextEndpoint for a real fetch.
sequenceDiagram
    participant ctrl as Controller
    participant shell as Shell
    participant ep as userContextEndpoint
    participant comp as Components
    ctrl->>shell: initialState.user (bootstrap)
    shell->>ep: GET (optional)
    ep-->>shell: 2xx JSON user fields
    shell->>shell: merge, publish permissions:changed
    shell->>comp: mount initial regions
    shell->>comp: snapshot.ready becomes true

While the endpoint request is pending, region change requests are answered with status: "deferred", errorCode: "security-not-ready". Because components mount only after the refresh, they never observe permissions:changed at startup; read the final user from useShellState() or useSecurity().

Shared state

The page definition's sharedState declares a cross-region store, exposed to components that hold the store capability. Each key is { schema?, initial?, readOnly? }:

sharedState: [
    "selectedCustomer": [ initial: null ],
    "appConfig"       : [ initial: [ theme: "light" ], readOnly: true ]
]
Field Meaning
initial Initial value of the key. The Shell stores a copy, so changing a value in place never changes the page definition.
readOnly When true, writing the key throws [amshell] Shared state key "<key>" is read-only. Components can read and subscribe but not write. The value stays initial for the life of the page.
schema Advisory shape, carried through; the store does not enforce it.

Only the page definition sets initial values and read-only keys. The sharedState block in a component's component.json documents the keys the component uses; it does not create them.

Components consume a key as a writable Ref:

import { useSharedState } from '@amshell/runtime'
const selected = useSharedState('selectedCustomer')   // requires capability "store"
selected.value = { id: '42' }   // throws if the key is readOnly
  • The Ref follows writes from every region and is unsubscribed when the component unmounts.
  • Keys not declared in sharedState are created on first access with the value undefined.
  • An empty key throws [amshell] Shared state key is required.
  • AI Form Regions use the same store, so their state can be shared with Shell Components.

Events and classic interop

The EventBus (capability events) carries notifications of things that happened. It never changes the composition of the page: region changes are commands that return a result (see Runtime region changes).

import { onUnmounted } from 'vue'
import { useEvents } from '@amshell/runtime'

const events = useEvents()
events.publish('customer:selected', { id })
const off = events.subscribe('customer:selected', (payload) => { /* … */ })
onUnmounted(off)

Unsubscribe when the component unmounts

subscribe returns a function that removes the listener. The Shell does not remove a component's listeners when the component unmounts, so call it in onUnmounted. The debug panel flags a subscriber whose component is no longer mounted as stale.

Runtime lifecycle events

The engine publishes these topics on the same bus. Subscribe to them for diagnostics or to coordinate regions:

Topic Payload When
shell:started { pageId, version, regionCount } After the security context is ready and the initial components are mounted.
shell:stopped { pageId } When the Shell is destroyed (for example on a perspective change).
region:changed { targetRegionId, componentKey, status, correlationId } After a successful change. status is mounted, updated or cleared; componentKey is null after a clear.
component:error { regionId, componentKey, phase, error } A mount, update or unmount failed. phase is mount, update or unmount; error is the error message. The region shows the fallback.
region:loop-detected { targetRegionId, componentKey, count, intervalMs, correlationId } Loop protection rejected a change (see Loop protection).
permissions:changed { userId, permissionsCount } At startup, after the userContextEndpoint returned a user.
shell:fragment-merged { sourceId, acceptedRegionIds, rejectedRegionIds } A contributor Shell tile added regions (see Multi-tile pages).
shell:fragment-rejected { sourceId, regionId, reason: 'region-id-collision' } A contributor region was rejected because the region id already exists.
form:submit { status: 'success', regionId, componentKey, formId, outcome? } or { status: 'error', regionId, componentKey, formId, errors } An AI Form Region submit finished. Published only when the form has the events capability. errors is a list of { field, message }.

shell:started, shell:stopped, permissions:changed, shell:fragment-merged and shell:fragment-rejected are also sent to the host telemetry sink. On a Smart View perspective the Shell Tile writes them to the browser console as [AMShell][telemetry] <topic> debug messages.

An AI Form Region also records its last submit outcome in Shared State under aiform:<regionId>:lastSubmit (or the registry entry's aiSubmitStateKey) when it has the store capability.

Commands for AI Form Regions

Page chrome, such as a toolbar component, can drive a mounted AI Form Region through these topics. The form listens only when its registry entry grants the events capability, which is off by default for AI Views.

Topic Payload Effect
form:action { action, formId?, regionId?, componentKey?, followRedirect? } The form runs that action. am_discard and cancel return the region to its return component without posting. Any other action runs as a form action, for example am_save. A redirect outcome navigates only when followRedirect is true.
form:return { formId?, regionId?, componentKey? }, at least one of them Same as discard: the form's region changes to its return component (returnComponentKey / aiReturnComponentKey prop, or the registry aiReturnComponentKey).

Addressing rules:

  • Each addressing field you send must match the form. A field you omit matches any form.
  • A form:action without any addressing field reaches every mounted form that listens.
  • form:return without an addressing field is ignored.

Do not send form:requestChange from page chrome

From page chrome, use form:return or form:action with am_discard. A form:requestChange sent on the page event bus uses componentKey both to find the form and as the component to mount, so it cannot change the region to another component. Inside the form, the AI View can still publish form:requestChange, as described below.

  • The return after form:return or a discard skips the dirty check. If no return component is configured, nothing happens and a warning is logged.
  • If the region change fails, the form shows the notification "Unable to leave this form." when it has the notifications capability.

Inside the form, the AI View can itself publish form:return and form:requestChange. These go only to its own mount and need no events capability. Any other topic published by the form is ignored with a console warning.

Classic ampagenotify bridge

Topics prefixed ampagenotify: are bridged to the classic Smart UI ampagenotify radio channel. This is the only supported interop with classic Smart Pages and Smart UI tiles. Publishing ampagenotify:foo triggers foo on the classic channel, and subscribing to ampagenotify:foo receives the classic foo events. Topics without the prefix stay inside the Shell.

The engine runs outside the Smart View module loader and cannot reach the classic channel by itself. The host supplies it: the Shell Tile hands the ampagenotify channel to the engine. Hosts that do not supply a channel, such as the Module Suite Runtime portal, disable the bridge, and prefixed topics then stay inside the Shell.

Bridge rules:

  • Only the leading ampagenotify: is removed, so ampagenotify:smartui:show:iv:sidepanel triggers smartui:show:iv:sidepanel. Colons in the rest of the topic are kept.
  • Classic trigger and on are bridged. Radio request and reply are not.
  • A bridged publish also reaches Shell subscribers of the same prefixed topic. A classic event that arrives through the bridge is not sent back to the classic channel.
  • The payload object is passed by reference, unchanged. Classic handlers may change it, so publish a plain object built for the call, not a Vue reactive or ref value. Pass JSON values only; do not pass callback, which would hand Backbone views back to the component.
  • Publishing or subscribing to a bridged topic needs the events capability, like any other topic.
  • The Shell removes its classic handlers when it is destroyed.

Triggering Smart UI commands from a Shell Component

The Smart UI commands that classic Smart Pages trigger with data-am-pub-channel or Radio.channel('ampagenotify').trigger(...) ship in the same Smart UI bundle as the Shell Tile. A Shell Component triggers them through the bridge. A classic page usually runs a server action first and triggers the command with the response (data-action and data-am-pub-channel). A Shell Component builds the payload itself, from data it already loaded through REST.

smartui:show:iv:sidepanel opens Intelligent Viewing in a Smart View side panel:

Field Required Meaning
id yes Page-unique string. It names the follow-up events.
node yes A document id (number), an array of ids, or [{ id, version_number }] to open specific versions. A value of another type shows "Invalid node ID"; an array with an invalid entry shows "Invalid node IDs".
panel yes { width, cssClass, layout: { header, footer, mask, resize }, slides? }. width is 10 to 100 in steps of 10 (percent of the window, CSS class amsui-smartui-slide-panel-<width>vw). Always pass cssClass (use ''). Without slides, the panel forces header and footer on.
error yes { type, text, details, options? }, shown with a Smart View global message when a node cannot be fetched or the viewer cannot load. Without it, a load failure becomes a script error.
mode no 'compare' opens the viewer in compare mode (two or more nodes).

Related topics (publish or subscribe with the ampagenotify: prefix):

Topic Direction Effect
smartui:hide:<id>:iv:sidepanel publish Closes the panel opened with that id.
smartui:destroy:iv publish Closes every open viewer.
smartui:<id>:iv:sidepanel:closed subscribe The user closed the panel.
smartui:show:iv publish Shows the viewer inside an element (target selector). Not usable from a Shell Component, because the component's DOM is in a shadow root.
const events = useEvents()   // capability "events"

events.publish('ampagenotify:smartui:show:iv:sidepanel', {
  id: 'contract-doc-viewer',
  node: [{ id: 24179987, version_number: 3 }],
  panel: { width: 50, cssClass: '', layout: { header: false, footer: false, mask: false, resize: true } },
  error: { type: 'error', text: 'The document cannot be opened in the viewer.', details: '' }
})

Requirements and limits:

  • Smart UI context. The command handlers read the perspective context from the classic channel. The Shell Tile registers that context, so the commands work on a perspective whose only Module Suite tile is a Shell Tile.
  • Intelligent Viewing must be installed and enabled for the user. Otherwise the error message is shown.
  • Content Server only. On the Module Suite Runtime portal and in the Component Builder preview there is no classic channel, and the publish stays in the Shell.
  • Fire and forget. There is no return value. Subscribe to the closed event when the component must know the panel state.

Dirty state and unsaved-changes protection

A region can declare unsaved changes so the Shell can protect them. AI Form Regions register themselves automatically. A Shell Component registers with useDirtyState() (capability dirtyState).

Dirty sources are grouped by region id, and the Shell checks the region it is about to replace. Always pass your component's regionId when you register. Without it, the region id defaults to sourceId, and the source is not linked to your region.

import { onUnmounted } from 'vue'
import { useDirtyState, useShellContext } from '@amshell/runtime'

const { regionId } = useShellContext().value
const dirty = useDirtyState()
const unregister = dirty.register({
  sourceId: 'customer-form',
  regionId,
  isDirty: () => form.hasChanges,           // pull check
  save:    async () => await form.save(),   // optional; return false to keep the region
  discard: () => form.reset()               // optional
})
onUnmounted(unregister)

// Or set an explicit flag on the registered source:
dirty.setDirty('customer-form', true)

setDirty(sourceId, dirty) updates the source registered under that sourceId. If none is registered, it creates one whose region id is sourceId. The service also offers isDirty(regionId?), hasDirty(sourceId?) and listDirtySources(regionId?). An isDirty callback that throws counts as dirty.

The dirty check runs when a region change replaces the mounted component with a different component key, or clears the region, unless the region policy sets requireDirtyCheck: false or the request sets options.checkDirtyState: false. An update with the same component key never runs it. The check works like this:

  1. If no source in the target region is dirty, the change proceeds.
  2. Otherwise the user is asked to discard the changes ("The current region content is dirty. Replace it?"). If the user accepts, the discard hooks run and the change proceeds.
  3. If the user declines and at least one dirty source has a save hook, the user is asked "Save changes before replacing this region?". If the user accepts, the save hooks run. If any hook returns false or throws, the user sees "Unable to save one or more dirty regions. Replacement was cancelled." and the change is cancelled.
  4. In every other case the change is cancelled.

A cancelled change returns status: "cancelled" with errorCode: "dirty-cancelled". An AI Form Region has no save hook, so declining the discard keeps the form.

While any source on the page is dirty, the browser shows its "Leave site?" prompt before the page is unloaded.

Runtime region changes

After the initial mount, components change region content through the scoped Region API (capability region, composable useRegion()). Changes are commands that return a result.

Request and result

interface RegionChangeRequest {
  targetRegionId: string
  componentKey: string | null            // null = clear
  props?: Record<string, unknown>
  sourceRegionId?: string                // set by the scoped Region API
  mode?: 'replace' | 'clear'             // inferred from componentKey when omitted
  reason?: string                        // free text, e.g. 'user-action'
  options?: {
    checkDirtyState?: boolean
    requirePermission?: boolean
    preserveState?: boolean              // accepted, currently no effect
    forceRemount?: boolean               // unmount and mount even when the key is unchanged
  }
  correlationId?: string
}

interface RegionChangeResult {
  ok: boolean
  accepted?: boolean                     // same value as ok
  code: 'ok' | 'denied' | 'region-not-found' | 'component-not-found' | 'invalid-request' | 'mount-failed' | 'policy-rejected'
  status?: 'mounted' | 'updated' | 'cleared' | 'rejected' | 'cancelled' | 'deferred' | 'not-found' | 'error'
  targetRegionId?: string
  componentKey?: string | null
  reason?: string                        // human-readable reason of a rejection
  message?: string                       // same as reason
  errorCode?: string
  correlationId?: string
}

Defaults: mode is replace, or clear when componentKey is null. checkDirtyState and requirePermission default to true. forceRemount defaults to false.

import { useRegion } from '@amshell/runtime'
const region = useRegion()

// Replace another region (list to detail):
await region.requestChange({ targetRegionId: 'main', componentKey: 'customer.detail', props: { customerId } })
// Replace my own region:
await region.requestSelfChange('customer.orders', { customerId })
// Empty my own region:
await region.clear('user closed detail')
// The component currently mounted in my region:
region.getCurrentComponent()             // { regionId, componentKey, props } or null

The scoped Region API always sets sourceRegionId to the component's own region. A component cannot pretend to be another region. requestSelfChange and clear target the component's own region.

Changes to the same region run one after the other, in the order they were requested.

Validation pipeline

Each request is checked in this order. The first failing check returns a result with ok: false and the listed values:

Check errorCode code status
targetRegionId is set invalid-target-region invalid-request rejected
Security context is ready security-not-ready invalid-request deferred
Target region is defined region-not-found region-not-found not-found
componentKey is set in replace mode invalid-component-key invalid-request rejected
Region is mutable (see below) region-immutable policy-rejected rejected
Self-replacement is allowed self-replacement-disabled policy-rejected rejected
Component is in allowedComponents component-not-allowed policy-rejected rejected
Source region is in allowedSourceRegions source-region-not-allowed policy-rejected rejected
User has requiredPermissionToChange missing-region-permission denied rejected
Security policy allows the change security-policy-denied denied rejected
Loop protection REGION_CHANGE_LOOP_DETECTED policy-rejected rejected
Component key is in the registry component-not-found component-not-found not-found
User has the component's requiredPermissions component-permission-denied denied rejected
Props match the component's inputSchema invalid-props invalid-request rejected
Region element exists in the DOM region-element-missing mount-failed deferred
Dirty check dirty-cancelled denied cancelled
Unmount of the previous component component-unmount-failed mount-failed error
Mount or update mount-failed mount-failed error

Notes on the policy checks:

  • The policy checks (mutability through loop protection) run only when the region has a policy.
  • Mutability. mutable: false rejects a change only when a component is already mounted and the request names a different component key. An empty immutable region can still be filled, the mounted component can still be updated with new props, and the region can still be cleared.
  • Self-replacement. A request whose source region is the target region is a self-change. allowSelfReplacement: false rejects it. allowedSourceRegions applies only to requests from other regions.
  • Permission. options.requirePermission: false skips the missing-region-permission check only. The security policy check that follows still requires requiredPermissionToChange, and rejects the change with security-policy-denied.
  • Clearing. A clear runs the checks up to loop protection, then the dirty check and the unmount. The registry, permission, props and DOM checks do not apply.

Initial components go through the same checks

The Shell mounts each region's initialComponentKey as a self-change of that region, without the dirty check. The region policy therefore applies to the initial mount too: allowSelfReplacement: false prevents the initial component from mounting, and requiredPermissionToChange, allowedComponents and the component's requiredPermissions must all allow it. A rejected initial mount leaves the region empty and logs the warning "Initial region mount rejected" with the result.

A deferred result is transient at startup. The Shell mounts the initial component itself once the region element appears in the page.

Loop protection

A region policy can limit how often a region changes:

{ "maxChangesPerInterval": { "count": 5, "intervalMs": 1000 } }

The Shell counts the successful changes (mount, update or clear) of the region within the last intervalMs milliseconds. When that number has reached count, the next request is rejected with errorCode: "REGION_CHANGE_LOOP_DETECTED", the Shell logs a warning and publishes region:loop-detected. Loop protection is off unless both count and intervalMs are greater than 0. There is no default limit.

Identity, update and remount, error isolation

  • A component's identity is its component key. A request with the key that is already mounted updates the props in place (status updated). A different key unmounts the old component and mounts the new one (status mounted). options.forceRemount: true unmounts and mounts again even when the key is the same.
  • A mount, update or unmount failure is isolated. The region shows the fallback "Component unavailable / This region failed to render and was isolated from the rest of the page.", the Shell publishes component:error, and the other regions keep working.

Multi-tile pages

Exactly one Shell runtime exists per page (window.__AM_SHELL__). You can still build a page from several Shell Tiles on the same perspective:

  • The first Shell Tile that renders starts the engine with its definition as the primary definition. Its registry is authoritative. It also decides whether the debug panel is on.
  • Each additional Shell Tile renders its region markup but does not start a second engine. It adds its regions to the running Shell as a contributor fragment. If the engine is still loading, the fragment is queued and added before the Shell starts.
  • Region id collisions are rejected. The primary definition wins, the engine logs an error and publishes shell:fragment-rejected.
  • A contributor's registry is ignored (a warning is logged). Only the primary registry resolves component keys, so every key a contributor region references must exist in the primary registry.
  • Regions with an initialComponentKey that a contributor adds after startup are mounted as soon as their region element is in the page.

The Shell lives as long as the perspective. Navigating to a sub-node of a cascading perspective keeps it. A real perspective change destroys it, and the next Shell Tile starts a new one.

The host bridge

The Shell Tile passes a host bridge to the engine. You rarely configure it, but it explains where the services a component receives come from. The engine fallback applies to hosts that do not supply a service:

Service Shell Tile Engine fallback
dialogs Smart View modal alert and confirmation dialogs; the browser prompt for prompts. window.alert, window.confirm, window.prompt.
notifications Smart View global messages (info, success, warning, error). Console messages prefixed [amshell/notify].
events.getClassicChannel The classic Smart UI radio channel (for example ampagenotify). None: the ampagenotify bridge is off.
REST transport Requests to Content Server go through the Smart View connector, which adds the user's session ticket and renews it. Requests to other origins use plain fetch without the ticket. fetch with credentials: 'same-origin'.
REST URLs and bodies The cs: URL prefix resolves to this Content Server. Native REST calls (/api/vN/…) send a mutating JSON body as a form body parameter; other Content Server calls (Content Script OData) send it in the classic form-encoded format. URLs are used as given; bodies are sent as JSON.
navigation Engine fallback. window.location.assign, window.location.reload, window.open(url, target, 'noopener'). Named pages (toPage) need a host that knows them.
telemetry.record Console debug message [AMShell][telemetry] <event>. None.
logger Engine fallback. Console messages prefixed [amshell].
hostWidgets.mount Loads the allow-listed Smart View widget (nodestable, documentoverview, metadata, relatedworkspaces, workspace, workspaceheader) on the live perspective context, with view state off where the widget supports it, into a light-DOM container slotted into the component's shadow root. Selection is mapped to plain JSON. Placeholder text "Smart View widgets require Content Server Smart View".
hostWidgets.pickNodes / pickMembers Opens the Smart View node picker or member picker on the live context. Resolves plain JSON, or null on cancel. Rejects with the same message.

The tile also:

  • resolves the engine URL from the Smart View connector's support path, falling back to /support/anscontentsmartui/amshell/engine/v1/engine.js;
  • adds runtime.css and an import map for vue and @amshell/runtime to the page once;
  • reads the inline #am-shell-definition JSON;
  • copies userContextScriptId from its data-source settings onto the definition when the definition does not set it;
  • passes the Smart View UI language as the lowest-priority user locale;
  • starts the engine with a native dynamic import().

The render path

flowchart LR
  ctrl["Controller sets model.data.shellDefinition"] --> resolve["Definition filtered by ACL and cached per user"]
  resolve --> html["Page HTML plus inline am-shell-definition JSON"]
  html --> tile["Shell Tile imports the engine"]
  tile --> boot["Engine discovers regions and mounts components"]
  1. A Smart Page whose pageRenderer is shell is a Shell Page. A page whose pageRenderer is shell-component is a Shell Component and cannot be rendered as a page.
  2. The child Content Script named Controller runs and sets model.data.shellDefinition to the raw definition. If it does not, rendering fails with "Shell page <id> Controller did not publish model.data.shellDefinition."
  3. The server resolves the raw definition: registry entries that point to a component node are completed from the stored component, entries for nodes the user cannot see are removed, and the result is cached per user. The resolved definition is written into <script type="application/json" id="am-shell-definition">. The server then removes model.data.shellDefinition from the model and sets model.data.am_include = true.
  4. The page HTML is the page view wrapped in <div class="am-shell-page">, followed by the definition script. A page template configured on the Smart Page is not used.
  5. The Shell Tile injects the HTML, imports the engine and calls createShell({ definition, root: document, host }).
  6. The engine discovers every [data-am-region] element in the document, maps it to the definition by region id, resolves the user context (optionally through userContextEndpoint) and mounts the initial components. A duplicate data-am-region is ignored.

Shell Pages do not round-trip. The page renders its static layout once, and the Shell owns all DOM changes afterward. Do not use body-replacing am_page.js actions on a Shell Page: they would destroy the mounted components.

Reference example

A minimal Shell Page Controller: three Shell Components in two regions, and an AI View form in a third region, with policies, permissions and a seeded user context.

// Controller for a Shell Page. 'model' is bound by the render path.
def components = docman.getNodeByPath(docman.getContentScriptVolume(), "CSComponents")

model.data.shellDefinition = [
    pageId : "customer-workspace",
    version: "1",

    initialState: [
        user: [
            id         : users.current.ID as String,
            displayName: users.current.displayName,
            permissions: [ "customer.view", "customer.edit" ],
            roles      : [ "customer-manager" ],
            attributes : [ department: "sales" ]
        ],
        metadata: [:]
    ],

    regions: [
        sidebar: [
            initialComponentKey: "customer.sidebar",
            initialProps       : [ customerId: params.objId ],
            policy             : [ mutable: false ]
        ],
        main: [
            initialComponentKey: "customer.detail",
            initialProps       : [ customerId: params.objId, mode: "view" ],
            policy: [
                mutable                   : true,
                allowedComponents         : [ "customer.detail", "customer.orders" ],
                allowedSourceRegions      : [ "sidebar", "main" ],
                allowSelfReplacement      : true,
                requireDirtyCheck         : true,
                requiredPermissionToChange: "customer.edit"
            ]
        ],
        form: [
            initialComponentKey: "intake.form",
            initialProps       : [ "af-form-data": [ customerId: params.objId ] ],
            policy             : [ mutable: false ]
        ]
    ],

    registry: [
        "customer.sidebar": [ componentNodeId: docman.getNodeByName(components, "customer-sidebar").ID ],
        "customer.detail" : [ componentNodeId: docman.getNodeByName(components, "customer-detail").ID, requiredPermissions: [ "customer.view" ] ],
        "customer.orders" : [ componentNodeId: docman.getNodeByName(components, "customer-orders").ID, requiredPermissions: [ "customer.view" ] ],
        // AI View node (subtype 43100): resolved as an AI Form Region; no bundleUrl or exportName needed.
        "intake.form"     : [ componentNodeId: docman.getNodeByPath(docman.getContentScriptVolume(), "CSForms:Customer Intake").ID ]
    ]
]

The main region requires customer.edit to change, and that check also applies to its initial component, so the seeded user must hold customer.edit for customer.detail to appear.

One possible view.vm hosts all three regions:

<div class="row">
  <div class="col-xs-3 am-shell-region" data-am-region="sidebar"></div>
  <div class="col-xs-6 am-shell-region" data-am-region="main"></div>
  <div class="col-xs-3 am-shell-region" data-am-region="form"></div>
</div>

The view does not have to host every region. A view with only sidebar, or with no region elements at all, is valid when the missing region elements are supplied by other tiles on the perspective. Every data-am-region in the view must be a region the Controller defines.

Troubleshooting

Symptom Cause Fix
Failed to fetch … engine/v1/engine.js (404) The Shell engine is not installed under the Content Server support path, or the tile resolved a wrong support path. Check that <supportPath>/anscontentsmartui/amshell/engine/v1/engine.js returns 200. The tile builds the URL from the Smart View connector's support path.
Component "<key>" has no bundleUrl at mount A registry entry was not resolved, so it has no bundleUrl. Reference a component node (componentNodeId, componentId, nodeId, id or dataId) that has a stored version, or an AI View with a rendered version, or set bundleUrl explicitly. The region shows the "Component unavailable" fallback.
Region shows "Component unavailable" The bundle failed to import, mount or update. Check the component:error event and the console for the region and key. Check that the bundle URL returns 200 and exports the component (the exportName export, or the default export). An AI View bundle must export FormView or a default component.
Region stays empty, console shows "Initial region mount rejected" The initial mount failed a check. component-not-found usually means the registry entry was removed because the user lacks See Contents on the component node. Policy and permission codes mean the region policy blocks the initial mount. Grant the permission on the component node, or adjust the region policy and seeded permissions.
Region stays empty, no message No element with that data-am-region exists anywhere on the perspective, or the region has no initialComponentKey. Add a region element whose id the Controller defines.
The page fails with "Controller did not publish model.data.shellDefinition" The Controller did not set model.data.shellDefinition, or the child Content Script is not named exactly Controller. Name the child Content Script Controller and assign the raw definition.
Region change returns status: "deferred" The security context is not ready, or the region element is not in the page yet. This is transient at startup; retry after shell:started. A persistent region-element-missing means no element on the perspective has that data-am-region.
Region change rejected (region-immutable, component-not-allowed, missing-region-permission, security-policy-denied, …) The region policy or the user's permissions do not allow the change. Adjust the region policy or seed the right permissions.
Form shows raw i18n keys (for example employeeid_label) An override replaced the form's bundled strings. The AI Form Region applies the bundled strings automatically. If you override them (aiLocales or af-i18n), include every key, or remove the override.
[amshell/runtime] Capability "x" is not granted for "<key>" The component called a composable for a capability it was not granted. Authorize the capability in component.json, or set it on the registry entry for a component with an explicit bundleUrl.
You cannot tell which component published an event, set a Shared State key or called an endpoint No logging in the components. Open the debug panel (?amshelldebug=1, then Ctrl+Alt+Shift+D) or export a Shell Trace (see Debugging a Shell Page).

Quick checklist

  • The Shell engine and runtime return 200 under <supportPath>/anscontentsmartui/amshell/…
  • The page artifact contains view.vm, controller.groovy and page.json
  • page.json sets pageRenderer to shell and lists the page regions
  • The Smart Page has a child Content Script named exactly Controller
  • The Controller sets model.data.shellDefinition
  • regions and registry are maps (keyed by region id and component key)
  • The Controller defines every region of the page specification
  • Every data-am-region in the view is a region the Controller defines (the view may host a subset or none)
  • Each initialComponentKey exists in registry
  • Shell Components exist (for example under CSComponents) with a stored version and an authorized component.json
  • registry entries reference component node ids, or set an explicit bundleUrl
  • Form regions reference an AI View node (subtype 43100) that has a rendered version
  • initialState.user.permissions contains the requiredPermissions and requiredPermissionToChange values the initial components need
  • No region with an initialComponentKey sets allowSelfReplacement: false
  • Components are granted only the capabilities they use

Field reference and defaults

PageRuntimeDefinition

Field Type Default
pageId string —
version string —
regions Record<regionId, RegionDefinition> —
registry Record<componentKey, ComponentRegistryEntry> —
initialState { user, metadata? } User with no id, no permissions, no roles and empty attributes
sharedState Record<key, { schema?, initial?, readOnly? }> none
userContextEndpoint string none. When set, the Shell calls it once at startup with a GET, and a JSON user object in a 2xx response is merged over initialState.user.
userContextScriptId number none. Copied from the Shell Tile's User-context Content Script ID setting; the engine does not call it. Use userContextEndpoint to load the user.

initialState.user

Field Type Default
id string none
displayName string none
permissions string[] []
roles string[] []
attributes object {}
locale string The Smart View UI language. A non-blank locale in the definition, or from userContextEndpoint, overrides it.

RegionDefinition

Field Type Default
initialComponentKey string none (the region starts empty)
initialProps object {}
policy RegionPolicy none: no policy check applies

RegionPolicy

Field Type Default
mutable boolean true
allowSelfReplacement boolean true
allowedComponents string[] any (an empty list also allows any)
allowedSourceRegions string[] any (an empty list also allows any)
requireDirtyCheck boolean true
requiredPermissionToChange string none
maxChangesPerInterval { count, intervalMs } off

ComponentRegistryEntry (after resolution)

Field Type Notes
key string Component key
bundleUrl string Required. For a component node it is set to a versioned Content Server URL of the stored bundle.js; for an AI View, to the view's bundle under the support path.
stylesUrl string Set when the stored component has a styles.css
exportName string For a Shell Component: the registry entry's value, else the component.json value, else the component key. The Shell uses that named export and falls back to the default export. For an AI View: FormView.
runtimeVersion string Runtime the component was built for, for example v1
adapter 'vue' or 'ai-form' Inferred when omitted: ai-form when aiBundleURL, aiRuntimeBase or aiSubmitURL is set, otherwise vue. Set to ai-form for AI View nodes.
designProfile string Taken from the stored component or AI View only; a value set in the page is removed. For a Shell Component, an empty value means the default profile, jato.
ai* strings and maps AI View only, filled by the server. Includes aiSubmitURL, aiDefaultAction, aiLocales, aiReturnComponentKey, aiReturnProps, aiNotifyOnSubmit and aiSubmitStateKey.
inputSchema object Props schema. Defaults to {}.
capabilities ComponentCapabilities Unset capabilities are denied
requiredPermissions string[] Mount gate. Defaults to [].
events / dataSources / sharedState objects Declared metadata, taken from the registry entry or component.json. For a Shell Component the defaults are { publishes: [], subscribes: [] }, [] and {}.

Capability defaults

The 12 capabilities are region, shellState, security, dirtyState, restClient, navigation, events, notifications, dialogs, store, logger and hostWidget. All of them are denied unless granted.

For AI View forms the server grants dirtyState and navigation (always, even when the page sets them to false), and notifications, logger and hostWidget by default. The page can turn off the defaults. The other capabilities are off unless set.

Built-in defaults

Setting Default
REST client (engine fallback) fetch with credentials: 'same-origin'
Submit URL (AI forms) ?func=ambwf.aifbsubmit on this Content Server
Submit action (AI forms) am_submit_reload
Design Profile (Shell Components) jato
Region change mode: replace (clear for a null key), checkDirtyState: true, requirePermission: true, forceRemount: false
Loop protection off
Debug panel off; hotkey Ctrl+Alt+Shift+D

Debugging a Shell Page

The engine includes a debug panel that shows how the page works: regions, mounted components and their Input Props, the Component Registry with each component's declared contracts, Shared State, EventBus topics and subscribers, and REST and fetch calls. You can try small in-memory changes, and export a Shell Trace to hand to a coding agent.

Enabling it

Debug is off by default. While it is off, nothing is recorded and inspect() returns null. On a Shell Tile, the first match wins:

  1. The URL query ?amshelldebug=1 (or true) enables it for this page load. ?amshelldebug=0 (or false) forces it off.
  2. localStorage.setItem('amshell.debug', '1') enables it in your browser until you remove the key.
  3. The Shell Tile option Developer tools → Enable debug panel (debug.enabled) enables it on the perspective for everyone. Toggle hotkey (debug.hotkey) changes the hotkey, and Open on load (debug.openOnLoad) opens the panel after startup.

When several Shell Tiles are on the perspective, the tile that starts the Shell decides.

Press Ctrl+Alt+Shift+D to open or close the panel. A custom hotkey uses the same form: modifiers (Ctrl, Alt, Shift, Meta; Control, Option, Cmd and Command are accepted) followed by one key, joined with +. An invalid value falls back to the default. Key presses inside iframes do not reach the panel. The panel remembers in the browser tab whether it was open, and reopens after a perspective change.

Module Suite Runtime portal

The amshelldebug query, the amshell.debug key and the tile options apply to the Shell Tile. On the Module Suite Runtime portal, the portal host's own configuration turns debug on.

Tabs

  • Overview: Shell state (started, security ready, dirty), the user (id, name, locale, roles, permissions), counts, and Copy definition JSON.
  • Regions: each region with its status, source (the definition or a contributor fragment), component, adapter, mount count and last result. Hover a row to outline the region on the page. Select a region to:

    • edit its props and click Apply props. The edit is merged over the live props; props that are not JSON, such as callbacks, are listed as preserved and kept;
    • Remount it (unmount and mount once);
    • Swap component; components you lack permission for are marked "missing permission";
    • Clear it.

    Region policies, permissions, props validation, loop protection and the dirty check still apply. allowSelfReplacement and allowedSourceRegions do not, because the panel acts as an external source. - Components: registry entries with capabilities, required permissions, inputSchema, declared events and data sources, and where each is mounted. Show in timeline filters the timeline by that component. - Shared state: keys with their value, read-only and declared badges, and who set them last. Edit a value as JSON and apply it; check Force to write a read-only key. Reset restores the declared initial value, even if a component changed it in place. - Events: topics with listener counts, subscribers and last payload. A subscriber whose component is no longer mounted is marked stale, which usually means a listener leak. You can publish any topic; ampagenotify: topics are bridged to the classic channel. - Timeline: every recorded entry, indented by cause (<- exact, <~ inferred), with filters, Pause, Clear and Export JSON. Expand a row to see its value, then Show cause chain to highlight its ancestors. The flag same-reference means a Shared State key was set to the object it already held, so subscribers were not notified. This is a common bug when a component changes an object in place and sets it again. - Network: engine REST calls (engine) and page-level fetch calls (page fetch, including AI Form Region load, submit and data sources), with request and response bodies. Content Server calls from components go through the Smart View connector and appear only as engine entries.

The timeline keeps the last 5000 entries. Page-level fetch bodies are recorded up to 20 KB.

Recording a scenario and exporting a Shell Trace

  1. Open the panel and click Start scenario.
  2. Reproduce the behavior you want to understand, for example by clicking a row in a list.
  3. Click Stop scenario, then Copy trace or Download trace (amshell-trace-<pageId>-<time>.md). Check Include full values for longer samples.

The file contains a legend, a page snapshot and a console-style log of the startup plus the scenario. Each line is indented under its cause, for example a user.click with the resulting event.publish, event.handle, rest.*, state.* and region.* lines below it.

To get help from a coding agent, attach the trace together with the ShellView.vue of the components involved. Then ask about the behavior and refer to lines by their #seq, for example "why does #16 not update the detail region?".

Traces contain business data

Samples and exports contain business data. Values under keys that match ticket, authorization, password, passwd, secret, token, cookie or session (case-insensitive, anywhere in the key name) are replaced with [redacted]. HTTP headers other than content-type and content-length are not recorded, and typed input values are never read. Review the file before you share it.

Console API

When debug is on, the Shell Tile logs where the inspector is. The Shell object exposes it as shell.inspect():

const inspector = window.__AM_SHELL__.inspect()
inspector.getSnapshot()                        // regions, components, shared state, topics
inspector.getEntries()                         // the recorded timeline
inspector.setSharedState('focused', { id: 5 }, { force: true })
inspector.publishEvent('customer:selected', { id: 5 })
await inspector.remountRegion('detail')
inspector.startScenario(); /* … */ inspector.stopScenario()
copy(inspector.buildTrace())                   // the Shell Trace as Markdown

The inspector also offers updateRegionProps(regionId, patch), changeRegionComponent(regionId, componentKey, props?), clearRegion(regionId), resetSharedState(key?), clearEntries() and exportSession().

See also