app-template
AGENTS.md1 starsChanged 12 days agoMITView on GitHub
Briefing for AI assistants (and humans) developing this abap2UI5 app repository. It was created from abap2UI5/app-template and comes with the validation gates preconfigured.
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 31,439 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
In this repository5 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- abap-checkSkill
- build-an-appSkill
- ui5-checkSkill
- view-chain-layoutSkill
The file
Show its rules and text
We split this AGENTS.md into the rules it gives an agent
18 rules. About 0 are common, 5 are distinctive, and 13 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈5
Distinctive · estimate
This author's own choices. Worth a look.
13
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 45
When a gate fails in a way that names a symptom rather than a cause — a syntax error inside node_modules (old Node), "browser not found" …
- Very specific: line 108
Run them on the system with ABAP Unit (ADT Ctrl+Shift+F10, or SE24) - or
- Very specific: line 330
Keep the check_on_init( ) arm where it earns its place — a model_init( ) call, or the one or two control-state flags an app seeds inline — …
- Very specific: line 469
The closing paren rides with the arrow: Never leave a ) alone at the end of a line — carry it to the start of the next segment, so every …
- Very specific: line 482
One call per line — no exceptions: Every ele( ), tag( ), a( ) and end( ) opens its own line with )->.
- Very specific: line 523
none: after a bare ele whose first child is another ele — a Shell → Page scaffold is one path down, not two blocks.
- Very specific: line 651
When a bound template's rows fill different subsets of the same properties, bind with client->_bind( val = t_rows omit_initial = abap_true …
- Very specific: line 707
Read in on_event via CASE client->get_event( )..
- Very specific: line 713
NOT resolved and arrives empty.
- Very specific: line 820
After the response, client->follow_up_action( val = … t_arg = … ) as a STATEMENT schedules a frontend action: focus (cs_event-set_focus), …
- Very specific: line 884
Call another app: client->nav_app_call( NEW zcl_other_app( ) ).
- Distinctive: line 930
State discipline: every PUBLIC attribute is serialized into the draft and sent to the browser on every render.
- Distinctive: line 937
Check your authorizations at the top of main, before any dispatch, and render an error when denied - the same place a transaction or a …
- Very specific: line 967
NOT on it. Every other host needs your own content_security_policy in the exit, and a system-local copy served by your own ICF node is the …
- Distinctive: line 1032
Every abap2UI5 page carries its own developer tools: a dialog over the running app that shows the roundtrips, the view XML and the model, …
- Distinctive: line 1064
Roundtrips: every roundtrip with backend vs.
- Distinctive: line 1117
No key, model or endpoint in the class: Read them from your own configuration and pass a destination (SM59 on Standard, a communication …
- Very specific: line 1121
Create the provider in the method that calls it - never keep it in an attribute, the app instance is serialized every roundtrip.
≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
AGENTS.md — abap2UI5 app template
Briefing for AI assistants (and humans) developing this abap2UI5 app repository. It was created from abap2UI5/app-template and comes with the validation gates preconfigured.
This repository
| Path | Purpose |
src/ | The app classes (abapGit project, STARTING_FOLDER=/src/, FOLDER_LOGIC=PREFIX) — one class per app, named ZCL_* |
src/zcl_app_001.clas.abap | The starter app — rename/copy it for your first real app (keep the .clas.xml sidecar's CLSNAME in sync) |
src/zcl_app_001.clas.testclasses.abap | ABAP Unit tests for the starter app: a test double for z2ui5_if_client drives main( ) through the first start, a SAVE event and a navigated roundtrip. Runs on the system (ADT Ctrl+Shift+F10); the local gates check it statically — see "Testing an app" below |
package.json | The two gates as devDependencies (@abaplint/cli, @abap2ui5/linter + its render runtime @abap2ui5/linter-render, on the same minor line — they are cut from one tag) and the npm run check* scripts. package-lock.json is committed, so CI and your machine run the same versions |
package-lock.json | Committed on purpose: npm ci reads it, so the gate versions CI runs are the ones you ran locally |
abaplint.jsonc | abaplint config; abaplint clones the abap2UI5 framework for dependency resolution, pinned to release tag 1.148.0 ("branch" — abaplint passes it to git clone --branch, which takes a tag; there is no "tag" key). That tag is the framework floor: 1.142.0 has neither z2ui5_cl_ui5_view_builder nor client->get_event( ), both of which the starter class uses. Bump the pin when you need a newer API, and run npm run check |
abap2ui5lint.jsonc | abap2UI5-linter config (paths, UI5 floor, distribution, rule severities, fail level) — CLI flags override it |
.github/workflows/check.yml | CI: the framework pin, then abaplint from the lockfile, then the abap2UI5-linter through its own action (abap2UI5/linter, SHA-pinned) for the static gate + headless render of every view |
scripts/check-pin.mjs | npm run check:pin — the framework release above is written in more than one place and no tool moves it; this fails when they disagree and notices (without failing) when a newer release is out |
scripts/doctor.mjs | npm run doctor — offline environment check, one line per finding with its remedy: Node version, both gates installed and on one minor line, a Chromium the render gate can launch, the framework pin, the linter Action pin against the devDependency, every .clas.xml sidecar (BOM, LF, CLSNAME, WITH_UNIT_TESTS), abap2ui5lint.jsonc, the VS Code extension, the linter's compatibility record, whether the installed linter has --watch. Exit 1 only on FAIL |
.claude/skills/ | Four agent skills mirrored from the framework — build-an-app, view-chain-layout, abap-check, ui5-check — loaded on demand by Claude Code and any agent that reads SKILL.md files. Generated (each file says so on its first line); "this repository" in their text is the framework's |
.claude/settings.json, .mcp.json | The permission allowlist for autonomous sessions, and the abap2UI5 MCP server registered for Claude Code (npx --yes @abap2ui5/mcp-server). The VS Code extension registers the same server for Copilot itself |
.devcontainer/, .vscode/extensions.json | A dev container (Node 22, both gates and Chromium installed on create, the abap2UI5, abaplint and Claude Code extensions) and the editor's extension recommendations |
Build & verify — run before every commit
npm ci # once - installs both gates from the lockfile
npx playwright install chromium # once - only the render gate needs a browser
npm run doctor # is this machine able to run the gates? one line per check, with the remedy
npm run check # abaplint + linter, expect 0 issues
npm run check:abap2ui5:fast # fast loop: linter without the render gate
npm run watch # the fast loop, again on every save - Ctrl+C ends it
npm run watch:render # the same with the render gate, one warm browser across the runs
npm run test:unit # the ABAP Unit tests in the transpiled backend - no system (see Testing an app)
npm run fix # apply the linter's mechanical corrections
npm run check:pin # the framework release this repo names, in one place
npm run check:all # everything CI runs: the pin, then both gates
npm test # the same as `npm run check:all`When a gate fails in a way that names a symptom rather than a cause — a
syntax error inside node_modules (old Node), "browser not found" (no
Chromium), a rule CI has and your run does not (Action pin and devDependency
on different minor lines) — npm run doctor says which, and what to run. It
is offline and takes a second.
npm run watch is abap2ui5lint --watch --no-render: the static check once,
then again on every save under src/ (and on an edit to
abap2ui5lint.jsonc, which is read again), the report on stdout and a
separator naming the changed file on stderr; npm run watch:render is the
same loop with the render gate on, keeping one warm browser across the runs
instead of launching one per run. A watch never exits 1 - it is the loop for
a developer whose editor has no linter in it (Eclipse ADT, with abapGit
pulling the classes into this checkout: save, pull, read), not a gate; it
refuses --fix, --screenshot and the JSON/SARIF outputs, so npm run fix
stays a separate step. VS Code users have the extension's live check instead.
The flag arrived with @abap2ui5/linter 0.7.0; on an older linter both
scripts print its unknown option '--watch' and exit 2 (npm run doctor says
whether the installed linter has it).
npm run check:all is the local equivalent of the CI job — a green run of it
means CI passes. (npm run check is the two gates alone and skips the pin,
which is why the two scripts exist separately.)
Both gates run offline from any SAP system (abaplint clones the framework on
first use, so it needs network). Deploy to a system via
abapGit and start an app with
<icf-endpoint>?app_start=zcl_app_001.
While writing, the VS Code extension surfaces the same linter findings as editor diagnostics with quick fixes, and mcp-server gives an MCP-capable agent the deploy → build → run-headless-and-screenshot loop.
Conventions: every .clas.abap needs its .clas.xml sidecar (UTF-8 with
BOM, LF endings — copy an existing one; abaplint.jsonc enables xml_bom
and xml_consistency, so a hand-edited sidecar fails the gate rather than the
next abapGit pull; a class with a .clas.testclasses.abap include also
carries <WITH_UNIT_TESTS>X</WITH_UNIT_TESTS> after <UNICODE>, which is how
abapGit serializes one); class names stay in ZCL_*/ZCX_*, which is both
object_naming in abaplint.jsonc and the rule the rename step enforces; the
whole repo is in English.
Testing an app
src/zcl_app_001.clas.testclasses.abap shows the shape: the app talks to the
framework only through the z2ui5_if_client it is handed, so a local class
with INTERFACES z2ui5_if_client PARTIALLY IMPLEMENTED — attributes deciding
what check_on_init( ) / check_on_navigated( ) / check_on_event( ) /
get_event( ) answer, tables recording what view_display( ) and
message_toast_display( ) were given — is the whole test bed. The three tests
are the three roundtrips every app has: the first call seeds the model and
displays a view (<Input, <List), the SAVE event toasts Saved, World,
and a navigated roundtrip re-displays without seeding again. The double
implements every method the app CALLS - the lifecycle questions, the two
things it does back, and the four the view code reaches for (_bind,
_event, _event_nav_app_leave, check_app_prev_stack): on a system a
PARTIALLY IMPLEMENTED interface's missing methods are generated empty, in
the transpiled runtime below they are not, and a call to one is a TypeError
there. Add a method to the double when the app starts calling another one;
the runner names the missing one.
Run them on the system with ABAP Unit (ADT Ctrl+Shift+F10, or SE24) - or
without a system: npm run test:unit (the mcp-server's abap2ui5-unit)
installs the framework at the release abaplint.jsonc pins from the npm
package @abap2ui5/node-runtime (cached under ~/.abap2ui5-mcp; a pin older
than 1.145.0 clones the release and builds its backend instead), transpiles
the classes under src/ with their test includes into it and runs the tests
through the open-abap runtime. The unit
job in check.yml does the same on every pull request through the
abap2UI5/mcp-server action, with the step summary naming every test method
and the first failure. What the runtime cannot model (the abap-check skill
lists the known cases) a system still has to answer. npm run check stays
what it is: abaplint compiles the include against the framework's interface,
and the abap2UI5-linter skips *.testclasses.abap, so the render gate sees
the app class alone. The rename step renames the include with the class (it
is in template.json's substitutions.class.files).
Agent skills
.claude/skills/ holds four SKILL.md files an agent loads when the task
matches their description: build-an-app (the checklist for writing an app
class, which points into this file), view-chain-layout (the seven layout
rules for a builder chain and the linter rule that checks them),
abap-check (ABAP that a green CI does not catch: abapGit round trip,
activation, extended check, runtime) and ui5-check (the same for the
view: names that do not exist on the oldest supported UI5 release, layout that
needs a newer one). They are mirrored from
abap2UI5/abap2UI5's .claude/skills/
by the template's generator, with a declared handful of sentences reworded
(npm run fmt:chains is npm run fix here, the guide is this file). Where a
skill says "this repository" or names a .github/scripts/ gate, it means the
framework's. Fix them upstream; a regeneration overwrites the copies (the
template regenerates both mirrors daily and opens a pull request when
abap2UI5 changed them; a project made from it takes newer text by copying
the files from the template's main).
Starting a project without this repository's own scaffolding: npm create
abap2ui5-app@latest my-app -- --class zcl_my_app writes the same files from
the template's main branch (see the template's README for the four ways).
Adding this setup to a project that did not start from the template: npm
create abap2ui5-app@latest -- --agent-setup in its directory writes the agent
files and the gates (template.json's agentSetup says which), skips every
file the project already has, and only adds to package.json and
.gitignore.
The rest of this file is the complete app-building reference — read it before writing or changing any app class.
Provenance: everything from "1. The model in one paragraph" down is a mirror of
docs/agents/building-apps.mdin abap2UI5/abap2UI5 — the copy exists so this repo needs no framework checkout. It is generated, in abap2UI5/app-template, from the guide plus a declared list of deviations, and carried there by a daily pull request whenever the guide changes. Fix it upstream (a CI gate there checks it against the real client API) rather than here. In your own project you may of course extend this file with your app's own rules — put those ABOVE this line, where a regeneration does not reach.Three sentences deviate on purpose, because the mirrored text names commands and files that exist in the framework repository and not here:
- §3 "Formatting the chain": the framework's
npm run fmt:chainsisnpm run fixhere (same command,abap2ui5lint --fix).- §3, same paragraph:
.github/abaplint/auto_abaplint_fix.jsoncis a file of the framework repository, so the sentence says whose it is. This repo has no autofix config — abaplint's formatting rules are simply not enabled inabaplint.jsonc.- §8 "Formatters":
npm run check:formatteris the framework's own gate, so the sentence says so.Those are not re-applied by hand. They are declared, and the template's generator and abap2UI5's own
npm run check:sharedapply the same declaration — one to write this half, one to check it — so a deviation whose upstream sentence was edited away fails both rather than being quietly skipped.
1. The model in one paragraph
An abap2UI5 app is one ABAP class implementing z2ui5_if_app. The
framework calls its main( client ) method on every HTTP roundtrip, and the
app dispatches on why it was called: it has to put its view on screen
(check_on_navigated, true on the first start and on every return into the
app), or the user interacted with it (check_on_event), or it is running for
the very first time and has one-time setup to do (check_on_init). The first
two are the dispatcher; the third is a branch an app only carries when it has
something to seed once. All three are arms of ONE IF/ELSEIF chain (§2).
The app builds a UI5 XML view as a string, binds ABAP attributes into it,
and registers named events. Between roundtrips the framework
serializes the app object into a draft table and restores it — every PUBLIC
attribute is persisted state and travels to the browser with the model, so
bound data goes in PUBLIC SECTION, everything else in PROTECTED SECTION.
There is no JavaScript, no OData service and no deployment step: activate the
class, call the ICF endpoint with ?app_start=zcl_my_app.
2. The canonical app template
CLASS zcl_my_app DEFINITION PUBLIC.
PUBLIC SECTION.
INTERFACES z2ui5_if_app.
" ONLY bound data here - PUBLIC attributes are serialized every roundtrip
TYPES: BEGIN OF ty_s_item,
product TYPE string,
quantity TYPE i,
END OF ty_s_item.
DATA t_items TYPE STANDARD TABLE OF ty_s_item WITH EMPTY KEY.
DATA name TYPE string.
PROTECTED SECTION.
DATA client TYPE REF TO z2ui5_if_client.
METHODS view_display.
METHODS on_event.
METHODS model_init.
PRIVATE SECTION.
ENDCLASS.
CLASS zcl_my_app IMPLEMENTATION.
METHOD z2ui5_if_app~main.
me->client = client.
IF client->check_on_init( ).
model_init( ).
view_display( ).
ELSEIF client->check_on_navigated( ).
view_display( ).
ELSEIF client->check_on_event( ).
on_event( ).
ENDIF.
ENDMETHOD.
METHOD view_display.
DATA(view) = z2ui5_cl_ui5_view_builder=>factory(
)->ele( n = `View` ns = `mvc`
)->a( n = `xmlns` v = `sap.m`
)->a( n = `xmlns:mvc` v = `sap.ui.core.mvc`
)->a( n = `displayBlock` v = `true`
)->a( n = `height` v = `100%`
)->ele( `Page`
)->a( n = `title` v = `My App`
)->tag( `Input`
)->a( n = `value` v = client->_bind( name )
)->ele( `List`
)->a( n = `items` v = client->_bind( t_items )
)->ele( `items`
)->tag( `StandardListItem`
)->a( n = `title` v = `{PRODUCT}`
)->a( n = `info` v = `{QUANTITY}`
)->end(
)->end(
)->tag( `Button`
)->a( n = `text` v = `Save`
)->a( n = `press` v = client->_event( `SAVE` ) ).
client->view_display( view->stringify( ) ).
ENDMETHOD.
METHOD on_event.
CASE client->get_event( ).
WHEN `SAVE`.
" bound data (name, t_items) already carries the user's input here,
" and the changed model is pushed back automatically
client->message_toast_display( |Saved, { name }| ).
ENDCASE.
ENDMETHOD.
METHOD model_init.
t_items = VALUE #( ( product = `Notebook` quantity = 2 )
( product = `Mouse` quantity = 5 ) ).
ENDMETHOD.
ENDCLASS.Conventions that keep apps uniform (proven in the samples-controls corpus):
main is a pure dispatcher; methods follow in call order with model_init
last; add model_init/on_event only when the app has data/events; always
dispatch events with CASE client->get_event( ). even for a single event.
The check_on_navigated( ) branch is part of the canonical dispatcher, not
an option: without it the view is only ever built on check_on_init, so
navigating away (nav_app_call) and back leaves the app blank — the
framework fires check_on_navigated, nothing re-displays. Always re-run
view_display( ) there.
The dispatcher is ONE IF/ELSEIF chain, so exactly one arm runs per
roundtrip — which is why the check_on_init( ) arm displays too.
check_on_init( ) being true implies check_on_navigated( ) is true: all
four ways an instance reaches its first main( ) set the flag
(factory_first_start for a fresh start and for a draft restore,
factory_system_startup, and prepare_app_stack for both nav_app_call and
nav_app_leave). On that first roundtrip the init arm wins and the ELSEIF
check_on_navigated( ) arm is skipped, so the init arm seeds AND displays, as
in the template above. What decides nothing is an init arm that ONLY displays:
IF check_on_init( ). view_display( ). ELSEIF check_on_navigated( ).
view_display( ). has two identical arms (the linter's redundant-init-display),
and IF check_on_init( ) OR check_on_navigated( ). is the same redundancy
spelled differently. An app with nothing to seed drops the init arm:
me->client = client.
IF client->check_on_navigated( ).
view_display( ).
ELSEIF client->check_on_event( ).
on_event( ).
ENDIF.Keep the check_on_init( ) arm where it earns its place — a model_init( )
call, or the one or two control-state flags an app seeds inline — followed by
the same view_display( ) the navigated arm makes.
- Do: one chain —
IF client->check_on_init( ).(seed, then display),ELSEIF client->check_on_navigated( ).(display),ELSEIF client->check_on_event( ).(dispatch), as in the template. - Don't: seed in an
IF client->check_on_init( ). … ENDIF.block of its own and display in a secondIF client->check_on_navigated( ).block, or nest one lifecycleIFinside another arm. The flags overlap — init implies navigated, and an app handed control back can arrive with an event too — so separate blocks run more than one of them on one roundtrip, and the order they happen to sit in becomes the behaviour. The linter reports every such pair asseparate-lifecycle-ifs, without an automatic fix for the seed-then-display pair: folding it into anELSEIFas it stands would leave the first screen empty. The one exempt shape is a guard block that leaves the method (… RETURN. ENDIF.).
Two more conventions, both about reading the source rather than running it:
A mock table is a table — line its columns up. In a
VALUE #( )with three or more rows and the same field list in each, pad every cell to the width of its column (the last one stays unpadded, so no spaces pile up before the closing)). Where a padded row would break the 255-character line limit, wrap it instead — at the same field boundaries in every row, so the columns still read down the page. Rows whose field list differs (an optional field, a nested child table) have no column to align and are left alone.t_items = VALUE #( ( title = `Fixed Item 1` icon = `sap-icon://employee` enabled = abap_true ) ( title = `Fixed Item 2` icon = `sap-icon://building` enabled = abap_true ) ( title = `Fixed Item 3` icon = `sap-icon://card` enabled = abap_true ) ).- A call that fits on one line goes on one line. Stacking parameters is for
calls that do not fit, not for calls that have two parameters:
client->popover_display( xml = popup->stringify( ) by_id = by_id ).is 71 characters and needs no second line. The view chain is the exception — it has its own layout rules (see theview-chain-layoutskill), and a wrappedt_arglist stays wrapped.
3. The view builder — z2ui5_cl_ui5_view_builder
The builder maps any UI5 XML view 1:1 — every control, property, aggregation and namespace, nothing is wrapped or approximated. Four verbs:
| Verb | XML meaning | Tree action |
ele( n ns ) | opening tag <X> | add child and descend into it |
tag( n ns ) | self-closing <X/> | add child, stay on current node |
end( ) | closing </X> | ascend to the parent |
a( n v ) / a( n b ) | one name="value" | attribute on the element the chain points at |
n= tag name,ns= namespace prefix (f,l,core,mvc, …); omitnsfor the default namespace (usuallysap.m). Declare every namespace asxmlns:*attributes on the rootView, exactly like XML.- Aggregations are just tags:
)->ele(items)for<items>. An aggregation carries the same prefix as in the XML —<m:content>under asap.ui.tabledefault namespace is)->ele( n =contentns =m). stringify( )renders from the root, so trailingend( )calls before the final).are optional —endonly moves the cursor to add siblings. That is also what lets the whole view hang off thefactory( )in one statement (see "Formatting the chain" below).- Booleans from ABAP variables go through
binstead ofv:)->a( n = `editable` b = mv_edit_mode )renderstrue/false(a literal is justv = `true`— never feedabap_trueintovraw, it serializes asX). - An element gets its attributes before its first child.
a( )lands on the element the chain is pointing at — the child just added byele( )ortag( ), or the node itself while it has none. Once an element has a child,a( )can no longer reach it. - Braces
{ }inside an ABAP|…|string template must be escaped\{ \}— an unescaped{is parsed as a UI5 binding. - Text from data goes through
t, notv. The same brace rule holds at runtime: the page runs with complex binding syntax, so a value that contains{/}— user input, an external payload — is parsed by UI5 as a binding (or expression) instead of shown as text, and can read arbitrary paths out of the view model. So a value that carries data is passed ast, which the builder escapes as a literal:)->a( n = `title` t = |Results for { mv_search }| ).vstays the form for everything with binding vocabulary (_bind, an event, a{/path}template, an expression) and for constant text, which needs no escaping — never pass a deliberate binding ast, escaping breaks it. Only the app knows which values are literals, which is whya( )does not decide it forv. When ONE attribute mixes text with a binding, escape the text part yourself withz2ui5_cl_ui5_view_builder=>escape_literal( )insidev. XML escaping is separate and always applied.
The legacy fluent builder z2ui5_cl_xml_view (one method per control) still
ships for existing apps but is frozen — new apps and new code use
z2ui5_cl_ui5_view_builder.
Formatting the chain (strict — reviewers check these)
A chain is read far more often than it is written, and its layout is the only
thing that still makes it legible as the XML tree it stands for. The rules
below are the ones the sample repositories are ported and reviewed against;
the framework repository's .github/abaplint/auto_abaplint_fix.jsonc excludes its shipped apps from
align_parameters / line_break_multiple_parameters so the auto-formatter
does not undo them.
They are machine-checked. The abap2UI5-linter's chain-house-layout rule
checks one call per line, four spaces per level and the closing call's column;
npm run check:abap2ui5 reports and npm run fix applies. It rewrites
whitespace between chain segments only and verifies that collapsing every run
of code-whitespace leaves the file identical, so a layout fix can never change
what the view builds. The blank-line rules below stay reviewer-enforced.
The rule is opt-in in the linter, because it encodes one house style — this
one — and is named in abap2ui5lint.jsonc rather than inherited. It is what
the linter's two older layout rules cannot reach: they judge a chain against
ITSELF, so a chain whose every level is uniformly wrong keeps its own rhythm
and passes.
The
factory( )shape follows the chain shape, and there are two. The variable has to denote the node you will attach to next:A view split into a statement per subtree (the shape of the apps here and of
abap2UI5/samples) hangs the chain off thefactory( )— one statement, not two — so the variable holds themvc:Viewand a laterview->ele(Shell… )lands inside it:DATA(view) = z2ui5_cl_ui5_view_builder=>factory( )->ele( n = `View` ns = `mvc` )->a( n = `xmlns` v = `sap.m` )->a( n = `xmlns:mvc` v = `sap.ui.core.mvc` ).- A view built as one chain (the shape of
abap2UI5/samples-controls, where a 1:1 port mirrors one original XML file) may take thefactory( ).as a statement of its own, because nothing attaches to the variable afterwards —stringify( )renders from the root either way. It buys two levels of indent back across the whole view, which is worth having in a deeply nested one.
What is never right is the standalone
factory( ).with the split shape: the variable then holds the root, andview->ele(Shell)adds a second root next to themvc:Viewinstead of a child inside it.- The closing paren rides with the arrow. Never leave a
)alone at the end of a line — carry it to the start of the next segment, so every continuation reads)->. With thea( )chain there is no nestedVALUE, so the whole view ends in a single).(not) ).). - Indent after every
ele. Eachele( )shifts its children's)->one level (4 spaces) to the right,end( )shifts back left, and the)->of anendsits in the same column as theeleit closes. - The indent states the depth — 4 spaces, never 2. The column a line starts in is the only place the reader can see where in the tree they are, so it has to be true: same depth, same column; one level deeper, exactly four more. A step of two, or a line that changes depth and leaves the next line in the old column, and the layout is no longer describing the XML — it is decorating it.
- One call per line — no exceptions. Every
ele( ),tag( ),a( )andend( )opens its own line with)->. A control does not share its line with its own attributes ()->tag( `Label` )->a( n = `text` …), and a container does not hand straight over to the next (view->ele( `Shell` )->ele( `Page`). Both used to be allowed here as "they hide nothing", and both were dropped when the two sample corpora were unified: the exception is what the worst shape grows out of.)->end( )->end( )->ele( `footer` )->ele( `OverflowToolbar`at the end of an attribute line — up two, down two — is four level changes nobody can follow, after which every line starts in a column that means nothing. With one call per line there is no line for it to hide on. This is also the abap2UI5-linter'schain-element-per-line. - Come back to a node through a variable, not through a run of
end( )s.end( )is for closing what you are finished with, one level, at the start of its own line. When you need a node again — a page you hang three sections off, afooterbeside a form — keep it:DATA(page) = view->ele( `Shell` )->ele( `Page` … ), thenpage->ele( … )per subtree, each its own statement, each starting at the method's indent. Every app in the framework and inabap2UI5/samplesis built this way, and it is what keeps the rule above satisfiable: a statement that never unwinds can indent monotonically. A subtree deep enough that its lines drift past the right-hand side of the screen is telling you the same thing — split it. - A control's
a( )lines sit one level (4 spaces) in from the control's own)->line — one attribute per line,v =column aligned across the block. A blank line opens a block — nothing else in the chain gets one. A block is a run of lines that is read as one thing, and there are exactly two of them:
- the content of a control that carries attributes — a blank after its
last
a( ), before its first child. The attribute lines are the control's own head; the content starts below them; - a run of
tags — a blank before the first one, none between them. A form's fields, a toolbar's buttons, a list's items belong together and are read as one block, whether or not each carries attributes.
Everything else runs without a blank:
- none between a control and its own
a( )s — they are its head, not its content; - none between consecutive
tags — see block 2. This overrules "separate a sibling": only a container block is set off from the sibling before it; - none after a bare
elewhose first child is anotherele— aShell→Pagescaffold is one path down, not two blocks; - blank before every
end; none after anendor between twoends.
- the content of a control that carries attributes — a blank after its
last
- Long text or binding values split with
&&— an.abapline is capped at 255 characters.
The two blocks, and the scaffold that is neither:
)->ele( `Shell` " bare ele into another ele -
)->ele( `Page` " one path down, no blank
)->a( n = `title` v = `My App`
" block 1: Page's content starts
)->ele( n = `SimpleForm` ns = `form`
)->a( n = `editable` v = `true`
" block 1: SimpleForm's content
)->ele( n = `content` ns = `form`
" block 2: the run of leafs
)->tag( n = `Title` ns = `core`
)->a( n = `text` v = `Your data`
)->tag( `Label`
)->a( n = `text` v = `Name`
)->tag( `Input`
)->a( n = `value` v = client->_bind( name ) ).The blank above Title is what makes the three fields read as the form's
content rather than as a continuation of the content aggregation; the
missing blanks between them are what keep the three together. Blanks in both
places, or in neither, and the same view reads as a pile of fragments.
The blank lines are the part no gate catches. abaplint's formatting rules
are deliberately kept off the app chains (align_parameters and
line_break_multiple_parameters are excluded in the framework repository's
.github/abaplint/auto_abaplint_fix.jsonc, and in_statement_indentation
is excluded for the shipped apps — they would
flatten exactly the layout this section builds), and no linter rule judges
where a blank line belongs. That stays with the reader.
The shape around them is judged. The abap2UI5-linter has three layout rules:
chain-indentation (a sibling in a different column than its siblings, a call
written left of the element it belongs to; the step size is not judged, only
that a chain keeps its own rhythm), chain-element-per-line (several controls
on one line; attributes may share their control's line, and so may the
container it opens), and chain-house-layout, which judges the step size too
and is the one this section describes.
All three default to hint, which a config with failOn: warning does not
even print — so what decides is the repository you are in, and every repository
in this ecosystem raises them. abap2UI5, abap2UI5/samples-stack and
abap2UI5/app-template enable chain-house-layout at warning;
abap2UI5/samples raises chain-indentation and chain-element-per-line
instead; abap2UI5/samples-controls runs its own chain-format gate. Check
the config before you trust a green run — and either way, re-read the chain you
just wrote against the example above.
What a broken chain looks like
From abap2UI5/samples z2ui5_cl_smp_app_052, after the port to this builder
— lint-green, and unreadable:
lo_popover->ele( `Popover`
)->a( n = `title` v = |abap2UI5 - Popover - { mv_product }|
)->a( n = `contentWidth` v = `20rem` )->ele( n = `SimpleForm` ns = `form`
)->a( n = `layout` v = `ColumnLayout`
)->a( n = `editable` b = abap_false )->ele( n = `content` ns = `form` )->tag( `Label`
)->a( n = `text` v = `Product` )->tag( `Text`
)->a( n = `text` v = mv_product )->tag( `Text`
)->a( n = `text` v = `this is a text` )->end( )->end( )->ele( `footer` )->ele( `OverflowToolbar`
)->tag( `ToolbarSpacer` )->tag( `Button`
)->a( n = `press` v = client->_event( `BUTTON_DETAILS` ) ).Three defects, and they compound: the indent steps by 2 and then by 4 and then
stops moving at all; one line opens content and a Label; and the
)->end( )->end( )->ele( `footer` )->ele( `OverflowToolbar` at the end of
an attribute line leaves the ToolbarSpacer two levels away from where its
column claims it is. Nothing here says footer is a sibling of SimpleForm.
The same tree, with the subtree held in a variable:
DATA(popover) = lo_popover->ele( `Popover`
)->a( n = `title` v = |abap2UI5 - Popover - { mv_product }|
)->a( n = `contentWidth` v = `20rem` ).
popover->ele( n = `SimpleForm` ns = `form`
)->a( n = `layout` v = `ColumnLayout`
)->a( n = `editable` b = abap_false
)->ele( n = `content` ns = `form`
)->tag( `Label` )->a( n = `text` v = `Product`
)->tag( `Text` )->a( n = `text` v = mv_product
)->tag( `Text` )->a( n = `text` v = `this is a text` ).
popover->ele( `footer` )->ele( `OverflowToolbar`
)->tag( `ToolbarSpacer`
)->tag( `Button`
)->a( n = `press` v = client->_event( `BUTTON_DETAILS` )
)->a( n = `text` v = `details` ).4. Data binding
client->_bind( var )binds a PUBLIC attribute: the value renders into the view, and user input is written back into the attribute before your event handler runs. Use it for everything, display-only included (_bind_editis an obsolete alias — do not use it in new code).- Inside a bound aggregation (a table/list template), child properties bind
relative to the row with braces on the upper-cased field name:
`{PRODUCT}`. A camelCase ABAP fieldsupplierNamebecomes{SUPPLIERNAME}. - A value shared by the whole app lives at the model root — inside a row
template a relative
{FIELD}resolves against the row and silently renders empty if the row has no such column; bind root values withclient->_bind( field )even inside templates. - Never hard-code a model path as text (
'/T_ITEMS'): paths come from_bind, so renames cannot break bindings. For raw binding-info strings (typed bindings, sorters) get the bare path viaclient->_bind( val = t_items path = abap_true ), e.g.v = |\{ path: '{ client->_bind( val = t_items path = abap_true ) }', sorter: \{ path: 'PRODUCT' \} \}|. - Typed/complex bindings pass through verbatim (braces escaped):
v = |\{ path: 'PRICE', type: 'sap.ui.model.type.Float' \}|— note thepath:uses the upper-cased ABAP field name. - Type numeric/boolean model fields as
i/p/abap_bool, notstring— UI5 2.x rejects a JSON string on a float/int/boolean control property. - An ABAP field is never absent — it is initial — so by default every field
reaches the browser as an explicit
""/0, which overrides the control's own default (and an enum-typed property rejects the empty string outright). When a bound template's rows fill different subsets of the same properties, bind withclient->_bind( val = t_rows omit_initial = abap_true ): initial fields then stay out of the model and each control keeps its UI5 default. A boolean that must sendfalseis the exception —abap_falseis itself initial, so the blanket flag would drop it and the control would fall back to its defaulttrue. Scope the omission instead:client->_bind( val = t_rows omit_initial_paths = VALUE #( (MIN) (MAX) ) )lists the columns that may vanish and leaves every other field alone. - A control property that expects an object — a
sap.ui.integrationwidgets:Cardmanifestis the case this exists for — cannot be fed from a typed ABAP value at all: a manifest's keys (sap.app,sap.card) are not valid ABAP field names, so it is no structure, and UI5 reads a string manifest as a manifest URL. Keep the JSON in astringattribute and bind it withclient->_bind( val = manifest json = abap_true )— the serializer then splices its content into the model as a JSON node instead of a quoted string. Outbound only (the client renders such a payload, it never authors it), and a string that does not parse raises rather than shipping broken JSON. Where the sample keeps one manifest per file, bind its URL instead — that path needs no flag. - A large table — more rows than the screen shows — binds with
client->_bind_tab( val = t_rows window = 200 )instead of_bind( ): the rows stay in the app, the browser gets 200 of them, and the list binding asks the framework for the next window when a growingsap.m.Table/Listgrows or asap.ui.table.Tablescrolls — a roundtrip of the framework's own that does not callmain( ). Sorting and filtering the binding (the column menu ofsap.ui.tableviasortProperty/filterProperty, acs_event-binding_callsort or filter, e.g. from aSearchField) run on the server over all rows; a checkbox ticked or a cell edited lands in the table's own row whatever the order shown. The app sets the order and the filter itself witht_sort/t_filter(typesty_t_bind_tab_sort/_filterofz2ui5_if_client); they take effect when they differ from the ones passed before, so a view rendered again keeps what the user sorted in the browser.valmay also be a data reference to the table (REF #( ), an attributeTYPE REF TO data). The number of rows the filter lets through is/__WINDOW/<path>/TOTALin the model — build it fromclient->_bind_path( t_rows ). Only a STANDARD table, bound whole; sorters and filters written into the binding info are not applied. An event that hands over a row's binding path (getBindingContext().getPath()→/T_ROWS/5) names a position in the order the browser shows, not a row of the table:client->_bind_tab_row( val = t_rows row = 5 + 1 )answers the row oft_rowsit is. - Bound data changed in an event handler is pushed automatically — the
framework compares the model before and after
main( )on every event round-trip and, when it differs, sends it to every open view slot (MAIN, POPUP, POPOVER; the nested views inherit MAIN's model). A handler cannot render stale by forgetting anything: there is nothing to call and nothing to switch on. Callview_displayagain only when the view structure itself changes. view_model_update( ),popup_model_update( ),popover_model_update( )and the two nested variants are obsolete and do nothing. They stay in the interface so existing apps keep compiling; delete the calls whenever you touch such an app, and never add a new one.
5. Events
- Wire:
)->a( n =pressv = client->_event(SAVE) ). - Read in
on_eventviaCASE client->get_event( ).. - Pass values into an event with
t_arg; read them back withclient->get_event_arg( )(index only for position 2+). A value resolved on the client must be$-prefixed —`${PRODUCT}`(row field),`$event.oSource.sId`(the pressed control's id),`${$parameters>/selected}`(an event parameter). A bare{…}arg is NOT resolved and arrives empty. Booleans arrive asabap_bool(X/space). - A control-valued event parameter arrives as JSON. Several UI5 events
hand over a control or a whole array of controls rather than a scalar —
ViewSettingsDialog.confirmgivesfilterItems,SinglePlanningCalendar.selectedDatesChangea list ofDateRange. Pass the parameter like any other (`${$parameters>/filterItems}`); the frontend marshals each control into an object carrying itsIDplus its public properties, soclient->get_event_arg( )returns a JSON array. Read the one property you need out of it BY HAND, per the bullet below — not withz2ui5_cl_ajson, which is framework-internal. Do not parse a display string such asfilterString— it is localized and its format is not a contract. Read only the fields you need, and never expect the payload to be the shape you modelled: it carries every public property of the control — aViewSettingsItemalso bringsenabled,textDirectionandwrapping— so a reader that walks for one named key is both the smallest and the most robust thing to write. Two properties on the same object need two walks, which is still less code than a mapping that has to anticipate the rest. Not every such payload is flat.projectValueinapp/webapp/core/Lib.jspasses an object- or array-valued property through unchanged (up toMAX_ARG_DEPTH), so a control with a property typedobject/anymarshals as a nested node. When that is what you are looking at, do not grow the reader: bind the value into the model instead and let the framework's own write-back fill the ABAP structure —whole_value_applyinz2ui5_cl_ui5_srv_modelconverts a whole object or array withto_abap( iv_corresponding = abap_true ), which is the corresponding-only mapping this used to reach ajson for, already written and already released. - JSON is built and read BY HAND, and there is no released parser.
z2ui5_cl_ui5_jsonwas one for two weeks and was removed on 2026-09-14, before it had shipped in any release — nothing downstream lost it, and nothing has to be migrated. Do not reach for a substitute:/ui2/cl_jsonis not released for ABAP Cloud,xco_cp_jsonis missing on 7.02, and the vendored ajson is framework-internal, which the linter'snon-released-apirule reports — correctly.- Outbound (a control property that must receive an OBJECT, e.g. a
sap.ui.integrationCard manifest): compose the JSON as a string in ABAP and bind it with_bind( val = … json = abap_true ), which splices it in as a model node instead of a quoted string. - Inbound (an event argument that arrives as JSON): write the few
lines that read the one field you need. Where the payload is written by
the framework and flat, a targeted
find/substring_beforewalk is the whole job —z2ui5_cl_smp_app_197(json_get_values, one property across an array of objects) andz2ui5_cl_smp_app_327(json_get_value, one field of a flat object) inabap2UI5/samplesare the pattern to copy. Two cases need more than that, and both have an answer that is not a bigger parser:- The payload is NESTED (see the control-parameter bullet above): bind the value instead of parsing the event argument. A two-way bound attribute is filled by the framework's own write-back, corresponding fields only, with no app-side parsing at all.
- The app WROTE the payload itself — publishing into an AMC channel,
writing browser storage — so it carries free user input. Then escaping
is the job: a quote the user typed reaches the reader as
\", and asubstring_beforeon the next quote ends the value early. Escape on the way out and WALK the value on the way in, resolving escapes;z2ui5_cl_smps_app_489inabap2UI5/samples-stack(json_build,json_escape,json_get_string) is that case written out. The walk also makes the key search safe: text containing","author":"cannot forge a sibling field, because its quotes are escaped and no longer match the marker. An app parsing genuinely arbitrary, nested JSON is doing something this framework does not hand it a tool for.
- Outbound (a control property that must receive an OBJECT, e.g. a
- A dynamic type names a type the SYSTEM has, not one abap2UI5 ships.
CREATE DATA … TYPE STANDARD TABLE OF (name)needs a name that exists at runtime, and the name belongs to the app's own domain — a DDIC structure of the customer's, a CDS view, whatever the app is browsing. The framework releasedz2ui5_t_02(aname/valuepair) as an anchor for exactly this and removed it again on 2026-09-22: in a year not one sample, and no app anybody reported, ever named it. abap2UI5 adds no dictionary object an app is supposed to point at (§ No new dictionary objects inAGENTS.md), and the framework's own tables (z2ui5_t_01) are internals that may change — naming one from an app is borrowing a table, not using an API. - The per-wire flags (
s_ctrl, typety_s_event_control). By default an event fired while a roundtrip is in flight is DROPPED — right for a click, wrong for a per-keystroke wire (liveChange,liveSearch,sliderChange), where it loses every keystroke typed under the running roundtrip, the last one included, and leaves the backend at the value of the last completed roundtrip while the control shows the current one.check_queue_last = abap_truekeeps the LAST event fired on the wire and dispatches it once the response has landed — one roundtrip in flight at a time, order preserved, the backend ends on the control's current value; no debounce, so a pause still costs one roundtrip. Pair it withcheck_no_busy = abap_true, which keeps the full-screen busy overlay down for that wire — without it every keystroke landing on a roundtrip in flight raises the overlay at once, over the very field being typed into (the roundtrip, the busy STATE and the guard are unchanged, only the overlay is not shown). There is no flag for sending every firing at once: only the newest response may commit, so the earlier roundtrips would be work thrown away. A background wire that must not wait — a timer tick, a poll — needs no queue flag either:START_TIMERcarries its own slot and waits out the roundtrip in flight (core/actions/ViewOps.js,evStartTimer). What it does share with a click is the overlay: a tick raises it after the usual one-second delay, so a poll whose backend call takes longer flashes it over a screen nobody is waiting on. An optional thirdt_arg,`X`—abap_trueast_argspells every flag — is the tick'scheck_no_busy:client->follow_up_action( val = z2ui5_if_client=>cs_event-start_timer t_arg = VALUE #( ( `TICK` ) ( `5000` ) ( `X` ) ) ). Like the flag, it takes the overlay down and nothing else — a click that lands while the tick is in flight is still dropped, and still raises it.check_prevent_default/prevent_default_exprcancel the control's built-in default before the roundtrip,check_arg_literalquotes every argument — see the doc onz2ui5_if_client=>_event. - Roundtrip-free client actions:
client->follow_up_action( val = … t_arg = … )written where its RESULT is consumed — in a view attribute — runs a whitelisted frontend action without a server call (toast from a row value, client-side sort/filter viabinding_call, …).client->_event_client( )is the obsolete name for exactly this and emits the identical wire. - After the response,
client->follow_up_action( val = … t_arg = … )as a STATEMENT schedules a frontend action: focus (cs_event-set_focus), scroll, title, clipboard, timers, keyboard shortcuts,control_by_id(call a public method on a control),binding_call(filter/sort an aggregation binding). The full catalog with per-actiont_argdocumentation sits onz2ui5_if_client=>follow_up_actionand incs_event.
6. Popups, popovers, messages
client->popup_display( val = … )opens acore:FragmentDefinitionstring (build it withz2ui5_cl_ui5_view_buildertoo) as a dialog. Closing: wire a Close button toclient->follow_up_action( val = z2ui5_if_client=>cs_event-popup_close )(round-trip free - the frontend tears the popup slot down without reaching yourmain), or callclient->popup_destroy( )server-side after handling your own event. Not_event( cs_event-popup_close ): that is a backend event namedPOPUP_CLOSElike any other, it reachesmain( )and closes nothing by itself.client->popover_display( xml = … by_id = … )anchors a popover to a control id. Mind the asymmetry: the popup takes its XML asval, the popover asxml— one of the most common first-try mistakes.client->message_toast_display( text )andclient->message_box_display( text type title actions … )for messages — never build your own toast/dialog for these.message_box_display( text )takes anything.textisTYPE any, so throw in what you have: a string, a BAPIRET2 / T100 / RAP /symsgstructure or table, a log object, an exception, an HTML string, a business table, a nested structure or tree, an object, a number. Messages are recognized first — they set the box's severity and title themselves and several of them collapse into one box with a bullet list. Anything else is rendered generically: a headline in the box (Table with 12 entries), the data itself in the details — which abap2UI5 shows unfolded, so nothing of it hides behind sap.m.MessageBox's "Show details" link. You do not pre-format anything and you do not branch on what you have. The one case that shows no box at all is complex data that is initial, somessage_box_display( lt_return )after a call that returned nothing stays silent, as it always has. The sampleZ2UI5_CL_SMP_APP_502in abap2UI5/samples walks every one of those cases with one button each.- A pure UI5 option is not a parameter of those two methods. They carry
what an ABAP app decides — the data, the kind of box, the buttons as a
table, the backend event raised when it closes. Where a toast docks, how
wide a box is, which icon it carries, what it is a dependent of: that is
the control, and it is set through the whitelisted global call, whose last
argument is the
sap.m.MessageToast.show( )/sap.m.MessageBox.<type>( )option object 1:1 (the display method IS the box type there, and at_argstarting with a brace travels as real JSON):client->follow_up_action( val = z2ui5_if_client=>cs_event-control_global t_arg = VALUE #( (MESSAGE_BOX) (error) (Not saved.) ({"contentWidth":"30rem","icon":"WARNING"}) ) ).Z2UI5_CL_SMP_APP_381andZ2UI5_CL_SMP_APP_512are the two samples that show that half. Wired into a view the same call runs without a round-trip at all, and extra arguments fill{0},{1}placeholders in the text. - Bind popup data exactly like main-view data; changed bound data reaches an open popup automatically.
- A popup or popover is closed automatically by anything that replaces the
screen underneath it: a
client->view_display( )in the same roundtrip and a navigation to another app both take it down. Re-open it in that same roundtrip if it is meant to stay —view_display( )first, thenpopup_display( )— and callpopup_destroy( )only when you close it without rebuilding the view.
7. Multi-view, navigation, routing
- One app can render
main, two nested views, a popup and a popover simultaneously (cs_viewnames them for scoped actions). - Call another app:
client->nav_app_call( NEW zcl_other_app( ) ); return withclient->nav_app_leave( )(orclient->get_app_prev( )to hand data back). The framework keeps the app stack across roundtrips. - URL routing:
client->follow_up_action( val = z2ui5_if_client=>cs_event-hash_routing )incheck_on_initmakes the app bookmarkable and wires the browser Back/Forward buttons — the mode rides int_arg:cs_nav_mode-keep(the default whent_argis empty) restores the exact draft state,cs_nav_mode-freshrestarts clean. Works inside the Fiori Launchpad. - The URL hash and the app state have named APIs of their own:
client->hash_set( val )writesvalas the app's URL hash with a pushed history entry (the UI5 router'snavTo),client->hash_replace( val )writes it without one (navTo( ..., true )— e.g. a layout arrow whose drags must not become Back steps).client->app_state_set_active( )keeps the id of the CURRENT app state in the URL on every roundtrip, so a reload/bookmark/shared link restores the exact state;client->app_state_get_href( )returns the absolute, FLP-safe link to that state for the app to copy, show, mail or render as a QR code. The matchingcs_eventconstants forfollow_up_action( )wires:hash_set/hash_replace(the same two writes as frontend actions),hash_back(one history step back with an optional fallback hash),hash_attach_changed(registers a backend event that fires when the hash changes under the app — Back/Forward, a manual edit; app-owned routing withhash_routingoff),hash_routing(the routing modes above) andapp_state_set_active.hash_routing,hash_attach_changedandapp_state_set_activeare mutually exclusive — each claims the whole app hash.
8. Rules that keep apps portable
- Thin frontend: business logic (thresholds, classification, unit
conversion, validation) is computed in ABAP and bound as a finished value —
never in a frontend formatter or custom JS. The shipped
z2ui5/model/formatterhelpers are a marshalling layer, not a formatting toolbox: a function only lives there when ABAP physically cannot produce the finished value — a real JSDatefor an object-typed property (DateCreateObject,DateAbapDateToDateObject,DateAbapDateTimeToDateObject) or an icon-font glyph of the loaded theme (expandInlineIcons). That is the whole set. Rounding a number, joining fields into a string or mapping a status to aValueState/icon is your app's job: compute it in ABAP and bind it (state="{STATUS_STATE}"). Functions that did those things were shipped once and removed again — the module header states the admission criteria and the framework's owncheck:formattergate enforces them there. - UI5 1.71 floor: the framework supports OpenUI5 down to 1.71. Before using a control/property/aggregation, check its "available since" in the UI5 API — post-1.71 members need a UI5 release that has them; a wrong aggregation name 404s the whole view on old releases.
- State discipline: every PUBLIC attribute is serialized into the draft and sent to the browser on every render. Large helper data, catalogs and anything not bound belongs in PROTECTED (or is re-read per request) — a bloated public section makes every click slower.
Authorization is the app's job: any user who can reach the ICF node can start any
z2ui5_if_appclass. The framework performs noAUTHORITY-CHECKof its own, on purpose (security model). Check your authorizations at the top ofmain, before any dispatch, and render an error when denied - the same place a transaction or a report does it. The shipped Hello World has no check because it guards nothing; an app that reads or writes business data starts like this:METHOD z2ui5_if_app~main. AUTHORITY-CHECK OBJECT 'Z_MY_APP' ID 'ACTVT' FIELD '03'. IF sy-subrc <> 0. client->message_box_display( text = `You are not authorized to use this app` type = `error` ). RETURN. ENDIF. me->client = client. IF client->check_on_init( ). ...The check runs on every roundtrip, which is what you want: a revoked authorization takes effect on the next click, not on the next cold start.
- The default UI5 bootstrap loads from the CDN; system-local hosting and
CSP/theme/bootstrap customizing go through
z2ui5_if_ui5_exit/z2ui5_cl_ui5_user_exit. - A third-party JS library is a deployment decision, not a view trick.
The default CSP in
z2ui5_cl_ui5_user_exitallows script only from the UI5 CDN hosts (ui5.sap.com,sapui5.hana.ondemand.com,openui5.hana.ondemand.com,sdk.openui5.org) — general-purpose CDNs such ascdn.jsdelivr.netandcdnjs.cloudflare.comare deliberately NOT on it. Every other host needs your owncontent_security_policyin the exit, and a system-local copy served by your own ICF node is the option that survives an offline system. Whichever you pick, load the library through the UI5 loader (sap.ui.loader.configpath mapping +sap.ui.define) and drive it from a custom control; do not rely on the jQuery$global — UI5 2.x no longer ships it — and keep the library call itself free of business decisions (see "thin frontend" above). - An imperative UI5 method that has no bindable equivalent — the
sap.m.p13n.*panels take their item list only throughsetP13nData( ), asap.m.Carouselonly moves viasetActivePage( )— is reached withclient->follow_up_action( val = cs_event-control_by_id … ). Checkcs_eventand the whitelist before writing custom JS: an argument the whitelist does not declare is silently dropped, but a method it declares needs no JS at all.
9. Validate and iterate like the framework does
- Starting a new app repo? Use abap2UI5/app-template — it ships the starter app, abaplint + linter CI, this guide as its AGENTS.md and the agent permission setup preconfigured.
- abap2UI5-linter checks
app classes statically (unknown/deprecated/too-new controls and members,
binding mistakes, builder-tree defects) and can render the view headless:
npx --yes @abap2ui5/linter my_app.clas.abap— also available as a GitHub Action and inside the VS Code extension. - mcp-server gives MCP-capable agents
the full loop without a SAP system:
capabilities→deploy_app→build_backend→run_app(headless screenshot + errors). - vscode-extension: F9 launches the class in an embedded preview against a real system.
- Worked examples, three catalogues with the same row shape, so one search
reads all of them: curated apps for "has somebody built this pattern" in
abap2UI5/samples, 622 gate-verified
demo-kit ports for "how is this control expressed" in
samples-controls (
src/), and apps that need something from your stack — OData, RAP, APC, the launchpad — in samples-stack. What abap2UI5 can express at all is answered in samples-controls'CAPABILITIES.md, each claim naming the port that proves it. - Unit-test the app class without a system: a local test double
ltd_clientwithINTERFACES z2ui5_if_client PARTIALLY IMPLEMENTED.in the class's.clas.testclasses.abapanswerscheck_on_init/check_on_event/get_eventfrom attributes and records whatview_display( )andmessage_toast_display( )receive; a test then callsmain( )and asserts. abap2UI5/app-template ships one for its starter class — abaplint checks it statically, ABAP Unit runs it on the system, and the MCP server'srun_unit_testsruns it in the transpiled backend;npm run test:unitin the project and theabap2UI5/mcp-serverGitHub Action do the same at the terminal and in CI, no system involved. Implement every method the app calls in the double: the transpiled runtime generates no empty stubs for aPARTIALLY IMPLEMENTEDinterface, a system does. npm run doctorin a project made from app-template: the environment check — Node, the two gates, Chromium for the render gate, the framework pin, the sidecars — that names the remedy for each failure.npm create abap2ui5-app@latest my-app -- --class zcl_my_app: the project scaffold without a GitHub template button or an editor.interact_appin the MCP server: click, type and fire events in the headless app and look at the result — the event branch, not just the boot.
10. The in-app developer tools (Ctrl+F12)
Every abap2UI5 page carries its own developer tools: a dialog over the running
app that shows the roundtrips, the view XML and the model, the log and the
environment, without the browser's devtools. They are part of the frontend
(app/webapp/devtools/), so they are there on every system and in every
app, with nothing to install and nothing to configure.
Starting them
| How | What it does |
| Ctrl+F12 in the running app | Opens the dialog; Ctrl+F12 again closes it. Without a tab named, it reopens where you left off (remembered per browser tab in sessionStorage) |
?z2ui5-devtools=1 on the page URL | Opens the tools as soon as the page has booted, on the Overview. For a problem that happens during startup, where Ctrl+F12 comes too late. Example: https://<host>/sap/bc/z2ui5?sap-client=100&z2ui5-devtools=1 |
?z2ui5-devtools=<KEY> | Opens them directly on one view, by its key. Group keys: OVERVIEW, PROBLEMS, ROUNDTRIPS, VIEWDATA, SYSTEM, SEARCH. View keys: ERROR, LOG, HISTORY, REQUEST, PLAIN, ACTIONS, DIFF, VIEWDIFF, VIEW, MODEL, BINDINGS, POPUP, POPUP_MODEL, POPUP_BINDINGS, POPOVER, POPOVER_MODEL, POPOVER_BINDINGS, NEST1, NEST2, JOURNEY. Example: ?z2ui5-devtools=HISTORY |
| "Open on Error" switch on the Overview tab | Once on, the tools pop open on the Log as soon as anything logs at error level: a backend error, an uncaught exception, an unhandled rejection, a binding problem. Off by default, remembered per browser tab |
From the start app (z2ui5_cl_ui5_app_start) | The System information popup covers the backend (settings, user exit, drafts); its text points to Ctrl+F12 for the frontend side (UI5 version, theme, device, requests, logs) |
The roundtrip history and the console capture record from the first
roundtrip on, whether the dialog was ever opened or not: opening the tools
after the problem still shows what happened before. The dialog itself and
the inspectors arrive on the first Ctrl+F12 as a second bundle
(GET <page url>&z2ui5-bundle=devtools, cached by ETag), so a plain app
start does not pay for them. There is no backend switch and no ABAP call to
open them; they are a frontend affair.
What you find there
- Overview: which app, which roundtrip, is anything broken, and where to go next. The landing tab, with the "Open on Error" switch.
- Problems: the last fatal error (with Retry / Restart / Logout) and one
Log timeline of everything: the framework's error log with stack traces,
UI5's log, uncaught errors, rejections, every
console.*call and the messages the user was shown. - Roundtrips: every roundtrip with backend vs. render time, payload sizes
and draft ids, the raw request and response JSON, the follow-up actions and
a diff of the model between two roundtrips. The Journey view records a
click-through: press "Record Journey", use the app, press it again, and
"Export ABAP Unit" hands over a local test class that replays the steps on
z2ui5_cl_frontend_simulator(headless-frontend)set_value( )for what was typed,click( )for every event, assertions on the messages, layers, control ids and events the screens showed. Paste it into the app's test include; record from the app start, because the test starts the app fresh.
- View & Data: the XML of the main view, the popup, the popover and the nested views as the backend sent it, the model that fills each, and the bindings with a missing-path check and a size ranking. The picker marks a control on screen and jumps to its XML.
- System: UI5 version, theme, device, the running app's ABAP class.
- Search: one term across every other tab at once.
- Report a Bug (toolbar): puts the whole session state on the clipboard as a GitHub-ready issue body (environment, error, log, roundtrip history, the app's class, each in a collapsed section). Paste it into an issue as it is.
- The (i) button opens the built-in help with the same keys and shortcuts.
When they do not open
- The page runs under the shipped CSP; the devtools bundle is a same-origin
file and needs nothing beyond
script-src 'self'. A CSP of your own that drops'self'blocks it, and the Log of the browser's console names the blocked URL. - On a Fiori launchpad or a BSP page the dialog's modules are asked from the page's own loader first and from the bundle second; a 404 in between is expected and harmless.
- An embedded component (
@abap2ui5/embed-control,?z2ui5-bundle) has the tools too, loaded from its own backend endpoint, but without the page-wide console capture: the host's page is not the app's.
11. Calling a language model
z2ui5_if_ui5_llm is the one seam: chat( system t_message s_options )
takes a system prompt, the conversation (role user/assistant plus
content) and model/max_tokens/temperature, and answers text,
stop_reason and s_usage; every failure is z2ui5_cx_ui5_llm (get_text( )
for the user, retryable for a 408/429/5xx or a timeout). Get a provider from
z2ui5_cl_ui5_llm: create_claude( ) (Messages API), create_openai( )
(Chat Completions - OpenAI, Azure with auth_header set to api-key, Ollama,
LM Studio, gateways) or create_local( t_rule = … ), a deterministic
rule-based provider without HTTP for 7.02, unit tests and demos.
DATA(li_llm) = z2ui5_cl_ui5_llm=>create_claude( model = ls_cfg-model
destination = ls_cfg-destination ).
DATA(ls_answer) = li_llm->chat( system = `Answer briefly.`
t_message = mt_chat ).- No key, model or endpoint in the class. Read them from your own configuration and pass a destination (SM59 on Standard, a communication arrangement or BTP destination on Cloud) or a URL plus key; there is no default model.
- Create the provider in the method that calls it - never keep it in an
attribute, the app instance is serialized every roundtrip. Keep the
conversation (
mt_chat) as the attribute instead, and call in an event branch: the user waits for the whole answer.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

