agentleFS
abap2UI5abap2UI5/app-template/AGENTS.md

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.

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
  1. Very specific:

    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" …

    line 45
  2. Very specific:

    Run them on the system with ABAP Unit (ADT Ctrl+Shift+F10, or SE24) - or

    line 108
  3. Very specific:

    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 — …

    line 330
  4. Very specific:

    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 …

    line 469
  5. Very specific:

    One call per line — no exceptions: Every ele( ), tag( ), a( ) and end( ) opens its own line with )->.

    line 482
  6. Very specific:

    none: after a bare ele whose first child is another ele — a Shell → Page scaffold is one path down, not two blocks.

    line 523
  7. Very specific:

    When a bound template's rows fill different subsets of the same properties, bind with client->_bind( val = t_rows omit_initial = abap_true …

    line 651
  8. Very specific:

    Read in on_event via CASE client->get_event( )..

    line 707
  9. Very specific:

    NOT resolved and arrives empty.

    line 713
  10. Very specific:

    After the response, client->follow_up_action( val = … t_arg = … ) as a STATEMENT schedules a frontend action: focus (cs_event-set_focus), …

    line 820
  11. Very specific:

    Call another app: client->nav_app_call( NEW zcl_other_app( ) ).

    line 884
  12. Distinctive:

    State discipline: every PUBLIC attribute is serialized into the draft and sent to the browser on every render.

    line 930
  13. Distinctive:

    Check your authorizations at the top of main, before any dispatch, and render an error when denied - the same place a transaction or a …

    line 937
  14. Very specific:

    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 …

    line 967
  15. Distinctive:

    Every abap2UI5 page carries its own developer tools: a dialog over the running app that shows the roundtrips, the view XML and the model, …

    line 1032
  16. Distinctive:

    Roundtrips: every roundtrip with backend vs.

    line 1064
  17. Distinctive:

    No key, model or endpoint in the class: Read them from your own configuration and pass a destination (SM59 on Standard, a communication …

    line 1117
  18. Very specific:

    Create the provider in the method that calls it - never keep it in an attribute, the app instance is serialized every roundtrip.

    line 1121

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
AGENTS.md

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

PathPurpose
src/The app classes (abapGit project, STARTING_FOLDER=/src/, FOLDER_LOGIC=PREFIX) — one class per app, named ZCL_*
src/zcl_app_001.clas.abapThe 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.abapABAP 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.jsonThe 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.jsonCommitted on purpose: npm ci reads it, so the gate versions CI runs are the ones you ran locally
abaplint.jsoncabaplint 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.jsoncabap2UI5-linter config (paths, UI5 floor, distribution, rule severities, fail level) — CLI flags override it
.github/workflows/check.ymlCI: 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.mjsnpm 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.mjsnpm 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.jsonThe 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.jsonA 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.md in 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:

  1. §3 "Formatting the chain": the framework's npm run fmt:chains is npm run fix here (same command, abap2ui5lint --fix).
  2. §3, same paragraph: .github/abaplint/auto_abaplint_fix.jsonc is 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 in abaplint.jsonc.
  3. §8 "Formatters": npm run check:formatter is 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:shared apply 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 second IF client->check_on_navigated( ). block, or nest one lifecycle IF inside 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 as separate-lifecycle-ifs, without an automatic fix for the seed-then-display pair: folding it into an ELSEIF as 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 the view-chain-layout skill), and a wrapped t_arg list 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:

VerbXML meaningTree 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, …); omit ns for the default namespace (usually sap.m). Declare every namespace as xmlns:* attributes on the root View, exactly like XML.
  • Aggregations are just tags: )->ele( items ) for <items>. An aggregation carries the same prefix as in the XML — <m:content> under a sap.ui.table default namespace is )->ele( n = contentns =m ).
  • stringify( ) renders from the root, so trailing end( ) calls before the final ). are optional — end only moves the cursor to add siblings. That is also what lets the whole view hang off the factory( ) in one statement (see "Formatting the chain" below).
  • Booleans from ABAP variables go through b instead of v: )->a( n = `editable` b = mv_edit_mode ) renders true/false (a literal is just v = `true` — never feed abap_true into v raw, it serializes as X).
  • An element gets its attributes before its first child. a( ) lands on the element the chain is pointing at — the child just added by ele( ) or tag( ), 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, not v. 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 as t, which the builder escapes as a literal: )->a( n = `title` t = |Results for { mv_search }| ). v stays 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 as t, escaping breaks it. Only the app knows which values are literals, which is why a( ) does not decide it for v. When ONE attribute mixes text with a binding, escape the text part yourself with z2ui5_cl_ui5_view_builder=>escape_literal( ) inside v. 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 the factory( ) — one statement, not two — so the variable holds the mvc:View and a later view->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 the factory( ). 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, and view->ele( Shell ) adds a second root next to the mvc:View instead 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 the a( ) chain there is no nested VALUE, so the whole view ends in a single ). (not ) ).).
  • Indent after every ele. Each ele( ) shifts its children's )-> one level (4 spaces) to the right, end( ) shifts back left, and the )-> of an end sits in the same column as the ele it 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( ) and end( ) 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's chain-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, a footer beside a form — keep it: DATA(page) = view->ele( `Shell` )->ele( `Page` … ), then page->ele( … ) per subtree, each its own statement, each starting at the method's indent. Every app in the framework and in abap2UI5/samples is 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:

    1. 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;
    2. 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 ele whose first child is another ele — a Shell → Page scaffold is one path down, not two blocks;
    • blank before every end; none after an end or between two ends.
  • Long text or binding values split with && — an .abap line 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_edit is 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 field supplierName becomes {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 with client->_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 via client->_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 the path: uses the upper-cased ABAP field name.
  • Type numeric/boolean model fields as i/p/abap_bool, not string — 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 with client->_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 send false is the exception — abap_false is itself initial, so the blanket flag would drop it and the control would fall back to its default true. 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.integration widgets:Card manifest is 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 a string attribute and bind it with client->_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 growing sap.m.Table/List grows or a sap.ui.table.Table scrolls — a roundtrip of the framework's own that does not call main( ). Sorting and filtering the binding (the column menu of sap.ui.table via sortProperty/filterProperty, a cs_event-binding_call sort or filter, e.g. from a SearchField) 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 with t_sort / t_filter (types ty_t_bind_tab_sort / _filter of z2ui5_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. val may also be a data reference to the table (REF #( ), an attribute TYPE REF TO data). The number of rows the filter lets through is /__WINDOW/<path>/TOTAL in the model — build it from client->_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 of t_rows it 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. Call view_display again 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_event via CASE client->get_event( )..
  • Pass values into an event with t_arg; read them back with client->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 as abap_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.confirm gives filterItems, SinglePlanningCalendar.selectedDatesChange a list of DateRange. Pass the parameter like any other (`${$parameters>/filterItems}`); the frontend marshals each control into an object carrying its ID plus its public properties, so client->get_event_arg( ) returns a JSON array. Read the one property you need out of it BY HAND, per the bullet below — not with z2ui5_cl_ajson, which is framework-internal. Do not parse a display string such as filterString — 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 — a ViewSettingsItem also brings enabled, textDirection and wrapping — 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. projectValue in app/webapp/core/Lib.js passes an object- or array-valued property through unchanged (up to MAX_ARG_DEPTH), so a control with a property typed object/any marshals 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_apply in z2ui5_cl_ui5_srv_model converts a whole object or array with to_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_json was 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_json is not released for ABAP Cloud, xco_cp_json is missing on 7.02, and the vendored ajson is framework-internal, which the linter's non-released-api rule reports — correctly.
    • Outbound (a control property that must receive an OBJECT, e.g. a sap.ui.integration Card 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_before walk is the whole job — z2ui5_cl_smp_app_197 (json_get_values, one property across an array of objects) and z2ui5_cl_smp_app_327 (json_get_value, one field of a flat object) in abap2UI5/samples are 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 a substring_before on 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_489 in abap2UI5/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.
  • 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 released z2ui5_t_02 (a name/value pair) 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 in AGENTS.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, type ty_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_true keeps 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 with check_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_TIMER carries 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 third t_arg, `X` — abap_true as t_arg spells every flag — is the tick's check_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_expr cancel the control's built-in default before the roundtrip, check_arg_literal quotes every argument — see the doc on z2ui5_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 via binding_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-action t_arg documentation sits on z2ui5_if_client=>follow_up_action and in cs_event.

6. Popups, popovers, messages

  • client->popup_display( val = … ) opens a core:FragmentDefinition string (build it with z2ui5_cl_ui5_view_builder too) as a dialog. Closing: wire a Close button to client->follow_up_action( val = z2ui5_if_client=>cs_event-popup_close ) (round-trip free - the frontend tears the popup slot down without reaching your main), or call client->popup_destroy( ) server-side after handling your own event. Not _event( cs_event-popup_close ): that is a backend event named POPUP_CLOSE like any other, it reaches main( ) 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 as val, the popover as xml — one of the most common first-try mistakes.
  • client->message_toast_display( text ) and client->message_box_display( text type title actions … ) for messages — never build your own toast/dialog for these.
  • message_box_display( text ) takes anything. text is TYPE any, so throw in what you have: a string, a BAPIRET2 / T100 / RAP / symsg structure 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, so message_box_display( lt_return ) after a call that returned nothing stays silent, as it always has. The sample Z2UI5_CL_SMP_APP_502 in 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 a t_arg starting 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_381 and Z2UI5_CL_SMP_APP_512 are 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, then popup_display( ) — and call popup_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_view names them for scoped actions).
  • Call another app: client->nav_app_call( NEW zcl_other_app( ) ); return with client->nav_app_leave( ) (or client->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 ) in check_on_init makes the app bookmarkable and wires the browser Back/Forward buttons — the mode rides in t_arg: cs_nav_mode-keep (the default when t_arg is empty) restores the exact draft state, cs_nav_mode-fresh restarts clean. Works inside the Fiori Launchpad.
  • The URL hash and the app state have named APIs of their own: client->hash_set( val ) writes val as the app's URL hash with a pushed history entry (the UI5 router's navTo), 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 matching cs_event constants for follow_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 with hash_routing off), hash_routing (the routing modes above) and app_state_set_active. hash_routing, hash_attach_changed and app_state_set_active are 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/formatter helpers are a marshalling layer, not a formatting toolbox: a function only lives there when ABAP physically cannot produce the finished value — a real JS Date for 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 a ValueState/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 own check:formatter gate 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_app class. The framework performs no AUTHORITY-CHECK of its own, on purpose (security model). Check your authorizations at the top of main, 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_exit allows 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 as cdn.jsdelivr.net and cdnjs.cloudflare.com are deliberately 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 option that survives an offline system. Whichever you pick, load the library through the UI5 loader (sap.ui.loader.config path 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 through setP13nData( ), a sap.m.Carousel only moves via setActivePage( ) — is reached with client->follow_up_action( val = cs_event-control_by_id … ). Check cs_event and 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_client with INTERFACES z2ui5_if_client PARTIALLY IMPLEMENTED. in the class's .clas.testclasses.abap answers check_on_init / check_on_event / get_event from attributes and records what view_display( ) and message_toast_display( ) receive; a test then calls main( ) 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's run_unit_tests runs it in the transpiled backend; npm run test:unit in the project and the abap2UI5/mcp-server GitHub 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 a PARTIALLY IMPLEMENTED interface, a system does.
  • npm run doctor in 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_app in 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

HowWhat it does
Ctrl+F12 in the running appOpens 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 URLOpens 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 tabOnce 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.

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.