cap-agent-ui5-webui

A SAP CAP plugin that gives any CAP project a working
A2A protocol chat UI by installing it — no wiring, no
copying files, no approuter changes. It serves a freestyle SAPUI5 TypeScript chat
client, same-origin from your CDS server, that talks to whichever of your services
carry the @agent annotation from @cap-js/agents.
@cap-js/agents is pinned to exactly 0.9.1 — it is pre-1.0, days old at the time of
writing, and its own README marks areas such as connectivity and content filtering as
experimental. This plugin inherits that instability; expect breakage on upgrade.
Install
npm add cap-agent-ui5-webui
Installing straight from the repository also works, if you want an unreleased commit:
npm add github:lemaiwo/cap-agent-ui5-webui
CAP auto-loads the plugin from any dependency via its cds-plugin.js convention —
there is nothing else to wire up. Start your server as usual:
cds watch
and open /chat/index.html (e.g. http://localhost:4004/chat/index.html). The UI
fetches <mountPath>/agents.json on startup to find out which of your services are
agents.
If your project has no @agent-annotated service, the UI still loads but has nothing
to talk to.
Configuration
Zero-config by default. Override under cds["cap-agent-ui5-webui"] in package.json:
{
"cds": {
"cap-agent-ui5-webui": {
"mountPath": "/chat", // default "/chat"
"enabled": true, // default true
"bootstrapUrl": null, // default null (use the SAPUI5 CDN)
"serveUi": true, // default true; false = HTML5 repo mode
"agents": ["CatalogService"] // default: all @agent services, in model order
}
}
}
All five keys are optional and independent:
mountPath — where the UI and agents.json are served. Must start with /
(a bare value is prefixed automatically). "/" and "" are refused and fall back
to /chat with a logged warning — CAP's own server registers a handler for /
synchronously, before this plugin's async bootstrap listener resumes, so a plugin
mounted at / would never actually be reached. Taking the value anyway would produce
a config that reads as applied but serves nothing, so the plugin refuses it loudly
instead.
enabled — set to false to disable the plugin entirely (e.g. in a profile where
you want the dependency present but the UI off). Equivalent to the shorthand
"cap-agent-ui5-webui": false.
bootstrapUrl — overrides the SAPUI5 bootstrap URL. See below.
serveUi — set to false to stop serving the UI from the CDS server, because you
deploy it to the HTML5 Application Repository instead. <mountPath>/agents.json stays
mounted: it is generated at runtime from the services carrying @agent, so no static
artifact can stand in for it. See HTML5 Application Repository.
agents — an allow-list of service names, in the order you want them offered. If
omitted, every @agent-annotated service is offered, in model order.
The boolean shorthand is also accepted: "cap-agent-ui5-webui": true means "all
defaults"; "cap-agent-ui5-webui": false disables the plugin.
Multi-agent behaviour
@agent is a service-level annotation, so "how many agents" is exactly "how many
@agent-annotated services" your project exposes:
- One agent — the UI connects to it directly. No picker; behaves exactly as a
single-agent setup always has.
- More than one — the UI renders a picker. Switching agents starts a new
conversation. Each agent owns its own
contextId and task history, so there is no
meaningful way to carry a conversation across agents.
bootstrapUrl and CSP
By default the UI bootstraps SAPUI5 from https://ui5.sap.com/1.120.0/resources/sap-ui-core.js.
A self-contained build would bundle megabytes of the UI5 framework into a dist/ that
is committed to git, so instead the plugin loads it from SAP's CDN and lets you override
the URL for two situations:
- an air-gapped environment with no route to
ui5.sap.com;
- a restrictive Content-Security-Policy that blocks the CDN's origin.
In either case, point bootstrapUrl at your own hosted copy of SAPUI5/OpenUI5. Without
it, the page will render blank rather than fail loudly — this is the most likely first
failure mode on a locked-down deployment, so if the chat UI loads with nothing visible,
check the browser console for a blocked script load before anything else.
Testing human-in-the-loop approval: test-support/scripted-llm
The default mock LLM shipped with @cap-js/agents ignores the prompt. It is
hardcoded to always call the read (query) tool against the first entity in your model,
and can never call an action. That means any human-in-the-loop approval flow you've
built — an action annotated @agent.hitl, gated behind Approve/Reject — is unreachable
under the bundled mock. This isn't specific to any one project; every CAP agent that
uses HITL hits it.
The plugin ships a generic, script-driven mock LLM to close that gap, exported as:
import ScriptedChatModel from "cap-agent-ui5-webui/test-support/scripted-llm"
It needs @langchain/core (declared as an optional peer dependency — install it
yourself if you use this export; the plugin does not require it otherwise). You supply a
script mapping regexes to tool calls:
const script = [
{
match: /order\s+(\d+)\s+.*book\s+(\d+)/i,
tool: "submitOrder",
args: (m) => ({ book: Number(m[2]), quantity: Number(m[1]) }),
},
]
new ScriptedChatModel("scripted", { script, entity: "Books" })
When no rule matches, it falls back to reading the first exposed entity of your service
(or the entity option, if given) — deliberately: it matches regexes and maps captures
to arguments, it does not infer intent, because determinism is its entire value.
Anything needing real inference needs an actual model via SAP AI Core and CAP's hybrid
profile.
Wire it in with a buildModel handler behind an environment guard, the way the test
fixture in this repo does (test/fixture/bookshop/srv/cat-service.js):
if (process.env.AGENT_LLM === "scripted") {
this.on("buildModel", async () => {
const { default: ScriptedChatModel } = await import("cap-agent-ui5-webui/test-support/scripted-llm")
return new ScriptedChatModel("scripted", { script, entity: "Books" })
})
}
Running on SAP BTP
The plugin serves static files with express.static — no UI5 Tooling, no
transpilation, and no framework download at runtime. The same code path runs under
cds watch and on Cloud Foundry.
Because the UI is served same-origin from your CDS server, requests it makes to your
agent carry the host's own session, so the agent stays protected exactly as it was —
there is no approuter and no HTML5 Application Repository to set up. Note that the
static assets and <mountPath>/agents.json are served ahead of CAP's auth middleware
(the plugin registers its routes on the raw Express app at bootstrap, before cds.serve
mounts service-level auth) and are readable without a session — agents.json in
particular enumerates your agent services' names, descriptions and mounted paths.
HTML5 Application Repository
The default above is the whole point of this plugin: no repository, no approuter content
module, nothing to build. The UI is ~20 KB and SAPUI5 comes from the CDN, so serving it
from your CDS server costs approximately nothing.
Use the repository instead when you need what only it provides — chiefly SAP Build Work
Zone, which can only surface apps that live there, and the Fiori shell around them. It
also keeps the UI off your srv route entirely, which closes the anonymous agents.json
read described above.
npx cap-agent-ui5-webui html5 app/chat-ui/dist
That writes the prebuilt UI, an xs-app.json, and a deployable <appId>.zip into that
directory, and prints the approuter routes you need. Then set serveUi: false so the UI
is not also served from the CDS server:
{ "cds": { "[production]": { "cap-agent-ui5-webui": { "serveUi": false } } } }
Scoping it to [production] keeps cds watch exactly as it was — locally there is no
repository to serve from.
What this does not remove
agents.json still comes from the CDS server. It is generated at runtime by discovering
which of your services carry @agent, so the repository — which stores static files —
cannot produce it. The UI fetches it relative to its own page, which after deployment
means /<appId>/agents.json, so that one path has to be routed back to your service.
Both generated configs do this for you.
The service name is html5-apps-repo-rt, in both files
This one costs an afternoon. Countless examples write
"service": "html5-apps-repo", and that is fine under the managed approuter, which
skips the validation entirely. A standalone approuter — what a CAP MTA usually has —
validates its own xs-app.json at startup and the app's fetched xs-app.json on every
request, resolving that name against a bound service. With html5-apps-repo you get
A route requires access to html5-apps-repo service but the service is not bound.
as a boot crash from the router's own file, or as a request-time 500 from the app's
file — while cf services shows the app-runtime instance bound perfectly well. The
name it wants is the bound service's runtime name, html5-apps-repo-rt. Both configs
this command generates already use it.
MTA wiring
You need an app-host instance (where content is uploaded), an app-runtime instance
bound to your approuter (which lets it serve that content), an html5 module whose
build-result points at the generated folder, and a com.sap.application.content module
referencing <appId>.zip. A worked example is in
the sample repo.
Two things that fail quietly there: build-result must name the folder holding the
archive, or mbt copies nothing and the repository rejects the upload with "Could not find
applications in the request"; and mbt only copies an archive, it never creates one —
which is why this command writes the .zip itself.
dist/ ships TypeScript sources — deliberately
The committed dist/ build output includes .ts source files and .js.map source-map
files alongside the compiled .js, and express.static serves them as-is — a request
for /chat/Component.ts (or any other source file under the mount path) returns the raw
TypeScript. This is intentional, not an oversight:
- it is client-side UI code, already delivered to the browser as compiled JavaScript —
shipping the source alongside it discloses nothing that inspecting the compiled bundle
wouldn't;
- the source maps make the compiled code debuggable in the browser's dev tools, which is
worth more than the marginal bytes saved by stripping them.
If your deployment has a reason to withhold .ts/.map files (e.g. a strict asset
allow-list at a reverse proxy), filter them there — the plugin does not.
Developing this plugin
This repository is itself the plugin (the root package.json is the published
package), plus a sample CAP project that consumes it.
Running the sample
npm run dev
That is the loop to use while changing anything in this repo. It builds ui/ into
dist/, starts the sample project at http://localhost:4004/chat/index.html, and
rebuilds automatically whenever you edit anything under ui/webapp. A rebuild needs no
server restart — express.static reads from disk per request, so refreshing the browser
is enough.
It starts the sample with AGENT_LLM=scripted, which matters: under the LLM that
@cap-js/agents ships by default, the agent ignores your prompt and can only ever call
the read tool, so the Approve/Reject flow is unreachable. With the scripted model, try
show me all books and then order 1 copies of book 2 to see the approval gate.
npm run dev deliberately serves the built dist/, the same path consumers and the E2E
suite use, rather than transpiling ui/ sources on the fly. That costs a few seconds per
rebuild and buys fidelity: a Component.ts bootstrap bug once hid from every check in
this repo precisely because nothing had loaded the built output in a browser.
Hybrid testing: the sample against a real LLM
npm run dev uses a deterministic stand-in for an LLM. To run the sample against a real
model on SAP AI Core — which is the only way to see the agent actually reason, rather
than match a regex — use hybrid mode.
npm run dev:hybrid
One-time setup, in test/fixture/bookshop:
cf login # the subaccount holding your AI Core instance
cds bind -2 <instance>:<service-key> # e.g. cds bind -2 aicore:aicore-key
cds bind writes .cdsrc-private.json. That file holds live service credentials and is
gitignored — keep it that way.
Two things are worth knowing before you try it:
- Hybrid needs
@sap/cds-dk installed
(npm i -g @sap/cds-dk); normal npm run dev does not. cds bind records only a
reference to the CF instance and key, and resolving that into the VCAP_SERVICES the
SAP AI SDK reads happens at startup in cds watch, which ships with cds-dk. Started any
other way, the SDK logs Could not find service binding of type 'aicore' and every call
then fails with a misleading "content safety check is temporarily unavailable" — the
filter cannot reach AI Core either.
AGENT_LLM is deliberately unset in hybrid. The scripted test double registers a
buildModel handler that would shadow the real model, and the agent would keep matching
regexes while looking like it was reasoning.
The model comes from @cap-js/agents' own [hybrid] defaults — anthropic--claude-4.6-sonnet
via the aicore kind — so the sample needs no LLM configuration of its own.
To just start the server in hybrid mode, without the build-and-watch loop:
npm run start:fixture:hybrid # the hybrid counterpart of npm run start:fixture
Note the asymmetry, which is forced by the tooling rather than chosen: start:fixture
uses cds-serve, but the hybrid script uses cds watch --profile hybrid. cds serve --profile hybrid does not resolve the service binding — verified: it starts cleanly, then
every model call fails with Could not find service binding of type 'aicore' and surfaces as
"content safety check is temporarily unavailable". Don't "simplify" it back to serve.
Hybrid tests
npm run test:hybrid
Starts the sample in hybrid mode, runs test/hybrid/ against the real model, and shuts the
server down. Not part of npm test and never run in CI — no runner has a binding, and a
suite that costs money per run has no business firing on every push. Expect roughly a minute.
Without a binding or without cds-dk it skips with an explanatory message and exits 0,
so a contributor who was never expected to run it does not see a red build.
The assertions deliberately never check the model's wording, which differs every run. They
check things that stay deterministic even when the prose does not: that the reply contains a
title fetched independently from OData (the agent could only know it by calling a tool), that
ordering reaches input-required with stock unchanged, that approving decrements stock by
exactly the amount ordered, that rejecting does not, and that a follow-up turn reuses the
contextId the server issued.
Verified working end to end: asked for the cheapest book and the cost of three copies, the
model queried the catalog and answered $11.11 → $33.33; ordering two copies paused at
input-required with stock untouched, and approving it moved stock 12 → 10.
Other commands
npm run build — runs the UI5 build (ui5-tooling-transpile-task) from ui/
into dist/. npm run dev does this for you; run it by hand before committing a
ui/ change.
dist/ is committed to git, deliberately (see .gitignore) — a git-install
consumer runs no build step of its own, so the built output has to already be there.
This means you must run npm run build and commit the result before pushing any
ui/ change; CI checks that dist/ matches a fresh build.
test/fixture/bookshop/ is the sample, and also the test fixture. It is a small
CAP project that depends on the plugin via file:../../.. exactly as a real consumer
would, and it defines two @agent services so the agent picker is exercised. Sample
and fixture are deliberately the same app: a separate sample would not be run by any
test and would rot silently. Its
srv/catalog-agent/AGENTS.md and srv/catalog-agent/skills/book-purchase/SKILL.md
are also a working example of the markdown agent templates below (see
docs/agent-phases.md for how the two agent-definition
styles differ in the resulting agent card).
Test commands:
npm test # unit (node:test via tsx) + fixture service tests (cds.test)
npm run test:e2e # Playwright, against the fixture and its built dist/
npm run typecheck # tsc --noEmit over ui/
npm test does not start a server. npm run test:e2e starts its own CAP server (via
playwright.config.ts's webServer, with AGENT_LLM=scripted) against the fixture,
and always starts a fresh instance rather than reusing one left over from cds watch.
E2E always runs against the built dist/, never against live-transpiled TypeScript
— that's the only way the suite proves what a real consumer actually runs.
Releasing
Publishing is triggered by publishing a GitHub Release, never by a push — nothing
reaches npm because a commit landed. .github/workflows/publish.yml re-runs the whole
quality gate (typecheck, unit, service, E2E, the dist/ freshness check and the packaging
smoke test) before it publishes, because npm's unpublish window is 72 hours and a release
is the last point at which a mistake is cheap.
It authenticates with npm trusted publishing over OIDC — there is no NPM_TOKEN in
this repository — and publishes with --provenance, so consumers can verify the package
was built from this repo by this workflow.
To cut a release:
- Bump
version in package.json, commit, push.
- Draft a GitHub Release whose tag is that version, prefixed with
v (v0.2.0 for
0.2.0). The workflow fails fast if the tag and package.json disagree rather
than publishing a version nobody can correlate with a release.
- Publish the release. The workflow does the rest.
One-time setup
0.1.0 was published manually, because trusted publishing requires the package to already
exist on npm — so that step is done. What remains, once, before the workflow can publish:
On npmjs.com, open the package's Settings → Trusted Publisher and add this repository
with workflow publish.yml. Until that is configured, a GitHub Release will run the
workflow and fail at the publish step with an authentication error.
After that, releases publish themselves with no token and no 2FA prompt — OIDC replaces
both.
Markdown agent templates
docs/templates/AGENTS.md.template and docs/templates/SKILL.md.template are
commented starting points for defining your own agent and its skills — domain
vocabulary is irreducibly yours, so the plugin ships templates, not content. Copy them
into your service (e.g. srv/<your-agent>/AGENTS.md and
srv/<your-agent>/skills/<skill-name>/SKILL.md) and fill them in.
What this is / is not
This plugin serves the chat UI and discovers your agents; it is not a general chat
framework and does not speak anything other than A2A as @cap-js/agents serves it.
It also does not publish to npm yet — GitHub install only.