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.
- How the pieces fit together
- Prerequisites
- The Shell Page node
- The Controller and the raw page definition
- Regions and the layout View
- Region policy
- The registry
- Use case A: native Vue Shell Components
- Use case B: AI View forms as regions
- Use case C: an explicit bundle URL
- Use case D: custom adapters
- Capabilities: the full reference
- The component runtime context
- Props and input schema
- Security and permissions
- Shared state
- Events and classic interop
- Dirty state and unsaved-changes protection
- Runtime region changes
- Multi-tile pages
- The host bridge
- The render path
- Reference example
- Troubleshooting
- Quick checklist
- Field reference and defaults
- Debugging a Shell Page
- See also
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:
- 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 storedcomponent.json, or resolves an AI View entry through theai-formadapter. - 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">. tile_content_shellinjects the page HTML, reads the inline definition, injects the kit stylesheetruntime.cssand an import map forvueand@amshell/runtime, importsengine.js, then callscreateShell({ definition, root: document, host, hostLocale, debug })andstart().- The engine discovers
[data-am-region]hosts across the whole document and mounts each region'sinitialComponentKey. The adapter imports the component'sbundleUrland 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
43300page in the project'spagesfolder withview.vm; - sets the page extended data:
pageRenderer, advanced mode, and thedepJs/depCsslists frompage.json; - creates or versions its subtype
43200childControllerwithcontroller.groovy; - rejects the artifact when
view.vmhosts adata-am-regionid thatpage.jsondoes not declare (view.vm hosts undefined page regions: …), or whenpage.jsonpageRendererdoes 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
shellDefinitionfrommodel.dataand 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
MutationObserveron 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
userContextEndpointcall). Region-change requests issued before then returnstatus: "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
initialComponentKeymust resolve inregistry; - 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) flaggedpageRenderer = "shell-component"— is resolved from thecomponent.jsonof 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 aliascomponentVersion). The resolver walks the versions from newest to oldest and picks the first whose version number equals the value, or whosecomponent.jsonversionIdmatches it (case-insensitive). If no version matches, the entry is dropped. - AI View: pin with
versionId,componentVersionoraiViewVersion. The value must be a version number; anything else selects the node's current version. The resolved entry reports the version inaiViewVersion.
"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 withComponent "<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 explicitbundleUrl. - 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¶
- 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.
- Store it. Each store adds one version to the Component node. The version file (extension
.amver) is a ZIP that must containbundle.jsandcomponent.json. It can also containstyles.css, and it carries the source and builder files (for exampleShellView.vue,locales.jsonand the builder conversation). You can also upload an exported.amveras a new version; the upload is refused when its Shell runtime version is not installed on the server. - The stored
component.jsoncarries the authorized metadata:key,inputSchema,capabilities,requiredPermissions,sharedState,events,dataSources, the pinneddesignProfile, the delivery platformtargetHost(content-serverorscript-console), aversionId, andauthorizationStatus(authorizedunless the builder sent another value).bundleUrlandstylesUrlare 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,CsuiWorkspaceandCsuiWorkspaceHeader; - the pickers
useCsuiNodePicker()anduseCsuiMemberPicker().
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
aiViewid 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=aifbPrepareRuntimeStatetoaiLoadURLwith the view id and version, theaf-form-dataseed and theparamsprop. The server builds the form from the view's form template, applies the seed, runs the view's OnLoad script (on Content Server anOnLoadchild 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
errorfield of thecomponent:errorevent 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
runAsandcsvarssettings of the standalone page are not applied, so the script runs as the calling user. - The adapter loads
@aifb/runtimeandcreateAfApp, 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:
redirectgoes to the Navigation Service (navigation.navigate), andreloadis 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): publishesform:submitwith{ status, regionId, componentKey, formId, outcome }on success or{ status, regionId, componentKey, formId, errors }on error. - Shared state (
store): writes the same record toaiSubmitStateKey. - Notification (
notifications, opt-in withaiNotifyOnSubmit): 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,getandpostresolve for every HTTP status and returnRestResponse { status, headers, data }. Checkres.statusyourself.json(url | request)returns the body of a 2xx response and throws aRestError(status,data,message) for any other status. Use it withtry/catchwhen a failure must reach your error handling.requesttakes{ url, method?, headers?, query?, body?, credentials?, responseType? };responseTypeis'json'(default),'text'or'blob'.queryis a flat record ofstring | 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>, resolvingcs:andapi:.
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-blanklocalein theuserContextEndpointresponse. 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 finallydefaultLocale. - 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 thatShellView.vuenever uses are warnings. - Do not put a language in
initialState.metadata; the Shell only readsuser.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
vueand@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.jsonneeds akey, aninputSchemawith apropertiesobject (use{ "type": "object", "properties": {} }for no inputs), andcapabilitiesas a boolean map. Only the capabilities set totrueare 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 readonlyRef) and shared atoms withuseSharedState(key)(a writableRef). Use.valuein<script>; in<template>use the bare name, neverprops.value.xorkey.value. - Do not use
defineProps; read inputs throughuseInputProps(). - 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 onlyonMounted. - 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 inlocales.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
undefinedgets itsdefault, if it has one. - A present value is coerced to its declared
type:numberandintegerwithNumber(...)(kept as is when the result is not a valid number or integer),booleanfrom"true"/"false",stringwithString(...).
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.
requiredPermissionsgates whether a component may be mounted. It comes from the page's registry entry, or from the component'scomponent.jsonwhen the registry entry does not set it. The user needs all listed permissions. Checked on the initial mount and on every change. A failure returnscode: "denied",errorCode: "component-permission-denied".requiredPermissionToChangeon a region policy gates whether the region may be changed. A failure returnscode: "denied",errorCode: "missing-region-permission"or"security-policy-denied". Passingoptions: { 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
permissionslist denies every permission-gated entry and region change. - Permissions are opaque application strings, not Content Server ACL bits.
- Non-string entries in
permissionsandrolesare dropped; a missingattributesbecomes{}. metadatais passed through unchanged touseShellState().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
Reffollows writes from every region and is unsubscribed when the component unmounts. - Keys not declared in
sharedStateare created on first access with the valueundefined. - 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:actionwithout any addressing field reaches every mounted form that listens. form:returnwithout 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:returnor 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
notificationscapability.
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, soampagenotify:smartui:show:iv:sidepaneltriggerssmartui:show:iv:sidepanel. Colons in the rest of the topic are kept. - Classic
triggerandonare bridged. Radiorequestandreplyare 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
reactiveorrefvalue. Pass JSON values only; do not passcallback, which would hand Backbone views back to the component. - Publishing or subscribing to a bridged topic needs the
eventscapability, 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
errormessage 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
closedevent 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:
- If no source in the target region is dirty, the change proceeds.
- Otherwise the user is asked to discard the changes ("The current region content is dirty.
Replace it?"). If the user accepts, the
discardhooks run and the change proceeds. - If the user declines and at least one dirty source has a
savehook, the user is asked "Save changes before replacing this region?". If the user accepts, thesavehooks run. If any hook returnsfalseor throws, the user sees "Unable to save one or more dirty regions. Replacement was cancelled." and the change is cancelled. - 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: falserejects 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: falserejects it.allowedSourceRegionsapplies only to requests from other regions. - Permission.
options.requirePermission: falseskips themissing-region-permissioncheck only. The security policy check that follows still requiresrequiredPermissionToChange, and rejects the change withsecurity-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 (statusmounted).options.forceRemount: trueunmounts 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
registryis 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
regionsto 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
initialComponentKeythat 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.cssand an import map forvueand@amshell/runtimeto the page once; - reads the inline
#am-shell-definitionJSON; - copies
userContextScriptIdfrom 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"]
- A Smart Page whose
pageRendererisshellis a Shell Page. A page whosepageRendererisshell-componentis a Shell Component and cannot be rendered as a page. - The child Content Script named
Controllerruns and setsmodel.data.shellDefinitionto the raw definition. If it does not, rendering fails with "Shell page <id> Controller did not publish model.data.shellDefinition." - 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 removesmodel.data.shellDefinitionfrom the model and setsmodel.data.am_include = true. - 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. - The Shell Tile injects the HTML, imports the engine and calls
createShell({ definition, root: document, host }). - The engine discovers every
[data-am-region]element in the document, maps it to the definition by region id, resolves the user context (optionally throughuserContextEndpoint) and mounts the initial components. A duplicatedata-am-regionis 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
200under<supportPath>/anscontentsmartui/amshell/… - The page artifact contains
view.vm,controller.groovyandpage.json -
page.jsonsetspageRenderertoshelland lists the page regions - The Smart Page has a child Content Script named exactly
Controller - The Controller sets
model.data.shellDefinition -
regionsandregistryare maps (keyed by region id and component key) - The Controller defines every region of the page specification
- Every
data-am-regionin the view is a region the Controller defines (the view may host a subset or none) - Each
initialComponentKeyexists inregistry - Shell Components exist (for example under
CSComponents) with a stored version and an authorizedcomponent.json -
registryentries reference component node ids, or set an explicitbundleUrl - Form regions reference an AI View node (subtype
43100) that has a rendered version -
initialState.user.permissionscontains therequiredPermissionsandrequiredPermissionToChangevalues the initial components need - No region with an
initialComponentKeysetsallowSelfReplacement: 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:
- The URL query
?amshelldebug=1(ortrue) enables it for this page load.?amshelldebug=0(orfalse) forces it off. localStorage.setItem('amshell.debug', '1')enables it in your browser until you remove the key.- 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.
allowSelfReplacementandallowedSourceRegionsdo 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 flagsame-referencemeans 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¶
- Open the panel and click Start scenario.
- Reproduce the behavior you want to understand, for example by clicking a row in a list.
- 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¶
- Shell pages and Shell components: how Shell Pages and components fit together.
- Communication between different tiles: the classic
ampagenotifyradio channel that the bridge connects to. - Smart UI commands: the full list of classic Smart UI commands.
- Smart View overrides: customizing Smart View without the Smart View SDK.
- AI Form Builder: building the AI Views that you mount as AI Form Regions.
- Module Suite Runtime: the portal that also hosts Shell Pages and Shell Components.