An agent does things in SAP through tools: small pieces of software that call an SAP API on the model's behalf. Tool design for agents covered how to make a tool easy for a model to choose. This topic covers what makes a tool safe to point at a real SAP system.
Five rules carry most of the weight:
Separate reading from changing. Read tools look things up. Anything that changes SAP gets its own tool, its own permission check and a person's approval.
Act as the user, not as a superuser. A tool should never show or do more than the person asking could do in SAP themselves.
Check every input in code. The model's arguments are suggestions. Your code checks them before anything reaches SAP.
Make changes safe to repeat. Networks fail and agents retry. A repeated request must not create a second order or a second release.
Write everything down. Every call is logged: who asked, which tool, with what, and what happened.
There is a sixth rule that is specific to SAP. As of October 2026, SAP's API policy restricts using its APIs with AI systems that decide on their own which calls to make, except through routes SAP endorses. Whether your agent fits is a question for your architects and your SAP account team, before go-live rather than after.
Take the running order-to-cash example: an agent that helps clerks with blocked sales orders.
A team builds a quick prototype. It connects the agent to the Sales Order API with one technical user that can read and change every order in the company. It works in the demo. Then the questions start:
Data exposure. A clerk in Germany asks about "all blocked orders" and sees US orders, with customer names and amounts. SAP would never show her those screens. The agent did, because it used someone else's access.
Unwanted changes. The model misreads "can we release 4711?" as an instruction. With a write tool and no approval step, the order is released. OWASP calls this excessive agency: too much functionality, too many permissions, too much autonomy.
Duplicates. A network timeout makes the agent retry a "create" call. Now there are two release requests, or worse, two orders.
No trail. The auditor asks who released the order. The SAP log shows the technical user. Nobody knows which person asked, or what the model was told.
Contract risk. SAP's API policy allows SAP to throttle or suspend API access that breaks its rules. An agent architecture that ignores the policy puts the integration itself at risk.
Each of these is cheap to design out early and expensive to fix after an incident. The decision for a leader: fund the controls around the tools as part of the agent, not as a later hardening phase.
As of October 2026, SAP's own answer has three parts. Product names and scope are moving quickly here, so check the current state before you plan.
The API policy. SAP's API Policy (version 4.2026a) says published APIs are the ones on the SAP Business Accelerator Hub or named in product documentation. Unpublished interfaces must not be used. It then prohibits using APIs with "(semi-) autonomous or generative AI systems that plan, select, or execute sequences of API calls", except through SAP-endorsed architectures or pathways. It also forbids getting around the rules through proxies, gateways or custom code.
Endorsed routes. In a July 2026 overview for the ASUG user group, SAP pointed customers to three routes: connecting agents to Joule agents through the A2A protocol, the MCP Gateway in SAP Integration Suite, and SAP Business Data Cloud for data extraction. ASUG reported that the MCP Gateway was released on 5 July 2026 and that an Agent Gateway was still to come.
Identity. SAP's Architecture Center describes two kinds of agent: agents acting in a user's context, and autonomous agents with their own technical identity. In its design, a gateway checks the agent's permissions together with the user's, and a third-party agent never receives backend credentials. SAP's page dates the policy enforcement point between agents and APIs to the second half of 2026.
Not every tool carries the same risk. Sort each tool into one of three kinds before anyone builds it.
Read tool
Draft tool
Change tool
What it does
Looks something up
Prepares a request for a person
Changes SAP data
Example
"Which of my orders are blocked?"
"Draft a release request for 4711"
Release the order in SAP
Who decides
The model may call it freely
The model may call it; a person approves
Only after a person approved this exact request
Identity
The user's own SAP access
The user, recorded on the draft
The approver's, or a named technical identity with a narrow scope
Repeat safety
Safe by nature
One draft per order, even if called twice
Must not run twice
Log
Who, what, when
Plus the draft and its reason
Plus who approved, and what SAP answered
Offered to
Every agent that needs it
Agents for users allowed to request
Never to the model directly
The clearest pattern for a first agent: read tools plus draft tools, and no change tools at all. People approve drafts in the normal SAP process. For sales documents, SAP already has one: when an approval reason is set and a workflow is defined, SAP's field documentation says the document can't be released until an approver has seen it.
"The agent uses a technical user, so it's safe." A technical user usually sees more than any single clerk. The agent then shows each clerk more than SAP would.
"The model knows not to change things." A prompt is a request, not a control. OWASP recommends putting the authorization in the downstream system, not in the model's judgment.
"Read-only tools are harmless." They can still leak data across sales organizations, or flood SAP with calls. Scope and limits apply to reads too.
"Retries are a network detail." For any tool that creates something, a retry without protection is a duplicate.
"The API policy only concerns vendors." It applies to how APIs are used, including agents a customer builds itself.
Pick one answer for each question. The explanation appears after you choose.
1Why should tools that change SAP data be separate from tools that read it?
Answer: A. Keeping changes in their own tools makes it easy to require a permission check, a person's approval and a specific audit line, and to leave them out of read-only agents. It is not a technical limit of SAP's APIs.
2A prototype agent reads SAP with one technical user that can see every sales organization. What is the main risk?
Answer: C. A technical user usually sees more than any one clerk, so the agent shows each person more than SAP would. The tool should act in the user's scope, ideally with the user's own SAP authorizations.
3The agent times out while creating a release request and tries again. What prevents a duplicate?
Answer: D. Retries are normal, so the server or tool layer must recognize a repeated request. An idempotency key lets it return the first result instead of running the action again. A prompt cannot control network failures.
4Which first design best fits an agent that helps clerks with blocked orders?
Answer: B. Read and draft tools let the agent do the research and the paperwork, while a person makes the decision. A general change tool is excessive agency, and a prompt is not a control.
5Your team plans an agent that decides on its own which SAP APIs to call. What should you check first?
Answer: C. As of October 2026, SAP's API policy restricts using its APIs with AI systems that plan, select or execute sequences of API calls, except through SAP-endorsed routes. Check the design with your SAP account team before go-live.
6An auditor asks who released a sales order that the agent prepared. What makes that answerable?
Answer: D. An audit trail needs who asked, what was proposed, who approved and what SAP answered, recorded by your system at the time. A technical user in SAP's log or the model's later account does not identify the people involved.
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.
A naive tool is a pipe: the model's arguments go in one end, an SAP request comes out the other. Everything the model asks for, SAP receives.
A production tool is a gateway. The model's request is the start of a decision your code makes:
flowchart LR
M[Model asks:<br/>tool + arguments] --> O{Offered to<br/>this user?}
O -- no --> X[Denied]
O -- yes --> V{Arguments<br/>valid?}
V -- no --> E[Error the model<br/>can fix]
V -- yes --> B{Within<br/>budget?}
B -- no --> S[Stop]
B -- yes --> R[Call SAP in the<br/>user's scope]
R --> C[Check, shape,<br/>return]
X --> L[(Audit log)]
E --> L
S --> L
C --> L
The model chooses. Your code decides. OWASP's term for the principle is complete mediation: authorization happens in the systems downstream of the model, not in the model's judgment. Everything in this topic is a box in that diagram.
OWASP's guidance for excessive agency starts with the tool list itself: offer only the tools the job needs, and prefer narrow tools to open-ended ones. For SAP, three kinds cover almost every case:
Kind
HTTP to SAP
Model may call it?
Extra controls
Read
GET only
Yes
Scope, limits, log
Draft
None; writes to your own store
Yes, if the user may request
Idempotent; the draft records who and why
Change
POST, PATCH, DELETE
No. Your approval step calls it
Approval of this exact request, idempotent, concurrency check, log
Two rules follow:
Offer tools per user. A tool list is not global. An auditor's agent gets read tools only. The model can't call a tool it was never offered, and your gateway refuses it even if it tries.
Change tools sit outside the model's reach. The Sales Order API itself has actions that change state. SAP's generated model of API_SALES_ORDER_SRV lists two function imports, releaseApprovalRequest and rejectApprovalRequest, sent as POST with a SalesOrder parameter. They belong to SAP's approval process for sales documents. An agent should never hold a tool that calls them; a person's approval step does, after a decision.
There are three options, from weakest to strongest:
One technical user for everyone. Simple and common in prototypes. SAP sees the technical user, so SAP's authorizations can't tell clerks apart. Your tool layer must filter by the user's scope, and any bug leaks data.
Technical user plus scope in code. Your gateway knows the signed-in user and their allowed sales organizations, adds them to every query and checks every result. Better, but your code now duplicates SAP's authorization logic.
The user's own identity (principal propagation). The BTP destination exchanges the user's token so SAP itself checks each call against that user's roles. OWASP's advice to execute actions in the user's context points here. The mechanics are in Grounding on SAP data with authorizations.
SAP's Architecture Center adds the agent's own identity to the picture. It describes agents acting in a user's context and autonomous agents with a dedicated technical identity, both managed in SAP Cloud Identity Services. In its design, the Agent Gateway enforces the intersection of the user's and the agent's permissions: the agent can only do what both the user and the agent are allowed to do.
The lab uses option 2, because SAP's sandbox has one shared API key. It filters before asking SAP (the user's sales organizations go into $filter) and checks after (every returned record's SalesOrganization must be in scope). Option 3 keeps both checks as defence in depth.
Each error says what to do next, as in Tool design for agents. One special case: an order outside the user's scope gets the same answer as an order that doesn't exist: "does not exist or you may not see it". Otherwise the agent becomes a way to test which order numbers exist in other sales organizations.
A retry is not an edge case. Timeouts, a crashed agent loop or a model that repeats itself all send the same request twice. For each kind of tool:
Reads are safe to repeat. The lab retries a GET once when SAP answers "busy" (HTTP 429, 502, 503 or 504).
Drafts use an idempotency key. The lab computes it from the user, the action and the order number, so asking twice returns the existing draft with "duplicate": true.
Changes need protection on the SAP side too. SAP Gateway documents a setting for idempotent services: the client sends a 32-character GUID as RequestID with a RepeatabilityCreation header, and the server runs the request once and answers repeats; a RepeatabilityResult header says whether it did. Whether a given S/4HANA API supports this depends on the service and system, so test it. Where it isn't available, check before creating, for example by searching for the reference your draft carries.
Changes also need a concurrency check: the record should not have changed between the approval and the call. OData change operations on S/4HANA use ETags: you send the version from your last read in the If-Match header, and the call fails if someone changed the document since. That is exactly the check you want after an approval: if the order changed, ask the approver again.
sequenceDiagram
participant M as Model
participant G as Tool gateway
participant D as Draft store
participant P as Approver
participant S as SAP
M->>G: release_request_draft 4711
G->>S: GET order (user's scope)
G->>D: save draft, key = user+action+order
G-->>M: D-0001, nothing changed in SAP
M->>G: release_request_draft 4711 (retry)
G->>D: key exists
G-->>M: D-0001, duplicate
P->>D: approve D-0001
Note over P,S: later units: approval step reads ETag,<br/>sends change with If-Match and a request ID
The log answers four questions for every call: who (user, session), what (tool, arguments, kind), what happened (outcome, SAP requests made, draft created) and when. The lab writes one JSON line per call to tool_audit.jsonl. It logs arguments and outcomes, not the data SAP returned, because results can hold personal data. A hash of the arguments makes it easy to find repeated calls.
On SAP BTP, CAP's audit logging plugin, @cap-js/audit-logging, writes events such as SensitiveDataRead and PersonalDataModified to the SAP Audit Log Service. It queues messages in a transactional outbox, so a failed transaction doesn't leave a false entry.
Every session gets two budgets: at most 12 tool calls and at most 30 SAP requests. A looping agent hits a clear stop message instead of hammering SAP. SAP's API policy says each API's rate limits and quotas are documented per API, and that SAP may throttle or suspend non-compliant access. Your limits should sit well inside SAP's.
SAP's API Policy version 4.2026a shapes the architecture before any code:
Published APIs only (section 1). The Sales Order API is published on the SAP Business Accelerator Hub. Internal or private interfaces are out.
Agentic use goes through endorsed routes (section 2.2.2). The restriction covers AI systems that "plan, select, or execute sequences of API calls", which describes the agent loop in Agents from first principles. It applies except through SAP-endorsed architectures, data services or pathways.
No workarounds (section 3). The policy names proxies, gateways and custom code as ways it must not be circumvented.
The lab calls SAP's public sandbox for learning. For a customer system, record which APIs each tool calls, whether a model chooses the calls, and which endorsed route the design uses. The lab's tool register does the first part.
#Build it yourself: read-only SAP tools with a gateway
You will build unit09/sap_tools.py: three tools over SAP's Sales Order API, behind a gateway that checks who is asking, validates arguments, enforces limits and logs every call. Two tools read. One drafts a release request into a local file; it never changes SAP.
flowchart LR
U[User: ana, ben or kim] --> G[Gateway<br/>offer, validate, budget]
G --> T1[sales_order_list_blocked]
G --> T2[sales_order_get]
G --> T3[release_request_draft]
T1 --> SAP[(Sales Order API:<br/>sandbox or sample)]
T2 --> SAP
T3 --> SAP
T3 --> D[(release_drafts.json)]
G --> L[(tool_audit.jsonl)]
Instead of a model, a scripted demo plays the agent and makes the calls a model typically makes, including its mistakes. That way you see every control work without a model bill. The tools return the same JSON a model would read.
In VS Code's file list, right-click unit09, choose New File and name it sap_tools.py.
Paste the code below and save.
"""Unit 9: SAP tools for agents.
Three tools over SAP's Sales Order API (API_SALES_ORDER_SRV), wrapped in the controls an agent
needs: input validation, the user's scope, limits, an audit log, and an idempotent draft tool
that changes nothing in SAP.
python unit09/sap_tools.py tools --user ana what a model would be offered for Ana
python unit09/sap_tools.py demo --sample a scripted agent session on made-up data
python unit09/sap_tools.py demo the same against SAP's sandbox (SAP_API_KEY)
python unit09/sap_tools.py call sales_order_get sales_order=4711 --user ana --sample
python unit09/sap_tools.py audit summarize the audit log
Nothing here sends a change to SAP. Every request to SAP is a GET.
"""
import argparse
import hashlib
import json
import os
import re
import sys
import time
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from pathlib import Path
HERE = Path(__file__).resolve().parent
AUDIT_LOG = HERE / "tool_audit.jsonl"
DRAFTS = HERE / "release_drafts.json"
SERVICE = "/sap/opu/odata/sap/API_SALES_ORDER_SRV"
SANDBOX = "https://sandbox.api.sap.com/s4hanacloud" + SERVICE
HEADER_FIELDS = ["SalesOrder", "SalesOrganization", "SoldToParty", "CreationDate", "TotalNetAmount",
"TransactionCurrency", "DeliveryBlockReason", "HeaderBillingBlockReason",
"TotalCreditCheckStatus"]
ITEM_FIELDS = ["SalesOrderItem", "Material", "SalesOrderItemText", "RequestedQuantity",
"RequestedQuantityUnit", "NetAmount", "ItemBillingBlockReason"]
MAX_TOOL_CALLS = 12 # per session: an agent that loops stops here
MAX_SAP_REQUESTS = 30 # per session: protects SAP from a runaway agent
SCAN_ROWS = 50 # the most orders one search reads from SAP
# Made-up people the agent acts for. In a real system this comes from the signed-in user's
# token and SAP's own authorizations, never from the model.
USERS = {
"ana": {"display": "Ana, order clerk, sales org 1010", "sales_orgs": ["1010"], "roles": ["ORDER_CLERK"]},
"ben": {"display": "Ben, order clerk, sales org 1710", "sales_orgs": ["1710"], "roles": ["ORDER_CLERK"]},
"kim": {"display": "Kim, auditor, sales org 1010", "sales_orgs": ["1010"], "roles": ["AUDITOR"]},
}
class ToolError(Exception):
"""An expected problem. Its message goes back to the model, so it says what to do next."""
class SAPError(Exception):
"""SAP answered with an error, or could not be reached."""
# ------------------------------------------------------------------ talking to SAP
class LiveSAP:
"""Sends real GET requests to SAP's sandbox with your API key."""
name = "SAP sandbox (shared API key)"
def __init__(self, key: str):
import requests # imported here so --sample works without the library
self.requests = requests
self.session = requests.Session()
self.session.headers.update({"APIKey": key, "Accept": "application/json"})
def get(self, path: str, params: dict | None = None) -> dict:
for attempt in (1, 2): # GET is safe to repeat; retry once on "busy" answers
try:
resp = self.session.get(SANDBOX + path, params=params, timeout=30)
except self.requests.exceptions.RequestException as err:
raise SAPError(f"could not reach sandbox.api.sap.com ({type(err).__name__})")
if resp.status_code in (429, 502, 503, 504) and attempt == 1:
time.sleep(2)
continue
break
if resp.status_code == 404:
return {}
if resp.status_code != 200:
raise SAPError(f"HTTP {resp.status_code}: {resp.text[:200]}")
return resp.json().get("d", {})
class SampleSAP:
"""Answers like the Sales Order API would, from made-up data. Understands the few
query options this script uses: $filter (eq, ne, and), $select, $top, $orderby.
Dates sort correctly here because every sample date has the same number of digits."""
name = "made-up sample data"
def get(self, path: str, params: dict | None = None) -> dict:
params = params or {}
one = re.fullmatch(r"/A_SalesOrder\('(\d+)'\)", path)
child = re.fullmatch(r"/A_SalesOrder\('(\d+)'\)/(to_\w+)", path)
if one:
row = next((o for o in SAMPLE_ORDERS if o["SalesOrder"] == one.group(1)), None)
return select(row, params) if row else {}
if child and child.group(2) in SAMPLE_CHILDREN:
rows = SAMPLE_CHILDREN[child.group(2)].get(child.group(1), [])
elif path == "/A_SalesOrder":
rows = [o for o in SAMPLE_ORDERS if matches(o, params.get("$filter", ""))]
field, _, direction = params.get("$orderby", "SalesOrder").partition(" ")
rows.sort(key=lambda o: o.get(field, ""), reverse=direction == "desc")
else:
raise SAPError(f"HTTP 404: no such resource {path}")
rows = rows[:int(params.get("$top", len(rows)))]
return {"results": [select(r, params) for r in rows]}
def matches(row: dict, odata_filter: str) -> bool:
"""Evaluate a simple OData filter such as: SalesOrganization eq '1010' and SoldToParty eq '17100001'."""
for part in filter(None, odata_filter.split(" and ")):
m = re.fullmatch(r"(\w+) (eq|ne) '([^']*)'", part.strip())
if not m:
raise SAPError(f"HTTP 400: sample data can't read the filter {part!r}")
field, op, value = m.groups()
if (row.get(field, "") == value) != (op == "eq"):
return False
return True
def select(row: dict, params: dict) -> dict:
keep = params.get("$select")
return {k: row[k] for k in keep.split(",") if k in row} if keep else dict(row)
def ms(day: str) -> str:
"""Write a date the way OData V2 JSON does: /Date(milliseconds since 1970)/."""
stamp = datetime.fromisoformat(day).replace(tzinfo=timezone.utc).timestamp()
return f"/Date({int(stamp * 1000)})/"
def order(no, org, customer, day, amount, delivery="", billing="", credit=""):
return {"SalesOrder": no, "SalesOrganization": org, "SoldToParty": customer, "CreationDate": ms(day),
"TotalNetAmount": amount, "TransactionCurrency": "EUR" if org == "1010" else "USD",
"DeliveryBlockReason": delivery, "HeaderBillingBlockReason": billing,
"TotalCreditCheckStatus": credit}
SAMPLE_ORDERS = [
order("4711", "1010", "10100001", "2026-09-28", "18250.00", delivery="01", credit="B"),
order("4712", "1010", "10100002", "2026-09-29", "940.00", billing="02"),
order("4715", "1010", "10100001", "2026-09-30", "5100.00"),
order("4718", "1010", "10100003", "2026-10-01", "7300.00", delivery="01"),
order("4801", "1710", "17100001", "2026-09-30", "22400.00", delivery="01", credit="B"),
order("4802", "1710", "17100002", "2026-10-01", "3150.00"),
]
SAMPLE_ITEMS = {
"4711": [{"SalesOrderItem": "10", "Material": "TG11", "SalesOrderItemText": "Trading good 11",
"RequestedQuantity": "50", "RequestedQuantityUnit": "PC", "NetAmount": "18250.00",
"ItemBillingBlockReason": ""}],
"4718": [{"SalesOrderItem": "10", "Material": "TG12", "SalesOrderItemText": "Trading good 12",
"RequestedQuantity": "20", "RequestedQuantityUnit": "PC", "NetAmount": "7300.00",
"ItemBillingBlockReason": ""}],
}
SAMPLE_CHILDREN = {"to_Item": SAMPLE_ITEMS} # navigation name -> records per sales order
# ------------------------------------------------------------------ helpers for the tools
def odata_date(value: str | None) -> str | None:
m = re.search(r"/Date\((-?\d+)", value or "")
if not m:
return value
return datetime.fromtimestamp(int(m.group(1)) / 1000, tz=timezone.utc).date().isoformat()
def blocks(row: dict) -> list[str]:
"""Which blocks are set. Codes stay raw: what '01' means is configured in each system."""
found = []
if row.get("DeliveryBlockReason"):
found.append(f"delivery block, reason code {row['DeliveryBlockReason']}")
if row.get("HeaderBillingBlockReason"):
found.append(f"billing block, reason code {row['HeaderBillingBlockReason']}")
if row.get("TotalCreditCheckStatus") == "B":
found.append("credit check status B")
return found
def header_summary(row: dict) -> dict:
return {"sales_order": row["SalesOrder"], "customer": row.get("SoldToParty"),
"sales_org": row.get("SalesOrganization"), "created_on": odata_date(row.get("CreationDate")),
"net_amount": str(Decimal(row.get("TotalNetAmount") or "0")),
"currency": row.get("TransactionCurrency"), "blocks": blocks(row)}
def read_visible_order(ctx, number: str) -> dict:
"""Read one header and check it is in the user's scope. The same answer for 'missing' and
'not yours', so the agent can't learn that an order exists in another sales org."""
row = ctx.sap_get(f"/A_SalesOrder('{number}')", {"$select": ",".join(HEADER_FIELDS)})
if not row or row.get("SalesOrganization") not in ctx.user["sales_orgs"]:
raise ToolError(f"Sales order {number} does not exist or you may not see it. Check the number "
"with the user; to find their blocked orders, use sales_order_list_blocked.")
return row
# ------------------------------------------------------------------ the tools
TOOLS = {}
def tool(name, kind, description, params, required=(), roles=("ORDER_CLERK", "AUDITOR")):
"""Register a tool with what the model sees (name, description, schema) and what only
your code sees (kind, roles, the SAP API it calls)."""
def register(fn):
TOOLS[name] = {"fn": fn, "kind": kind, "roles": set(roles), "sap_api": "API_SALES_ORDER_SRV",
"definition": {"name": name, "description": description, "parameters": {
"type": "object", "properties": params, "required": list(required),
"additionalProperties": False}}}
return fn
return register
@tool("sales_order_list_blocked", "read",
"List the user's sales orders that have a delivery block, a billing block or credit check "
"status B, newest first. Returns order number, customer, amount and the blocks in words. "
"Use it when the user asks which orders are stuck. Read-only; it only sees the user's own "
"sales organizations and scans at most the 50 newest orders per organization.",
{"customer": {"type": ["string", "null"], "pattern": r"^\d{1,10}$",
"description": "Optional customer number (SoldToParty), digits only, e.g. '10100001'."},
"limit": {"type": ["integer", "null"], "minimum": 1, "maximum": 20,
"description": "How many orders to return, 1 to 20. Default 10."}})
def sales_order_list_blocked(ctx, customer=None, limit=None):
limit = limit or 10
found, scanned = [], 0
for org in ctx.user["sales_orgs"]: # scope comes from the user, never from the model
flt = f"SalesOrganization eq '{org}'" + (f" and SoldToParty eq '{customer}'" if customer else "")
page = ctx.sap_get("/A_SalesOrder", {"$filter": flt, "$select": ",".join(HEADER_FIELDS),
"$orderby": "CreationDate desc", "$top": str(SCAN_ROWS)})
rows = page.get("results", [])
scanned += len(rows)
found += [header_summary(r) for r in rows
if r.get("SalesOrganization") in ctx.user["sales_orgs"] and blocks(r)] # check after
result = {"orders": found[:limit], "blocked_found": len(found), "orders_scanned": scanned}
if len(found) > limit:
result["note"] = f"Showing {limit} of {len(found)}. Raise limit (max 20) or filter by customer."
if not found:
result["note"] = "No blocked orders in the scanned orders. This is a valid answer, not an error."
return result
@tool("sales_order_get", "read",
"Read one sales order: customer, amount, creation date, the blocks in words, and up to 20 "
"items with material, quantity and value. Use it after sales_order_list_blocked, or when the "
"user names an order. Read-only. Order numbers are digits only, without prefixes.",
{"sales_order": {"type": "string", "pattern": r"^\d{1,10}$",
"description": "Sales order number, digits only, e.g. '4711'. Remove prefixes like 'SO-'."}},
required=("sales_order",))
def sales_order_get(ctx, sales_order):
row = read_visible_order(ctx, sales_order)
items = ctx.sap_get(f"/A_SalesOrder('{sales_order}')/to_Item",
{"$select": ",".join(ITEM_FIELDS), "$top": "20"}).get("results", [])
summary = header_summary(row)
summary["items"] = [{"item": i.get("SalesOrderItem"), "material": i.get("Material"),
"text": i.get("SalesOrderItemText"),
"quantity": str(Decimal(i.get("RequestedQuantity") or "0")),
"unit": i.get("RequestedQuantityUnit"),
"net_amount": str(Decimal(i.get("NetAmount") or "0"))} for i in items]
return summary
@tool("release_request_draft", "draft",
"Draft a request to release one blocked sales order, for a person to review and approve. "
"It does NOT change anything in SAP and does not release the order. Calling it again for the "
"same order returns the existing draft instead of making a second one. Use it only after "
"reading the order with sales_order_get, and only when the user asks for a release.",
{"sales_order": {"type": "string", "pattern": r"^\d{1,10}$",
"description": "Sales order number, digits only, e.g. '4711'."},
"justification": {"type": "string", "minLength": 20, "maxLength": 500,
"description": "Why the order should be released, in the user's words, 20 to 500 characters."}},
required=("sales_order", "justification"), roles=("ORDER_CLERK",))
def release_request_draft(ctx, sales_order, justification):
row = read_visible_order(ctx, sales_order)
if not blocks(row):
raise ToolError(f"Sales order {sales_order} has no block, so there is nothing to release. "
"Tell the user; don't draft a request.")
# Idempotency key: the same user asking for the same action on the same order is one request.
key = hashlib.sha256(f"{ctx.user_id}|release|{sales_order}".encode()).hexdigest()[:16]
drafts = json.loads(DRAFTS.read_text(encoding="utf-8")) if DRAFTS.exists() else {}
if key in drafts:
return {"draft_id": drafts[key]["draft_id"], "status": drafts[key]["status"], "duplicate": True,
"message": "A draft for this order already exists; no second draft was made. Nothing in SAP changed."}
draft = {"draft_id": f"D-{len(drafts) + 1:04d}", "status": "awaiting_approval", "sales_order": sales_order,
"requested_by": ctx.user_id, "justification": " ".join(justification.split()),
"order_snapshot": header_summary(row), "created_at": now(), "idempotency_key": key}
drafts[key] = draft
DRAFTS.write_text(json.dumps(drafts, indent=2), encoding="utf-8")
return {"draft_id": draft["draft_id"], "status": draft["status"], "duplicate": False,
"message": "Draft saved for approval. Nothing in SAP changed; a person decides."}
# ------------------------------------------------------------------ the gateway every call goes through
def now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
class Session:
"""One agent session for one user: holds the SAP connection, counters and the audit trail."""
def __init__(self, api, user_id: str):
if user_id not in USERS:
sys.exit(f"Unknown user {user_id!r}. Choose one of: {', '.join(USERS)}")
self.api, self.user_id, self.user = api, user_id, USERS[user_id]
self.session_id = uuid.uuid4().hex[:8]
self.tool_calls = self.sap_requests = 0
self.requests_this_call = []
def offered_tools(self) -> list[dict]:
"""Only the tools this user's roles allow. A read-only user never sees a draft tool."""
return [t["definition"] for t in TOOLS.values() if t["roles"] & set(self.user["roles"])]
def sap_get(self, path: str, params: dict) -> dict:
if self.sap_requests >= MAX_SAP_REQUESTS:
raise ToolError("The SAP request budget for this session is used up. Stop and report what you have.")
self.sap_requests += 1
self.requests_this_call.append(path)
return self.api.get(path, params)
def call(self, name: str, args: dict) -> dict:
started, self.requests_this_call = time.perf_counter(), []
entry = TOOLS.get(name)
try:
if entry is None or not entry["roles"] & set(self.user["roles"]):
raise PermissionError(f"Tool {name} is not offered to this user.")
self.tool_calls += 1
if self.tool_calls > MAX_TOOL_CALLS:
raise ToolError("Tool call limit for this session reached. Stop and summarize for the user.")
clean = validate(entry["definition"]["parameters"], args)
answer = {"ok": True, "result": entry["fn"](self, **clean)}
outcome = "ok"
except PermissionError as err:
answer, outcome = {"ok": False, "error": str(err)}, "denied"
except ToolError as err:
answer, outcome = {"ok": False, "error": str(err)}, "tool_error"
except SAPError as err:
answer, outcome = {"ok": False, "error": f"SAP could not answer ({err}). Try again later "
"or tell the user the system is unavailable."}, "sap_error"
self.audit(name, entry, args, outcome, answer, started)
return answer
def audit(self, name, entry, args, outcome, answer, started) -> None:
"""One line per tool call: who, what, with which arguments, what happened. No results."""
line = {"ts": now(), "session": self.session_id, "user": self.user_id, "tool": name,
"kind": entry["kind"] if entry else None, "args": args,
"args_sha256": hashlib.sha256(json.dumps(args, sort_keys=True).encode()).hexdigest()[:16],
"outcome": outcome, "sap_requests": self.requests_this_call, "backend": self.api.name,
"ms": round((time.perf_counter() - started) * 1000)}
result = answer.get("result") or {}
if "orders" in result:
line["rows_returned"] = len(result["orders"])
if "draft_id" in result:
line["draft_id"] = result["draft_id"]
with AUDIT_LOG.open("a", encoding="utf-8") as f:
f.write(json.dumps(line) + "\n")
def validate(schema: dict, args: dict) -> dict:
"""Check the model's arguments against the tool's schema before anything reaches SAP."""
props = schema["properties"]
unknown = set(args) - set(props)
if unknown:
raise ToolError(f"Unknown argument(s): {', '.join(sorted(unknown))}. Allowed: {', '.join(props)}.")
for name in schema["required"]:
if args.get(name) in (None, ""):
raise ToolError(f"{name} is required. {props[name]['description']}")
clean = {}
for name, value in args.items():
rule, types = props[name], props[name]["type"]
types = types if isinstance(types, list) else [types]
if value is None and "null" in types:
continue
if "integer" in types:
if isinstance(value, bool) or not isinstance(value, int):
raise ToolError(f"{name} must be a whole number. {rule['description']}")
if not rule["minimum"] <= value <= rule["maximum"]:
raise ToolError(f"{name} must be between {rule['minimum']} and {rule['maximum']}; you sent {value}.")
elif not isinstance(value, str):
raise ToolError(f"{name} must be text. {rule['description']}")
else:
value = value.strip()
if "pattern" in rule and not re.fullmatch(rule["pattern"], value):
raise ToolError(f"{name} has the wrong format: you sent {value[:30]!r}. {rule['description']}")
if not rule.get("minLength", 0) <= len(value) <= rule.get("maxLength", 10_000):
raise ToolError(f"{name} must be {rule.get('minLength', 0)} to {rule.get('maxLength', 10_000)} characters; "
f"you sent {len(value)}.")
clean[name] = value
return clean
# ------------------------------------------------------------------ commands
def show(label: str, answer: dict) -> None:
print(f"\n> {label}")
print(" " + json.dumps(answer, indent=2, ensure_ascii=False).replace("\n", "\n "))
def find_other_org_order(api, user) -> str:
"""For the demo only: find an order outside the user's scope, by asking SAP directly."""
orgs = " and ".join(f"SalesOrganization ne '{o}'" for o in user["sales_orgs"])
rows = api.get("/A_SalesOrder", {"$filter": orgs, "$select": "SalesOrder", "$top": "1"}).get("results", [])
return rows[0]["SalesOrder"] if rows else "9999999999"
def demo(api) -> None:
ana = Session(api, "ana")
print(f"Backend: {api.name}")
print(f"Acting for: {ana.user['display']} (session {ana.session_id})")
if isinstance(api, LiveSAP):
print("Note: every sandbox call uses SAP's shared API key, not Ana's identity. Her scope is "
"enforced only by this tool layer here; in production, SAP checks her own authorizations too.")
print(f"Tools offered: {', '.join(t['name'] for t in ana.offered_tools())}")
listed = ana.call("sales_order_list_blocked", {"limit": 5})
show("1. sales_order_list_blocked limit=5", listed)
orders = listed.get("result", {}).get("orders", [])
first = orders[0]["sales_order"] if orders else "4711"
show(f"2. sales_order_get sales_order='SO-{first}' (a typical model mistake)",
ana.call("sales_order_get", {"sales_order": f"SO-{first}"}))
show(f"3. sales_order_get sales_order='{first}'", ana.call("sales_order_get", {"sales_order": first}))
other = find_other_org_order(api, ana.user)
show(f"4. sales_order_get sales_order='{other}' (an order outside Ana's sales org)",
ana.call("sales_order_get", {"sales_order": other}))
show("5. sales_order_list_blocked limit=500 (out of range)",
ana.call("sales_order_list_blocked", {"limit": 500}))
why = "Customer paid the overdue invoice today; finance confirmed by phone."
if orders:
show(f"6. release_request_draft sales_order='{first}'",
ana.call("release_request_draft", {"sales_order": first, "justification": why}))
show(f"7. release_request_draft sales_order='{first}' again (a retry)",
ana.call("release_request_draft", {"sales_order": first, "justification": why}))
else:
print("\n> 6-7. skipped: no blocked orders were found, so there is nothing to draft.")
kim = Session(api, "kim")
print(f"\nActing for: {kim.user['display']} (session {kim.session_id})")
print(f"Tools offered: {', '.join(t['name'] for t in kim.offered_tools())}")
show(f"8. release_request_draft sales_order='{first}' (not offered to an auditor)",
kim.call("release_request_draft", {"sales_order": first, "justification": why}))
print(f"\nAudit log: {AUDIT_LOG.name}. Drafts: {DRAFTS.name}. Nothing was sent to SAP except GET requests.")
def parse_pairs(pairs: list[str], name: str) -> dict:
"""Turn key=value words into arguments; numbers become integers where the schema expects them."""
props = TOOLS[name]["definition"]["parameters"]["properties"] if name in TOOLS else {}
args = {}
for pair in pairs:
key, sep, value = pair.partition("=")
if not sep:
sys.exit(f"Write arguments as key=value, for example sales_order=4711 (got {pair!r}).")
types = props.get(key, {}).get("type", [])
args[key] = int(value) if "integer" in types and value.lstrip("-").isdigit() else value
return args
def audit_summary() -> None:
if not AUDIT_LOG.exists():
sys.exit("No audit log yet. Run the demo first.")
lines = [json.loads(x) for x in AUDIT_LOG.read_text(encoding="utf-8").splitlines() if x.strip()]
print(f"{len(lines)} tool calls in {AUDIT_LOG.name}\n")
print(f"{'time (UTC)':20} {'user':5} {'tool':26} {'outcome':11} {'SAP calls':>9}")
for x in lines[-15:]:
print(f"{x['ts'][:19]:20} {x['user']:5} {x['tool']:26} {x['outcome']:11} {len(x['sap_requests']):>9}")
counts = {}
for x in lines:
counts[x["outcome"]] = counts.get(x["outcome"], 0) + 1
print("\nBy outcome: " + ", ".join(f"{k} {v}" for k, v in sorted(counts.items())))
def connect(sample: bool):
if sample:
return SampleSAP()
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
key = os.environ.get("SAP_API_KEY")
if not key:
sys.exit("SAP_API_KEY is not set. Add it to .env, or run with --sample.")
return LiveSAP(key)
def main() -> None:
parser = argparse.ArgumentParser(description="Read-only SAP sales order tools for agents, with controls.")
parser.add_argument("command", choices=["tools", "demo", "call", "audit"])
parser.add_argument("rest", nargs="*", help="for call: the tool name, then key=value arguments")
parser.add_argument("--user", default="ana", help=f"who the agent acts for: {', '.join(USERS)}")
parser.add_argument("--sample", action="store_true", help="use made-up data; no key or internet")
a = parser.parse_args()
if a.command == "tools":
print(json.dumps(Session(SampleSAP(), a.user).offered_tools(), indent=2))
elif a.command == "audit":
audit_summary()
else:
api = connect(a.sample)
if a.command == "demo":
demo(api)
else:
if not a.rest:
sys.exit("Name a tool, for example: call sales_order_get sales_order=4711")
name, pairs = a.rest[0], a.rest[1:]
show(f"{name} as {a.user}", Session(api, a.user).call(name, parse_pairs(pairs, name)))
if __name__ == "__main__":
main()
What success looks like: a JSON list of three tool definitions, starting like this:
[
{
"name": "sales_order_list_blocked",
"description": "List the user's sales orders that have a delivery block, ...",
"parameters": {
"type": "object",
"properties": {
"customer": {
Now run it for the auditor:
python unit09/sap_tools.py tools --user kim
You see only two tools. release_request_draft is missing, because Kim's role may not request releases. The model in Kim's session would never learn the tool exists.
What success looks like (shortened; your session IDs differ):
Backend: made-up sample data
Acting for: Ana, order clerk, sales org 1010 (session 14432eb4)
Tools offered: sales_order_list_blocked, sales_order_get, release_request_draft
> 1. sales_order_list_blocked limit=5
{
"ok": true,
"result": {
"orders": [
{
"sales_order": "4718",
...
"blocked_found": 3,
"orders_scanned": 4
}
}
> 2. sales_order_get sales_order='SO-4718' (a typical model mistake)
{
"ok": false,
"error": "sales_order has the wrong format: you sent 'SO-4718'. Sales order number, digits only, e.g. '4711'. Remove prefixes like 'SO-'."
}
> 4. sales_order_get sales_order='4801' (an order outside Ana's sales org)
{
"ok": false,
"error": "Sales order 4801 does not exist or you may not see it. ..."
}
> 5. sales_order_list_blocked limit=500 (out of range)
{
"ok": false,
"error": "limit must be between 1 and 20; you sent 500."
}
> 6. release_request_draft sales_order='4718'
... "draft_id": "D-0001", "status": "awaiting_approval", "duplicate": false ...
> 7. release_request_draft sales_order='4718' again (a retry)
... "draft_id": "D-0001", "status": "awaiting_approval", "duplicate": true ...
Acting for: Kim, auditor, sales org 1010 (session 42368ad7)
Tools offered: sales_order_list_blocked, sales_order_get
> 8. release_request_draft sales_order='4718' (not offered to an auditor)
{
"ok": false,
"error": "Tool release_request_draft is not offered to this user."
}
Read it as the model would. Every error says what to do next. Order 4801 exists in the sample data, but Ana gets the same answer as for a missing order. The retry in call 7 returns the same draft instead of a second one.
8 tool calls in tool_audit.jsonl
time (UTC) user tool outcome SAP calls
2026-10-06T03:33:31 ana sales_order_list_blocked ok 1
2026-10-06T03:33:31 ana sales_order_get tool_error 0
2026-10-06T03:33:31 ana sales_order_get ok 2
2026-10-06T03:33:31 ana sales_order_get tool_error 1
2026-10-06T03:33:31 ana sales_order_list_blocked tool_error 0
2026-10-06T03:33:31 ana release_request_draft ok 1
2026-10-06T03:33:31 ana release_request_draft ok 1
2026-10-06T03:33:31 kim release_request_draft denied 0
By outcome: denied 1, ok 4, tool_error 3
Look at the SAP calls column. The bad order number and the out-of-range limit made zero calls to SAP: validation stopped them first. The out-of-scope order made one call, because the scope check needs SAP's answer. Open unit09/tool_audit.jsonl in VS Code to see the full lines, including the exact SAP paths each call used.
If you run the demo again, the log grows and the draft stays D-0001 with "duplicate": true. To start fresh, delete unit09/tool_audit.jsonl and unit09/release_drafts.json.
The first returns order 4711 with its item. The second shows Ben's view of sales organization 1710. The third fails with Unknown argument(s): color, which is what a model inventing a parameter would see.
What success looks like: the same eight steps, with real order numbers from SAP's sandbox and this line near the top:
Note: every sandbox call uses SAP's shared API key, not Ana's identity. Her scope is enforced only by this tool layer here; in production, SAP checks her own authorizations too.
The sandbox's data changes over time. If step 1 says "No blocked orders in the scanned orders. This is a valid answer, not an error.", nothing in sales organization 1010 is blocked right now; steps 6 and 7 are then skipped. Try --user ben with call sales_order_list_blocked to look at 1710.
As of October 2026, here is how the same controls map onto SAP's offerings. Several pieces were announced or released in mid-2026, so check current status before relying on them.
SAP's July 2026 Integration Suite update describes the MCP Gateway as turning existing APIs and services into tools agents can discover, with policies, authentication and usage controls. Its listed controls include rate limiting, IP blocking, authentication and authorization based on OIDC, payload protection, and logs and traces. Principal propagation and OAuth2/SAML bearer token exchange were listed on the roadmap, not as available. So as of that update, a tool exposed through the gateway did not yet reach S/4HANA as the individual user; plan the identity design accordingly. The protocol itself is covered later in Unit 9.
SAP's Agent Identity architecture uses SAP Cloud Identity Services for both people and agents. For agents working in a user's context, Joule is the engagement layer and the user's identity decides which agents and functions they may use. For autonomous agents, the agent authenticates with its own identity, and the gateway connects to the backend: the agent itself holds no SAP credentials. For your own BTP application today, principal propagation through BTP destinations is the established path, as in Grounding on SAP data with authorizations.
The Sales Order API carries SAP's own approval process. The fields SalesOrderApprovalReason and SalesDocApprovalStatus show whether an order needs approval and at which stage; releaseApprovalRequest and rejectApprovalRequest are the approver's actions. Where your process already uses SAP's approval workflow, a draft tool that routes to it is better than a parallel approval system. Joule Studio offers a human-in-the-loop tool type for Joule agents, as noted in Tool design for agents.
# SKETCH: runs only in the approval step, never as a tool the model can call.
def execute_approved(session, base, draft, approval):
assert approval["draft_id"] == draft["draft_id"] and approval["decision"] == "approve"
# 1. Read again: fetch a CSRF token and the record's current version (ETag).
r = session.get(f"{base}/A_SalesOrder('{draft['sales_order']}')",
headers={"X-CSRF-Token": "Fetch", "Accept": "application/json"}, timeout=30)
token, etag = r.headers["X-CSRF-Token"], r.headers.get("ETag")
if not etag:
raise RuntimeError("No ETag: can't prove the order is unchanged. Ask the approver to look again.")
# 2. Change with If-Match so it fails if the order changed since the approver looked.
# RequestID (a 32-character GUID) and RepeatabilityCreation let a service with idempotent
# settings answer a repeat without running it twice; check the timestamp format your system expects.
resp = session.post(f"{base}/releaseApprovalRequest", params={"SalesOrder": f"'{draft['sales_order']}'"},
headers={"X-CSRF-Token": token, "If-Match": etag,
"RequestID": hashlib.md5(draft["idempotency_key"].encode()).hexdigest(),
"RepeatabilityCreation": draft["created_at"]}, timeout=30)
audit(draft, approval, resp.status_code) # who asked, who approved, what SAP answered
return resp.status_code
Whether this service returns an ETag, accepts the idempotency headers and how it reports errors depends on the system release; test each in your own landscape.
For a CAP service on BTP, @cap-js/audit-logging with @PersonalData annotations logs reads of sensitive data and changes to personal data to the SAP Audit Log Service. Your tool gateway still needs its own log of tool calls, because the audit service records data access, not the agent's decisions.
A tool layer like the lab's, or your own MCP server
MCP Gateway in SAP Integration Suite
User's identity in SAP
Principal propagation through BTP destinations from your app
Agent Gateway and SAP Cloud Identity Services (forthcoming parts dated H2 2026)
Scope and validation
Your gateway code
Gateway policies, plus your tool code for business rules
Approval before changes
Draft tools plus your approval step
SAP's approval workflow for sales documents; Joule Studio's human-in-the-loop tool
Idempotency
Keys in your draft store; check before create
Idempotent service settings where the API supports them
Audit
Your JSONL or log platform
SAP Audit Log Service, CAP audit logging, MCP Gateway logs
API policy fit
Your own analysis; risky alone
Endorsed routes; confirm with SAP
A rule of thumb: learn and prototype with your own gateway on sample data and the sandbox. For a customer's production system, start from SAP's endorsed routes and add your own controls on top. Never the other way round.
Security and SAP authorizations. Prefer the user's own identity in SAP. If a technical user is unavoidable, give it read-only rights for the needed APIs and scope every query in code. Keep both the "filter before" and "check after" steps.
Least functionality. Offer each agent the fewest tools its users need. Remove tools nobody calls.
Change control. No tool the model can call sends POST, PATCH or DELETE to SAP. Changes run in an approval step, with an ETag check and an idempotency key.
Prompt injection. Results can carry text an attacker wrote, such as an order note. The lab returns no free text. If a job needs it, label it as data and keep it away from instructions. Unit 11 covers this.
Evaluation. Test the gateway like code: unknown arguments, out-of-scope orders, retries, budget limits. Then test tool selection with a model, as in Tool design for agents.
Audit and privacy. Log every call with user, tool, arguments, outcome and SAP requests. Arguments can contain personal data, such as a justification that names a person; set retention and access rules for the log.
Cost and load. Budgets per session protect SAP and your model bill. Watch the SAP request count per conversation; a rise means a tool returns too little or the model is looping.
API policy. Keep a register per tool: SAP API, read or change, whether a model chooses the call, and which endorsed route applies. Review it with your SAP account team.
Clean core. Published APIs only, called from BTP. No direct table reads, no custom RFC calls invented for the agent.
One all-powerful technical user. The fastest prototype and the biggest leak.
Scope from the model. If sales_org is a tool argument, the model can ask for any sales organization.
Different errors for "missing" and "forbidden". The agent becomes a way to probe what exists.
A change tool "just for the demo". Demos become pilots. Draft from day one.
Retrying creates without a key. Every retry is a possible duplicate.
Logging results instead of decisions. Logs full of customer data are a liability; logs without the user are useless.
Ignoring the API policy until go-live. By then the architecture is fixed.
Treating codes as universal. The lab shows block reasons as raw codes and treats credit check status B as a block, as earlier labs did. Check the value lists in your own system.
#Exercise: add a partner tool and prove the controls
You will add a fourth read tool, sales_order_get_partners, that lists an order's partners from the to_Partner navigation of the Sales Order API. Then you will prove that the gateway's controls apply to it without any extra code. The tool set feeds the next Unit 9 topic, where you offer these tools through MCP.
Open unit09/sap_tools.py.
Find the line that starts with SAMPLE_CHILDREN = {"to_Item": SAMPLE_ITEMS}. Directly below it, add made-up partners for order 4711. The function codes are made up too; real codes are configured per system:
Find the line @tool("release_request_draft", "draft",. Directly above it, add the new tool, with no indentation:
@tool("sales_order_get_partners", "read",
"List the partners of one sales order: each partner function code with its customer number, "
"for example who orders and who receives the goods. Use it when the user asks where an order "
"ships or who the partners are. Read-only. Order numbers are digits only.",
{"sales_order": {"type": "string", "pattern": r"^\d{1,10}$",
"description": "Sales order number, digits only, e.g. '4711'."}},
required=("sales_order",))
def sales_order_get_partners(ctx, sales_order):
read_visible_order(ctx, sales_order) # scope check first: same answer for missing and not yours
rows = ctx.sap_get(f"/A_SalesOrder('{sales_order}')/to_Partner",
{"$select": "PartnerFunction,Customer", "$top": "20"}).get("results", [])
return {"sales_order": sales_order,
"partners": [{"function": r.get("PartnerFunction"), "customer": r.get("Customer")} for r in rows]}
Save the file and check that the auditor now gets three tools:
python unit09/sap_tools.py tools --user kim
Call the tool for an order in Kim's scope, then one outside it:
python unit09/sap_tools.py call sales_order_get_partners sales_order=4711 --user kim --sample
python unit09/sap_tools.py call sales_order_get_partners sales_order=4801 --user kim --sample
python unit09/sap_tools.py call sales_order_get_partners sales_order=SO-4711 --user kim --sample
Run python unit09/sap_tools.py audit and find your three calls.
In a new file unit09/tool_register.md, write one line per tool (four lines): name, read or draft, SAP API and entity it calls, roles that get it, and whether a model chooses when it runs.
Commit sap_tools.py and tool_register.md.
Done when:tools --user kim lists three tools including sales_order_get_partners; order 4711 returns two partners; order 4801 returns "does not exist or you may not see it"; SO-4711 returns a format error with zero SAP calls in the audit log; and tool_register.md has four lines.
Pick one answer for each question. The explanation appears after you choose.
1Why does the lab put the user's sales organizations into the session instead of making them a tool argument?
Answer: B. If scope were an argument, the model could ask for any sales organization, by mistake or because of injected text. The lab takes scope from the signed-in user and adds it to every query in code.
2Ana asks about order 4801, which exists but belongs to sales organization 1710. What does the tool answer, and why?
Answer: C. Giving the same answer for missing and forbidden orders stops the agent from revealing which order numbers exist elsewhere. The message still tells the model what to do next.
3In the lab's audit log, why does the call with SO-4718 show zero SAP requests?
Answer: A. Session.call validates arguments against the schema before running the tool. A bad format never reaches SAP, which protects SAP and keeps malformed values out of URLs.
4The agent calls release_request_draft for order 4718 twice because of a retry. What happens?
Answer: D. The draft's idempotency key is built from the user, the action and the order, so a repeat finds the existing draft. Nothing in SAP changes either way; the tool only writes to a local draft store.
5After a manager approves a release, someone edits the order before the change step runs. What should catch it?
Answer: B. S/4HANA OData change operations can carry the record's version in If-Match. If the document changed since that read, the call fails, and the approver can look again. A CSRF token protects the session, not the record's version.
6As of the July 2026 update, why can't you assume a tool exposed through SAP's MCP Gateway reaches S/4HANA as the individual user?
Answer: D. SAP's July 2026 Integration Suite update lists principal propagation and bearer token exchange for the MCP Gateway as roadmap items. Until they ship, plan how scope is enforced and check the current status.
7Your team wants to let a custom agent on BTP call the Sales Order API directly, choosing its own calls. What do you do first?
Answer: C. SAP's API policy restricts AI systems that plan, select or execute sequences of API calls, except through endorsed routes, and forbids getting around that with proxies or gateways. Confirm the route, such as the MCP Gateway or Joule agents, with your SAP account team.
8Which belongs in the lab's audit line for each tool call?
Answer: C. The log records who asked, what was called, what happened and which SAP requests were made, without storing the data SAP returned. Full responses can hold personal data, and logging only failures leaves no trail for the calls that worked.
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
SAP API Policy v.4.2026a (help.sap.com)— 1.1 published APIs are those on the SAP Business Accelerator Hub or identified in product documentation; 1.2 non-published APIs must not be accessed; 2.1 rate limits, quotas and other controls documented per API; 2.2.2 except through SAP-endorsed architectures, data services or service-specific pathways, API use for (semi-)autonomous or generative AI systems that plan, select, or execute sequences of API calls is prohibited; 3 monitoring, throttling, suspension; no circumvention through proxies, gateways or custom code
Agentic AI & AI Agents (SAP Architecture Center, updated 27 August 2026)— Agent Gateway for discovery and execution of agentic resources with authentication, principal propagation and policy enforcement; intersection of user and agent permissions; MCP Gateway in SAP Integration Suite; API usage subject to the SAP API Policy
Agent Identity (SAP Architecture Center, updated 12 May 2026)— two models, agents acting in a user's context and autonomous agents with their own technical identity; SAP Cloud Identity Services; third-party agents get no backend credentials, the gateway handles the connection; policy enforcement point between agent and API, tools or MCP planned for H2 2026
SAP Integration Suite monthly update, July 2026 (SAP user group webinar slides)— MCP Gateway turns APIs and services into tools for agents; rate limiting, IP blocking, OIDC-based authentication and authorization, payload protection; logs and traces; principal propagation and OAuth2/SAML bearer token exchange listed on the roadmap
@sap/cloud-sdk-vdm-sales-order-service 2.1.0 (npm, SAP Cloud SDK)— generated model of API_SALES_ORDER_SRV; fields SalesOrganization, LastChangeDateTime, PurchaseOrderByCustomer, SalesOrderApprovalReason, SalesDocApprovalStatus, TotalCreditCheckStatus; function imports releaseApprovalRequest and rejectApprovalRequest, sent as POST with a SalesOrder parameter; to_Partner fields PartnerFunction and Customer
Audit Logging (CAP documentation, capire)— @cap-js/audit-logging plugin; events SensitiveDataRead, PersonalDataModified, ConfigurationModified, SecurityEvent; @PersonalData annotations; SAP Audit Log Service on BTP; transactional outbox
LLM06:2025 Excessive Agency (OWASP Gen AI Security Project)— excessive functionality, permissions and autonomy; minimize extensions and permissions; execute in the user's context; human approval for high-impact actions; complete mediation in downstream systems; input sanitization; monitoring and rate limiting