Add AI to SAP S/4HANA without touching its core, by building a CAP extension on SAP BTP that reads released APIs, calls an AI service and checks who may ask.
A side-by-side extension is a separate application that adds a new capability to SAP S/4HANA without changing S/4HANA itself. It runs on SAP BTP, next to the ERP system rather than inside it. It reads and writes business data only through APIs that SAP has released for that purpose.
For AI, this is usually the right home. The AI part changes fast: models, prompts and providers move every few months. The ERP core should change slowly and predictably. A side-by-side extension keeps the two apart. You can swap a model on Tuesday without an S/4HANA transport, and an S/4HANA upgrade doesn't break your AI feature, as long as the released APIs stay stable.
CAP, SAP's Cloud Application Programming Model, is the framework SAP recommends for building these extensions. You describe your data and services in a short model; CAP turns it into a working service with standard OData endpoints, sign-in checks and a database. You write code only for the part that is truly yours, such as "ask the AI why this order is blocked".
Take the course's running example: blocked sales orders in order-to-cash. The order desk wants a button that explains, in plain words, why an order is on hold and who should act.
There are three places you could build that button:
Inside S/4HANA, by changing SAP's code. Fast at first, expensive forever. Every upgrade has to be retested against your change. This is what "clean core" exists to stop.
Inside S/4HANA, using SAP's released extension points. Good for small rules and fields close to the data. Less suitable for calling outside model providers, handling model outages and changing prompts weekly.
Side by side, on SAP BTP. The extension reads the order through a released API, calls the AI, stores the answer and shows it. S/4HANA stays untouched.
The side-by-side choice has three business effects:
Upgrades stay cheap. SAP's learning material describes side-by-side extensions as having an independent lifecycle. The AI feature and the ERP release on their own schedules.
Risk is contained. If the model misbehaves or the provider goes down, the extension falls back to a rule-based answer. Order processing in S/4HANA carries on regardless.
Governance has one home. Who may use the AI, what it said, and whether it helped are stored in the extension's own database. That is your audit trail and, later, your evaluation data.
The cost is real too. A side-by-side extension is one more application to run, secure, monitor and pay for. It needs a BTP account, a connection to S/4HANA, and someone who owns it after go-live.
As of October 2026, SAP's learning material on the S/4HANA Cloud extensibility model names four options:
Option
Who builds it
Where it runs
Clean core?
Key-user extensibility
Business users and citizen developers, with low-code tools
Inside S/4HANA
Yes
Developer extensibility
Professional ABAP developers, with ABAP Cloud (RAP)
Inside S/4HANA
Yes, through released extension points
Side-by-side extensibility
Professional developers, with CAP, Java or Node.js
On SAP BTP
Yes, through released APIs
Classic extensibility
Experienced ABAP developers
Inside S/4HANA; private edition and on-premise only
Not guaranteed
For AI applications, SAP's Architecture Center (last updated 23 April 2026) describes a CAP-based backend that manages the application logic. It runs on Cloud Foundry or Kyma, connects to SAP back ends through destinations, and calls models through SAP Cloud SDK for AI, available for Java, JavaScript and Python. Model access needs SAP AI Core with the extended plan. SAP also recommends keeping prompts in the prompt registry rather than in code, and points to a CAP LLM Plugin for generative AI in CAP applications.
CAP's own tools have grown AI features too. As of October 2026, the CAP command line can add an AI plugin (@cap-js/ai) whose documentation describes value-help recommendations using SAP's RPT-1 model and simpler access to SAP AI Core. Treat it as a building block to evaluate, not a finished business feature.
#A decision guide: where should this AI feature live?
Use these questions, in order, for each AI idea on your list.
If the honest answer is...
Lean towards
"It's a new field, a simple rule or a UI tweak, and no model is involved"
Key-user extensibility
"It's logic that must run inside the S/4HANA transaction, close to the data"
Developer extensibility (ABAP Cloud)
"It calls a model, combines S/4HANA with other data, or needs its own users and history"
Side-by-side on SAP BTP, usually with CAP
"SAP already ships it in Joule or as an SAP Business AI feature"
Use SAP's feature first; extend only the gap
"It needs SAP code changed to work"
Stop and redesign; this breaks clean core
Most AI features land in the third row. The fourth row matters more each release: check what SAP ships before you build, as covered in the SAP Business AI landscape.
A day in the life of the finished extension: at 9:05 a clerk opens the blocked-orders list in a Fiori app. The list comes from the extension, which asks S/4HANA through the Sales Order API. She clicks Explain on order 4711. The extension checks she holds the order-clerk role, calls the AI service, saves the answer under her name and shows it. At 11:00 the model provider is slow; she still gets a short rule-based answer, labeled as a fallback. On Friday her manager reviews which answers clerks marked as unhelpful.
"Side by side means a copy of the ERP data." Not necessarily. The extension in this topic reads orders live through an API and stores only its own data: the AI answers.
"Clean core means no custom code." It means custom code only through released APIs and extension points. A side-by-side extension can hold a lot of custom code; it just doesn't sit inside SAP's.
"Signed in means allowed." Being signed in to the extension proves who you are. Roles in the extension, and authorizations in S/4HANA, decide what you may do.
"CAP is only for Fiori apps." CAP serves standard OData and is used for back-end services, integrations and AI services as well.
"Moving AI to BTP removes the S/4HANA risk." It removes the risk of breaking the core. Wrong answers, data exposure and cost still need controls, in the extension.
Pick one answer for each question. The explanation appears after you choose.
1What makes a side-by-side extension a good home for an AI feature on S/4HANA?
Answer: B. Models and prompts change every few months, while the ERP core should change slowly. A side-by-side extension on SAP BTP keeps the two apart and reads data through released APIs, so each side can release on its own schedule.
2A team proposes changing SAP's own code so the AI explanation appears faster. What is the main risk?
Answer: C. Modifying SAP code breaks clean core. Upgrades then have to be checked against the change, which makes them slower and more expensive. SAP's learning material lists this kind of classic extensibility as not guaranteeing a clean core.
3Which extensibility option fits an AI feature that calls a model and keeps its own history of answers?
Answer: D. Calling outside models, handling outages and storing answers with their own access rules fit best outside the core. The decision guide puts such features in the side-by-side row, with CAP as SAP's recommended framework.
4As of October 2026, what does SAP's Architecture Center describe as the backend for generative AI applications on BTP?
Answer: A. The Architecture Center's AI golden path names a CAP-based backend on Cloud Foundry or Kyma, with SAP Cloud SDK for AI for model access and destinations to SAP back ends. Prompts belong in the prompt registry.
5A vendor says users must sign in, so the AI extension is secure. What should you ask next?
Answer: D. Signing in only proves who someone is. Roles decide what they may do, and a test that shows a user without the role being refused is the proof. Ask for both.
6Why should the extension store each AI answer with the name of the person who asked?
Answer: B. Stored answers show who asked what and what the AI said, which is your audit trail. With ratings added, the same records become evaluation data. The model doesn't learn from them by itself.
7Before funding a custom AI extension, what should a leader check first?
Answer: C. Building what SAP already delivers, or will soon, wastes money and creates something to maintain. Check SAP's own features first and extend only the gap.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
Deep layer · 40 min read
#Mental model: a thin, owned layer between a stable core and a fast-moving model
Picture three boxes in a row. On the left, S/4HANA: the system of record, slow to change, upgraded by SAP. On the right, the model: fast to change, sometimes down, always paid per call. In the middle, your extension: a small CAP application you own.
The middle box has four jobs, and only four:
Translate. Read S/4HANA through a released API and turn its fields into your terms (DeliveryBlockReason becomes deliveryBlock).
Decide who may act. Check roles before reading or calling the model.
Call the AI safely. With a timeout, a key kept out of code, and a fallback.
Remember. Store what the AI said, for whom, and from which source.
Everything else stays where it belongs. Business rules for releasing an order stay in S/4HANA. Prompt and model logic stay in the AI service from Building an AI API, or in SAP's orchestration service. If you catch the middle box doing a fifth job, ask whether it belongs somewhere else.
CAP makes this layer small because it treats the remote S/4HANA API, your own database and your service the same way: as CAP services you query with the same commands. CAP's documentation calls this treating remote services as if they were local.
flowchart LR
U[Fiori app or<br/>test script] -->|OData v4, signed in| A[AssistService<br/>CAP]
A -->|role check| A
A -->|OData v2, released API| S[S/4HANA<br/>Sales Order API]
A -->|X-API-Key| M[AI API<br/>Unit 6]
A -->|INSERT| D[(Explanations<br/>own database)]
On a project you download the API's metadata file (an EDMX file) and run cds import on it. CAP's documentation shows this creates a CDS model in srv/external/, and you then register it in package.json with "kind": "odata-v2" for an OData V2 API like the S/4HANA Sales Order API.
The useful trick comes next. During development, cds watchmocks every imported service: it serves it locally, filled with CSV data from srv/external/data/. Your code can't tell the difference. It calls cds.connect.to('API_SALES_ORDER_SRV') and gets a service object, mock or real. For a more realistic test, cds mock API_SALES_ORDER_SRV runs the mock as a separate server, and your app then talks to it over HTTP, as it would to S/4HANA.
You rarely want the whole remote entity. A consumption view is a CDS projection that keeps the fields you need and renames them:
entity BlockedOrders as projection on S4.A_SalesOrder {
key SalesOrder as salesOrder,
DeliveryBlockReason as deliveryBlock
}
When your handler sends a query on BlockedOrders to the remote service, CAP translates the names back. In our test, a filter on deliveryBlock reached the mock as an OData filter on DeliveryBlockReason. That keeps S/4HANA's names in one file.
Reading data is generic, and CAP does it for you. The AI step is not, so it becomes a custom action. A bound action belongs to one record, so the URL names the order:
POST /odata/v4/assist/BlockedOrders('4711')/explain
In JavaScript you register a handler with this.on('explain', BlockedOrders, req => ...). The request object carries the key in req.params, any input in req.data, and the signed-in user in req.user. req.reject(404, '...') ends the request with an error.
CAP checks permissions from annotations in the CDS file, before your handler runs:
@requires: 'authenticated-user' on the service: nobody anonymous gets in (401).
@(requires: 'OrderClerk') on the action: signed-in users without the role get 403.
@restrict with where: 'createdBy = $user' on Explanations: clerks see only the rows they created. CAP adds that filter to every read.
Because the rules sit in the model, they are visible in a code review and apply to every route into the data. In development, CAP uses mocked authentication: users and roles you list in package.json, signing in with basic authentication (a name and password sent with each request). In production, the same annotations are checked against real tokens from SAP's identity services; the deployment topic later in this unit sets that up.
There are two common shapes, and this topic builds the first:
Shape
How
Good when
CAP calls a separate AI service
CAP sends the order to the Python AI API from the previous topic
The AI logic is in Python, owned by an AI team, or reused by other callers
CAP calls the model itself
The CAP handler uses SAP Cloud SDK for AI for JavaScript
One small team, one app, and a simple prompt
Either way, CAP stays the place that knows the user, the roles and the business data.
#Build it yourself: an AI side-by-side extension for blocked orders
You will build order-assist, a CAP extension. It reads sales orders from a mocked S/4HANA Sales Order API, lists only the blocked ones, lets order clerks ask for an AI explanation, and stores every answer with the name of the person who asked. By default it explains orders with simple rules, so it runs with no AI service at all. In Step 10 you connect it to the AI API you built in the previous topic.
flowchart LR
T[test_order_assist.py<br/>9 checks] -->|clara, sam, maria, victor| C[order-assist<br/>CAP, port 4004]
C --> S[Mocked Sales Order API<br/>CSV data]
C -->|default| R[Rule-based answer<br/>source: sample]
C -->|Step 10| A[ai_api.py<br/>port 8000]
Before you start: complete Set up your computer for this course and Set up for Unit 6. They create your orchestrate-course folder with its .venv, and install Node.js 24, the CAP development kit (cds) and FastAPI. Step 10 also uses unit06/ai_api.py and the ORCHESTRATE_API_KEY line in .env from Building an AI API. This walkthrough doesn't repeat those steps.
Your course folder with the Unit 6 setup done (python check_unit06.py ends with All set).
About 60 to 90 minutes.
Cost: free. Everything runs on your computer. Step 10 uses the sample model in your own AI API, also free.
No SAP system or BTP account is needed. S/4HANA is mocked.
#Step 1: Open your course folder and check the tools
Open VS Code, choose File > Open Folder, and open orchestrate-course.
Open a terminal: Terminal > New Terminal.
Turn on the virtual environment if the prompt doesn't start with (.venv):
Windows (PowerShell):
.venv\Scripts\Activate.ps1
macOS / Linux:
source .venv/bin/activate
Check Node.js and CAP (the same on every system):
node --version
cds version
You should see v24 (or v22) for Node.js, and a table that includes @sap/cds-dk with a version starting 10. If cds isn't found, go back to Step 3 of the Unit 6 setup.
Go into unit06 and create the project (the same on every system):
cd unit06
cds init order-assist --add nodejs
cd order-assist
What success looks like:
Successfully initialized CAP project
The option --add nodejs gives the project its own package.json, the file that lists its libraries and settings. In the Unit 6 setup you skipped it; this project needs it.
In the folders VS Code shows under unit06/order-assist, check you have app, db, srv and a package.json.
From now on, run the commands in this topic from unit06/order-assist unless a step says otherwise.
On a real project you would download the Sales Order API's metadata from SAP Business Accelerator Hub and run cds import. Here you write a small slice of it by hand, with the same entity and field names as the real API, API_SALES_ORDER_SRV.
In VS Code, inside unit06/order-assist/srv, create a folder named external.
In srv/external, create API_SALES_ORDER_SRV.cds, paste this and save:
// A hand-written slice of SAP's Sales Order API (API_SALES_ORDER_SRV, entity A_SalesOrder).
// On a project you generate the full model with "cds import"; this keeps only the
// fields this topic uses, under the names the real API uses.
@cds.external : true
service API_SALES_ORDER_SRV {
entity A_SalesOrder {
key SalesOrder : String(10);
SoldToParty : String(10);
TotalNetAmount : Decimal(16, 3);
TransactionCurrency : String(5);
DeliveryBlockReason : String(2);
HeaderBillingBlockReason : String(2);
}
}
Inside srv/external, create a folder named data. In it, create API_SALES_ORDER_SRV-A_SalesOrder.csv, paste this and save. The name must match exactly: service name, a dash, entity name.
The extension keeps one table of its own. It lives in the extension's database, never in S/4HANA.
In unit06/order-assist/db, create schema.cds, paste this and save:
namespace orderassist;
using { cuid, managed } from '@sap/cds/common';
// Our own data: every AI explanation we gave, who asked for it and where it came from.
// It lives in the extension's database, not in S/4HANA.
entity Explanations : cuid, managed {
salesOrder : String(10);
explanation : String(800);
nextStep : String(30);
source : String(20); // model, cache, fallback or sample
model : String(60);
requestId : String(40);
}
cuid gives each row a unique ID. managed adds createdAt, createdBy, modifiedAt and modifiedBy, which CAP fills in from the signed-in user. That is how each answer gets its author without any code.
In unit06/order-assist/srv, create assist-service.cds, paste this and save:
using { API_SALES_ORDER_SRV as S4 } from './external/API_SALES_ORDER_SRV';
using { orderassist as my } from '../db/schema';
@requires: 'authenticated-user'
service AssistService {
// A consumption view: only the fields we need, renamed into our own words.
// The handler adds the filter "only orders with a delivery block".
@readonly
entity BlockedOrders as projection on S4.A_SalesOrder {
key SalesOrder as salesOrder,
SoldToParty as customer,
TotalNetAmount as netValue,
TransactionCurrency as currency,
DeliveryBlockReason as deliveryBlock
}
actions {
// Only order clerks may ask the AI; every answer is stored in Explanations.
@(requires: 'OrderClerk')
action explain() returns Explanations;
};
// Clerks see their own explanations; managers see all of them.
@readonly
@(restrict: [
{ grant: 'READ', to: 'OrderClerk', where: 'createdBy = $user' },
{ grant: 'READ', to: 'OrderManager' }
])
entity Explanations as projection on my.Explanations;
}
Read it top to bottom; it is the whole design in 30 lines. Everyone must sign in. Anyone signed in may read blocked orders. Only OrderClerk may call explain. Clerks see their own explanations; OrderManager sees all of them.
In unit06/order-assist/srv, create assist-service.js, paste this and save. The file name must match assist-service.cds; that is how CAP finds the code for the service.
// The logic behind AssistService: read orders from S/4HANA, ask the AI API, store the answer.
import cds from '@sap/cds'
const LOG = cds.log('assist')
// Keys stay in the course's .env file (two folders up), never in code.
try { process.loadEnvFile(new URL('../../../.env', import.meta.url)) } catch { /* no .env: sample mode */ }
const AI_API_URL = process.env.AI_API_URL // e.g. http://127.0.0.1:8000; not set = sample mode
const AI_API_KEY = process.env.ORCHESTRATE_API_KEY
const TIMEOUT_MS = 8000
// Made-up texts for the made-up block codes in the mock data.
// Real codes and their texts are configured in each SAP system; read them, don't copy these.
const BLOCK_TEXTS = {
'01': ['Credit limit exceeded', 'credit_review'],
'02': ['Missing export documents', 'complete_documents'],
'03': ['Incomplete delivery address', 'fix_master_data'],
}
function ruleAnswer (order, source) {
const [text, nextStep] = BLOCK_TEXTS[order.deliveryBlock] ?? ['Delivery block ' + order.deliveryBlock, 'contact_customer']
return {
explanation: `Order ${order.salesOrder} is blocked for delivery. Reason: ${text}. ` +
`It is worth ${order.netValue} ${order.currency}.`,
nextStep, source, model: 'rules', requestId: null,
}
}
async function askAiApi (order) {
const response = await fetch(`${AI_API_URL}/v1/explain`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': AI_API_KEY ?? '' },
body: JSON.stringify({ sales_order: order.salesOrder }),
signal: AbortSignal.timeout(TIMEOUT_MS),
})
if (!response.ok) throw new Error(`AI API answered ${response.status}`)
const answer = await response.json()
return {
explanation: answer.explanation, nextStep: answer.next_step, source: answer.source,
model: answer.model, requestId: answer.request_id,
}
}
export default class AssistService extends cds.ApplicationService {
async init () {
const { BlockedOrders, Explanations } = this.entities
const s4 = await cds.connect.to('API_SALES_ORDER_SRV')
// Only orders with a delivery block. S/4HANA sends an empty text for "no block"; the mock sends null.
const hasBlock = { deliveryBlock: { '!=': '' }, and: { deliveryBlock: { '!=': null } } }
// Reading orders: add our filter and hand the query to S/4HANA (a mock on your laptop).
this.on('READ', BlockedOrders, req => s4.run(req.query.where(hasBlock)))
// The AI step, as a bound action: POST .../BlockedOrders('4711')/explain
this.on('explain', BlockedOrders, async req => {
const [key] = req.params
const salesOrder = typeof key === 'object' ? key.salesOrder : key
const order = await s4.run(SELECT.one.from(BlockedOrders).where({ salesOrder }).and(hasBlock))
if (!order) return req.reject(404, `Order ${salesOrder} is not blocked or does not exist`)
let answer
if (!AI_API_URL) {
answer = ruleAnswer(order, 'sample')
} else {
try {
answer = await askAiApi(order)
} catch (error) {
LOG.warn(`AI API unavailable for order ${salesOrder}: ${error.message}`)
answer = ruleAnswer(order, 'fallback')
}
}
const ID = cds.utils.uuid()
await INSERT.into(Explanations).entries({ ID, salesOrder, ...answer })
LOG.info(`order ${salesOrder} explained for ${req.user.id} (source: ${answer.source})`)
return SELECT.one.from(Explanations, ID)
})
return super.init()
}
}
The cds part does two things. API_SALES_ORDER_SRV tells CAP the app needs the Sales Order API, as OData V2, described by the file from Step 3. auth lists four made-up users for development, each with the password equal to the name. "*": false refuses any other name.
User
Roles
May
clara
OrderClerk
Read orders, ask the AI, see her own answers
sam
OrderClerk
The same, for his own answers
maria
OrderManager
Read orders, see every answer
victor
none
Read orders only
Install the project's libraries (the same on every system):
npm install
What success looks like: a few lines ending with found 0 vulnerabilities. A node_modules folder appears. It is listed in the project's .gitignore, so Git ignores it.
[cds] - connect to db > sqlite { url: ':memory:' }
> init from srv/external/data/API_SALES_ORDER_SRV-A_SalesOrder.csv
/> successfully deployed to in-memory database.
[cds] - using auth strategy { kind: 'mocked' }
[cds] - serving AssistService {
at: [ '/odata/v4/assist' ],
decl: 'srv/assist-service.cds:5',
impl: 'srv/assist-service.js'
}
[cds] - mocking API_SALES_ORDER_SRV {
at: [ '/odata/v4/api-sales-order-srv' ],
decl: 'srv/external/API_SALES_ORDER_SRV.cds:5'
}
[cds] - server listening on { url: 'http://localhost:4004' }
mocking API_SALES_ORDER_SRV is the stand-in for S/4HANA. On Node.js 22 you may also see ExperimentalWarning: SQLite is an experimental feature. It is harmless here.
Open http://localhost:4004/odata/v4/assist/BlockedOrders in your browser. A sign-in box appears. Enter victor as the user name and victor as the password. You see four orders, 4711 to 4714. Order 4715 is missing, because it has no block. That is the filter in the handler at work.
What success looks like (shortened to the first order):
Ask for an explanation. A browser can't send this kind of request, so use a second terminal (Terminal > New Terminal; the first one is busy running cds watch). First as victor, who has no clerk role:
What success looks like (your ID and times differ):
{"@odata.context":"../$metadata#Explanations/$entity","ID":"19a65e05-2d5e-472a-9618-0f1c6723ac81","createdAt":"2026-10-02T12:37:46.109Z","createdBy":"clara","modifiedAt":"2026-10-02T12:37:46.109Z","modifiedBy":"clara","salesOrder":"4711","explanation":"Order 4711 is blocked for delivery. Reason: Credit limit exceeded. It is worth 12500.000 EUR.","nextStep":"credit_review","source":"sample","model":"rules","requestId":null}
"source":"sample" means the rule-based answer: no AI service is connected yet. "createdBy":"clara" came from CAP, not from your code.
Look at the first terminal. The handler left a line:
[assist] - order 4711 explained for clara (source: sample)
Clicking around proves little. A test script proves every rule, every time. It uses only built-in Python, so nothing new to install.
In VS Code, in unit06 (not inside order-assist), create test_order_assist.py, paste this and save:
"""Check the order-assist CAP extension: who may read, who may ask the AI, who sees which answers.
Start the extension first (in unit06/order-assist: cds watch), then, from your course folder:
python unit06/test_order_assist.py
It uses only built-in Python and the made-up users from package.json.
"""
import base64
import json
import sys
import urllib.error
import urllib.request
BASE = "http://localhost:4004/odata/v4/assist"
def call(path, user=None, method="GET", body=None):
"""Send one request; return (status code, parsed JSON or None)."""
request = urllib.request.Request(BASE + path, method=method)
if user: # mocked users sign in with basic authentication: name and password
token = base64.b64encode(f"{user}:{user}".encode()).decode()
request.add_header("Authorization", "Basic " + token)
if method == "POST":
request.add_header("Content-Type", "application/json")
request.data = json.dumps(body or {}).encode()
try:
with urllib.request.urlopen(request, timeout=20) as response:
return response.status, json.loads(response.read() or b"null")
except urllib.error.HTTPError as error:
return error.code, None
def check(name, ok, detail=""):
print(("PASS " if ok else "FAIL ") + name + ("" if ok else f" ({detail})"))
return ok
def main() -> None:
try:
call("/$metadata")
except urllib.error.URLError:
print("The extension isn't running. In a second terminal: cd unit06/order-assist, then cds watch.")
sys.exit(1)
results = []
status, _ = call("/BlockedOrders")
results.append(check("no sign-in -> 401", status == 401, status))
status, body = call("/BlockedOrders", "victor")
numbers = sorted(row["salesOrder"] for row in (body or {}).get("value", []))
results.append(check("signed-in user sees only blocked orders", numbers == ["4711", "4712", "4713", "4714"], numbers))
status, _ = call("/BlockedOrders('4711')/explain", "victor", "POST")
results.append(check("user without OrderClerk may not ask the AI -> 403", status == 403, status))
status, _ = call("/BlockedOrders('4715')/explain", "clara", "POST")
results.append(check("order without a block -> 404", status == 404, status))
status, answer = call("/BlockedOrders('4712')/explain", "clara", "POST")
answer = answer or {}
results.append(check("clerk asks the AI -> 200 with an explanation", status == 200 and bool(answer.get("explanation")), status))
results.append(check("answer says where it came from", answer.get("source") in {"model", "cache", "fallback", "sample"}, answer.get("source")))
results.append(check("answer is stored under the clerk's name", answer.get("createdBy") == "clara", answer.get("createdBy")))
_, body = call("/Explanations", "sam")
others = [row for row in (body or {}).get("value", []) if row.get("createdBy") != "sam"]
results.append(check("another clerk can't read clara's answers", others == [], len(others)))
_, body = call("/Explanations", "maria")
results.append(check("manager can read every answer", any(row.get("createdBy") == "clara" for row in (body or {}).get("value", [])), body))
print(f"\n{sum(results)} of {len(results)} passed")
sys.exit(0 if all(results) else 1)
if __name__ == "__main__":
main()
In the second terminal, go back to the course folder and run it. Make sure (.venv) is on (Step 1).
Windows (PowerShell):
cd ..\..
python unit06\test_order_assist.py
macOS / Linux:
cd ../..
python unit06/test_order_assist.py
cd goes up two folders, from unit06/order-assist to the course folder. A new terminal in VS Code already starts in the course folder; then skip the cd line.
What success looks like:
PASS no sign-in -> 401
PASS signed-in user sees only blocked orders
PASS user without OrderClerk may not ask the AI -> 403
PASS order without a block -> 404
PASS clerk asks the AI -> 200 with an explanation
PASS answer says where it came from
PASS answer is stored under the clerk's name
PASS another clerk can't read clara's answers
PASS manager can read every answer
9 of 9 passed
If you see The extension isn't running, start cds watch again in unit06/order-assist. The data lives in memory, so every restart begins empty; the tests don't depend on earlier runs.
Now replace the rules with the AI API from Building an AI API. It runs with its sample model, so this is still free.
Open .env in the course folder. Add this line at the end and save:
AI_API_URL="http://127.0.0.1:8000"
ORCHESTRATE_API_KEY should already be in the same file. The extension sends it in the X-API-Key header, exactly as your earlier tests did.
In the second terminal, in the course folder, start the AI API:
python unit06/ai_api.py
You should see Uvicorn running on http://127.0.0.1:8000.
Go to the first terminal. Stop cds watch with Ctrl+C, then start it again so it reads the new .env line:
cds watch
Open a third terminal (Terminal > New Terminal). Ask for 4711 as clara twice, with the commands from Step 8, item 4. The $clara line must be run again in a new PowerShell terminal.
What success looks like (shortened):
..."explanation":"Order 4711 for Made-up Retail GmbH is on hold. Reason in SAP: Credit limit exceeded. It is worth 12,500.00 EUR. The responsible team must review it before it can ship.","nextStep":"credit_review","source":"model","model":"sample-model","requestId":"18abb262c8754bdea1b775208f956cfd"}
..."source":"cache","model":"sample-model","requestId":"f21ac979c1fa43c68a0324868f48cf9c"}
The first answer came from the AI API's model, the second from its cache. The AI API's requestId is stored with the answer, so you can find the call in both logs.
Make it fail on purpose. Stop the AI API in the second terminal with Ctrl+C, then ask for 4713 as clara. You still get an answer, with "source":"fallback", and the first terminal shows why:
[assist] - AI API unavailable for order 4713: fetch failed
[assist] - order 4713 explained for clara (source: fallback)
Run the tests again with the AI API on or off: 9 of 9 passed both ways. Stop everything with Ctrl+C in each terminal.
The extension runs on your laptop, against a mock, with made-up users. On SAP BTP, the same design maps to managed pieces. As of October 2026:
Concern
In this topic
On SAP BTP
Framework
CAP on Node.js
CAP on Cloud Foundry or Kyma; SAP's Architecture Center names a CAP-based backend for generative AI applications
S/4HANA API model
A hand-written slice
cds import of the API's EDMX into srv/external
S/4HANA connection
cds watch mocks the service
A destination in the production profile; for on-premise systems also the Connectivity service and Cloud Connector
Users and roles
Mocked users in package.json
Real sign-in through SAP's identity services; cds add xsuaa or cds add ias adds the configuration
Model access
The Python AI API
The same AI API on BTP, or SAP Cloud SDK for AI for JavaScript called from the handler
Prompts
Inside the AI API
The prompt registry, which SAP recommends over hard-coded prompts
Database
SQLite in memory
SAP HANA Cloud; cds add hana adds the configuration
Connecting to S/4HANA. CAP's documentation shows the remote service's real address going into a production profile, pointing at a destination by name. This sketch shows the shape for the Sales Order API, whose service path the SAP Cloud SDK lists as /sap/opu/odata/sap/API_SALES_ORDER_SRV:
Calling the model from CAP directly. SAP Cloud SDK for AI for JavaScript provides an OrchestrationClient in the @sap-ai-sdk/orchestration package. Its documentation shows a prompt template with placeholders, chatCompletion with placeholderValues, and getContent() for the answer. This sketch would replace askAiApi in the handler:
// Sketch: needs SAP AI Core (extended plan) and AICORE_SERVICE_KEY in .env, or a service binding on BTP.
// Install in order-assist with: npm install @sap-ai-sdk/orchestration
import { OrchestrationClient } from '@sap-ai-sdk/orchestration'
const client = new OrchestrationClient({
promptTemplating: {
model: { name: 'gpt-5' }, // use a model name from your own catalog
prompt: {
template: [{ role: 'user', content: 'In under 60 words, explain why sales order {{?order}} is blocked: {{?reason}}' }],
},
},
})
async function askModel (order, reasonText) {
const response = await client.chatCompletion({ placeholderValues: { order: order.salesOrder, reason: reasonText } })
return { explanation: response.getContent(), nextStep: 'contact_customer', source: 'model', model: 'gpt-5', requestId: null }
}
CAP's AI plugin. As of October 2026, cds add ai in the CAP development kit (version 10.1) adds the @cap-js/ai plugin. Its README describes value-help recommendations using SAP-RPT-1 through SAP AI Core, and an AICore CAP service for resource groups and deployments. It needs an SAP AI Core binding in production. It does not replace your own action for a task like "explain this order".
Licensing notes. SAP AI Core's extended plan, the Destination service, identity services and SAP HANA Cloud are separate BTP entitlements. Check what your company's BTP contract includes before you design around any of them.
Two layers of authorization. CAP roles decide who may use the extension. S/4HANA authorizations decide what data comes back. With one technical user for everyone, S/4HANA sees only that user, and every clerk can reach every order the technical user can. Unit 7 covers passing the user's identity through.
Instance checks for actions. CAP's documentation notes that bound actions support only simple, static where conditions in @restrict. For "only the author may change this", check in the handler, as the exercise does.
Mocks never ship. The mock serves the Sales Order API without sign-in on /odata/v4/api-sales-order-srv. That is fine on a laptop and must not exist in production. The production profile replaces it with the destination.
Mocked users never ship. Passwords in package.json are for development. Production uses SAP's identity services, with role collections assigned by administrators.
Released APIs only. Use APIs SAP has released for customer use, and check their state on SAP Business Accelerator Hub. Unreleased services are a clean-core and upgrade risk.
Data stored in the extension.Explanations holds customer-related text. Set a retention period, restrict who may read it, and include it in data-protection reviews.
Timeouts and fallbacks. The AI call has a timeout shorter than the user's patience, and the S/4HANA call needs one too. Every failure path returns a labeled answer or a clear error.
Evaluation. The tests prove the access rules. They don't prove the explanations are good. Ratings, added in the exercise, feed the evaluation harness in Unit 8.
Cost. Every explain may call a paid model. The AI API's cache and rate limit apply; also consider caching per order in the extension.
Operations. CAP, Node.js and the libraries need regular upgrades. CAP's release notes announce supported Node.js versions; plan for them, as the Unit 6 setup describes.
Clean core. The extension reads S/4HANA through a released API and stores its own data on BTP. Nothing in S/4HANA changes.
Copying the whole remote entity into your service. Expose a consumption view with the fields you need, in your own names.
Putting authorization in if statements only. Use @requires and @restrict first; code checks are for what annotations can't express.
Forgetting that != keeps empty values. In our test, a filter on "not empty text" still returned the order with no block from the mock, which sends a missing value. Test filters against both.
Hard-coding block codes. Codes and texts differ per system. Read them from the system.
Calling the model in a READ handler. Listing 50 orders would make 50 paid calls. Keep AI behind an explicit action.
Trusting the mock too much. Real APIs bring paging, slow responses, authorization errors and CSRF tokens on writes. Test against a sandbox or test system before go-live.
Starting cds watch in the wrong folder. It serves whatever project it finds. Run it in unit06/order-assist.
Leaving the AI URL pointing at your laptop.127.0.0.1 means "this computer". On BTP the AI service has its own address, set per environment.
You will add a rate action so a clerk can mark an explanation as helpful or not, with a short comment. Only the clerk who asked may rate it. These ratings become test data for the evaluation harness in Unit 8.
Open unit06/order-assist/db/schema.cds. Below the requestId line, add two fields and save:
helpful : Boolean; // the clerk's rating
comment : String(200);
Open srv/assist-service.cds. Change the Explanations part at the end so it reads as follows, and save. The new grant: 'rate' line lets clerks call the action; the handler checks who wrote the answer.
// Clerks see their own explanations; managers see all of them.
@readonly
@(restrict: [
{ grant: 'READ', to: 'OrderClerk', where: 'createdBy = $user' },
{ grant: 'READ', to: 'OrderManager' },
{ grant: 'rate', to: 'OrderClerk' }
])
entity Explanations as projection on my.Explanations
actions {
action rate(helpful : Boolean, comment : String(200)) returns Explanations;
};
}
Open srv/assist-service.js. Just above the line return super.init(), add this handler and save:
// Exercise: a clerk rates one of their own explanations.
this.on('rate', Explanations, async req => {
const [key] = req.params
const ID = typeof key === 'object' ? key.ID : key
const row = await SELECT.one.from(Explanations, ID)
if (!row) return req.reject(404, 'No such explanation')
if (row.createdBy !== req.user.id) return req.reject(403, 'You can only rate your own explanations')
await UPDATE(Explanations, ID).with({ helpful: req.data.helpful, comment: req.data.comment ?? null })
return SELECT.one.from(Explanations, ID)
})
Open unit06/test_order_assist.py. Just above the line that starts with print(f"\n{sum(results)}, add these lines (indented like the lines around them) and save:
status, _ = call(f"/Explanations({answer.get('ID')})/rate", "sam", "POST", {"helpful": True})
results.append(check("a clerk can't rate someone else's answer -> 403", status == 403, status))
status, rated = call(f"/Explanations({answer.get('ID')})/rate", "clara", "POST", {"helpful": False, "comment": "Wrong reason"})
results.append(check("clerk rates her own answer", status == 200 and (rated or {}).get("helpful") is False, status))
Start cds watch in unit06/order-assist (it may have restarted on its own), then run the tests from the course folder:
In unit06, create extension_design.md with three short sections: Roles (each role and what it may do), Data stored (each field in Explanations and why), and APIs used (the S/4HANA API and the fields read). This goes into the solution design document later in this unit.
Commit your work from the course folder:
git add unit06/order-assist unit06/test_order_assist.py unit06/extension_design.md
git commit -m "Add the order-assist CAP extension with roles, ratings and tests"
git status should not list node_modules or .env.
Done when:python unit06/test_order_assist.py prints 11 of 11 passed, maria can filter the unhelpful answers in the browser, and extension_design.md is committed with all three sections filled in.
Pick one answer for each question. The explanation appears after you choose.
1In the order-assist design, which job does NOT belong in the CAP extension?
Answer: C. The extension translates, checks roles, calls the AI safely and remembers. Business rules for releasing an order stay in S/4HANA, the system of record. If the extension takes on that job, the core and the extension start to disagree.
2What does CAP do with an imported remote service like API_SALES_ORDER_SRV during development?
Answer: B. cds watch mocks imported services from files in srv/external/data. Your handler calls cds.connect.to('API_SALES_ORDER_SRV') and can't tell mock from real, so the same code later works against a destination.
3Why does the topic expose BlockedOrders as a consumption view instead of the whole A_SalesOrder entity?
Answer: D. A consumption view keeps S/4HANA's names in one file and shows callers only what they need. CAP translates names back, so a filter on deliveryBlock reached the mock as DeliveryBlockReason.
4victor, who has no roles, calls the explain action. What happens, and where is that decided?
Answer: B. victor is signed in, so it is not 401, and he can read orders, so not 404. The @(requires: 'OrderClerk') annotation makes CAP refuse him with 403 before the handler runs, which keeps the rule visible in the model.
5How does the extension make sure a clerk only sees their own explanations?
Answer: C. The where: 'createdBy = $user' condition in @restrict is an instance-based rule. CAP adds it to every query, and createdBy is filled by the managed aspect from the signed-in user.
6The AI API is down when clara asks about order 4713. What does the extension return?
Answer: D. askAiApi has an 8-second timeout, and the handler catches any failure. It stores and returns ruleAnswer(order, 'fallback') and logs why, so the clerk still gets a labeled answer.
7A colleague wants the extension to call the model in the READ handler, so every listed order shows an explanation. What would you advise?
Answer: B. A READ handler runs for every list request, so 50 orders could mean 50 paid model calls and a slow screen. An explicit action keeps cost and control in the user's hands.
8Your extension reads S/4HANA through one technical user for all clerks. What is the main risk?
Answer: D. With one technical user, S/4HANA only sees that user, so every clerk can reach everything it can. CAP roles control the extension, but S/4HANA authorizations need the real user's identity, which Unit 7 covers.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
Sources
Exploring the SAP S/4HANA Cloud Extensibility Model (learning.sap.com)— key-user, classic, developer and side-by-side extensibility; classic only for private edition and on-premise; side-by-side on SAP BTP with CAP, Java or Node.js, using released APIs, with an independent lifecycle
Consuming Services (CAP documentation, capire)— cds import of an EDMX into srv/external; cds.requires entry with kind odata-v2 and model; mock data in srv/external/data; cds mock in a separate process; cds.connect.to and run; destination credentials in the production profile; cds add xsuaa,destination,connectivity for on-premise
CAP-Level Service Integration (CAP documentation, capire)— remote services are proxied by CAP services and used like local ones; imported services are mocked out of the box; consumption views map remote definitions to your domain
Custom Actions and Functions (CAP documentation, capire)— bound actions declared in an actions block; Node.js handlers with this.on('action', 'Entity', ...), params and data; bound actions are called with the service name prefix in OData v4
CAP-level Authorization (CAP documentation, capire)— @requires and @restrict with grant, to and where; pseudo roles authenticated-user and any; instance-based where conditions with $user; grant accepts action names
Authentication (CAP documentation, capire)— mocked authentication in development with basic authentication; custom mocked users with password, roles and attr in cds.requires.auth.users; 401 for missing credentials, 403 for missing permissions
Chat Completion (SAP Cloud SDK for AI, JavaScript)— OrchestrationClient from @sap-ai-sdk/orchestration; promptTemplating with model name and template; chatCompletion with placeholderValues; getContent and getTokenUsage
@sap/cloud-sdk-vdm-sales-order-service (npm, SAP Cloud SDK)— entity A_SalesOrder of API_SALES_ORDER_SRV at /sap/opu/odata/sap/API_SALES_ORDER_SRV; fields SalesOrder, SoldToParty, TotalNetAmount, TransactionCurrency, DeliveryBlockReason, HeaderBillingBlockReason with their lengths