| name | snu-aix-experience |
| description | Build/edit ServiceNow AI Experience (AIX) apps on the sys_aix_* tables: experiences, pages, containers, custom Lit widgets + server data scripts, left-nav menus, and record click-through. Covers routing/cache rules, why creation needs a background script (REST = E_ACL), the widget compile-metadata pattern, and the OOTB-widget/app-shell gotchas. Read this before creating or modifying anything on the sys_aix_* tables. |
SN ScriptSync — AI Experience (AIX)
How to build a working AI Experience end-to-end on the sys_aix_* tables: the data model, routing/cache rules, custom Lit widgets with server data, left-nav menus, and record click-through. This is a new, lightly-documented framework — the notes below are the field-tested recipe, including the dead ends to avoid.
Prereq: you drive all of this through the Agent API. Read snu-agent-api
first. For screenshots / network / console verification read snu-browser-debug.
1. Data model (the tables that matter)
| Table | Role | Key fields |
|---|
sys_aix_experience | Top-level app | title, url_suffix, landing_path, theme, app_shell |
sys_aix_experience_properties | Per-experience config (incl. menu binding) | experience, name, value |
sys_aix_page | A route within an experience | sys_name, title, path_pattern |
sys_aix_experience_page_rel | Links a page into an experience | experience, page, order |
sys_aix_page_route_map | Path → page routing / aliases | experience, page, route_from_path, route_to_path, active, order |
sys_aix_container | Layout box (tree via parent_container) | page, parent_container, order, classes |
sys_aix_widget_instance | Places a widget in a container | container, widget, order, classes, properties (JSON), name |
sys_aix_widget | The component definition (extends sys_ux_widget) | name, id, category, component (Lit), script (server), component_compile_metadata, input_schema |
sys_aix_menu | A navigation menu | name |
sys_aix_menu_item | A menu entry | menu, title, target_type, target_url, icon, order, active, category |
sys_aix_app_shell | App chrome (header/footer/side regions) | name, header, start, end, footer (each → sys_aix_widget), css |
Composition: experience → (page_rel) → page → containers (tree by parent_container, attached by container.page) → widget_instance → widget.
A page's content is every container whose page points at it; the top of the tree is the container(s) with an empty parent_container. There is no root_container field — the page↔container link is container.page.
classes on containers/instances are Tailwind utility classes (e.g. flex flex-col gap-6 p-6 w-full max-w-3xl). The runtime ships Tailwind.
2. Routing & cache (read before you "can't find" your page)
- URL pattern is
/aiux/<url_suffix>/<path_pattern> — e.g. /aiux/sspoc/home.
It is not under /now/.
- Route params use
:name segments: path_pattern = /profile/:userId →
/aiux/sspoc/profile/<sys_id>. The widget reads the param from the URL.
- After creating/changing experiences, pages, routes, flush the cache:
rest_request GET /cache.do. New routes don't resolve until you do.
- The runtime caches the experience/page config in the browser tab. A soft
reload often re-uses the stale config (your new container/widget won't appear).
To force a fresh config fetch, open a new tab (or append a throwaway query
like
?cb=1) — don't trust an in-place reload after structural changes.
- ⚠️ Don't leave a cache-buster query on a route that has a
:param you depend
on if you're unsure how it parses — verify the param still resolves.
3. Creating records: use a background script, not REST
Direct REST writes to sys_aix_* fail (E_ACL, and create_artifact →
E_INTERNAL) even with the right transaction scope. Create/modify these
records with run_background_script doing plain GlideRecord inserts, and set
sys_scope explicitly to your app.
var APP = '<x_scope_app_sys_id>';
var THEME = '<sys_aix_theme_sys_id>';
var WIDGET= '<sys_aix_widget_sys_id>';
var out = {};
function ins(table, vals){
var gr = new GlideRecord(table); gr.initialize();
for (var k in vals) gr.setValue(k, vals[k]);
gr.setValue('sys_scope', APP);
return gr.insert();
}
out.exp = ins('sys_aix_experience', {title:'ScriptSync POC', url_suffix:'sspoc', landing_path:'/home', theme:THEME});
out.page = ins('sys_aix_page', {title:'Home', sys_name:'Home', path_pattern:'/home'});
out.rel = ins('sys_aix_experience_page_rel', {experience:out.exp, page:out.page, order:1});
out.cont = ins('sys_aix_container', {page:out.page, order:0, classes:'flex flex-col gap-6 p-6 w-full max-w-3xl mx-auto'});
out.wi = ins('sys_aix_widget_instance', {name:'My Widget', container:out.cont, widget:WIDGET, order:0});
out.route= ins('sys_aix_page_route_map', {experience:out.exp, page:out.page, route_from_path:'/home', route_to_path:'/home', active:true, order:10});
gs.print('RESULT_JSON=' + JSON.stringify(out));
Print RESULT_JSON=... and parse the sys_ids out of the script output for the
next step. Then flush the cache.
Reuse OOTB widgets the same way — just point widget_instance.widget at the
OOTB sys_aix_widget sys_id.
4. Custom widgets = Lit component + server script + compile metadata
A sys_aix_widget is authored as Lit source in component, with an optional
server-side data script in script. The platform auto-compiles on insert/update;
you must supply component_compile_metadata describing the imports.
component (client, Lit):
import { html, css } from 'lit';
import { AIUXWidgetElement } from '@servicenow/aiux-components-core';
import { locationService } from '@servicenow/aiux-services';
class MyWidget extends AIUXWidgetElement {
static properties = {
table: { type: String },
_records: { type: Array, state: true },
_loading: { type: Boolean, state: true }
};
constructor() { super(); this.table = 'incident'; this._records = []; this._loading = true; }
connectedCallback() { super.connectedCallback(); this._load(); }
_load() {
this.server.get({ action: 'incidents', table: this.table }).then((d) => {
this._records = (d && d.records) || []; this._loading = false;
}).catch(() => { this._loading = false; });
}
_open(r) { if (r && r.sys_id) locationService.navigate('/record/' + this.table + '/' + r.sys_id); }
render() {
return html`<div class="flex flex-col gap-2 p-6">
${this._records.map((r) => html`
<div class="p-3 rounded-xl" style="cursor:pointer" @click=${() => this._open(r)}>
${r.number} — ${r.short_description}
</div>`)}
</div>`;
}
static styles = css`:host{display:block;}`;
}
script (server) — Service-Portal-style (data, options, input):
(function(data, options, input) {
var t = (input && input.table) ? input.table : 'incident';
data.table = t;
try {
var gr = new GlideRecord(t);
gr.addEncodedQuery('active=true');
gr.orderByDesc('sys_updated_on');
gr.setLimit(5);
gr.query();
var rows = [];
while (gr.next()) rows.push({
number: gr.getValue('number'),
short_description: gr.getValue('short_description'),
priority: gr.getValue('priority'),
sys_id: gr.getUniqueValue()
});
data.records = rows;
} catch (e) { data.error = e.message; }
})(data, options, input);
component_compile_metadata (JSON) — declare every import:
{
"imports": {
"lit": ["html", "css"],
"@servicenow/aiux-components-core": ["AIUXWidgetElement"],
"@servicenow/aiux-services": ["locationService"]
},
"elementClassName": "MyWidget",
"importBoundaries": [0, 163]
}
importBoundaries = [0, <byte length of the import block>] (the chars before
your class declaration). On a successful save the API echoes back the metadata
with hasServerScript: true — use that to confirm compilation.
- Data fetching: prefer
this.server.get(...) → the widget script. Avoid
client-side fetch to the Table API; if you must, it needs the X-UserToken
header (window.g_ck) and credentials:'include', and you own the ACLs.
- Create the widget with
create_artifact (table: sys_aix_widget,
scope: <your app>, await: true) or update with update_record_batch.
Build the payload in a file (JSON.stringify) and curl -d @body.json —
never hand-escape Lit/JSON on the shell. See snu-artifacts › "Large payloads".
5. Click-through to a record page
- The OOTB "Record" page has
path_pattern = /record/:table/:sys_id and
renders the standard AIX record form. (There are also /ticket/:table/:sys_id
and /task/:table/:sys_id.)
- That page lives in the system experience. To use it from your experience,
link it in with a
sys_aix_experience_page_rel (your experience + the
Record page sys_id). Then /aiux/<suffix>/record/<table>/<sys_id> resolves.
- In your widget, navigate on click with
locationService.navigate('/record/' + table + '/' + sysId)
(import locationService from @servicenow/aiux-services).
6. Left-nav menu
Data model: a sys_aix_menu plus ordered sys_aix_menu_item rows. Each item:
target_type: 'url', target_url: '/home' (an in-app path), icon (an AIX icon
name like home-outline, user-outline, tree-outline), title, order,
active. Bind a menu to an experience via a sys_aix_experience_properties row
with name = 'menu', value = <menu sys_id>.
out.menu = ins('sys_aix_menu', {name:'My Menu'});
out.mi_home = ins('sys_aix_menu_item', {menu:out.menu, title:'Home', target_type:'url', target_url:'/home', icon:'home-outline', order:0, active:true});
out.mi_prof = ins('sys_aix_menu_item', {menu:out.menu, title:'Profile', target_type:'url', target_url:'/profile/'+ID, icon:'user-outline', order:1, active:true});
out.bind = ins('sys_aix_experience_properties', {experience:EXP, name:'menu', value:out.menu});
⚠️ The menu won't render a nav rail in a custom sub-experience by itself
Binding the menu property is correct, but in this build a custom experience
does not get the platform left-nav chrome from it, and sys_aix_app_shell
region widgets (start/end/header/footer) do not render custom widgets
either (the OOTB builder shell leaves them empty and only ships CSS). The
root-experience nav is driven by the protected root runtime and isn't
reproducible in a sub-experience.
What actually works — render the nav as a page widget in a left column:
- Build a small nav widget (Lit) whose
script reads the menu items
(GlideRecord('sys_aix_menu_item') filtered to your menu, ordered), and whose
render lists them with @click=${() => locationService.navigate(it.target_url)}.
Highlight the active item via locationService.path().
- Lay the page out as a row: nest containers via
parent_container.
var row = ins('sys_aix_container', {page:PAGE, parent_container:'', order:0, classes:'flex flex-row gap-0 w-full min-h-screen'});
var navc = ins('sys_aix_container', {page:PAGE, parent_container:row, order:0, classes:'shrink-0'});
ins('sys_aix_widget_instance', {name:'Nav', container:navc, widget:NAV_WIDGET, order:0});
var c = new GlideRecord('sys_aix_container');
if (c.get(EXISTING_CONTENT)) { c.setValue('parent_container', row); c.setValue('order', 1); c.update(); }
After this, flush the cache and load in a fresh tab (§2) — a soft reload will
show the stale single-column layout.
7. OOTB route-driven widgets (e.g. Employee Profile Card)
Some OOTB widgets are route-driven: they read their key (e.g. userId) from
the URL :param, not from widget_instance.properties. The Employee Profile
Card renders only when:
- it sits on a page whose
path_pattern supplies the param (/profile/:userId),
reached at /aiux/<suffix>/profile/<sys_id>; and
- the target user actually has the backing data (an
sn_employee_profile record
and a valid employee definition). A user without it renders empty — that's a
data problem, not a wiring problem. Test with a known-valid employee first.
Setting sysUserId in instance properties does not drive these widgets.
8. Verify with CDP (see snu-browser-debug)
capture_full_page (no per-tab grant) to screenshot the rendered experience.
start_network_capture (includeBodies:true) filtered to /api/now/aix/widget
to confirm a widget's this.server.get fired (POST 201 /api/now/aix/widget/<widget-id>) and inspect the returned data.
start_console_capture to catch Lit/compile/runtime errors. If a region or
nested container renders empty, first re-capture in a fresh tab to rule out
the config cache before debugging the records.
- The experience config endpoint (
/api/now/aix/config/<suffix>) only answers
multipart/mixed, so read it via a network capture, not rest_request.
9. Gotchas checklist